Merge branch '21128-toolbar-context-menu' main
authorLisa Knox <lisaknox83@gmail.com>
Thu, 21 Dec 2023 14:06:01 +0000 (09:06 -0500)
committerLisa Knox <lisaknox83@gmail.com>
Thu, 21 Dec 2023 14:06:01 +0000 (09:06 -0500)
closes #21128

Arvados-DCO-1.1-Signed-off-by: Lisa Knox <lisa.knox@curii.com>

281 files changed:
.yarn/releases/yarn-3.2.0.cjs
Makefile
cypress/fixtures/workflow_directory_array.yaml [new file with mode: 0644]
cypress/integration/banner-tooltip.spec.js
cypress/integration/collection.spec.js
cypress/integration/create-workflow.spec.js
cypress/integration/group-manage.spec.js
cypress/integration/multiselect-toolbar.spec.js [new file with mode: 0644]
cypress/integration/page-not-found.spec.js
cypress/integration/process.spec.js
cypress/integration/project.spec.js
cypress/integration/search.spec.js
cypress/integration/sharing.spec.js
cypress/integration/side-panel.spec.js
cypress/integration/user-profile.spec.js
cypress/integration/virtual-machine-admin.spec.js
cypress/integration/workflow.spec.js [new file with mode: 0644]
cypress/support/commands.js
package.json
public/arrow-to-left.png [deleted file]
public/arrow-to-right.png [deleted file]
public/collapseLHS-New.svg [deleted file]
public/mui-start-icon.svg [new file with mode: 0644]
src/common/config.ts
src/common/html-sanitize.ts [new file with mode: 0644]
src/common/redirect-to.test.ts
src/common/redirect-to.ts
src/common/use-async-interval.test.tsx [new file with mode: 0644]
src/common/use-async-interval.ts [new file with mode: 0644]
src/components/autocomplete/autocomplete.tsx
src/components/collection-panel-files/collection-panel-files.tsx
src/components/confirmation-dialog/confirmation-dialog.tsx
src/components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar.tsx
src/components/data-explorer/data-explorer.test.tsx
src/components/data-explorer/data-explorer.tsx
src/components/data-table-filters/data-table-filters-popover.tsx
src/components/data-table-filters/data-table-filters-tree.tsx
src/components/data-table-multiselect-popover/data-table-multiselect-popover.tsx [new file with mode: 0644]
src/components/data-table/data-table.test.tsx
src/components/data-table/data-table.tsx
src/components/dropdown-menu/dropdown-menu.tsx
src/components/icon/icon.tsx
src/components/multiselect-toolbar/MultiselectToolbar.tsx [new file with mode: 0644]
src/components/multiselect-toolbar/ms-kind-action-differentiator.ts [new file with mode: 0644]
src/components/multiselect-toolbar/ms-toolbar-action-filters.ts [new file with mode: 0644]
src/components/select-field/select-field.tsx
src/components/subprocess-progress-bar/subprocess-progress-bar.test.tsx [new file with mode: 0644]
src/components/subprocess-progress-bar/subprocess-progress-bar.tsx [new file with mode: 0644]
src/components/tree/tree.tsx
src/index.tsx
src/models/collection-file.ts
src/models/container-request.ts
src/models/group.ts
src/models/log.ts
src/models/project.ts
src/models/test-utils.ts
src/models/tree.test.ts
src/models/tree.ts
src/models/user.ts
src/models/workflow.ts
src/routes/route-change-handlers.ts
src/routes/routes.ts
src/services/ancestors-service/ancestors-service.ts
src/services/auth-service/auth-service.ts
src/services/collection-service/collection-service-files-response.ts
src/services/collection-service/collection-service.test.ts
src/services/collection-service/collection-service.ts
src/services/common-service/common-resource-service.ts
src/services/common-service/common-service.ts
src/services/common-service/trashable-resource-service.ts
src/services/groups-service/groups-service.ts
src/services/log-service/log-service.test.ts [new file with mode: 0644]
src/services/log-service/log-service.ts
src/services/services.ts
src/services/user-service/user-service.ts
src/store/advanced-tab/advanced-tab.tsx
src/store/all-processes-panel/all-processes-panel-middleware-service.ts
src/store/auth/auth-action.test.ts
src/store/auth/auth-middleware.ts
src/store/breadcrumbs/breadcrumbs-actions.ts
src/store/collection-panel/collection-panel-action.ts
src/store/collection-panel/collection-panel-files/collection-panel-files-state.ts
src/store/collections-content-address-panel/collections-content-address-middleware-service.ts
src/store/collections/collection-copy-actions.ts
src/store/collections/collection-info-actions.ts
src/store/collections/collection-move-actions.ts
src/store/collections/collection-partial-copy-actions.ts
src/store/collections/collection-partial-move-actions.ts [new file with mode: 0644]
src/store/context-menu/context-menu-actions.test.ts
src/store/context-menu/context-menu-actions.ts
src/store/copy-dialog/copy-dialog.ts
src/store/data-explorer/data-explorer-action.ts
src/store/data-explorer/data-explorer-middleware-service.ts
src/store/data-explorer/data-explorer-middleware.ts
src/store/data-explorer/data-explorer-reducer.ts
src/store/dialog/dialog-reducer.ts
src/store/dialog/with-dialog.ts
src/store/favorites/favorites-actions.ts
src/store/group-details-panel/group-details-panel-members-middleware-service.ts
src/store/link-panel/link-panel-middleware-service.ts
src/store/move-to-dialog/move-to-dialog.ts
src/store/multiselect/multiselect-actions.tsx [new file with mode: 0644]
src/store/multiselect/multiselect-reducer.tsx [new file with mode: 0644]
src/store/navigation/navigation-action.ts
src/store/open-in-new-tab/open-in-new-tab.actions.ts
src/store/process-logs-panel/process-logs-panel-actions.ts
src/store/process-logs-panel/process-logs-panel-reducer.ts
src/store/process-logs-panel/process-logs-panel.ts
src/store/process-panel/process-panel-actions.ts
src/store/process-panel/process-panel-reducer.ts
src/store/processes/process-copy-actions.ts
src/store/processes/process-move-actions.ts
src/store/processes/process-update-actions.ts
src/store/processes/process.ts
src/store/processes/processes-actions.ts
src/store/project-panel/project-panel-action-bind.ts [new file with mode: 0644]
src/store/project-panel/project-panel-action.ts
src/store/project-panel/project-panel-middleware-service.ts
src/store/projects/project-lock-actions.ts
src/store/projects/project-move-actions.ts
src/store/projects/project-update-actions.ts
src/store/public-favorites/public-favorites-actions.ts
src/store/resource-type-filters/resource-type-filters.test.ts
src/store/resource-type-filters/resource-type-filters.ts
src/store/resources/resources-actions.ts
src/store/resources/resources-reducer.ts
src/store/resources/resources.test.ts
src/store/resources/resources.ts
src/store/run-process-panel/run-process-panel-actions.test.ts
src/store/run-process-panel/run-process-panel-actions.ts
src/store/search-results-panel/search-results-middleware-service.ts
src/store/shared-with-me-panel/shared-with-me-middleware-service.ts
src/store/sharing-dialog/sharing-dialog-actions.ts
src/store/sharing-dialog/sharing-dialog-types.ts
src/store/side-panel-tree/side-panel-tree-actions.ts
src/store/store.ts
src/store/subprocess-panel/subprocess-panel-actions.ts
src/store/subprocess-panel/subprocess-panel-middleware-service.ts
src/store/trash-panel/trash-panel-middleware-service.ts
src/store/trash/trash-actions.ts
src/store/tree-picker/picker-id.tsx
src/store/tree-picker/tree-picker-actions.test.ts [new file with mode: 0644]
src/store/tree-picker/tree-picker-actions.ts
src/store/tree-picker/tree-picker-middleware.ts
src/store/tree-picker/tree-picker-reducer.test.ts
src/store/tree-picker/tree-picker-reducer.ts
src/store/user-profile/user-profile-actions.ts
src/store/users/user-panel-middleware-service.ts
src/store/virtual-machines/virtual-machines-actions.ts
src/store/workbench/workbench-actions.ts
src/store/workflow-panel/workflow-panel-actions.ts
src/views-components/advanced-tab-dialog/advanced-tab-dialog.tsx
src/views-components/baner/banner.tsx
src/views-components/context-menu/action-sets/api-client-authorization-action-set.ts
src/views-components/context-menu/action-sets/collection-action-set.ts
src/views-components/context-menu/action-sets/collection-files-action-set.ts
src/views-components/context-menu/action-sets/collection-files-item-action-set.ts
src/views-components/context-menu/action-sets/collection-files-not-selected-action-set.ts
src/views-components/context-menu/action-sets/favorite-action-set.ts
src/views-components/context-menu/action-sets/group-action-set.ts
src/views-components/context-menu/action-sets/group-member-action-set.ts
src/views-components/context-menu/action-sets/keep-service-action-set.ts
src/views-components/context-menu/action-sets/link-action-set.ts
src/views-components/context-menu/action-sets/permission-edit-action-set.ts
src/views-components/context-menu/action-sets/process-resource-action-set.ts
src/views-components/context-menu/action-sets/project-action-set.ts
src/views-components/context-menu/action-sets/project-admin-action-set.ts
src/views-components/context-menu/action-sets/repository-action-set.ts
src/views-components/context-menu/action-sets/resource-action-set.ts
src/views-components/context-menu/action-sets/root-project-action-set.ts
src/views-components/context-menu/action-sets/search-results-action-set.ts
src/views-components/context-menu/action-sets/ssh-key-action-set.ts
src/views-components/context-menu/action-sets/trash-action-set.ts
src/views-components/context-menu/action-sets/trashed-collection-action-set.ts
src/views-components/context-menu/action-sets/user-action-set.ts
src/views-components/context-menu/action-sets/virtual-machine-action-set.ts
src/views-components/context-menu/action-sets/workflow-action-set.ts
src/views-components/context-menu/context-menu-action-set.ts
src/views-components/context-menu/context-menu.tsx
src/views-components/data-explorer/data-explorer.tsx
src/views-components/data-explorer/renderers.tsx
src/views-components/details-panel/details-panel.tsx
src/views-components/details-panel/workflow-details.tsx
src/views-components/dialog-copy/dialog-collection-partial-copy-to-existing-collection.tsx [moved from src/views-components/dialog-copy/dialog-partial-copy-to-collection.tsx with 63% similarity]
src/views-components/dialog-copy/dialog-collection-partial-copy-to-new-collection.tsx [moved from src/views-components/dialog-copy/dialog-collection-partial-copy.tsx with 68% similarity]
src/views-components/dialog-copy/dialog-collection-partial-copy-to-separate-collections.tsx [new file with mode: 0644]
src/views-components/dialog-copy/dialog-copy.tsx
src/views-components/dialog-copy/dialog-process-rerun.tsx
src/views-components/dialog-forms/copy-collection-dialog.ts
src/views-components/dialog-forms/copy-process-dialog.ts
src/views-components/dialog-forms/move-project-dialog.ts
src/views-components/dialog-forms/partial-copy-collection-dialog.ts [deleted file]
src/views-components/dialog-forms/partial-copy-to-collection-dialog.ts [deleted file]
src/views-components/dialog-forms/partial-copy-to-existing-collection-dialog.ts [new file with mode: 0644]
src/views-components/dialog-forms/partial-copy-to-new-collection-dialog.ts [new file with mode: 0644]
src/views-components/dialog-forms/partial-copy-to-separate-collections-dialog.ts [new file with mode: 0644]
src/views-components/dialog-forms/partial-move-to-existing-collection-dialog.ts [new file with mode: 0644]
src/views-components/dialog-forms/partial-move-to-new-collection-dialog.ts [new file with mode: 0644]
src/views-components/dialog-forms/partial-move-to-separate-collections-dialog.ts [new file with mode: 0644]
src/views-components/dialog-move/dialog-collection-partial-move-to-existing-collection.tsx [new file with mode: 0644]
src/views-components/dialog-move/dialog-collection-partial-move-to-new-collection.tsx [new file with mode: 0644]
src/views-components/dialog-move/dialog-collection-partial-move-to-separate-collections.tsx [new file with mode: 0644]
src/views-components/form-fields/collection-form-fields.tsx
src/views-components/login-form/login-form.tsx
src/views-components/main-app-bar/account-menu.tsx
src/views-components/main-app-bar/main-app-bar.tsx
src/views-components/main-app-bar/notifications-menu.tsx
src/views-components/multiselect-toolbar/ms-collection-action-set.ts [new file with mode: 0644]
src/views-components/multiselect-toolbar/ms-menu-actions.ts [new file with mode: 0644]
src/views-components/multiselect-toolbar/ms-process-action-set.ts [new file with mode: 0644]
src/views-components/multiselect-toolbar/ms-project-action-set.ts [new file with mode: 0644]
src/views-components/multiselect-toolbar/ms-workflow-action-set.ts [new file with mode: 0644]
src/views-components/projects-tree-picker/favorites-tree-picker.tsx
src/views-components/projects-tree-picker/generic-projects-tree-picker.tsx
src/views-components/projects-tree-picker/home-tree-picker.tsx
src/views-components/projects-tree-picker/projects-tree-picker.tsx
src/views-components/projects-tree-picker/public-favorites-tree-picker.tsx
src/views-components/projects-tree-picker/search-projects-picker.tsx
src/views-components/projects-tree-picker/shared-tree-picker.tsx
src/views-components/projects-tree-picker/tree-picker-field.tsx
src/views-components/search-bar/search-bar-view.tsx
src/views-components/sharing-dialog/participant-select.tsx
src/views-components/sharing-dialog/sharing-dialog-component.test.tsx
src/views-components/sharing-dialog/sharing-dialog-component.tsx
src/views-components/sharing-dialog/sharing-dialog.tsx
src/views-components/sharing-dialog/sharing-invitation-form-component.tsx
src/views-components/sharing-dialog/sharing-invitation-form.tsx
src/views-components/sharing-dialog/sharing-management-form-component.tsx
src/views-components/sharing-dialog/sharing-management-form.tsx
src/views-components/sharing-dialog/sharing-public-access-form-component.tsx
src/views-components/sharing-dialog/sharing-public-access-form.tsx
src/views-components/sharing-dialog/sharing-urls-component.tsx
src/views-components/sharing-dialog/visibility-level-select.tsx
src/views-components/side-panel-button/side-panel-button.tsx
src/views-components/side-panel-toggle/side-panel-toggle.tsx
src/views-components/side-panel-tree/side-panel-tree.tsx
src/views-components/side-panel/side-panel-collapsed.tsx [new file with mode: 0644]
src/views-components/side-panel/side-panel.tsx
src/views-components/virtual-machines-dialog/group-array-input.tsx
src/views/all-processes-panel/all-processes-panel.tsx
src/views/collection-panel/collection-panel.tsx
src/views/favorite-panel/favorite-panel.tsx
src/views/group-details-panel/group-details-panel.tsx
src/views/inactive-panel/inactive-panel.tsx
src/views/keep-service-panel/keep-service-panel-root.tsx
src/views/login-panel/login-panel.tsx
src/views/not-found-panel/not-found-panel.tsx
src/views/process-panel/process-details-attributes.tsx
src/views/process-panel/process-details-card.tsx
src/views/process-panel/process-io-card.tsx
src/views/process-panel/process-log-card.tsx
src/views/process-panel/process-log-code-snippet.tsx
src/views/process-panel/process-panel-root.tsx
src/views/process-panel/process-panel.tsx
src/views/process-panel/process-resource-card.tsx
src/views/project-panel/project-panel.tsx
src/views/public-favorites-panel/public-favorites-panel.tsx
src/views/repositories-panel/repositories-panel.tsx
src/views/run-process-panel/inputs/directory-array-input.tsx
src/views/run-process-panel/inputs/directory-input.tsx
src/views/run-process-panel/inputs/enum-input.tsx
src/views/run-process-panel/inputs/file-array-input.tsx
src/views/run-process-panel/inputs/file-input.tsx
src/views/run-process-panel/inputs/project-input.tsx
src/views/run-process-panel/run-process-inputs-form.tsx
src/views/search-results-panel/search-results-panel.tsx
src/views/shared-with-me-panel/shared-with-me-panel.tsx
src/views/ssh-key-panel/ssh-key-panel-root.tsx
src/views/subprocess-panel/subprocess-panel-root.tsx
src/views/subprocess-panel/subprocess-panel.tsx
src/views/trash-panel/trash-panel.tsx
src/views/user-profile-panel/user-profile-panel-root.tsx
src/views/user-profile-panel/user-profile-panel.tsx
src/views/virtual-machine-panel/virtual-machine-admin-panel.tsx
src/views/virtual-machine-panel/virtual-machine-user-panel.tsx
src/views/workbench/workbench.tsx
src/views/workflow-panel/registered-workflow-panel.tsx [new file with mode: 0644]
src/websocket/websocket.ts
tools/run-integration-tests.sh
tsconfig.json
yarn.lock

index 59267757f98a302a96d4ff8f5ccd3e48916a930a..b30d0655d004fa0f15cda007116aa609eb7353a1 100755 (executable)
@@ -470,7 +470,7 @@ Try running the command again with the package name prefixed: ${ae.pretty(e,"yar
       This command will unset a configuration setting.
     `,examples:[["Unset a simple configuration setting","yarn config unset initScope"],["Unset a complex configuration setting","yarn config unset packageExtensions"],["Unset a nested configuration setting","yarn config unset npmScopes.company.npmRegistryServer"]]});var uae=Am;var KN=ge(require("util")),lm=class extends Le{constructor(){super(...arguments);this.verbose=z.Boolean("-v,--verbose",!1,{description:"Print the setting description on top of the regular key/value information"});this.why=z.Boolean("--why",!1,{description:"Print the reason why a setting is set a particular way"});this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"})}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins,{strict:!1});return(await Je.start({configuration:e,json:this.json,stdout:this.context.stdout},async i=>{if(e.invalid.size>0&&!this.json){for(let[n,s]of e.invalid)i.reportError($.INVALID_CONFIGURATION_KEY,`Invalid configuration key "${n}" in ${s}`);i.reportSeparator()}if(this.json){let n=Se.sortMap(e.settings.keys(),s=>s);for(let s of n){let o=e.settings.get(s),a=e.getSpecial(s,{hideSecrets:!0,getNativePaths:!0}),l=e.sources.get(s);this.verbose?i.reportJson({key:s,effective:a,source:l}):i.reportJson(N({key:s,effective:a,source:l},o))}}else{let n=Se.sortMap(e.settings.keys(),a=>a),s=n.reduce((a,l)=>Math.max(a,l.length),0),o={breakLength:Infinity,colors:e.get("enableColors"),maxArrayLength:2};if(this.why||this.verbose){let a=n.map(c=>{let u=e.settings.get(c);if(!u)throw new Error(`Assertion failed: This settings ("${c}") should have been registered`);let g=this.why?e.sources.get(c)||"<default>":u.description;return[c,g]}),l=a.reduce((c,[,u])=>Math.max(c,u.length),0);for(let[c,u]of a)i.reportInfo(null,`${c.padEnd(s," ")}   ${u.padEnd(l," ")}   ${(0,KN.inspect)(e.getSpecial(c,{hideSecrets:!0,getNativePaths:!0}),o)}`)}else for(let a of n)i.reportInfo(null,`${a.padEnd(s," ")}   ${(0,KN.inspect)(e.getSpecial(a,{hideSecrets:!0,getNativePaths:!0}),o)}`)}})).exitCode()}};lm.paths=[["config"]],lm.usage=Re.Usage({description:"display the current configuration",details:`
       This command prints the current active configuration settings.
-    `,examples:[["Print the active configuration settings","$0 config"]]});var gae=lm;Es();var HN={};ft(HN,{Strategy:()=>Iu,acceptedStrategies:()=>R8e,dedupe:()=>jN});var fae=ge(ts()),Iu;(function(e){e.HIGHEST="highest"})(Iu||(Iu={}));var R8e=new Set(Object.values(Iu)),F8e={highest:async(t,e,{resolver:r,fetcher:i,resolveOptions:n,fetchOptions:s})=>{let o=new Map;for(let[a,l]of t.storedResolutions){let c=t.storedDescriptors.get(a);if(typeof c=="undefined")throw new Error(`Assertion failed: The descriptor (${a}) should have been registered`);Se.getSetWithDefault(o,c.identHash).add(l)}return Array.from(t.storedDescriptors.values(),async a=>{if(e.length&&!fae.default.isMatch(P.stringifyIdent(a),e))return null;let l=t.storedResolutions.get(a.descriptorHash);if(typeof l=="undefined")throw new Error(`Assertion failed: The resolution (${a.descriptorHash}) should have been registered`);let c=t.originalPackages.get(l);if(typeof c=="undefined"||!r.shouldPersistResolution(c,n))return null;let u=o.get(a.identHash);if(typeof u=="undefined")throw new Error(`Assertion failed: The resolutions (${a.identHash}) should have been registered`);if(u.size===1)return null;let g=[...u].map(y=>{let Q=t.originalPackages.get(y);if(typeof Q=="undefined")throw new Error(`Assertion failed: The package (${y}) should have been registered`);return Q.reference}),f=await r.getSatisfying(a,g,n),h=f==null?void 0:f[0];if(typeof h=="undefined")return null;let p=h.locatorHash,m=t.originalPackages.get(p);if(typeof m=="undefined")throw new Error(`Assertion failed: The package (${p}) should have been registered`);return p===l?null:{descriptor:a,currentPackage:c,updatedPackage:m}})}};async function jN(t,{strategy:e,patterns:r,cache:i,report:n}){let{configuration:s}=t,o=new pi,a=s.makeResolver(),l=s.makeFetcher(),c={cache:i,checksums:t.storedChecksums,fetcher:l,project:t,report:o,skipIntegrityCheck:!0,cacheOptions:{skipIntegrityCheck:!0}},u={project:t,resolver:a,report:o,fetchOptions:c};return await n.startTimerPromise("Deduplication step",async()=>{let f=await F8e[e](t,r,{resolver:a,resolveOptions:u,fetcher:l,fetchOptions:c}),h=Ji.progressViaCounter(f.length);n.reportProgress(h);let p=0;await Promise.all(f.map(Q=>Q.then(S=>{if(S===null)return;p++;let{descriptor:x,currentPackage:M,updatedPackage:Y}=S;n.reportInfo($.UNNAMED,`${P.prettyDescriptor(s,x)} can be deduped from ${P.prettyLocator(s,M)} to ${P.prettyLocator(s,Y)}`),n.reportJson({descriptor:P.stringifyDescriptor(x),currentResolution:P.stringifyLocator(M),updatedResolution:P.stringifyLocator(Y)}),t.storedResolutions.set(x.descriptorHash,Y.locatorHash)}).finally(()=>h.tick())));let m;switch(p){case 0:m="No packages";break;case 1:m="One package";break;default:m=`${p} packages`}let y=ae.pretty(s,e,ae.Type.CODE);return n.reportInfo($.UNNAMED,`${m} can be deduped using the ${y} strategy`),p})}var cm=class extends Le{constructor(){super(...arguments);this.strategy=z.String("-s,--strategy",Iu.HIGHEST,{description:"The strategy to use when deduping dependencies",validator:nn(Iu)});this.check=z.Boolean("-c,--check",!1,{description:"Exit with exit code 1 when duplicates are found, without persisting the dependency tree"});this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.mode=z.String("--mode",{description:"Change what artifacts installs generate",validator:nn(di)});this.patterns=z.Rest()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r}=await ze.find(e,this.context.cwd),i=await Nt.find(e);await r.restoreInstallState({restoreResolutions:!1});let n=0,s=await Je.start({configuration:e,includeFooter:!1,stdout:this.context.stdout,json:this.json},async o=>{n=await jN(r,{strategy:this.strategy,patterns:this.patterns,cache:i,report:o})});return s.hasErrors()?s.exitCode():this.check?n?1:0:(await Je.start({configuration:e,stdout:this.context.stdout,json:this.json},async a=>{await r.install({cache:i,report:a,mode:this.mode})})).exitCode()}};cm.paths=[["dedupe"]],cm.usage=Re.Usage({description:"deduplicate dependencies with overlapping ranges",details:"\n      Duplicates are defined as descriptors with overlapping ranges being resolved and locked to different locators. They are a natural consequence of Yarn's deterministic installs, but they can sometimes pile up and unnecessarily increase the size of your project.\n\n      This command dedupes dependencies in the current project using different strategies (only one is implemented at the moment):\n\n      - `highest`: Reuses (where possible) the locators with the highest versions. This means that dependencies can only be upgraded, never downgraded. It's also guaranteed that it never takes more than a single pass to dedupe the entire dependency tree.\n\n      **Note:** Even though it never produces a wrong dependency tree, this command should be used with caution, as it modifies the dependency tree, which can sometimes cause problems when packages don't strictly follow semver recommendations. Because of this, it is recommended to also review the changes manually.\n\n      If set, the `-c,--check` flag will only report the found duplicates, without persisting the modified dependency tree. If changes are found, the command will exit with a non-zero exit code, making it suitable for CI purposes.\n\n      If the `--mode=<mode>` option is set, Yarn will change which artifacts are generated. The modes currently supported are:\n\n      - `skip-build` will not run the build scripts at all. Note that this is different from setting `enableScripts` to false because the later will disable build scripts, and thus affect the content of the artifacts generated on disk, whereas the former will just disable the build step - but not the scripts themselves, which just won't run.\n\n      - `update-lockfile` will skip the link step altogether, and only fetch packages that are missing from the lockfile (or that have no associated checksums). This mode is typically used by tools like Renovate or Dependabot to keep a lockfile up-to-date without incurring the full install cost.\n\n      This command accepts glob patterns as arguments (if valid Idents and supported by [micromatch](https://github.com/micromatch/micromatch)). Make sure to escape the patterns, to prevent your own shell from trying to expand them.\n\n      ### In-depth explanation:\n\n      Yarn doesn't deduplicate dependencies by default, otherwise installs wouldn't be deterministic and the lockfile would be useless. What it actually does is that it tries to not duplicate dependencies in the first place.\n\n      **Example:** If `foo@^2.3.4` (a dependency of a dependency) has already been resolved to `foo@2.3.4`, running `yarn add foo@*`will cause Yarn to reuse `foo@2.3.4`, even if the latest `foo` is actually `foo@2.10.14`, thus preventing unnecessary duplication.\n\n      Duplication happens when Yarn can't unlock dependencies that have already been locked inside the lockfile.\n\n      **Example:** If `foo@^2.3.4` (a dependency of a dependency) has already been resolved to `foo@2.3.4`, running `yarn add foo@2.10.14` will cause Yarn to install `foo@2.10.14` because the existing resolution doesn't satisfy the range `2.10.14`. This behavior can lead to (sometimes) unwanted duplication, since now the lockfile contains 2 separate resolutions for the 2 `foo` descriptors, even though they have overlapping ranges, which means that the lockfile can be simplified so that both descriptors resolve to `foo@2.10.14`.\n    ",examples:[["Dedupe all packages","$0 dedupe"],["Dedupe all packages using a specific strategy","$0 dedupe --strategy highest"],["Dedupe a specific package","$0 dedupe lodash"],["Dedupe all packages with the `@babel/*` scope","$0 dedupe '@babel/*'"],["Check for duplicates (can be used as a CI step)","$0 dedupe --check"]]});var hae=cm;var ib=class extends Le{async execute(){let{plugins:e}=await ye.find(this.context.cwd,this.context.plugins),r=[];for(let o of e){let{commands:a}=o[1];if(a){let c=Is.from(a).definitions();r.push([o[0],c])}}let i=this.cli.definitions(),n=(o,a)=>o.split(" ").slice(1).join()===a.split(" ").slice(1).join(),s=dae()["@yarnpkg/builder"].bundles.standard;for(let o of r){let a=o[1];for(let l of a)i.find(c=>n(c.path,l.path)).plugin={name:o[0],isDefault:s.includes(o[0])}}this.context.stdout.write(`${JSON.stringify(i,null,2)}
+    `,examples:[["Print the active configuration settings","$0 config"]]});var gae=lm;Es();var HN={};ft(HN,{Strategy:()=>Iu,acceptedStrategies:()=>R8e,dedupe:()=>jN});var fae=ge(ts()),Iu;(function(e){e.HIGHEST="highest"})(Iu||(Iu={}));var R8e=new Set(Object.values(Iu)),F8e={highest:async(t,e,{resolver:r,fetcher:i,resolveOptions:n,fetchOptions:s})=>{let o=new Map;for(let[a,l]of t.storedResolutions){let c=t.storedDescriptors.get(a);if(typeof c=="undefined")throw new Error(`Assertion failed: The descriptor (${a}) should have been registered`);Se.getSetWithDefault(o,c.identHash).add(l)}return Array.from(t.storedDescriptors.values(),async a=>{if(e.length&&!fae.default.isMatch(P.stringifyIdent(a),e))return null;let l=t.storedResolutions.get(a.descriptorHash);if(typeof l=="undefined")throw new Error(`Assertion failed: The resolution (${a.descriptorHash}) should have been registered`);let c=t.originalPackages.get(l);if(typeof c=="undefined"||!r.shouldPersistResolution(c,n))return null;let u=o.get(a.identHash);if(typeof u=="undefined")throw new Error(`Assertion failed: The resolutions (${a.identHash}) should have been registered`);if(u.size===1)return null;let g=[...u].map(y=>{let Q=t.originalPackages.get(y);if(typeof Q=="undefined")throw new Error(`Assertion failed: The package (${y}) should have been registered`);return Q.reference}),f=await r.getSatisfying(a,g,n),h=f==null?void 0:f[0];if(typeof h=="undefined")return null;let p=h.locatorHash,m=t.originalPackages.get(p);if(typeof m=="undefined")throw new Error(`Assertion failed: The package (${p}) should have been registered`);return p===l?null:{descriptor:a,currentPackage:c,updatedPackage:m}})}};async function jN(t,{strategy:e,patterns:r,cache:i,report:n}){let{configuration:s}=t,o=new pi,a=s.makeResolver(),l=s.makeFetcher(),c={cache:i,checksums:t.storedChecksums,fetcher:l,project:t,report:o,skipIntegrityCheck:!0,cacheOptions:{skipIntegrityCheck:!0}},u={project:t,resolver:a,report:o,fetchOptions:c};return await n.startTimerPromise("Deduplication step",async()=>{let f=await F8e[e](t,r,{resolver:a,resolveOptions:u,fetcher:l,fetchOptions:c}),h=Ji.progressViaCounter(f.length);n.reportProgress(h);let p=0;await Promise.all(f.map(Q=>Q.then(S=>{if(S===null)return;p++;let{descriptor:x,currentPackage:M,updatedPackage:Y}=S;n.reportInfo($.UNNAMED,`${P.prettyDescriptor(s,x)} can be deduped from ${P.prettyLocator(s,M)} to ${P.prettyLocator(s,Y)}`),n.reportJson({descriptor:P.stringifyDescriptor(x),currentResolution:P.stringifyLocator(M),updatedResolution:P.stringifyLocator(Y)}),t.storedResolutions.set(x.descriptorHash,Y.locatorHash)}).finally(()=>h.tick())));let m;switch(p){case 0:m="No packages";break;case 1:m="One package";break;default:m=`${p} packages`}let y=ae.pretty(s,e,ae.Type.CODE);return n.reportInfo($.UNNAMED,`${m} can be deduped using the ${y} strategy`),p})}var cm=class extends Le{constructor(){super(...arguments);this.strategy=z.String("-s,--strategy",Iu.HIGHEST,{description:"The strategy to use when deduping dependencies",validator:nn(Iu)});this.check=z.Boolean("-c,--check",!1,{description:"Exit with exit code 1 when duplicates are found, without persisting the dependency tree"});this.json=z.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.mode=z.String("--mode",{description:"Change what artifacts installs generate",validator:nn(di)});this.patterns=z.Rest()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r}=await ze.find(e,this.context.cwd),i=await Nt.find(e);await r.restoreInstallState({restoreResolutions:!1});let n=0,s=await Je.start({configuration:e,includeFooter:!1,stdout:this.context.stdout,json:this.json},async o=>{n=await jN(r,{strategy:this.strategy,patterns:this.patterns,cache:i,report:o})});return s.hasErrors()?s.exitCode():this.check?n?1:0:(await Je.start({configuration:e,stdout:this.context.stdout,json:this.json},async a=>{await r.install({cache:i,report:a,mode:this.mode})})).exitCode()}};cm.paths=[["dedupe"]],cm.usage=Re.Usage({description:"deduplicate dependencies with overlapping ranges",details:"\n      Duplicates are defined as descriptors with overlapping ranges being resolved and locked to different locators. They are a natural consequence of Yarn's deterministic installs, but they can sometimes pile up and unnecessarily increase the size of your project.\n\n      This command dedupes dependencies in the current project using different strategies (only one is implemented at the moment):\n\n      - `highest`: Reuses (where possible) the locators with the highest versions. This means that dependencies can only be upgraded, never downgraded. It's also guaranteed that it never takes more than a single pass to dedupe the entire dependency tree.\n\n      **Note:** Even though it never produces a wrong dependency tree, this command should be used with caution, as it modifies the dependency tree, which can sometimes cause problems when packages don't strictly follow semver recommendations. Because of this, it is recommended to also review the changes manually.\n\n      If set, the `-c,--check` flag will only report the found duplicates, without persisting the modified dependency tree. If changes are found, the command will exit with a non-zero exit code, making it suitable for CI purposes.\n\n      If the `--mode=<mode>` option is set, Yarn will change which artifacts are generated. The modes currently supported are:\n\n      - `skip-build` will not run the build scripts at all. Note that this is different from setting `enableScripts` to false because the later will disable build scripts, and thus affect the content of the artifacts generated on disk, whereas the former will just disable the build step - but not the scripts themselves, which just won't run.\n\n      - `update-lockfile` will skip the link step altogether, and only fetch packages that are missing from the lockfile (or that have no associated checksums). This mode is typically used by tools like Renovate or Dependabot to keep a lockfile up-to-date without incurring the full install cost.\n\n      This command accepts glob patterns as arguments (if valid Idents and supported by [micromatch](https://github.com/micromatch/micromatch)). Make sure to escape the patterns, to prevent your own shell from trying to expand them.\n\n      ### In-depth explanation:\n\n      Yarn doesn't deduplicate dependencies by default, otherwise installs wouldn't be deterministic and the lockfile would be useless. What it actually does is that it tries to not duplicate dependencies in the first place.\n\n      **Example:** If `foo@^2.3.4` (a dependency of a dependency) has already been resolved to `foo@2.3.4`, running `yarn add foo@*`will cause Yarn to reuse `foo@2.3.4`, even if the latest `foo` is actually `foo@2.10.14`, thus preventing unnecessary duplication.\n\n      Duplication happens when Yarn can't unlock dependencies that have already been locked inside the lockfile.\n\n      **Example:** If `foo@^2.3.4` (a dependency of a dependency) has already been resolved to `foo@2.3.4`, running `yarn add foo@2.10.14` will cause Yarn to install `foo@2.10.14` because the existing resolution doesn't satisfy the range `2.10.14`. This behavior can lead to (sometimes) unwanted duplication, since now the lockfile contains 2 separate resolutions for the 2 `foo` descriptors, even though they have overlapping ranges, which means that the lockfile can be simplified so that both descriptors resolve to `foo@2.10.14`.\n    ",examples:[["Dedupe all packages","$0 dedupe"],["Dedupe all packages using a specific strategy","$0 dedupe --strategy highest"],["Dedupe a specific package","$0 dedupe lodash"],["Dedupe all packages with the `@babel/*` scope","$0 dedupe '@babel/*'"],["Check for duplicates (can be used as a CI step)","$0 dedupe --check"]]});var hae=cm;var ib=class extends Le{async execute(){let{plugins:e}=await ye.find(this.context.cwd,this.context.plugins),r=[];for(let o of e){let{commands:a}=o[1];if(a){let c=Is.from(a).definitions();r.push([o[0],c])}}let i=this.cli.definitions(),n=(o,a)=>o.split(" ").slice(1).join()===a.split(" ").slice(1).join(),s=dae()["@yarnpkg/builder"].bundles.standard;for(let o of r){let a=o[1];for(let l of a)i.find(c=>n(c.path,l.path)).plugin={name:o[0],useAlts:s.includes(o[0])}}this.context.stdout.write(`${JSON.stringify(i,null,2)}
 `)}};ib.paths=[["--clipanion=definitions"]];var Cae=ib;var nb=class extends Le{async execute(){this.context.stdout.write(this.cli.usage(null))}};nb.paths=[["help"],["--help"],["-h"]];var mae=nb;var GN=class extends Le{constructor(){super(...arguments);this.leadingArgument=z.String();this.args=z.Proxy()}async execute(){if(this.leadingArgument.match(/[\\/]/)&&!P.tryParseIdent(this.leadingArgument)){let e=k.resolve(this.context.cwd,j.toPortablePath(this.leadingArgument));return await this.cli.run(this.args,{cwd:e})}else return await this.cli.run(["run",this.leadingArgument,...this.args])}},Eae=GN;var sb=class extends Le{async execute(){this.context.stdout.write(`${Ur||"<unknown>"}
 `)}};sb.paths=[["-v"],["--version"]];var Iae=sb;var um=class extends Le{constructor(){super(...arguments);this.commandName=z.String();this.args=z.Proxy()}async execute(){let e=await ye.find(this.context.cwd,this.context.plugins),{project:r,locator:i}=await ze.find(e,this.context.cwd);return await r.restoreInstallState(),await Zt.executePackageShellcode(i,this.commandName,this.args,{cwd:this.context.cwd,stdin:this.context.stdin,stdout:this.context.stdout,stderr:this.context.stderr,project:r})}};um.paths=[["exec"]],um.usage=Re.Usage({description:"execute a shell script",details:`
       This command simply executes a shell script within the context of the root directory of the active workspace using the portable shell.
index 07c3b200ea5258a7aa97fd50634f19a2d7175eeb..c7a9cbfb8ea514e40017a0ae77515c54d6afe065 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -20,7 +20,7 @@ GIT_COMMIT?=$(shell git rev-parse --short HEAD)
 # changes in the package. (i.e. example config files externally added
 ITERATION?=1
 
-TARGETS?=centos7 debian10 debian11 ubuntu1804 ubuntu2004
+TARGETS?=centos7 rocky8 debian10 debian11 ubuntu1804 ubuntu2004
 
 ARVADOS_DIRECTORY?=unset
 
@@ -36,6 +36,7 @@ DEB_FILE=$(APP_NAME)_$(VERSION)-$(ITERATION)_amd64.deb
 # redHat package file
 RPM_FILE=$(APP_NAME)-$(VERSION)-$(ITERATION).x86_64.rpm
 
+GOPATH=$(shell go env GOPATH)
 export WORKSPACE?=$(shell pwd)
 
 .PHONY: help clean* yarn-install test build packages packages-with-version integration-tests-in-docker
@@ -62,14 +63,15 @@ clean-node-modules:
 
 clean: clean-rpm clean-deb clean-node-modules
 
-arvados-server-install:
+arvados-server-install: check-arvados-directory
        cd $(ARVADOS_DIRECTORY)
        go mod download
        cd cmd/arvados-server
-       go install
+       echo GOPATH is $(GOPATH)
+       GOFLAGS=-buildvcs=false go install
        cd -
-       ls -l ~/go/bin/arvados-server
-       ~/go/bin/arvados-server install -type test
+       ls -l $(GOPATH)/bin/arvados-server
+       $(GOPATH)/bin/arvados-server install -type test
 
 yarn-install: arvados-server-install
        yarn install
@@ -77,13 +79,19 @@ yarn-install: arvados-server-install
 unit-tests: yarn-install
        yarn test --no-watchAll --bail --ci
 
-integration-tests: yarn-install
+integration-tests: yarn-install check-arvados-directory
        yarn run cypress install
        $(WORKSPACE)/tools/run-integration-tests.sh -a $(ARVADOS_DIRECTORY)
 
-integration-tests-in-docker: workbench2-build-image
+integration-tests-in-docker: workbench2-build-image check-arvados-directory
        docker run -ti -v$(PWD):/usr/src/workbench2 -v$(ARVADOS_DIRECTORY):/usr/src/arvados -w /usr/src/workbench2 -e ARVADOS_DIRECTORY=/usr/src/arvados workbench2-build make integration-tests
 
+unit-tests-in-docker: workbench2-build-image check-arvados-directory
+       docker run -ti -v$(PWD):/usr/src/workbench2 -v$(ARVADOS_DIRECTORY):/usr/src/arvados -w /usr/src/workbench2 -e ARVADOS_DIRECTORY=/usr/src/arvados workbench2-build make unit-tests
+
+tests-in-docker: workbench2-build-image check-arvados-directory
+       docker run -t -v$(PWD):/usr/src/workbench2 -v$(ARVADOS_DIRECTORY):/usr/src/arvados -w /usr/src/workbench2 -e ARVADOS_DIRECTORY=/usr/src/arvados -e ci="${ci}" workbench2-build make test
+
 test: unit-tests integration-tests
 
 build: yarn-install
@@ -122,16 +130,15 @@ $(RPM_FILE): build
        etc/arvados/workbench2/workbench2.example.json=/etc/arvados/$(APP_NAME)/workbench2.example.json
 
 copy: $(DEB_FILE) $(RPM_FILE)
-       for target in $(TARGETS) ; do \
-               mkdir -p packages/$$target
-               if [[ $$target =~ ^centos ]]; then
-                       cp -p $(RPM_FILE) packages/$$target ; \
-               else
-                       cp -p $(DEB_FILE) packages/$$target ; \
-               fi
-       done
-       rm -f $(RPM_FILE)
-       rm -f $(DEB_FILE)
+       for target in $(TARGETS); do \
+               mkdir -p "packages/$$target" && \
+               case "$$target" in \
+                       centos*|rocky*) cp -p "$(RPM_FILE)" "packages/$$target" ;; \
+                       debian*|ubuntu*) cp -p "$(DEB_FILE)" "packages/$$target" ;; \
+                       *) echo "Unknown copy target $$target"; exit 1 ;; \
+               esac ; \
+       done ; \
+       rm -f "$(DEB_FILE)" "$(RPM_FILE)"
 
 # use FPM to create DEB and RPM
 packages: copy
@@ -144,12 +151,15 @@ packages-in-docker: check-arvados-directory workbench2-build-image
        docker run --env ci="true" \
                --env ARVADOS_DIRECTORY=/tmp/arvados \
                --env APP_NAME=${APP_NAME} \
+               --env VERSION="${VERSION}" \
                --env ITERATION=${ITERATION} \
                --env TARGETS="${TARGETS}" \
+               --env MAINTAINER="${MAINTAINER}" \
+               --env DESCRIPTION="${DESCRIPTION}" \
                -w="/tmp/workbench2" \
                -t -v ${WORKSPACE}:/tmp/workbench2 \
                -v ${ARVADOS_DIRECTORY}:/tmp/arvados workbench2-build:latest \
-               make packages
+               sh -c 'git config --global --add safe.directory /tmp/workbench2 && make packages'
 
 workbench2-build-image:
        (cd docker && docker build -t workbench2-build .)
diff --git a/cypress/fixtures/workflow_directory_array.yaml b/cypress/fixtures/workflow_directory_array.yaml
new file mode 100644 (file)
index 0000000..fbdbd32
--- /dev/null
@@ -0,0 +1,20 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+---
+"$graph":
+- class: Workflow
+  cwlVersion: v1.2
+  hints:
+  - acrContainerImage: 7009415fdc959d0c2819ee2e9db96561+261
+    class: http://arvados.org/cwl#WorkflowRunnerResources
+  id: "#main"
+  inputs:
+  - id: "#main/directoryInputName"
+    type:
+      items: Directory
+      type: array
+  outputs: []
+  steps: []
+cwlVersion: v1.2
index 6156909c89c054228e55e6584733c5f26cc52b37..295bc380c3d20ed716da34f9240f41fb11bf192b 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-describe('Collection panel tests', function () {
+describe('Banner / tooltip tests', function () {
     let activeUser;
     let adminUser;
     let collectionUUID;
@@ -94,7 +94,6 @@ describe('Collection panel tests', function () {
 
                     cy.getAll('@banner', '@tooltips')
                         .then(([banner, tooltips]) => {
-                            console.log(tooltips)
                             cy.get('[data-cy=drag-and-drop]').upload(banner, 'banner.html', false);
                             cy.get('[data-cy=drag-and-drop]').upload(tooltips, 'tooltips.json', false);
                         });
index efde53e5e87f1762695cb8bfe6a6192222a4091e..54c570f7c4453fdafa3fe5bd4cd27795eadcb1e1 100644 (file)
@@ -2,9 +2,9 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-const path = require('path');
+const path = require("path");
 
-describe('Collection panel tests', function () {
+describe("Collection panel tests", function () {
     let activeUser;
     let adminUser;
     let downloadsFolder;
@@ -14,17 +14,17 @@ describe('Collection panel tests', function () {
         // aliases are cleaned up after every test. Also it doesn't make sense
         // to set the same users on beforeEach() over and over again, so we
         // separate a little from Cypress' 'Best Practices' here.
-        cy.getUser('admin', 'Admin', 'User', true, true)
-            .as('adminUser').then(function () {
+        cy.getUser("admin", "Admin", "User", true, true)
+            .as("adminUser")
+            .then(function () {
                 adminUser = this.adminUser;
-            }
-            );
-        cy.getUser('collectionuser1', 'Collection', 'User', false, true)
-            .as('activeUser').then(function () {
+            });
+        cy.getUser("collectionuser1", "Collection", "User", false, true)
+            .as("activeUser")
+            .then(function () {
                 activeUser = this.activeUser;
-            }
-            );
-        downloadsFolder = Cypress.config('downloadsFolder');
+            });
+        downloadsFolder = Cypress.config("downloadsFolder");
     });
 
     beforeEach(function () {
@@ -32,328 +32,364 @@ describe('Collection panel tests', function () {
         cy.clearLocalStorage();
     });
 
-    it('allows to download mountain duck config for a collection', () => {
+    it('shows the appropriate buttons in the toolbar', () => {
+
+        const msButtonTooltips = [
+            'API Details',
+            'Add to Favorites',
+            'Copy to clipboard',
+            'Edit collection',
+            'Make a copy',
+            'Move to',
+            'Move to trash',
+            'Open in new tab',
+            'Open with 3rd party client',
+            'Share',
+            'View details',
+        ];
+
+        cy.loginAs(activeUser);
+        const name = `Test collection ${Math.floor(Math.random() * 999999)}`;
+        cy.get("[data-cy=side-panel-button]").click({force: true});
+        cy.get("[data-cy=side-panel-new-collection]").click();
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "New collection")
+            .within(() => {
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(name);
+                });
+                cy.get("[data-cy=form-submit-btn]").click();
+            });
+            cy.get("[data-cy=side-panel-tree]").contains("Home Projects").click();
+            cy.waitForDom()
+            cy.get('[data-cy=data-table-row]').contains(name).should('exist').parent().parent().parent().parent().click()
+            cy.get('[data-cy=multiselect-button]').should('have.length', msButtonTooltips.length)
+            for (let i = 0; i < msButtonTooltips.length; i++) {
+                cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseover');
+                cy.get('body').contains(msButtonTooltips[i]).should('exist')
+                cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseout');
+            }
+    })
+
+    it("allows to download mountain duck config for a collection", () => {
         cy.createCollection(adminUser.token, {
             name: `Test collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
         })
-        .as('testCollection').then(function (testCollection) {
-            cy.loginAs(activeUser);
-            cy.goToPath(`/collections/${testCollection.uuid}`);
-
-            cy.get('[data-cy=collection-panel-options-btn]').click();
-            cy.get('[data-cy=context-menu]').contains('Open with 3rd party client').click();
-            cy.get('[data-cy=download-button').click();
-
-            const filename = path.join(downloadsFolder, `${testCollection.name}.duck`);
-
-            cy.readFile(filename, { timeout: 15000 })
-                .then((body) => {
-                    const childrenCollection = Array.prototype.slice.call(Cypress.$(body).find('dict')[0].children);
-                    const map = {};
-                    let i, j = 2;
-
-                    for (i=0; i < childrenCollection.length; i += j) {
-                      map[childrenCollection[i].outerText] = childrenCollection[i + 1].outerText;
-                    }
+            .as("testCollection")
+            .then(function (testCollection) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${testCollection.uuid}`);
 
-                    cy.get('#simple-tabpanel-0').find('a')
-                        .then((a) => {
-                            const [host, port] = a.text().split('@')[1].split('/')[0].split(':');
-                            expect(map['Protocol']).to.equal('davs');
-                            expect(map['UUID']).to.equal(testCollection.uuid);
-                            expect(map['Username']).to.equal(activeUser.user.username);
-                            expect(map['Port']).to.equal(port);
-                            expect(map['Hostname']).to.equal(host);
-                            if (map['Path']) {
-                                expect(map['Path']).to.equal(`/c=${testCollection.uuid}`);
-                            }
-                        });
-                })
-                .then(() => cy.task('clearDownload', { filename }));
-        });
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Open with 3rd party client").click();
+                cy.get("[data-cy=download-button").click();
+
+                const filename = path.join(downloadsFolder, `${testCollection.name}.duck`);
+
+                cy.readFile(filename, { timeout: 15000 })
+                    .then(body => {
+                        const childrenCollection = Array.prototype.slice.call(Cypress.$(body).find("dict")[0].children);
+                        const map = {};
+                        let i,
+                            j = 2;
+
+                        for (i = 0; i < childrenCollection.length; i += j) {
+                            map[childrenCollection[i].outerText] = childrenCollection[i + 1].outerText;
+                        }
+
+                        cy.get("#simple-tabpanel-0")
+                            .find("a")
+                            .then(a => {
+                                const [host, port] = a.text().split("@")[1].split("/")[0].split(":");
+                                expect(map["Protocol"]).to.equal("davs");
+                                expect(map["UUID"]).to.equal(testCollection.uuid);
+                                expect(map["Username"]).to.equal(activeUser.user.username);
+                                expect(map["Port"]).to.equal(port);
+                                expect(map["Hostname"]).to.equal(host);
+                                if (map["Path"]) {
+                                    expect(map["Path"]).to.equal(`/c=${testCollection.uuid}`);
+                                }
+                            });
+                    })
+                    .then(() => cy.task("clearDownload", { filename }));
+            });
     });
 
-    it('attempts to use a preexisting name creating or updating a collection', function() {
+    it("attempts to use a preexisting name creating or updating a collection", function () {
         const name = `Test collection ${Math.floor(Math.random() * 999999)}`;
         cy.createCollection(adminUser.token, {
             name: name,
             owner_uuid: activeUser.user.uuid,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
         });
         cy.loginAs(activeUser);
         cy.goToPath(`/projects/${activeUser.user.uuid}`);
-        cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects');
-        cy.get('[data-cy=breadcrumb-last]').should('not.exist');
+        cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
+        cy.get("[data-cy=breadcrumb-last]").should("not.exist");
         // Attempt to create new collection with a duplicate name
-        cy.get('[data-cy=side-panel-button]').click();
-        cy.get('[data-cy=side-panel-new-collection]').click();
-        cy.get('[data-cy=form-dialog]')
-            .should('contain', 'New collection')
+        cy.get("[data-cy=side-panel-button]").click();
+        cy.get("[data-cy=side-panel-new-collection]").click();
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "New collection")
             .within(() => {
-                cy.get('[data-cy=name-field]').within(() => {
-                    cy.get('input').type(name);
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(name);
                 });
-                cy.get('[data-cy=form-submit-btn]').click();
+                cy.get("[data-cy=form-submit-btn]").click();
             });
         // Error message should display, allowing editing the name
-        cy.get('[data-cy=form-dialog]').should('exist')
-            .and('contain', 'Collection with the same name already exists')
+        cy.get("[data-cy=form-dialog]")
+            .should("exist")
+            .and("contain", "Collection with the same name already exists")
             .within(() => {
-                cy.get('[data-cy=name-field]').within(() => {
-                    cy.get('input').type(' renamed');
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(" renamed");
                 });
-                cy.get('[data-cy=form-submit-btn]').click();
+                cy.get("[data-cy=form-submit-btn]").click();
             });
-        cy.get('[data-cy=form-dialog]').should('not.exist');
+        cy.get("[data-cy=form-dialog]").should("not.exist");
         // Attempt to rename the collection with the duplicate name
-        cy.get('[data-cy=collection-panel-options-btn]').click();
-        cy.get('[data-cy=context-menu]').contains('Edit collection').click();
-        cy.get('[data-cy=form-dialog]')
-            .should('contain', 'Edit Collection')
+        cy.get("[data-cy=collection-panel-options-btn]").click();
+        cy.get("[data-cy=context-menu]").contains("Edit collection").click();
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "Edit Collection")
             .within(() => {
-                cy.get('[data-cy=name-field]').within(() => {
-                    cy.get('input')
-                        .type('{selectall}{backspace}')
-                        .type(name);
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type("{selectall}{backspace}").type(name);
                 });
-                cy.get('[data-cy=form-submit-btn]').click();
+                cy.get("[data-cy=form-submit-btn]").click();
             });
-        cy.get('[data-cy=form-dialog]').should('exist')
-            .and('contain', 'Collection with the same name already exists');
+        cy.get("[data-cy=form-dialog]").should("exist").and("contain", "Collection with the same name already exists");
     });
 
-    it('uses the property editor (from edit dialog) with vocabulary terms', function () {
+    
+
+    it("uses the property editor (from edit dialog) with vocabulary terms", function () {
         cy.createCollection(adminUser.token, {
             name: `Test collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
         })
-            .as('testCollection').then(function () {
+            .as("testCollection")
+            .then(function () {
                 cy.loginAs(activeUser);
                 cy.goToPath(`/collections/${this.testCollection.uuid}`);
 
-                cy.get('[data-cy=collection-info-panel')
-                    .should('contain', this.testCollection.name)
-                    .and('not.contain', 'Color: Magenta');
+                cy.get("[data-cy=collection-info-panel").should("contain", this.testCollection.name).and("not.contain", "Color: Magenta");
 
-                cy.get('[data-cy=collection-panel-options-btn]').click();
-                cy.get('[data-cy=context-menu]').contains('Edit collection').click();
-                cy.get('[data-cy=form-dialog]').should('contain', 'Properties');
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Edit collection").click();
+                cy.get("[data-cy=form-dialog]").should("contain", "Properties");
 
                 // Key: Color (IDTAGCOLORS) - Value: Magenta (IDVALCOLORS3)
-                cy.get('[data-cy=resource-properties-form]').within(() => {
-                    cy.get('[data-cy=property-field-key]').within(() => {
-                        cy.get('input').type('Color');
+                cy.get("[data-cy=resource-properties-form]").within(() => {
+                    cy.get("[data-cy=property-field-key]").within(() => {
+                        cy.get("input").type("Color");
                     });
-                    cy.get('[data-cy=property-field-value]').within(() => {
-                        cy.get('input').type('Magenta');
+                    cy.get("[data-cy=property-field-value]").within(() => {
+                        cy.get("input").type("Magenta");
                     });
                     cy.root().submit();
                 });
                 // Confirm proper vocabulary labels are displayed on the UI.
-                cy.get('[data-cy=form-dialog]').should('contain', 'Color: Magenta');
-                cy.get('[data-cy=form-dialog]').contains('Save').click();
-                cy.get('[data-cy=form-dialog]').should('not.exist');
+                cy.get("[data-cy=form-dialog]").should("contain", "Color: Magenta");
+                cy.get("[data-cy=form-dialog]").contains("Save").click();
+                cy.get("[data-cy=form-dialog]").should("not.exist");
                 // Confirm proper vocabulary IDs were saved on the backend.
-                cy.doRequest('GET', `/arvados/v1/collections/${this.testCollection.uuid}`)
-                    .its('body').as('collection')
+                cy.doRequest("GET", `/arvados/v1/collections/${this.testCollection.uuid}`)
+                    .its("body")
+                    .as("collection")
                     .then(function () {
-                        expect(this.collection.properties.IDTAGCOLORS).to.equal('IDVALCOLORS3');
+                        expect(this.collection.properties.IDTAGCOLORS).to.equal("IDVALCOLORS3");
                     });
                 // Confirm the property is displayed on the UI.
-                cy.get('[data-cy=collection-info-panel')
-                    .should('contain', this.testCollection.name)
-                    .and('contain', 'Color: Magenta');
+                cy.get("[data-cy=collection-info-panel").should("contain", this.testCollection.name).and("contain", "Color: Magenta");
             });
     });
 
-    it('uses the editor (from details panel) with vocabulary terms', function () {
+    
+
+    it("uses the editor (from details panel) with vocabulary terms", function () {
         cy.createCollection(adminUser.token, {
             name: `Test collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
         })
-            .as('testCollection').then(function () {
+            .as("testCollection")
+            .then(function () {
                 cy.loginAs(activeUser);
                 cy.goToPath(`/collections/${this.testCollection.uuid}`);
 
-                cy.get('[data-cy=collection-info-panel')
-                    .should('contain', this.testCollection.name)
-                    .and('not.contain', 'Color: Magenta')
-                    .and('not.contain', 'Size: S');
-                cy.get('[data-cy=additional-info-icon]').click();
+                cy.get("[data-cy=collection-info-panel")
+                    .should("contain", this.testCollection.name)
+                    .and("not.contain", "Color: Magenta")
+                    .and("not.contain", "Size: S");
+                cy.get("[data-cy=additional-info-icon]").click();
 
-                cy.get('[data-cy=details-panel]').within(() => {
-                    cy.get('[data-cy=details-panel-edit-btn]').click();
+                cy.get("[data-cy=details-panel]").within(() => {
+                    cy.get("[data-cy=details-panel-edit-btn]").click();
                 });
-                cy.get('[data-cy=form-dialog').contains('Edit Collection');
+                cy.get("[data-cy=form-dialog").contains("Edit Collection");
 
                 // Key: Color (IDTAGCOLORS) - Value: Magenta (IDVALCOLORS3)
-                cy.get('[data-cy=resource-properties-form]').within(() => {
-                    cy.get('[data-cy=property-field-key]').within(() => {
-                        cy.get('input').type('Color');
+                cy.get("[data-cy=resource-properties-form]").within(() => {
+                    cy.get("[data-cy=property-field-key]").within(() => {
+                        cy.get("input").type("Color");
                     });
-                    cy.get('[data-cy=property-field-value]').within(() => {
-                        cy.get('input').type('Magenta');
+                    cy.get("[data-cy=property-field-value]").within(() => {
+                        cy.get("input").type("Magenta");
                     });
                     cy.root().submit();
                 });
                 // Confirm proper vocabulary labels are displayed on the UI.
-                cy.get('[data-cy=form-dialog]')
-                    .should('contain', 'Color: Magenta');
+                cy.get("[data-cy=form-dialog]").should("contain", "Color: Magenta");
 
                 // Case-insensitive on-blur auto-selection test
                 // Key: Size (IDTAGSIZES) - Value: Small (IDVALSIZES2)
-                cy.get('[data-cy=resource-properties-form]').within(() => {
-                    cy.get('[data-cy=property-field-key]').within(() => {
-                        cy.get('input').type('sIzE');
+                cy.get("[data-cy=resource-properties-form]").within(() => {
+                    cy.get("[data-cy=property-field-key]").within(() => {
+                        cy.get("input").type("sIzE");
                     });
-                    cy.get('[data-cy=property-field-value]').within(() => {
-                        cy.get('input').type('sMaLL');
+                    cy.get("[data-cy=property-field-value]").within(() => {
+                        cy.get("input").type("sMaLL");
                     });
                     // Cannot "type()" TAB on Cypress so let's click another field
                     // to trigger the onBlur event.
-                    cy.get('[data-cy=property-field-key]').click();
+                    cy.get("[data-cy=property-field-key]").click();
                     cy.root().submit();
                 });
                 // Confirm proper vocabulary labels are displayed on the UI.
-                cy.get('[data-cy=form-dialog]')
-                    .should('contain', 'Size: S');
+                cy.get("[data-cy=form-dialog]").should("contain", "Size: S");
 
-                cy.get('[data-cy=form-dialog]').contains('Save').click();
-                cy.get('[data-cy=form-dialog]').should('not.exist');
+                cy.get("[data-cy=form-dialog]").contains("Save").click();
+                cy.get("[data-cy=form-dialog]").should("not.exist");
 
                 // Confirm proper vocabulary IDs were saved on the backend.
-                cy.doRequest('GET', `/arvados/v1/collections/${this.testCollection.uuid}`)
-                    .its('body').as('collection')
+                cy.doRequest("GET", `/arvados/v1/collections/${this.testCollection.uuid}`)
+                    .its("body")
+                    .as("collection")
                     .then(function () {
-                        expect(this.collection.properties.IDTAGCOLORS).to.equal('IDVALCOLORS3');
-                        expect(this.collection.properties.IDTAGSIZES).to.equal('IDVALSIZES2');
+                        expect(this.collection.properties.IDTAGCOLORS).to.equal("IDVALCOLORS3");
+                        expect(this.collection.properties.IDTAGSIZES).to.equal("IDVALSIZES2");
                     });
 
                 // Confirm properties display on the UI.
-                cy.get('[data-cy=collection-info-panel')
-                    .should('contain', this.testCollection.name)
-                    .and('contain', 'Color: Magenta')
-                    .and('contain', 'Size: S');
+                cy.get("[data-cy=collection-info-panel")
+                    .should("contain", this.testCollection.name)
+                    .and("contain", "Color: Magenta")
+                    .and("contain", "Size: S");
             });
     });
 
-    it('shows collection by URL', function () {
+    it("shows collection by URL", function () {
         cy.loginAs(activeUser);
         [true, false].map(function (isWritable) {
             // Using different file names to avoid test flakyness: the second iteration
             // on this loop may pass an assertion from the first iteration by looking
             // for the same file name.
-            const fileName = isWritable ? 'bar' : 'foo';
-            const subDirName = 'subdir';
+            const fileName = isWritable ? "bar" : "foo";
+            const subDirName = "subdir";
             cy.createGroup(adminUser.token, {
-                name: 'Shared project',
-                group_class: 'project',
-            }).as('sharedGroup').then(function () {
-                // Creates the collection using the admin token so we can set up
-                // 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", 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 !== ""
-                          ? clusterConfig.Services.WebDAV.ExternalURL
-                          : clusterConfig.Services.WebDAVDownload.ExternalURL;
-                      expect(inlineUrl).to.not.contain("*");
-                    })
-                    .createCollection(adminUser.token, {
-                      name: 'Test collection',
-                      owner_uuid: this.sharedGroup.uuid,
-                      properties: { someKey: 'someValue' },
-                      manifest_text: `. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n./${subDirName} 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n`
-                    })
-                    .as('testCollection').then(function () {
-                        // Share the group with active user.
-                        cy.createLink(adminUser.token, {
-                            name: isWritable ? 'can_write' : 'can_read',
-                            link_class: 'permission',
-                            head_uuid: this.sharedGroup.uuid,
-                            tail_uuid: activeUser.user.uuid
+                name: "Shared project",
+                group_class: "project",
+            })
+                .as("sharedGroup")
+                .then(function () {
+                    // Creates the collection using the admin token so we can set up
+                    // 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", 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 !== ""
+                                    ? clusterConfig.Services.WebDAV.ExternalURL
+                                    : clusterConfig.Services.WebDAVDownload.ExternalURL;
+                            expect(inlineUrl).to.not.contain("*");
+                        })
+                        .createCollection(adminUser.token, {
+                            name: "Test collection",
+                            owner_uuid: this.sharedGroup.uuid,
+                            properties: { someKey: "someValue" },
+                            manifest_text: `. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n./${subDirName} 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n`,
                         })
-                        cy.goToPath(`/collections/${this.testCollection.uuid}`);
-
-                        // Check that name & uuid are correct.
-                        cy.get('[data-cy=collection-info-panel]')
-                            .should('contain', this.testCollection.name)
-                            .and('contain', this.testCollection.uuid)
-                            .and('not.contain', 'This is an old version');
-                        // Check for the read-only icon
-                        cy.get('[data-cy=read-only-icon]').should(`${isWritable ? 'not.' : ''}exist`);
-                        // Check that both read and write operations are available on
-                        // the 'More options' menu.
-                        cy.get('[data-cy=collection-panel-options-btn]')
-                            .click()
-                        cy.get('[data-cy=context-menu]')
-                            .should('contain', 'Add to favorites')
-                            .and(`${isWritable ? '' : 'not.'}contain`, 'Edit collection');
-                        cy.get('body').click(); // Collapse the menu avoiding details panel expansion
-                        cy.get('[data-cy=collection-info-panel]')
-                            .should('contain', 'someKey: someValue')
-                            .and('not.contain', 'anotherKey: anotherValue');
-                        // Check that the file listing show both read & write operations
-                        cy.waitForDom().get('[data-cy=collection-files-panel]').within(() => {
-                            cy.get('[data-cy=collection-files-right-panel]', { timeout: 5000 })
-                                .should('contain', fileName);
-                            if (isWritable) {
-                                cy.get('[data-cy=upload-button]')
-                                    .should(`${isWritable ? '' : 'not.'}contain`, 'Upload data');
-                            }
+                        .as("testCollection")
+                        .then(function () {
+                            // Share the group with active user.
+                            cy.createLink(adminUser.token, {
+                                name: isWritable ? "can_write" : "can_read",
+                                link_class: "permission",
+                                head_uuid: this.sharedGroup.uuid,
+                                tail_uuid: activeUser.user.uuid,
+                            });
+                            cy.goToPath(`/collections/${this.testCollection.uuid}`);
+
+                            // Check that name & uuid are correct.
+                            cy.get("[data-cy=collection-info-panel]")
+                                .should("contain", this.testCollection.name)
+                                .and("contain", this.testCollection.uuid)
+                                .and("not.contain", "This is an old version");
+                            // Check for the read-only icon
+                            cy.get("[data-cy=read-only-icon]").should(`${isWritable ? "not." : ""}exist`);
+                            // Check that both read and write operations are available on
+                            // the 'More options' menu.
+                            cy.get("[data-cy=collection-panel-options-btn]").click();
+                            cy.get("[data-cy=context-menu]")
+                                .should("contain", "Add to favorites")
+                                .and(`${isWritable ? "" : "not."}contain`, "Edit collection");
+                            cy.get("body").click(); // Collapse the menu avoiding details panel expansion
+                            cy.get("[data-cy=collection-info-panel]")
+                                .should("contain", "someKey: someValue")
+                                .and("not.contain", "anotherKey: anotherValue");
+                            // Check that the file listing show both read & write operations
+                            cy.waitForDom()
+                                .get("[data-cy=collection-files-panel]")
+                                .within(() => {
+                                    cy.get("[data-cy=collection-files-right-panel]", { timeout: 5000 }).should("contain", fileName);
+                                    if (isWritable) {
+                                        cy.get("[data-cy=upload-button]").should(`${isWritable ? "" : "not."}contain`, "Upload data");
+                                    }
+                                });
+                            // Test context menus
+                            cy.get("[data-cy=collection-files-panel]").contains(fileName).rightclick();
+                            cy.get("[data-cy=context-menu]")
+                                .should("contain", "Download")
+                                .and("contain", "Open in new tab")
+                                .and("contain", "Copy to clipboard")
+                                .and(`${isWritable ? "" : "not."}contain`, "Rename")
+                                .and(`${isWritable ? "" : "not."}contain`, "Remove");
+                            cy.get("body").click(); // Collapse the menu
+                            cy.get("[data-cy=collection-files-panel]").contains(subDirName).rightclick();
+                            cy.get("[data-cy=context-menu]")
+                                .should("not.contain", "Download")
+                                .and("contain", "Open in new tab")
+                                .and("contain", "Copy to clipboard")
+                                .and(`${isWritable ? "" : "not."}contain`, "Rename")
+                                .and(`${isWritable ? "" : "not."}contain`, "Remove");
+                            cy.get("body").click(); // Collapse the menu
+                            // File/dir item 'more options' button
+                            cy.get("[data-cy=file-item-options-btn").first().click();
+                            cy.get("[data-cy=context-menu]").should(`${isWritable ? "" : "not."}contain`, "Remove");
+                            cy.get("body").click(); // Collapse the menu
+                            // Hamburger 'more options' menu button
+                            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+                            cy.get("[data-cy=context-menu]").should("contain", "Select all").click();
+                            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+                            cy.get("[data-cy=context-menu]").should(`${isWritable ? "" : "not."}contain`, "Remove selected");
+                            cy.get("body").click(); // Collapse the menu
                         });
-                        // Test context menus
-                        cy.get('[data-cy=collection-files-panel]')
-                            .contains(fileName).rightclick();
-                        cy.get('[data-cy=context-menu]')
-                            .should('contain', 'Download')
-                            .and('contain', 'Open in new tab')
-                            .and('contain', 'Copy to clipboard')
-                            .and(`${isWritable ? '' : 'not.'}contain`, 'Rename')
-                            .and(`${isWritable ? '' : 'not.'}contain`, 'Remove');
-                        cy.get('body').click(); // Collapse the menu
-                        cy.get('[data-cy=collection-files-panel]')
-                            .contains(subDirName).rightclick();
-                        cy.get('[data-cy=context-menu]')
-                            .should('not.contain', 'Download')
-                            .and('contain', 'Open in new tab')
-                            .and('contain', 'Copy to clipboard')
-                            .and(`${isWritable ? '' : 'not.'}contain`, 'Rename')
-                            .and(`${isWritable ? '' : 'not.'}contain`, 'Remove');
-                        cy.get('body').click(); // Collapse the menu
-                        // File/dir item 'more options' button
-                        cy.get('[data-cy=file-item-options-btn')
-                            .first()
-                            .click()
-                        cy.get('[data-cy=context-menu]')
-                            .should(`${isWritable ? '' : 'not.'}contain`, 'Remove');
-                        cy.get('body').click(); // Collapse the menu
-                        // Hamburger 'more options' menu button
-                        cy.get('[data-cy=collection-files-panel-options-btn]')
-                            .click()
-                        cy.get('[data-cy=context-menu]')
-                            .should('contain', 'Select all')
-                            .click()
-                        cy.get('[data-cy=collection-files-panel-options-btn]')
-                            .click()
-                        cy.get('[data-cy=context-menu]')
-                            .should(`${isWritable ? '' : 'not.'}contain`, 'Remove selected')
-                        cy.get('body').click(); // Collapse the menu
-                    })
-            })
-        })
-    })
+                });
+        });
+    });
 
-    it('renames a file using valid names', function () {
-        function eachPair(lst, func){
-            for(var i=0; i < lst.length - 1; i++){
-                func(lst[i], lst[i + 1])
+    it("renames a file using valid names", function () {
+        function eachPair(lst, func) {
+            for (var i = 0; i < lst.length - 1; i++) {
+                func(lst[i], lst[i + 1]);
             }
         }
         // Creates the collection using the admin token so we can set up
@@ -361,185 +397,171 @@ describe('Collection panel tests', function () {
         cy.createCollection(adminUser.token, {
             name: `Test collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
         })
-            .as('testCollection').then(function () {
+            .as("testCollection")
+            .then(function () {
                 cy.loginAs(activeUser);
                 cy.goToPath(`/collections/${this.testCollection.uuid}`);
 
                 const names = [
-                    'bar', // initial name already set
-                    '&',
-                    'foo',
-                    '&amp;',
-                    'I ❤️ ⛵️',
-                    '...',
-                    '#..',
-                    'some name with whitespaces',
-                    'some name with #2',
-                    'is this name legal? I hope it is',
-                    'some_file.pdf#',
-                    'some_file.pdf?',
-                    '?some_file.pdf',
-                    'some%file.pdf',
-                    'some%2Ffile.pdf',
-                    'some%22file.pdf',
-                    'some%20file.pdf',
+                    "bar", // initial name already set
+                    "&",
+                    "foo",
+                    "&amp;",
+                    "I ❤️ ⛵️",
+                    "...",
+                    "#..",
+                    "some name with whitespaces",
+                    "some name with #2",
+                    "is this name legal? I hope it is",
+                    "some_file.pdf#",
+                    "some_file.pdf?",
+                    "?some_file.pdf",
+                    "some%file.pdf",
+                    "some%2Ffile.pdf",
+                    "some%22file.pdf",
+                    "some%20file.pdf",
                     "G%C3%BCnter's%20file.pdf",
-                    'table%&?*2',
-                    'bar' // make sure we can go back to the original name as a last step
+                    "table%&?*2",
+                    "bar", // make sure we can go back to the original name as a last step
                 ];
+                cy.intercept({ method: "PUT", url: "**/arvados/v1/collections/*" }).as("renameRequest");
                 eachPair(names, (from, to) => {
-                    cy.waitForDom().get('[data-cy=collection-files-panel]')
-                        .contains(`${from}`).rightclick();
-                    cy.get('[data-cy=context-menu]')
-                        .contains('Rename')
-                        .click();
-                    cy.get('[data-cy=form-dialog]')
-                        .should('contain', 'Rename')
+                    cy.waitForDom().get("[data-cy=collection-files-panel]").contains(`${from}`).rightclick();
+                    cy.get("[data-cy=context-menu]").contains("Rename").click();
+                    cy.get("[data-cy=form-dialog]")
+                        .should("contain", "Rename")
                         .within(() => {
-                            cy.get('input')
-                                .type('{selectall}{backspace}')
-                                .type(to, { parseSpecialCharSequences: false });
+                            cy.get("input").type("{selectall}{backspace}").type(to, { parseSpecialCharSequences: false });
                         });
-                    cy.get('[data-cy=form-submit-btn]').click();
-                    cy.get('[data-cy=collection-files-panel]')
-                        .should('not.contain', `${from}`)
-                        .and('contain', `${to}`);
-                })
+                    cy.get("[data-cy=form-submit-btn]").click();
+                    cy.wait("@renameRequest");
+                    cy.get("[data-cy=collection-files-panel]").should("not.contain", `${from}`).and("contain", `${to}`);
+                });
             });
     });
 
-    it('renames a file to a different directory', function () {
+    it("renames a file to a different directory", function () {
         // Creates the collection using the admin token so we can set up
         // a bogus manifest text without block signatures.
         cy.createCollection(adminUser.token, {
             name: `Test collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
         })
-            .as('testCollection').then(function () {
+            .as("testCollection")
+            .then(function () {
                 cy.loginAs(activeUser);
                 cy.goToPath(`/collections/${this.testCollection.uuid}`);
 
-                ['subdir', 'G%C3%BCnter\'s%20file', 'table%&?*2'].forEach((subdir) => {
-                    cy.waitForDom().get('[data-cy=collection-files-panel]')
-                        .contains('bar').rightclick();
-                    cy.get('[data-cy=context-menu]')
-                        .contains('Rename')
-                        .click();
-                    cy.get('[data-cy=form-dialog]')
-                        .should('contain', 'Rename')
+                ["subdir", "G%C3%BCnter's%20file", "table%&?*2"].forEach(subdir => {
+                    cy.waitForDom().get("[data-cy=collection-files-panel]").contains("bar").rightclick();
+                    cy.get("[data-cy=context-menu]").contains("Rename").click();
+                    cy.get("[data-cy=form-dialog]")
+                        .should("contain", "Rename")
                         .within(() => {
-                            cy.get('input').type(`{selectall}{backspace}${subdir}/foo`);
+                            cy.get("input").type(`{selectall}{backspace}${subdir}/foo`);
                         });
-                    cy.get('[data-cy=form-submit-btn]').click();
-                    cy.get('[data-cy=collection-files-panel]')
-                        .should('not.contain', 'bar')
-                        .and('contain', subdir);
-                    cy.get('[data-cy=collection-files-panel]').contains(subdir).click();
+                    cy.get("[data-cy=form-submit-btn]").click();
+                    cy.get("[data-cy=collection-files-panel]").should("not.contain", "bar").and("contain", subdir);
+                    cy.get("[data-cy=collection-files-panel]").contains(subdir).click();
 
                     // Rename 'subdir/foo' to 'bar'
                     cy.wait(1000);
-                    cy.get('[data-cy=collection-files-panel]')
-                        .contains('foo').rightclick();
-                    cy.get('[data-cy=context-menu]')
-                        .contains('Rename')
-                        .click();
-                    cy.get('[data-cy=form-dialog]')
-                        .should('contain', 'Rename')
+                    cy.get("[data-cy=collection-files-panel]").contains("foo").rightclick();
+                    cy.get("[data-cy=context-menu]").contains("Rename").click();
+                    cy.get("[data-cy=form-dialog]")
+                        .should("contain", "Rename")
                         .within(() => {
-                            cy.get('input')
-                                .should('have.value', `${subdir}/foo`)
-                                .type(`{selectall}{backspace}bar`);
+                            cy.get("input").should("have.value", `${subdir}/foo`).type(`{selectall}{backspace}bar`);
                         });
-                    cy.get('[data-cy=form-submit-btn]').click();
+                    cy.get("[data-cy=form-submit-btn]").click();
+
+                    // need to wait for dialog to dismiss
+                    cy.get("[data-cy=form-dialog]").should("not.exist");
 
-                    cy.get('[data-cy=collection-files-panel]')
-                        .contains('Home')
-                        .click();
+                    cy.waitForDom().get("[data-cy=collection-files-panel]").contains("Home").click();
 
                     cy.wait(2000);
-                    cy.get('[data-cy=collection-files-panel]')
-                        .should('contain', subdir) // empty dir kept
-                        .and('contain', 'bar');
-
-                    cy.get('[data-cy=collection-files-panel]')
-                        .contains(subdir).rightclick();
-                    cy.get('[data-cy=context-menu]')
-                        .contains('Remove')
-                        .click();
-                    cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+                    cy.get("[data-cy=collection-files-panel]")
+                        .should("contain", subdir) // empty dir kept
+                        .and("contain", "bar");
+
+                    cy.get("[data-cy=collection-files-panel]").contains(subdir).rightclick();
+                    cy.get("[data-cy=context-menu]").contains("Remove").click();
+                    cy.get("[data-cy=confirmation-dialog-ok-btn]").click();
+                    cy.get("[data-cy=form-dialog]").should("not.exist");
                 });
             });
     });
 
-    it('shows collection owner', () => {
+    it("shows collection owner", () => {
         cy.createCollection(adminUser.token, {
             name: `Test collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
         })
-            .as('testCollection').then((testCollection) => {
+            .as("testCollection")
+            .then(testCollection => {
                 cy.loginAs(activeUser);
                 cy.goToPath(`/collections/${testCollection.uuid}`);
                 cy.wait(5000);
-                cy.get('[data-cy=collection-info-panel]').contains(`Collection User`);
+                cy.get("[data-cy=collection-info-panel]").contains(`Collection User`);
             });
     });
 
-    it('tries to rename a file with illegal names', function () {
+    it("tries to rename a file with illegal names", function () {
         // Creates the collection using the admin token so we can set up
         // a bogus manifest text without block signatures.
         cy.createCollection(adminUser.token, {
             name: `Test collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
         })
-            .as('testCollection').then(function () {
+            .as("testCollection")
+            .then(function () {
                 cy.loginAs(activeUser);
                 cy.goToPath(`/collections/${this.testCollection.uuid}`);
 
                 const illegalNamesFromUI = [
-                    ['.', "Name cannot be '.' or '..'"],
-                    ['..', "Name cannot be '.' or '..'"],
-                    ['', 'This field is required'],
-                    [' ', 'Leading/trailing whitespaces not allowed'],
-                    [' foo', 'Leading/trailing whitespaces not allowed'],
-                    ['foo ', 'Leading/trailing whitespaces not allowed'],
-                    ['//foo', 'Empty dir name not allowed']
-                ]
+                    [".", "Name cannot be '.' or '..'"],
+                    ["..", "Name cannot be '.' or '..'"],
+                    ["", "This field is required"],
+                    [" ", "Leading/trailing whitespaces not allowed"],
+                    [" foo", "Leading/trailing whitespaces not allowed"],
+                    ["foo ", "Leading/trailing whitespaces not allowed"],
+                    ["//foo", "Empty dir name not allowed"],
+                ];
                 illegalNamesFromUI.forEach(([name, errMsg]) => {
-                    cy.get('[data-cy=collection-files-panel]')
-                        .contains('bar').rightclick();
-                    cy.get('[data-cy=context-menu]')
-                        .contains('Rename')
-                        .click();
-                    cy.get('[data-cy=form-dialog]')
-                        .should('contain', 'Rename')
+                    cy.get("[data-cy=collection-files-panel]").contains("bar").rightclick();
+                    cy.get("[data-cy=context-menu]").contains("Rename").click();
+                    cy.get("[data-cy=form-dialog]")
+                        .should("contain", "Rename")
                         .within(() => {
-                            cy.get('input').type(`{selectall}{backspace}${name}`);
+                            cy.get("input").type(`{selectall}{backspace}${name}`);
                         });
-                    cy.get('[data-cy=form-dialog]')
-                        .should('contain', 'Rename')
+                    cy.get("[data-cy=form-dialog]")
+                        .should("contain", "Rename")
                         .within(() => {
                             cy.contains(`${errMsg}`);
                         });
-                    cy.get('[data-cy=form-cancel-btn]').click();
-                })
+                    cy.get("[data-cy=form-cancel-btn]").click();
+                });
             });
     });
 
-    it('can correctly display old versions', function () {
+    it("can correctly display old versions", function () {
         const colName = `Versioned Collection ${Math.floor(Math.random() * 999999)}`;
-        let colUuid = '';
-        let oldVersionUuid = '';
+        let colUuid = "";
+        let oldVersionUuid = "";
         // Make sure no other collections with this name exist
-        cy.doRequest('GET', '/arvados/v1/collections', null, {
+        cy.doRequest("GET", "/arvados/v1/collections", null, {
             filters: `[["name", "=", "${colName}"]]`,
-            include_old_versions: true
+            include_old_versions: true,
         })
-            .its('body.items').as('collections')
+            .its("body.items")
+            .as("collections")
             .then(function () {
                 expect(this.collections).to.be.empty;
             });
@@ -549,21 +571,23 @@ describe('Collection panel tests', function () {
             name: colName,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
         })
-            .as('originalVersion').then(function () {
+            .as("originalVersion")
+            .then(function () {
                 // Change the file name to create a new version.
                 cy.updateCollection(adminUser.token, this.originalVersion.uuid, {
-                    manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n"
-                })
+                    manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n",
+                });
                 colUuid = this.originalVersion.uuid;
             });
         // Confirm that there are 2 versions of the collection
-        cy.doRequest('GET', '/arvados/v1/collections', null, {
+        cy.doRequest("GET", "/arvados/v1/collections", null, {
             filters: `[["name", "=", "${colName}"]]`,
-            include_old_versions: true
+            include_old_versions: true,
         })
-            .its('body.items').as('collections')
+            .its("body.items")
+            .as("collections")
             .then(function () {
                 expect(this.collections).to.have.lengthOf(2);
                 this.collections.map(function (aCollection) {
@@ -573,82 +597,80 @@ describe('Collection panel tests', function () {
                     }
                 });
                 // Check the old version displays as what it is.
-                cy.loginAs(activeUser)
+                cy.loginAs(activeUser);
                 cy.goToPath(`/collections/${oldVersionUuid}`);
 
-                cy.get('[data-cy=collection-info-panel]').should('contain', 'This is an old version');
-                cy.get('[data-cy=read-only-icon]').should('exist');
-                cy.get('[data-cy=collection-info-panel]').should('contain', colName);
-                cy.get('[data-cy=collection-files-panel]').should('contain', 'bar');
+                cy.get("[data-cy=collection-info-panel]").should("contain", "This is an old version");
+                cy.get("[data-cy=read-only-icon]").should("exist");
+                cy.get("[data-cy=collection-info-panel]").should("contain", colName);
+                cy.get("[data-cy=collection-files-panel]").should("contain", "bar");
             });
     });
 
-    it('views & edits storage classes data', function () {
-        const colName= `Test Collection ${Math.floor(Math.random() * 999999)}`;
+    it("views & edits storage classes data", function () {
+        const colName = `Test Collection ${Math.floor(Math.random() * 999999)}`;
         cy.createCollection(adminUser.token, {
             name: colName,
             owner_uuid: activeUser.user.uuid,
             manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:some-file\n",
-        }).as('collection').then(function () {
-            expect(this.collection.storage_classes_desired).to.deep.equal(['default'])
-
-            cy.loginAs(activeUser)
-            cy.goToPath(`/collections/${this.collection.uuid}`);
-
-            // Initial check: it should show the 'default' storage class
-            cy.get('[data-cy=collection-info-panel]')
-                .should('contain', 'Storage classes')
-                .and('contain', 'default')
-                .and('not.contain', 'foo')
-                .and('not.contain', 'bar');
-            // Edit collection: add storage class 'foo'
-            cy.get('[data-cy=collection-panel-options-btn]').click();
-            cy.get('[data-cy=context-menu]').contains('Edit collection').click();
-            cy.get('[data-cy=form-dialog]')
-                .should('contain', 'Edit Collection')
-                .and('contain', 'Storage classes')
-                .and('contain', 'default')
-                .and('contain', 'foo')
-                .and('contain', 'bar')
-                .within(() => {
-                    cy.get('[data-cy=checkbox-foo]').click();
-                });
-            cy.get('[data-cy=form-submit-btn]').click();
-            cy.get('[data-cy=collection-info-panel]')
-                .should('contain', 'default')
-                .and('contain', 'foo')
-                .and('not.contain', 'bar');
-            cy.doRequest('GET', `/arvados/v1/collections/${this.collection.uuid}`)
-                .its('body').as('updatedCollection')
-                .then(function () {
-                    expect(this.updatedCollection.storage_classes_desired).to.deep.equal(['default', 'foo']);
-                });
-            // Edit collection: remove storage class 'default'
-            cy.get('[data-cy=collection-panel-options-btn]').click();
-            cy.get('[data-cy=context-menu]').contains('Edit collection').click();
-            cy.get('[data-cy=form-dialog]')
-                .should('contain', 'Edit Collection')
-                .and('contain', 'Storage classes')
-                .and('contain', 'default')
-                .and('contain', 'foo')
-                .and('contain', 'bar')
-                .within(() => {
-                    cy.get('[data-cy=checkbox-default]').click();
-                });
-            cy.get('[data-cy=form-submit-btn]').click();
-            cy.get('[data-cy=collection-info-panel]')
-                .should('not.contain', 'default')
-                .and('contain', 'foo')
-                .and('not.contain', 'bar');
-            cy.doRequest('GET', `/arvados/v1/collections/${this.collection.uuid}`)
-                .its('body').as('updatedCollection')
-                .then(function () {
-                    expect(this.updatedCollection.storage_classes_desired).to.deep.equal(['foo']);
-                });
         })
+            .as("collection")
+            .then(function () {
+                expect(this.collection.storage_classes_desired).to.deep.equal(["default"]);
+
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${this.collection.uuid}`);
+
+                // Initial check: it should show the 'default' storage class
+                cy.get("[data-cy=collection-info-panel]")
+                    .should("contain", "Storage classes")
+                    .and("contain", "default")
+                    .and("not.contain", "foo")
+                    .and("not.contain", "bar");
+                // Edit collection: add storage class 'foo'
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Edit collection").click();
+                cy.get("[data-cy=form-dialog]")
+                    .should("contain", "Edit Collection")
+                    .and("contain", "Storage classes")
+                    .and("contain", "default")
+                    .and("contain", "foo")
+                    .and("contain", "bar")
+                    .within(() => {
+                        cy.get("[data-cy=checkbox-foo]").click();
+                    });
+                cy.get("[data-cy=form-submit-btn]").click();
+                cy.get("[data-cy=collection-info-panel]").should("contain", "default").and("contain", "foo").and("not.contain", "bar");
+                cy.doRequest("GET", `/arvados/v1/collections/${this.collection.uuid}`)
+                    .its("body")
+                    .as("updatedCollection")
+                    .then(function () {
+                        expect(this.updatedCollection.storage_classes_desired).to.deep.equal(["default", "foo"]);
+                    });
+                // Edit collection: remove storage class 'default'
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Edit collection").click();
+                cy.get("[data-cy=form-dialog]")
+                    .should("contain", "Edit Collection")
+                    .and("contain", "Storage classes")
+                    .and("contain", "default")
+                    .and("contain", "foo")
+                    .and("contain", "bar")
+                    .within(() => {
+                        cy.get("[data-cy=checkbox-default]").click();
+                    });
+                cy.get("[data-cy=form-submit-btn]").click();
+                cy.get("[data-cy=collection-info-panel]").should("not.contain", "default").and("contain", "foo").and("not.contain", "bar");
+                cy.doRequest("GET", `/arvados/v1/collections/${this.collection.uuid}`)
+                    .its("body")
+                    .as("updatedCollection")
+                    .then(function () {
+                        expect(this.updatedCollection.storage_classes_desired).to.deep.equal(["foo"]);
+                    });
+            });
     });
 
-    it('moves a collection to a different project', function () {
+    it("moves a collection to a different project", function () {
         const collName = `Test Collection ${Math.floor(Math.random() * 999999)}`;
         const projName = `Test Project ${Math.floor(Math.random() * 999999)}`;
         const fileName = `Test_File_${Math.floor(Math.random() * 999999)}`;
@@ -657,86 +679,73 @@ describe('Collection panel tests', function () {
             name: collName,
             owner_uuid: activeUser.user.uuid,
             manifest_text: `. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n`,
-        }).as('testCollection');
+        }).as("testCollection");
         cy.createGroup(adminUser.token, {
             name: projName,
-            group_class: 'project',
+            group_class: "project",
             owner_uuid: activeUser.user.uuid,
-        }).as('testProject');
+        }).as("testProject");
 
-        cy.getAll('@testCollection', '@testProject')
-            .then(function ([testCollection, testProject]) {
-                cy.loginAs(activeUser);
-                cy.goToPath(`/collections/${testCollection.uuid}`);
-                cy.get('[data-cy=collection-files-panel]').should('contain', fileName);
-                cy.get('[data-cy=collection-info-panel]')
-                    .should('not.contain', projName)
-                    .and('not.contain', testProject.uuid);
-                cy.get('[data-cy=collection-panel-options-btn]').click();
-                cy.get('[data-cy=context-menu]').contains('Move to').click();
-                cy.get('[data-cy=form-dialog]')
-                    .should('contain', 'Move to')
-                    .within(() => {
-                        // must use .then to avoid selecting instead of expanding https://github.com/cypress-io/cypress/issues/5529
-                        cy.get('[data-cy=projects-tree-home-tree-picker]')
-                            .find('i')
-                            .then(el => el.click());
-                        cy.get('[data-cy=projects-tree-home-tree-picker]')
-                            .contains(projName)
-                            .click();
-                    });
-                cy.get('[data-cy=form-submit-btn]').click();
-                cy.get('[data-cy=snackbar]')
-                    .contains('Collection has been moved')
-                cy.get('[data-cy=collection-info-panel]')
-                    .contains(projName).and('contain', testProject.uuid);
-                // Double check that the collection is in the project
-                cy.goToPath(`/projects/${testProject.uuid}`);
-                cy.waitForDom().get('[data-cy=project-panel]').should('contain', collName);
-            });
+        cy.getAll("@testCollection", "@testProject").then(function ([testCollection, testProject]) {
+            cy.loginAs(activeUser);
+            cy.goToPath(`/collections/${testCollection.uuid}`);
+            cy.get("[data-cy=collection-files-panel]").should("contain", fileName);
+            cy.get("[data-cy=collection-info-panel]").should("not.contain", projName).and("not.contain", testProject.uuid);
+            cy.get("[data-cy=collection-panel-options-btn]").click();
+            cy.get("[data-cy=context-menu]").contains("Move to").click();
+            cy.get("[data-cy=form-dialog]")
+                .should("contain", "Move to")
+                .within(() => {
+                    // must use .then to avoid selecting instead of expanding https://github.com/cypress-io/cypress/issues/5529
+                    cy.get("[data-cy=projects-tree-home-tree-picker]")
+                        .find("i")
+                        .then(el => el.click());
+                    cy.get("[data-cy=projects-tree-home-tree-picker]").contains(projName).click();
+                });
+            cy.get("[data-cy=form-submit-btn]").click();
+            cy.get("[data-cy=snackbar]").contains("Collection has been moved");
+            cy.get("[data-cy=collection-info-panel]").contains(projName).and("contain", testProject.uuid);
+            // Double check that the collection is in the project
+            cy.goToPath(`/projects/${testProject.uuid}`);
+            cy.waitForDom().get("[data-cy=project-panel]").should("contain", collName);
+        });
     });
 
-    it('automatically updates the collection UI contents without using the Refresh button', function () {
+    it("automatically updates the collection UI contents without using the Refresh button", function () {
         const collName = `Test Collection ${Math.floor(Math.random() * 999999)}`;
 
         cy.createCollection(adminUser.token, {
             name: collName,
             owner_uuid: activeUser.user.uuid,
-        }).as('testCollection');
+        }).as("testCollection");
 
-        cy.getAll('@testCollection').then(function ([testCollection]) {
+        cy.getAll("@testCollection").then(function ([testCollection]) {
             cy.loginAs(activeUser);
 
-            const files = [
-                "foobar",
-                "anotherFile",
-                "",
-                "finalName",
-            ];
+            const files = ["foobar", "anotherFile", "", "finalName"];
 
             cy.goToPath(`/collections/${testCollection.uuid}`);
-            cy.get('[data-cy=collection-files-panel]').should('contain', 'This collection is empty');
-            cy.get('[data-cy=collection-files-panel]').should('not.contain', files[0]);
-            cy.get('[data-cy=collection-info-panel]').should('contain', collName);
+            cy.get("[data-cy=collection-files-panel]").should("contain", "This collection is empty");
+            cy.get("[data-cy=collection-files-panel]").should("not.contain", files[0]);
+            cy.get("[data-cy=collection-info-panel]").should("contain", collName);
 
             files.map((fileName, i, files) => {
                 cy.updateCollection(adminUser.token, testCollection.uuid, {
-                    name: `${collName + ' updated'}`,
+                    name: `${collName + " updated"}`,
                     manifest_text: fileName ? `. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n` : "",
-                }).as('updatedCollection');
-                cy.getAll('@updatedCollection').then(function ([updatedCollection]) {
-                    expect(updatedCollection.name).to.equal(`${collName + ' updated'}`);
-                    cy.get('[data-cy=collection-info-panel]').should('contain', updatedCollection.name);
+                }).as("updatedCollection");
+                cy.getAll("@updatedCollection").then(function ([updatedCollection]) {
+                    expect(updatedCollection.name).to.equal(`${collName + " updated"}`);
+                    cy.get("[data-cy=collection-info-panel]").should("contain", updatedCollection.name);
                     fileName
-                        ? cy.get('[data-cy=collection-files-panel]').should('contain', fileName)
-                        : cy.get('[data-cy=collection-files-panel]').should('not.contain', files[i-1]);;
+                        ? cy.get("[data-cy=collection-files-panel]").should("contain", fileName)
+                        : cy.get("[data-cy=collection-files-panel]").should("not.contain", files[i - 1]);
                 });
             });
-
         });
     });
 
-    it('makes a copy of an existing collection', function() {
+    it("makes a copy of an existing collection", function () {
         const collName = `Test Collection ${Math.floor(Math.random() * 999999)}`;
         const copyName = `Copy of: ${collName}`;
 
@@ -744,32 +753,28 @@ describe('Collection panel tests', function () {
             name: collName,
             owner_uuid: activeUser.user.uuid,
             manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:some-file\n",
-        }).as('collection').then(function () {
-            cy.loginAs(activeUser)
-            cy.goToPath(`/collections/${this.collection.uuid}`);
-            cy.get('[data-cy=collection-files-panel]')
-                .should('contain', 'some-file');
-            cy.get('[data-cy=collection-panel-options-btn]').click();
-            cy.get('[data-cy=context-menu]').contains('Make a copy').click();
-            cy.get('[data-cy=form-dialog]')
-                .should('contain', 'Make a copy')
-                .within(() => {
-                    cy.get('[data-cy=projects-tree-home-tree-picker]')
-                        .contains('Projects')
-                        .click();
-                    cy.get('[data-cy=form-submit-btn]').click();
-                });
-            cy.get('[data-cy=snackbar]')
-                .contains('Collection has been copied.')
-            cy.get('[data-cy=snackbar-goto-action]').click();
-            cy.get('[data-cy=project-panel]')
-                .contains(copyName).click();
-            cy.get('[data-cy=collection-files-panel]')
-                .should('contain', 'some-file');
-        });
+        })
+            .as("collection")
+            .then(function () {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${this.collection.uuid}`);
+                cy.get("[data-cy=collection-files-panel]").should("contain", "some-file");
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Make a copy").click();
+                cy.get("[data-cy=form-dialog]")
+                    .should("contain", "Make a copy")
+                    .within(() => {
+                        cy.get("[data-cy=projects-tree-home-tree-picker]").contains("Projects").click();
+                        cy.get("[data-cy=form-submit-btn]").click();
+                    });
+                cy.get("[data-cy=snackbar]").contains("Collection has been copied.");
+                cy.get("[data-cy=snackbar-goto-action]").click();
+                cy.get("[data-cy=project-panel]").contains(copyName).click();
+                cy.get("[data-cy=collection-files-panel]").should("contain", "some-file");
+            });
     });
 
-    it('uses the collection version browser to view a previous version', function () {
+    it("uses the collection version browser to view a previous version", function () {
         const colName = `Test Collection ${Math.floor(Math.random() * 999999)}`;
 
         // Creates the collection using the admin token so we can set up
@@ -778,386 +783,562 @@ describe('Collection panel tests', function () {
             name: colName,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
         })
-            .as('collection').then(function () {
+            .as("collection")
+            .then(function () {
                 // Visit collection, check basic information
-                cy.loginAs(activeUser)
+                cy.loginAs(activeUser);
                 cy.goToPath(`/collections/${this.collection.uuid}`);
 
-                cy.get('[data-cy=collection-info-panel]').should('not.contain', 'This is an old version');
-                cy.get('[data-cy=read-only-icon]').should('not.exist');
-                cy.get('[data-cy=collection-version-number]').should('contain', '1');
-                cy.get('[data-cy=collection-info-panel]').should('contain', colName);
-                cy.get('[data-cy=collection-files-panel]').should('contain', 'foo').and('contain', 'bar');
+                cy.get("[data-cy=collection-info-panel]").should("not.contain", "This is an old version");
+                cy.get("[data-cy=read-only-icon]").should("not.exist");
+                cy.get("[data-cy=collection-version-number]").should("contain", "1");
+                cy.get("[data-cy=collection-info-panel]").should("contain", colName);
+                cy.get("[data-cy=collection-files-panel]").should("contain", "foo").and("contain", "bar");
 
                 // Modify collection, expect version number change
-                cy.get('[data-cy=collection-files-panel]').contains('foo').rightclick();
-                cy.get('[data-cy=context-menu]').contains('Remove').click();
-                cy.get('[data-cy=confirmation-dialog]').should('contain', 'Removing file');
-                cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
-                cy.get('[data-cy=collection-version-number]').should('contain', '2');
-                cy.get('[data-cy=collection-files-panel]').should('not.contain', 'foo').and('contain', 'bar');
+                cy.get("[data-cy=collection-files-panel]").contains("foo").rightclick();
+                cy.get("[data-cy=context-menu]").contains("Remove").click();
+                cy.get("[data-cy=confirmation-dialog]").should("contain", "Removing file");
+                cy.get("[data-cy=confirmation-dialog-ok-btn]").click();
+                cy.get("[data-cy=collection-version-number]").should("contain", "2");
+                cy.get("[data-cy=collection-files-panel]").should("not.contain", "foo").and("contain", "bar");
 
                 // Click on version number, check version browser. Click on past version.
-                cy.get('[data-cy=collection-version-browser]').should('not.exist');
-                cy.get('[data-cy=collection-version-number]').contains('2').click();
-                cy.get('[data-cy=collection-version-browser]')
-                    .should('contain', 'Nr').and('contain', 'Size').and('contain', 'Date')
+                cy.get("[data-cy=collection-version-browser]").should("not.exist");
+                cy.get("[data-cy=collection-version-number]").contains("2").click();
+                cy.get("[data-cy=collection-version-browser]")
+                    .should("contain", "Nr")
+                    .and("contain", "Size")
+                    .and("contain", "Date")
                     .within(() => {
                         // Version 1: 6 bytes in size
-                        cy.get('[data-cy=collection-version-browser-select-1]')
-                            .should('contain', '1')
-                            .and('contain', '6 B')
-                            .and('contain', adminUser.user.uuid);
+                        cy.get("[data-cy=collection-version-browser-select-1]")
+                            .should("contain", "1")
+                            .and("contain", "6 B")
+                            .and("contain", adminUser.user.full_name);
                         // Version 2: 3 bytes in size (one file removed)
-                        cy.get('[data-cy=collection-version-browser-select-2]')
-                            .should('contain', '2')
-                            .and('contain', '3 B')
-                            .and('contain', activeUser.user.full_name);
-                        cy.get('[data-cy=collection-version-browser-select-3]')
-                            .should('not.exist');
-                        cy.get('[data-cy=collection-version-browser-select-1]')
-                            .click();
+                        cy.get("[data-cy=collection-version-browser-select-2]")
+                            .should("contain", "2")
+                            .and("contain", "3 B")
+                            .and("contain", activeUser.user.full_name);
+                        cy.get("[data-cy=collection-version-browser-select-3]").should("not.exist");
+                        cy.get("[data-cy=collection-version-browser-select-1]").click();
                     });
-                cy.get('[data-cy=collection-info-panel]').should('contain', 'This is an old version');
-                cy.get('[data-cy=read-only-icon]').should('exist');
-                cy.get('[data-cy=collection-version-number]').should('contain', '1');
-                cy.get('[data-cy=collection-info-panel]').should('contain', colName);
-                cy.get('[data-cy=collection-files-panel]')
-                    .should('contain', 'foo').and('contain', 'bar');
+                cy.get("[data-cy=collection-info-panel]").should("contain", "This is an old version");
+                cy.get("[data-cy=read-only-icon]").should("exist");
+                cy.get("[data-cy=collection-version-number]").should("contain", "1");
+                cy.get("[data-cy=collection-info-panel]").should("contain", colName);
+                cy.get("[data-cy=collection-files-panel]").should("contain", "foo").and("contain", "bar");
 
                 // Check that only old collection action are available on context menu
-                cy.get('[data-cy=collection-panel-options-btn]').click();
-                cy.get('[data-cy=context-menu]')
-                    .should('contain', 'Restore version')
-                    .and('not.contain', 'Add to favorites');
-                cy.get('body').click(); // Collapse the menu avoiding details panel expansion
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").should("contain", "Restore version").and("not.contain", "Add to favorites");
+                cy.get("body").click(); // Collapse the menu avoiding details panel expansion
 
                 // Click on "head version" link, confirm that it's the latest version.
-                cy.get('[data-cy=collection-info-panel]').contains('head version').click();
-                cy.get('[data-cy=collection-info-panel]')
-                    .should('not.contain', 'This is an old version');
-                cy.get('[data-cy=read-only-icon]').should('not.exist');
-                cy.get('[data-cy=collection-version-number]').should('contain', '2');
-                cy.get('[data-cy=collection-info-panel]').should('contain', colName);
-                cy.get('[data-cy=collection-files-panel]').
-                    should('not.contain', 'foo').and('contain', 'bar');
+                cy.get("[data-cy=collection-info-panel]").contains("head version").click();
+                cy.get("[data-cy=collection-info-panel]").should("not.contain", "This is an old version");
+                cy.get("[data-cy=read-only-icon]").should("not.exist");
+                cy.get("[data-cy=collection-version-number]").should("contain", "2");
+                cy.get("[data-cy=collection-info-panel]").should("contain", colName);
+                cy.get("[data-cy=collection-files-panel]").should("not.contain", "foo").and("contain", "bar");
 
                 // Check that old collection action isn't available on context menu
-                cy.get('[data-cy=collection-panel-options-btn]').click()
-                cy.get('[data-cy=context-menu]').should('not.contain', 'Restore version')
-                cy.get('body').click(); // Collapse the menu avoiding details panel expansion
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").should("not.contain", "Restore version");
+                cy.get("body").click(); // Collapse the menu avoiding details panel expansion
 
                 // Make another change, confirm new version.
-                cy.get('[data-cy=collection-panel-options-btn]').click();
-                cy.get('[data-cy=context-menu]').contains('Edit collection').click();
-                cy.get('[data-cy=form-dialog]')
-                    .should('contain', 'Edit Collection')
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Edit collection").click();
+                cy.get("[data-cy=form-dialog]")
+                    .should("contain", "Edit Collection")
                     .within(() => {
                         // appends some text
-                        cy.get('input').first().type(' renamed');
+                        cy.get("input").first().type(" renamed");
                     });
-                cy.get('[data-cy=form-submit-btn]').click();
-                cy.get('[data-cy=collection-info-panel]')
-                    .should('not.contain', 'This is an old version');
-                cy.get('[data-cy=read-only-icon]').should('not.exist');
-                cy.get('[data-cy=collection-version-number]').should('contain', '3');
-                cy.get('[data-cy=collection-info-panel]').should('contain', colName + ' renamed');
-                cy.get('[data-cy=collection-files-panel]')
-                    .should('not.contain', 'foo').and('contain', 'bar');
-                cy.get('[data-cy=collection-version-browser-select-3]')
-                    .should('contain', '3').and('contain', '3 B');
+                cy.get("[data-cy=form-submit-btn]").click();
+                cy.get("[data-cy=collection-info-panel]").should("not.contain", "This is an old version");
+                cy.get("[data-cy=read-only-icon]").should("not.exist");
+                cy.get("[data-cy=collection-version-number]").should("contain", "3");
+                cy.get("[data-cy=collection-info-panel]").should("contain", colName + " renamed");
+                cy.get("[data-cy=collection-files-panel]").should("not.contain", "foo").and("contain", "bar");
+                cy.get("[data-cy=collection-version-browser-select-3]").should("contain", "3").and("contain", "3 B");
 
                 // Check context menus on version browser
-                cy.get('[data-cy=collection-version-browser-select-3]').rightclick()
-                cy.get('[data-cy=context-menu]')
-                    .should('contain', 'Add to favorites')
-                    .and('contain', 'Make a copy')
-                    .and('contain', 'Edit collection');
-                cy.get('body').click();
+                cy.waitForDom();
+                cy.get("[data-cy=collection-version-browser-select-3]").rightclick();
+                cy.get("[data-cy=context-menu]")
+                    .should("contain", "Add to favorites")
+                    .and("contain", "Make a copy")
+                    .and("contain", "Edit collection");
+                cy.get("body").click();
                 // (and now an old version...)
-                cy.get('[data-cy=collection-version-browser-select-1]').rightclick()
-                cy.get('[data-cy=context-menu]')
-                    .should('not.contain', 'Add to favorites')
-                    .and('contain', 'Make a copy')
-                    .and('not.contain', 'Edit collection');
-                cy.get('body').click();
+                cy.get("[data-cy=collection-version-browser-select-1]").rightclick();
+                cy.get("[data-cy=context-menu]")
+                    .should("not.contain", "Add to favorites")
+                    .and("contain", "Make a copy")
+                    .and("not.contain", "Edit collection");
+                cy.get("body").click();
 
                 // Restore first version
-                cy.get('[data-cy=collection-version-browser]').within(() => {
-                    cy.get('[data-cy=collection-version-browser-select-1]').click();
+                cy.get("[data-cy=collection-version-browser]").within(() => {
+                    cy.get("[data-cy=collection-version-browser-select-1]").click();
                 });
-                cy.get('[data-cy=collection-panel-options-btn]').click()
-                cy.get('[data-cy=context-menu]').contains('Restore version').click();
-                cy.get('[data-cy=confirmation-dialog]').should('contain', 'Restore version');
-                cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
-                cy.get('[data-cy=collection-info-panel]')
-                    .should('not.contain', 'This is an old version');
-                cy.get('[data-cy=collection-version-number]').should('contain', '4');
-                cy.get('[data-cy=collection-info-panel]').should('contain', colName);
-                cy.get('[data-cy=collection-files-panel]')
-                    .should('contain', 'foo').and('contain', 'bar');
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Restore version").click();
+                cy.get("[data-cy=confirmation-dialog]").should("contain", "Restore version");
+                cy.get("[data-cy=confirmation-dialog-ok-btn]").click();
+                cy.get("[data-cy=collection-info-panel]").should("not.contain", "This is an old version");
+                cy.get("[data-cy=collection-version-number]").should("contain", "4");
+                cy.get("[data-cy=collection-info-panel]").should("contain", colName);
+                cy.get("[data-cy=collection-files-panel]").should("contain", "foo").and("contain", "bar");
             });
     });
 
-    it('creates collection from selected files of another collection', () => {
+    it("copies selected files into new collection", () => {
         cy.createCollection(adminUser.token, {
             name: `Test Collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
         })
-            .as('collection').then(function () {
+            .as("collection")
+            .then(function () {
                 // Visit collection, check basic information
-                cy.loginAs(activeUser)
+                cy.loginAs(activeUser);
                 cy.goToPath(`/collections/${this.collection.uuid}`);
 
-                cy.get('[data-cy=collection-files-panel]').within(() => {
-                    cy.get('input[type=checkbox]').first().click();
+                cy.get("[data-cy=collection-files-panel]").within(() => {
+                    cy.get("input[type=checkbox]").first().click();
                 });
 
-                cy.get('[data-cy=collection-files-panel-options-btn]').click();
-                cy.get('[data-cy=context-menu]').contains('Create a new collection with selected').click();
+                cy.get("[data-cy=collection-files-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Copy selected into new collection").click();
 
-                cy.get('[data-cy=form-dialog]').contains('Projects').click();
+                cy.get("[data-cy=form-dialog]").contains("Projects").click();
 
-                cy.get('[data-cy=form-submit-btn]').click();
+                cy.get("[data-cy=form-submit-btn]").click();
 
-                cy.waitForDom().get('.layout-pane-primary', { timeout: 12000 }).contains('Projects').click();
+                cy.waitForDom().get(".layout-pane-primary", { timeout: 12000 }).contains("Projects").click();
 
-                cy.get('main').contains(`Files extracted from: ${this.collection.name}`).should('exist');
+                cy.waitForDom().get("main").contains(`Files extracted from: ${this.collection.name}`).click();
+                cy.get("[data-cy=collection-files-panel]").and("contain", "bar");
             });
     });
 
-    it('creates new collection with properties on home project', function () {
+    it("copies selected files into existing collection", () => {
+        cy.createCollection(adminUser.token, {
+            name: `Test Collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
+        }).as("sourceCollection");
+
+        cy.createCollection(adminUser.token, {
+            name: `Destination Collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: "",
+        }).as("destinationCollection");
+
+        cy.getAll("@sourceCollection", "@destinationCollection").then(function ([sourceCollection, destinationCollection]) {
+            // Visit collection, check basic information
+            cy.loginAs(activeUser);
+            cy.goToPath(`/collections/${sourceCollection.uuid}`);
+
+            cy.get("[data-cy=collection-files-panel]").within(() => {
+                cy.get("input[type=checkbox]").first().click();
+            });
+
+            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+            cy.get("[data-cy=context-menu]").contains("Copy selected into existing collection").click();
+
+            cy.get("[data-cy=form-dialog]").contains(destinationCollection.name).click();
+
+            cy.get("[data-cy=form-submit-btn]").click();
+            cy.wait(2000);
+
+            cy.goToPath(`/collections/${destinationCollection.uuid}`);
+
+            cy.get("main").contains(destinationCollection.name).should("exist");
+            cy.get("[data-cy=collection-files-panel]").and("contain", "bar");
+        });
+    });
+
+    it("copies selected files into separate collections", () => {
+        cy.createCollection(adminUser.token, {
+            name: `Test Collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
+        }).as("sourceCollection");
+
+        cy.getAll("@sourceCollection").then(function ([sourceCollection]) {
+            // Visit collection, check basic information
+            cy.loginAs(activeUser);
+            cy.goToPath(`/collections/${sourceCollection.uuid}`);
+
+            // Select both files
+            cy.waitForDom()
+                .get("[data-cy=collection-files-panel]")
+                .within(() => {
+                    cy.get("input[type=checkbox]").first().click();
+                    cy.get("input[type=checkbox]").last().click();
+                });
+
+            // Copy to separate collections
+            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+            cy.get("[data-cy=context-menu]").contains("Copy selected into separate collections").click();
+            cy.get("[data-cy=form-dialog]").contains("Projects").click();
+            cy.get("[data-cy=form-submit-btn]").click();
+
+            // Verify created collections
+            cy.waitForDom().get(".layout-pane-primary", { timeout: 12000 }).contains("Projects").click();
+            cy.get("main").contains(`File copied from collection ${sourceCollection.name}/foo`).click();
+            cy.get("[data-cy=collection-files-panel]").and("contain", "foo");
+            cy.get(".layout-pane-primary").contains("Projects").click();
+            cy.get("main").contains(`File copied from collection ${sourceCollection.name}/bar`).click();
+            cy.get("[data-cy=collection-files-panel]").and("contain", "bar");
+
+            // Verify separate collection menu items not present when single file selected
+            // Wait for dom for collection to re-render
+            cy.waitForDom()
+                .get("[data-cy=collection-files-panel]")
+                .within(() => {
+                    cy.get("input[type=checkbox]").first().click();
+                });
+            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+            cy.get("[data-cy=context-menu]").should("not.contain", "Copy selected into separate collections");
+            cy.get("[data-cy=context-menu]").should("not.contain", "Move selected into separate collections");
+        });
+    });
+
+    it("moves selected files into new collection", () => {
+        cy.createCollection(adminUser.token, {
+            name: `Test Collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
+        })
+            .as("collection")
+            .then(function () {
+                // Visit collection, check basic information
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${this.collection.uuid}`);
+
+                cy.get("[data-cy=collection-files-panel]").within(() => {
+                    cy.get("input[type=checkbox]").first().click();
+                });
+
+                cy.get("[data-cy=collection-files-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Move selected into new collection").click();
+
+                cy.get("[data-cy=form-dialog]").contains("Projects").click();
+
+                cy.get("[data-cy=form-submit-btn]").click();
+
+                cy.waitForDom().get(".layout-pane-primary", { timeout: 12000 }).contains("Projects").click();
+
+                cy.get("main").contains(`Files moved from: ${this.collection.name}`).click();
+                cy.get("[data-cy=collection-files-panel]").and("contain", "bar");
+            });
+    });
+
+    it("moves selected files into existing collection", () => {
+        cy.createCollection(adminUser.token, {
+            name: `Test Collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
+        }).as("sourceCollection");
+
+        cy.createCollection(adminUser.token, {
+            name: `Destination Collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: "",
+        }).as("destinationCollection");
+
+        cy.getAll("@sourceCollection", "@destinationCollection").then(function ([sourceCollection, destinationCollection]) {
+            // Visit collection, check basic information
+            cy.loginAs(activeUser);
+            cy.goToPath(`/collections/${sourceCollection.uuid}`);
+
+            cy.get("[data-cy=collection-files-panel]").within(() => {
+                cy.get("input[type=checkbox]").first().click();
+            });
+
+            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+            cy.get("[data-cy=context-menu]").contains("Move selected into existing collection").click();
+
+            cy.get("[data-cy=form-dialog]").contains(destinationCollection.name).click();
+
+            cy.get("[data-cy=form-submit-btn]").click();
+            cy.wait(2000);
+
+            cy.goToPath(`/collections/${destinationCollection.uuid}`);
+
+            cy.get("main").contains(destinationCollection.name).should("exist");
+            cy.get("[data-cy=collection-files-panel]").and("contain", "bar");
+        });
+    });
+
+    it("moves selected files into separate collections", () => {
+        cy.createCollection(adminUser.token, {
+            name: `Test Collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
+        }).as("sourceCollection");
+
+        cy.getAll("@sourceCollection").then(function ([sourceCollection]) {
+            // Visit collection, check basic information
+            cy.loginAs(activeUser);
+            cy.goToPath(`/collections/${sourceCollection.uuid}`);
+
+            // Select both files
+            cy.get("[data-cy=collection-files-panel]").within(() => {
+                cy.get("input[type=checkbox]").first().click();
+                cy.get("input[type=checkbox]").last().click();
+            });
+
+            // Copy to separate collections
+            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+            cy.get("[data-cy=context-menu]").contains("Move selected into separate collections").click();
+            cy.get("[data-cy=form-dialog]").contains("Projects").click();
+            cy.get("[data-cy=form-submit-btn]").click();
+
+            // Verify created collections
+            cy.waitForDom().get(".layout-pane-primary", { timeout: 12000 }).contains("Projects").click();
+            cy.get("main").contains(`File moved from collection ${sourceCollection.name}/foo`).click();
+            cy.get("[data-cy=collection-files-panel]").and("contain", "foo");
+            cy.get(".layout-pane-primary").contains("Projects").click();
+            cy.get("main").contains(`File moved from collection ${sourceCollection.name}/bar`).click();
+            cy.get("[data-cy=collection-files-panel]").and("contain", "bar");
+        });
+    });
+
+    it("creates new collection with properties on home project", function () {
         cy.loginAs(activeUser);
         cy.goToPath(`/projects/${activeUser.user.uuid}`);
-        cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects');
-        cy.get('[data-cy=breadcrumb-last]').should('not.exist');
+        cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
+        cy.get("[data-cy=breadcrumb-last]").should("not.exist");
         // Create new collection
-        cy.get('[data-cy=side-panel-button]').click();
-        cy.get('[data-cy=side-panel-new-collection]').click();
+        cy.get("[data-cy=side-panel-button]").click();
+        cy.get("[data-cy=side-panel-new-collection]").click();
         // Name between brackets tests bugfix #17582
         const collName = `[Test collection (${Math.floor(999999 * Math.random())})]`;
 
         // Select a storage class.
-        cy.get('[data-cy=form-dialog]')
-            .should('contain', 'New collection')
-            .and('contain', 'Storage classes')
-            .and('contain', 'default')
-            .and('contain', 'foo')
-            .and('contain', 'bar')
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "New collection")
+            .and("contain", "Storage classes")
+            .and("contain", "default")
+            .and("contain", "foo")
+            .and("contain", "bar")
             .within(() => {
-                cy.get('[data-cy=parent-field]').within(() => {
-                    cy.get('input').should('have.value', 'Home project');
+                cy.get("[data-cy=parent-field]").within(() => {
+                    cy.get("input").should("have.value", "Home project");
                 });
-                cy.get('[data-cy=name-field]').within(() => {
-                    cy.get('input').type(collName);
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(collName);
                 });
-                cy.get('[data-cy=checkbox-foo]').click();
-            })
+                cy.get("[data-cy=checkbox-foo]").click();
+            });
 
         // Add a property.
         // Key: Color (IDTAGCOLORS) - Value: Magenta (IDVALCOLORS3)
-        cy.get('[data-cy=form-dialog]').should('not.contain', 'Color: Magenta');
-        cy.get('[data-cy=resource-properties-form]').within(() => {
-            cy.get('[data-cy=property-field-key]').within(() => {
-                cy.get('input').type('Color');
+        cy.get("[data-cy=form-dialog]").should("not.contain", "Color: Magenta");
+        cy.get("[data-cy=resource-properties-form]").within(() => {
+            cy.get("[data-cy=property-field-key]").within(() => {
+                cy.get("input").type("Color");
             });
-            cy.get('[data-cy=property-field-value]').within(() => {
-                cy.get('input').type('Magenta');
+            cy.get("[data-cy=property-field-value]").within(() => {
+                cy.get("input").type("Magenta");
             });
             cy.root().submit();
         });
         // Confirm proper vocabulary labels are displayed on the UI.
-        cy.get('[data-cy=form-dialog]').should('contain', 'Color: Magenta');
+        cy.get("[data-cy=form-dialog]").should("contain", "Color: Magenta");
 
         // Value field should not complain about being required just after
         // adding a new property. See #19732
-        cy.get('[data-cy=form-dialog]').should('not.contain', 'This field is required');
+        cy.get("[data-cy=form-dialog]").should("not.contain", "This field is required");
 
-        cy.get('[data-cy=form-submit-btn]').click();
+        cy.get("[data-cy=form-submit-btn]").click();
         // Confirm that the user was taken to the newly created collection
-        cy.get('[data-cy=form-dialog]').should('not.exist');
-        cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects');
-        cy.get('[data-cy=breadcrumb-last]').should('contain', collName);
-        cy.get('[data-cy=collection-info-panel]')
-            .should('contain', 'default')
-            .and('contain', 'foo')
-            .and('contain', 'Color: Magenta')
-            .and('not.contain', 'bar');
+        cy.get("[data-cy=form-dialog]").should("not.exist");
+        cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
+        cy.get("[data-cy=breadcrumb-last]").should("contain", collName);
+        cy.get("[data-cy=collection-info-panel]")
+            .should("contain", "default")
+            .and("contain", "foo")
+            .and("contain", "Color: Magenta")
+            .and("not.contain", "bar");
         // Confirm that the collection's properties has the real values.
-        cy.doRequest('GET', '/arvados/v1/collections', null, {
+        cy.doRequest("GET", "/arvados/v1/collections", null, {
             filters: `[["name", "=", "${collName}"]]`,
         })
-        .its('body.items').as('collections')
-        .then(function() {
-            expect(this.collections).to.have.lengthOf(1);
-            expect(this.collections[0].properties).to.have.property(
-                'IDTAGCOLORS', 'IDVALCOLORS3');
-        });
+            .its("body.items")
+            .as("collections")
+            .then(function () {
+                expect(this.collections).to.have.lengthOf(1);
+                expect(this.collections[0].properties).to.have.property("IDTAGCOLORS", "IDVALCOLORS3");
+            });
     });
 
-    it('shows responsible person for collection if available', () => {
+    it("shows responsible person for collection if available", () => {
         cy.createCollection(adminUser.token, {
             name: `Test collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
-        }).as('testCollection1');
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        }).as("testCollection1");
 
         cy.createCollection(adminUser.token, {
             name: `Test collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: adminUser.user.uuid,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
-        }).as('testCollection2').then(function (testCollection2) {
-            cy.shareWith(adminUser.token, activeUser.user.uuid, testCollection2.uuid, 'can_write');
-        });
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        })
+            .as("testCollection2")
+            .then(function (testCollection2) {
+                cy.shareWith(adminUser.token, activeUser.user.uuid, testCollection2.uuid, "can_write");
+            });
 
-        cy.getAll('@testCollection1', '@testCollection2')
-            .then(function ([testCollection1, testCollection2]) {
-                cy.loginAs(activeUser);
+        cy.getAll("@testCollection1", "@testCollection2").then(function ([testCollection1, testCollection2]) {
+            cy.loginAs(activeUser);
 
-                cy.goToPath(`/collections/${testCollection1.uuid}`);
-                cy.get('[data-cy=responsible-person-wrapper]')
-                    .contains(activeUser.user.uuid);
+            cy.goToPath(`/collections/${testCollection1.uuid}`);
+            cy.get("[data-cy=responsible-person-wrapper]").contains(activeUser.user.uuid);
 
-                cy.goToPath(`/collections/${testCollection2.uuid}`);
-                cy.get('[data-cy=responsible-person-wrapper]')
-                    .contains(adminUser.user.uuid);
-            });
+            cy.goToPath(`/collections/${testCollection2.uuid}`);
+            cy.get("[data-cy=responsible-person-wrapper]").contains(adminUser.user.uuid);
+        });
     });
 
-    describe('file upload', () => {
+    describe("file upload", () => {
         beforeEach(() => {
             cy.createCollection(adminUser.token, {
                 name: `Test collection ${Math.floor(Math.random() * 999999)}`,
                 owner_uuid: activeUser.user.uuid,
-                manifest_text: "./subdir 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
-            }).as('testCollection1');
+                manifest_text: "./subdir 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+            }).as("testCollection1");
         });
 
-        it('uploads a file and checks the collection UI to be fresh', () => {
-            cy.getAll('@testCollection1')
-                .then(function([testCollection1]) {
-                    cy.loginAs(activeUser);
-                    cy.goToPath(`/collections/${testCollection1.uuid}`);
-                    cy.get('[data-cy=upload-button]').click();
-                    cy.get('[data-cy=collection-files-panel]')
-                        .contains('5mb_a.bin').should('not.exist');
-                    cy.get('[data-cy=collection-file-count]').should('contain', '2');
-                    cy.fixture('files/5mb.bin', 'base64').then(content => {
-                        cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_a.bin');
-                        cy.get('[data-cy=form-submit-btn]').click();
-                        cy.get('[data-cy=form-submit-btn]').should('not.exist');
-                        cy.get('[data-cy=collection-files-panel]')
-                            .contains('5mb_a.bin').should('exist');
-                        cy.get('[data-cy=collection-file-count]').should('contain', '3');
-
-                        cy.get('[data-cy=collection-files-panel]').contains('subdir').click();
-                        cy.get('[data-cy=upload-button]').click();
-                        cy.fixture('files/5mb.bin', 'base64').then(content => {
-                            cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_b.bin');
-                            cy.get('[data-cy=form-submit-btn]').click();
-                            cy.get('[data-cy=form-submit-btn]').should('not.exist');
-                            cy.get('[data-cy=collection-files-right-panel]')
-                                 .contains('5mb_b.bin').should('exist');
-                        });
+        it("uploads a file and checks the collection UI to be fresh", () => {
+            cy.getAll("@testCollection1").then(function ([testCollection1]) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${testCollection1.uuid}`);
+                cy.get("[data-cy=upload-button]").click();
+                cy.get("[data-cy=collection-files-panel]").contains("5mb_a.bin").should("not.exist");
+                cy.get("[data-cy=collection-file-count]").should("contain", "2");
+                cy.fixture("files/5mb.bin", "base64").then(content => {
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_a.bin");
+                    cy.get("[data-cy=form-submit-btn]").click();
+                    cy.get("[data-cy=form-submit-btn]").should("not.exist");
+                    cy.get("[data-cy=collection-files-panel]").contains("5mb_a.bin").should("exist");
+                    cy.get("[data-cy=collection-file-count]").should("contain", "3");
+
+                    cy.get("[data-cy=collection-files-panel]").contains("subdir").click();
+                    cy.get("[data-cy=upload-button]").click();
+                    cy.fixture("files/5mb.bin", "base64").then(content => {
+                        cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_b.bin");
+                        cy.get("[data-cy=form-submit-btn]").click();
+                        cy.waitForDom().get("[data-cy=form-submit-btn]").should("not.exist");
+                        // subdir gets unselected, I think this is a bug but
+                        // for the time being let's just make sure the test works.
+                        cy.get("[data-cy=collection-files-panel]").contains("subdir").click();
+                        cy.waitForDom().get("[data-cy=collection-files-right-panel]").contains("5mb_b.bin").should("exist");
                     });
                 });
+            });
         });
 
-        it('allows to cancel running upload', () => {
-            cy.getAll('@testCollection1')
-                .then(function([testCollection1]) {
-                    cy.loginAs(activeUser);
+        it("allows to cancel running upload", () => {
+            cy.getAll("@testCollection1").then(function ([testCollection1]) {
+                cy.loginAs(activeUser);
 
-                    cy.goToPath(`/collections/${testCollection1.uuid}`);
+                cy.goToPath(`/collections/${testCollection1.uuid}`);
 
-                    cy.get('[data-cy=upload-button]').click();
+                cy.get("[data-cy=upload-button]").click();
 
-                    cy.fixture('files/5mb.bin', 'base64').then(content => {
-                        cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_a.bin');
-                        cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_b.bin');
+                cy.fixture("files/5mb.bin", "base64").then(content => {
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_a.bin");
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_b.bin");
 
-                        cy.get('[data-cy=form-submit-btn]').click();
+                    cy.get("[data-cy=form-submit-btn]").click();
 
-                        cy.get('button').contains('Cancel').click();
+                    cy.get("button").contains("Cancel").click();
 
-                        cy.get('[data-cy=form-submit-btn]').should('not.exist');
-                    });
+                    cy.get("[data-cy=form-submit-btn]").should("not.exist");
                 });
+            });
         });
 
-        it('allows to cancel single file from the running upload', () => {
-            cy.getAll('@testCollection1')
-                .then(function([testCollection1]) {
-                    cy.loginAs(activeUser);
+        it("allows to cancel single file from the running upload", () => {
+            cy.getAll("@testCollection1").then(function ([testCollection1]) {
+                cy.loginAs(activeUser);
 
-                    cy.goToPath(`/collections/${testCollection1.uuid}`);
+                cy.goToPath(`/collections/${testCollection1.uuid}`);
 
-                    cy.get('[data-cy=upload-button]').click();
+                cy.get("[data-cy=upload-button]").click();
 
-                    cy.fixture('files/5mb.bin', 'base64').then(content => {
-                        cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_a.bin');
-                        cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_b.bin');
+                cy.fixture("files/5mb.bin", "base64").then(content => {
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_a.bin");
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_b.bin");
 
-                        cy.get('[data-cy=form-submit-btn]').click();
+                    cy.get("[data-cy=form-submit-btn]").click();
 
-                        cy.get('button[aria-label=Remove]').eq(1).click();
+                    cy.get("button[aria-label=Remove]").eq(1).click();
 
-                        cy.get('[data-cy=form-submit-btn]').should('not.exist');
+                    cy.get("[data-cy=form-submit-btn]").should("not.exist");
 
-                        cy.get('[data-cy=collection-files-panel]').contains('5mb_a.bin').should('exist');
-                    });
+                    cy.get("[data-cy=collection-files-panel]").contains("5mb_a.bin").should("exist");
                 });
+            });
         });
 
-        it('allows to cancel all files from the running upload', () => {
-            cy.getAll('@testCollection1')
-                .then(function([testCollection1]) {
-                    cy.loginAs(activeUser);
-
-                    cy.goToPath(`/collections/${testCollection1.uuid}`);
-
-                    // Confirm initial collection state.
-                    cy.get('[data-cy=collection-files-panel]')
-                        .contains('bar').should('exist');
-                    cy.get('[data-cy=collection-files-panel]')
-                        .contains('5mb_a.bin').should('not.exist');
-                    cy.get('[data-cy=collection-files-panel]')
-                        .contains('5mb_b.bin').should('not.exist');
-
-                    cy.get('[data-cy=upload-button]').click();
-
-                    cy.fixture('files/5mb.bin', 'base64').then(content => {
-                        cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_a.bin');
-                        cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_b.bin');
-
-                        cy.get('[data-cy=form-submit-btn]').click();
-
-                        cy.get('button[aria-label=Remove]').should('exist');
-                        cy.get('button[aria-label=Remove]')
-                            .click({ multiple: true, force: true });
-
-                        cy.get('[data-cy=form-submit-btn]').should('not.exist');
-
-                        // Confirm final collection state.
-                        cy.get('[data-cy=collection-files-panel]')
-                            .contains('bar').should('exist');
-                        // The following fails, but doesn't seem to happen
-                        // in the real world. Maybe there's a race between
-                        // the PUT request finishing and the 'Remove' button
-                        // dissapearing, because sometimes just one of the 2
-                        // files gets uploaded.
-                        // Maybe this will be needed to simulate a slow network:
-                        // https://docs.cypress.io/api/commands/intercept#Convenience-functions-1
-                        // cy.get('[data-cy=collection-files-panel]')
-                        //     .contains('5mb_a.bin').should('not.exist');
-                        // cy.get('[data-cy=collection-files-panel]')
-                        //     .contains('5mb_b.bin').should('not.exist');
-                    });
+        it("allows to cancel all files from the running upload", () => {
+            cy.getAll("@testCollection1").then(function ([testCollection1]) {
+                cy.loginAs(activeUser);
+
+                cy.goToPath(`/collections/${testCollection1.uuid}`);
+
+                // Confirm initial collection state.
+                cy.get("[data-cy=collection-files-panel]").contains("bar").should("exist");
+                cy.get("[data-cy=collection-files-panel]").contains("5mb_a.bin").should("not.exist");
+                cy.get("[data-cy=collection-files-panel]").contains("5mb_b.bin").should("not.exist");
+
+                cy.get("[data-cy=upload-button]").click();
+
+                cy.fixture("files/5mb.bin", "base64").then(content => {
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_a.bin");
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_b.bin");
+
+                    cy.get("[data-cy=form-submit-btn]").click();
+
+                    cy.get("button[aria-label=Remove]").should("exist");
+                    cy.get("button[aria-label=Remove]").click({ multiple: true, force: true });
+
+                    cy.get("[data-cy=form-submit-btn]").should("not.exist");
+
+                    // Confirm final collection state.
+                    cy.get("[data-cy=collection-files-panel]").contains("bar").should("exist");
+                    // The following fails, but doesn't seem to happen
+                    // in the real world. Maybe there's a race between
+                    // the PUT request finishing and the 'Remove' button
+                    // dissapearing, because sometimes just one of the 2
+                    // files gets uploaded.
+                    // Maybe this will be needed to simulate a slow network:
+                    // https://docs.cypress.io/api/commands/intercept#Convenience-functions-1
+                    // cy.get('[data-cy=collection-files-panel]')
+                    //     .contains('5mb_a.bin').should('not.exist');
+                    // cy.get('[data-cy=collection-files-panel]')
+                    //     .contains('5mb_b.bin').should('not.exist');
                 });
+            });
         });
     });
-})
+});
index ca56e404ad61c60400a1cebb8e8dcf32e7c81f6c..e6469039348338873aef4df1337556ffe3397cd5 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-describe('Multi-file deletion tests', function () {
+describe('Create workflow tests', function () {
     let activeUser;
     let adminUser;
 
@@ -166,8 +166,8 @@ describe('Multi-file deletion tests', function () {
                                 cy.get(`[data-id=${testCollection.uuid}]`)
                                     .find('i').click();
 
+                                cy.wait(1000);
                                 cy.contains('bar').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').click();
-
                                 cy.contains('baz').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').click();
 
                                 cy.get('[data-cy=ok-button]').click();
@@ -204,4 +204,81 @@ describe('Multi-file deletion tests', function () {
                     });
             });
     }));
+
+    it('allows selecting collection subdirectories and reselects existing selections', () => {
+        cy.createProject({
+            owningUser: activeUser,
+            projectName: 'myProject1',
+            addToFavorites: true
+        });
+
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: "./subdir/dir1 d41d8cd98f00b204e9800998ecf8427e+0 0:0:\\056\n./subdir/dir2 d41d8cd98f00b204e9800998ecf8427e+0 0:0:\\056\n"
+        })
+            .as('testCollection');
+
+        cy.getAll('@myProject1', '@testCollection')
+            .then(function ([myProject1, testCollection]) {
+                cy.readFile('cypress/fixtures/workflow_directory_array.yaml').then(workflow => {
+                    cy.createWorkflow(adminUser.token, {
+                        name: `TestWorkflow${Math.floor(Math.random() * 999999)}.cwl`,
+                        definition: workflow,
+                        owner_uuid: myProject1.uuid,
+                    })
+                        .as('testWorkflow');
+                });
+
+                cy.loginAs(activeUser);
+
+                cy.get('main').contains(myProject1.name).click();
+
+                cy.get('[data-cy=side-panel-button]').click();
+
+                cy.get('#aside-menu-list').contains('Run a workflow').click();
+
+                cy.get('@testWorkflow')
+                    .then((testWorkflow) => {
+                        cy.get('main').contains(testWorkflow.name).click();
+                        cy.get('[data-cy=run-process-next-button]').click();
+
+                        cy.get('label').contains('directoryInputName').parent('div').find('input').click();
+                        cy.get('div[role=dialog]')
+                            .within(() => {
+                                // must use .then to avoid selecting instead of expanding https://github.com/cypress-io/cypress/issues/5529
+                                cy.get('p').contains('Home Projects').closest('ul')
+                                    .find('i')
+                                    .then(el => el.click());
+
+                                cy.get(`[data-id=${testCollection.uuid}]`)
+                                    .find('i').click();
+
+                                cy.get(`[data-id="${testCollection.uuid}/subdir"]`)
+                                    .find('i').click();
+
+                                cy.contains('dir1').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').click();
+                                cy.contains('dir2').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').click();
+
+                                cy.get('[data-cy=ok-button]').click();
+                            });
+
+                        // Verify subdirectories were selected
+                        cy.get('label').contains('directoryInputName').parent('div')
+                            .within(() => {
+                                cy.contains('dir1');
+                                cy.contains('dir2');
+                            });
+
+                        // Reopen tree picker and verify subdirectories are preselected
+                        cy.get('label').contains('directoryInputName').parent('div').find('input').click();
+                        cy.waitForDom().get('div[role=dialog]')
+                            .within(() => {
+                                cy.contains('dir1').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').should('be.checked');
+                                cy.contains('dir2').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').should('be.checked');
+                            });
+                    });
+
+            });
+    })
 })
index 1fd9e4165f502eb51fb06b8bf94f4c0071ae2eb4..c4731bb3c6bf01bdde33ccdb62cc57579c4531bc 100644 (file)
@@ -77,7 +77,7 @@ describe('Group manage tests', function() {
                 cy.get('[data-cy=invite-people-field] input').type("admin");
             });
         cy.get('[role=tooltip]').click();
-        cy.get('.sharing-dialog').contains('Save').click();
+        cy.get('.sharing-dialog').get('[data-cy=add-invited-people]').click();
         cy.get('.sharing-dialog').contains('Close').click();
 
         // Check that both users are present with appropriate permissions
diff --git a/cypress/integration/multiselect-toolbar.spec.js b/cypress/integration/multiselect-toolbar.spec.js
new file mode 100644 (file)
index 0000000..ef503f7
--- /dev/null
@@ -0,0 +1,36 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Multiselect Toolbar Tests', () => {
+    let activeUser;
+    let adminUser;
+
+    before(function () {
+        // Only set up common users once. These aren't set up as aliases because
+        // aliases are cleaned up after every test. Also it doesn't make sense
+        // to set the same users on beforeEach() over and over again, so we
+        // separate a little from Cypress' 'Best Practices' here.
+        cy.getUser('admin', 'Admin', 'User', true, true)
+            .as('adminUser')
+            .then(function () {
+                adminUser = this.adminUser;
+            });
+        cy.getUser('user', 'Active', 'User', false, true)
+            .as('activeUser')
+            .then(function () {
+                activeUser = this.activeUser;
+            });
+    });
+
+    beforeEach(function () {
+        cy.clearCookies();
+        cy.clearLocalStorage();
+    });
+
+    it('exists in DOM in neutral state', () => {
+        cy.loginAs(activeUser);
+        cy.get('[data-cy=multiselect-toolbar]').should('exist');
+        cy.get('[data-cy=multiselect-button]').should('not.exist');
+    });
+});
index 4df4135c878ed63e4ef667eda50697985a31201d..6eab27c827dc3b1d0ffbc6b4cd22ce0f79e9b6fb 100644 (file)
@@ -45,8 +45,7 @@ describe('Page not found tests', function() {
             cy.goToPath(path);
 
             // then
-            cy.get('[data-cy=not-found-page]').should('not.exist');
-            cy.get('[data-cy=not-found-content]').should('exist');
+            cy.get('[data-cy=not-found-view]').should('exist');
         });
     });
-})
\ No newline at end of file
+})
index 19544c9ca543bcbc257b823df48f2d2eaf97fa6f..9ea026b9906511c297cb6367370b8c08955f8869 100644 (file)
@@ -2,28 +2,30 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-describe('Process tests', function() {
+import { ContainerState } from "models/container";
+
+describe("Process tests", function () {
     let activeUser;
     let adminUser;
 
-    before(function() {
+    before(function () {
         // Only set up common users once. These aren't set up as aliases because
         // aliases are cleaned up after every test. Also it doesn't make sense
         // to set the same users on beforeEach() over and over again, so we
         // separate a little from Cypress' 'Best Practices' here.
-        cy.getUser('admin', 'Admin', 'User', true, true)
-            .as('adminUser').then(function() {
+        cy.getUser("admin", "Admin", "User", true, true)
+            .as("adminUser")
+            .then(function () {
                 adminUser = this.adminUser;
-            }
-        );
-        cy.getUser('user', 'Active', 'User', false, true)
-            .as('activeUser').then(function() {
+            });
+        cy.getUser("user", "Active", "User", false, true)
+            .as("activeUser")
+            .then(function () {
                 activeUser = this.activeUser;
-            }
-        );
+            });
     });
 
-    beforeEach(function() {
+    beforeEach(function () {
         cy.clearCookies();
         cy.clearLocalStorage();
     });
@@ -31,42 +33,46 @@ describe('Process tests', function() {
     function setupDockerImage(image_name) {
         // Create a collection that will be used as a docker image for the tests.
         cy.createCollection(adminUser.token, {
-            name: 'docker_image',
-            manifest_text: ". d21353cfe035e3e384563ee55eadbb2f+67108864 5c77a43e329b9838cbec18ff42790e57+55605760 0:122714624:sha256:d8309758b8fe2c81034ffc8a10c36460b77db7bc5e7b448c4e5b684f9d95a678.tar\n"
-        }).as('dockerImage').then(function(dockerImage) {
-            // Give read permissions to the active user on the docker image.
-            cy.createLink(adminUser.token, {
-                link_class: 'permission',
-                name: 'can_read',
-                tail_uuid: activeUser.user.uuid,
-                head_uuid: dockerImage.uuid
-            }).as('dockerImagePermission').then(function() {
-                // Set-up docker image collection tags
-                cy.createLink(activeUser.token, {
-                    link_class: 'docker_image_repo+tag',
-                    name: image_name,
-                    head_uuid: dockerImage.uuid,
-                }).as('dockerImageRepoTag');
-                cy.createLink(activeUser.token, {
-                    link_class: 'docker_image_hash',
-                    name: 'sha256:d8309758b8fe2c81034ffc8a10c36460b77db7bc5e7b448c4e5b684f9d95a678',
+            name: "docker_image",
+            manifest_text:
+                ". d21353cfe035e3e384563ee55eadbb2f+67108864 5c77a43e329b9838cbec18ff42790e57+55605760 0:122714624:sha256:d8309758b8fe2c81034ffc8a10c36460b77db7bc5e7b448c4e5b684f9d95a678.tar\n",
+        })
+            .as("dockerImage")
+            .then(function (dockerImage) {
+                // Give read permissions to the active user on the docker image.
+                cy.createLink(adminUser.token, {
+                    link_class: "permission",
+                    name: "can_read",
+                    tail_uuid: activeUser.user.uuid,
                     head_uuid: dockerImage.uuid,
-                }).as('dockerImageHash');
-            })
-        });
-        return cy.getAll('@dockerImage', '@dockerImageRepoTag', '@dockerImageHash',
-            '@dockerImagePermission').then(function([dockerImage]) {
-                return dockerImage;
+                })
+                    .as("dockerImagePermission")
+                    .then(function () {
+                        // Set-up docker image collection tags
+                        cy.createLink(activeUser.token, {
+                            link_class: "docker_image_repo+tag",
+                            name: image_name,
+                            head_uuid: dockerImage.uuid,
+                        }).as("dockerImageRepoTag");
+                        cy.createLink(activeUser.token, {
+                            link_class: "docker_image_hash",
+                            name: "sha256:d8309758b8fe2c81034ffc8a10c36460b77db7bc5e7b448c4e5b684f9d95a678",
+                            head_uuid: dockerImage.uuid,
+                        }).as("dockerImageHash");
+                    });
             });
+        return cy.getAll("@dockerImage", "@dockerImageRepoTag", "@dockerImageHash", "@dockerImagePermission").then(function ([dockerImage]) {
+            return dockerImage;
+        });
     }
 
-    function createContainerRequest(user, name, docker_image, command, reuse = false, state = 'Uncommitted') {
-        return setupDockerImage(docker_image).then(function(dockerImage) {
+    function createContainerRequest(user, name, docker_image, command, reuse = false, state = "Uncommitted") {
+        return setupDockerImage(docker_image).then(function (dockerImage) {
             return cy.createContainerRequest(user.token, {
                 name: name,
                 command: command,
                 container_image: dockerImage.portable_data_hash, // for some reason, docker_image doesn't work here
-                output_path: 'stdout.txt',
+                output_path: "stdout.txt",
                 priority: 1,
                 runtime_constraints: {
                     vcpus: 1,
@@ -76,1285 +82,1438 @@ describe('Process tests', function() {
                 state: state,
                 mounts: {
                     foo: {
-                        kind: 'tmp',
-                        path: '/tmp/foo',
-                    }
-                }
+                        kind: "tmp",
+                        path: "/tmp/foo",
+                    },
+                },
             });
         });
     }
 
-    it('shows process logs', function() {
-        const crName = 'test_container_request';
-        createContainerRequest(
-            activeUser,
-            crName,
-            'arvados/jobs',
-            ['echo', 'hello world'],
-            false, 'Committed')
-        .then(function(containerRequest) {
-            cy.loginAs(activeUser);
-            cy.goToPath(`/processes/${containerRequest.uuid}`);
-            cy.get('[data-cy=process-details]').should('contain', crName);
-            cy.get('[data-cy=process-logs]')
-                .should('contain', 'No logs yet')
-                .and('not.contain', 'hello world');
-            cy.createLog(activeUser.token, {
-                object_uuid: containerRequest.container_uuid,
-                properties: {
-                    text: 'hello world'
-                },
-                event_type: 'stdout'
-            }).then(function(log) {
-                cy.get('[data-cy=process-logs]', {timeout: 7000})
-                    .should('not.contain', 'No logs yet')
-                    .and('contain', 'hello world');
-            })
+    describe('Multiselect Toolbar', () => {
+        it('shows the appropriate buttons in the toolbar', () => {
+
+            const msButtonTooltips = [
+                'API Details',
+                'Add to Favorites',
+                'CANCEL',
+                'Copy and re-run process',
+                'Edit process',
+                'Move to',
+                'Open in new tab',
+                'Outputs',
+                'Remove',
+                'Share',
+                'View details',
+            ];
+    
+            createContainerRequest(
+                activeUser,
+                `test_container_request ${Math.floor(Math.random() * 999999)}`,
+                "arvados/jobs",
+                ["echo", "hello world"],
+                false,
+                "Committed"
+            ).then(function (containerRequest) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-details]").should("contain", containerRequest.name);
+                cy.get("[data-cy=process-details-attributes-modifiedby-user]").contains(`Active User (${activeUser.user.uuid})`);
+                cy.get("[data-cy=process-details-attributes-runtime-user]").should("not.exist");
+                cy.get("[data-cy=side-panel-tree]").contains("Home Projects").click();
+                cy.waitForDom()
+                cy.get('[data-cy=data-table-row]').contains(containerRequest.name).should('exist').parent().parent().parent().parent().click()
+                cy.get('[data-cy=multiselect-button]').should('have.length', msButtonTooltips.length)
+                for (let i = 0; i < msButtonTooltips.length; i++) {
+                    cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseover');
+                    cy.get('body').contains(msButtonTooltips[i]).should('exist')
+                    cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseout');
+                }
+            });
+        })
+    })
+
+    describe("Details panel", function () {
+        it("shows process details", function () {
+            createContainerRequest(
+                activeUser,
+                `test_container_request ${Math.floor(Math.random() * 999999)}`,
+                "arvados/jobs",
+                ["echo", "hello world"],
+                false,
+                "Committed"
+            ).then(function (containerRequest) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-details]").should("contain", containerRequest.name);
+                cy.get("[data-cy=process-details-attributes-modifiedby-user]").contains(`Active User (${activeUser.user.uuid})`);
+                cy.get("[data-cy=process-details-attributes-runtime-user]").should("not.exist");
+            });
+
+            // Fake submitted by another user
+            cy.intercept({ method: "GET", url: "**/arvados/v1/container_requests/*" }, req => {
+                req.reply(res => {
+                    res.body.modified_by_user_uuid = "zzzzz-tpzed-000000000000000";
+                });
+            });
+
+            createContainerRequest(
+                activeUser,
+                `test_container_request ${Math.floor(Math.random() * 999999)}`,
+                "arvados/jobs",
+                ["echo", "hello world"],
+                false,
+                "Committed"
+            ).then(function (containerRequest) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-details]").should("contain", containerRequest.name);
+                cy.get("[data-cy=process-details-attributes-modifiedby-user]").contains(`zzzzz-tpzed-000000000000000`);
+                cy.get("[data-cy=process-details-attributes-runtime-user]").contains(`Active User (${activeUser.user.uuid})`);
+            });
         });
-    });
 
-    it('shows process details', function() {
-        createContainerRequest(
-            activeUser,
-            `test_container_request ${Math.floor(Math.random() * 999999)}`,
-            'arvados/jobs',
-            ['echo', 'hello world'],
-            false, 'Committed')
-        .then(function(containerRequest) {
-            cy.loginAs(activeUser);
-            cy.goToPath(`/processes/${containerRequest.uuid}`);
-            cy.get('[data-cy=process-details]').should('contain', containerRequest.name);
-            cy.get('[data-cy=process-details-attributes-modifiedby-user]').contains(`Active User (${activeUser.user.uuid})`);
-            cy.get('[data-cy=process-details-attributes-runtime-user]').should('not.exist');
+        it("should show runtime status indicators", function () {
+            // Setup running container with runtime_status error & warning messages
+            createContainerRequest(activeUser, "test_container_request", "arvados/jobs", ["echo", "hello world"], false, "Committed")
+                .as("containerRequest")
+                .then(function (containerRequest) {
+                    expect(containerRequest.state).to.equal("Committed");
+                    expect(containerRequest.container_uuid).not.to.be.equal("");
+
+                    cy.getContainer(activeUser.token, containerRequest.container_uuid).then(function (queuedContainer) {
+                        expect(queuedContainer.state).to.be.equal("Queued");
+                    });
+                    cy.updateContainer(adminUser.token, containerRequest.container_uuid, {
+                        state: "Locked",
+                    }).then(function (lockedContainer) {
+                        expect(lockedContainer.state).to.be.equal("Locked");
+
+                        cy.updateContainer(adminUser.token, lockedContainer.uuid, {
+                            state: "Running",
+                            runtime_status: {
+                                error: "Something went wrong",
+                                errorDetail: "Process exited with status 1",
+                                warning: "Free disk space is low",
+                            },
+                        })
+                            .as("runningContainer")
+                            .then(function (runningContainer) {
+                                expect(runningContainer.state).to.be.equal("Running");
+                                expect(runningContainer.runtime_status).to.be.deep.equal({
+                                    error: "Something went wrong",
+                                    errorDetail: "Process exited with status 1",
+                                    warning: "Free disk space is low",
+                                });
+                            });
+                    });
+                });
+            // Test that the UI shows the error and warning messages
+            cy.getAll("@containerRequest", "@runningContainer").then(function ([containerRequest]) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-runtime-status-error]")
+                    .should("contain", "Something went wrong")
+                    .and("contain", "Process exited with status 1");
+                cy.get("[data-cy=process-runtime-status-warning]")
+                    .should("contain", "Free disk space is low")
+                    .and("contain", "No additional warning details available");
+            });
+
+            // Force container_count for testing
+            let containerCount = 2;
+            cy.intercept({ method: "GET", url: "**/arvados/v1/container_requests/*" }, req => {
+                req.reply(res => {
+                    res.body.container_count = containerCount;
+                });
+            });
+
+            cy.getAll("@containerRequest").then(function ([containerRequest]) {
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-runtime-status-retry-warning]", { timeout: 7000 }).should("contain", "Process retried 1 time");
+            });
+
+            cy.getAll("@containerRequest").then(function ([containerRequest]) {
+                containerCount = 3;
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-runtime-status-retry-warning]", { timeout: 7000 }).should("contain", "Process retried 2 times");
+            });
         });
 
-        // Fake submitted by another user
-        cy.intercept({method: 'GET', url: '**/arvados/v1/container_requests/*'}, (req) => {
-            req.reply((res) => {
-                res.body.modified_by_user_uuid = 'zzzzz-tpzed-000000000000000';
+        it("allows copying processes", function () {
+            const crName = "first_container_request";
+            const copiedCrName = "copied_container_request";
+            createContainerRequest(activeUser, crName, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (containerRequest) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-details]").should("contain", crName);
+
+                cy.get("[data-cy=process-details]").find('button[title="More options"]').click();
+                cy.get("ul[data-cy=context-menu]").contains("Copy and re-run process").click();
+            });
+
+            cy.get("[data-cy=form-dialog]").within(() => {
+                cy.get("input[name=name]").clear().type(copiedCrName);
+                cy.get("[data-cy=projects-tree-home-tree-picker]").click();
+                cy.get("[data-cy=form-submit-btn]").click();
             });
+
+            cy.get("[data-cy=process-details]").should("contain", copiedCrName);
+            cy.get("[data-cy=process-details]").find("button").contains("Run");
         });
 
-        createContainerRequest(
-            activeUser,
-            `test_container_request ${Math.floor(Math.random() * 999999)}`,
-            'arvados/jobs',
-            ['echo', 'hello world'],
-            false, 'Committed')
-        .then(function(containerRequest) {
-            cy.loginAs(activeUser);
-            cy.goToPath(`/processes/${containerRequest.uuid}`);
-            cy.get('[data-cy=process-details]').should('contain', containerRequest.name);
-            cy.get('[data-cy=process-details-attributes-modifiedby-user]').contains(`zzzzz-tpzed-000000000000000`);
-            cy.get('[data-cy=process-details-attributes-runtime-user]').contains(`Active User (${activeUser.user.uuid})`);
+        const getFakeContainer = fakeContainerUuid => ({
+            href: `/containers/${fakeContainerUuid}`,
+            kind: "arvados#container",
+            etag: "ecfosljpnxfari9a8m7e4yv06",
+            uuid: fakeContainerUuid,
+            owner_uuid: "zzzzz-tpzed-000000000000000",
+            created_at: "2023-02-13T15:55:47.308915000Z",
+            modified_by_client_uuid: "zzzzz-ozdt8-q6dzdi1lcc03155",
+            modified_by_user_uuid: "zzzzz-tpzed-000000000000000",
+            modified_at: "2023-02-15T19:12:45.987086000Z",
+            command: [
+                "arvados-cwl-runner",
+                "--api=containers",
+                "--local",
+                "--project-uuid=zzzzz-j7d0g-yr18k784zplfeza",
+                "/var/lib/cwl/workflow.json#main",
+                "/var/lib/cwl/cwl.input.json",
+            ],
+            container_image: "4ad7d11381df349e464694762db14e04+303",
+            cwd: "/var/spool/cwl",
+            environment: {},
+            exit_code: null,
+            finished_at: null,
+            locked_by_uuid: null,
+            log: null,
+            output: null,
+            output_path: "/var/spool/cwl",
+            progress: null,
+            runtime_constraints: {
+                API: true,
+                cuda: {
+                    device_count: 0,
+                    driver_version: "",
+                    hardware_capability: "",
+                },
+                keep_cache_disk: 2147483648,
+                keep_cache_ram: 0,
+                ram: 1342177280,
+                vcpus: 1,
+            },
+            runtime_status: {},
+            started_at: null,
+            auth_uuid: null,
+            scheduling_parameters: {
+                max_run_time: 0,
+                partitions: [],
+                preemptible: false,
+            },
+            runtime_user_uuid: "zzzzz-tpzed-vllbpebicy84rd5",
+            runtime_auth_scopes: ["all"],
+            lock_count: 2,
+            gateway_address: null,
+            interactive_session_started: false,
+            output_storage_classes: ["default"],
+            output_properties: {},
+            cost: 0.0,
+            subrequests_cost: 0.0,
         });
-    });
 
-    it('filters process logs by event type', function() {
-        const nodeInfoLogs = [
-            'Host Information',
-            'Linux compute-99cb150b26149780de44b929577e1aed-19rgca8vobuvc4p 5.4.0-1059-azure #62~18.04.1-Ubuntu SMP Tue Sep 14 17:53:18 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux',
-            'CPU Information',
-            'processor  : 0',
-            'vendor_id  : GenuineIntel',
-            'cpu family : 6',
-            'model      : 79',
-            'model name : Intel(R) Xeon(R) CPU E5-2673 v4 @ 2.30GHz'
-        ];
-        const crunchRunLogs = [
-            '2022-03-22T13:56:22.542417997Z using local keepstore process (pid 3733) at http://localhost:46837, writing logs to keepstore.txt in log collection',
-            '2022-03-22T13:56:26.237571754Z crunch-run 2.4.0~dev20220321141729 (go1.17.1) started',
-            '2022-03-22T13:56:26.244704134Z crunch-run process has uid=0(root) gid=0(root) groups=0(root)',
-            '2022-03-22T13:56:26.244862836Z Executing container \'zzzzz-dz642-1wokwvcct9s9du3\' using docker runtime',
-            '2022-03-22T13:56:26.245037738Z Executing on host \'compute-99cb150b26149780de44b929577e1aed-19rgca8vobuvc4p\'',
-        ];
-        const stdoutLogs = [
-            'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec dui nisi, hendrerit porta sapien a, pretium dignissim purus.',
-            'Integer viverra, mauris finibus aliquet ultricies, dui mauris cursus justo, ut venenatis nibh ex eget neque.',
-            'In hac habitasse platea dictumst.',
-            'Fusce fringilla turpis id accumsan faucibus. Donec congue congue ex non posuere. In semper mi quis tristique rhoncus.',
-            'Interdum et malesuada fames ac ante ipsum primis in faucibus.',
-            'Quisque fermentum tortor ex, ut suscipit velit feugiat faucibus.',
-            'Donec vitae porta risus, at luctus nulla. Mauris gravida iaculis ipsum, id sagittis tortor egestas ac.',
-            'Maecenas condimentum volutpat nulla. Integer lacinia maximus risus eu posuere.',
-            'Donec vitae leo id augue gravida bibendum.',
-            'Nam libero libero, pretium ac faucibus elementum, mattis nec ex.',
-            'Nullam id laoreet nibh. Vivamus tellus metus, pretium quis justo ut, bibendum varius metus. Pellentesque vitae accumsan lorem, quis tincidunt augue.',
-            'Aliquam viverra nisi nulla, et efficitur dolor mattis in.',
-            'Sed at enim sit amet nulla tincidunt mattis. Aenean eget aliquet ex, non ultrices ex. Nulla ex tortor, vestibulum aliquam tempor ac, aliquam vel est.',
-            'Fusce auctor faucibus libero id venenatis. Etiam sodales, odio eu cursus efficitur, quam sem blandit ex, quis porttitor enim dui quis lectus. In id tincidunt felis.',
-            'Phasellus non ex quis arcu tempus faucibus molestie in sapien.',
-            'Duis tristique semper dolor, vitae pulvinar risus.',
-            'Aliquam tortor elit, luctus nec tortor eget, porta tristique nulla.',
-            'Nulla eget mollis ipsum.',
-        ];
+        it("shows cancel button when appropriate", function () {
+            // Ignore collection requests
+            cy.intercept(
+                { method: "GET", url: `**/arvados/v1/collections/*` },
+                {
+                    statusCode: 200,
+                    body: {},
+                }
+            );
+
+            // Uncommitted container
+            const crUncommitted = `Test process ${Math.floor(Math.random() * 999999)}`;
+            createContainerRequest(activeUser, crUncommitted, "arvados/jobs", ["echo", "hello world"], false, "Uncommitted").then(function (
+                containerRequest
+            ) {
+                // Navigate to process and verify run / cancel button
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.waitForDom();
+                cy.get("[data-cy=process-details]").should("contain", crUncommitted);
+                cy.get("[data-cy=process-run-button]").should("exist");
+                cy.get("[data-cy=process-cancel-button]").should("not.exist");
+            });
 
-        createContainerRequest(
-            activeUser,
-            'test_container_request',
-            'arvados/jobs',
-            ['echo', 'hello world'],
-            false, 'Committed')
-        .then(function(containerRequest) {
-            cy.logsForContainer(activeUser.token, containerRequest.container_uuid,
-                'node-info', nodeInfoLogs).as('nodeInfoLogs');
-            cy.logsForContainer(activeUser.token, containerRequest.container_uuid,
-                'crunch-run', crunchRunLogs).as('crunchRunLogs');
-            cy.logsForContainer(activeUser.token, containerRequest.container_uuid,
-                'stdout', stdoutLogs).as('stdoutLogs');
-            cy.getAll('@stdoutLogs', '@nodeInfoLogs', '@crunchRunLogs').then(function() {
-                cy.loginAs(activeUser);
+            // Queued container
+            const crQueued = `Test process ${Math.floor(Math.random() * 999999)}`;
+            const fakeCrUuid = "zzzzz-dz642-000000000000001";
+            createContainerRequest(activeUser, crQueued, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (
+                containerRequest
+            ) {
+                // Fake container uuid
+                cy.intercept({ method: "GET", url: `**/arvados/v1/container_requests/${containerRequest.uuid}` }, req => {
+                    req.reply(res => {
+                        res.body.output_uuid = fakeCrUuid;
+                        res.body.priority = 500;
+                        res.body.state = "Committed";
+                    });
+                });
+
+                // Fake container
+                const container = getFakeContainer(fakeCrUuid);
+                cy.intercept(
+                    { method: "GET", url: `**/arvados/v1/container/${fakeCrUuid}` },
+                    {
+                        statusCode: 200,
+                        body: { ...container, state: "Queued", priority: 500 },
+                    }
+                );
+
+                // Navigate to process and verify cancel button
                 cy.goToPath(`/processes/${containerRequest.uuid}`);
-                // Should show main logs by default
-                cy.get('[data-cy=process-logs-filter]', {timeout: 7000}).should('contain', 'Main logs');
-                cy.get('[data-cy=process-logs]')
-                    .should('contain', stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
-                    .and('not.contain', nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
-                    .and('contain', crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
-                // Select 'All logs'
-                cy.get('[data-cy=process-logs-filter]').click();
-                cy.get('body').contains('li', 'All logs').click();
-                cy.get('[data-cy=process-logs]')
-                    .should('contain', stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
-                    .and('contain', nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
-                    .and('contain', crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
-                // Select 'node-info' logs
-                cy.get('[data-cy=process-logs-filter]').click();
-                cy.get('body').contains('li', 'node-info').click();
-                cy.get('[data-cy=process-logs]')
-                    .should('not.contain', stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
-                    .and('contain', nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
-                    .and('not.contain', crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
-                // Select 'stdout' logs
-                cy.get('[data-cy=process-logs-filter]').click();
-                cy.get('body').contains('li', 'stdout').click();
-                cy.get('[data-cy=process-logs]')
-                    .should('contain', stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
-                    .and('not.contain', nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
-                    .and('not.contain', crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
+                cy.waitForDom();
+                cy.get("[data-cy=process-details]").should("contain", crQueued);
+                cy.get("[data-cy=process-cancel-button]").contains("Cancel");
+            });
+
+            // Locked container
+            const crLocked = `Test process ${Math.floor(Math.random() * 999999)}`;
+            const fakeCrLockedUuid = "zzzzz-dz642-000000000000002";
+            createContainerRequest(activeUser, crLocked, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (
+                containerRequest
+            ) {
+                // Fake container uuid
+                cy.intercept({ method: "GET", url: `**/arvados/v1/container_requests/${containerRequest.uuid}` }, req => {
+                    req.reply(res => {
+                        res.body.output_uuid = fakeCrLockedUuid;
+                        res.body.priority = 500;
+                        res.body.state = "Committed";
+                    });
+                });
+
+                // Fake container
+                const container = getFakeContainer(fakeCrLockedUuid);
+                cy.intercept(
+                    { method: "GET", url: `**/arvados/v1/container/${fakeCrLockedUuid}` },
+                    {
+                        statusCode: 200,
+                        body: { ...container, state: "Locked", priority: 500 },
+                    }
+                );
+
+                // Navigate to process and verify cancel button
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.waitForDom();
+                cy.get("[data-cy=process-details]").should("contain", crLocked);
+                cy.get("[data-cy=process-cancel-button]").contains("Cancel");
+            });
+
+            // On Hold container
+            const crOnHold = `Test process ${Math.floor(Math.random() * 999999)}`;
+            const fakeCrOnHoldUuid = "zzzzz-dz642-000000000000003";
+            createContainerRequest(activeUser, crOnHold, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (
+                containerRequest
+            ) {
+                // Fake container uuid
+                cy.intercept({ method: "GET", url: `**/arvados/v1/container_requests/${containerRequest.uuid}` }, req => {
+                    req.reply(res => {
+                        res.body.output_uuid = fakeCrOnHoldUuid;
+                        res.body.priority = 0;
+                        res.body.state = "Committed";
+                    });
+                });
+
+                // Fake container
+                const container = getFakeContainer(fakeCrOnHoldUuid);
+                cy.intercept(
+                    { method: "GET", url: `**/arvados/v1/container/${fakeCrOnHoldUuid}` },
+                    {
+                        statusCode: 200,
+                        body: { ...container, state: "Queued", priority: 0 },
+                    }
+                );
+
+                // Navigate to process and verify cancel button
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.waitForDom();
+                cy.get("[data-cy=process-details]").should("contain", crOnHold);
+                cy.get("[data-cy=process-run-button]").should("exist");
+                cy.get("[data-cy=process-cancel-button]").should("not.exist");
             });
         });
     });
 
-    it('should show runtime status indicators', function() {
-        // Setup running container with runtime_status error & warning messages
-        createContainerRequest(
-            activeUser,
-            'test_container_request',
-            'arvados/jobs',
-            ['echo', 'hello world'],
-            false, 'Committed')
-        .as('containerRequest')
-        .then(function(containerRequest) {
-            expect(containerRequest.state).to.equal('Committed');
-            expect(containerRequest.container_uuid).not.to.be.equal('');
-
-            cy.getContainer(activeUser.token, containerRequest.container_uuid)
-            .then(function(queuedContainer) {
-                expect(queuedContainer.state).to.be.equal('Queued');
+    describe("Logs panel", function () {
+        it("shows live process logs", function () {
+            cy.intercept({ method: "GET", url: "**/arvados/v1/containers/*" }, req => {
+                req.reply(res => {
+                    res.body.state = ContainerState.RUNNING;
+                });
             });
-            cy.updateContainer(adminUser.token, containerRequest.container_uuid, {
-                state: 'Locked'
-            }).then(function(lockedContainer) {
-                expect(lockedContainer.state).to.be.equal('Locked');
-
-                cy.updateContainer(adminUser.token, lockedContainer.uuid, {
-                    state: 'Running',
-                    runtime_status: {
-                        error: 'Something went wrong',
-                        errorDetail: 'Process exited with status 1',
-                        warning: 'Free disk space is low',
-                    }
-                })
-                .as('runningContainer')
-                .then(function(runningContainer) {
-                    expect(runningContainer.state).to.be.equal('Running');
-                    expect(runningContainer.runtime_status).to.be.deep.equal({
-                        'error': 'Something went wrong',
-                        'errorDetail': 'Process exited with status 1',
-                        'warning': 'Free disk space is low',
-                    });
+
+            const crName = "test_container_request";
+            createContainerRequest(activeUser, crName, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (containerRequest) {
+                // Create empty log file before loading process page
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", [""]);
+
+                cy.loginAs(activeUser);
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-details]").should("contain", crName);
+                cy.get("[data-cy=process-logs]").should("contain", "No logs yet").and("not.contain", "hello world");
+
+                // Append a log line
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", ["2023-07-18T20:14:48.128642814Z hello world"]).then(() => {
+                    cy.get("[data-cy=process-logs]", { timeout: 7000 }).should("not.contain", "No logs yet").and("contain", "hello world");
                 });
-            })
+
+                // Append new log line to different file
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stderr.txt", ["2023-07-18T20:14:49.128642814Z hello new line"]).then(() => {
+                    cy.get("[data-cy=process-logs]", { timeout: 7000 }).should("not.contain", "No logs yet").and("contain", "hello new line");
+                });
+            });
         });
-        // Test that the UI shows the error and warning messages
-        cy.getAll('@containerRequest', '@runningContainer').then(function([containerRequest]) {
-            cy.loginAs(activeUser);
-            cy.goToPath(`/processes/${containerRequest.uuid}`);
-            cy.get('[data-cy=process-runtime-status-error]')
-                .should('contain', 'Something went wrong')
-                .and('contain', 'Process exited with status 1');
-            cy.get('[data-cy=process-runtime-status-warning]')
-                .should('contain', 'Free disk space is low')
-                .and('contain', 'No additional warning details available');
+
+        it("filters process logs by event type", function () {
+            const nodeInfoLogs = [
+                "Host Information",
+                "Linux compute-99cb150b26149780de44b929577e1aed-19rgca8vobuvc4p 5.4.0-1059-azure #62~18.04.1-Ubuntu SMP Tue Sep 14 17:53:18 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux",
+                "CPU Information",
+                "processor  : 0",
+                "vendor_id  : GenuineIntel",
+                "cpu family : 6",
+                "model      : 79",
+                "model name : Intel(R) Xeon(R) CPU E5-2673 v4 @ 2.30GHz",
+            ];
+            const crunchRunLogs = [
+                "2022-03-22T13:56:22.542417997Z using local keepstore process (pid 3733) at http://localhost:46837, writing logs to keepstore.txt in log collection",
+                "2022-03-22T13:56:26.237571754Z crunch-run 2.4.0~dev20220321141729 (go1.17.1) started",
+                "2022-03-22T13:56:26.244704134Z crunch-run process has uid=0(root) gid=0(root) groups=0(root)",
+                "2022-03-22T13:56:26.244862836Z Executing container 'zzzzz-dz642-1wokwvcct9s9du3' using docker runtime",
+                "2022-03-22T13:56:26.245037738Z Executing on host 'compute-99cb150b26149780de44b929577e1aed-19rgca8vobuvc4p'",
+            ];
+            const stdoutLogs = [
+                "2022-03-22T13:56:22.542417987Z Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec dui nisi, hendrerit porta sapien a, pretium dignissim purus.",
+                "2022-03-22T13:56:22.542417997Z Integer viverra, mauris finibus aliquet ultricies, dui mauris cursus justo, ut venenatis nibh ex eget neque.",
+                "2022-03-22T13:56:22.542418007Z In hac habitasse platea dictumst.",
+                "2022-03-22T13:56:22.542418027Z Fusce fringilla turpis id accumsan faucibus. Donec congue congue ex non posuere. In semper mi quis tristique rhoncus.",
+                "2022-03-22T13:56:22.542418037Z Interdum et malesuada fames ac ante ipsum primis in faucibus.",
+                "2022-03-22T13:56:22.542418047Z Quisque fermentum tortor ex, ut suscipit velit feugiat faucibus.",
+                "2022-03-22T13:56:22.542418057Z Donec vitae porta risus, at luctus nulla. Mauris gravida iaculis ipsum, id sagittis tortor egestas ac.",
+                "2022-03-22T13:56:22.542418067Z Maecenas condimentum volutpat nulla. Integer lacinia maximus risus eu posuere.",
+                "2022-03-22T13:56:22.542418077Z Donec vitae leo id augue gravida bibendum.",
+                "2022-03-22T13:56:22.542418087Z Nam libero libero, pretium ac faucibus elementum, mattis nec ex.",
+                "2022-03-22T13:56:22.542418097Z Nullam id laoreet nibh. Vivamus tellus metus, pretium quis justo ut, bibendum varius metus. Pellentesque vitae accumsan lorem, quis tincidunt augue.",
+                "2022-03-22T13:56:22.542418107Z Aliquam viverra nisi nulla, et efficitur dolor mattis in.",
+                "2022-03-22T13:56:22.542418117Z Sed at enim sit amet nulla tincidunt mattis. Aenean eget aliquet ex, non ultrices ex. Nulla ex tortor, vestibulum aliquam tempor ac, aliquam vel est.",
+                "2022-03-22T13:56:22.542418127Z Fusce auctor faucibus libero id venenatis. Etiam sodales, odio eu cursus efficitur, quam sem blandit ex, quis porttitor enim dui quis lectus. In id tincidunt felis.",
+                "2022-03-22T13:56:22.542418137Z Phasellus non ex quis arcu tempus faucibus molestie in sapien.",
+                "2022-03-22T13:56:22.542418147Z Duis tristique semper dolor, vitae pulvinar risus.",
+                "2022-03-22T13:56:22.542418157Z Aliquam tortor elit, luctus nec tortor eget, porta tristique nulla.",
+                "2022-03-22T13:56:22.542418167Z Nulla eget mollis ipsum.",
+            ];
+
+            createContainerRequest(activeUser, "test_container_request", "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (
+                containerRequest
+            ) {
+                cy.appendLog(adminUser.token, containerRequest.uuid, "node-info.txt", nodeInfoLogs).as("nodeInfoLogs");
+                cy.appendLog(adminUser.token, containerRequest.uuid, "crunch-run.txt", crunchRunLogs).as("crunchRunLogs");
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", stdoutLogs).as("stdoutLogs");
+
+                cy.getAll("@stdoutLogs", "@nodeInfoLogs", "@crunchRunLogs").then(function () {
+                    cy.loginAs(activeUser);
+                    cy.goToPath(`/processes/${containerRequest.uuid}`);
+                    // Should show main logs by default
+                    cy.get("[data-cy=process-logs-filter]", { timeout: 7000 }).should("contain", "Main logs");
+                    cy.get("[data-cy=process-logs]")
+                        .should("contain", stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
+                        .and("not.contain", nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
+                        .and("contain", crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
+                    // Select 'All logs'
+                    cy.get("[data-cy=process-logs-filter]").click();
+                    cy.get("body").contains("li", "All logs").click();
+                    cy.get("[data-cy=process-logs]")
+                        .should("contain", stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
+                        .and("contain", nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
+                        .and("contain", crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
+                    // Select 'node-info' logs
+                    cy.get("[data-cy=process-logs-filter]").click();
+                    cy.get("body").contains("li", "node-info").click();
+                    cy.get("[data-cy=process-logs]")
+                        .should("not.contain", stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
+                        .and("contain", nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
+                        .and("not.contain", crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
+                    // Select 'stdout' logs
+                    cy.get("[data-cy=process-logs-filter]").click();
+                    cy.get("body").contains("li", "stdout").click();
+                    cy.get("[data-cy=process-logs]")
+                        .should("contain", stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
+                        .and("not.contain", nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
+                        .and("not.contain", crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
+                });
+            });
         });
 
+        it("sorts combined logs", function () {
+            const crName = "test_container_request";
+            createContainerRequest(activeUser, crName, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (containerRequest) {
+                cy.appendLog(adminUser.token, containerRequest.uuid, "node-info.txt", [
+                    "3: nodeinfo 1",
+                    "2: nodeinfo 2",
+                    "1: nodeinfo 3",
+                    "2: nodeinfo 4",
+                    "3: nodeinfo 5",
+                ]).as("node-info");
+
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", [
+                    "2023-07-18T20:14:48.128642814Z first",
+                    "2023-07-18T20:14:49.128642814Z third",
+                ]).as("stdout");
+
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stderr.txt", ["2023-07-18T20:14:48.528642814Z second"]).as("stderr");
 
-        // Force container_count for testing
-        let containerCount = 2;
-        cy.intercept({method: 'GET', url: '**/arvados/v1/container_requests/*'}, (req) => {
-            req.reply((res) => {
-                res.body.container_count = containerCount;
+                cy.loginAs(activeUser);
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-details]").should("contain", crName);
+                cy.get("[data-cy=process-logs]").should("contain", "No logs yet");
+
+                cy.getAll("@node-info", "@stdout", "@stderr").then(() => {
+                    // Verify sorted main logs
+                    cy.get("[data-cy=process-logs] pre", { timeout: 7000 }).eq(0).should("contain", "2023-07-18T20:14:48.128642814Z first");
+                    cy.get("[data-cy=process-logs] pre").eq(1).should("contain", "2023-07-18T20:14:48.528642814Z second");
+                    cy.get("[data-cy=process-logs] pre").eq(2).should("contain", "2023-07-18T20:14:49.128642814Z third");
+
+                    // Switch to All logs
+                    cy.get("[data-cy=process-logs-filter]").click();
+                    cy.get("body").contains("li", "All logs").click();
+                    // Verify non-sorted lines were preserved
+                    cy.get("[data-cy=process-logs] pre").eq(0).should("contain", "3: nodeinfo 1");
+                    cy.get("[data-cy=process-logs] pre").eq(1).should("contain", "2: nodeinfo 2");
+                    cy.get("[data-cy=process-logs] pre").eq(2).should("contain", "1: nodeinfo 3");
+                    cy.get("[data-cy=process-logs] pre").eq(3).should("contain", "2: nodeinfo 4");
+                    cy.get("[data-cy=process-logs] pre").eq(4).should("contain", "3: nodeinfo 5");
+                    // Verify sorted logs
+                    cy.get("[data-cy=process-logs] pre").eq(5).should("contain", "2023-07-18T20:14:48.128642814Z first");
+                    cy.get("[data-cy=process-logs] pre").eq(6).should("contain", "2023-07-18T20:14:48.528642814Z second");
+                    cy.get("[data-cy=process-logs] pre").eq(7).should("contain", "2023-07-18T20:14:49.128642814Z third");
+                });
             });
         });
 
-        cy.getAll('@containerRequest').then(function([containerRequest]) {
-            cy.goToPath(`/processes/${containerRequest.uuid}`);
-            cy.get('[data-cy=process-runtime-status-retry-warning]', {timeout: 7000})
-                .should('contain', 'Process retried 1 time');
+        it("preserves original ordering of lines within the same log type", function () {
+            const crName = "test_container_request";
+            createContainerRequest(activeUser, crName, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (containerRequest) {
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", [
+                    // Should come first
+                    "2023-07-18T20:14:46.000000000Z A out 1",
+                    // Comes fourth in a contiguous block
+                    "2023-07-18T20:14:48.128642814Z A out 2",
+                    "2023-07-18T20:14:48.128642814Z X out 3",
+                    "2023-07-18T20:14:48.128642814Z A out 4",
+                ]).as("stdout");
+
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stderr.txt", [
+                    // Comes second
+                    "2023-07-18T20:14:47.000000000Z Z err 1",
+                    // Comes third in a contiguous block
+                    "2023-07-18T20:14:48.128642814Z B err 2",
+                    "2023-07-18T20:14:48.128642814Z C err 3",
+                    "2023-07-18T20:14:48.128642814Z Y err 4",
+                    "2023-07-18T20:14:48.128642814Z Z err 5",
+                    "2023-07-18T20:14:48.128642814Z A err 6",
+                ]).as("stderr");
+
+                cy.loginAs(activeUser);
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-details]").should("contain", crName);
+                cy.get("[data-cy=process-logs]").should("contain", "No logs yet");
+
+                cy.getAll("@stdout", "@stderr").then(() => {
+                    // Switch to All logs
+                    cy.get("[data-cy=process-logs-filter]").click();
+                    cy.get("body").contains("li", "All logs").click();
+                    // Verify sorted logs
+                    cy.get("[data-cy=process-logs] pre").eq(0).should("contain", "2023-07-18T20:14:46.000000000Z A out 1");
+                    cy.get("[data-cy=process-logs] pre").eq(1).should("contain", "2023-07-18T20:14:47.000000000Z Z err 1");
+                    cy.get("[data-cy=process-logs] pre").eq(2).should("contain", "2023-07-18T20:14:48.128642814Z B err 2");
+                    cy.get("[data-cy=process-logs] pre").eq(3).should("contain", "2023-07-18T20:14:48.128642814Z C err 3");
+                    cy.get("[data-cy=process-logs] pre").eq(4).should("contain", "2023-07-18T20:14:48.128642814Z Y err 4");
+                    cy.get("[data-cy=process-logs] pre").eq(5).should("contain", "2023-07-18T20:14:48.128642814Z Z err 5");
+                    cy.get("[data-cy=process-logs] pre").eq(6).should("contain", "2023-07-18T20:14:48.128642814Z A err 6");
+                    cy.get("[data-cy=process-logs] pre").eq(7).should("contain", "2023-07-18T20:14:48.128642814Z A out 2");
+                    cy.get("[data-cy=process-logs] pre").eq(8).should("contain", "2023-07-18T20:14:48.128642814Z X out 3");
+                    cy.get("[data-cy=process-logs] pre").eq(9).should("contain", "2023-07-18T20:14:48.128642814Z A out 4");
+                });
+            });
         });
 
-        cy.getAll('@containerRequest').then(function([containerRequest]) {
-            containerCount = 3;
-            cy.goToPath(`/processes/${containerRequest.uuid}`);
-            cy.get('[data-cy=process-runtime-status-retry-warning]', {timeout: 7000})
-                .should('contain', 'Process retried 2 times');
+        it("correctly generates sniplines", function () {
+            const SNIPLINE = `================ ✀ ================ ✀ ========= Some log(s) were skipped ========= ✀ ================ ✀ ================`;
+            const crName = "test_container_request";
+            createContainerRequest(activeUser, crName, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (containerRequest) {
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", [
+                    "X".repeat(63999) + "_" + "O".repeat(100) + "_" + "X".repeat(63999),
+                ]).as("stdout");
+
+                cy.loginAs(activeUser);
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-details]").should("contain", crName);
+                cy.get("[data-cy=process-logs]").should("contain", "No logs yet");
+
+                // Switch to stdout since lines are unsortable (no timestamp)
+                cy.get("[data-cy=process-logs-filter]").click();
+                cy.get("body").contains("li", "stdout").click();
+
+                cy.getAll("@stdout").then(() => {
+                    // Verify first 64KB and snipline
+                    cy.get("[data-cy=process-logs] pre", { timeout: 7000 })
+                        .eq(0)
+                        .should("contain", "X".repeat(63999) + "_\n" + SNIPLINE);
+                    // Verify last 64KB
+                    cy.get("[data-cy=process-logs] pre")
+                        .eq(1)
+                        .should("contain", "_" + "X".repeat(63999));
+                    // Verify none of the Os got through
+                    cy.get("[data-cy=process-logs] pre").should("not.contain", "O");
+                });
+            });
         });
     });
 
-
-    const testInputs = [
-        {
-            definition: {
-                "id": "#main/input_file",
-                "label": "Label Description",
-                "type": "File"
+    describe("I/O panel", function () {
+        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,
+                },
             },
-            input: {
-                "input_file": {
-                    "basename": "input1.tar",
-                    "class": "File",
-                    "location": "keep:00000000000000000000000000000000+01/input1.tar",
-                    "secondaryFiles": [
+            {
+                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": "input1-2.txt",
-                            "class": "File",
-                            "location": "keep:00000000000000000000000000000000+01/input1-2.txt"
+                            basename: "input2.tar",
+                            class: "File",
+                            location: "keep:00000000000000000000000000000000+02/input2.tar",
                         },
                         {
-                            "basename": "input1-3.txt",
-                            "class": "File",
-                            "location": "keep:00000000000000000000000000000000+01/input1-3.txt"
+                            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",
+                                },
+                            ],
                         },
                         {
-                            "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"
+                            $import: "import_path",
+                        },
+                    ],
+                },
             },
-            input: {
-                "input_int": 1,
-            }
-        },
-        {
-            definition: {
-                "id": "#main/input_long",
-                "type": "long"
+            {
+                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",
+                        },
+                        {
+                            $import: "import_path",
+                        },
+                    ],
+                },
             },
-            input: {
-                "input_long" : 1,
-            }
-        },
-        {
-            definition: {
-                "id": "#main/input_float",
-                "type": "float"
+            {
+                definition: {
+                    id: "#main/input_int_array",
+                    type: {
+                        items: "int",
+                        type: "array",
+                    },
+                },
+                input: {
+                    input_int_array: [
+                        1,
+                        3,
+                        5,
+                        {
+                            $import: "import_path",
+                        },
+                    ],
+                },
             },
-            input: {
-                "input_float": 1.5,
-            }
-        },
-        {
-            definition: {
-                "id": "#main/input_double",
-                "type": "double"
+            {
+                definition: {
+                    id: "#main/input_long_array",
+                    type: {
+                        items: "long",
+                        type: "array",
+                    },
+                },
+                input: {
+                    input_long_array: [
+                        10,
+                        20,
+                        {
+                            $import: "import_path",
+                        },
+                    ],
+                },
             },
-            input: {
-                "input_double": 1.3,
-            }
-        },
-        {
-            definition: {
-                "id": "#main/input_string",
-                "type": "string"
+            {
+                definition: {
+                    id: "#main/input_float_array",
+                    type: {
+                        items: "float",
+                        type: "array",
+                    },
+                },
+                input: {
+                    input_float_array: [
+                        10.2,
+                        10.4,
+                        10.6,
+                        {
+                            $import: "import_path",
+                        },
+                    ],
+                },
             },
-            input: {
-                "input_string": "Hello World",
-            }
-        },
-        {
-            definition: {
-                "id": "#main/input_file_array",
-                "type": {
-                  "items": "File",
-                  "type": "array"
-                }
+            {
+                definition: {
+                    id: "#main/input_double_array",
+                    type: {
+                        items: "double",
+                        type: "array",
+                    },
+                },
+                input: {
+                    input_double_array: [
+                        20.1,
+                        20.2,
+                        20.3,
+                        {
+                            $import: "import_path",
+                        },
+                    ],
+                },
             },
-            input: {
-                "input_file_array": [
-                    {
-                        "basename": "input2.tar",
-                        "class": "File",
-                        "location": "keep:00000000000000000000000000000000+02/input2.tar"
+            {
+                definition: {
+                    id: "#main/input_string_array",
+                    type: {
+                        items: "string",
+                        type: "array",
                     },
-                    {
-                        "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"
-                            }
-                        ]
+                },
+                input: {
+                    input_string_array: [
+                        "Hello",
+                        "World",
+                        "!",
+                        {
+                            $import: "import_path",
+                        },
+                    ],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_bool_include",
+                    type: "boolean",
+                },
+                input: {
+                    input_bool_include: {
+                        $include: "include_path",
                     },
-                    {
-                        "$import": "import_path"
-                    }
-                ]
-            }
-        },
-        {
-            definition: {
-                "id": "#main/input_dir_array",
-                "type": {
-                  "items": "Directory",
-                  "type": "array"
-                }
+                },
             },
-            input: {
-                "input_dir_array": [
-                    {
-                        "basename": "11111111111111111111111111111111+02",
-                        "class": "Directory",
-                        "location": "keep:11111111111111111111111111111111+02"
+            {
+                definition: {
+                    id: "#main/input_int_include",
+                    type: "int",
+                },
+                input: {
+                    input_int_include: {
+                        $include: "include_path",
                     },
-                    {
-                        "basename": "11111111111111111111111111111111+03",
-                        "class": "Directory",
-                        "location": "keep:11111111111111111111111111111111+03"
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_float_include",
+                    type: "float",
+                },
+                input: {
+                    input_float_include: {
+                        $include: "include_path",
                     },
-                    {
-                        "$import": "import_path"
-                    }
-                ]
-            }
-        },
-        {
-            definition: {
-                "id": "#main/input_int_array",
-                "type": {
-                  "items": "int",
-                  "type": "array"
-                }
+                },
             },
-            input: {
-                "input_int_array": [
-                    1,
-                    3,
-                    5,
-                    {
-                        "$import": "import_path"
-                    }
-                ]
-            }
-        },
-        {
-            definition: {
-                "id": "#main/input_long_array",
-                "type": {
-                  "items": "long",
-                  "type": "array"
-                }
+            {
+                definition: {
+                    id: "#main/input_string_include",
+                    type: "string",
+                },
+                input: {
+                    input_string_include: {
+                        $include: "include_path",
+                    },
+                },
             },
-            input: {
-                "input_long_array": [
-                    10,
-                    20,
-                    {
-                        "$import": "import_path"
-                    }
-                ]
-            }
-        },
-        {
-            definition: {
-                "id": "#main/input_float_array",
-                "type": {
-                  "items": "float",
-                  "type": "array"
-                }
+            {
+                definition: {
+                    id: "#main/input_file_include",
+                    type: "File",
+                },
+                input: {
+                    input_file_include: {
+                        $include: "include_path",
+                    },
+                },
             },
-            input: {
-                "input_float_array": [
-                    10.2,
-                    10.4,
-                    10.6,
-                    {
-                        "$import": "import_path"
-                    }
-                ]
-            }
-        },
-        {
-            definition: {
-                "id": "#main/input_double_array",
-                "type": {
-                  "items": "double",
-                  "type": "array"
-                }
+            {
+                definition: {
+                    id: "#main/input_directory_include",
+                    type: "Directory",
+                },
+                input: {
+                    input_directory_include: {
+                        $include: "include_path",
+                    },
+                },
             },
-            input: {
-                "input_double_array": [
-                    20.1,
-                    20.2,
-                    20.3,
-                    {
-                        "$import": "import_path"
-                    }
-                ]
-            }
-        },
-        {
-            definition: {
-                "id": "#main/input_string_array",
-                "type": {
-                  "items": "string",
-                  "type": "array"
-                }
+            {
+                definition: {
+                    id: "#main/input_file_url",
+                    type: "File",
+                },
+                input: {
+                    input_file_url: {
+                        basename: "index.html",
+                        class: "File",
+                        location: "http://example.com/index.html",
+                    },
+                },
             },
-            input: {
-                "input_string_array": [
-                    "Hello",
-                    "World",
-                    "!",
-                    {
-                        "$import": "import_path"
-                    }
-                ]
-            }
-        },
-        {
-            definition: {
-                "id": "#main/input_bool_include",
-                "type": "boolean"
+        ];
+
+        const testOutputs = [
+            {
+                definition: {
+                    id: "#main/output_file",
+                    label: "Label Description",
+                    type: "File",
+                },
+                output: {
+                    output_file: {
+                        basename: "cat.png",
+                        class: "File",
+                        location: "cat.png",
+                    },
+                },
             },
-            input: {
-                "input_bool_include": {
-                    "$include": "include_path"
-                }
-            }
-        },
-        {
-            definition: {
-                "id": "#main/input_int_include",
-                "type": "int"
+            {
+                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",
+                            },
+                        ],
+                    },
+                },
             },
-            input: {
-                "input_int_include": {
-                    "$include": "include_path"
-                }
-            }
-        },
-        {
-            definition: {
-                "id": "#main/input_float_include",
-                "type": "float"
+            {
+                definition: {
+                    id: "#main/output_dir",
+                    doc: ["Doc desc 1", "Doc desc 2"],
+                    type: "Directory",
+                },
+                output: {
+                    output_dir: {
+                        basename: "outdir1",
+                        class: "Directory",
+                        location: "outdir1",
+                    },
+                },
             },
-            input: {
-                "input_float_include": {
-                    "$include": "include_path"
-                }
-            }
-        },
-        {
-            definition: {
-                "id": "#main/input_string_include",
-                "type": "string"
+            {
+                definition: {
+                    id: "#main/output_bool",
+                    type: "boolean",
+                },
+                output: {
+                    output_bool: true,
+                },
             },
-            input: {
-                "input_string_include": {
-                    "$include": "include_path"
-                }
-            }
-        },
-        {
-            definition: {
-                "id": "#main/input_file_include",
-                "type": "File"
+            {
+                definition: {
+                    id: "#main/output_int",
+                    type: "int",
+                },
+                output: {
+                    output_int: 1,
+                },
             },
-            input: {
-                "input_file_include": {
-                    "$include": "include_path"
-                }
-            }
-        },
-        {
-            definition: {
-                "id": "#main/input_directory_include",
-                "type": "Directory"
+            {
+                definition: {
+                    id: "#main/output_long",
+                    type: "long",
+                },
+                output: {
+                    output_long: 1,
+                },
             },
-            input: {
-                "input_directory_include": {
-                    "$include": "include_path"
-                }
-            }
-        },
-        {
-            definition: {
-                "id": "#main/input_file_url",
-                "type": "File"
+            {
+                definition: {
+                    id: "#main/output_float",
+                    type: "float",
+                },
+                output: {
+                    output_float: 100.5,
+                },
             },
-            input: {
-                "input_file_url": {
-                    "basename": "index.html",
-                    "class": "File",
-                    "location": "http://example.com/index.html"
-                  }
-            }
-        }
-    ];
-
-    const testOutputs = [
-        {
-            definition: {
-                "id": "#main/output_file",
-                "label": "Label Description",
-                "type": "File"
+            {
+                definition: {
+                    id: "#main/output_double",
+                    type: "double",
+                },
+                output: {
+                    output_double: 100.3,
+                },
             },
-            output: {
-                "output_file": {
-                    "basename": "cat.png",
-                    "class": "File",
-                    "location": "cat.png"
-                }
-            }
-        },
-        {
-            definition: {
-                "id": "#main/output_file_with_secondary",
-                "doc": "Doc Description",
-                "type": "File"
+            {
+                definition: {
+                    id: "#main/output_string",
+                    type: "string",
+                },
+                output: {
+                    output_string: "Hello output",
+                },
             },
-            output: {
-                "output_file_with_secondary": {
-                    "basename": "main.dat",
-                    "class": "File",
-                    "location": "main.dat",
-                    "secondaryFiles": [
+            {
+                definition: {
+                    id: "#main/output_file_array",
+                    type: {
+                        items: "File",
+                        type: "array",
+                    },
+                },
+                output: {
+                    output_file_array: [
                         {
-                            "basename": "secondary.dat",
-                            "class": "File",
-                            "location": "secondary.dat"
+                            basename: "output2.tar",
+                            class: "File",
+                            location: "output2.tar",
                         },
                         {
-                            "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"
-                }
+                            basename: "output3.tar",
+                            class: "File",
+                            location: "output3.tar",
+                        },
+                    ],
+                },
             },
-            output: {
-                "output_file_array": [
-                    {
-                        "basename": "output2.tar",
-                        "class": "File",
-                        "location": "output2.tar"
+            {
+                definition: {
+                    id: "#main/output_dir_array",
+                    type: {
+                        items: "Directory",
+                        type: "array",
                     },
-                    {
-                        "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",
+                        },
+                    ],
+                },
             },
-            output: {
-                "output_dir_array": [
-                    {
-                        "basename": "outdir2",
-                        "class": "Directory",
-                        "location": "outdir2"
+            {
+                definition: {
+                    id: "#main/output_int_array",
+                    type: {
+                        items: "int",
+                        type: "array",
                     },
-                    {
-                        "basename": "outdir3",
-                        "class": "Directory",
-                        "location": "outdir3"
-                    }
-                ]
-            }
-        },
-        {
-            definition: {
-                "id": "#main/output_int_array",
-                "type": {
-                    "items": "int",
-                    "type": "array"
-                }
+                },
+                output: {
+                    output_int_array: [10, 11, 12],
+                },
             },
-            output: {
-                "output_int_array": [
-                    10,
-                    11,
-                    12
-                ]
-            }
-        },
-        {
-            definition: {
-                "id": "#main/output_long_array",
-                "type": {
-                    "items": "long",
-                    "type": "array"
-                }
+            {
+                definition: {
+                    id: "#main/output_long_array",
+                    type: {
+                        items: "long",
+                        type: "array",
+                    },
+                },
+                output: {
+                    output_long_array: [51, 52],
+                },
             },
-            output: {
-                "output_long_array": [
-                    51,
-                    52
-                ]
-            }
-        },
-        {
-            definition: {
-                "id": "#main/output_float_array",
-                "type": {
-                    "items": "float",
-                    "type": "array"
-                }
+            {
+                definition: {
+                    id: "#main/output_float_array",
+                    type: {
+                        items: "float",
+                        type: "array",
+                    },
+                },
+                output: {
+                    output_float_array: [100.2, 100.4, 100.6],
+                },
             },
-            output: {
-                "output_float_array": [
-                    100.2,
-                    100.4,
-                    100.6
-                ]
-            }
-        },
-        {
-            definition: {
-                "id": "#main/output_double_array",
-                "type": {
-                    "items": "double",
-                    "type": "array"
-                }
+            {
+                definition: {
+                    id: "#main/output_double_array",
+                    type: {
+                        items: "double",
+                        type: "array",
+                    },
+                },
+                output: {
+                    output_double_array: [100.1, 100.2, 100.3],
+                },
             },
-            output: {
-                "output_double_array": [
-                    100.1,
-                    100.2,
-                    100.3
-                ]
-            }
-        },
-        {
-            definition: {
-                "id": "#main/output_string_array",
-                "type": {
-                    "items": "string",
-                    "type": "array"
-                }
+            {
+                definition: {
+                    id: "#main/output_string_array",
+                    type: {
+                        items: "string",
+                        type: "array",
+                    },
+                },
+                output: {
+                    output_string_array: ["Hello", "Output", "!"],
+                },
             },
-            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));
+        ];
+
+        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 {
-                        cy.contains(val);
+                        if (val) {
+                            if (Array.isArray(val)) {
+                                val.forEach(v => cy.contains(v));
+                            } else {
+                                cy.contains(val);
+                            }
+                        }
+                        if (collection) {
+                            cy.contains(collection);
+                        }
                     }
-                }
-                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))
-                            }]
-                        }
-                    };
+        };
+
+        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);
 
-            // 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,
-                }
-            });
+                cy.goToPath(`/collections/${testOutputCollection.uuid}`);
 
-            // 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)), {})
-            });
+                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");
+                });
 
-            // Stub webdav response, points to output json
-            cy.intercept({method: 'PROPFIND', url: '*'}, {
-                fixture: 'webdav-propfind-outputs.xml',
+                cy.getCollection(activeUser.token, testOutputCollection.uuid).as("testOutputCollection");
             });
-        });
 
-        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_file_array', null, null, 'Cannot display value', undefined, true);
-                    verifyIOParameter('input_dir_array', null, null, '/', '11111111111111111111111111111111+02');
-                    verifyIOParameter('input_dir_array', null, null, '/', '11111111111111111111111111111111+03', true);
-                    verifyIOParameter('input_dir_array', null, null, 'Cannot display value', undefined, true);
-                    verifyIOParameter('input_int_array', null, null, ["1", "3", "5", "Cannot display value"]);
-                    verifyIOParameter('input_long_array', null, null, ["10", "20", "Cannot display value"]);
-                    verifyIOParameter('input_float_array', null, null, ["10.2", "10.4", "10.6", "Cannot display value"]);
-                    verifyIOParameter('input_double_array', null, null, ["20.1", "20.2", "20.3", "Cannot display value"]);
-                    verifyIOParameter('input_string_array', null, null, ["Hello", "World", "!", "Cannot display value"]);
-                    verifyIOParameter('input_bool_include', null, null, "Cannot display value");
-                    verifyIOParameter('input_int_include', null, null, "Cannot display value");
-                    verifyIOParameter('input_float_include', null, null, "Cannot display value");
-                    verifyIOParameter('input_string_include', null, null, "Cannot display value");
-                    verifyIOParameter('input_file_include', null, null, "Cannot display value");
-                    verifyIOParameter('input_directory_include', null, null, "Cannot display value");
-                    verifyIOParameter('input_file_url', null, null, "http://example.com/index.html");
-                });
-            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", "!"]);
+            // 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),
+                                    },
+                                ],
+                            },
+                        };
+                    });
                 });
-        });
-    });
 
-    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/${testOutputCollection.uuid}*` },
+                    {
+                        statusCode: 200,
+                        body: {
+                            uuid: testOutputCollection.uuid,
+                            portable_data_hash: testOutputCollection.portable_data_hash,
+                        },
                     }
-                };
-            });
-        });
+                );
 
-        // 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: {}
-        });
+                // 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), {}),
+                    }
+                );
 
-        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)
+                // 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').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');
+            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_file_array", null, null, "Cannot display value", undefined, true);
+                        verifyIOParameter("input_dir_array", null, null, "/", "11111111111111111111111111111111+02");
+                        verifyIOParameter("input_dir_array", null, null, "/", "11111111111111111111111111111111+03", true);
+                        verifyIOParameter("input_dir_array", null, null, "Cannot display value", undefined, true);
+                        verifyIOParameter("input_int_array", null, null, ["1", "3", "5", "Cannot display value"]);
+                        verifyIOParameter("input_long_array", null, null, ["10", "20", "Cannot display value"]);
+                        verifyIOParameter("input_float_array", null, null, ["10.2", "10.4", "10.6", "Cannot display value"]);
+                        verifyIOParameter("input_double_array", null, null, ["20.1", "20.2", "20.3", "Cannot display value"]);
+                        verifyIOParameter("input_string_array", null, null, ["Hello", "World", "!", "Cannot display value"]);
+                        verifyIOParameter("input_bool_include", null, null, "Cannot display value");
+                        verifyIOParameter("input_int_include", null, null, "Cannot display value");
+                        verifyIOParameter("input_float_include", null, null, "Cannot display value");
+                        verifyIOParameter("input_string_include", null, null, "Cannot display value");
+                        verifyIOParameter("input_file_include", null, null, "Cannot display value");
+                        verifyIOParameter("input_directory_include", null, null, "Cannot display value");
+                        verifyIOParameter("input_file_url", null, null, "http://example.com/index.html");
                     });
-                });
-            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');
+                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({ waitForAnimations: false });
+                        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/";
 
-    it('allows copying processes', function() {
-        const crName = 'first_container_request';
-        const copiedCrName = 'copied_container_request';
-        createContainerRequest(
-            activeUser,
-            crName,
-            'arvados/jobs',
-            ['echo', 'hello world'],
-            false, 'Committed')
-        .then(function(containerRequest) {
             cy.loginAs(activeUser);
-            cy.goToPath(`/processes/${containerRequest.uuid}`);
-            cy.get('[data-cy=process-details]').should('contain', crName);
-
-            cy.get('[data-cy=process-details]').find('button[title="More options"]').click();
-            cy.get('ul[data-cy=context-menu]').contains("Copy and re-run process").click();
-        });
-
-        cy.get('[data-cy=form-dialog]').within(() => {
-            cy.get('input[name=name]').clear().type(copiedCrName);
-            cy.get('[data-cy=projects-tree-home-tree-picker]').click();
-            cy.get('[data-cy=form-submit-btn]').click();
-        });
-
-        cy.get('[data-cy=process-details]').should('contain', copiedCrName);
-        cy.get('[data-cy=process-details]').find('button').contains('Run');
-    });
 
-    const getFakeContainer = (fakeContainerUuid) => ({
-        href: `/containers/${fakeContainerUuid}`,
-        kind: "arvados#container",
-        etag: "ecfosljpnxfari9a8m7e4yv06",
-        uuid: fakeContainerUuid,
-        owner_uuid: "zzzzz-tpzed-000000000000000",
-        created_at: "2023-02-13T15:55:47.308915000Z",
-        modified_by_client_uuid: "zzzzz-ozdt8-q6dzdi1lcc03155",
-        modified_by_user_uuid: "zzzzz-tpzed-000000000000000",
-        modified_at: "2023-02-15T19:12:45.987086000Z",
-        command: [
-          "arvados-cwl-runner",
-          "--api=containers",
-          "--local",
-          "--project-uuid=zzzzz-j7d0g-yr18k784zplfeza",
-          "/var/lib/cwl/workflow.json#main",
-          "/var/lib/cwl/cwl.input.json",
-        ],
-        container_image: "4ad7d11381df349e464694762db14e04+303",
-        cwd: "/var/spool/cwl",
-        environment: {},
-        exit_code: null,
-        finished_at: null,
-        locked_by_uuid: null,
-        log: null,
-        output: null,
-        output_path: "/var/spool/cwl",
-        progress: null,
-        runtime_constraints: {
-          API: true,
-          cuda: {
-            device_count: 0,
-            driver_version: "",
-            hardware_capability: "",
-          },
-          keep_cache_disk: 2147483648,
-          keep_cache_ram: 0,
-          ram: 1342177280,
-          vcpus: 1,
-        },
-        runtime_status: {},
-        started_at: null,
-        auth_uuid: null,
-        scheduling_parameters: {
-          max_run_time: 0,
-          partitions: [],
-          preemptible: false,
-        },
-        runtime_user_uuid: "zzzzz-tpzed-vllbpebicy84rd5",
-        runtime_auth_scopes: ["all"],
-        lock_count: 2,
-        gateway_address: null,
-        interactive_session_started: false,
-        output_storage_classes: ["default"],
-        output_properties: {},
-        cost: 0.0,
-        subrequests_cost: 0.0,
-      });
-
-    it('shows cancel button when appropriate', function() {
-        // Ignore collection requests
-        cy.intercept({method: 'GET', url: `**/arvados/v1/collections/*`}, {
-            statusCode: 200,
-            body: {}
-        });
-
-        // Uncommitted container
-        const crUncommitted = `Test process ${Math.floor(Math.random() * 999999)}`;
-        createContainerRequest(
-            activeUser,
-            crUncommitted,
-            'arvados/jobs',
-            ['echo', 'hello world'],
-            false, 'Uncommitted')
-        .then(function(containerRequest) {
-            // Navigate to process and verify run / cancel button
-            cy.goToPath(`/processes/${containerRequest.uuid}`);
-            cy.waitForDom();
-            cy.get('[data-cy=process-details]').should('contain', crUncommitted);
-            cy.get('[data-cy=process-run-button]').should('exist');
-            cy.get('[data-cy=process-cancel-button]').should('not.exist');
-        });
-
-        // Queued container
-        const crQueued = `Test process ${Math.floor(Math.random() * 999999)}`;
-        const fakeCrUuid = 'zzzzz-dz642-000000000000001';
-        createContainerRequest(
-            activeUser,
-            crQueued,
-            'arvados/jobs',
-            ['echo', 'hello world'],
-            false, 'Committed')
-        .then(function(containerRequest) {
-            // Fake container uuid
-            cy.intercept({method: 'GET', url: `**/arvados/v1/container_requests/${containerRequest.uuid}`}, (req) => {
-                req.reply((res) => {
-                    res.body.output_uuid = fakeCrUuid;
-                    res.body.priority = 500;
-                    res.body.state = "Committed";
+            // 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),
+                                },
+                            ],
+                        },
+                    };
                 });
             });
 
-            // Fake container
-            const container = getFakeContainer(fakeCrUuid);
-            cy.intercept({method: 'GET', url: `**/arvados/v1/container/${fakeCrUuid}`}, {
-                statusCode: 200,
-                body: {...container, state: "Queued", priority: 500}
-            });
-
-            // Navigate to process and verify cancel button
-            cy.goToPath(`/processes/${containerRequest.uuid}`);
-            cy.waitForDom();
-            cy.get('[data-cy=process-details]').should('contain', crQueued);
-            cy.get('[data-cy=process-cancel-button]').contains('Cancel');
-        });
+            // Stub fake output collection
+            cy.intercept(
+                { method: "GET", url: `**/arvados/v1/collections/${fakeOutputUUID}*` },
+                {
+                    statusCode: 200,
+                    body: {
+                        uuid: fakeOutputUUID,
+                        portable_data_hash: fakeOutputPDH,
+                    },
+                }
+            );
 
-        // Locked container
-        const crLocked = `Test process ${Math.floor(Math.random() * 999999)}`;
-        const fakeCrLockedUuid = 'zzzzz-dz642-000000000000002';
-        createContainerRequest(
-            activeUser,
-            crLocked,
-            'arvados/jobs',
-            ['echo', 'hello world'],
-            false, 'Committed')
-        .then(function(containerRequest) {
-            // Fake container uuid
-            cy.intercept({method: 'GET', url: `**/arvados/v1/container_requests/${containerRequest.uuid}`}, (req) => {
-                req.reply((res) => {
-                    res.body.output_uuid = fakeCrLockedUuid;
-                    res.body.priority = 500;
-                    res.body.state = "Committed";
-                });
-            });
+            // Stub fake output json
+            cy.intercept(
+                { method: "GET", url: `**/c%3D${fakeOutputUUID}/cwl.output.json` },
+                {
+                    statusCode: 200,
+                    body: {},
+                }
+            );
 
-            // Fake container
-            const container = getFakeContainer(fakeCrLockedUuid);
-            cy.intercept({method: 'GET', url: `**/arvados/v1/container/${fakeCrLockedUuid}`}, {
-                statusCode: 200,
-                body: {...container, state: "Locked", priority: 500}
+            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),
+                    }
+                );
             });
 
-            // Navigate to process and verify cancel button
-            cy.goToPath(`/processes/${containerRequest.uuid}`);
-            cy.waitForDom();
-            cy.get('[data-cy=process-details]').should('contain', crLocked);
-            cy.get('[data-cy=process-cancel-button]').contains('Cancel');
-        });
+            createContainerRequest(activeUser, "test_container_request", "arvados/jobs", ["echo", "hello world"], false, "Committed").as(
+                "containerRequest"
+            );
 
-        // On Hold container
-        const crOnHold = `Test process ${Math.floor(Math.random() * 999999)}`;
-        const fakeCrOnHoldUuid = 'zzzzz-dz642-000000000000003';
-        createContainerRequest(
-            activeUser,
-            crOnHold,
-            'arvados/jobs',
-            ['echo', 'hello world'],
-            false, 'Committed')
-        .then(function(containerRequest) {
-            // Fake container uuid
-            cy.intercept({method: 'GET', url: `**/arvados/v1/container_requests/${containerRequest.uuid}`}, (req) => {
-                req.reply((res) => {
-                    res.body.output_uuid = fakeCrOnHoldUuid;
-                    res.body.priority = 0;
-                    res.body.state = "Committed";
-                });
-            });
-
-            // Fake container
-            const container = getFakeContainer(fakeCrOnHoldUuid);
-            cy.intercept({method: 'GET', url: `**/arvados/v1/container/${fakeCrOnHoldUuid}`}, {
-                statusCode: 200,
-                body: {...container, state: "Queued", priority: 0}
+            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");
+                        });
+                    });
             });
-
-            // Navigate to process and verify cancel button
-            cy.goToPath(`/processes/${containerRequest.uuid}`);
-            cy.waitForDom();
-            cy.get('[data-cy=process-details]').should('contain', crOnHold);
-            cy.get('[data-cy=process-run-button]').should('exist');
-            cy.get('[data-cy=process-cancel-button]').should('not.exist');
         });
     });
-
 });
index 6a3043d668c05ced8cf17d25a356274cf4fffdd3..e6185c108e94c454970ad2605d83f3bc8b4637a2 100644 (file)
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-describe('Project tests', function() {
+describe("Project tests", function () {
     let activeUser;
     let adminUser;
 
-    before(function() {
+    before(function () {
         // Only set up common users once. These aren't set up as aliases because
         // aliases are cleaned up after every test. Also it doesn't make sense
         // to set the same users on beforeEach() over and over again, so we
         // separate a little from Cypress' 'Best Practices' here.
-        cy.getUser('admin', 'Admin', 'User', true, true)
-            .as('adminUser').then(function() {
+        cy.getUser("admin", "Admin", "User", true, true)
+            .as("adminUser")
+            .then(function () {
                 adminUser = this.adminUser;
-            }
-        );
-        cy.getUser('user', 'Active', 'User', false, true)
-            .as('activeUser').then(function() {
+            });
+        cy.getUser("user", "Active", "User", false, true)
+            .as("activeUser")
+            .then(function () {
                 activeUser = this.activeUser;
-            }
-        );
+            });
     });
 
-    beforeEach(function() {
+    beforeEach(function () {
         cy.clearCookies();
         cy.clearLocalStorage();
     });
 
-    it('creates a new project with multiple properties', function() {
+    it("creates a new project with multiple properties", function () {
         const projName = `Test project (${Math.floor(999999 * Math.random())})`;
         cy.loginAs(activeUser);
-        cy.get('[data-cy=side-panel-button]').click();
-        cy.get('[data-cy=side-panel-new-project]').click();
-        cy.get('[data-cy=form-dialog]')
-            .should('contain', 'New Project')
+        cy.get("[data-cy=side-panel-button]").click();
+        cy.get("[data-cy=side-panel-new-project]").click();
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "New Project")
             .within(() => {
-                cy.get('[data-cy=name-field]').within(() => {
-                    cy.get('input').type(projName);
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(projName);
                 });
-
             });
         // Key: Color (IDTAGCOLORS) - Value: Magenta (IDVALCOLORS3)
-        cy.get('[data-cy=form-dialog]').should('not.contain', 'Color: Magenta');
-        cy.get('[data-cy=resource-properties-form]').within(() => {
-            cy.get('[data-cy=property-field-key]').within(() => {
-                cy.get('input').type('Color');
+        cy.get("[data-cy=form-dialog]").should("not.contain", "Color: Magenta");
+        cy.get("[data-cy=resource-properties-form]").within(() => {
+            cy.get("[data-cy=property-field-key]").within(() => {
+                cy.get("input").type("Color");
             });
-            cy.get('[data-cy=property-field-value]').within(() => {
-                cy.get('input').type('Magenta');
+            cy.get("[data-cy=property-field-value]").within(() => {
+                cy.get("input").type("Magenta");
             });
             cy.root().submit();
-            cy.get('[data-cy=property-field-value]').within(() => {
-                cy.get('input').type('Pink');
+            cy.get("[data-cy=property-field-value]").within(() => {
+                cy.get("input").type("Pink");
             });
             cy.root().submit();
-            cy.get('[data-cy=property-field-value]').within(() => {
-                cy.get('input').type('Yellow');
+            cy.get("[data-cy=property-field-value]").within(() => {
+                cy.get("input").type("Yellow");
             });
             cy.root().submit();
         });
         // Confirm proper vocabulary labels are displayed on the UI.
-        cy.get('[data-cy=form-dialog]').should('contain', 'Color: Magenta');
-        cy.get('[data-cy=form-dialog]').should('contain', 'Color: Pink');
-        cy.get('[data-cy=form-dialog]').should('contain', 'Color: Yellow');
+        cy.get("[data-cy=form-dialog]").should("contain", "Color: Magenta");
+        cy.get("[data-cy=form-dialog]").should("contain", "Color: Pink");
+        cy.get("[data-cy=form-dialog]").should("contain", "Color: Yellow");
 
-        cy.get('[data-cy=resource-properties-form]').within(() => {
-            cy.get('[data-cy=property-field-key]').within(() => {
-                cy.get('input').focus();
+        cy.get("[data-cy=resource-properties-form]").within(() => {
+            cy.get("[data-cy=property-field-key]").within(() => {
+                cy.get("input").focus();
             });
-            cy.get('[data-cy=property-field-key]').should('not.contain', 'Color');
+            cy.get("[data-cy=property-field-key]").should("not.contain", "Color");
         });
 
         // Create project and confirm the properties' real values.
-        cy.get('[data-cy=form-submit-btn]').click();
-        cy.get('[data-cy=breadcrumb-last]').should('contain', projName);
-        cy.doRequest('GET', '/arvados/v1/groups', null, {
+        cy.get("[data-cy=form-submit-btn]").click();
+        cy.get("[data-cy=breadcrumb-last]").should("contain", projName);
+        cy.doRequest("GET", "/arvados/v1/groups", null, {
             filters: `[["name", "=", "${projName}"], ["group_class", "=", "project"]]`,
         })
-        .its('body.items').as('projects')
-        .then(function() {
-            expect(this.projects).to.have.lengthOf(1);
-            expect(this.projects[0].properties).to.deep.equal(
-                // Pink is not in the test vocab
-                {IDTAGCOLORS: ['IDVALCOLORS3', 'Pink', 'IDVALCOLORS1']});
-        });
+            .its("body.items")
+            .as("projects")
+            .then(function () {
+                expect(this.projects).to.have.lengthOf(1);
+                expect(this.projects[0].properties).to.deep.equal(
+                    // Pink is not in the test vocab
+                    { IDTAGCOLORS: ["IDVALCOLORS3", "Pink", "IDVALCOLORS1"] }
+                );
+            });
 
         // Open project edit via breadcrumbs
-        cy.get('[data-cy=breadcrumbs]').contains(projName).rightclick();
-        cy.get('[data-cy=context-menu]').contains('Edit').click();
-        cy.get('[data-cy=form-dialog]').within(() => {
-            cy.get('[data-cy=resource-properties-list]').within(() => {
-                cy.get('div[role=button]').contains('Color: Magenta');
-                cy.get('div[role=button]').contains('Color: Pink');
-                cy.get('div[role=button]').contains('Color: Yellow');
+        cy.get("[data-cy=breadcrumbs]").contains(projName).rightclick();
+        cy.get("[data-cy=context-menu]").contains("Edit").click();
+        cy.get("[data-cy=form-dialog]").within(() => {
+            cy.get("[data-cy=resource-properties-list]").within(() => {
+                cy.get("div[role=button]").contains("Color: Magenta");
+                cy.get("div[role=button]").contains("Color: Pink");
+                cy.get("div[role=button]").contains("Color: Yellow");
             });
         });
         // Add another property
-        cy.get('[data-cy=resource-properties-form]').within(() => {
-            cy.get('[data-cy=property-field-key]').within(() => {
-                cy.get('input').type('Animal');
+        cy.get("[data-cy=resource-properties-form]").within(() => {
+            cy.get("[data-cy=property-field-key]").within(() => {
+                cy.get("input").type("Animal");
             });
-            cy.get('[data-cy=property-field-value]').within(() => {
-                cy.get('input').type('Dog');
+            cy.get("[data-cy=property-field-value]").within(() => {
+                cy.get("input").type("Dog");
             });
             cy.root().submit();
         });
-        cy.get('[data-cy=form-submit-btn]').click();
+        cy.get("[data-cy=form-submit-btn]").click({ force: true });
         // Reopen edit via breadcrumbs and verify properties
-        cy.get('[data-cy=breadcrumbs]').contains(projName).rightclick();
-        cy.get('[data-cy=context-menu]').contains('Edit').click();
-        cy.get('[data-cy=form-dialog]').within(() => {
-            cy.get('[data-cy=resource-properties-list]').within(() => {
-                cy.get('div[role=button]').contains('Color: Magenta');
-                cy.get('div[role=button]').contains('Color: Pink');
-                cy.get('div[role=button]').contains('Color: Yellow');
-                cy.get('div[role=button]').contains('Animal: Dog');
+        cy.get("[data-cy=breadcrumbs]").contains(projName).rightclick();
+        cy.get("[data-cy=context-menu]").contains("Edit").click();
+        cy.get("[data-cy=form-dialog]").within(() => {
+            cy.get("[data-cy=resource-properties-list]").within(() => {
+                cy.get("div[role=button]").contains("Color: Magenta");
+                cy.get("div[role=button]").contains("Color: Pink");
+                cy.get("div[role=button]").contains("Color: Yellow");
+                cy.get("div[role=button]").contains("Animal: Dog");
             });
         });
     });
 
-    it('creates a project without and with description', function() {
+    it("creates a project without and with description", function () {
         const projName = `Test project (${Math.floor(999999 * Math.random())})`;
         cy.loginAs(activeUser);
 
         // Create project
-        cy.get('[data-cy=side-panel-button]').click();
-        cy.get('[data-cy=side-panel-new-project]').click();
-        cy.get('[data-cy=form-dialog]')
-            .should('contain', 'New Project')
+        cy.get("[data-cy=side-panel-button]").click();
+        cy.get("[data-cy=side-panel-new-project]").click();
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "New Project")
             .within(() => {
-                cy.get('[data-cy=name-field]').within(() => {
-                    cy.get('input').type(projName);
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(projName);
                 });
             });
-        cy.get('[data-cy=form-submit-btn]').click();
+        cy.get("[data-cy=form-submit-btn]").click();
+        cy.get("[data-cy=form-dialog]").should("not.exist");
 
         const editProjectDescription = (name, type) => {
-            cy.get('[data-cy=side-panel-tree]').contains('Home Projects').click();
-            cy.get('[data-cy=project-panel] tbody tr').contains(name).rightclick();
-            cy.get('[data-cy=context-menu]').contains('Edit').click();
-            cy.get('[data-cy=form-dialog]').within(() => {
-                cy.get('div[contenteditable=true]')
-                    .click()
-                    .type(type);
-                cy.get('[data-cy=form-submit-btn]').click();
+            cy.get("[data-cy=side-panel-tree]").contains("Home Projects").click();
+            cy.get("[data-cy=project-panel] tbody tr").contains(name).rightclick({ force: true });
+            cy.get("[data-cy=context-menu]").contains("Edit").click();
+            cy.get("[data-cy=form-dialog]").within(() => {
+                cy.get("div[contenteditable=true]").click().type(type);
+                cy.get("[data-cy=form-submit-btn]").click();
             });
         };
 
         const verifyProjectDescription = (name, description) => {
-            cy.doRequest('GET', '/arvados/v1/groups', null, {
+            cy.doRequest("GET", "/arvados/v1/groups", null, {
                 filters: `[["name", "=", "${name}"], ["group_class", "=", "project"]]`,
             })
-            .its('body.items').as('projects')
-            .then(function() {
-                expect(this.projects).to.have.lengthOf(1);
-                expect(this.projects[0].description).to.equal(description);
-            });
+                .its("body.items")
+                .as("projects")
+                .then(function () {
+                    expect(this.projects).to.have.lengthOf(1);
+                    expect(this.projects[0].description).to.equal(description);
+                });
         };
 
         // Edit description
-        editProjectDescription(projName, 'Test description');
+        editProjectDescription(projName, "Test description");
 
         // Check description is set
         verifyProjectDescription(projName, "<p>Test description</p>");
 
         // Clear description
-        editProjectDescription(projName, '{selectall}{backspace}');
+        editProjectDescription(projName, "{selectall}{backspace}");
 
         // Check description is null
         verifyProjectDescription(projName, null);
 
         // Set description to contain whitespace
-        editProjectDescription(projName, '{selectall}{backspace}    x');
-        editProjectDescription(projName, '{backspace}');
+        editProjectDescription(projName, "{selectall}{backspace}    x");
+        editProjectDescription(projName, "{backspace}");
 
         // Check description is null
         verifyProjectDescription(projName, null);
-
     });
 
-    it('creates new project on home project and then a subproject inside it', function() {
-        const createProject = function(name, parentName) {
-            cy.get('[data-cy=side-panel-button]').click();
-            cy.get('[data-cy=side-panel-new-project]').click();
-            cy.get('[data-cy=form-dialog]')
-                .should('contain', 'New Project')
+    it('shows the appropriate buttons in the multiselect toolbar', () => {
+
+        const msButtonTooltips = [
+            'API Details',
+            'Add to Favorites',
+            'Copy to clipboard',
+            'Edit project',
+            'Freeze Project',
+            'Move to',
+            'Move to trash',
+            'New project',
+            'Open in new tab',
+            'Open with 3rd party client',
+            'Share',
+            'View details',
+        ];
+
+        cy.loginAs(activeUser);
+        const projName = `Test project (${Math.floor(999999 * Math.random())})`;
+        cy.get('[data-cy=side-panel-button]').click();
+        cy.get('[data-cy=side-panel-new-project]').click();
+        cy.get('[data-cy=form-dialog]')
+            .should('contain', 'New Project')
+            .within(() => {
+                cy.get('[data-cy=name-field]').within(() => {
+                    cy.get('input').type(projName);
+                });
+            })
+        cy.get("[data-cy=form-submit-btn]").click();
+        cy.waitForDom()
+        cy.go('back')
+
+        cy.get('[data-cy=data-table-row]').contains(projName).should('exist').parent().parent().parent().click()
+        cy.get('[data-cy=multiselect-button]').should('have.length', msButtonTooltips.length)
+        for (let i = 0; i < msButtonTooltips.length; i++) {
+            cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseover');
+            cy.get('body').contains(msButtonTooltips[i]).should('exist')
+            cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseout');
+        }
+    })
+
+    it("creates new project on home project and then a subproject inside it", function () {
+        const createProject = function (name, parentName) {
+            cy.get("[data-cy=side-panel-button]").click();
+            cy.get("[data-cy=side-panel-new-project]").click();
+            cy.get("[data-cy=form-dialog]")
+                .should("contain", "New Project")
                 .within(() => {
-                    cy.get('[data-cy=parent-field]').within(() => {
-                        cy.get('input').invoke('val').then((val) => {
-                            expect(val).to.include(parentName);
-                        });
+                    cy.get("[data-cy=parent-field]").within(() => {
+                        cy.get("input")
+                            .invoke("val")
+                            .then(val => {
+                                expect(val).to.include(parentName);
+                            });
                     });
-                    cy.get('[data-cy=name-field]').within(() => {
-                        cy.get('input').type(name);
+                    cy.get("[data-cy=name-field]").within(() => {
+                        cy.get("input").type(name);
                     });
                 });
-            cy.get('[data-cy=form-submit-btn]').click();
-        }
+            cy.get("[data-cy=form-submit-btn]").click();
+        };
 
         cy.loginAs(activeUser);
         cy.goToPath(`/projects/${activeUser.user.uuid}`);
-        cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects');
-        cy.get('[data-cy=breadcrumb-last]').should('not.exist');
+        cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
+        cy.get("[data-cy=breadcrumb-last]").should("not.exist");
         // Create new project
         const projName = `Test project (${Math.floor(999999 * Math.random())})`;
-        createProject(projName, 'Home project');
+        createProject(projName, "Home project");
         // Confirm that the user was taken to the newly created thing
-        cy.get('[data-cy=form-dialog]').should('not.exist');
-        cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects');
-        cy.get('[data-cy=breadcrumb-last]').should('contain', projName);
+        cy.get("[data-cy=form-dialog]").should("not.exist");
+        cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
+        cy.get("[data-cy=breadcrumb-last]").should("contain", projName);
         // Create a subproject
         const subProjName = `Test project (${Math.floor(999999 * Math.random())})`;
         createProject(subProjName, projName);
-        cy.get('[data-cy=form-dialog]').should('not.exist');
-        cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects');
-        cy.get('[data-cy=breadcrumb-last]').should('contain', subProjName);
+        cy.get("[data-cy=form-dialog]").should("not.exist");
+        cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
+        cy.get("[data-cy=breadcrumb-last]").should("contain", subProjName);
     });
 
-    it('attempts to use a preexisting name creating a project', function() {
+    it("attempts to use a preexisting name creating a project", function () {
         const name = `Test project ${Math.floor(Math.random() * 999999)}`;
         cy.createGroup(activeUser.token, {
             name: name,
-            group_class: 'project',
+            group_class: "project",
         });
         cy.loginAs(activeUser);
         cy.goToPath(`/projects/${activeUser.user.uuid}`);
 
         // Attempt to create new collection with a duplicate name
-        cy.get('[data-cy=side-panel-button]').click();
-        cy.get('[data-cy=side-panel-new-project]').click();
-        cy.get('[data-cy=form-dialog]')
-            .should('contain', 'New Project')
+        cy.get("[data-cy=side-panel-button]").click();
+        cy.get("[data-cy=side-panel-new-project]").click();
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "New Project")
             .within(() => {
-                cy.get('[data-cy=name-field]').within(() => {
-                    cy.get('input').type(name);
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(name);
                 });
-                cy.get('[data-cy=form-submit-btn]').click();
+                cy.get("[data-cy=form-submit-btn]").click();
             });
         // Error message should display, allowing editing the name
-        cy.get('[data-cy=form-dialog]').should('exist')
-            .and('contain', 'Project with the same name already exists')
+        cy.get("[data-cy=form-dialog]")
+            .should("exist")
+            .and("contain", "Project with the same name already exists")
             .within(() => {
-                cy.get('[data-cy=name-field]').within(() => {
-                    cy.get('input').type(' renamed');
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(" renamed");
                 });
-                cy.get('[data-cy=form-submit-btn]').click();
+                cy.get("[data-cy=form-submit-btn]").click();
             });
-        cy.get('[data-cy=form-dialog]').should('not.exist');
+        cy.get("[data-cy=form-dialog]").should("not.exist");
     });
 
-    it('navigates to the parent project after trashing the one being displayed', function() {
+    it("navigates to the parent project after trashing the one being displayed", function () {
         cy.createGroup(activeUser.token, {
             name: `Test root project ${Math.floor(Math.random() * 999999)}`,
-            group_class: 'project',
-        }).as('testRootProject').then(function() {
-            cy.createGroup(activeUser.token, {
-                name : `Test subproject ${Math.floor(Math.random() * 999999)}`,
-                group_class: 'project',
-                owner_uuid: this.testRootProject.uuid,
-            }).as('testSubProject');
-        });
-        cy.getAll('@testRootProject', '@testSubProject').then(function([testRootProject, testSubProject]) {
+            group_class: "project",
+        })
+            .as("testRootProject")
+            .then(function () {
+                cy.createGroup(activeUser.token, {
+                    name: `Test subproject ${Math.floor(Math.random() * 999999)}`,
+                    group_class: "project",
+                    owner_uuid: this.testRootProject.uuid,
+                }).as("testSubProject");
+            });
+        cy.getAll("@testRootProject", "@testSubProject").then(function ([testRootProject, testSubProject]) {
             cy.loginAs(activeUser);
 
             // Go to subproject and trash it.
             cy.goToPath(`/projects/${testSubProject.uuid}`);
-            cy.get('[data-cy=side-panel-tree]').should('contain', testSubProject.name);
-            cy.get('[data-cy=breadcrumb-last]')
-                .should('contain', testSubProject.name)
-                .rightclick();
-            cy.get('[data-cy=context-menu]').contains('Move to trash').click();
+            cy.get("[data-cy=side-panel-tree]").should("contain", testSubProject.name);
+            cy.get("[data-cy=breadcrumb-last]").should("contain", testSubProject.name).rightclick();
+            cy.get("[data-cy=context-menu]").contains("Move to trash").click();
 
             // Confirm that the parent project should be displayed.
-            cy.get('[data-cy=breadcrumb-last]').should('contain', testRootProject.name);
-            cy.url().should('contain', `/projects/${testRootProject.uuid}`);
-            cy.get('[data-cy=side-panel-tree]').should('not.contain', testSubProject.name);
+            cy.get("[data-cy=breadcrumb-last]").should("contain", testRootProject.name);
+            cy.url().should("contain", `/projects/${testRootProject.uuid}`);
+            cy.get("[data-cy=side-panel-tree]").should("not.contain", testSubProject.name);
 
             // Checks for bugfix #17637.
-            cy.get('[data-cy=not-found-content]').should('not.exist');
-            cy.get('[data-cy=not-found-page]').should('not.exist');
+            cy.get("[data-cy=not-found-content]").should("not.exist");
+            cy.get("[data-cy=not-found-page]").should("not.exist");
         });
     });
 
-    it('resets the search box only when navigating out of the current project', function() {
+    it("resets the search box only when navigating out of the current project", function () {
         const fooProjectNameA = `Test foo project ${Math.floor(Math.random() * 999999)}`;
         const fooProjectNameB = `Test foo project ${Math.floor(Math.random() * 999999)}`;
         const barProjectNameA = `Test bar project ${Math.floor(Math.random() * 999999)}`;
@@ -291,322 +335,323 @@ describe('Project tests', function() {
         [fooProjectNameA, fooProjectNameB, barProjectNameA].forEach(projName => {
             cy.createGroup(activeUser.token, {
                 name: projName,
-                group_class: 'project',
+                group_class: "project",
             });
         });
 
         cy.loginAs(activeUser);
-        cy.get('[data-cy=project-panel]')
-            .should('contain', fooProjectNameA)
-            .and('contain', fooProjectNameB)
-            .and('contain', barProjectNameA);
+        cy.get("[data-cy=project-panel]").should("contain", fooProjectNameA).and("contain", fooProjectNameB).and("contain", barProjectNameA);
 
-        cy.get('[data-cy=search-input]').type('foo');
-        cy.get('[data-cy=project-panel]')
-            .should('contain', fooProjectNameA)
-            .and('contain', fooProjectNameB)
-            .and('not.contain', barProjectNameA);
+        cy.get("[data-cy=search-input]").type("foo");
+        cy.get("[data-cy=project-panel]").should("contain", fooProjectNameA).and("contain", fooProjectNameB).and("not.contain", barProjectNameA);
 
         // Click on the table row to select it, search should remain the same.
-        cy.get(`p:contains(${fooProjectNameA})`)
-            .parent().parent().parent().parent().click();
-        cy.get('[data-cy=search-input] input').should('have.value', 'foo');
+        cy.get(`p:contains(${fooProjectNameA})`).parent().parent().parent().parent().click();
+        cy.get("[data-cy=search-input] input").should("have.value", "foo");
 
         // Click to navigate to the project, search should be reset
         cy.get(`p:contains(${fooProjectNameA})`).click();
-        cy.get('[data-cy=search-input] input').should('not.have.value', 'foo');
+        cy.get("[data-cy=search-input] input").should("not.have.value", "foo");
     });
 
-    it('navigates to the root project after trashing the parent of the one being displayed', function() {
+    it("navigates to the root project after trashing the parent of the one being displayed", function () {
         cy.createGroup(activeUser.token, {
             name: `Test root project ${Math.floor(Math.random() * 999999)}`,
-            group_class: 'project',
-        }).as('testRootProject').then(function() {
-            cy.createGroup(activeUser.token, {
-                name : `Test subproject ${Math.floor(Math.random() * 999999)}`,
-                group_class: 'project',
-                owner_uuid: this.testRootProject.uuid,
-            }).as('testSubProject').then(function() {
+            group_class: "project",
+        })
+            .as("testRootProject")
+            .then(function () {
                 cy.createGroup(activeUser.token, {
-                    name : `Test sub subproject ${Math.floor(Math.random() * 999999)}`,
-                    group_class: 'project',
-                    owner_uuid: this.testSubProject.uuid,
-                }).as('testSubSubProject');
+                    name: `Test subproject ${Math.floor(Math.random() * 999999)}`,
+                    group_class: "project",
+                    owner_uuid: this.testRootProject.uuid,
+                })
+                    .as("testSubProject")
+                    .then(function () {
+                        cy.createGroup(activeUser.token, {
+                            name: `Test sub subproject ${Math.floor(Math.random() * 999999)}`,
+                            group_class: "project",
+                            owner_uuid: this.testSubProject.uuid,
+                        }).as("testSubSubProject");
+                    });
             });
-        });
-        cy.getAll('@testRootProject', '@testSubProject', '@testSubSubProject').then(function([testRootProject, testSubProject, testSubSubProject]) {
+        cy.getAll("@testRootProject", "@testSubProject", "@testSubSubProject").then(function ([testRootProject, testSubProject, testSubSubProject]) {
             cy.loginAs(activeUser);
 
             // Go to innermost project and trash its parent.
             cy.goToPath(`/projects/${testSubSubProject.uuid}`);
-            cy.get('[data-cy=side-panel-tree]').should('contain', testSubSubProject.name);
-            cy.get('[data-cy=breadcrumb-last]').should('contain', testSubSubProject.name);
-            cy.get('[data-cy=side-panel-tree]')
-                .contains(testSubProject.name)
-                .rightclick();
-            cy.get('[data-cy=context-menu]').contains('Move to trash').click();
+            cy.get("[data-cy=side-panel-tree]").should("contain", testSubSubProject.name);
+            cy.get("[data-cy=breadcrumb-last]").should("contain", testSubSubProject.name);
+            cy.get("[data-cy=side-panel-tree]").contains(testSubProject.name).rightclick();
+            cy.get("[data-cy=context-menu]").contains("Move to trash").click();
 
             // Confirm that the trashed project's parent should be displayed.
-            cy.get('[data-cy=breadcrumb-last]').should('contain', testRootProject.name);
-            cy.url().should('contain', `/projects/${testRootProject.uuid}`);
-            cy.get('[data-cy=side-panel-tree]').should('not.contain', testSubProject.name);
-            cy.get('[data-cy=side-panel-tree]').should('not.contain', testSubSubProject.name);
+            cy.get("[data-cy=breadcrumb-last]").should("contain", testRootProject.name);
+            cy.url().should("contain", `/projects/${testRootProject.uuid}`);
+            cy.get("[data-cy=side-panel-tree]").should("not.contain", testSubProject.name);
+            cy.get("[data-cy=side-panel-tree]").should("not.contain", testSubSubProject.name);
 
             // Checks for bugfix #17637.
-            cy.get('[data-cy=not-found-content]').should('not.exist');
-            cy.get('[data-cy=not-found-page]').should('not.exist');
+            cy.get("[data-cy=not-found-content]").should("not.exist");
+            cy.get("[data-cy=not-found-page]").should("not.exist");
         });
     });
 
-    it('shows details panel when clicking on the info icon', () => {
+    it("shows details panel when clicking on the info icon", () => {
         cy.createGroup(activeUser.token, {
             name: `Test root project ${Math.floor(Math.random() * 999999)}`,
-            group_class: 'project',
-        }).as('testRootProject').then(function(testRootProject) {
-            cy.loginAs(activeUser);
+            group_class: "project",
+        })
+            .as("testRootProject")
+            .then(function (testRootProject) {
+                cy.loginAs(activeUser);
 
-            cy.get('[data-cy=side-panel-tree]').contains(testRootProject.name).click();
+                cy.get("[data-cy=side-panel-tree]").contains(testRootProject.name).click();
 
-            cy.get('[data-cy=additional-info-icon]').click();
+                cy.get("[data-cy=additional-info-icon]").click();
 
-            cy.contains(testRootProject.uuid).should('exist');
-        });
+                cy.contains(testRootProject.uuid).should("exist");
+            });
     });
 
-    it('clears search input when changing project', () => {
+    it("clears search input when changing project", () => {
         cy.createGroup(activeUser.token, {
             name: `Test root project ${Math.floor(Math.random() * 999999)}`,
-            group_class: 'project',
-        }).as('testProject1').then((testProject1) => {
-            cy.shareWith(adminUser.token, activeUser.user.uuid, testProject1.uuid, 'can_write');
-        });
+            group_class: "project",
+        })
+            .as("testProject1")
+            .then(testProject1 => {
+                cy.shareWith(adminUser.token, activeUser.user.uuid, testProject1.uuid, "can_write");
+            });
 
-        cy.getAll('@testProject1').then(function([testProject1]) {
+        cy.getAll("@testProject1").then(function ([testProject1]) {
             cy.loginAs(activeUser);
 
-            cy.get('[data-cy=side-panel-tree]').contains(testProject1.name).click();
+            cy.get("[data-cy=side-panel-tree]").contains(testProject1.name).click();
 
-            cy.get('[data-cy=search-input] input').type('test123');
+            cy.get("[data-cy=search-input] input").type("test123");
 
-            cy.get('[data-cy=side-panel-tree]').contains('Projects').click();
+            cy.get("[data-cy=side-panel-tree]").contains("Projects").click();
 
-            cy.get('[data-cy=search-input] input').should('not.have.value', 'test123');
+            cy.get("[data-cy=search-input] input").should("not.have.value", "test123");
         });
     });
 
-    it('opens advanced popup for project with username', () => {
+    it("opens advanced popup for project with username", () => {
         const projectName = `Test project ${Math.floor(Math.random() * 999999)}`;
 
         cy.createGroup(adminUser.token, {
             name: projectName,
-            group_class: 'project',
-        }).as('mainProject')
+            group_class: "project",
+        }).as("mainProject");
 
-        cy.getAll('@mainProject')
-            .then(function ([mainProject]) {
-                cy.loginAs(adminUser);
+        cy.getAll("@mainProject").then(function ([mainProject]) {
+            cy.loginAs(adminUser);
 
-                cy.get('[data-cy=side-panel-tree]').contains('Groups').click();
+            cy.get("[data-cy=side-panel-tree]").contains("Groups").click();
 
-                cy.get('[data-cy=uuid]').eq(0).invoke('text').then(uuid => {
+            cy.get("[data-cy=uuid]")
+                .eq(0)
+                .invoke("text")
+                .then(uuid => {
                     cy.createLink(adminUser.token, {
-                        name: 'can_write',
-                        link_class: 'permission',
+                        name: "can_write",
+                        link_class: "permission",
                         head_uuid: mainProject.uuid,
-                        tail_uuid: uuid
+                        tail_uuid: uuid,
                     });
 
                     cy.createLink(adminUser.token, {
-                        name: 'can_write',
-                        link_class: 'permission',
+                        name: "can_write",
+                        link_class: "permission",
                         head_uuid: mainProject.uuid,
-                        tail_uuid: activeUser.user.uuid
+                        tail_uuid: activeUser.user.uuid,
                     });
 
-                    cy.get('[data-cy=side-panel-tree]').contains('Projects').click();
+                    cy.get("[data-cy=side-panel-tree]").contains("Projects").click();
 
-                    cy.get('main').contains(projectName).rightclick();
+                    cy.get("main").contains(projectName).rightclick();
 
-                    cy.get('[data-cy=context-menu]').contains('API Details').click();
+                    cy.get("[data-cy=context-menu]").contains("API Details").click();
 
-                    cy.get('[role=tablist]').contains('METADATA').click();
+                    cy.get("[role=tablist]").contains("METADATA").click();
 
-                    cy.get('td').contains(uuid).should('exist');
+                    cy.get("td").contains(uuid).should("exist");
 
-                    cy.get('td').contains(activeUser.user.uuid).should('exist');
+                    cy.get("td").contains(activeUser.user.uuid).should("exist");
                 });
         });
     });
 
-    describe('Frozen projects', () => {
+    describe("Frozen projects", () => {
         beforeEach(() => {
             cy.createGroup(activeUser.token, {
                 name: `Main project ${Math.floor(Math.random() * 999999)}`,
-                group_class: 'project',
-            }).as('mainProject');
+                group_class: "project",
+            }).as("mainProject");
 
             cy.createGroup(adminUser.token, {
                 name: `Admin project ${Math.floor(Math.random() * 999999)}`,
-                group_class: 'project',
-            }).as('adminProject').then((mainProject) => {
-                cy.shareWith(adminUser.token, activeUser.user.uuid, mainProject.uuid, 'can_write');
-            });
+                group_class: "project",
+            })
+                .as("adminProject")
+                .then(mainProject => {
+                    cy.shareWith(adminUser.token, activeUser.user.uuid, mainProject.uuid, "can_write");
+                });
 
-            cy.get('@mainProject').then((mainProject) => {
+            cy.get("@mainProject").then(mainProject => {
                 cy.createGroup(adminUser.token, {
-                    name : `Sub project ${Math.floor(Math.random() * 999999)}`,
-                    group_class: 'project',
+                    name: `Sub project ${Math.floor(Math.random() * 999999)}`,
+                    group_class: "project",
                     owner_uuid: mainProject.uuid,
-                }).as('subProject');
+                }).as("subProject");
 
                 cy.createCollection(adminUser.token, {
                     name: `Main collection ${Math.floor(Math.random() * 999999)}`,
                     owner_uuid: mainProject.uuid,
-                    manifest_text: "./subdir 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
-                }).as('mainCollection');
+                    manifest_text: "./subdir 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+                }).as("mainCollection");
             });
         });
 
-        it('should be able to froze own project', () => {
-            cy.getAll('@mainProject').then(([mainProject]) => {
+        it("should be able to freeze own project", () => {
+            cy.getAll("@mainProject").then(([mainProject]) => {
                 cy.loginAs(activeUser);
 
-                cy.get('[data-cy=project-panel]').contains(mainProject.name).rightclick();
+                cy.get("[data-cy=project-panel]").contains(mainProject.name).rightclick();
 
-                cy.get('[data-cy=context-menu]').contains('Freeze').click();
+                cy.get("[data-cy=context-menu]").contains("Freeze").click();
 
-                cy.get('[data-cy=project-panel]').contains(mainProject.name).rightclick();
+                cy.get("[data-cy=project-panel]").contains(mainProject.name).rightclick();
 
-                cy.get('[data-cy=context-menu]').contains('Freeze').should('not.exist');
+                cy.get("[data-cy=context-menu]").contains("Freeze").should("not.exist");
             });
         });
 
-        it('should not be able to modify items within the frozen project', () => {
-            cy.getAll('@mainProject', '@mainCollection').then(([mainProject, mainCollection]) => {
+        it("should not be able to modify items within the frozen project", () => {
+            cy.getAll("@mainProject", "@mainCollection").then(([mainProject, mainCollection]) => {
                 cy.loginAs(activeUser);
 
-                cy.get('[data-cy=project-panel]').contains(mainProject.name).rightclick();
+                cy.get("[data-cy=project-panel]").contains(mainProject.name).rightclick();
 
-                cy.get('[data-cy=context-menu]').contains('Freeze').click();
+                cy.get("[data-cy=context-menu]").contains("Freeze").click();
 
-                cy.get('[data-cy=project-panel]').contains(mainProject.name).click();
+                cy.get("[data-cy=project-panel]").contains(mainProject.name).click();
 
-                cy.get('[data-cy=project-panel]').contains(mainCollection.name).rightclick();
+                cy.get("[data-cy=project-panel]").contains(mainCollection.name).rightclick();
 
-                cy.get('[data-cy=context-menu]').contains('Move to trash').should('not.exist');
+                cy.get("[data-cy=context-menu]").contains("Move to trash").should("not.exist");
             });
         });
 
-        it('should be able to froze not owned project', () => {
-            cy.getAll('@adminProject').then(([adminProject]) => {
+        it("should be able to freeze not owned project", () => {
+            cy.getAll("@adminProject").then(([adminProject]) => {
                 cy.loginAs(activeUser);
 
-                cy.get('[data-cy=side-panel-tree]').contains('Shared with me').click();
+                cy.get("[data-cy=side-panel-tree]").contains("Shared with me").click();
 
-                cy.get('main').contains(adminProject.name).rightclick();
+                cy.get("main").contains(adminProject.name).rightclick();
 
-                cy.get('[data-cy=context-menu]').contains('Freeze').should('not.exist');
+                cy.get("[data-cy=context-menu]").contains("Freeze").should("not.exist");
             });
         });
 
-        it('should be able to unfroze project if user is an admin', () => {
-            cy.getAll('@adminProject').then(([adminProject]) => {
+        it("should be able to unfreeze project if user is an admin", () => {
+            cy.getAll("@adminProject").then(([adminProject]) => {
                 cy.loginAs(adminUser);
 
-                cy.get('main').contains(adminProject.name).rightclick();
+                cy.get("main").contains(adminProject.name).rightclick();
 
-                cy.get('[data-cy=context-menu]').contains('Freeze').click();
+                cy.get("[data-cy=context-menu]").contains("Freeze").click();
 
                 cy.wait(1000);
 
-                cy.get('main').contains(adminProject.name).rightclick();
+                cy.get("main").contains(adminProject.name).rightclick();
 
-                cy.get('[data-cy=context-menu]').contains('Unfreeze').click();
+                cy.get("[data-cy=context-menu]").contains("Unfreeze").click();
 
-                cy.get('main').contains(adminProject.name).rightclick();
+                cy.get("main").contains(adminProject.name).rightclick();
 
-                cy.get('[data-cy=context-menu]').contains('Freeze').should('exist');
+                cy.get("[data-cy=context-menu]").contains("Freeze").should("exist");
             });
         });
     });
 
-    it('copies project URL to clipboard', () => {
+    it("copies project URL to clipboard", () => {
         const projectName = `Test project (${Math.floor(999999 * Math.random())})`;
 
         cy.loginAs(activeUser);
-        cy.get('[data-cy=side-panel-button]').click();
-        cy.get('[data-cy=side-panel-new-project]').click();
-        cy.get('[data-cy=form-dialog]')
-            .should('contain', 'New Project')
+        cy.get("[data-cy=side-panel-button]").click();
+        cy.get("[data-cy=side-panel-new-project]").click();
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "New Project")
             .within(() => {
-                cy.get('[data-cy=name-field]').within(() => {
-                    cy.get('input').type(projectName);
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(projectName);
                 });
-                cy.get('[data-cy=form-submit-btn]').click();
+                cy.get("[data-cy=form-submit-btn]").click();
             });
-
-        cy.get('[data-cy=side-panel-tree]').contains('Projects').click();
-        cy.get('[data-cy=project-panel]').contains(projectName).rightclick();
-        cy.get('[data-cy=context-menu]').contains('Copy to clipboard').click();
-        cy.window().then((win) => (
-            win.navigator.clipboard.readText().then((text) => {
-                expect(text).to.match(/https\:\/\/localhost\:[0-9]+\/projects\/[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}/,);
+        cy.get("[data-cy=form-dialog]").should("not.exist");
+        cy.get("[data-cy=snackbar]").contains("created");
+        cy.get("[data-cy=snackbar]").should("not.exist");
+        cy.get("[data-cy=side-panel-tree]").contains("Projects").click();
+        cy.waitForDom();
+        cy.get("[data-cy=project-panel]").contains(projectName).should("be.visible").rightclick();
+        cy.get("[data-cy=context-menu]").contains("Copy to clipboard").click();
+        cy.window().then(win =>
+            win.navigator.clipboard.readText().then(text => {
+                expect(text).to.match(/https\:\/\/127\.0\.0\.1\:[0-9]+\/projects\/[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}/);
             })
-        ));
-
+        );
     });
 
-    it('sorts displayed items correctly', () => {
+    it("sorts displayed items correctly", () => {
         cy.loginAs(activeUser);
 
         cy.get('[data-cy=project-panel] button[title="Select columns"]').click();
-        cy.get('div[role=presentation] ul > div[role=button]').contains('Date Created').click();
-        cy.get('div[role=presentation] ul > div[role=button]').contains('Trash at').click();
-        cy.get('div[role=presentation] ul > div[role=button]').contains('Delete at').click();
-        cy.get('div[role=presentation] > div[aria-hidden=true]').click();
+        cy.get("div[role=presentation] ul > div[role=button]").contains("Date Created").click();
+        cy.get("div[role=presentation] ul > div[role=button]").contains("Trash at").click();
+        cy.get("div[role=presentation] ul > div[role=button]").contains("Delete at").click();
+        cy.get("div[role=presentation] > div[aria-hidden=true]").click();
 
-        cy.intercept({method: 'GET', url: '**/arvados/v1/groups/*/contents*'}).as('filteredQuery');
+        cy.intercept({ method: "GET", url: "**/arvados/v1/groups/*/contents*" }).as("filteredQuery");
         [
             {
                 name: "Name",
-                asc: "collections.name asc,container_requests.name asc,groups.name asc",
-                desc: "collections.name desc,container_requests.name desc,groups.name desc"
+                asc: "collections.name asc,container_requests.name asc,groups.name asc,container_requests.created_at desc",
+                desc: "collections.name desc,container_requests.name desc,groups.name desc,container_requests.created_at desc",
             },
             {
                 name: "Last Modified",
-                asc: "collections.modified_at asc,container_requests.modified_at asc,groups.modified_at asc",
-                desc: "collections.modified_at desc,container_requests.modified_at desc,groups.modified_at desc"
+                asc: "collections.modified_at asc,container_requests.modified_at asc,groups.modified_at asc,container_requests.created_at desc",
+                desc: "collections.modified_at desc,container_requests.modified_at desc,groups.modified_at desc,container_requests.created_at desc",
             },
             {
                 name: "Date Created",
-                asc: "collections.created_at asc,container_requests.created_at asc,groups.created_at asc",
-                desc: "collections.created_at desc,container_requests.created_at desc,groups.created_at desc"
-
+                asc: "collections.created_at asc,container_requests.created_at asc,groups.created_at asc,container_requests.created_at desc",
+                desc: "collections.created_at desc,container_requests.created_at desc,groups.created_at desc,container_requests.created_at desc",
             },
             {
                 name: "Trash at",
-                asc: "collections.trash_at asc,container_requests.trash_at asc,groups.trash_at asc",
-                desc: "collections.trash_at desc,container_requests.trash_at desc,groups.trash_at desc"
-
+                asc: "collections.trash_at asc,container_requests.trash_at asc,groups.trash_at asc,container_requests.created_at desc",
+                desc: "collections.trash_at desc,container_requests.trash_at desc,groups.trash_at desc,container_requests.created_at desc",
             },
             {
                 name: "Delete at",
-                asc: "collections.delete_at asc,container_requests.delete_at asc,groups.delete_at asc",
-                desc: "collections.delete_at desc,container_requests.delete_at desc,groups.delete_at desc"
-
+                asc: "collections.delete_at asc,container_requests.delete_at asc,groups.delete_at asc,container_requests.created_at desc",
+                desc: "collections.delete_at desc,container_requests.delete_at desc,groups.delete_at desc,container_requests.created_at desc",
             },
-        ].forEach((test) => {
-            cy.get('[data-cy=project-panel] table thead th').contains(test.name).click();
-            cy.wait('@filteredQuery').then(interception => {
-                const searchParams = new URLSearchParams((new URL(interception.request.url).search));
-                expect(searchParams.get('order')).to.eq(test.asc);
+        ].forEach(test => {
+            cy.get("[data-cy=project-panel] table thead th").contains(test.name).click();
+            cy.wait("@filteredQuery").then(interception => {
+                const searchParams = new URLSearchParams(new URL(interception.request.url).search);
+                expect(searchParams.get("order")).to.eq(test.asc);
             });
-            cy.get('[data-cy=project-panel] table thead th').contains(test.name).click();
-            cy.wait('@filteredQuery').then(interception => {
-                const searchParams = new URLSearchParams((new URL(interception.request.url).search));
-                expect(searchParams.get('order')).to.eq(test.desc);
+            cy.get("[data-cy=project-panel] table thead th").contains(test.name).click();
+            cy.wait("@filteredQuery").then(interception => {
+                const searchParams = new URLSearchParams(new URL(interception.request.url).search);
+                expect(searchParams.get("order")).to.eq(test.desc);
             });
         });
-
     });
 });
index c8e262f011097b43ad9b5902a7d75bacdfa26cf1..d8aa35d3d2d4b6398282350f9d68e88ccb5a2030 100644 (file)
@@ -2,87 +2,91 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-describe('Search tests', function() {
+describe("Search tests", function () {
     let activeUser;
     let adminUser;
 
-    before(function() {
+    before(function () {
         // Only set up common users once. These aren't set up as aliases because
         // aliases are cleaned up after every test. Also it doesn't make sense
         // to set the same users on beforeEach() over and over again, so we
         // separate a little from Cypress' 'Best Practices' here.
-        cy.getUser('admin', 'Admin', 'User', true, true)
-            .as('adminUser').then(function() {
+        cy.getUser("admin", "Admin", "User", true, true)
+            .as("adminUser")
+            .then(function () {
                 adminUser = this.adminUser;
-            }
-        );
-        cy.getUser('collectionuser1', 'Collection', 'User', false, true)
-            .as('activeUser').then(function() {
+            });
+        cy.getUser("collectionuser1", "Collection", "User", false, true)
+            .as("activeUser")
+            .then(function () {
                 activeUser = this.activeUser;
-            }
-        );
-    })
+            });
+    });
 
-    beforeEach(function() {
-        cy.clearCookies()
-        cy.clearLocalStorage()
-    })
+    beforeEach(function () {
+        cy.clearCookies();
+        cy.clearLocalStorage();
+    });
 
-    it('can search for old collection versions', function() {
+    it("can search for old collection versions", function () {
         const colName = `Versioned Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
-        let colUuid = '';
-        let oldVersionUuid = '';
+        let colUuid = "";
+        let oldVersionUuid = "";
         // Make sure no other collections with this name exist
-        cy.doRequest('GET', '/arvados/v1/collections', null, {
+        cy.doRequest("GET", "/arvados/v1/collections", null, {
             filters: `[["name", "=", "${colName}"]]`,
-            include_old_versions: true
+            include_old_versions: true,
         })
-        .its('body.items').as('collections')
-        .then(function() {
-            expect(this.collections).to.be.empty;
-        });
+            .its("body.items")
+            .as("collections")
+            .then(function () {
+                expect(this.collections).to.be.empty;
+            });
         // Creates the collection using the admin token so we can set up
         // a bogus manifest text without block signatures.
         cy.createCollection(adminUser.token, {
             name: colName,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"})
-        .as('originalVersion').then(function() {
-            // Change the file name to create a new version.
-            cy.updateCollection(adminUser.token, this.originalVersion.uuid, {
-                manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n"
-            })
-            colUuid = this.originalVersion.uuid;
-        });
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        })
+            .as("originalVersion")
+            .then(function () {
+                // Change the file name to create a new version.
+                cy.updateCollection(adminUser.token, this.originalVersion.uuid, {
+                    manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n",
+                });
+                colUuid = this.originalVersion.uuid;
+            });
         // Confirm that there are 2 versions of the collection
-        cy.doRequest('GET', '/arvados/v1/collections', null, {
+        cy.doRequest("GET", "/arvados/v1/collections", null, {
             filters: `[["name", "=", "${colName}"]]`,
-            include_old_versions: true
+            include_old_versions: true,
         })
-        .its('body.items').as('collections')
-        .then(function() {
-            expect(this.collections).to.have.lengthOf(2);
-            this.collections.map(function(aCollection) {
-                expect(aCollection.current_version_uuid).to.equal(colUuid);
-                if (aCollection.uuid !== aCollection.current_version_uuid) {
-                    oldVersionUuid = aCollection.uuid;
-                }
+            .its("body.items")
+            .as("collections")
+            .then(function () {
+                expect(this.collections).to.have.lengthOf(2);
+                this.collections.map(function (aCollection) {
+                    expect(aCollection.current_version_uuid).to.equal(colUuid);
+                    if (aCollection.uuid !== aCollection.current_version_uuid) {
+                        oldVersionUuid = aCollection.uuid;
+                    }
+                });
+                cy.loginAs(activeUser);
+                const searchQuery = `${colName} type:arvados#collection`;
+                // Search for only collection's current version
+                cy.doSearch(`${searchQuery}`);
+                cy.get("[data-cy=search-results]").should("contain", "head version");
+                cy.get("[data-cy=search-results]").should("not.contain", "version 1");
+                // ...and then, include old versions.
+                cy.doSearch(`${searchQuery} is:pastVersion`);
+                cy.get("[data-cy=search-results]").should("contain", "head version");
+                cy.get("[data-cy=search-results]").should("contain", "version 1");
             });
-            cy.loginAs(activeUser);
-            const searchQuery = `${colName} type:arvados#collection`;
-            // Search for only collection's current version
-            cy.doSearch(`${searchQuery}`);
-            cy.get('[data-cy=search-results]').should('contain', 'head version');
-            cy.get('[data-cy=search-results]').should('not.contain', 'version 1');
-            // ...and then, include old versions.
-            cy.doSearch(`${searchQuery} is:pastVersion`);
-            cy.get('[data-cy=search-results]').should('contain', 'head version');
-            cy.get('[data-cy=search-results]').should('contain', 'version 1');
-        });
     });
 
-    it('can display path of the selected item', function() {
+    it("can display path of the selected item", function () {
         const colName = `Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
 
         // Creates the collection using the admin token so we can set up
@@ -91,21 +95,21 @@ describe('Search tests', function() {
             name: colName,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
-        }).then(function() {
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        }).then(function () {
             cy.loginAs(activeUser);
 
             cy.doSearch(colName);
 
-            cy.get('[data-cy=search-results]').should('contain', colName);
+            cy.get("[data-cy=search-results]").should("contain", colName);
 
-            cy.get('[data-cy=search-results]').contains(colName).closest('tr').click();
+            cy.get("[data-cy=search-results]").contains(colName).closest("tr").click();
 
-            cy.get('[data-cy=element-path]').should('contain', `/ Projects / ${colName}`);
+            cy.get("[data-cy=element-path]").should("contain", `/ Projects / ${colName}`);
         });
     });
 
-    it('can search items using quotes', function() {
+    it("can search items using quotes", function () {
         const random = Math.floor(Math.random() * Math.floor(999999));
         const colName = `Collection ${random}`;
         const colName2 = `Collection test ${random}`;
@@ -116,138 +120,139 @@ describe('Search tests', function() {
             name: colName,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
-        }).as('collection1');
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        }).as("collection1");
 
         cy.createCollection(adminUser.token, {
             name: colName2,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
-        }).as('collection2');
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        }).as("collection2");
 
-        cy.getAll('@collection1', '@collection2')
-            .then(function() {
-                cy.loginAs(activeUser);
+        cy.getAll("@collection1", "@collection2").then(function () {
+            cy.loginAs(activeUser);
 
-                cy.doSearch(colName);
-                cy.get('[data-cy=search-results] table tbody tr').should('have.length', 2);
+            cy.doSearch(colName);
+            cy.get("[data-cy=search-results] table tbody tr").should("have.length", 2);
 
-                cy.doSearch(`"${colName}"`);
-                cy.get('[data-cy=search-results] table tbody tr').should('have.length', 1);
-            });
+            cy.doSearch(`"${colName}"`);
+            cy.get("[data-cy=search-results] table tbody tr").should("have.length", 1);
+        });
     });
 
-    it('can display owner of the item', function() {
+    it("can display owner of the item", function () {
         const colName = `Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
 
         cy.createCollection(adminUser.token, {
             name: colName,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
-        }).then(function() {
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        }).then(function () {
             cy.loginAs(activeUser);
 
             cy.doSearch(colName);
 
-            cy.get('[data-cy=search-results]').should('contain', colName);
+            cy.get("[data-cy=search-results]").should("contain", colName);
 
-            cy.get('[data-cy=search-results]').contains(colName).closest('tr')
+            cy.get("[data-cy=search-results]")
+                .contains(colName)
+                .closest("tr")
                 .within(() => {
-                    cy.get('p').contains(activeUser.user.uuid).should('contain', activeUser.user.full_name);
+                    cy.get("p").contains(activeUser.user.uuid).should("contain", activeUser.user.full_name);
                 });
         });
     });
 
-    it('shows search context menu', function() {
-        const colName = `Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
-        const federatedColName = `Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
+    it("shows search context menu", function () {
+        const colName = `Home Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
+        const federatedColName = `Federated Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
         const federatedColUuid = "xxxxx-4zz18-000000000000000";
 
         // Intercept config to insert remote cluster
-        cy.intercept({method: 'GET', hostname: 'localhost', url: '**/arvados/v1/config?nocache=*'}, (req) => {
-            req.reply((res) => {
+        cy.intercept({ method: "GET", hostname: "127.0.0.1", url: "**/arvados/v1/config?nocache=*" }, req => {
+            req.reply(res => {
                 res.body.RemoteClusters = {
                     "*": res.body.RemoteClusters["*"],
-                    "xxxxx": {
-                        "ActivateUsers": true,
-                        "Host": "xxxxx.fakecluster.tld",
-                        "Insecure": false,
-                        "Proxy": true,
-                        "Scheme": ""
-                    }
+                    xxxxx: {
+                        ActivateUsers: true,
+                        Host: "xxxxx.fakecluster.tld",
+                        Insecure: false,
+                        Proxy: true,
+                        Scheme: "",
+                    },
                 };
             });
         });
 
         // Fake remote cluster config
         cy.intercept(
-          {
-            method: "GET",
-            hostname: "xxxxx.fakecluster.tld",
-            url: "**/arvados/v1/config",
-          },
-          {
-            statusCode: 200,
-            body: {
-              API: {},
-              ClusterID: "xxxxx",
-              Collections: {},
-              Containers: {},
-              InstanceTypes: {},
-              Login: {},
-              Mail: { SupportEmailAddress: "arvados@example.com" },
-              RemoteClusters: {
-                "*": {
-                  ActivateUsers: false,
-                  Host: "",
-                  Insecure: false,
-                  Proxy: false,
-                  Scheme: "https",
-                },
-              },
-              Services: {
-                Composer: { ExternalURL: "" },
-                Controller: { ExternalURL: "https://xxxxx.fakecluster.tld:34763/" },
-                DispatchCloud: { ExternalURL: "" },
-                DispatchLSF: { ExternalURL: "" },
-                DispatchSLURM: { ExternalURL: "" },
-                GitHTTP: { ExternalURL: "https://xxxxx.fakecluster.tld:39105/" },
-                GitSSH: { ExternalURL: "" },
-                Health: { ExternalURL: "https://xxxxx.fakecluster.tld:42915/" },
-                Keepbalance: { ExternalURL: "" },
-                Keepproxy: { ExternalURL: "https://xxxxx.fakecluster.tld:46773/" },
-                Keepstore: { ExternalURL: "" },
-                RailsAPI: { ExternalURL: "" },
-                WebDAV: { ExternalURL: "https://xxxxx.fakecluster.tld:36041/" },
-                WebDAVDownload: { ExternalURL: "https://xxxxx.fakecluster.tld:42957/" },
-                WebShell: { ExternalURL: "" },
-                Websocket: { ExternalURL: "wss://xxxxx.fakecluster.tld:37121/websocket" },
-                Workbench1: { ExternalURL: "https://wb1.xxxxx.fakecluster.tld/" },
-                Workbench2: { ExternalURL: "https://wb2.xxxxx.fakecluster.tld/" },
-              },
-              StorageClasses: {
-                default: { Default: true, Priority: 0 },
-              },
-              Users: {},
-              Volumes: {},
-              Workbench: {},
+            {
+                method: "GET",
+                hostname: "xxxxx.fakecluster.tld",
+                url: "**/arvados/v1/config",
             },
-          }
+            {
+                statusCode: 200,
+                body: {
+                    API: {},
+                    ClusterID: "xxxxx",
+                    Collections: {},
+                    Containers: {},
+                    InstanceTypes: {},
+                    Login: {},
+                    Mail: { SupportEmailAddress: "arvados@example.com" },
+                    RemoteClusters: {
+                        "*": {
+                            ActivateUsers: false,
+                            Host: "",
+                            Insecure: false,
+                            Proxy: false,
+                            Scheme: "https",
+                        },
+                    },
+                    Services: {
+                        Composer: { ExternalURL: "" },
+                        Controller: { ExternalURL: "https://xxxxx.fakecluster.tld:34763/" },
+                        DispatchCloud: { ExternalURL: "" },
+                        DispatchLSF: { ExternalURL: "" },
+                        DispatchSLURM: { ExternalURL: "" },
+                        GitHTTP: { ExternalURL: "https://xxxxx.fakecluster.tld:39105/" },
+                        GitSSH: { ExternalURL: "" },
+                        Health: { ExternalURL: "https://xxxxx.fakecluster.tld:42915/" },
+                        Keepbalance: { ExternalURL: "" },
+                        Keepproxy: { ExternalURL: "https://xxxxx.fakecluster.tld:46773/" },
+                        Keepstore: { ExternalURL: "" },
+                        RailsAPI: { ExternalURL: "" },
+                        WebDAV: { ExternalURL: "https://xxxxx.fakecluster.tld:36041/" },
+                        WebDAVDownload: { ExternalURL: "https://xxxxx.fakecluster.tld:42957/" },
+                        WebShell: { ExternalURL: "" },
+                        Websocket: { ExternalURL: "wss://xxxxx.fakecluster.tld:37121/websocket" },
+                        Workbench1: { ExternalURL: "https://wb1.xxxxx.fakecluster.tld/" },
+                        Workbench2: { ExternalURL: "https://wb2.xxxxx.fakecluster.tld/" },
+                    },
+                    StorageClasses: {
+                        default: { Default: true, Priority: 0 },
+                    },
+                    Users: {},
+                    Volumes: {},
+                    Workbench: {},
+                },
+            }
         );
 
         cy.createCollection(adminUser.token, {
             name: colName,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
-        }).then(function(testCollection) {
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        }).then(function (testCollection) {
             cy.loginAs(activeUser);
 
             // Intercept search results to add federated result
-            cy.intercept({method: 'GET', url: '**/arvados/v1/groups/contents?*'}, (req) => {
-                req.reply((res) => {
+            cy.intercept({ method: "GET", url: "**/arvados/v1/groups/contents?*" }, req => {
+                req.reply(res => {
                     res.body.items = [
                         res.body.items[0],
                         {
@@ -256,7 +261,7 @@ describe('Search tests', function() {
                             portable_data_hash: "00000000000000000000000000000000+0",
                             name: federatedColName,
                             href: res.body.items[0].href.replace(testCollection.uuid, federatedColUuid),
-                        }
+                        },
                     ];
                     res.body.items_available += 1;
                 });
@@ -266,51 +271,54 @@ describe('Search tests', function() {
 
             // Stub new window
             cy.window().then(win => {
-                cy.stub(win, 'open').as('Open')
+                cy.stub(win, "open").as("Open");
             });
 
             // Check copy to clipboard
-            cy.get('[data-cy=search-results]').contains(colName).rightclick();
-            cy.get('[data-cy=context-menu]').within((ctx) => {
+            cy.get("[data-cy=search-results]").contains(colName).rightclick();
+            cy.get("[data-cy=context-menu]").within(ctx => {
                 // Check that there are 4 items in the menu
-                cy.get(ctx).children().should('have.length', 4);
-                cy.contains('API Details');
-                cy.contains('Copy to clipboard');
-                cy.contains('Open in new tab');
-                cy.contains('View details');
-
-                cy.contains('Copy to clipboard').click();
-                cy.window().then((win) => (
-                    win.navigator.clipboard.readText().then((text) => {
+                cy.get(ctx).children().should("have.length", 4);
+                cy.contains("API Details");
+                cy.contains("Copy to clipboard");
+                cy.contains("Open in new tab");
+                cy.contains("View details");
+
+                cy.contains("Copy to clipboard").click();
+                cy.waitForDom();
+                cy.window().then(win =>
+                    win.navigator.clipboard.readText().then(text => {
                         expect(text).to.match(new RegExp(`/collections/${testCollection.uuid}$`));
                     })
-                ));
+                );
             });
 
             // Check open in new tab
-            cy.get('[data-cy=search-results]').contains(colName).rightclick();
-            cy.get('[data-cy=context-menu]').within(() => {
-                cy.contains('Open in new tab').click();
-                cy.get('@Open').should('have.been.calledOnceWith', `${window.location.origin}/collections/${testCollection.uuid}`)
+            cy.get("[data-cy=search-results]").contains(colName).rightclick();
+            cy.get("[data-cy=context-menu]").within(() => {
+                cy.contains("Open in new tab").click();
+                cy.waitForDom();
+                cy.get("@Open").should("have.been.calledOnceWith", `${window.location.origin}/collections/${testCollection.uuid}`);
             });
 
             // Check federated result copy to clipboard
-            cy.get('[data-cy=search-results]').contains(federatedColName).rightclick();
-            cy.get('[data-cy=context-menu]').within(() => {
-                cy.contains('Copy to clipboard').click();
-                cy.window().then((win) => (
-                    win.navigator.clipboard.readText().then((text) => {
+            cy.get("[data-cy=search-results]").contains(federatedColName).rightclick();
+            cy.get("[data-cy=context-menu]").within(() => {
+                cy.contains("Copy to clipboard").click();
+                cy.waitForDom();
+                cy.window().then(win =>
+                    win.navigator.clipboard.readText().then(text => {
                         expect(text).to.equal(`https://wb2.xxxxx.fakecluster.tld/collections/${federatedColUuid}`);
                     })
-                ));
+                );
             });
             // Check open in new tab
-            cy.get('[data-cy=search-results]').contains(federatedColName).rightclick();
-            cy.get('[data-cy=context-menu]').within(() => {
-                cy.contains('Open in new tab').click();
-                cy.get('@Open').should('have.been.calledWith', `https://wb2.xxxxx.fakecluster.tld/collections/${federatedColUuid}`)
+            cy.get("[data-cy=search-results]").contains(federatedColName).rightclick();
+            cy.get("[data-cy=context-menu]").within(() => {
+                cy.contains("Open in new tab").click();
+                cy.waitForDom();
+                cy.get("@Open").should("have.been.calledWith", `https://wb2.xxxxx.fakecluster.tld/collections/${federatedColUuid}`);
             });
-
         });
     });
 });
index 1d3112c2c8187fd6e0b04309de56e51b8a9fea87..f742d09062a4f0b95fe8be8946fb20464a0b8ef7 100644 (file)
@@ -77,7 +77,7 @@ describe('Sharing tests', function () {
             cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
             cy.get('[role=tooltip]').click();
             cy.get('@sharingDialog').within(() => {
-                cy.contains('Save changes').click();
+                cy.get('[data-cy=add-invited-people]').click();
                 cy.contains('Close').click();
             });
         });
@@ -95,7 +95,7 @@ describe('Sharing tests', function () {
             cy.get('[data-cy=invite-people-field]').find('input').type(activeUser.user.email);
             cy.get('[role=tooltip]').click();
             cy.get('@sharingDialog').within(() => {
-                cy.contains('Save changes').click();
+                cy.get('[data-cy=add-invited-people]').click();
                 cy.contains('Close').click();
             });
         });
@@ -106,6 +106,20 @@ describe('Sharing tests', function () {
 
                 cy.contains('Shared with me').click();
 
+                // Test search
+                cy.get('[data-cy=search-input] input').type('readonly');
+                cy.get('main').should('not.contain', mySharedWritableProject.name);
+                cy.get('main').should('contain', mySharedReadonlyProject.name);
+                cy.get('[data-cy=search-input] input').clear();
+
+                // Test filter
+                cy.waitForDom().get('th').contains('Type').click();
+                cy.get('div[role=presentation]').contains('Project').click();
+                cy.waitForDom().get('main table tr td').contains('Project').should('not.exist');
+                cy.get('div[role=presentation]').contains('Project').click();
+                cy.waitForDom().get('div[role=presentation] button').contains('Close').click();
+
+                // Test move to trash
                 cy.get('main').contains(mySharedWritableProject.name).rightclick();
                 cy.get('[data-cy=context-menu]').should('contain', 'Move to trash');
                 cy.get('[data-cy=context-menu]').contains('Move to trash').click();
@@ -140,4 +154,25 @@ describe('Sharing tests', function () {
                 cy.testEditProjectOrCollection('main', mySharedWritableProject.name, newProjectName, newProjectDescription);
             });
     });
-});
\ No newline at end of file
+
+    it('can share only when target users are present', () => {
+        const collName = `mySharedCollectionForUsers-${new Date().getTime()}`;
+        cy.createCollection(adminUser.token, {
+            name: collName,
+            owner_uuid: adminUser.uuid,
+        }).as('mySharedCollectionForUsers')
+
+        cy.getAll('@mySharedCollectionForUsers')
+            .then(function ([]) {
+                cy.loginAs(adminUser);
+                cy.get('[data-cy=project-panel]').contains(collName).rightclick();
+                cy.get('[data-cy=context-menu]').contains('Share').click();
+                cy.get('button').get('[data-cy=add-invited-people]').should('be.disabled');
+                cy.get('[data-cy=invite-people-field] input').type('Anonymous');
+                cy.get('div[role=tooltip]').contains('anonymous').click();
+                cy.get('button').get('[data-cy=add-invited-people]').should('not.be.disabled');
+                cy.get('[data-cy=invite-people-field] div[role=button]').contains('anonymous').parent().find('svg').click();
+                cy.get('button').get('[data-cy=add-invited-people]').should('be.disabled');
+            });
+    });
+});
index e187d533ead19b2b43e9ed01aae24215f84c4955..d6ac754d0a4d0da815b51c7ee4ef7564b43b1e9d 100644 (file)
@@ -65,7 +65,7 @@ describe('Side panel tests', function() {
             {url: '/all_processes', label: 'All Processes'},
             {url: '/trash', label: 'Trash'},
         ].map(function(section) {
-            cy.goToPath(section.url);
+            cy.waitForDom().goToPath(section.url);
             cy.get('[data-cy=breadcrumb-first]')
                 .should('contain', section.label);
             cy.get('[data-cy=side-panel-button]')
@@ -135,4 +135,41 @@ describe('Side panel tests', function() {
             });
         });
     });
+
+    it('collapses and un-collapses', () => {
+
+        cy.loginAs(activeUser)
+        cy.get('[data-cy=side-panel-tree]').should('exist')
+        cy.get('[data-cy=side-panel-toggle]').click()
+        cy.get('[data-cy=side-panel-tree]').should('not.exist')
+        cy.get('[data-cy=side-panel-collapsed]').should('exist')
+        cy.get('[data-cy=side-panel-toggle]').click()
+        cy.get('[data-cy=side-panel-tree]').should('exist')
+        cy.get('[data-cy=side-panel-collapsed]').should('not.exist')
+    })
+
+    it('can navigate from collapsed panel', () => {
+
+        const collapsedCategories = {
+            'shared-with-me': '/shared-with-me',
+            'public-favorites': '/public-favorites',
+            'my-favorites': '/favorites',
+            'groups': '/groups',
+            'all-processes': '/all_processes',
+            'trash': '/trash',
+            'shell-access': '/virtual-machines-user',
+            'home-projects': `/projects/${activeUser.user.uuid}`,
+        }
+
+        cy.loginAs(activeUser)
+        cy.get('[data-cy=side-panel-tree]').should('exist')
+        cy.get('[data-cy=side-panel-toggle]').click()
+        cy.get('[data-cy=side-panel-collapsed]').should('exist')
+
+        for (const cat in collapsedCategories) {
+            cy.get(`[data-cy=collapsed-${cat}]`).should('exist').click()
+            cy.url().should('include', collapsedCategories[cat])
+        }
+    })
 })
+
index d91dbb0bc5bf4a49f98e7ffd256b2df31248edfb..0a06eaf361ba97763ba20d4730001c3b26e93378 100644 (file)
@@ -145,6 +145,8 @@ describe('User profile tests', function() {
             website: 'example.com',
         });
 
+        cy.get('[data-cy=profile-form] button[type="submit"]').should('not.be.disabled');
+
         // Submit
         cy.get('[data-cy=profile-form] button[type="submit"]').click();
 
@@ -159,6 +161,9 @@ describe('User profile tests', function() {
             role: 'Data Scientist',
             website: 'example.com',
         });
+
+        // if it worked, the save button should be disabled.
+        cy.get('[data-cy=profile-form] button[type="submit"]').should('be.disabled');
     });
 
     it('non-admin cannot edit other profile', function() {
index 49cb12394648138417404b52a001f2698002a541..92011b208ee51a7887996ca4088c60b39838aad0 100644 (file)
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-describe('Virtual machine login manage tests', function() {
+describe("Virtual machine login manage tests", function () {
     let activeUser;
     let adminUser;
 
     const vmHost = `vm-${Math.floor(999999 * Math.random())}.host`;
 
-    before(function() {
+    before(function () {
         // Only set up common users once. These aren't set up as aliases because
         // aliases are cleaned up after every test. Also it doesn't make sense
         // to set the same users on beforeEach() over and over again, so we
         // separate a little from Cypress' 'Best Practices' here.
-        cy.getUser('admin', 'VMAdmin', 'User', true, true)
-            .as('adminUser').then(function() {
+        cy.getUser("admin", "VMAdmin", "User", true, true)
+            .as("adminUser")
+            .then(function () {
                 adminUser = this.adminUser;
-            }
-        );
-        cy.getUser('user', 'VMActive', 'User', false, true)
-            .as('activeUser').then(function() {
+            });
+        cy.getUser("user", "VMActive", "User", false, true)
+            .as("activeUser")
+            .then(function () {
                 activeUser = this.activeUser;
-            }
-        );
+            });
     });
 
-    it('adds and removes vm logins', function() {
+    it("adds and removes vm logins", function () {
         cy.loginAs(adminUser);
-        cy.createVirtualMachine(adminUser.token, {hostname: vmHost});
+        cy.createVirtualMachine(adminUser.token, { hostname: vmHost });
 
         // Navigate to VM admin
         cy.get('header button[title="Admin Panel"]').click();
-        cy.get('#admin-menu').contains('Virtual Machines').click();
+        cy.get("#admin-menu").contains("Virtual Machines").click();
 
         // Add login permission to admin
-        cy.get('[data-cy=vm-admin-table]')
+        cy.get("[data-cy=vm-admin-table]")
             .contains(vmHost)
-            .parents('tr')
+            .parents("tr")
             .within(() => {
                 cy.get('button[title="Add Login Permission"]').click();
             });
-        cy.get('[data-cy=form-dialog]')
-            .should('contain', 'Add login permission')
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "Add login permission")
             .within(() => {
-                cy.get('label')
-                  .contains('Search for user')
-                  .parent()
-                  .within(() => {
-                    cy.get('input').type('VMAdmin');
-                  })
+                cy.get("label")
+                    .contains("Search for user")
+                    .parent()
+                    .within(() => {
+                        cy.get("input").type("VMAdmin");
+                    });
             });
-        cy.get('[role=tooltip]').click();
-        cy.get('[data-cy=form-dialog]').as('add-login-dialog')
-            .should('contain', 'Add login permission')
+        cy.waitForDom().get("[role=tooltip]").click();
+        cy.get("[data-cy=form-dialog]")
+            .as("add-login-dialog")
+            .should("contain", "Add login permission")
             .within(() => {
-                cy.get('label')
-                  .contains('Add groups')
-                  .parent()
-                  .within(() => {
-                    cy.get('input').type('docker ');
-                    // Veryfy submit enabled (form has changed)
-                    cy.get('@add-login-dialog').within(() => {
-                        cy.get('[data-cy=form-submit-btn]').should('be.enabled');
-                    });
-                    cy.get('input').type('sudo');
-                    // Veryfy submit disabled (partial input in chips)
-                    cy.get('@add-login-dialog').within(() => {
-                        cy.get('[data-cy=form-submit-btn]').should('be.disabled');
+                cy.get("label")
+                    .contains("Add groups")
+                    .parent()
+                    .within(() => {
+                        cy.get("input").type("docker ");
+                        // Veryfy submit enabled (form has changed)
+                        cy.get("@add-login-dialog").within(() => {
+                            cy.get("[data-cy=form-submit-btn]").should("be.enabled");
+                        });
+                        cy.get("input").type("sudo");
+                        // Veryfy submit disabled (partial input in chips)
+                        cy.get("@add-login-dialog").within(() => {
+                            cy.get("[data-cy=form-submit-btn]").should("be.disabled");
+                        });
+                        cy.get("input").type("{enter}");
                     });
-                    cy.get('input').type('{enter}');
-                  })
             });
-        cy.get('[data-cy=form-dialog]').within(() => {
-            cy.get('[data-cy=form-submit-btn]').click();
+        cy.get("[data-cy=form-dialog]").within(() => {
+            cy.get("[data-cy=form-submit-btn]").click();
         });
 
-        cy.get('[data-cy=vm-admin-table]')
+        cy.get("[data-cy=vm-admin-table]")
             .contains(vmHost)
-            .parents('tr')
+            .parents("tr")
             .within(() => {
-                cy.get('td').contains('admin');
-        });
+                cy.get("td").contains("admin");
+            });
 
         // Add login permission to activeUser
-        cy.get('[data-cy=vm-admin-table]')
+        cy.get("[data-cy=vm-admin-table]")
             .contains(vmHost)
-            .parents('tr')
+            .parents("tr")
             .within(() => {
                 cy.get('button[title="Add Login Permission"]').click();
             });
-        cy.get('[data-cy=form-dialog]')
-            .should('contain', 'Add login permission')
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "Add login permission")
             .within(() => {
-                cy.get('label')
-                  .contains('Search for user')
-                  .parent()
-                  .within(() => {
-                    cy.get('input').type('VMActive user');
-                  })
+                cy.get("label")
+                    .contains("Search for user")
+                    .parent()
+                    .within(() => {
+                        cy.get("input").type("VMActive user");
+                    });
             });
-        cy.get('[role=tooltip]').click();
-        cy.get('[data-cy=form-dialog]').within(() => {
-            cy.get('[data-cy=form-submit-btn]').click();
+        cy.get("[role=tooltip]").click();
+        cy.get("[data-cy=form-dialog]").within(() => {
+            cy.get("[data-cy=form-submit-btn]").click();
         });
 
-        cy.get('[data-cy=vm-admin-table]')
+        cy.get("[data-cy=vm-admin-table]")
             .contains(vmHost)
-            .parents('tr')
+            .parents("tr")
             .within(() => {
-                cy.get('td').contains('user');
-        });
+                cy.get("td").contains("user");
+            });
 
         // Check admin's vm page for login
         cy.get('header button[title="Account Management"]').click();
-        cy.get('#account-menu').contains('Virtual Machines').click();
+        cy.get("#account-menu").contains("Virtual Machines").click();
 
-        cy.get('[data-cy=vm-user-table]')
+        cy.get("[data-cy=vm-user-table]")
             .contains(vmHost)
-            .parents('tr')
+            .parents("tr")
             .within(() => {
-                cy.get('td').contains('admin');
-                cy.get('td').contains('docker');
-                cy.get('td').contains('sudo');
-                cy.get('td').contains('ssh admin@' + vmHost);
-        });
+                cy.get("td").contains("admin");
+                cy.get("td").contains("docker");
+                cy.get("td").contains("sudo");
+                cy.get("td").contains("ssh admin@" + vmHost);
+            });
 
         // Check activeUser's vm page for login
         cy.loginAs(activeUser);
         cy.get('header button[title="Account Management"]').click();
-        cy.get('#account-menu').contains('Virtual Machines').click();
+        cy.get("#account-menu").contains("Virtual Machines").click();
 
-        cy.get('[data-cy=vm-user-table]')
+        cy.get("[data-cy=vm-user-table]")
             .contains(vmHost)
-            .parents('tr')
+            .parents("tr")
             .within(() => {
-                cy.get('td').contains('user');
-                cy.get('td').should('not.contain', 'docker');
-                cy.get('td').should('not.contain', 'sudo');
-                cy.get('td').contains('ssh user@' + vmHost);
-        });
+                cy.get("td").contains("user");
+                cy.get("td").should("not.contain", "docker");
+                cy.get("td").should("not.contain", "sudo");
+                cy.get("td").contains("ssh user@" + vmHost);
+            });
 
         // Edit login permissions
         cy.loginAs(adminUser);
         cy.get('header button[title="Admin Panel"]').click();
-        cy.get('#admin-menu').contains('Virtual Machines').click();
+        cy.get("#admin-menu").contains("Virtual Machines").click();
 
-        cy.get('[data-cy=vm-admin-table]')
-            .contains('admin'); // Wait for page to finish
+        cy.get("[data-cy=vm-admin-table]").contains("admin"); // Wait for page to finish
 
-        cy.get('[data-cy=vm-admin-table]')
-            .contains(vmHost)
-            .parents('tr')
-            .contains('admin')
-            .click();
+        cy.get("[data-cy=vm-admin-table]").contains(vmHost).parents("tr").contains("admin").click();
 
-        cy.get('[data-cy=form-dialog]')
-            .should('contain', 'Update login permission')
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "Update login permission")
             .within(() => {
-                cy.get('label')
-                    .contains('Add groups')
-                    .parent()
-                    .as('groupInput');
+                cy.get("label").contains("Add groups").parent().as("groupInput");
             });
 
-        cy.get('@groupInput').within(() => {
-            cy.get('div[role=button]').contains('sudo').parent().find('svg').click();
-            cy.get('div[role=button]').contains('docker').parent().find('svg').click();
+        cy.get("@groupInput").within(() => {
+            cy.get("div[role=button]").contains("sudo").parent().find("svg").click();
+            cy.get("div[role=button]").contains("docker").parent().find("svg").click();
         });
 
-        cy.get('[data-cy=form-dialog]').within(() => {
-            cy.get('[data-cy=form-submit-btn]').click();
+        cy.get("[data-cy=form-dialog]").within(() => {
+            cy.get("[data-cy=form-submit-btn]").click();
         });
 
         // Wait for page to finish loading
-        cy.get('[data-cy=vm-admin-table]')
+        cy.get("[data-cy=vm-admin-table]")
             .contains(vmHost)
-            .parents('tr')
+            .parents("tr")
             .within(() => {
-                cy.get('div[role=button]')
-                    .parent()
-                    .first()
-                    .contains('admin')
+                cy.get("div[role=button]").parent().first().contains("admin");
             });
 
-        cy.get('[data-cy=vm-admin-table]')
-            .contains(vmHost)
-            .parents('tr')
-            .contains('user')
-            .click();
+        cy.get("[data-cy=vm-admin-table]").contains(vmHost).parents("tr").contains("user").click();
 
-        cy.get('[data-cy=form-dialog]')
-            .should('contain', 'Update login permission')
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "Update login permission")
             .within(() => {
-                cy.get('label')
-                    .contains('Add groups')
+                cy.get("label")
+                    .contains("Add groups")
                     .parent()
                     .within(() => {
-                        cy.get('input').type('docker{enter}');
-                    })
+                        cy.get("input").type("docker{enter}");
+                    });
             });
 
-        cy.get('[data-cy=form-dialog]').within(() => {
-            cy.get('[data-cy=form-submit-btn]').click();
+        cy.get("[data-cy=form-dialog]").within(() => {
+            cy.get("[data-cy=form-submit-btn]").click();
         });
 
         // Verify new login permissions
         // Check admin's vm page for login
         cy.get('header button[title="Account Management"]').click();
-        cy.get('#account-menu').contains('Virtual Machines').click();
+        cy.get("#account-menu").contains("Virtual Machines").click();
 
-        cy.get('[data-cy=vm-user-table]')
+        cy.get("[data-cy=vm-user-table]")
             .contains(vmHost)
-            .parents('tr')
+            .parents("tr")
             .within(() => {
-                cy.get('td').contains('admin');
-                cy.get('td').should('not.contain', 'docker');
-                cy.get('td').should('not.contain', 'sudo');
-                cy.get('td').contains('ssh admin@' + vmHost);
-        });
+                cy.get("td").contains("admin");
+                cy.get("td").should("not.contain", "docker");
+                cy.get("td").should("not.contain", "sudo");
+                cy.get("td").contains("ssh admin@" + vmHost);
+            });
 
         // Verify new login permissions
         // Check activeUser's vm page for login
         cy.loginAs(activeUser);
         cy.get('header button[title="Account Management"]').click();
-        cy.get('#account-menu').contains('Virtual Machines').click();
+        cy.get("#account-menu").contains("Virtual Machines").click();
 
-        cy.get('[data-cy=vm-user-table]')
+        cy.get("[data-cy=vm-user-table]")
             .contains(vmHost)
-            .parents('tr')
+            .parents("tr")
             .within(() => {
-                cy.get('td').contains('user');
-                cy.get('td').contains('docker');
-                cy.get('td').should('not.contain', 'sudo');
-                cy.get('td').contains('ssh user@' + vmHost);
-        });
+                cy.get("td").contains("user");
+                cy.get("td").contains("docker");
+                cy.get("td").should("not.contain", "sudo");
+                cy.get("td").contains("ssh user@" + vmHost);
+            });
 
         // Remove login permissions
         cy.loginAs(adminUser);
         cy.get('header button[title="Admin Panel"]').click();
-        cy.get('#admin-menu').contains('Virtual Machines').click();
+        cy.get("#admin-menu").contains("Virtual Machines").click();
 
-        cy.get('[data-cy=vm-admin-table]')
-            .contains('user'); // Wait for page to finish
+        cy.get("[data-cy=vm-admin-table]").contains("user"); // Wait for page to finish
 
-        cy.get('[data-cy=vm-admin-table]')
+        cy.get("[data-cy=vm-admin-table]")
             .contains(vmHost)
-            .parents('tr')
-            .as('vmRow')
-            .contains('user')
-            .parents('[role=button]')
-            .find('svg')
-            .as('removeButton');
-        cy.get('@removeButton').click();
-        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
-
-        cy.get('@vmRow')
-            .within(() => {
-                cy.get('div[role=button]').should('not.contain', 'user');
-                cy.get('div[role=button]').should('have.length', 1)
-            });
+            .parents("tr")
+            .as("vmRow")
+            .contains("user")
+            .parents("[role=button]")
+            .find("svg")
+            .as("removeButton");
+        cy.get("@removeButton").click();
+        cy.get("[data-cy=confirmation-dialog-ok-btn]").click();
+
+        cy.get("@vmRow").within(() => {
+            cy.get("div[role=button]").should("not.contain", "user");
+            cy.get("div[role=button]").should("have.length", 1);
+        });
 
-        cy.get('@vmRow')
-            .find('div[role=button]')
-            .contains('admin')
-            .parents('[role=button]')
-            .find('svg')
-            .as('removeButton');
-        cy.get('@removeButton').click();
-        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+        cy.get("@vmRow").find("div[role=button]").contains("admin").parents("[role=button]").find("svg").as("removeButton");
+        cy.get("@removeButton").click();
+        cy.get("[data-cy=confirmation-dialog-ok-btn]").click();
 
-        cy.get('[data-cy=vm-admin-table]')
+        cy.waitForDom()
+            .get("[data-cy=vm-admin-table]")
             .contains(vmHost)
-            .parents('tr')
+            .parents("tr")
             .within(() => {
-                cy.get('div[role=button]').should('not.contain', 'admin');
+                cy.get("div[role=button]").should("not.exist");
             });
 
         // Check admin's vm page for login
         cy.get('header button[title="Account Management"]').click();
-        cy.get('#account-menu').contains('Virtual Machines').click();
+        cy.get("#account-menu").contains("Virtual Machines").click();
 
-        cy.get('[data-cy=vm-user-panel]')
-            .should('not.contain', vmHost);
+        cy.get("[data-cy=vm-user-panel]").should("not.contain", vmHost);
 
         // Check activeUser's vm page for login
         cy.loginAs(activeUser);
         cy.get('header button[title="Account Management"]').click();
-        cy.get('#account-menu').contains('Virtual Machines').click();
+        cy.get("#account-menu").contains("Virtual Machines").click();
 
-        cy.get('[data-cy=vm-user-panel]')
-            .should('not.contain', vmHost);
+        cy.get("[data-cy=vm-user-panel]").should("not.contain", vmHost);
     });
 });
diff --git a/cypress/integration/workflow.spec.js b/cypress/integration/workflow.spec.js
new file mode 100644 (file)
index 0000000..844e87d
--- /dev/null
@@ -0,0 +1,295 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Registered workflow panel tests', function() {
+    let activeUser;
+    let adminUser;
+
+    before(function() {
+        // Only set up common users once. These aren't set up as aliases because
+        // aliases are cleaned up after every test. Also it doesn't make sense
+        // to set the same users on beforeEach() over and over again, so we
+        // separate a little from Cypress' 'Best Practices' here.
+        cy.getUser('admin', 'Admin', 'User', true, true)
+            .as('adminUser').then(function() {
+                adminUser = this.adminUser;
+            }
+        );
+        cy.getUser('user', 'Active', 'User', false, true)
+            .as('activeUser').then(function() {
+                activeUser = this.activeUser;
+            }
+        );
+    });
+
+    it('should handle null definition', function() {
+        cy.createResource(activeUser.token, "workflows", {workflow: {name: "Test wf"}})
+            .then(function(workflowResource) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/workflows/${workflowResource.uuid}`);
+                cy.get('[data-cy=registered-workflow-info-panel]').should('contain', workflowResource.name);
+                cy.get('[data-cy=workflow-details-attributes-modifiedby-user]').contains(`Active User (${activeUser.user.uuid})`);
+            });
+    });
+
+    it('should handle malformed definition', function() {
+        cy.createResource(activeUser.token, "workflows", {workflow: {name: "Test wf", definition: "zap:"}})
+            .then(function(workflowResource) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/workflows/${workflowResource.uuid}`);
+                cy.get('[data-cy=registered-workflow-info-panel]').should('contain', workflowResource.name);
+                cy.get('[data-cy=workflow-details-attributes-modifiedby-user]').contains(`Active User (${activeUser.user.uuid})`);
+            });
+    });
+
+    it('should handle malformed run', function() {
+        cy.createResource(activeUser.token, "workflows", {workflow: {
+            name: "Test wf",
+            definition: JSON.stringify({
+                cwlVersion: "v1.2",
+                $graph: [
+                    {
+                        "class": "Workflow",
+                        "id": "#main",
+                        "inputs": [],
+                        "outputs": [],
+                        "requirements": [
+                            {
+                                "class": "SubworkflowFeatureRequirement"
+                            }
+                        ],
+                        "steps": [
+                            {
+                                "id": "#main/cat1-testcli.cwl (v1.2.0-109-g9b091ed)",
+                                "in": [],
+                                "label": "cat1-testcli.cwl (v1.2.0-109-g9b091ed)",
+                                "out": [
+                                    {
+                                        "id": "#main/step/args"
+                                    }
+                                ],
+                                "run": `keep:undefined/bar`
+                            }
+                        ]
+                    }
+                ],
+                "cwlVersion": "v1.2",
+                "http://arvados.org/cwl#gitBranch": "1.2.1_proposed",
+                "http://arvados.org/cwl#gitCommit": "9b091ed7e0bef98b3312e9478c52b89ba25792de",
+                "http://arvados.org/cwl#gitCommitter": "GitHub <noreply@github.com>",
+                "http://arvados.org/cwl#gitDate": "Sun, 11 Sep 2022 21:24:42 +0200",
+                "http://arvados.org/cwl#gitDescribe": "v1.2.0-109-g9b091ed",
+                "http://arvados.org/cwl#gitOrigin": "git@github.com:common-workflow-language/cwl-v1.2",
+                "http://arvados.org/cwl#gitPath": "tests/cat1-testcli.cwl",
+                "http://arvados.org/cwl#gitStatus": ""
+            })
+        }}).then(function(workflowResource) {
+            cy.loginAs(activeUser);
+            cy.goToPath(`/workflows/${workflowResource.uuid}`);
+            cy.get('[data-cy=registered-workflow-info-panel]').should('contain', workflowResource.name);
+            cy.get('[data-cy=workflow-details-attributes-modifiedby-user]').contains(`Active User (${activeUser.user.uuid})`);
+        });
+    });
+
+    const verifyIOParameter = (name, label, doc, val, collection) => {
+        cy.get('table tr').contains(name).parents('tr').within(($mainRow) => {
+            label && cy.contains(label);
+
+            if (val) {
+                if (Array.isArray(val)) {
+                    val.forEach(v => cy.contains(v));
+                } else {
+                    cy.contains(val);
+                }
+            }
+            if (collection) {
+                cy.contains(collection);
+            }
+        });
+    };
+
+    it('shows workflow details', function() {
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+        })
+            .then(function(collectionResource) {
+                cy.createResource(activeUser.token, "workflows", {workflow: {
+                    name: "Test wf",
+                    definition: JSON.stringify({
+                        cwlVersion: "v1.2",
+                        $graph: [
+                            {
+                                "class": "Workflow",
+                                "hints": [
+                                    {
+                                        "class": "DockerRequirement",
+                                        "dockerPull": "python:2-slim"
+                                    }
+                                ],
+                                "id": "#main",
+                                "inputs": [
+                                    {
+                                        "id": "#main/file1",
+                                        "type": "File"
+                                    },
+                                    {
+                                        "id": "#main/numbering",
+                                        "type": [
+                                            "null",
+                                            "boolean"
+                                        ]
+                                    },
+                                    {
+                                        "default": {
+                                            "basename": "args.py",
+                                            "class": "File",
+                                            "location": "keep:de738550734533c5027997c87dc5488e+53/args.py",
+                                            "nameext": ".py",
+                                            "nameroot": "args",
+                                            "size": 179
+                                        },
+                                        "id": "#main/args.py",
+                                        "type": "File"
+                                    }
+                                ],
+                                "outputs": [
+                                    {
+                                        "id": "#main/args",
+                                        "outputSource": "#main/step/args",
+                                        "type": {
+                                            "items": "string",
+                                            "name": "_:b0adccc1-502d-476f-8a5b-c8ef7119e2dc",
+                                            "type": "array"
+                                        }
+                                    }
+                                ],
+                                "requirements": [
+                                    {
+                                        "class": "SubworkflowFeatureRequirement"
+                                    }
+                                ],
+                                "steps": [
+                                    {
+                                        "id": "#main/cat1-testcli.cwl (v1.2.0-109-g9b091ed)",
+                                        "in": [
+                                            {
+                                                "id": "#main/step/file1",
+                                                "source": "#main/file1"
+                                            },
+                                            {
+                                                "id": "#main/step/numbering",
+                                                "source": "#main/numbering"
+                                            },
+                                            {
+                                                "id": "#main/step/args.py",
+                                                "source": "#main/args.py"
+                                            }
+                                        ],
+                                        "label": "cat1-testcli.cwl (v1.2.0-109-g9b091ed)",
+                                        "out": [
+                                            {
+                                                "id": "#main/step/args"
+                                            }
+                                        ],
+                                        "run": `keep:${collectionResource.portable_data_hash}/bar`
+                                    }
+                                ]
+                            }
+                        ],
+                        "cwlVersion": "v1.2",
+                        "http://arvados.org/cwl#gitBranch": "1.2.1_proposed",
+                        "http://arvados.org/cwl#gitCommit": "9b091ed7e0bef98b3312e9478c52b89ba25792de",
+                        "http://arvados.org/cwl#gitCommitter": "GitHub <noreply@github.com>",
+                        "http://arvados.org/cwl#gitDate": "Sun, 11 Sep 2022 21:24:42 +0200",
+                        "http://arvados.org/cwl#gitDescribe": "v1.2.0-109-g9b091ed",
+                        "http://arvados.org/cwl#gitOrigin": "git@github.com:common-workflow-language/cwl-v1.2",
+                        "http://arvados.org/cwl#gitPath": "tests/cat1-testcli.cwl",
+                        "http://arvados.org/cwl#gitStatus": ""
+                    })
+                }}).then(function(workflowResource) {
+                    cy.loginAs(activeUser);
+                    cy.goToPath(`/workflows/${workflowResource.uuid}`);
+                    cy.get('[data-cy=registered-workflow-info-panel]').should('contain', workflowResource.name);
+                    cy.get('[data-cy=workflow-details-attributes-modifiedby-user]').contains(`Active User (${activeUser.user.uuid})`);
+                    cy.get('[data-cy=registered-workflow-info-panel')
+                        .should('contain', 'gitCommit: 9b091ed7e0bef98b3312e9478c52b89ba25792de')
+
+                    cy.get('[data-cy=process-io-card] h6').contains('Inputs')
+                        .parents('[data-cy=process-io-card]').within(() => {
+                            verifyIOParameter('file1', null, '', '', '');
+                            verifyIOParameter('numbering', null, '', '', '');
+                            verifyIOParameter('args.py', null, '', 'args.py', 'de738550734533c5027997c87dc5488e+53');
+                        });
+                    cy.get('[data-cy=process-io-card] h6').contains('Outputs')
+                        .parents('[data-cy=process-io-card]').within(() => {
+                            verifyIOParameter('args', null, '', '', '');
+                        });
+                    cy.get('[data-cy=collection-files-panel]').within(() => {
+                        cy.get('[data-cy=collection-files-right-panel]', { timeout: 5000 })
+                            .should('contain', 'bar');
+                    });
+                });
+            });
+    });
+
+    it('can delete a workflow', function() {
+        cy.createResource(activeUser.token, "workflows", {workflow: {name: "Test wf"}})
+            .then(function(workflowResource) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/projects/${activeUser.user.uuid}`);
+                cy.get('[data-cy=project-panel] table tbody').contains(workflowResource.name).rightclick();
+                cy.get('[data-cy=context-menu]').contains('Delete Workflow').click();
+                cy.get('[data-cy=project-panel] table tbody').should('not.contain', workflowResource.name);
+            });
+    });
+
+    it('cannot delete readonly workflow', function() {
+        cy.createProject({
+            owningUser: adminUser,
+            targetUser: activeUser,
+            projectName: 'mySharedReadonlyProject',
+            canWrite: false,
+        });
+        cy.getAll('@mySharedReadonlyProject')
+            .then(function ([mySharedReadonlyProject]) {
+                cy.createResource(adminUser.token, "workflows", {workflow: {name: "Test wf", owner_uuid: mySharedReadonlyProject.uuid}})
+                    .then(function(workflowResource) {
+                        cy.loginAs(activeUser);
+                        cy.goToPath(`/shared-with-me`);
+                        cy.contains("mySharedReadonlyProject").click();
+                        cy.get('[data-cy=project-panel] table tbody').contains(workflowResource.name).rightclick();
+                        cy.get('[data-cy=context-menu]').should("not.contain", 'Delete Workflow');
+                    });
+            });
+    });
+
+    it('shows the appropriate buttons in the multiselect toolbar', () => {
+
+        const msButtonTooltips = [
+            'API Details',
+            'Copy to clipboard',
+            'Delete Workflow',
+            'Open in new tab',
+            'Run Workflow',
+            'View details',
+        ];
+
+        cy.createResource(activeUser.token, "workflows", {workflow: {name: "Test wf"}})
+            .then(function(workflowResource) {
+                cy.loginAs(activeUser);
+                cy.get("[data-cy=side-panel-tree]").contains("Home Projects").click();
+                cy.waitForDom()
+                cy.get('[data-cy=data-table-row]').contains(workflowResource.name).should('exist').parent().parent().parent().click()
+                cy.get('[data-cy=multiselect-button]').should('have.length', msButtonTooltips.length)
+                for (let i = 0; i < msButtonTooltips.length; i++) {
+                        cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseover');
+                        cy.get('body').contains(msButtonTooltips[i]).should('exist')
+                        cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseout');
+                    }
+                });
+    })
+
+});
index f09d959b8fe9ac6ec35b36f35bde9e7aa36799c2..1682a8a814440c875fa43ba312e0d76beea183ba 100644 (file)
 // -- This will overwrite an existing command --
 // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
 
-const controllerURL = Cypress.env('controller_url');
-const systemToken = Cypress.env('system_token');
+import { extractFilesData } from "services/collection-service/collection-service-files-response";
+
+const controllerURL = Cypress.env("controller_url");
+const systemToken = Cypress.env("system_token");
 let createdResources = [];
 
-// Clean up on a 'before' hook to allow post-mortem analysis on individual tests.
-beforeEach(function () {
+const containerLogFolderPrefix = "log for container ";
+
+// Clean up anything that was created.  You can temporarily add
+// 'return' to the top if you need the resources to hang around to
+// debug a specific test.
+afterEach(function () {
     if (createdResources.length === 0) {
         return;
     }
-    cy.log(`Cleaning ${createdResources.length} previously created resource(s)`);
-    createdResources.forEach(function({suffix, uuid}) {
+    cy.log(`Cleaning ${createdResources.length} previously created resource(s).`);
+    createdResources.forEach(function ({ suffix, uuid }) {
         // Don't fail when a resource isn't already there, some objects may have
         // been removed, directly or indirectly, from the test that created them.
         cy.deleteResource(systemToken, suffix, uuid, false);
@@ -47,363 +53,416 @@ beforeEach(function () {
 });
 
 Cypress.Commands.add(
-    "doRequest", (method = 'GET', path = '', data = null, qs = null,
-        token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => {
-    return cy.request({
-        method: method,
-        url: `${controllerURL.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`,
-        body: data,
-        qs: auth ? qs : Object.assign({ api_token: token }, qs),
-        auth: auth ? { bearer: `${token}` } : undefined,
-        followRedirect: followRedirect,
-        failOnStatusCode: failOnStatusCode
-    });
-});
+    "doRequest",
+    (method = "GET", path = "", data = null, qs = null, token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => {
+        return cy.request({
+            method: method,
+            url: `${controllerURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`,
+            body: data,
+            qs: auth ? qs : Object.assign({ api_token: token }, qs),
+            auth: auth ? { bearer: `${token}` } : undefined,
+            followRedirect: followRedirect,
+            failOnStatusCode: failOnStatusCode,
+        });
+    }
+);
 
 Cypress.Commands.add(
-    "getUser", (username, first_name = '', last_name = '', is_admin = false, is_active = true) => {
-        // Create user if not already created
-        return cy.doRequest('POST', '/auth/controller/callback', {
-            auth_info: JSON.stringify({
-                email: `${username}@example.local`,
-                username: username,
-                first_name: first_name,
-                last_name: last_name,
-                alternate_emails: []
-            }),
-            return_to: ',https://example.local'
-        }, null, systemToken, true, false) // Don't follow redirects so we can catch the token
-        .its('headers.location').as('location')
-        // Get its token and set the account up as admin and/or active
-        .then(function () {
-            this.userToken = this.location.split("=")[1]
-            assert.isString(this.userToken)
-            return cy.doRequest('GET', '/arvados/v1/users', null, {
-                filters: `[["username", "=", "${username}"]]`
-            })
-            .its('body.items.0').as('aUser')
+    "doWebDAVRequest",
+    (method = "GET", path = "", data = null, qs = null, token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => {
+        return cy.doRequest("GET", "/arvados/v1/config", null, null).then(({ body: config }) => {
+            return cy.request({
+                method: method,
+                url: `${config.Services.WebDAVDownload.ExternalURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`,
+                body: data,
+                qs: auth ? qs : Object.assign({ api_token: token }, qs),
+                auth: auth ? { bearer: `${token}` } : undefined,
+                followRedirect: followRedirect,
+                failOnStatusCode: failOnStatusCode,
+            });
+        });
+    }
+);
+
+Cypress.Commands.add("getUser", (username, first_name = "", last_name = "", is_admin = false, is_active = true) => {
+    // Create user if not already created
+    return (
+        cy
+            .doRequest(
+                "POST",
+                "/auth/controller/callback",
+                {
+                    auth_info: JSON.stringify({
+                        email: `${username}@example.local`,
+                        username: username,
+                        first_name: first_name,
+                        last_name: last_name,
+                        alternate_emails: [],
+                    }),
+                    return_to: ",https://controller.api.client.invalid",
+                },
+                null,
+                systemToken,
+                true,
+                false
+            ) // Don't follow redirects so we can catch the token
+            .its("headers.location")
+            .as("location")
+            // Get its token and set the account up as admin and/or active
             .then(function () {
-                cy.doRequest('PUT', `/arvados/v1/users/${this.aUser.uuid}`, {
-                    user: {
-                        is_admin: is_admin,
-                        is_active: is_active
-                    }
-                })
-                .its('body').as('theUser')
-                .then(function () {
-                    cy.doRequest('GET', '/arvados/v1/api_clients', null, {
-                        filters: `[["is_trusted", "=", false]]`,
-                        order: `["created_at desc"]`
-                    })
-                    .its('body.items').as('apiClients')
-                    .then(function () {
-                        if (this.apiClients.length > 0) {
-                            cy.doRequest('PUT', `/arvados/v1/api_clients/${this.apiClients[0].uuid}`, {
-                                api_client: {
-                                    is_trusted: true
-                                }
-                            })
-                            .its('body').as('updatedApiClient')
-                            .then(function() {
-                                assert(this.updatedApiClient.is_trusted);
-                            })
-                        }
+                this.userToken = this.location.split("=")[1];
+                assert.isString(this.userToken);
+                return cy
+                    .doRequest("GET", "/arvados/v1/users", null, {
+                        filters: `[["username", "=", "${username}"]]`,
                     })
+                    .its("body.items.0")
+                    .as("aUser")
                     .then(function () {
-                        return { user: this.theUser, token: this.userToken };
-                    })
-                })
+                        cy.doRequest("PUT", `/arvados/v1/users/${this.aUser.uuid}`, {
+                            user: {
+                                is_admin: is_admin,
+                                is_active: is_active,
+                            },
+                        })
+                            .its("body")
+                            .as("theUser")
+                            .then(function () {
+                                cy.doRequest("GET", "/arvados/v1/api_clients", null, {
+                                    filters: `[["is_trusted", "=", false]]`,
+                                    order: `["created_at desc"]`,
+                                })
+                                    .its("body.items")
+                                    .as("apiClients")
+                                    .then(function () {
+                                        if (this.apiClients.length > 0) {
+                                            cy.doRequest("PUT", `/arvados/v1/api_clients/${this.apiClients[0].uuid}`, {
+                                                api_client: {
+                                                    is_trusted: true,
+                                                },
+                                            })
+                                                .its("body")
+                                                .as("updatedApiClient")
+                                                .then(function () {
+                                                    assert(this.updatedApiClient.is_trusted);
+                                                });
+                                        }
+                                    })
+                                    .then(function () {
+                                        return { user: this.theUser, token: this.userToken };
+                                    });
+                            });
+                    });
             })
-        })
-    }
-)
+    );
+});
 
-Cypress.Commands.add(
-    "createLink", (token, data) => {
-        return cy.createResource(token, 'links', {
-            link: JSON.stringify(data)
-        })
-    }
-)
+Cypress.Commands.add("createLink", (token, data) => {
+    return cy.createResource(token, "links", {
+        link: JSON.stringify(data),
+    });
+});
 
-Cypress.Commands.add(
-    "createGroup", (token, data) => {
-        return cy.createResource(token, 'groups', {
-            group: JSON.stringify(data),
-            ensure_unique_name: true
-        })
-    }
-)
+Cypress.Commands.add("createGroup", (token, data) => {
+    return cy.createResource(token, "groups", {
+        group: JSON.stringify(data),
+        ensure_unique_name: true,
+    });
+});
 
-Cypress.Commands.add(
-    "trashGroup", (token, uuid) => {
-        return cy.deleteResource(token, 'groups', uuid);
-    }
-)
+Cypress.Commands.add("trashGroup", (token, uuid) => {
+    return cy.deleteResource(token, "groups", uuid);
+});
 
+Cypress.Commands.add("createWorkflow", (token, data) => {
+    return cy.createResource(token, "workflows", {
+        workflow: JSON.stringify(data),
+        ensure_unique_name: true,
+    });
+});
 
-Cypress.Commands.add(
-    "createWorkflow", (token, data) => {
-        return cy.createResource(token, 'workflows', {
-            workflow: JSON.stringify(data),
-            ensure_unique_name: true
-        })
-    }
-)
+Cypress.Commands.add("createCollection", (token, data) => {
+    return cy.createResource(token, "collections", {
+        collection: JSON.stringify(data),
+        ensure_unique_name: true,
+    });
+});
 
-Cypress.Commands.add(
-    "getCollection", (token, uuid) => {
-        return cy.getResource(token, 'collections', uuid)
-    }
-)
+Cypress.Commands.add("getCollection", (token, uuid) => {
+    return cy.getResource(token, "collections", uuid);
+});
 
-Cypress.Commands.add(
-    "createCollection", (token, data) => {
-        return cy.createResource(token, 'collections', {
-            collection: JSON.stringify(data),
-            ensure_unique_name: true
-        })
-    }
-)
+Cypress.Commands.add("updateCollection", (token, uuid, data) => {
+    return cy.updateResource(token, "collections", uuid, {
+        collection: JSON.stringify(data),
+    });
+});
 
-Cypress.Commands.add(
-    "updateCollection", (token, uuid, data) => {
-        return cy.updateResource(token, 'collections', uuid, {
-            collection: JSON.stringify(data)
-        })
-    }
-)
+Cypress.Commands.add("collectionReplaceFiles", (token, uuid, data) => {
+    return cy.updateResource(token, "collections", uuid, {
+        collection: {
+            preserve_version: true,
+        },
+        replace_files: JSON.stringify(data),
+    });
+});
 
-Cypress.Commands.add(
-    "getContainer", (token, uuid) => {
-        return cy.getResource(token, 'containers', uuid)
-    }
-)
+Cypress.Commands.add("getContainer", (token, uuid) => {
+    return cy.getResource(token, "containers", uuid);
+});
 
-Cypress.Commands.add(
-    "updateContainer", (token, uuid, data) => {
-        return cy.updateResource(token, 'containers', uuid, {
-            container: JSON.stringify(data)
-        })
-    }
-)
+Cypress.Commands.add("updateContainer", (token, uuid, data) => {
+    return cy.updateResource(token, "containers", uuid, {
+        container: JSON.stringify(data),
+    });
+});
 
-Cypress.Commands.add(
-    'createContainerRequest', (token, data) => {
-        return cy.createResource(token, 'container_requests', {
-            container_request: JSON.stringify(data),
-            ensure_unique_name: true
-        })
-    }
-)
+Cypress.Commands.add("getContainerRequest", (token, uuid) => {
+    return cy.getResource(token, "container_requests", uuid);
+});
 
-Cypress.Commands.add(
-    "updateContainerRequest", (token, uuid, data) => {
-        return cy.updateResource(token, 'container_requests', uuid, {
-            container_request: JSON.stringify(data)
-        })
-    }
-)
+Cypress.Commands.add("createContainerRequest", (token, data) => {
+    return cy.createResource(token, "container_requests", {
+        container_request: JSON.stringify(data),
+        ensure_unique_name: true,
+    });
+});
 
-Cypress.Commands.add(
-    "createLog", (token, data) => {
-        return cy.createResource(token, 'logs', {
-            log: JSON.stringify(data)
-        })
-    }
-)
+Cypress.Commands.add("updateContainerRequest", (token, uuid, data) => {
+    return cy.updateResource(token, "container_requests", uuid, {
+        container_request: JSON.stringify(data),
+    });
+});
 
-Cypress.Commands.add(
-    "logsForContainer", (token, uuid, logType, logTextArray = []) => {
-        let logs = [];
-        for (const logText of logTextArray) {
-            logs.push(cy.createLog(token, {
-                object_uuid: uuid,
-                event_type: logType,
-                properties: {
-                    text: logText
+/**
+ * Requires an admin token for log_uuid modification to succeed
+ */
+Cypress.Commands.add("appendLog", (token, crUuid, fileName, lines = []) =>
+    cy.getContainerRequest(token, crUuid).then(containerRequest => {
+        if (containerRequest.log_uuid) {
+            cy.listContainerRequestLogs(token, crUuid).then(logFiles => {
+                const filePath = `${containerRequest.log_uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}/${fileName}`;
+                if (logFiles.find(file => file.name === fileName)) {
+                    // File exists, fetch and append
+                    return cy
+                        .doWebDAVRequest("GET", `c=${filePath}`, null, null, token)
+                        .then(({ body: contents }) =>
+                            cy.doWebDAVRequest("PUT", `c=${filePath}`, contents.split("\n").concat(lines).join("\n"), null, token)
+                        );
+                } else {
+                    // File not exists, put new file
+                    cy.doWebDAVRequest("PUT", `c=${filePath}`, lines.join("\n"), null, token);
                 }
-            }).as('lastLogRecord'))
+            });
+        } else {
+            // Create log collection
+            return cy
+                .createCollection(token, {
+                    name: `Test log collection ${Math.floor(Math.random() * 999999)}`,
+                    owner_uuid: containerRequest.owner_uuid,
+                    manifest_text: "",
+                })
+                .then(collection => {
+                    // Update CR log_uuid to fake log collection
+                    cy.updateContainerRequest(token, containerRequest.uuid, {
+                        log_uuid: collection.uuid,
+                    }).then(() =>
+                        // Create empty directory for container uuid
+                        cy
+                            .collectionReplaceFiles(token, collection.uuid, {
+                                [`/${containerLogFolderPrefix}${containerRequest.container_uuid}`]: "d41d8cd98f00b204e9800998ecf8427e+0",
+                            })
+                            .then(() =>
+                                // Put new log file with contents into fake log collection
+                                cy.doWebDAVRequest(
+                                    "PUT",
+                                    `c=${collection.uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}/${fileName}`,
+                                    lines.join("\n"),
+                                    null,
+                                    token
+                                )
+                            )
+                    );
+                });
         }
-        cy.getAll('@lastLogRecord').then(function () {
-            return logs;
-        })
-    }
-)
-
-Cypress.Commands.add(
-    "createVirtualMachine", (token, data) => {
-        return cy.createResource(token, 'virtual_machines', {
-            virtual_machine: JSON.stringify(data),
-            ensure_unique_name: true
-        })
-    }
-)
-
-Cypress.Commands.add(
-    "getResource", (token, suffix, uuid) => {
-        return cy.doRequest('GET', `/arvados/v1/${suffix}/${uuid}`, null, {}, token)
-            .its('body')
-            .then(function (resource) {
-                return resource;
+    })
+);
+
+Cypress.Commands.add("listContainerRequestLogs", (token, crUuid) =>
+    cy.getContainerRequest(token, crUuid).then(containerRequest =>
+        cy
+            .doWebDAVRequest(
+                "PROPFIND",
+                `c=${containerRequest.log_uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}`,
+                null,
+                null,
+                token
+            )
+            .then(({ body: data }) => {
+                return extractFilesData(new DOMParser().parseFromString(data, "text/xml"));
             })
-    }
-)
+    )
+);
 
-Cypress.Commands.add(
-    "createResource", (token, suffix, data) => {
-        return cy.doRequest('POST', '/arvados/v1/' + suffix, data, null, token, true)
-            .its('body')
-            .then(function (resource) {
-                createdResources.push({suffix, uuid: resource.uuid});
-                return resource;
-            })
-    }
-)
+Cypress.Commands.add("createVirtualMachine", (token, data) => {
+    return cy.createResource(token, "virtual_machines", {
+        virtual_machine: JSON.stringify(data),
+        ensure_unique_name: true,
+    });
+});
 
-Cypress.Commands.add(
-    "deleteResource", (token, suffix, uuid, failOnStatusCode = true) => {
-        return cy.doRequest('DELETE', '/arvados/v1/' + suffix + '/' + uuid, null, null, token, false, true, failOnStatusCode)
-            .its('body')
-            .then(function (resource) {
-                return resource;
-            })
-    }
-)
+Cypress.Commands.add("getResource", (token, suffix, uuid) => {
+    return cy
+        .doRequest("GET", `/arvados/v1/${suffix}/${uuid}`, null, {}, token)
+        .its("body")
+        .then(function (resource) {
+            return resource;
+        });
+});
 
-Cypress.Commands.add(
-    "updateResource", (token, suffix, uuid, data) => {
-        return cy.doRequest('PATCH', '/arvados/v1/' + suffix + '/' + uuid, data, null, token, true)
-            .its('body')
-            .then(function (resource) {
-                return resource;
-            })
-    }
-)
+Cypress.Commands.add("createResource", (token, suffix, data) => {
+    return cy
+        .doRequest("POST", "/arvados/v1/" + suffix, data, null, token, true)
+        .its("body")
+        .then(function (resource) {
+            createdResources.push({ suffix, uuid: resource.uuid });
+            return resource;
+        });
+});
 
-Cypress.Commands.add(
-    "loginAs", (user) => {
-        cy.clearCookies()
-        cy.clearLocalStorage()
-        cy.visit(`/token/?api_token=${user.token}`);
-        cy.url({timeout: 10000}).should('contain', '/projects/');
-        cy.get('div#root').should('contain', 'Arvados Workbench (zzzzz)');
-        cy.get('div#root').should('not.contain', 'Your account is inactive');
-    }
-)
+Cypress.Commands.add("deleteResource", (token, suffix, uuid, failOnStatusCode = true) => {
+    return cy
+        .doRequest("DELETE", "/arvados/v1/" + suffix + "/" + uuid, null, null, token, false, true, failOnStatusCode)
+        .its("body")
+        .then(function (resource) {
+            return resource;
+        });
+});
 
-Cypress.Commands.add(
-    "testEditProjectOrCollection", (container, oldName, newName, newDescription, isProject = true) => {
-        cy.get(container).contains(oldName).rightclick();
-        cy.get('[data-cy=context-menu]').contains(isProject ? 'Edit project' : 'Edit collection').click();
-        cy.get('[data-cy=form-dialog]').within(() => {
-            cy.get('input[name=name]').clear().type(newName);
-            cy.get(isProject ? 'div[contenteditable=true]' : 'input[name=description]').clear().type(newDescription);
-            cy.get('[data-cy=form-submit-btn]').click();
+Cypress.Commands.add("updateResource", (token, suffix, uuid, data) => {
+    return cy
+        .doRequest("PATCH", "/arvados/v1/" + suffix + "/" + uuid, data, null, token, true)
+        .its("body")
+        .then(function (resource) {
+            return resource;
         });
+});
 
-        cy.get(container).contains(newName).rightclick();
-        cy.get('[data-cy=context-menu]').contains(isProject ? 'Edit project' : 'Edit collection').click();
-        cy.get('[data-cy=form-dialog]').within(() => {
-            cy.get('input[name=name]').should('have.value', newName);
+Cypress.Commands.add("loginAs", user => {
+    cy.clearCookies();
+    cy.clearLocalStorage();
+    cy.visit(`/token/?api_token=${user.token}`);
+    cy.url({ timeout: 10000 }).should("contain", "/projects/");
+    cy.get("div#root").should("contain", "Arvados Workbench (zzzzz)");
+    cy.get("div#root").should("not.contain", "Your account is inactive");
+});
 
-            if (isProject) {
-                cy.get('span[data-text=true]').contains(newDescription);
-            } else {
-                cy.get('input[name=description]').should('have.value', newDescription);
-            }
+Cypress.Commands.add("testEditProjectOrCollection", (container, oldName, newName, newDescription, isProject = true) => {
+    cy.get(container).contains(oldName).rightclick();
+    cy.get("[data-cy=context-menu]")
+        .contains(isProject ? "Edit project" : "Edit collection")
+        .click();
+    cy.get("[data-cy=form-dialog]").within(() => {
+        cy.get("input[name=name]").clear().type(newName);
+        cy.get(isProject ? "div[contenteditable=true]" : "input[name=description]")
+            .clear()
+            .type(newDescription);
+        cy.get("[data-cy=form-submit-btn]").click();
+    });
 
-            cy.get('[data-cy=form-cancel-btn]').click();
-        });
-    }
-)
+    cy.get(container).contains(newName).rightclick();
+    cy.get("[data-cy=context-menu]")
+        .contains(isProject ? "Edit project" : "Edit collection")
+        .click();
+    cy.get("[data-cy=form-dialog]").within(() => {
+        cy.get("input[name=name]").should("have.value", newName);
+
+        if (isProject) {
+            cy.get("span[data-text=true]").contains(newDescription);
+        } else {
+            cy.get("input[name=description]").should("have.value", newDescription);
+        }
 
-Cypress.Commands.add(
-    "doSearch", (searchTerm) => {
-        cy.get('[data-cy=searchbar-input-field]').type(`{selectall}${searchTerm}{enter}`);
-    }
-)
+        cy.get("[data-cy=form-cancel-btn]").click();
+    });
+});
 
-Cypress.Commands.add(
-    "goToPath", (path) => {
-        return cy.window().its('appHistory').invoke('push', path);
-    }
-)
+Cypress.Commands.add("doSearch", searchTerm => {
+    cy.get("[data-cy=searchbar-input-field]").type(`{selectall}${searchTerm}{enter}`);
+});
+
+Cypress.Commands.add("goToPath", path => {
+    return cy.window().its("appHistory").invoke("push", path);
+});
 
-Cypress.Commands.add('getAll', (...elements) => {
-    const promise = cy.wrap([], { log: false })
+Cypress.Commands.add("getAll", (...elements) => {
+    const promise = cy.wrap([], { log: false });
 
     for (let element of elements) {
-        promise.then(arr => cy.get(element).then(got => cy.wrap([...arr, got])))
+        promise.then(arr => cy.get(element).then(got => cy.wrap([...arr, got])));
     }
 
-    return promise
-})
+    return promise;
+});
 
-Cypress.Commands.add('shareWith', (srcUserToken, targetUserUUID, itemUUID, permission = 'can_write') => {
+Cypress.Commands.add("shareWith", (srcUserToken, targetUserUUID, itemUUID, permission = "can_write") => {
     cy.createLink(srcUserToken, {
         name: permission,
-        link_class: 'permission',
+        link_class: "permission",
         head_uuid: itemUUID,
-        tail_uuid: targetUserUUID
+        tail_uuid: targetUserUUID,
     });
-})
+});
 
-Cypress.Commands.add('addToFavorites', (userToken, userUUID, itemUUID) => {
+Cypress.Commands.add("addToFavorites", (userToken, userUUID, itemUUID) => {
     cy.createLink(userToken, {
         head_uuid: itemUUID,
-        link_class: 'star',
-        name: '',
+        link_class: "star",
+        name: "",
         owner_uuid: userUUID,
         tail_uuid: userUUID,
     });
-})
+});
 
-Cypress.Commands.add('createProject', ({
-    owningUser,
-    targetUser,
-    projectName,
-    canWrite,
-    addToFavorites
-}) => {
-    const writePermission = canWrite ? 'can_write' : 'can_read';
+Cypress.Commands.add("createProject", ({ owningUser, targetUser, projectName, canWrite, addToFavorites }) => {
+    const writePermission = canWrite ? "can_write" : "can_read";
 
     cy.createGroup(owningUser.token, {
         name: `${projectName} ${Math.floor(Math.random() * 999999)}`,
-        group_class: 'project',
-    }).as(`${projectName}`).then((project) => {
-        if (targetUser && targetUser !== owningUser) {
-            cy.shareWith(owningUser.token, targetUser.user.uuid, project.uuid, writePermission);
-        }
-        if (addToFavorites) {
-            const user = targetUser ? targetUser : owningUser;
-            cy.addToFavorites(user.token, user.user.uuid, project.uuid);
-        }
-    });
+        group_class: "project",
+    })
+        .as(`${projectName}`)
+        .then(project => {
+            if (targetUser && targetUser !== owningUser) {
+                cy.shareWith(owningUser.token, targetUser.user.uuid, project.uuid, writePermission);
+            }
+            if (addToFavorites) {
+                const user = targetUser ? targetUser : owningUser;
+                cy.addToFavorites(user.token, user.user.uuid, project.uuid);
+            }
+        });
 });
 
 Cypress.Commands.add(
-    'upload',
+    "upload",
     {
-        prevSubject: 'element',
+        prevSubject: "element",
     },
     (subject, file, fileName, binaryMode = true) => {
         cy.window().then(window => {
-            const blob = binaryMode
-                ? b64toBlob(file, '', 512)
-                : new Blob([file], {type: 'text/plain'});
+            const blob = binaryMode ? b64toBlob(file, "", 512) : new Blob([file], { type: "text/plain" });
             const testFile = new window.File([blob], fileName);
 
-            cy.wrap(subject).trigger('drop', {
+            cy.wrap(subject).trigger("drop", {
                 dataTransfer: { files: [testFile] },
             });
-        })
+        });
     }
-)
+);
 
-function b64toBlob(b64Data, contentType = '', sliceSize = 512) {
-    const byteCharacters = atob(b64Data)
-    const byteArrays = []
+function b64toBlob(b64Data, contentType = "", sliceSize = 512) {
+    const byteCharacters = atob(b64Data);
+    const byteArrays = [];
 
     for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
         const slice = byteCharacters.slice(offset, offset + sliceSize);
@@ -419,78 +478,85 @@ function b64toBlob(b64Data, contentType = '', sliceSize = 512) {
     }
 
     const blob = new Blob(byteArrays, { type: contentType });
-    return blob
+    return blob;
 }
 
 // From https://github.com/cypress-io/cypress/issues/7306#issuecomment-1076451070=
 // This command requires the async package (https://www.npmjs.com/package/async)
-Cypress.Commands.add('waitForDom', () => {
-    cy.window().then({
-        // Don't timeout before waitForDom finishes
-        timeout: 10000
-    }, win => {
-      let timeElapsed = 0;
-
-      cy.log("Waiting for DOM mutations to complete");
-
-      return new Cypress.Promise((resolve) => {
-        // set the required variables
-        let async = require("async");
-        let observerConfig = { attributes: true, childList: true, subtree: true };
-        let items = Array.apply(null, { length: 50 }).map(Number.call, Number);
-        win.mutationCount = 0;
-        win.previousMutationCount = null;
-
-        // create an observer instance
-        let observer = new win.MutationObserver((mutations) => {
-          mutations.forEach((mutation) => {
-            // Only record "attributes" type mutations that are not a "class" mutation.
-            // If the mutation is not an "attributes" type, then we always record it.
-            if (mutation.type === 'attributes' && mutation.attributeName !== 'class') {
-              win.mutationCount += 1;
-            } else if (mutation.type !== 'attributes') {
-              win.mutationCount += 1;
-            }
-          });
-
-          // initialize the previousMutationCount
-          if (win.previousMutationCount == null) win.previousMutationCount = 0;
-        });
-
-        // watch the document body for the specified mutations
-        observer.observe(win.document.body, observerConfig);
-
-        // check the DOM for mutations up to 50 times for a maximum time of 5 seconds
-        async.eachSeries(items, function iteratee(item, callback) {
-          // keep track of the elapsed time so we can log it at the end of the command
-          timeElapsed = timeElapsed + 100;
-
-          // make each iteration of the loop 100ms apart
-          setTimeout(() => {
-            if (win.mutationCount === win.previousMutationCount) {
-              // pass an argument to the async callback to exit the loop
-              return callback('Resolved - DOM changes complete.');
-            } else if (win.previousMutationCount != null) {
-              // only set the previous count if the observer has checked the DOM at least once
-              win.previousMutationCount = win.mutationCount;
-              return callback();
-            } else if (win.mutationCount === 0 && win.previousMutationCount == null && item === 4) {
-              // this is an early exit in case nothing is changing in the DOM. That way we only
-              // wait 500ms instead of the full 5 seconds when no DOM changes are occurring.
-              return callback('Resolved - Exiting early since no DOM changes were detected.');
-            } else {
-              // proceed to the next iteration
-              return callback();
-            }
-          }, 100);
-        }, function done() {
-          // Log the total wait time so users can see it
-          cy.log(`DOM mutations ${timeElapsed >= 5000 ? "did not complete" : "completed"} in ${timeElapsed} ms`);
-
-          // disconnect the observer and resolve the promise
-          observer.disconnect();
-          resolve();
-        });
-      });
-    });
-  });
+Cypress.Commands.add("waitForDom", () => {
+    cy.window().then(
+        {
+            // Don't timeout before waitForDom finishes
+            timeout: 10000,
+        },
+        win => {
+            let timeElapsed = 0;
+
+            cy.log("Waiting for DOM mutations to complete");
+
+            return new Cypress.Promise(resolve => {
+                // set the required variables
+                let async = require("async");
+                let observerConfig = { attributes: true, childList: true, subtree: true };
+                let items = Array.apply(null, { length: 50 }).map(Number.call, Number);
+                win.mutationCount = 0;
+                win.previousMutationCount = null;
+
+                // create an observer instance
+                let observer = new win.MutationObserver(mutations => {
+                    mutations.forEach(mutation => {
+                        // Only record "attributes" type mutations that are not a "class" mutation.
+                        // If the mutation is not an "attributes" type, then we always record it.
+                        if (mutation.type === "attributes" && mutation.attributeName !== "class") {
+                            win.mutationCount += 1;
+                        } else if (mutation.type !== "attributes") {
+                            win.mutationCount += 1;
+                        }
+                    });
+
+                    // initialize the previousMutationCount
+                    if (win.previousMutationCount == null) win.previousMutationCount = 0;
+                });
+
+                // watch the document body for the specified mutations
+                observer.observe(win.document.body, observerConfig);
+
+                // check the DOM for mutations up to 50 times for a maximum time of 5 seconds
+                async.eachSeries(
+                    items,
+                    function iteratee(item, callback) {
+                        // keep track of the elapsed time so we can log it at the end of the command
+                        timeElapsed = timeElapsed + 100;
+
+                        // make each iteration of the loop 100ms apart
+                        setTimeout(() => {
+                            if (win.mutationCount === win.previousMutationCount) {
+                                // pass an argument to the async callback to exit the loop
+                                return callback("Resolved - DOM changes complete.");
+                            } else if (win.previousMutationCount != null) {
+                                // only set the previous count if the observer has checked the DOM at least once
+                                win.previousMutationCount = win.mutationCount;
+                                return callback();
+                            } else if (win.mutationCount === 0 && win.previousMutationCount == null && item === 4) {
+                                // this is an early exit in case nothing is changing in the DOM. That way we only
+                                // wait 500ms instead of the full 5 seconds when no DOM changes are occurring.
+                                return callback("Resolved - Exiting early since no DOM changes were detected.");
+                            } else {
+                                // proceed to the next iteration
+                                return callback();
+                            }
+                        }, 100);
+                    },
+                    function done() {
+                        // Log the total wait time so users can see it
+                        cy.log(`DOM mutations ${timeElapsed >= 5000 ? "did not complete" : "completed"} in ${timeElapsed} ms`);
+
+                        // disconnect the observer and resolve the promise
+                        observer.disconnect();
+                        resolve();
+                    }
+                );
+            });
+        }
+    );
+});
index c17fc9174500db0ad7cbe43788d9c18d4c1574e4..abb204907be5b5ac12aa25c994d4473cb18a2800 100644 (file)
@@ -3,6 +3,8 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
+    "@coreui/coreui": "^4.3.2",
+    "@coreui/react": "^4.11.0",
     "@date-io/date-fns": "1",
     "@fortawesome/fontawesome-svg-core": "1.2.28",
     "@fortawesome/free-solid-svg-icons": "5.13.0",
@@ -10,6 +12,7 @@
     "@material-ui/core": "3.9.3",
     "@material-ui/icons": "3.0.1",
     "@types/debounce": "3.0.0",
+    "@types/dompurify": "^3.0.3",
     "@types/file-saver": "2.0.0",
     "@types/js-yaml": "3.11.2",
     "@types/jssha": "0.0.29",
     "axios": "^0.21.1",
     "babel-core": "6.26.3",
     "babel-runtime": "6.26.0",
+    "bootstrap": "^5.3.2",
     "caniuse-lite": "1.0.30001299",
     "classnames": "2.2.6",
     "cwlts": "1.15.29",
     "date-fns": "^2.28.0",
     "debounce": "1.2.0",
+    "dompurify": "^3.0.6",
     "elliptic": "6.5.4",
     "file-saver": "2.0.1",
     "fstream": "1.0.12",
     "is-image": "3.0.0",
     "js-yaml": "3.13.1",
     "jssha": "2.3.1",
-    "jszip": "3.1.5",
+    "jszip": "^3.10.1",
     "lodash": "^4.17.21",
-    "lodash-es": "4.17.14",
+    "lodash-es": "^4.17.21",
     "lodash.mergewith": "4.6.2",
     "lodash.template": "4.5.0",
     "material-ui-pickers": "^2.2.4",
     "mem": "4.0.0",
     "mime": "^3.0.0",
-    "moment": "2.29.1",
+    "moment": "^2.29.4",
     "parse-duration": "0.4.4",
     "prop-types": "15.7.2",
     "query-string": "6.9.0",
-    "react": "16.8.6",
+    "react": "16.14.0",
     "react-copy-to-clipboard": "5.0.3",
     "react-dnd": "5.0.0",
     "react-dnd-html5-backend": "5.0.1",
-    "react-dom": "16.8.6",
+    "react-dom": "16.14.0",
     "react-dropzone": "5.1.1",
     "react-highlight-words": "0.14.0",
     "react-idle-timer": "4.3.6",
     "react-router": "4.3.1",
     "react-router-dom": "4.3.1",
     "react-router-redux": "5.0.0-alpha.9",
-    "react-rte": "0.16.3",
+    "react-rte": "^0.16.5",
     "react-scripts": "3.4.4",
     "react-splitter-layout": "3.0.1",
     "react-transition-group": "2.5.0",
     "react-virtualized-auto-sizer": "1.0.2",
     "react-window": "1.8.5",
     "redux": "4.0.3",
+    "redux-devtools-extension": "^2.13.9",
     "redux-form": "7.4.2",
     "redux-thunk": "2.3.0",
     "reselect": "4.0.0",
@@ -75,8 +81,6 @@
     "shell-escape": "^0.2.0",
     "sinon": "7.3",
     "tippy.js": "^6.3.7",
-    "tslint": "5.20.0",
-    "tslint-etc": "1.6.0",
     "unionize": "2.1.2",
     "uuid": "3.3.2"
   },
@@ -92,6 +96,7 @@
     "watch-css": "npm run build-css && node-sass-chokidar src/ -o src/ --watch --recursive"
   },
   "devDependencies": {
+    "@sinonjs/fake-timers": "^10.3.0",
     "@types/classnames": "2.2.6",
     "@types/enzyme": "3.1.14",
     "@types/enzyme-adapter-react-16": "1.0.3",
     "enzyme": "3.11.0",
     "enzyme-adapter-react-16": "1.15.6",
     "jest-localstorage-mock": "2.2.0",
-    "node-sass": "^4.9.4",
-    "node-sass-chokidar": "1.5.0",
+    "node-sass": "^9.0.0",
+    "node-sass-chokidar": "^2.0.0",
     "redux-devtools": "3.4.1",
     "redux-mock-store": "1.5.4",
     "ts-mock-imports": "1.3.7",
+    "tslint": "5.20.0",
+    "tslint-etc": "1.6.0",
     "typescript": "4.3.4",
     "wait-on": "4.0.2",
     "yamljs": "0.3.0"
diff --git a/public/arrow-to-left.png b/public/arrow-to-left.png
deleted file mode 100644 (file)
index 262c148..0000000
Binary files a/public/arrow-to-left.png and /dev/null differ
diff --git a/public/arrow-to-right.png b/public/arrow-to-right.png
deleted file mode 100644 (file)
index 8205c21..0000000
Binary files a/public/arrow-to-right.png and /dev/null differ
diff --git a/public/collapseLHS-New.svg b/public/collapseLHS-New.svg
deleted file mode 100644 (file)
index ce2eac8..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<!-- Created with Inkscape (http://www.inkscape.org/) -->
-
-<svg
-   version="1.1"
-   id="svg148"
-   width="300"
-   height="300"
-   viewBox="0 0 300 300"
-   xml:space="preserve"
-   xmlns="http://www.w3.org/2000/svg"
-   xmlns:svg="http://www.w3.org/2000/svg"><defs
-     id="defs152" /><g
-     id="g154"><g
-       id="g6337"><path
-         style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:22.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
-         d="m 191.30938,11.567958 0.0193,275.898262"
-         id="path400" /><path
-         style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:22.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
-         d="m 202.57626,149.50744 -89.79939,0.0193"
-         id="path400-3" /><path
-         style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:59.0707;stroke-dasharray:none;stroke-opacity:1"
-         id="path4546"
-         d="M 81.113348,90.153499 -22.761723,90.479332 28.893633,0.35796487 Z"
-         transform="matrix(0,0.5047589,0.28743877,-0.01237225,93.434122,136.22641)" /></g></g></svg>
diff --git a/public/mui-start-icon.svg b/public/mui-start-icon.svg
new file mode 100644 (file)
index 0000000..3140cc3
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M80-240v-480h80v480H80Zm560 0-57-56 144-144H240v-80h487L584-664l56-56 240 240-240 240Z"/></svg>
\ No newline at end of file
index 9b0542820db85e16315f009d59c587c90f5a1f28..eff998ae5ea45cff369d753d249acb8c6a510684 100644 (file)
@@ -5,12 +5,12 @@
 import Axios from 'axios';
 
 export const WORKBENCH_CONFIG_URL =
-  process.env.REACT_APP_ARVADOS_CONFIG_URL || '/config.json';
+    process.env.REACT_APP_ARVADOS_CONFIG_URL || '/config.json';
 
 interface WorkbenchConfig {
-  API_HOST: string;
-  VOCABULARY_URL?: string;
-  FILE_VIEWERS_CONFIG_URL?: string;
+    API_HOST: string;
+    VOCABULARY_URL?: string;
+    FILE_VIEWERS_CONFIG_URL?: string;
 }
 
 export interface ClusterConfigJSON {
@@ -28,18 +28,42 @@ export interface ClusterConfigJSON {
             Scheme: string
         }
     };
-  Mail?: {
-    SupportEmailAddress: string;
-  };
-  Services: {
-    Controller: {
-      ExternalURL: string;
+    Mail?: {
+        SupportEmailAddress: string;
     };
-    Workbench1: {
-      ExternalURL: string;
-    };
-    Workbench2: {
-      ExternalURL: string;
+    Services: {
+        Controller: {
+            ExternalURL: string;
+        };
+        Workbench1: {
+            ExternalURL: string;
+        };
+        Workbench2: {
+            ExternalURL: string;
+        };
+        Workbench: {
+            DisableSharingURLsUI: boolean;
+            ArvadosDocsite: string;
+            FileViewersConfigURL: string;
+            WelcomePageHTML: string;
+            InactivePageHTML: string;
+            SSHHelpPageHTML: string;
+            SSHHelpHostSuffix: string;
+            SiteName: string;
+            IdleTimeout: string;
+        };
+        Websocket: {
+            ExternalURL: string;
+        };
+        WebDAV: {
+            ExternalURL: string;
+        };
+        WebDAVDownload: {
+            ExternalURL: string;
+        };
+        WebShell: {
+            ExternalURL: string;
+        };
     };
     Workbench: {
         DisableSharingURLsUI: boolean;
@@ -51,322 +75,308 @@ export interface ClusterConfigJSON {
         SSHHelpHostSuffix: string;
         SiteName: string;
         IdleTimeout: string;
+        BannerUUID: string;
+        UserProfileFormFields: {};
+        UserProfileFormMessage: string;
     };
-    Websocket: {
-      ExternalURL: string;
-    };
-    WebDAV: {
-      ExternalURL: string;
-    };
-    WebDAVDownload: {
-      ExternalURL: string;
-    };
-    WebShell: {
-      ExternalURL: string;
-    };
-  };
-  Workbench: {
-    DisableSharingURLsUI: boolean;
-    ArvadosDocsite: string;
-    FileViewersConfigURL: string;
-    WelcomePageHTML: string;
-    InactivePageHTML: string;
-    SSHHelpPageHTML: string;
-    SSHHelpHostSuffix: string;
-    SiteName: string;
-    IdleTimeout: string;
-    BannerUUID: string;
-  };
-  Login: {
-    LoginCluster: string;
-    Google: {
-      Enable: boolean;
-    };
-    LDAP: {
-      Enable: boolean;
-    };
-    OpenIDConnect: {
-      Enable: boolean;
-    };
-    PAM: {
-      Enable: boolean;
+    Login: {
+        LoginCluster: string;
+        Google: {
+            Enable: boolean;
+        };
+        LDAP: {
+            Enable: boolean;
+        };
+        OpenIDConnect: {
+            Enable: boolean;
+        };
+        PAM: {
+            Enable: boolean;
+        };
+        SSO: {
+            Enable: boolean;
+        };
+        Test: {
+            Enable: boolean;
+        };
     };
-    SSO: {
-      Enable: boolean;
+    Collections: {
+        ForwardSlashNameSubstitution: string;
+        ManagedProperties?: {
+            [key: string]: {
+                Function: string;
+                Value: string;
+                Protected?: boolean;
+            };
+        };
+        TrustAllContent: boolean;
     };
-    Test: {
-      Enable: boolean;
-    };
-  };
-  Collections: {
-    ForwardSlashNameSubstitution: string;
-    ManagedProperties?: {
-      [key: string]: {
-        Function: string;
-        Value: string;
-        Protected?: boolean;
-      };
+    Volumes: {
+        [key: string]: {
+            StorageClasses: {
+                [key: string]: boolean;
+            };
+        };
     };
-    TrustAllContent: boolean;
-  };
-  Volumes: {
-    [key: string]: {
-      StorageClasses: {
-        [key: string]: boolean;
-      };
+    Users: {
+        AnonymousUserToken: string;
     };
-  };
 }
 
 export class Config {
-  baseUrl!: string;
-  keepWebServiceUrl!: string;
-  keepWebInlineServiceUrl!: string;
-  remoteHosts!: {
-    [key: string]: string;
-  };
-  rootUrl!: string;
-  uuidPrefix!: string;
-  websocketUrl!: string;
-  workbenchUrl!: string;
-  workbench2Url!: string;
-  vocabularyUrl!: string;
-  fileViewersConfigUrl!: string;
-  loginCluster!: string;
-  clusterConfig!: ClusterConfigJSON;
-  apiRevision!: number;
+    baseUrl!: string;
+    keepWebServiceUrl!: string;
+    keepWebInlineServiceUrl!: string;
+    remoteHosts!: {
+        [key: string]: string;
+    };
+    rootUrl!: string;
+    uuidPrefix!: string;
+    websocketUrl!: string;
+    workbenchUrl!: string;
+    workbench2Url!: string;
+    vocabularyUrl!: string;
+    fileViewersConfigUrl!: string;
+    loginCluster!: string;
+    clusterConfig!: ClusterConfigJSON;
+    apiRevision!: number;
 }
 
 export const buildConfig = (clusterConfig: ClusterConfigJSON): Config => {
-  const clusterConfigJSON = removeTrailingSlashes(clusterConfig);
-  const config = new Config();
-  config.rootUrl = clusterConfigJSON.Services.Controller.ExternalURL;
-  config.baseUrl = `${config.rootUrl}/${ARVADOS_API_PATH}`;
-  config.uuidPrefix = clusterConfigJSON.ClusterID;
-  config.websocketUrl = clusterConfigJSON.Services.Websocket.ExternalURL;
-  config.workbench2Url = clusterConfigJSON.Services.Workbench2.ExternalURL;
-  config.workbenchUrl = clusterConfigJSON.Services.Workbench1.ExternalURL;
-  config.keepWebServiceUrl =
-    clusterConfigJSON.Services.WebDAVDownload.ExternalURL;
-  config.keepWebInlineServiceUrl =
-    clusterConfigJSON.Services.WebDAV.ExternalURL;
-  config.loginCluster = clusterConfigJSON.Login.LoginCluster;
-  config.clusterConfig = clusterConfigJSON;
-  config.apiRevision = 0;
-  mapRemoteHosts(clusterConfigJSON, config);
-  return config;
+    const clusterConfigJSON = removeTrailingSlashes(clusterConfig);
+    const config = new Config();
+    config.rootUrl = clusterConfigJSON.Services.Controller.ExternalURL;
+    config.baseUrl = `${config.rootUrl}/${ARVADOS_API_PATH}`;
+    config.uuidPrefix = clusterConfigJSON.ClusterID;
+    config.websocketUrl = clusterConfigJSON.Services.Websocket.ExternalURL;
+    config.workbench2Url = clusterConfigJSON.Services.Workbench2.ExternalURL;
+    config.workbenchUrl = clusterConfigJSON.Services.Workbench1.ExternalURL;
+    config.keepWebServiceUrl =
+        clusterConfigJSON.Services.WebDAVDownload.ExternalURL;
+    config.keepWebInlineServiceUrl =
+        clusterConfigJSON.Services.WebDAV.ExternalURL;
+    config.loginCluster = clusterConfigJSON.Login.LoginCluster;
+    config.clusterConfig = clusterConfigJSON;
+    config.apiRevision = 0;
+    mapRemoteHosts(clusterConfigJSON, config);
+    return config;
 };
 
 export const getStorageClasses = (config: Config): string[] => {
-  const classes: Set<string> = new Set(['default']);
-  const volumes = config.clusterConfig.Volumes;
-  Object.keys(volumes).forEach((v) => {
-    Object.keys(volumes[v].StorageClasses || {}).forEach((sc) => {
-      if (volumes[v].StorageClasses[sc]) {
-        classes.add(sc);
-      }
+    const classes: Set<string> = new Set(['default']);
+    const volumes = config.clusterConfig.Volumes;
+    Object.keys(volumes).forEach((v) => {
+        Object.keys(volumes[v].StorageClasses || {}).forEach((sc) => {
+            if (volumes[v].StorageClasses[sc]) {
+                classes.add(sc);
+            }
+        });
     });
-  });
-  return Array.from(classes);
+    return Array.from(classes);
 };
 
 const getApiRevision = async (apiUrl: string) => {
-  try {
-    const dd = (await Axios.get<any>(`${apiUrl}/${DISCOVERY_DOC_PATH}`)).data;
-    return parseInt(dd.revision, 10) || 0;
-  } catch {
-    console.warn(
-      'Unable to get API Revision number, defaulting to zero. Some features may not work properly.'
-    );
-    return 0;
-  }
+    try {
+        const dd = (await Axios.get<any>(`${apiUrl}/${DISCOVERY_DOC_PATH}`)).data;
+        return parseInt(dd.revision, 10) || 0;
+    } catch {
+        console.warn(
+            'Unable to get API Revision number, defaulting to zero. Some features may not work properly.'
+        );
+        return 0;
+    }
 };
 
 const removeTrailingSlashes = (
-  config: ClusterConfigJSON
+    config: ClusterConfigJSON
 ): ClusterConfigJSON => {
-  const svcs: any = {};
-  Object.keys(config.Services).forEach((s) => {
-    svcs[s] = config.Services[s];
-    if (svcs[s].hasOwnProperty('ExternalURL')) {
-      svcs[s].ExternalURL = svcs[s].ExternalURL.replace(/\/+$/, '');
-    }
-  });
-  return { ...config, Services: svcs };
+    const svcs: any = {};
+    Object.keys(config.Services).forEach((s) => {
+        svcs[s] = config.Services[s];
+        if (svcs[s].hasOwnProperty('ExternalURL')) {
+            svcs[s].ExternalURL = svcs[s].ExternalURL.replace(/\/+$/, '');
+        }
+    });
+    return { ...config, Services: svcs };
 };
 
 export const fetchConfig = () => {
-  return Axios.get<WorkbenchConfig>(
-    WORKBENCH_CONFIG_URL + '?nocache=' + new Date().getTime()
-  )
-    .then((response) => response.data)
-    .catch(() => {
-      console.warn(
-        `There was an exception getting the Workbench config file at ${WORKBENCH_CONFIG_URL}. Using defaults instead.`
-      );
-      return Promise.resolve(getDefaultConfig());
-    })
-    .then((workbenchConfig) => {
-      if (workbenchConfig.API_HOST === undefined) {
-        throw new Error(
-          `Unable to start Workbench. API_HOST is undefined in ${WORKBENCH_CONFIG_URL} or the environment.`
-        );
-      }
-      return Axios.get<ClusterConfigJSON>(
-        getClusterConfigURL(workbenchConfig.API_HOST)
-      ).then(async (response) => {
-        const apiRevision = await getApiRevision(
-          response.data.Services.Controller.ExternalURL.replace(/\/+$/, '')
-        );
-        const config = { ...buildConfig(response.data), apiRevision };
-        const warnLocalConfig = (varName: string) =>
-          console.warn(
-            `A value for ${varName} was found in ${WORKBENCH_CONFIG_URL}. To use the Arvados centralized configuration instead, \
+    return Axios.get<WorkbenchConfig>(
+        WORKBENCH_CONFIG_URL + '?nocache=' + new Date().getTime()
+    )
+        .then((response) => response.data)
+        .catch(() => {
+            console.warn(
+                `There was an exception getting the Workbench config file at ${WORKBENCH_CONFIG_URL}. Using defaults instead.`
+            );
+            return Promise.resolve(getDefaultConfig());
+        })
+        .then((workbenchConfig) => {
+            if (workbenchConfig.API_HOST === undefined) {
+                throw new Error(
+                    `Unable to start Workbench. API_HOST is undefined in ${WORKBENCH_CONFIG_URL} or the environment.`
+                );
+            }
+            return Axios.get<ClusterConfigJSON>(
+                getClusterConfigURL(workbenchConfig.API_HOST)
+            ).then(async (response) => {
+                const apiRevision = await getApiRevision(
+                    response.data.Services.Controller.ExternalURL.replace(/\/+$/, '')
+                );
+                const config = { ...buildConfig(response.data), apiRevision };
+                const warnLocalConfig = (varName: string) =>
+                    console.warn(
+                        `A value for ${varName} was found in ${WORKBENCH_CONFIG_URL}. To use the Arvados centralized configuration instead, \
 remove the entire ${varName} entry from ${WORKBENCH_CONFIG_URL}`
-          );
+                    );
 
-        // Check if the workbench config has an entry for vocabulary and file viewer URLs
-        // If so, use these values (even if it is an empty string), but print a console warning.
-        // Otherwise, use the cluster config.
-        let fileViewerConfigUrl;
-        if (workbenchConfig.FILE_VIEWERS_CONFIG_URL !== undefined) {
-          warnLocalConfig('FILE_VIEWERS_CONFIG_URL');
-          fileViewerConfigUrl = workbenchConfig.FILE_VIEWERS_CONFIG_URL;
-        } else {
-          fileViewerConfigUrl =
-            config.clusterConfig.Workbench.FileViewersConfigURL ||
-            '/file-viewers-example.json';
-        }
-        config.fileViewersConfigUrl = fileViewerConfigUrl;
+                // Check if the workbench config has an entry for vocabulary and file viewer URLs
+                // If so, use these values (even if it is an empty string), but print a console warning.
+                // Otherwise, use the cluster config.
+                let fileViewerConfigUrl;
+                if (workbenchConfig.FILE_VIEWERS_CONFIG_URL !== undefined) {
+                    warnLocalConfig('FILE_VIEWERS_CONFIG_URL');
+                    fileViewerConfigUrl = workbenchConfig.FILE_VIEWERS_CONFIG_URL;
+                } else {
+                    fileViewerConfigUrl =
+                        config.clusterConfig.Workbench.FileViewersConfigURL ||
+                        '/file-viewers-example.json';
+                }
+                config.fileViewersConfigUrl = fileViewerConfigUrl;
 
-        if (workbenchConfig.VOCABULARY_URL !== undefined) {
-          console.warn(
-            `A value for VOCABULARY_URL was found in ${WORKBENCH_CONFIG_URL}. It will be ignored as the cluster already provides its own endpoint, you can safely remove it.`
-          );
-        }
-        config.vocabularyUrl = getVocabularyURL(workbenchConfig.API_HOST);
+                if (workbenchConfig.VOCABULARY_URL !== undefined) {
+                    console.warn(
+                        `A value for VOCABULARY_URL was found in ${WORKBENCH_CONFIG_URL}. It will be ignored as the cluster already provides its own endpoint, you can safely remove it.`
+                    );
+                }
+                config.vocabularyUrl = getVocabularyURL(workbenchConfig.API_HOST);
 
-        return { config, apiHost: workbenchConfig.API_HOST };
-      });
-    });
+                return { config, apiHost: workbenchConfig.API_HOST };
+            });
+        });
 };
 
 // Maps remote cluster hosts and removes the default RemoteCluster entry
 export const mapRemoteHosts = (
-  clusterConfigJSON: ClusterConfigJSON,
-  config: Config
+    clusterConfigJSON: ClusterConfigJSON,
+    config: Config
 ) => {
-  config.remoteHosts = {};
-  Object.keys(clusterConfigJSON.RemoteClusters).forEach((k) => {
-    config.remoteHosts[k] = clusterConfigJSON.RemoteClusters[k].Host;
-  });
-  delete config.remoteHosts['*'];
+    config.remoteHosts = {};
+    Object.keys(clusterConfigJSON.RemoteClusters).forEach((k) => {
+        config.remoteHosts[k] = clusterConfigJSON.RemoteClusters[k].Host;
+    });
+    delete config.remoteHosts['*'];
 };
 
 export const mockClusterConfigJSON = (
-  config: Partial<ClusterConfigJSON>
+    config: Partial<ClusterConfigJSON>
 ): ClusterConfigJSON => ({
-  API: {
-    UnfreezeProjectRequiresAdmin: false,
-    MaxItemsPerResponse: 1000,
-  },
-  ClusterID: '',
-  RemoteClusters: {},
-  Services: {
-    Controller: { ExternalURL: '' },
-    Workbench1: { ExternalURL: '' },
-    Workbench2: { ExternalURL: '' },
-    Websocket: { ExternalURL: '' },
-    WebDAV: { ExternalURL: '' },
-    WebDAVDownload: { ExternalURL: '' },
-    WebShell: { ExternalURL: '' },
-    Workbench: {
-      DisableSharingURLsUI: false,
-      ArvadosDocsite: "",
-      FileViewersConfigURL: "",
-      WelcomePageHTML: "",
-      InactivePageHTML: "",
-      SSHHelpPageHTML: "",
-      SSHHelpHostSuffix: "",
-      SiteName: "",
-      IdleTimeout: "0s"
-    },
-  },
-  Workbench: {
-    DisableSharingURLsUI: false,
-    ArvadosDocsite: '',
-    FileViewersConfigURL: '',
-    WelcomePageHTML: '',
-    InactivePageHTML: '',
-    SSHHelpPageHTML: '',
-    SSHHelpHostSuffix: '',
-    SiteName: '',
-    IdleTimeout: '0s',
-    BannerUUID: ""
-  },
-  Login: {
-    LoginCluster: '',
-    Google: {
-      Enable: false,
+    API: {
+        UnfreezeProjectRequiresAdmin: false,
+        MaxItemsPerResponse: 1000,
     },
-    LDAP: {
-      Enable: false,
+    ClusterID: '',
+    RemoteClusters: {},
+    Services: {
+        Controller: { ExternalURL: '' },
+        Workbench1: { ExternalURL: '' },
+        Workbench2: { ExternalURL: '' },
+        Websocket: { ExternalURL: '' },
+        WebDAV: { ExternalURL: '' },
+        WebDAVDownload: { ExternalURL: '' },
+        WebShell: { ExternalURL: '' },
+        Workbench: {
+            DisableSharingURLsUI: false,
+            ArvadosDocsite: "",
+            FileViewersConfigURL: "",
+            WelcomePageHTML: "",
+            InactivePageHTML: "",
+            SSHHelpPageHTML: "",
+            SSHHelpHostSuffix: "",
+            SiteName: "",
+            IdleTimeout: "0s"
+        },
     },
-    OpenIDConnect: {
-      Enable: false,
+    Workbench: {
+        DisableSharingURLsUI: false,
+        ArvadosDocsite: '',
+        FileViewersConfigURL: '',
+        WelcomePageHTML: '',
+        InactivePageHTML: '',
+        SSHHelpPageHTML: '',
+        SSHHelpHostSuffix: '',
+        SiteName: '',
+        IdleTimeout: '0s',
+        BannerUUID: "",
+        UserProfileFormFields: {},
+        UserProfileFormMessage: '',
     },
-    PAM: {
-      Enable: false,
+    Login: {
+        LoginCluster: '',
+        Google: {
+            Enable: false,
+        },
+        LDAP: {
+            Enable: false,
+        },
+        OpenIDConnect: {
+            Enable: false,
+        },
+        PAM: {
+            Enable: false,
+        },
+        SSO: {
+            Enable: false,
+        },
+        Test: {
+            Enable: false,
+        },
     },
-    SSO: {
-      Enable: false,
+    Collections: {
+        ForwardSlashNameSubstitution: '',
+        TrustAllContent: false,
     },
-    Test: {
-      Enable: false,
+    Volumes: {},
+    Users: {
+        AnonymousUserToken: ""
     },
-  },
-  Collections: {
-    ForwardSlashNameSubstitution: '',
-    TrustAllContent: false,
-  },
-  Volumes: {},
-  ...config,
+    ...config,
 });
 
 export const mockConfig = (config: Partial<Config>): Config => ({
-  baseUrl: '',
-  keepWebServiceUrl: '',
-  keepWebInlineServiceUrl: '',
-  remoteHosts: {},
-  rootUrl: '',
-  uuidPrefix: '',
-  websocketUrl: '',
-  workbenchUrl: '',
-  workbench2Url: '',
-  vocabularyUrl: '',
-  fileViewersConfigUrl: '',
-  loginCluster: '',
-  clusterConfig: mockClusterConfigJSON({}),
-  apiRevision: 0,
-  ...config,
+    baseUrl: '',
+    keepWebServiceUrl: '',
+    keepWebInlineServiceUrl: '',
+    remoteHosts: {},
+    rootUrl: '',
+    uuidPrefix: '',
+    websocketUrl: '',
+    workbenchUrl: '',
+    workbench2Url: '',
+    vocabularyUrl: '',
+    fileViewersConfigUrl: '',
+    loginCluster: '',
+    clusterConfig: mockClusterConfigJSON({}),
+    apiRevision: 0,
+    ...config,
 });
 
 const getDefaultConfig = (): WorkbenchConfig => {
-  let apiHost = '';
-  const envHost = process.env.REACT_APP_ARVADOS_API_HOST;
-  if (envHost !== undefined) {
-    console.warn(`Using default API host ${envHost}.`);
-    apiHost = envHost;
-  } else {
-    console.warn(
-      `No API host was found in the environment. Workbench may not be able to communicate with Arvados components.`
-    );
-  }
-  return {
-    API_HOST: apiHost,
-    VOCABULARY_URL: undefined,
-    FILE_VIEWERS_CONFIG_URL: undefined,
-  };
+    let apiHost = '';
+    const envHost = process.env.REACT_APP_ARVADOS_API_HOST;
+    if (envHost !== undefined) {
+        console.warn(`Using default API host ${envHost}.`);
+        apiHost = envHost;
+    } else {
+        console.warn(
+            `No API host was found in the environment. Workbench may not be able to communicate with Arvados components.`
+        );
+    }
+    return {
+        API_HOST: apiHost,
+        VOCABULARY_URL: undefined,
+        FILE_VIEWERS_CONFIG_URL: undefined,
+    };
 };
 
 export const ARVADOS_API_PATH = 'arvados/v1';
@@ -374,6 +384,6 @@ export const CLUSTER_CONFIG_PATH = 'arvados/v1/config';
 export const VOCABULARY_PATH = 'arvados/v1/vocabulary';
 export const DISCOVERY_DOC_PATH = 'discovery/v1/apis/arvados/v1/rest';
 export const getClusterConfigURL = (apiHost: string) =>
-  `https://${apiHost}/${CLUSTER_CONFIG_PATH}?nocache=${new Date().getTime()}`;
+    `https://${apiHost}/${CLUSTER_CONFIG_PATH}?nocache=${new Date().getTime()}`;
 export const getVocabularyURL = (apiHost: string) =>
-  `https://${apiHost}/${VOCABULARY_PATH}?nocache=${new Date().getTime()}`;
+    `https://${apiHost}/${VOCABULARY_PATH}?nocache=${new Date().getTime()}`;
diff --git a/src/common/html-sanitize.ts b/src/common/html-sanitize.ts
new file mode 100644 (file)
index 0000000..e7c66f1
--- /dev/null
@@ -0,0 +1,51 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import DOMPurify from 'dompurify';
+
+type TDomPurifyConfig = {
+    ALLOWED_TAGS: string[];
+    ALLOWED_ATTR: string[];
+};
+
+const domPurifyConfig: TDomPurifyConfig = {
+    ALLOWED_TAGS: [
+        'a',
+        'b',
+        'blockquote',
+        'br',
+        'code',
+        'del',
+        'dd',
+        'dl',
+        'dt',
+        'em',
+        'h1',
+        'h2',
+        'h3',
+        'h4',
+        'h5',
+        'h6',
+        'hr',
+        'i',
+        'img',
+        'kbd',
+        'li',
+        'ol',
+        'p',
+        'pre',
+        's',
+        'del',
+        'section',
+        'span',
+        'strong',
+        'sub',
+        'sup',
+        'ul',
+    ],
+    ALLOWED_ATTR: ['src', 'width', 'height', 'href', 'alt', 'title', 'style' ],
+};
+
+export const sanitizeHTML = (dirtyString: string): string => DOMPurify.sanitize(dirtyString, domPurifyConfig);
+
index 0168fd803f327f484be2f848e805d4df503352b3..adb52f4b0ce494167170c02390433a4ba82dec9f 100644 (file)
@@ -10,7 +10,7 @@ describe('redirect-to', () => {
         keepWebServiceUrl: 'http://localhost',
         keepWebServiceInlineUrl: 'http://localhost-inline'
     };
-    const redirectTo = '/test123';
+    const redirectTo = 'c=acbd18db4cc2f85cedef654fccc4a4d8%2B3/foo';
     const locationTemplate = {
         hash: '',
         hostname: '',
@@ -51,7 +51,7 @@ describe('redirect-to', () => {
             storeRedirects();
 
             // then
-            expect(window.localStorage.setItem).toHaveBeenCalledWith('redirectToDownload', redirectTo);
+            expect(window.localStorage.setItem).toHaveBeenCalledWith('redirectToDownload', decodeURIComponent(redirectTo));
         });
     });
 
index 73c9484323a76169dc43f60bd210f16dfcf2e679..e71ebde7f15e35d757a2e542c5c0419591fe8fd9 100644 (file)
@@ -39,7 +39,7 @@ export const storeRedirects = () => {
     const redirectStoreKey = redirectKey === REDIRECT_TO_KEY ? REDIRECT_TO_PREVIEW_KEY : redirectKey;
 
     if (localStorage && redirectKey && redirectStoreKey) {
-        localStorage.setItem(redirectStoreKey, href.split(`${redirectKey}=`)[1]);
+        localStorage.setItem(redirectStoreKey, decodeURIComponent(href.split(`${redirectKey}=`)[1]));
     }
 };
 
diff --git a/src/common/use-async-interval.test.tsx b/src/common/use-async-interval.test.tsx
new file mode 100644 (file)
index 0000000..188f184
--- /dev/null
@@ -0,0 +1,96 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { useAsyncInterval } from './use-async-interval';
+import { configure, mount } from 'enzyme';
+import Adapter from 'enzyme-adapter-react-16';
+import FakeTimers from "@sinonjs/fake-timers";
+
+configure({ adapter: new Adapter() });
+const clock = FakeTimers.install();
+
+jest.mock('react', () => {
+    const originalReact = jest.requireActual('react');
+    const mUseRef = jest.fn();
+    return {
+        ...originalReact,
+        useRef: mUseRef,
+    };
+});
+
+const TestComponent = (props): JSX.Element => {
+    useAsyncInterval(props.callback, 2000);
+    return <span />;
+};
+
+describe('useAsyncInterval', () => {
+    it('should fire repeatedly after the interval', async () => {
+        const mockedReact = React as jest.Mocked<typeof React>;
+        const ref = { current: {} };
+        mockedReact.useRef.mockReturnValue(ref);
+
+        const syncCallback = jest.fn();
+        const testComponent = mount(<TestComponent
+            callback={syncCallback}
+        />);
+
+        // cb queued with interval but not called
+        expect(syncCallback).not.toHaveBeenCalled();
+
+        // wait for first tick
+        await clock.tickAsync(2000);
+        expect(syncCallback).toHaveBeenCalledTimes(1);
+
+        // wait for second tick
+        await clock.tickAsync(2000);
+        expect(syncCallback).toHaveBeenCalledTimes(2);
+
+        // wait for third tick
+        await clock.tickAsync(2000);
+        expect(syncCallback).toHaveBeenCalledTimes(3);
+    });
+
+    it('should wait for async callbacks to complete in between polling', async () => {
+        const mockedReact = React as jest.Mocked<typeof React>;
+        const ref = { current: {} };
+        mockedReact.useRef.mockReturnValue(ref);
+
+        const delayedCallback = jest.fn(() => (
+            new Promise<void>((resolve) => {
+                setTimeout(() => {
+                    resolve();
+                }, 2000);
+            })
+        ));
+        const testComponent = mount(<TestComponent
+            callback={delayedCallback}
+        />);
+
+        // cb queued with setInterval but not called
+        expect(delayedCallback).not.toHaveBeenCalled();
+
+        // Wait 2 seconds for first tick
+        await clock.tickAsync(2000);
+        // First cb called after 2 seconds
+        expect(delayedCallback).toHaveBeenCalledTimes(1);
+        // Wait for cb to resolve for 2 seconds
+        await clock.tickAsync(2000);
+        expect(delayedCallback).toHaveBeenCalledTimes(1);
+
+        // Wait 2 seconds for second tick
+        await clock.tickAsync(2000);
+        expect(delayedCallback).toHaveBeenCalledTimes(2);
+        // Wait for cb to resolve for 2 seconds
+        await clock.tickAsync(2000);
+        expect(delayedCallback).toHaveBeenCalledTimes(2);
+
+        // Wait 2 seconds for third tick
+        await clock.tickAsync(2000);
+        expect(delayedCallback).toHaveBeenCalledTimes(3);
+        // Wait for cb to resolve for 2 seconds
+        await clock.tickAsync(2000);
+        expect(delayedCallback).toHaveBeenCalledTimes(3);
+    });
+});
diff --git a/src/common/use-async-interval.ts b/src/common/use-async-interval.ts
new file mode 100644 (file)
index 0000000..3be7309
--- /dev/null
@@ -0,0 +1,45 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+
+export const useAsyncInterval = function (callback, delay) {
+    const ref = React.useRef<{cb: () => Promise<any>, active: boolean}>({
+        cb: async () => {},
+        active: false}
+    );
+
+    // Remember the latest callback.
+    React.useEffect(() => {
+        ref.current.cb = callback;
+    }, [callback]);
+    // Set up the interval.
+    React.useEffect(() => {
+        function tick() {
+            if (ref.current.active) {
+                // Wrap execution chain with promise so that execution errors or
+                //   non-async callbacks still fall through to .finally, avoids breaking polling
+                new Promise((resolve) => {
+                    return resolve(ref.current.cb());
+                }).then(() => {
+                    // Promise succeeded
+                    // Possibly implement back-off reset
+                }).catch(() => {
+                    // Promise rejected
+                    // Possibly implement back-off in the future
+                }).finally(() => {
+                    setTimeout(tick, delay);
+                });
+            }
+        }
+        if (delay !== null) {
+            ref.current.active = true;
+            setTimeout(tick, delay);
+        }
+        // Suppress warning about cleanup function - can be ignored when variables are unrelated to dom elements
+        //   https://github.com/facebook/react/issues/15841#issuecomment-500133759
+        // eslint-disable-next-line
+        return () => {ref.current.active = false;};
+    }, [delay]);
+};
index b5c634c3d1875016d0a3d14ad7f2fada71aaf6da..17d85e856c3cb53901f468b31ccd5bf4e93c6d40 100644 (file)
@@ -175,17 +175,17 @@ export class Autocomplete<Value, Suggestion> extends React.Component<Autocomplet
             (item, index) => {
                 const tooltip = this.props.renderChipTooltip ? this.props.renderChipTooltip(item) : '';
                 if (tooltip && tooltip.length) {
-                    return <Tooltip title={tooltip}>
+                    return <span key={index}>
+                        <Tooltip title={tooltip}>
                         <Chip
                             label={this.renderChipValue(item)}
                             key={index}
                             onDelete={onDelete && !this.props.disabled ? (() =>  onDelete(item, index)) : undefined} />
-                    </Tooltip>
+                    </Tooltip></span>
                 } else {
-                    return <Chip
+                    return <span key={index}><Chip
                         label={this.renderChipValue(item)}
-                        key={index}
-                        onDelete={onDelete && !this.props.disabled ? (() =>  onDelete(item, index)) : undefined} />
+                        onDelete={onDelete && !this.props.disabled ? (() =>  onDelete(item, index)) : undefined} /></span>
                 }
             }
         );
index 08944d40a93519b59770ea52df546f2720d68ad8..f1e50e0f0bf7d2f9c4699265e19d515954a9d7cc 100644 (file)
@@ -2,14 +2,14 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-import classNames from 'classnames';
-import { connect } from 'react-redux';
+import React from "react";
+import classNames from "classnames";
+import { connect } from "react-redux";
 import { FixedSizeList } from "react-window";
 import AutoSizer from "react-virtualized-auto-sizer";
-import servicesProvider from 'common/service-provider';
-import { CustomizeTableIcon, DownloadIcon, MoreOptionsIcon } from 'components/icon/icon';
-import { SearchInput } from 'components/search-input/search-input';
+import servicesProvider from "common/service-provider";
+import { DownloadIcon, MoreHorizontalIcon, MoreVerticalIcon } from "components/icon/icon";
+import { SearchInput } from "components/search-input/search-input";
 import {
     ListItemIcon,
     StyleRulesCallback,
@@ -21,24 +21,19 @@ import {
     Checkbox,
     CircularProgress,
     Button,
-} from '@material-ui/core';
-import { FileTreeData } from '../file-tree/file-tree-data';
-import { TreeItem, TreeItemStatus } from '../tree/tree';
-import { RootState } from 'store/store';
-import { WebDAV, WebDAVRequestConfig } from 'common/webdav';
-import { AuthState } from 'store/auth/auth-reducer';
-import { extractFilesData } from 'services/collection-service/collection-service-files-response';
-import {
-    DefaultIcon,
-    DirectoryIcon,
-    FileIcon,
-    BackIcon,
-    SidePanelRightArrowIcon
-} from 'components/icon/icon';
-import { setCollectionFiles } from 'store/collection-panel/collection-panel-files/collection-panel-files-actions';
-import { sortBy } from 'lodash';
-import { formatFileSize } from 'common/formatters';
-import { getInlineFileUrl, sanitizeToken } from 'views-components/context-menu/actions/helpers';
+} from "@material-ui/core";
+import { FileTreeData } from "../file-tree/file-tree-data";
+import { TreeItem, TreeItemStatus } from "../tree/tree";
+import { RootState } from "store/store";
+import { WebDAV, WebDAVRequestConfig } from "common/webdav";
+import { AuthState } from "store/auth/auth-reducer";
+import { extractFilesData } from "services/collection-service/collection-service-files-response";
+import { DefaultIcon, DirectoryIcon, FileIcon, BackIcon, SidePanelRightArrowIcon } from "components/icon/icon";
+import { setCollectionFiles } from "store/collection-panel/collection-panel-files/collection-panel-files-actions";
+import { sortBy } from "lodash";
+import { formatFileSize } from "common/formatters";
+import { getInlineFileUrl, sanitizeToken } from "views-components/context-menu/actions/helpers";
+import { extractUuidKind, ResourceKind } from "models/resource";
 
 export interface CollectionPanelFilesProps {
     isWritable: boolean;
@@ -55,7 +50,8 @@ export interface CollectionPanelFilesProps {
     collectionPanel: any;
 }
 
-type CssRules = "backButton"
+type CssRules =
+    | "backButton"
     | "backButtonHidden"
     | "pathPanelPathWrapper"
     | "uploadButton"
@@ -83,514 +79,630 @@ type CssRules = "backButton"
 
 const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
     wrapper: {
-        display: 'flex',
-        minHeight: '600px',
-        color: 'rgba(0,0,0,0.87)',
-        fontSize: '0.875rem',
+        display: "flex",
+        minHeight: "600px",
+        color: "rgba(0,0,0,0.87)",
+        fontSize: "0.875rem",
         fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
         fontWeight: 400,
-        lineHeight: '1.5',
-        letterSpacing: '0.01071em'
+        lineHeight: "1.5",
+        letterSpacing: "0.01071em",
     },
     backButton: {
-        color: '#00bfa5',
-        cursor: 'pointer',
-        float: 'left',
+        color: "#00bfa5",
+        cursor: "pointer",
+        float: "left",
     },
     backButtonHidden: {
-        display: 'none',
+        display: "none",
     },
     dataWrapper: {
-        minHeight: '500px'
+        minHeight: "500px",
     },
     row: {
-        display: 'flex',
-        marginTop: '0.5rem',
-        marginBottom: '0.5rem',
-        cursor: 'pointer',
+        display: "flex",
+        marginTop: "0.5rem",
+        marginBottom: "0.5rem",
+        cursor: "pointer",
         "&:hover": {
-            backgroundColor: 'rgba(0, 0, 0, 0.08)',
-        }
+            backgroundColor: "rgba(0, 0, 0, 0.08)",
+        },
     },
     rowEmpty: {
-        top: '40%',
-        width: '100%',
-        textAlign: 'center',
-        position: 'absolute'
+        top: "40%",
+        width: "100%",
+        textAlign: "center",
+        position: "absolute",
     },
     loader: {
-        top: '50%',
-        left: '50%',
-        marginTop: '-15px',
-        marginLeft: '-15px',
-        position: 'absolute'
+        top: "50%",
+        left: "50%",
+        marginTop: "-15px",
+        marginLeft: "-15px",
+        position: "absolute",
     },
     rowName: {
-        display: 'inline-flex',
-        flexDirection: 'column',
-        justifyContent: 'center'
+        display: "inline-flex",
+        flexDirection: "column",
+        justifyContent: "center",
     },
     searchWrapper: {
-        display: 'inline-block',
-        marginBottom: '1rem',
-        marginLeft: '1rem',
+        display: "inline-block",
+        marginBottom: "1rem",
+        marginLeft: "1rem",
     },
     searchWrapperHidden: {
-        width: '0px'
+        width: "0px",
     },
     rowSelection: {
-        padding: '0px',
+        padding: "0px",
     },
     rowActive: {
         color: `${theme.palette.primary.main} !important`,
     },
     listItemIcon: {
-        display: 'inline-flex',
-        flexDirection: 'column',
-        justifyContent: 'center'
+        display: "inline-flex",
+        flexDirection: "column",
+        justifyContent: "center",
     },
     pathPanelMenu: {
-        float: 'right',
-        marginTop: '-15px',
+        float: "right",
+        marginTop: "-15px",
     },
     pathPanel: {
-        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%)',
+        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%)",
     },
     pathPanelPathWrapper: {
-        display: 'inline-block',
+        display: "inline-block",
     },
     leftPanel: {
         flex: 0,
-        padding: '0 1rem 1rem',
-        marginRight: '1rem',
-        whiteSpace: 'nowrap',
-        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%)',
+        padding: "0 1rem 1rem",
+        marginRight: "1rem",
+        whiteSpace: "nowrap",
+        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%)",
     },
     leftPanelVisible: {
         opacity: 1,
-        flex: '50%',
-        animation: `animateVisible 1000ms ${theme.transitions.easing.easeOut}`
+        flex: "50%",
+        animation: `animateVisible 1000ms ${theme.transitions.easing.easeOut}`,
     },
     leftPanelHidden: {
         opacity: 0,
-        flex: 'initial',
-        padding: '0',
-        marginRight: '0',
+        flex: "initial",
+        padding: "0",
+        marginRight: "0",
     },
     "@keyframes animateVisible": {
         "0%": {
             opacity: 0,
-            flex: 'initial',
+            flex: "initial",
         },
         "100%": {
             opacity: 1,
-            flex: '50%',
-        }
+            flex: "50%",
+        },
     },
     rightPanel: {
-        flex: '50%',
-        padding: '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%)',
+        flex: "50%",
+        padding: "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%)",
     },
     pathPanelItem: {
-        cursor: 'pointer',
+        cursor: "pointer",
     },
     uploadIcon: {
-        transform: 'rotate(180deg)'
+        transform: "rotate(180deg)",
     },
     uploadButton: {
-        float: 'right',
+        float: "right",
     },
     moreOptionsButton: {
         width: theme.spacing.unit * 3,
         height: theme.spacing.unit * 3,
         marginRight: theme.spacing.unit,
-        marginTop: 'auto',
-        marginBottom: 'auto',
-        justifyContent: 'center',
+        marginTop: "auto",
+        marginBottom: "auto",
+        justifyContent: "center",
     },
     moreOptions: {
-        position: 'absolute'
+        position: "absolute",
     },
 });
 
 const pathPromise = {};
 
-export const CollectionPanelFiles = withStyles(styles)(connect((state: RootState) => ({
-    auth: state.auth,
-    collectionPanel: state.collectionPanel,
-    collectionPanelFiles: state.collectionPanelFiles,
-}))((props: CollectionPanelFilesProps & WithStyles<CssRules> & { auth: AuthState }) => {
-    const { classes, onItemMenuOpen, onUploadDataClick, isWritable, dispatch, collectionPanelFiles, collectionPanel } = props;
-    const { apiToken, config } = props.auth;
-
-    const webdavClient = new WebDAV({
-        baseURL: config.keepWebServiceUrl,
-        headers: {
-            Authorization: `Bearer ${apiToken}`
-        },
-    });
-
-    const webDAVRequestConfig: WebDAVRequestConfig = {
-        headers: {
-            Depth: '1',
-        },
-    };
-
-    const parentRef = React.useRef(null);
-    const [path, setPath] = React.useState<string[]>([]);
-    const [pathData, setPathData] = React.useState({});
-    const [isLoading, setIsLoading] = React.useState(false);
-    const [leftSearch, setLeftSearch] = React.useState('');
-    const [rightSearch, setRightSearch] = React.useState('');
-
-    const leftKey = (path.length > 1 ? path.slice(0, path.length - 1) : path).join('/');
-    const rightKey = path.join('/');
-
-    const leftData = pathData[leftKey] || [];
-    const rightData = pathData[rightKey];
-
-    React.useEffect(() => {
-        if (props.currentItemUuid) {
-            setPathData({});
-            setPath([props.currentItemUuid]);
-        }
-    }, [props.currentItemUuid]);
-
-    const fetchData = (keys, ignoreCache = false) => {
-        const keyArray = Array.isArray(keys) ? keys : [keys];
-
-        Promise.all(keyArray.filter(key => !!key)
-            .map((key) => {
-                const dataExists = !!pathData[key];
-                const runningRequest = pathPromise[key];
-
-                if (ignoreCache || (!dataExists && !runningRequest)) {
-                    if (!isLoading) {
-                        setIsLoading(true);
-                    }
+export const CollectionPanelFiles = withStyles(styles)(
+    connect((state: RootState) => ({
+        auth: state.auth,
+        collectionPanel: state.collectionPanel,
+        collectionPanelFiles: state.collectionPanelFiles,
+    }))((props: CollectionPanelFilesProps & WithStyles<CssRules> & { auth: AuthState }) => {
+        const { classes, onItemMenuOpen, onUploadDataClick, isWritable, dispatch, collectionPanelFiles, collectionPanel } = props;
+        const { apiToken, config } = props.auth;
+
+        const webdavClient = new WebDAV({
+            baseURL: config.keepWebServiceUrl,
+            headers: {
+                Authorization: `Bearer ${apiToken}`,
+            },
+        });
 
-                    pathPromise[key] = true;
+        const webDAVRequestConfig: WebDAVRequestConfig = {
+            headers: {
+                Depth: "1",
+            },
+        };
 
-                    return webdavClient.propfind(`c=${key}`, webDAVRequestConfig);
-                }
+        const parentRef = React.useRef(null);
+        const [path, setPath] = React.useState<string[]>([]);
+        const [pathData, setPathData] = React.useState({});
+        const [isLoading, setIsLoading] = React.useState(false);
+        const [leftSearch, setLeftSearch] = React.useState("");
+        const [rightSearch, setRightSearch] = React.useState("");
 
-                return Promise.resolve(null);
-            })
-            .filter((promise) => !!promise)
-        )
-        .then((requests) => {
-            const newState = requests.map((request, index) => {
-                if (request && request.responseXML != null) {
-                    const key = keyArray[index];
-                    const result: any = extractFilesData(request.responseXML);
-                    const sortedResult = sortBy(result, (n) => n.name).sort((n1, n2) => {
-                        if (n1.type === 'directory' && n2.type !== 'directory') {
-                            return -1;
-                        }
-                        if (n1.type !== 'directory' && n2.type === 'directory') {
-                            return 1;
-                        }
-                        return 0;
-                    });
+        const leftKey = (path.length > 1 ? path.slice(0, path.length - 1) : path).join("/");
+        const rightKey = path.join("/");
 
-                    return { [key]: sortedResult };
-                }
-                return {};
-            }).reduce((prev, next) => {
-                return { ...next, ...prev };
-            }, {});
-
-            setPathData({ ...pathData, ...newState });
-        })
-        .finally(() => {
-            setIsLoading(false);
-            keyArray.forEach(key => delete pathPromise[key]);
-        });
-    };
-
-    React.useEffect(() => {
-        if (rightKey) {
-            fetchData(rightKey);
-            setLeftSearch('');
-            setRightSearch('');
-        }
-    }, [rightKey]); // eslint-disable-line react-hooks/exhaustive-deps
-
-    const currentPDH = (collectionPanel.item || {}).portableDataHash;
-    React.useEffect(() => {
-        if (currentPDH) {
-            fetchData([leftKey, rightKey], true);
-        }
-    }, [currentPDH]); // eslint-disable-line react-hooks/exhaustive-deps
-
-    React.useEffect(() => {
-        if (rightData) {
-            const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1);
-            setCollectionFiles(filtered, false)(dispatch);
-        }
-    }, [rightData, dispatch, rightSearch]);
-
-    const handleRightClick = React.useCallback(
-        (event) => {
-            event.preventDefault();
-            let elem = event.target;
-
-            while (elem && elem.dataset && !elem.dataset.item) {
-                elem = elem.parentNode;
-            }
+        const leftData = pathData[leftKey] || [];
+        const rightData = pathData[rightKey];
 
-            if (!elem || !elem.dataset) {
-                return;
+        React.useEffect(() => {
+            if (props.currentItemUuid && extractUuidKind(props.currentItemUuid) === ResourceKind.COLLECTION) {
+                setPathData({});
+                setPath([props.currentItemUuid]);
             }
+        }, [props.currentItemUuid]);
 
-            const { id } = elem.dataset;
+        const fetchData = (keys, ignoreCache = false) => {
+            const keyArray = Array.isArray(keys) ? keys : [keys];
 
-            const item: any = {
-                id,
-                data: rightData.find((elem) => elem.id === id),
-            };
+            Promise.all(
+                keyArray
+                    .filter(key => !!key)
+                    .map(key => {
+                        const dataExists = !!pathData[key];
+                        const runningRequest = pathPromise[key];
 
-            if (id) {
-                onItemMenuOpen(event, item, isWritable);
-            }
-        },
-        [onItemMenuOpen, isWritable, rightData]);
+                        if (ignoreCache || (!dataExists && !runningRequest)) {
+                            if (!isLoading) {
+                                setIsLoading(true);
+                            }
 
-    React.useEffect(() => {
-        let node = null;
+                            pathPromise[key] = true;
 
-        if (parentRef?.current) {
-            node = parentRef.current;
-            (node as any).addEventListener('contextmenu', handleRightClick);
-        }
+                            return webdavClient.propfind(`c=${key}`, webDAVRequestConfig);
+                        }
 
-        return () => {
-            if (node) {
-                (node as any).removeEventListener('contextmenu', handleRightClick);
-            }
+                        return Promise.resolve(null);
+                    })
+                    .filter(promise => !!promise)
+            )
+                .then(requests => {
+                    const newState = requests
+                        .map((request, index) => {
+                            if (request && request.responseXML != null) {
+                                const key = keyArray[index];
+                                const result: any = extractFilesData(request.responseXML);
+                                const sortedResult = sortBy(result, n => n.name).sort((n1, n2) => {
+                                    if (n1.type === "directory" && n2.type !== "directory") {
+                                        return -1;
+                                    }
+                                    if (n1.type !== "directory" && n2.type === "directory") {
+                                        return 1;
+                                    }
+                                    return 0;
+                                });
+
+                                return { [key]: sortedResult };
+                            }
+                            return {};
+                        })
+                        .reduce((prev, next) => {
+                            return { ...next, ...prev };
+                        }, {});
+                    setPathData(state => ({ ...state, ...newState }));
+                }, () => {
+                    // Nothing to do
+                })
+                .finally(() => {
+                    setIsLoading(false);
+                    keyArray.forEach(key => delete pathPromise[key]);
+                });
         };
-    }, [parentRef, handleRightClick]);
 
-    const handleClick = React.useCallback(
-        (event: any) => {
-            let isCheckbox = false;
-            let isMoreButton = false;
-            let elem = event.target;
-
-            if (elem.type === 'checkbox') {
-                isCheckbox = true;
-            }
-            // The "More options" button click event could be triggered on its
-            // internal graphic element.
-            else if ((elem.dataset && elem.dataset.id === 'moreOptions') || (elem.parentNode && elem.parentNode.dataset && elem.parentNode.dataset.id === 'moreOptions')) {
-                isMoreButton = true;
+        React.useEffect(() => {
+            if (rightKey) {
+                fetchData(rightKey);
+                setLeftSearch("");
+                setRightSearch("");
             }
+        }, [rightKey, rightData]); // eslint-disable-line react-hooks/exhaustive-deps
 
-            while (elem && elem.dataset && !elem.dataset.item) {
-                elem = elem.parentNode;
+        const currentPDH = (collectionPanel.item || {}).portableDataHash;
+        React.useEffect(() => {
+            if (currentPDH) {
+                fetchData([leftKey, rightKey], true);
             }
+        }, [currentPDH]); // eslint-disable-line react-hooks/exhaustive-deps
 
-            if (elem && elem.dataset && !isCheckbox && !isMoreButton) {
-                const { parentPath, subfolderPath, breadcrumbPath, type } = elem.dataset;
-
-                if (breadcrumbPath) {
-                    const index = path.indexOf(breadcrumbPath);
-                    setPath([...path.slice(0, index + 1)]);
-                }
-
-                if (parentPath && type === 'directory') {
-                    if (path.length > 1) {
-                        path.pop()
-                    }
+        React.useEffect(() => {
+            if (rightData) {
+                const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1);
+                setCollectionFiles(filtered, false)(dispatch);
+            }
+        }, [rightData, dispatch, rightSearch]);
 
-                    setPath([...path, parentPath]);
-                }
+        const handleRightClick = React.useCallback(
+            event => {
+                event.preventDefault();
+                let elem = event.target;
 
-                if (subfolderPath && type === 'directory') {
-                    setPath([...path, subfolderPath]);
+                while (elem && elem.dataset && !elem.dataset.item) {
+                    elem = elem.parentNode;
                 }
 
-                if (elem.dataset.id && type === 'file') {
-                    const item = rightData.find(({id}) => id === elem.dataset.id) || leftData.find(({ id }) => id === elem.dataset.id);
-                    const enhancedItem = servicesProvider.getServices().collectionService.extendFileURL(item);
-                    const fileUrl = sanitizeToken(getInlineFileUrl(enhancedItem.url, config.keepWebServiceUrl, config.keepWebInlineServiceUrl), true);
-                    window.open(fileUrl, '_blank');
+                if (!elem || !elem.dataset) {
+                    return;
                 }
-            }
 
-            if (isCheckbox) {
-                const { id } = elem.dataset;
-                const item = collectionPanelFiles[id];
-                props.onSelectionToggle(event, item);
-            }
-            if (isMoreButton) {
                 const { id } = elem.dataset;
+
                 const item: any = {
                     id,
-                    data: rightData.find((elem) => elem.id === id),
+                    data: rightData.find(elem => elem.id === id),
                 };
-                onItemMenuOpen(event, item, isWritable);
-            }
-        },
-        [path, setPath, collectionPanelFiles] // eslint-disable-line react-hooks/exhaustive-deps
-    );
-
-    const getItemIcon = React.useCallback(
-        (type: string, activeClass: string | null) => {
-            let Icon = DefaultIcon;
-
-            switch (type) {
-                case 'directory':
-                    Icon = DirectoryIcon;
-                    break;
-                case 'file':
-                    Icon = FileIcon;
-                    break;
-            }
 
-            return (
-                <ListItemIcon className={classNames(classes.listItemIcon, activeClass)}>
-                    <Icon />
-                </ListItemIcon>
-            )
-        },
-        [classes]
-    );
+                if (id) {
+                    onItemMenuOpen(event, item, isWritable);
+                }
+            },
+            [onItemMenuOpen, isWritable, rightData]
+        );
 
-    const getActiveClass = React.useCallback(
-        (name) => {
-            return path[path.length - 1] === name ? classes.rowActive : null;
-        },
-        [path, classes]
-    );
+        React.useEffect(() => {
+            let node = null;
 
-    const onOptionsMenuOpen = React.useCallback(
-        (ev, isWritable) => {
-            props.onOptionsMenuOpen(ev, isWritable);
-        },
-        [props.onOptionsMenuOpen] // eslint-disable-line react-hooks/exhaustive-deps
-    );
-
-    return <div data-cy="collection-files-panel" onClick={handleClick} ref={parentRef}>
-        <div className={classes.pathPanel}>
-            <div className={classes.pathPanelPathWrapper}>
-            { path.map( (p: string, index: number) =>
-                <span key={`${index}-${p}`} data-item="true"
-                className={classes.pathPanelItem} data-breadcrumb-path={p}>
-                    <span className={classes.rowActive}>{index === 0 ? 'Home' : p}</span> <b>/</b>&nbsp;
-                </span>)
+            if (parentRef?.current) {
+                node = parentRef.current;
+                (node as any).addEventListener("contextmenu", handleRightClick);
             }
-            </div>
-            <Tooltip className={classes.pathPanelMenu} title="More options" disableFocusListener>
-                <IconButton data-cy='collection-files-panel-options-btn'
-                    onClick={(ev) => {
-                        onOptionsMenuOpen(ev, isWritable);
-                    }}>
-                    <CustomizeTableIcon />
-                </IconButton>
-            </Tooltip>
-        </div>
-        <div className={classes.wrapper}>
-            <div className={classNames(classes.leftPanel, path.length > 1 ? classes.leftPanelVisible : classes.leftPanelHidden)} data-cy="collection-files-left-panel">
-                <Tooltip title="Go back" className={path.length > 1 ? classes.backButton : classes.backButtonHidden}>
-                    <IconButton onClick={() => setPath([...path.slice(0, path.length -1)])}>
-                        <BackIcon />
-                    </IconButton>
-                </Tooltip>
-                <div className={path.length > 1 ? classes.searchWrapper : classes.searchWrapperHidden}>
-                    <SearchInput selfClearProp={leftKey} label="Search" value={leftSearch} onSearch={setLeftSearch} />
+
+            return () => {
+                if (node) {
+                    (node as any).removeEventListener("contextmenu", handleRightClick);
+                }
+            };
+        }, [parentRef, handleRightClick]);
+
+        const handleClick = React.useCallback(
+            (event: any) => {
+                let isCheckbox = false;
+                let isMoreButton = false;
+                let elem = event.target;
+
+                if (elem.type === "checkbox") {
+                    isCheckbox = true;
+                }
+                // The "More options" button click event could be triggered on its
+                // internal graphic element.
+                else if (
+                    (elem.dataset && elem.dataset.id === "moreOptions") ||
+                    (elem.parentNode && elem.parentNode.dataset && elem.parentNode.dataset.id === "moreOptions")
+                ) {
+                    isMoreButton = true;
+                }
+
+                while (elem && elem.dataset && !elem.dataset.item) {
+                    elem = elem.parentNode;
+                }
+
+                if (elem && elem.dataset && !isCheckbox && !isMoreButton) {
+                    const { parentPath, subfolderPath, breadcrumbPath, type } = elem.dataset;
+
+                    if (breadcrumbPath) {
+                        const index = path.indexOf(breadcrumbPath);
+                        setPath(state => [...state.slice(0, index + 1)]);
+                    }
+
+                    if (parentPath && type === "directory") {
+                        if (path.length > 1) {
+                            path.pop();
+                        }
+
+                        setPath(state => [...state, parentPath]);
+                    }
+
+                    if (subfolderPath && type === "directory") {
+                        setPath(state => [...state, subfolderPath]);
+                    }
+
+                    if (elem.dataset.id && type === "file") {
+                        const item = rightData.find(({ id }) => id === elem.dataset.id) || leftData.find(({ id }) => id === elem.dataset.id);
+                        const enhancedItem = servicesProvider.getServices().collectionService.extendFileURL(item);
+                        const fileUrl = sanitizeToken(
+                            getInlineFileUrl(enhancedItem.url, config.keepWebServiceUrl, config.keepWebInlineServiceUrl),
+                            true
+                        );
+                        window.open(fileUrl, "_blank");
+                    }
+                }
+
+                if (isCheckbox) {
+                    const { id } = elem.dataset;
+                    const item = collectionPanelFiles[id];
+                    props.onSelectionToggle(event, item);
+                }
+                if (isMoreButton) {
+                    const { id } = elem.dataset;
+                    const item: any = {
+                        id,
+                        data: rightData.find(elem => elem.id === id),
+                    };
+                    onItemMenuOpen(event, item, isWritable);
+                }
+            },
+            [path, setPath, collectionPanelFiles] // eslint-disable-line react-hooks/exhaustive-deps
+        );
+
+        const getItemIcon = React.useCallback(
+            (type: string, activeClass: string | null) => {
+                let Icon = DefaultIcon;
+
+                switch (type) {
+                    case "directory":
+                        Icon = DirectoryIcon;
+                        break;
+                    case "file":
+                        Icon = FileIcon;
+                        break;
+                }
+
+                return (
+                    <ListItemIcon className={classNames(classes.listItemIcon, activeClass)}>
+                        <Icon />
+                    </ListItemIcon>
+                );
+            },
+            [classes]
+        );
+
+        const getActiveClass = React.useCallback(
+            name => {
+                return path[path.length - 1] === name ? classes.rowActive : null;
+            },
+            [path, classes]
+        );
+
+        const onOptionsMenuOpen = React.useCallback(
+            (ev, isWritable) => {
+                props.onOptionsMenuOpen(ev, isWritable);
+            },
+            [props.onOptionsMenuOpen] // eslint-disable-line react-hooks/exhaustive-deps
+        );
+
+        return (
+            <div
+                data-cy="collection-files-panel"
+                onClick={handleClick}
+                ref={parentRef}
+            >
+                <div className={classes.pathPanel}>
+                    <div className={classes.pathPanelPathWrapper}>
+                        {path.map((p: string, index: number) => (
+                            <span
+                                key={`${index}-${p}`}
+                                data-item="true"
+                                className={classes.pathPanelItem}
+                                data-breadcrumb-path={p}
+                            >
+                                <span className={classes.rowActive}>{index === 0 ? "Home" : p}</span> <b>/</b>&nbsp;
+                            </span>
+                        ))}
+                    </div>
+                    <Tooltip
+                        className={classes.pathPanelMenu}
+                        title="More options"
+                        disableFocusListener
+                    >
+                        <IconButton
+                            data-cy="collection-files-panel-options-btn"
+                            onClick={ev => {
+                                onOptionsMenuOpen(ev, isWritable);
+                            }}
+                        >
+                            <MoreVerticalIcon />
+                        </IconButton>
+                    </Tooltip>
                 </div>
-                <div className={classes.dataWrapper}>{ leftData
-                ? <AutoSizer defaultWidth={0}>{({ height, width }) => {
-                    const filtered = leftData.filter(({ name }) => name.indexOf(leftSearch) > -1);
-                    return !!filtered.length
-                    ? <FixedSizeList height={height} itemCount={filtered.length}
-                        itemSize={35} width={width}>{ ({ index, style }) => {
-                        const { id, type, name } = filtered[index];
-                        return <div data-id={id} style={style} data-item="true"
-                            data-type={type} data-parent-path={name}
-                            className={classNames(classes.row, getActiveClass(name))}
-                            key={id}>
-                                { getItemIcon(type, getActiveClass(name)) }
-                                <div className={classes.rowName}>
-                                    {name}
+                <div className={classes.wrapper}>
+                    <div
+                        className={classNames(classes.leftPanel, path.length > 1 ? classes.leftPanelVisible : classes.leftPanelHidden)}
+                        data-cy="collection-files-left-panel"
+                    >
+                        <Tooltip
+                            title="Go back"
+                            className={path.length > 1 ? classes.backButton : classes.backButtonHidden}
+                        >
+                            <IconButton onClick={() => setPath(state => [...state.slice(0, state.length - 1)])}>
+                                <BackIcon />
+                            </IconButton>
+                        </Tooltip>
+                        <div className={path.length > 1 ? classes.searchWrapper : classes.searchWrapperHidden}>
+                            <SearchInput
+                                selfClearProp={leftKey}
+                                label="Search"
+                                value={leftSearch}
+                                onSearch={setLeftSearch}
+                            />
+                        </div>
+                        <div className={classes.dataWrapper}>
+                            {leftData ? (
+                                <AutoSizer defaultWidth={0}>
+                                    {({ height, width }) => {
+                                        const filtered = leftData.filter(({ name }) => name.indexOf(leftSearch) > -1);
+                                        return !!filtered.length ? (
+                                            <FixedSizeList
+                                                height={height}
+                                                itemCount={filtered.length}
+                                                itemSize={35}
+                                                width={width}
+                                            >
+                                                {({ index, style }) => {
+                                                    const { id, type, name } = filtered[index];
+                                                    return (
+                                                        <div
+                                                            data-id={id}
+                                                            style={style}
+                                                            data-item="true"
+                                                            data-type={type}
+                                                            data-parent-path={name}
+                                                            className={classNames(classes.row, getActiveClass(name))}
+                                                            key={id}
+                                                        >
+                                                            {getItemIcon(type, getActiveClass(name))}
+                                                            <div className={classes.rowName}>{name}</div>
+                                                            {getActiveClass(name) ? (
+                                                                <SidePanelRightArrowIcon
+                                                                    style={{ display: "inline", marginTop: "5px", marginLeft: "5px" }}
+                                                                />
+                                                            ) : null}
+                                                        </div>
+                                                    );
+                                                }}
+                                            </FixedSizeList>
+                                        ) : (
+                                            <div className={classes.rowEmpty}>No directories available</div>
+                                        );
+                                    }}
+                                </AutoSizer>
+                            ) : (
+                                <div
+                                    data-cy="collection-loader"
+                                    className={classes.row}
+                                >
+                                    <CircularProgress
+                                        className={classes.loader}
+                                        size={30}
+                                    />
                                 </div>
-                                { getActiveClass(name)
-                                ? <SidePanelRightArrowIcon
-                                    style={{ display: 'inline', marginTop: '5px', marginLeft: '5px' }} />
-                                : null
-                                }
-                        </div>;
-                    }}</FixedSizeList>
-                    : <div className={classes.rowEmpty}>No directories available</div>
-                    }}
-                </AutoSizer>
-                : <div data-cy="collection-loader" className={classes.row}><CircularProgress className={classes.loader} size={30} /></div> }
-                </div>
-            </div>
-            <div className={classes.rightPanel} data-cy="collection-files-right-panel">
-                <div className={classes.searchWrapper}>
-                    <SearchInput selfClearProp={rightKey} label="Search" value={rightSearch} onSearch={setRightSearch} />
-                </div>
-                { isWritable &&
-                <Button className={classes.uploadButton} data-cy='upload-button'
-                    onClick={() => {
-                        onUploadDataClick(rightKey === leftKey ? undefined : rightKey);
-                    }}
-                    variant='contained' color='primary' size='small'>
-                    <DownloadIcon className={classes.uploadIcon} />
-                    Upload data
-                </Button> }
-                <div className={classes.dataWrapper}>{ rightData && !isLoading
-                    ? <AutoSizer defaultHeight={500}>{({ height, width }) => {
-                        const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1);
-                        return !!filtered.length
-                        ? <FixedSizeList height={height} itemCount={filtered.length}
-                            itemSize={35} width={width}>{ ({ index, style }) => {
-                                const { id, type, name, size } = filtered[index];
-
-                                return <div style={style} data-id={id} data-item="true"
-                                    data-type={type} data-subfolder-path={name}
-                                    className={classes.row} key={id}>
-                                    <Checkbox color="primary"
-                                        className={classes.rowSelection}
-                                        checked={collectionPanelFiles[id] ? collectionPanelFiles[id].value.selected : false}
-                                    />&nbsp;
-                                    {getItemIcon(type, null)}
-                                    <div className={classes.rowName}>
-                                        {name}
-                                    </div>
-                                    <span className={classes.rowName} style={{
-                                        marginLeft: 'auto', marginRight: '1rem' }}>
-                                        { formatFileSize(size) }
-                                    </span>
-                                    <Tooltip title="More options" disableFocusListener>
-                                        <IconButton data-id='moreOptions'
-                                            data-cy='file-item-options-btn'
-                                            className={classes.moreOptionsButton}>
-                                            <MoreOptionsIcon
-                                                data-id='moreOptions'
-                                                className={classes.moreOptions} />
-                                        </IconButton>
-                                    </Tooltip>
+                            )}
+                        </div>
+                    </div>
+                    <div
+                        className={classes.rightPanel}
+                        data-cy="collection-files-right-panel"
+                    >
+                        <div className={classes.searchWrapper}>
+                            <SearchInput
+                                selfClearProp={rightKey}
+                                label="Search"
+                                value={rightSearch}
+                                onSearch={setRightSearch}
+                            />
+                        </div>
+                        {isWritable && (
+                            <Button
+                                className={classes.uploadButton}
+                                data-cy="upload-button"
+                                onClick={() => {
+                                    onUploadDataClick(rightKey === leftKey ? undefined : rightKey);
+                                }}
+                                variant="contained"
+                                color="primary"
+                                size="small"
+                            >
+                                <DownloadIcon className={classes.uploadIcon} />
+                                Upload data
+                            </Button>
+                        )}
+                        <div className={classes.dataWrapper}>
+                            {rightData && !isLoading ? (
+                                <AutoSizer defaultHeight={500}>
+                                    {({ height, width }) => {
+                                        const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1);
+                                        return !!filtered.length ? (
+                                            <FixedSizeList
+                                                height={height}
+                                                itemCount={filtered.length}
+                                                itemSize={35}
+                                                width={width}
+                                            >
+                                                {({ index, style }) => {
+                                                    const { id, type, name, size } = filtered[index];
+
+                                                    return (
+                                                        <div
+                                                            style={style}
+                                                            data-id={id}
+                                                            data-item="true"
+                                                            data-type={type}
+                                                            data-subfolder-path={name}
+                                                            className={classes.row}
+                                                            key={id}
+                                                        >
+                                                            <Checkbox
+                                                                color="primary"
+                                                                className={classes.rowSelection}
+                                                                checked={collectionPanelFiles[id] ? collectionPanelFiles[id].value.selected : false}
+                                                            />
+                                                            &nbsp;
+                                                            {getItemIcon(type, null)}
+                                                            <div className={classes.rowName}>{name}</div>
+                                                            <span
+                                                                className={classes.rowName}
+                                                                style={{
+                                                                    marginLeft: "auto",
+                                                                    marginRight: "1rem",
+                                                                }}
+                                                            >
+                                                                {formatFileSize(size)}
+                                                            </span>
+                                                            <Tooltip
+                                                                title="More options"
+                                                                disableFocusListener
+                                                            >
+                                                                <IconButton
+                                                                    data-id="moreOptions"
+                                                                    data-cy="file-item-options-btn"
+                                                                    className={classes.moreOptionsButton}
+                                                                >
+                                                                    <MoreHorizontalIcon
+                                                                        data-id="moreOptions"
+                                                                        className={classes.moreOptions}
+                                                                    />
+                                                                </IconButton>
+                                                            </Tooltip>
+                                                        </div>
+                                                    );
+                                                }}
+                                            </FixedSizeList>
+                                        ) : (
+                                            <div className={classes.rowEmpty}>This collection is empty</div>
+                                        );
+                                    }}
+                                </AutoSizer>
+                            ) : (
+                                <div className={classes.row}>
+                                    <CircularProgress
+                                        className={classes.loader}
+                                        size={30}
+                                    />
                                 </div>
-                            } }</FixedSizeList>
-                        : <div className={classes.rowEmpty}>This collection is empty</div>
-                    }}</AutoSizer>
-                    : <div className={classes.row}>
-                        <CircularProgress className={classes.loader} size={30} />
-                    </div> }
+                            )}
+                        </div>
+                    </div>
                 </div>
             </div>
-        </div>
-    </div>}));
+        );
+    })
+);
index 28b19bb9bb2722a9ba6683464c3df2269ba61df9..fa09ffc62dc48ac476ca88cc75850a2e9d0bde4c 100644 (file)
@@ -26,8 +26,8 @@ export const ConfirmationDialog = (props: ConfirmationDialogProps & WithDialogPr
             <DialogContent style={{ display: 'flex', alignItems: 'center' }}>
                 <WarningIcon />
                 <DialogContentText style={{ paddingLeft: '8px' }}>
-                    <div>{props.data.text}</div>
-                    <div>{props.data.info}</div>
+                    <span style={{display: 'block'}}>{props.data.text}</span>
+                    <span style={{display: 'block'}}>{props.data.info}</span>
                 </DialogContentText>
             </DialogContent>
             <DialogActions style={{ margin: '0px 24px 24px' }}>
index 3b2ff68a334cf112b3bc4edd6ae9b513a4dcc62b..3ef483dfe03faf63edd24fa3f02ff322e16110c4 100644 (file)
@@ -2,9 +2,9 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from "react";
-import { connect, DispatchProp } from "react-redux";
-import { StyleRulesCallback, Tooltip, WithStyles, withStyles } from "@material-ui/core";
+import React from 'react';
+import { connect, DispatchProp } from 'react-redux';
+import { StyleRulesCallback, Tooltip, WithStyles, withStyles } from '@material-ui/core';
 import { ArvadosTheme } from 'common/custom-theme';
 import CopyToClipboard from 'react-copy-to-clipboard';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
@@ -13,46 +13,50 @@ import { CopyIcon } from 'components/icon/icon';
 type CssRules = 'copyIcon';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
-  copyIcon: {
-    marginLeft: theme.spacing.unit,
-    color: theme.palette.grey["500"],
-    cursor: 'pointer',
-    display: 'inline',
-    '& svg': {
-      fontSize: '1rem',
-      verticalAlign: 'middle',
-    }
-  }
+    copyIcon: {
+        marginLeft: theme.spacing.unit,
+        color: theme.palette.grey['500'],
+        cursor: 'pointer',
+        display: 'inline',
+        '& svg': {
+            fontSize: '1rem',
+            verticalAlign: 'middle',
+        },
+    },
 });
 
 interface CopyToClipboardDataProps {
-  children?: React.ReactNode;
-  value: string;
+    children?: React.ReactNode;
+    value: string;
 }
 
 type CopyToClipboardProps = CopyToClipboardDataProps & WithStyles<CssRules> & DispatchProp;
 
-export const CopyToClipboardSnackbar = connect()(withStyles(styles)(
-  class CopyToClipboardSnackbar extends React.Component<CopyToClipboardProps> {
-    onCopy = () => {
-      this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
-        message: 'Copied',
-        hideDuration: 2000,
-        kind: SnackbarKind.SUCCESS
-    }));
-    };
+export const CopyToClipboardSnackbar = connect()(
+    withStyles(styles)(
+        class CopyToClipboardSnackbar extends React.Component<CopyToClipboardProps> {
+            onCopy = () => {
+                this.props.dispatch(
+                    snackbarActions.OPEN_SNACKBAR({
+                        message: 'Copied',
+                        hideDuration: 2000,
+                        kind: SnackbarKind.SUCCESS,
+                    })
+                );
+            };
 
-    render() {
-      const { children, value, classes } = this.props;
-      return (
-        <Tooltip title="Copy to clipboard">
-          <span className={classes.copyIcon}>
-            <CopyToClipboard text={value} onCopy={this.onCopy}>
-              {children || <CopyIcon />}
-            </CopyToClipboard>
-          </span>
-        </Tooltip>
-      );
-    }
-  }
-));
+            render() {
+                const { children, value, classes } = this.props;
+                return (
+                    <Tooltip title='Copy to clipboard' onClick={(ev) => ev.stopPropagation()}>
+                        <span className={classes.copyIcon}>
+                            <CopyToClipboard text={value} onCopy={this.onCopy}>
+                                {children || <CopyIcon />}
+                            </CopyToClipboard>
+                        </span>
+                    </Tooltip>
+                );
+            }
+        }
+    )
+);
index dc7e8725793524b57bf7cfb76aadd5c466577274..b86567a54c4a73cde90e2c5ef115df5df63037ce 100644 (file)
@@ -4,29 +4,53 @@
 
 import React from "react";
 import { configure, mount } from "enzyme";
-import Adapter from 'enzyme-adapter-react-16';
+import Adapter from "enzyme-adapter-react-16";
 
 import { DataExplorer } from "./data-explorer";
 import { ColumnSelector } from "../column-selector/column-selector";
 import { DataTable, DataTableFetchMode } from "../data-table/data-table";
 import { SearchInput } from "../search-input/search-input";
 import { TablePagination } from "@material-ui/core";
-import { ProjectIcon } from '../icon/icon';
-import { SortDirection } from '../data-table/data-column';
+import { ProjectIcon } from "../icon/icon";
+import { SortDirection } from "../data-table/data-column";
+import { combineReducers, createStore } from "redux";
+import { Provider } from "react-redux";
 
 configure({ adapter: new Adapter() });
 
 describe("<DataExplorer />", () => {
+    let store;
+    beforeEach(() => {
+        const initialMSState = {
+            multiselect: {
+                checkedList: {},
+                isVisible: false,
+            },
+            resources: {},
+        };
+        store = createStore(
+            combineReducers({
+                multiselect: (state: any = initialMSState.multiselect, action: any) => state,
+                resources: (state: any = initialMSState.resources, action: any) => state,
+            })
+        );
+    });
 
     it("communicates with <SearchInput/>", () => {
         const onSearch = jest.fn();
         const onSetColumns = jest.fn();
-        const dataExplorer = mount(<DataExplorer
-            {...mockDataExplorerProps()}
-            items={[{ name: "item 1" }]}
-            searchValue="search value"
-            onSearch={onSearch}
-            onSetColumns={onSetColumns} />);
+
+        const dataExplorer = mount(
+            <Provider store={store}>
+                <DataExplorer
+                    {...mockDataExplorerProps()}
+                    items={[{ name: "item 1" }]}
+                    searchValue="search value"
+                    onSearch={onSearch}
+                    onSetColumns={onSetColumns}
+                />
+            </Provider>
+        );
         expect(dataExplorer.find(SearchInput).prop("value")).toEqual("search value");
         dataExplorer.find(SearchInput).prop("onSearch")("new value");
         expect(onSearch).toHaveBeenCalledWith("new value");
@@ -36,12 +60,17 @@ describe("<DataExplorer />", () => {
         const onColumnToggle = jest.fn();
         const onSetColumns = jest.fn();
         const columns = [{ name: "Column 1", render: jest.fn(), selected: true, configurable: true, sortDirection: SortDirection.ASC, filters: {} }];
-        const dataExplorer = mount(<DataExplorer
-            {...mockDataExplorerProps()}
-            columns={columns}
-            onColumnToggle={onColumnToggle}
-            items={[{ name: "item 1" }]}
-            onSetColumns={onSetColumns} />);
+        const dataExplorer = mount(
+            <Provider store={store}>
+                <DataExplorer
+                    {...mockDataExplorerProps()}
+                    columns={columns}
+                    onColumnToggle={onColumnToggle}
+                    items={[{ name: "item 1" }]}
+                    onSetColumns={onSetColumns}
+                />
+            </Provider>
+        );
         expect(dataExplorer.find(ColumnSelector).prop("columns")).toBe(columns);
         dataExplorer.find(ColumnSelector).prop("onColumnToggle")("columns");
         expect(onColumnToggle).toHaveBeenCalledWith("columns");
@@ -54,15 +83,20 @@ describe("<DataExplorer />", () => {
         const onSetColumns = jest.fn();
         const columns = [{ name: "Column 1", render: jest.fn(), selected: true, configurable: true, sortDirection: SortDirection.ASC, filters: {} }];
         const items = [{ name: "item 1" }];
-        const dataExplorer = mount(<DataExplorer
-            {...mockDataExplorerProps()}
-            columns={columns}
-            items={items}
-            onFiltersChange={onFiltersChange}
-            onSortToggle={onSortToggle}
-            onRowClick={onRowClick}
-            onSetColumns={onSetColumns} />);
-        expect(dataExplorer.find(DataTable).prop("columns").slice(0, -1)).toEqual(columns);
+        const dataExplorer = mount(
+            <Provider store={store}>
+                <DataExplorer
+                    {...mockDataExplorerProps()}
+                    columns={columns}
+                    items={items}
+                    onFiltersChange={onFiltersChange}
+                    onSortToggle={onSortToggle}
+                    onRowClick={onRowClick}
+                    onSetColumns={onSetColumns}
+                />
+            </Provider>
+        );
+        expect(dataExplorer.find(DataTable).prop("columns").slice(1, 2)).toEqual(columns);
         expect(dataExplorer.find(DataTable).prop("items")).toBe(items);
         dataExplorer.find(DataTable).prop("onRowClick")("event", "rowClick");
         dataExplorer.find(DataTable).prop("onFiltersChange")("filtersChange");
@@ -76,14 +110,19 @@ describe("<DataExplorer />", () => {
         const onChangePage = jest.fn();
         const onChangeRowsPerPage = jest.fn();
         const onSetColumns = jest.fn();
-        const dataExplorer = mount(<DataExplorer
-            {...mockDataExplorerProps()}
-            items={[{ name: "item 1" }]}
-            page={10}
-            rowsPerPage={50}
-            onChangePage={onChangePage}
-            onChangeRowsPerPage={onChangeRowsPerPage}
-            onSetColumns={onSetColumns} />);
+        const dataExplorer = mount(
+            <Provider store={store}>
+                <DataExplorer
+                    {...mockDataExplorerProps()}
+                    items={[{ name: "item 1" }]}
+                    page={10}
+                    rowsPerPage={50}
+                    onChangePage={onChangePage}
+                    onChangeRowsPerPage={onChangeRowsPerPage}
+                    onSetColumns={onSetColumns}
+                />
+            </Provider>
+        );
         expect(dataExplorer.find(TablePagination).prop("page")).toEqual(10);
         expect(dataExplorer.find(TablePagination).prop("rowsPerPage")).toEqual(50);
         dataExplorer.find(TablePagination).prop("onChangePage")(undefined, 6);
@@ -115,6 +154,10 @@ const mockDataExplorerProps = () => ({
     defaultIcon: ProjectIcon,
     onSetColumns: jest.fn(),
     onLoadMore: jest.fn(),
-    defaultMessages: ['testing'],
-    contextMenuColumn: true
+    defaultMessages: ["testing"],
+    contextMenuColumn: true,
+    setCheckedListOnStore: jest.fn(),
+    toggleMSToolbar: jest.fn(),
+    isMSToolbarVisible: false,
+    checkedList: {},
 });
index fcee0c54deedcefaf5f9ed3282bfa757ccaadd4b..27e46d584962c8d3e1cb1ca536b21ab1b4577ecf 100644 (file)
@@ -2,61 +2,72 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-import { Grid, Paper, Toolbar, StyleRulesCallback, withStyles, WithStyles, TablePagination, IconButton, Tooltip, Button } from '@material-ui/core';
+import React from "react";
+import { Grid, Paper, Toolbar, StyleRulesCallback, withStyles, WithStyles, TablePagination, IconButton, Tooltip, Button } from "@material-ui/core";
 import { ColumnSelector } from "components/column-selector/column-selector";
 import { DataTable, DataColumns, DataTableFetchMode } from "components/data-table/data-table";
 import { DataColumn } from "components/data-table/data-column";
-import { SearchInput } from 'components/search-input/search-input';
+import { SearchInput } from "components/search-input/search-input";
 import { ArvadosTheme } from "common/custom-theme";
-import { createTree } from 'models/tree';
-import { DataTableFilters } from 'components/data-table-filters/data-table-filters-tree';
-import {
-    CloseIcon,
-    IconType,
-    MaximizeIcon,
-    UnMaximizeIcon,
-    MoreOptionsIcon
-} from 'components/icon/icon';
-import { PaperProps } from '@material-ui/core/Paper';
-import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
+import { MultiselectToolbar } from "components/multiselect-toolbar/MultiselectToolbar";
+import { TCheckedList } from "components/data-table/data-table";
+import { createTree } from "models/tree";
+import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree";
+import { CloseIcon, IconType, MaximizeIcon, UnMaximizeIcon, MoreVerticalIcon } from "components/icon/icon";
+import { PaperProps } from "@material-ui/core/Paper";
+import { MPVPanelProps } from "components/multi-panel-view/multi-panel-view";
 
-type CssRules = 'searchBox' | 'headerMenu' | "toolbar" | "footer" | "root" | 'moreOptionsButton' | 'title' | 'dataTable' | 'container';
+type CssRules = "titleWrapper" | "searchBox" | "headerMenu" | "toolbar" | "footer" | "root" | "moreOptionsButton" | "title" | 'subProcessTitle' | "dataTable" | "container";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    titleWrapper: {
+        display: "flex",
+        justifyContent: "space-between",
+    },
     searchBox: {
         paddingBottom: 0,
     },
     toolbar: {
         paddingTop: 0,
         paddingRight: theme.spacing.unit,
+        paddingLeft: "10px",
     },
     footer: {
-        overflow: 'auto'
+        overflow: "auto",
     },
     root: {
-        height: '100%',
+        height: "100%",
     },
     moreOptionsButton: {
-        padding: 0
+        padding: 0,
     },
     title: {
-        display: 'inline-block',
+        display: "inline-block",
+        paddingLeft: theme.spacing.unit * 2,
+        paddingTop: theme.spacing.unit * 2,
+        fontSize: "18px",
+        paddingRight: "10px",
+    },
+    subProcessTitle: {
+        display: "inline-block",
         paddingLeft: theme.spacing.unit * 2,
         paddingTop: theme.spacing.unit * 2,
-        fontSize: '18px'
+        fontSize: "18px",
+        flexGrow: 0,
+        paddingRight: "10px",
     },
     dataTable: {
-        height: '100%',
-        overflow: 'auto',
+        height: "100%",
+        overflow: "auto",
     },
     container: {
-        height: '100%',
+        height: "100%",
     },
     headerMenu: {
-        float: 'right',
-        display: 'inline-block',
-    }
+        marginLeft: "auto",
+        flexBasis: "initial",
+        flexGrow: 0,
+    },
 });
 
 interface DataExplorerDataProps<T> {
@@ -80,9 +91,12 @@ interface DataExplorerDataProps<T> {
     actions?: React.ReactNode;
     hideSearchInput?: boolean;
     title?: React.ReactNode;
+    progressBar?: React.ReactNode;
     paperKey?: string;
     currentItemUuid: string;
     elementPath?: string;
+    isMSToolbarVisible: boolean;
+    checkedList: TCheckedList;
 }
 
 interface DataExplorerActionProps<T> {
@@ -98,22 +112,25 @@ interface DataExplorerActionProps<T> {
     onChangeRowsPerPage: (rowsPerPage: number) => void;
     onLoadMore: (page: number) => void;
     extractKey?: (item: T) => React.Key;
+    toggleMSToolbar: (isVisible: boolean) => void;
+    setCheckedListOnStore: (checkedList: TCheckedList) => void;
 }
 
-type DataExplorerProps<T> = DataExplorerDataProps<T> &
-    DataExplorerActionProps<T> & WithStyles<CssRules> & MPVPanelProps;
+type DataExplorerProps<T> = DataExplorerDataProps<T> & DataExplorerActionProps<T> & WithStyles<CssRules> & MPVPanelProps;
 
 export const DataExplorer = withStyles(styles)(
     class DataExplorerGeneric<T> extends React.Component<DataExplorerProps<T>> {
         state = {
             showLoading: false,
-            prevRefresh: '',
-            prevRoute: '',
+            prevRefresh: "",
+            prevRoute: "",
         };
 
+        multiSelectToolbarInTitle = !this.props.title && !this.props.progressBar;
+
         componentDidUpdate(prevProps: DataExplorerProps<T>) {
-            const currentRefresh = this.props.currentRefresh || '';
-            const currentRoute = this.props.currentRoute || '';
+            const currentRefresh = this.props.currentRefresh || "";
+            const currentRoute = this.props.currentRoute || "";
 
             if (currentRoute !== this.state.prevRoute) {
                 // Component already mounted, but the user comes from a route change,
@@ -146,119 +163,243 @@ export const DataExplorer = withStyles(styles)(
             // Component just mounted, so we need to show the loading indicator.
             this.setState({
                 showLoading: this.props.working,
-                prevRefresh: this.props.currentRefresh || '',
-                prevRoute: this.props.currentRoute || '',
+                prevRefresh: this.props.currentRefresh || "",
+                prevRoute: this.props.currentRoute || "",
             });
         }
 
         render() {
             const {
-                columns, onContextMenu, onFiltersChange, onSortToggle, extractKey,
-                rowsPerPage, rowsPerPageOptions, onColumnToggle, searchLabel, searchValue, onSearch,
-                items, itemsAvailable, onRowClick, onRowDoubleClick, classes,
-                defaultViewIcon, defaultViewMessages, hideColumnSelector, actions, paperProps, hideSearchInput,
-                paperKey, fetchMode, currentItemUuid, title,
-                doHidePanel, doMaximizePanel, doUnMaximizePanel, panelName, panelMaximized, elementPath
+                columns,
+                onContextMenu,
+                onFiltersChange,
+                onSortToggle,
+                extractKey,
+                rowsPerPage,
+                rowsPerPageOptions,
+                onColumnToggle,
+                searchLabel,
+                searchValue,
+                onSearch,
+                items,
+                itemsAvailable,
+                onRowClick,
+                onRowDoubleClick,
+                classes,
+                defaultViewIcon,
+                defaultViewMessages,
+                hideColumnSelector,
+                actions,
+                paperProps,
+                hideSearchInput,
+                paperKey,
+                fetchMode,
+                currentItemUuid,
+                currentRoute,
+                title,
+                progressBar,
+                doHidePanel,
+                doMaximizePanel,
+                doUnMaximizePanel,
+                panelName,
+                panelMaximized,
+                elementPath,
+                toggleMSToolbar,
+                setCheckedListOnStore,
+                checkedList,
             } = this.props;
-            return <Paper className={classes.root} {...paperProps} key={paperKey} data-cy={this.props["data-cy"]}>
-                <Grid container direction="column" wrap="nowrap" className={classes.container}>
-                    <div>
-                        {title && <Grid item xs className={classes.title}>{title}</Grid>}
-                        {
-                            (!hideColumnSelector || !hideSearchInput || !!actions) &&
-                            <Grid className={classes.headerMenu} item xs>
-                                <Toolbar className={classes.toolbar}>
-                                    {!hideSearchInput && <div className={classes.searchBox}>
-                                        {!hideSearchInput && <SearchInput
-                                            label={searchLabel}
-                                            value={searchValue}
-                                            selfClearProp={''}
-                                            onSearch={onSearch} />}
-                                    </div>}
-                                    {actions}
-                                    {!hideColumnSelector && <ColumnSelector
-                                        columns={columns}
-                                        onColumnToggle={onColumnToggle} />}
-                                    { doUnMaximizePanel && panelMaximized &&
-                                    <Tooltip title={`Unmaximize ${panelName || 'panel'}`} disableFocusListener>
-                                        <IconButton onClick={doUnMaximizePanel}><UnMaximizeIcon /></IconButton>
-                                    </Tooltip> }
-                                    { doMaximizePanel && !panelMaximized &&
-                                        <Tooltip title={`Maximize ${panelName || 'panel'}`} disableFocusListener>
-                                            <IconButton onClick={doMaximizePanel}><MaximizeIcon /></IconButton>
-                                        </Tooltip> }
-                                    { doHidePanel &&
-                                        <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
-                                            <IconButton disabled={panelMaximized} onClick={doHidePanel}><CloseIcon /></IconButton>
-                                        </Tooltip> }
-                                </Toolbar>
-                            </Grid>
-                        }
-                    </div>
-                <Grid item xs="auto" className={classes.dataTable}><DataTable
-                    columns={this.props.contextMenuColumn ? [...columns, this.contextMenuColumn] : columns}
-                    items={items}
-                    onRowClick={(_, item: T) => onRowClick(item)}
-                    onContextMenu={onContextMenu}
-                    onRowDoubleClick={(_, item: T) => onRowDoubleClick(item)}
-                    onFiltersChange={onFiltersChange}
-                    onSortToggle={onSortToggle}
-                    extractKey={extractKey}
-                    working={this.state.showLoading}
-                    defaultViewIcon={defaultViewIcon}
-                    defaultViewMessages={defaultViewMessages}
-                    currentItemUuid={currentItemUuid}
-                    currentRoute={paperKey} /></Grid>
-                <Grid item xs><Toolbar className={classes.footer}>
-                    {
-                        elementPath &&
-                        <Grid container>
-                            <span data-cy="element-path">
-                                {elementPath}
-                            </span>
+            return (
+                <Paper
+                    className={classes.root}
+                    {...paperProps}
+                    key={paperKey}
+                    data-cy={this.props["data-cy"]}
+                >
+                    <Grid
+                        container
+                        direction="column"
+                        wrap="nowrap"
+                        className={classes.container}
+                    >
+                        <div className={classes.titleWrapper} style={currentRoute?.includes('search-results') || !!progressBar ? {marginBottom: '-20px'} : {}}>
+                            {title && (
+                                <Grid
+                                    item
+                                    xs
+                                    className={!!progressBar ? classes.subProcessTitle : classes.title}
+                                >
+                                    {title}
+                                </Grid>
+                            )}
+                            {!!progressBar && progressBar}
+                            {this.multiSelectToolbarInTitle && <MultiselectToolbar />}
+                            {(!hideColumnSelector || !hideSearchInput || !!actions) && (
+                                <Grid
+                                    className={classes.headerMenu}
+                                    item
+                                    xs
+                                >
+                                    <Toolbar className={classes.toolbar}>
+                                        <Grid container justify="space-between" wrap="nowrap" alignItems="center">
+                                            {!hideSearchInput && (
+                                                <div className={classes.searchBox}>
+                                                    {!hideSearchInput && (
+                                                        <SearchInput
+                                                            label={searchLabel}
+                                                            value={searchValue}
+                                                            selfClearProp={""}
+                                                            onSearch={onSearch}
+                                                        />
+                                                    )}
+                                                </div>
+                                            )}
+                                            {actions}
+                                            {!hideColumnSelector && (
+                                                <ColumnSelector
+                                                    columns={columns}
+                                                    onColumnToggle={onColumnToggle}
+                                                />
+                                            )}
+                                        </Grid>
+                                        {doUnMaximizePanel && panelMaximized && (
+                                            <Tooltip
+                                                title={`Unmaximize ${panelName || "panel"}`}
+                                                disableFocusListener
+                                            >
+                                                <IconButton onClick={doUnMaximizePanel}>
+                                                    <UnMaximizeIcon />
+                                                </IconButton>
+                                            </Tooltip>
+                                        )}
+                                        {doMaximizePanel && !panelMaximized && (
+                                            <Tooltip
+                                                title={`Maximize ${panelName || "panel"}`}
+                                                disableFocusListener
+                                            >
+                                                <IconButton onClick={doMaximizePanel}>
+                                                    <MaximizeIcon />
+                                                </IconButton>
+                                            </Tooltip>
+                                        )}
+                                        {doHidePanel && (
+                                            <Tooltip
+                                                title={`Close ${panelName || "panel"}`}
+                                                disableFocusListener
+                                            >
+                                                <IconButton
+                                                    disabled={panelMaximized}
+                                                    onClick={doHidePanel}
+                                                >
+                                                    <CloseIcon />
+                                                </IconButton>
+                                            </Tooltip>
+                                        )}
+                                    </Toolbar>
+                                </Grid>
+                            )}
+                        </div>
+                        {!this.multiSelectToolbarInTitle && <MultiselectToolbar />}
+                        <Grid
+                            item
+                            xs="auto"
+                            className={classes.dataTable}
+                            style={currentRoute?.includes('search-results')  || !!progressBar ? {marginTop: '-10px'} : {}}
+                        >
+                            <DataTable
+                                columns={this.props.contextMenuColumn ? [...columns, this.contextMenuColumn] : columns}
+                                items={items}
+                                onRowClick={(_, item: T) => onRowClick(item)}
+                                onContextMenu={onContextMenu}
+                                onRowDoubleClick={(_, item: T) => onRowDoubleClick(item)}
+                                onFiltersChange={onFiltersChange}
+                                onSortToggle={onSortToggle}
+                                extractKey={extractKey}
+                                working={this.state.showLoading}
+                                defaultViewIcon={defaultViewIcon}
+                                defaultViewMessages={defaultViewMessages}
+                                currentItemUuid={currentItemUuid}
+                                currentRoute={paperKey}
+                                toggleMSToolbar={toggleMSToolbar}
+                                setCheckedListOnStore={setCheckedListOnStore}
+                                checkedList={checkedList}
+                            />
+                        </Grid>
+                        <Grid
+                            item
+                            xs
+                        >
+                            <Toolbar className={classes.footer}>
+                                {elementPath && (
+                                    <Grid container>
+                                        <span data-cy="element-path">{elementPath}</span>
+                                    </Grid>
+                                )}
+                                <Grid
+                                    container={!elementPath}
+                                    justify="flex-end"
+                                >
+                                    {fetchMode === DataTableFetchMode.PAGINATED ? (
+                                        <TablePagination
+                                            count={itemsAvailable}
+                                            rowsPerPage={rowsPerPage}
+                                            rowsPerPageOptions={rowsPerPageOptions}
+                                            page={this.props.page}
+                                            onChangePage={this.changePage}
+                                            onChangeRowsPerPage={this.changeRowsPerPage}
+                                            // Disable next button on empty lists since that's not default behavior
+                                            nextIconButtonProps={itemsAvailable > 0 ? {} : { disabled: true }}
+                                            component="div"
+                                        />
+                                    ) : (
+                                        <Button
+                                            variant="text"
+                                            size="medium"
+                                            onClick={this.loadMore}
+                                        >
+                                            Load more
+                                        </Button>
+                                    )}
+                                </Grid>
+                            </Toolbar>
                         </Grid>
-                    }
-                    <Grid container={!elementPath} justify="flex-end">
-                        {fetchMode === DataTableFetchMode.PAGINATED ? <TablePagination
-                            count={itemsAvailable}
-                            rowsPerPage={rowsPerPage}
-                            rowsPerPageOptions={rowsPerPageOptions}
-                            page={this.props.page}
-                            onChangePage={this.changePage}
-                            onChangeRowsPerPage={this.changeRowsPerPage}
-                            // Disable next button on empty lists since that's not default behavior
-                            nextIconButtonProps={(itemsAvailable > 0) ? {} : {disabled: true}}
-                            component="div" /> : <Button
-                                variant="text"
-                                size="medium"
-                                onClick={this.loadMore}
-                            >Load more</Button>}
                     </Grid>
-                </Toolbar></Grid>
-                </Grid>
-            </Paper>;
+                </Paper>
+            );
         }
 
         changePage = (event: React.MouseEvent<HTMLButtonElement>, page: number) => {
             this.props.onChangePage(page);
-        }
+        };
 
-        changeRowsPerPage: React.ChangeEventHandler<HTMLTextAreaElement | HTMLInputElement> = (event) => {
+        changeRowsPerPage: React.ChangeEventHandler<HTMLTextAreaElement | HTMLInputElement> = event => {
             this.props.onChangeRowsPerPage(parseInt(event.target.value, 10));
-        }
+        };
 
         loadMore = () => {
             this.props.onLoadMore(this.props.page + 1);
-        }
+        };
 
-        renderContextMenuTrigger = (item: T) =>
-            <Grid container justify="center">
-                <Tooltip title="More options" disableFocusListener>
-                    <IconButton className={this.props.classes.moreOptionsButton} onClick={event => this.props.onContextMenu(event, item)}>
-                        <MoreOptionsIcon />
+        renderContextMenuTrigger = (item: T) => (
+            <Grid
+                container
+                justify="center"
+            >
+                <Tooltip
+                    title="More options"
+                    disableFocusListener
+                >
+                    <IconButton
+                        className={this.props.classes.moreOptionsButton}
+                        onClick={event => {
+                            event.stopPropagation()
+                            this.props.onContextMenu(event, item)
+                        }}
+                    >
+                        <MoreVerticalIcon />
                     </IconButton>
                 </Tooltip>
             </Grid>
+        );
 
         contextMenuColumn: DataColumn<any, any> = {
             name: "Actions",
@@ -266,7 +407,7 @@ export const DataExplorer = withStyles(styles)(
             configurable: false,
             filters: createTree(),
             key: "context-actions",
-            render: this.renderContextMenuTrigger
+            render: this.renderContextMenuTrigger,
         };
     }
 );
index b51878664449b67a9f3a33b992fa9a0605820173..557abd825a004cf85c0a8fe1486f436940cfc79b 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React, { useEffect } from "react";
+import React, { useEffect } from 'react';
 import {
     WithStyles,
     withStyles,
@@ -16,28 +16,28 @@ import {
     Typography,
     CardContent,
     Tooltip,
-    IconButton
-} from "@material-ui/core";
-import classnames from "classnames";
-import { DefaultTransformOrigin } from "components/popover/helpers";
+    IconButton,
+} from '@material-ui/core';
+import classnames from 'classnames';
+import { DefaultTransformOrigin } from 'components/popover/helpers';
 import { createTree } from 'models/tree';
-import { DataTableFilters, DataTableFiltersTree } from "./data-table-filters-tree";
+import { DataTableFilters, DataTableFiltersTree } from './data-table-filters-tree';
 import { getNodeDescendants } from 'models/tree';
-import debounce from "lodash/debounce";
+import debounce from 'lodash/debounce';
 
-export type CssRules = "root" | "icon" | "iconButton" | "active" | "checkbox";
+export type CssRules = 'root' | 'icon' | 'iconButton' | 'active' | 'checkbox';
 
 const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
     root: {
-        cursor: "pointer",
-        display: "inline-flex",
-        justifyContent: "flex-start",
-        flexDirection: "inherit",
-        alignItems: "center",
-        "&:hover": {
+        cursor: 'pointer',
+        display: 'inline-flex',
+        justifyContent: 'flex-start',
+        flexDirection: 'inherit',
+        alignItems: 'center',
+        '&:hover': {
             color: theme.palette.text.primary,
         },
-        "&:focus": {
+        '&:focus': {
             color: theme.palette.text.primary,
         },
     },
@@ -52,7 +52,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
         userSelect: 'none',
         width: 16,
         height: 15,
-        marginTop: 1
+        marginTop: 1,
     },
     iconButton: {
         color: theme.palette.text.primary,
@@ -60,13 +60,13 @@ const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
     },
     checkbox: {
         width: 24,
-        height: 24
-    }
+        height: 24,
+    },
 });
 
 enum SelectionMode {
     ALL = 'all',
-    NONE = 'none'
+    NONE = 'none',
 }
 
 export interface DataTableFilterProps {
@@ -103,68 +103,52 @@ export const DataTableFiltersPopover = withStyles(styles)(
 
         render() {
             const { name, classes, defaultSelection = SelectionMode.ALL, children } = this.props;
-            const isActive = getNodeDescendants('')(this.state.filters)
-                .some(f => defaultSelection === SelectionMode.ALL
-                    ? !f.selected
-                    : f.selected
-                );
-            return <>
-                <Tooltip disableFocusListener title='Filters'>
-                    <ButtonBase
-                        className={classnames([classes.root, { [classes.active]: isActive }])}
-                        component="span"
-                        onClick={this.open}
-                        disableRipple>
-                        {children}
-                        <IconButton component='span' classes={{ root: classes.iconButton }} tabIndex={-1}>
-                            <i className={classnames(["fas fa-filter", classes.icon])}
-                                data-fa-transform="shrink-3"
-                                ref={this.icon} />
-                        </IconButton>
-                    </ButtonBase>
-                </Tooltip>
-                <Popover
-                    anchorEl={this.state.anchorEl}
-                    open={!!this.state.anchorEl}
-                    anchorOrigin={DefaultTransformOrigin}
-                    transformOrigin={DefaultTransformOrigin}
-                    onClose={this.close}>
-                    <Card>
-                        <CardContent>
-                            <Typography variant="caption">
-                                {name}
-                            </Typography>
-                        </CardContent>
-                        <DataTableFiltersTree
-                            filters={this.state.filters}
-                            mutuallyExclusive={this.props.mutuallyExclusive}
-                            onChange={this.onChange} />
-                        {this.props.mutuallyExclusive ||
-                        <CardActions>
-                            <Button
-                                color="primary"
-                                variant="outlined"
-                                size="small"
-                                onClick={this.close}>
-                                Close
-                            </Button>
-                        </CardActions >
-                        }
-                    </Card>
-                </Popover>
-                <this.MountHandler />
-            </>;
+            const isActive = getNodeDescendants('')(this.state.filters).some((f) => (defaultSelection === SelectionMode.ALL ? !f.selected : f.selected));
+            return (
+                <>
+                    <Tooltip disableFocusListener title='Filters'>
+                        <ButtonBase className={classnames([classes.root, { [classes.active]: isActive }])} component='span' onClick={this.open} disableRipple>
+                            {children}
+                            <IconButton component='span' classes={{ root: classes.iconButton }} tabIndex={-1}>
+                                <i className={classnames(['fas fa-filter', classes.icon])} data-fa-transform='shrink-3' ref={this.icon} />
+                            </IconButton>
+                        </ButtonBase>
+                    </Tooltip>
+                    <Popover
+                        anchorEl={this.state.anchorEl}
+                        open={!!this.state.anchorEl}
+                        anchorOrigin={DefaultTransformOrigin}
+                        transformOrigin={DefaultTransformOrigin}
+                        onClose={this.close}
+                    >
+                        <Card>
+                            <CardContent>
+                                <Typography variant='caption'>{name}</Typography>
+                            </CardContent>
+                            <DataTableFiltersTree filters={this.state.filters} mutuallyExclusive={this.props.mutuallyExclusive} onChange={this.onChange} />
+                            <>
+                                {this.props.mutuallyExclusive || (
+                                    <CardActions>
+                                        <Button color='primary' variant='outlined' size='small' onClick={this.close}>
+                                            Close
+                                        </Button>
+                                    </CardActions>
+                                )}
+                            </>
+                        </Card>
+                    </Popover>
+                    <this.MountHandler />
+                </>
+            );
         }
 
         static getDerivedStateFromProps(props: DataTableFilterProps, state: DataTableFilterState): DataTableFilterState {
-            return props.filters !== state.prevFilters
-                ? { ...state, filters: props.filters, prevFilters: props.filters }
-                : state;
+            return props.filters !== state.prevFilters ? { ...state, filters: props.filters, prevFilters: props.filters } : state;
         }
 
         open = () => {
             this.setState({ anchorEl: this.icon.current || undefined });
-        }
+        };
 
         onChange = (filters) => {
             this.setState({ filters });
@@ -179,9 +163,9 @@ export const DataTableFiltersPopover = withStyles(styles)(
                 // Non-mutually exclusive filters are debounced
                 this.submit();
             }
-        }
+        };
 
-        submit = debounce (() => {
+        submit = debounce(() => {
             const { onChange } = this.props;
             if (onChange) {
                 onChange(this.state.filters);
@@ -192,17 +176,16 @@ export const DataTableFiltersPopover = withStyles(styles)(
             useEffect(() => {
                 return () => {
                     this.submit.cancel();
-                }
-            },[]);
+                };
+            }, []);
             return null;
         };
 
         close = () => {
-            this.setState(prev => ({
+            this.setState((prev) => ({
                 ...prev,
-                anchorEl: undefined
+                anchorEl: undefined,
             }));
-        }
-
+        };
     }
 );
index 7b97865bba4b546085d6c90ab7cb68a7b5821e45..d52b58f5ae30ac6f09ed533a0e52e4213c13a8c7 100644 (file)
@@ -59,14 +59,14 @@ export class DataTableFiltersTree extends React.Component<DataTableFilterProps>
         if (item.selected) { return; }
 
         // Otherwise select this node and deselect the others
-        const filters = selectNode(item.id)(this.props.filters);
+        const filters = selectNode(item.id, true)(this.props.filters);
         const toDeselect = Object.keys(this.props.filters).filter((id) => (id !== item.id));
-        onChange(deselectNodes(toDeselect)(filters));
+        onChange(deselectNodes(toDeselect, true)(filters));
     }
 
     toggleFilter = (_: React.MouseEvent, item: TreeItem<DataTableFilterItem>) => {
         const { onChange = noop } = this.props;
-        onChange(toggleNodeSelection(item.id)(this.props.filters));
+        onChange(toggleNodeSelection(item.id, true)(this.props.filters));
     }
 
     toggleOpen = (_: React.MouseEvent, item: TreeItem<DataTableFilterItem>) => {
diff --git a/src/components/data-table-multiselect-popover/data-table-multiselect-popover.tsx b/src/components/data-table-multiselect-popover/data-table-multiselect-popover.tsx
new file mode 100644 (file)
index 0000000..0248c82
--- /dev/null
@@ -0,0 +1,149 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { WithStyles, withStyles, ButtonBase, StyleRulesCallback, Theme, Popover, Card, Tooltip, IconButton } from "@material-ui/core";
+import classnames from "classnames";
+import { DefaultTransformOrigin } from "components/popover/helpers";
+import { grey } from "@material-ui/core/colors";
+import { TCheckedList } from "components/data-table/data-table";
+
+export type CssRules = "root" | "icon" | "iconButton" | "disabled" | "optionsContainer" | "option";
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+    root: {
+        borderRadius: "7px",
+        "&:hover": {
+            backgroundColor: grey[200],
+        },
+        "&:focus": {
+            color: theme.palette.text.primary,
+        },
+    },
+    icon: {
+        cursor: "pointer",
+        fontSize: 20,
+        userSelect: "none",
+        "&:hover": {
+            color: theme.palette.text.primary,
+        },
+        paddingBottom: "5px",
+    },
+    iconButton: {
+        color: theme.palette.text.primary,
+        opacity: 0.6,
+        padding: 1,
+        paddingBottom: 5,
+    },
+    disabled: {
+        color: grey[500],
+    },
+    optionsContainer: {
+        padding: "1rem 0",
+        flex: 1,
+    },
+    option: {
+        cursor: "pointer",
+        display: "flex",
+        padding: "3px 2rem",
+        fontSize: "0.9rem",
+        alignItems: "center",
+        "&:hover": {
+            backgroundColor: "rgba(0, 0, 0, 0.08)",
+        },
+    },
+});
+
+export type DataTableMultiselectOption = {
+    name: string;
+    fn: (checkedList) => void;
+};
+
+export interface DataTableMultiselectProps {
+    name: string;
+    disabled: boolean;
+    options: DataTableMultiselectOption[];
+    checkedList: TCheckedList;
+}
+
+interface DataTableFMultiselectPopState {
+    anchorEl?: HTMLElement;
+}
+
+export const DataTableMultiselectPopover = withStyles(styles)(
+    class extends React.Component<DataTableMultiselectProps & WithStyles<CssRules>, DataTableFMultiselectPopState> {
+        state: DataTableFMultiselectPopState = {
+            anchorEl: undefined,
+        };
+        icon = React.createRef<HTMLElement>();
+
+        render() {
+            const { classes, children, options, checkedList, disabled } = this.props;
+            return (
+                <>
+                    <Tooltip
+                        disableFocusListener
+                        title="Select Options"
+                    >
+                        <ButtonBase
+                            className={classnames(classes.root)}
+                            component="span"
+                            onClick={disabled ? () => {} : this.open}
+                            disableRipple
+                        >
+                            {children}
+                            <IconButton
+                                component="span"
+                                classes={{ root: classes.iconButton }}
+                                tabIndex={-1}
+                            >
+                                <i
+                                    className={`${classnames(["fas fa-sort-down", classes.icon])}${disabled ? ` ${classes.disabled}` : ""}`}
+                                    data-fa-transform="shrink-3"
+                                    ref={this.icon}
+                                />
+                            </IconButton>
+                        </ButtonBase>
+                    </Tooltip>
+                    <Popover
+                        anchorEl={this.state.anchorEl}
+                        open={!!this.state.anchorEl}
+                        anchorOrigin={DefaultTransformOrigin}
+                        transformOrigin={DefaultTransformOrigin}
+                        onClose={this.close}
+                    >
+                        <Card>
+                            <div className={classes.optionsContainer}>
+                                {options.length &&
+                                    options.map((option, i) => (
+                                        <div
+                                            key={i}
+                                            className={classes.option}
+                                            onClick={() => {
+                                                option.fn(checkedList);
+                                                this.close();
+                                            }}
+                                        >
+                                            {option.name}
+                                        </div>
+                                    ))}
+                            </div>
+                        </Card>
+                    </Popover>
+                </>
+            );
+        }
+
+        open = () => {
+            this.setState({ anchorEl: this.icon.current || undefined });
+        };
+
+        close = () => {
+            this.setState(prev => ({
+                ...prev,
+                anchorEl: undefined,
+            }));
+        };
+    }
+);
index a72056d142aa110b09a43ffe0b82784d7a389e24..880868bdf8d54c4d0b24c198b07bcea7a66f3a0a 100644 (file)
@@ -4,13 +4,13 @@
 
 import React from "react";
 import { mount, configure } from "enzyme";
-import { pipe } from 'lodash/fp';
+import { pipe } from "lodash/fp";
 import { TableHead, TableCell, Typography, TableBody, Button, TableSortLabel } from "@material-ui/core";
 import Adapter from "enzyme-adapter-react-16";
 import { DataTable, DataColumns } from "./data-table";
 import { SortDirection, createDataColumn } from "./data-column";
-import { DataTableFiltersPopover } from 'components/data-table-filters/data-table-filters-popover';
-import { createTree, setNode, initTreeNode } from 'models/tree';
+import { DataTableFiltersPopover } from "components/data-table-filters/data-table-filters-popover";
+import { createTree, setNode, initTreeNode } from "models/tree";
 import { DataTableFilterItem } from "components/data-table-filters/data-table-filters-tree";
 
 configure({ adapter: new Adapter() });
@@ -22,30 +22,34 @@ describe("<DataTable />", () => {
                 name: "Column 1",
                 render: () => <span />,
                 selected: true,
-                configurable: true
+                configurable: true,
             }),
             createDataColumn({
                 name: "Column 2",
                 render: () => <span />,
                 selected: true,
-                configurable: true
+                configurable: true,
             }),
             createDataColumn({
                 name: "Column 3",
                 render: () => <span />,
                 selected: false,
-                configurable: true
+                configurable: true,
             }),
         ];
-        const dataTable = mount(<DataTable
-            columns={columns}
-            items={[{ key: "1", name: "item 1" }]}
-            onFiltersChange={jest.fn()}
-            onRowClick={jest.fn()}
-            onRowDoubleClick={jest.fn()}
-            onContextMenu={jest.fn()}
-            onSortToggle={jest.fn()} />);
-        expect(dataTable.find(TableHead).find(TableCell)).toHaveLength(2);
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                items={[{ key: "1", name: "item 1" }]}
+                onFiltersChange={jest.fn()}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onContextMenu={jest.fn()}
+                onSortToggle={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
+        expect(dataTable.find(TableHead).find(TableCell)).toHaveLength(3);
     });
 
     it("renders column name", () => {
@@ -54,18 +58,22 @@ describe("<DataTable />", () => {
                 name: "Column 1",
                 render: () => <span />,
                 selected: true,
-                configurable: true
+                configurable: true,
             }),
         ];
-        const dataTable = mount(<DataTable
-            columns={columns}
-            items={["item 1"]}
-            onFiltersChange={jest.fn()}
-            onRowClick={jest.fn()}
-            onRowDoubleClick={jest.fn()}
-            onContextMenu={jest.fn()}
-            onSortToggle={jest.fn()} />);
-        expect(dataTable.find(TableHead).find(TableCell).text()).toBe("Column 1");
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                items={["item 1"]}
+                onFiltersChange={jest.fn()}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onContextMenu={jest.fn()}
+                onSortToggle={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
+        expect(dataTable.find(TableHead).find(TableCell).last().text()).toBe("Column 1");
     });
 
     it("uses renderHeader instead of name prop", () => {
@@ -75,18 +83,22 @@ describe("<DataTable />", () => {
                 renderHeader: () => <span>Column Header</span>,
                 render: () => <span />,
                 selected: true,
-                configurable: true
+                configurable: true,
             }),
         ];
-        const dataTable = mount(<DataTable
-            columns={columns}
-            items={[]}
-            onFiltersChange={jest.fn()}
-            onRowClick={jest.fn()}
-            onRowDoubleClick={jest.fn()}
-            onContextMenu={jest.fn()}
-            onSortToggle={jest.fn()} />);
-        expect(dataTable.find(TableHead).find(TableCell).text()).toBe("Column Header");
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                items={[]}
+                onFiltersChange={jest.fn()}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onContextMenu={jest.fn()}
+                onSortToggle={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
+        expect(dataTable.find(TableHead).find(TableCell).last().text()).toBe("Column Header");
     });
 
     it("passes column key prop to corresponding cells", () => {
@@ -96,116 +108,137 @@ describe("<DataTable />", () => {
                 key: "column-1-key",
                 render: () => <span />,
                 selected: true,
-                configurable: true
-            })
+                configurable: true,
+            }),
         ];
-        const dataTable = mount(<DataTable
-            columns={columns}
-            working={false}
-            items={["item 1"]}
-            onFiltersChange={jest.fn()}
-            onRowClick={jest.fn()}
-            onRowDoubleClick={jest.fn()}
-            onContextMenu={jest.fn()}
-            onSortToggle={jest.fn()} />);
-        expect(dataTable.find(TableHead).find(TableCell).key()).toBe("column-1-key");
-        expect(dataTable.find(TableBody).find(TableCell).key()).toBe("column-1-key");
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                working={false}
+                items={["item 1"]}
+                onFiltersChange={jest.fn()}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onContextMenu={jest.fn()}
+                onSortToggle={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
+        expect(dataTable.find(TableBody).find(TableCell).last().key()).toBe("column-1-key");
     });
 
     it("renders items", () => {
         const columns: DataColumns<string, string> = [
             createDataColumn({
                 name: "Column 1",
-                render: (item) => <Typography>{item}</Typography>,
+                render: item => <Typography>{item}</Typography>,
                 selected: true,
-                configurable: true
+                configurable: true,
             }),
             createDataColumn({
                 name: "Column 2",
-                render: (item) => <Button>{item}</Button>,
+                render: item => <Button>{item}</Button>,
                 selected: true,
-                configurable: true
-            })
+                configurable: true,
+            }),
         ];
-        const dataTable = mount(<DataTable
-            columns={columns}
-            working={false}
-            items={["item 1"]}
-            onFiltersChange={jest.fn()}
-            onRowClick={jest.fn()}
-            onRowDoubleClick={jest.fn()}
-            onContextMenu={jest.fn()}
-            onSortToggle={jest.fn()} />);
-        expect(dataTable.find(TableBody).find(Typography).text()).toBe("item 1");
-        expect(dataTable.find(TableBody).find(Button).text()).toBe("item 1");
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                working={false}
+                items={["item 1"]}
+                onFiltersChange={jest.fn()}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onContextMenu={jest.fn()}
+                onSortToggle={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
+        expect(dataTable.find(TableBody).find(Typography).last().text()).toBe("item 1");
+        expect(dataTable.find(TableBody).find(Button).last().text()).toBe("item 1");
     });
 
     it("passes sorting props to <TableSortLabel />", () => {
         const columns: DataColumns<string, string> = [
             createDataColumn({
                 name: "Column 1",
-                sort: {direction: SortDirection.ASC, field: "length"},
+                sort: { direction: SortDirection.ASC, field: "length" },
                 selected: true,
                 configurable: true,
-                render: (item) => <Typography>{item}</Typography>
-            })];
+                render: item => <Typography>{item}</Typography>,
+            }),
+        ];
         const onSortToggle = jest.fn();
-        const dataTable = mount(<DataTable
-            columns={columns}
-            items={["item 1"]}
-            onFiltersChange={jest.fn()}
-            onRowClick={jest.fn()}
-            onRowDoubleClick={jest.fn()}
-            onContextMenu={jest.fn()}
-            onSortToggle={onSortToggle} />);
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                items={["item 1"]}
+                onFiltersChange={jest.fn()}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onContextMenu={jest.fn()}
+                onSortToggle={onSortToggle}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
         expect(dataTable.find(TableSortLabel).prop("active")).toBeTruthy();
         dataTable.find(TableSortLabel).at(0).simulate("click");
-        expect(onSortToggle).toHaveBeenCalledWith(columns[0]);
+        expect(onSortToggle).toHaveBeenCalledWith(columns[1]);
     });
 
     it("does not display <DataTableFiltersPopover /> if there is no filters provided", () => {
-        const columns: DataColumns<string, string> = [{
-            name: "Column 1",
-            selected: true,
-            configurable: true,
-            filters: [],
-            render: (item) => <Typography>{item}</Typography>
-        }];
+        const columns: DataColumns<string, string> = [
+            {
+                name: "Column 1",
+                selected: true,
+                configurable: true,
+                filters: [],
+                render: item => <Typography>{item}</Typography>,
+            },
+        ];
         const onFiltersChange = jest.fn();
-        const dataTable = mount(<DataTable
-            columns={columns}
-            items={[]}
-            onFiltersChange={onFiltersChange}
-            onRowClick={jest.fn()}
-            onRowDoubleClick={jest.fn()}
-            onSortToggle={jest.fn()}
-            onContextMenu={jest.fn()} />);
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                items={[]}
+                onFiltersChange={onFiltersChange}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onSortToggle={jest.fn()}
+                onContextMenu={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
         expect(dataTable.find(DataTableFiltersPopover)).toHaveLength(0);
     });
 
     it("passes filter props to <DataTableFiltersPopover />", () => {
-        const filters = pipe(
-            () => createTree<DataTableFilterItem>(),
-            setNode(initTreeNode({ id: 'filter', value: { name: 'filter' } }))
-        );
-        const columns: DataColumns<string, string> = [{
-            name: "Column 1",
-            selected: true,
-            configurable: true,
-            filters: filters(),
-            render: (item) => <Typography>{item}</Typography>
-        }];
+        const filters = pipe(() => createTree<DataTableFilterItem>(), setNode(initTreeNode({ id: "filter", value: { name: "filter" } })));
+        const columns: DataColumns<string, string> = [
+            {
+                name: "Column 1",
+                selected: true,
+                configurable: true,
+                filters: filters(),
+                render: item => <Typography>{item}</Typography>,
+            },
+        ];
         const onFiltersChange = jest.fn();
-        const dataTable = mount(<DataTable
-            columns={columns}
-            items={[]}
-            onFiltersChange={onFiltersChange}
-            onRowClick={jest.fn()}
-            onRowDoubleClick={jest.fn()}
-            onSortToggle={jest.fn()}
-            onContextMenu={jest.fn()} />);
-        expect(dataTable.find(DataTableFiltersPopover).prop("filters")).toBe(columns[0].filters);
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                items={[]}
+                onFiltersChange={onFiltersChange}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onSortToggle={jest.fn()}
+                onContextMenu={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
+        expect(dataTable.find(DataTableFiltersPopover).prop("filters")).toBe(columns[1].filters);
         dataTable.find(DataTableFiltersPopover).prop("onChange")([]);
-        expect(onFiltersChange).toHaveBeenCalledWith([], columns[0]);
+        expect(onFiltersChange).toHaveBeenCalledWith([], columns[1]);
     });
 });
index 4a82b6607c32ed44ecb77ea54bdd8a6cc1ab4a10..de3e272d1ed88f8ec2d622222bc390b61e720dad 100644 (file)
@@ -2,23 +2,39 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-import { Table, TableBody, TableRow, TableCell, TableHead, TableSortLabel, StyleRulesCallback, Theme, WithStyles, withStyles, IconButton } from '@material-ui/core';
-import classnames from 'classnames';
-import { DataColumn, SortDirection } from './data-column';
-import { DataTableDefaultView } from '../data-table-default-view/data-table-default-view';
-import { DataTableFilters } from '../data-table-filters/data-table-filters-tree';
-import { DataTableFiltersPopover } from '../data-table-filters/data-table-filters-popover';
-import { countNodes, getTreeDirty } from 'models/tree';
-import { IconType, PendingIcon } from 'components/icon/icon';
-import { SvgIconProps } from '@material-ui/core/SvgIcon';
-import ArrowDownwardIcon from '@material-ui/icons/ArrowDownward';
+import React from "react";
+import {
+    Table,
+    TableBody,
+    TableRow,
+    TableCell,
+    TableHead,
+    TableSortLabel,
+    StyleRulesCallback,
+    Theme,
+    WithStyles,
+    withStyles,
+    IconButton,
+    Tooltip,
+} from "@material-ui/core";
+import classnames from "classnames";
+import { DataColumn, SortDirection } from "./data-column";
+import { DataTableDefaultView } from "../data-table-default-view/data-table-default-view";
+import { DataTableFilters } from "../data-table-filters/data-table-filters-tree";
+import { DataTableMultiselectPopover } from "../data-table-multiselect-popover/data-table-multiselect-popover";
+import { DataTableFiltersPopover } from "../data-table-filters/data-table-filters-popover";
+import { countNodes, getTreeDirty } from "models/tree";
+import { IconType, PendingIcon } from "components/icon/icon";
+import { SvgIconProps } from "@material-ui/core/SvgIcon";
+import ArrowDownwardIcon from "@material-ui/icons/ArrowDownward";
+import { createTree } from "models/tree";
+import { DataTableMultiselectOption } from "../data-table-multiselect-popover/data-table-multiselect-popover";
 
 export type DataColumns<I, R> = Array<DataColumn<I, R>>;
 
 export enum DataTableFetchMode {
     PAGINATED,
-    INFINITE
+    INFINITE,
 }
 
 export interface DataTableDataProps<I> {
@@ -35,154 +51,358 @@ export interface DataTableDataProps<I> {
     defaultViewMessages?: string[];
     currentItemUuid?: string;
     currentRoute?: string;
+    toggleMSToolbar: (isVisible: boolean) => void;
+    setCheckedListOnStore: (checkedList: TCheckedList) => void;
+    checkedList: TCheckedList;
 }
 
-type CssRules = "tableBody" | "root" | "content" | "noItemsInfo" | 'tableCell' | 'arrow' | 'arrowButton' | 'tableCellWorkflows' | 'loader';
+type CssRules =
+    | "tableBody"
+    | "root"
+    | "content"
+    | "noItemsInfo"
+    | "checkBoxHead"
+    | "checkBoxCell"
+    | "checkBox"
+    | "firstTableCell"
+    | "tableCell"
+    | "arrow"
+    | "arrowButton"
+    | "tableCellWorkflows"
+    | "loader";
 
 const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
     root: {
-        width: '100%',
+        width: "100%",
     },
     content: {
-        display: 'inline-block',
-        width: '100%',
+        display: "inline-block",
+        width: "100%",
     },
     tableBody: {
-        background: theme.palette.background.paper
+        background: theme.palette.background.paper,
     },
     loader: {
-        left: '50%',
-        marginLeft: '-84px',
-        position: 'absolute'
+        left: "50%",
+        marginLeft: "-84px",
+        position: "absolute",
     },
     noItemsInfo: {
         textAlign: "center",
-        padding: theme.spacing.unit
+        padding: theme.spacing.unit,
+    },
+    checkBoxHead: {
+        padding: "0",
+        display: "flex",
+    },
+    checkBoxCell: {
+        padding: "0",
+        paddingLeft: "10px",
+    },
+    checkBox: {
+        cursor: "pointer",
     },
     tableCell: {
-        wordWrap: 'break-word',
-        paddingRight: '24px',
-        color: '#737373'
-
+        wordWrap: "break-word",
+        paddingRight: "24px",
+        color: "#737373",
+    },
+    firstTableCell: {
+        paddingLeft: "5px",
     },
     tableCellWorkflows: {
-        '&:nth-last-child(2)': {
-            padding: '0px',
-            maxWidth: '48px'
+        "&:nth-last-child(2)": {
+            padding: "0px",
+            maxWidth: "48px",
+        },
+        "&:last-child": {
+            padding: "0px",
+            paddingRight: "24px",
+            width: "48px",
         },
-        '&:last-child': {
-            padding: '0px',
-            paddingRight: '24px',
-            width: '48px'
-        }
     },
     arrow: {
-        margin: 0
+        margin: 0,
     },
     arrowButton: {
-        color: theme.palette.text.primary
-    }
+        color: theme.palette.text.primary,
+    },
 });
 
+export type TCheckedList = Record<string, boolean>;
+
+type DataTableState = {
+    isSelected: boolean;
+};
+
 type DataTableProps<T> = DataTableDataProps<T> & WithStyles<CssRules>;
 
 export const DataTable = withStyles(styles)(
     class Component<T> extends React.Component<DataTableProps<T>> {
+        state: DataTableState = {
+            isSelected: false,
+        };
+
+        componentDidMount(): void {
+            this.initializeCheckedList([]);
+        }
+
+        componentDidUpdate(prevProps: Readonly<DataTableProps<T>>, prevState: DataTableState) {
+            const { items, setCheckedListOnStore } = this.props;
+            const { isSelected } = this.state;
+            if (prevProps.items !== items) {
+                if (isSelected === true) this.setState({ isSelected: false });
+                if (items.length) this.initializeCheckedList(items);
+                else setCheckedListOnStore({});
+            }
+            if (prevProps.currentRoute !== this.props.currentRoute) {
+                this.initializeCheckedList([])
+            }
+        }
+
+        componentWillUnmount(): void {
+            this.initializeCheckedList([])
+        }
+
+        checkBoxColumn: DataColumn<any, any> = {
+            name: "checkBoxColumn",
+            selected: true,
+            configurable: false,
+            filters: createTree(),
+            render: uuid => {
+                const { classes, checkedList } = this.props;
+                return (
+                    <input
+                        data-cy={`multiselect-checkbox-${uuid}`}
+                        type="checkbox"
+                        name={uuid}
+                        className={classes.checkBox}
+                        checked={checkedList && checkedList[uuid] ? checkedList[uuid] : false}
+                        onChange={() => this.handleSelectOne(uuid)}
+                        onDoubleClick={ev => ev.stopPropagation()}></input>
+                );
+            },
+        };
+
+        multiselectOptions: DataTableMultiselectOption[] = [
+            { name: "All", fn: list => this.handleSelectAll(list) },
+            { name: "None", fn: list => this.handleSelectNone(list) },
+            { name: "Invert", fn: list => this.handleInvertSelect(list) },
+        ];
+
+        initializeCheckedList = (uuids: any[]): void => {
+            const newCheckedList = { ...this.props.checkedList };
+
+            uuids.forEach(uuid => {
+                if (!newCheckedList.hasOwnProperty(uuid)) {
+                    newCheckedList[uuid] = false;
+                }
+            });
+            for (const key in newCheckedList) {
+                if (!uuids.includes(key)) {
+                    delete newCheckedList[key];
+                }
+            }
+            this.props.setCheckedListOnStore(newCheckedList);
+        };
+
+        isAllSelected = (list: TCheckedList): boolean => {
+            for (const key in list) {
+                if (list[key] === false) return false;
+            }
+            return true;
+        };
+
+        isAnySelected = (): boolean => {
+            const { checkedList } = this.props;
+            if (!Object.keys(checkedList).length) return false;
+            for (const key in checkedList) {
+                if (checkedList[key] === true) return true;
+            }
+            return false;
+        };
+
+        handleSelectOne = (uuid: string): void => {
+            const { checkedList } = this.props;
+            const newCheckedList = { ...checkedList };
+            newCheckedList[uuid] = !checkedList[uuid];
+            this.setState({ isSelected: this.isAllSelected(newCheckedList) });
+            this.props.setCheckedListOnStore(newCheckedList);
+        };
+
+        handleSelectorSelect = (): void => {
+            const { checkedList } = this.props;
+            const { isSelected } = this.state;
+            isSelected ? this.handleSelectNone(checkedList) : this.handleSelectAll(checkedList);
+        };
+
+        handleSelectAll = (list: TCheckedList): void => {
+            if (Object.keys(list).length) {
+                const newCheckedList = { ...list };
+                for (const key in newCheckedList) {
+                    newCheckedList[key] = true;
+                }
+                this.setState({ isSelected: true });
+                this.props.setCheckedListOnStore(newCheckedList);
+            }
+        };
+
+        handleSelectNone = (list: TCheckedList): void => {
+            const newCheckedList = { ...list };
+            for (const key in newCheckedList) {
+                newCheckedList[key] = false;
+            }
+            this.setState({ isSelected: false });
+            this.props.setCheckedListOnStore(newCheckedList);
+        };
+
+        handleInvertSelect = (list: TCheckedList): void => {
+            if (Object.keys(list).length) {
+                const newCheckedList = { ...list };
+                for (const key in newCheckedList) {
+                    newCheckedList[key] = !list[key];
+                }
+                this.setState({ isSelected: this.isAllSelected(newCheckedList) });
+                this.props.setCheckedListOnStore(newCheckedList);
+            }
+        };
+
         render() {
-            const { items, classes, working } = this.props;
-            return <div className={classes.root}>
-                <div className={classes.content}>
-                    <Table>
-                        <TableHead>
-                            <TableRow>
-                                {this.mapVisibleColumns(this.renderHeadCell)}
-                            </TableRow>
-                        </TableHead>
-                        <TableBody className={classes.tableBody}>
-                            { !working && items.map(this.renderBodyRow) }
-                        </TableBody>
-                    </Table>
-                    { !!working &&
-                        <div className={classes.loader}>
-                            <DataTableDefaultView
-                                icon={PendingIcon}
-                                messages={['Loading data, please wait.']} />
-                        </div> }
-                    {items.length === 0 && !working && this.renderNoItemsPlaceholder(this.props.columns)}
+            const { items, classes, working, columns } = this.props;
+            if (columns[0].name === this.checkBoxColumn.name) columns.shift();
+            columns.unshift(this.checkBoxColumn);
+            return (
+                <div className={classes.root}>
+                    <div className={classes.content}>
+                        <Table>
+                            <TableHead>
+                                <TableRow>{this.mapVisibleColumns(this.renderHeadCell)}</TableRow>
+                            </TableHead>
+                            <TableBody className={classes.tableBody}>{!working && items.map(this.renderBodyRow)}</TableBody>
+                        </Table>
+                        {!!working && (
+                            <div className={classes.loader}>
+                                <DataTableDefaultView
+                                    icon={PendingIcon}
+                                    messages={["Loading data, please wait."]}
+                                />
+                            </div>
+                        )}
+                        {items.length === 0 && !working && this.renderNoItemsPlaceholder(this.props.columns)}
+                    </div>
                 </div>
-            </div>;
+            );
         }
 
         renderNoItemsPlaceholder = (columns: DataColumns<T, any>) => {
-            const dirty = columns.some((column) => getTreeDirty('')(column.filters));
-            return <DataTableDefaultView
-                icon={this.props.defaultViewIcon}
-                messages={this.props.defaultViewMessages}
-                filtersApplied={dirty} />;
-        }
+            const dirty = columns.some(column => getTreeDirty("")(column.filters));
+            return (
+                <DataTableDefaultView
+                    icon={this.props.defaultViewIcon}
+                    messages={this.props.defaultViewMessages}
+                    filtersApplied={dirty}
+                />
+            );
+        };
 
         renderHeadCell = (column: DataColumn<T, any>, index: number) => {
             const { name, key, renderHeader, filters, sort } = column;
-            const { onSortToggle, onFiltersChange, classes } = this.props;
-            return <TableCell className={classes.tableCell} key={key || index}>
-                {renderHeader ?
-                    renderHeader() :
-                    countNodes(filters) > 0
-                        ? <DataTableFiltersPopover
+            const { onSortToggle, onFiltersChange, classes, checkedList } = this.props;
+            const { isSelected } = this.state;
+            return column.name === "checkBoxColumn" ? (
+                <TableCell
+                    key={key || index}
+                    className={classes.checkBoxCell}>
+                    <div className={classes.checkBoxHead}>
+                        <Tooltip title={this.state.isSelected ? "Deselect All" : "Select All"}>
+                            <input
+                                type="checkbox"
+                                className={classes.checkBox}
+                                checked={isSelected}
+                                disabled={!this.props.items.length}
+                                onChange={this.handleSelectorSelect}></input>
+                        </Tooltip>
+                        <DataTableMultiselectPopover
+                            name={`Options`}
+                            disabled={!this.props.items.length}
+                            options={this.multiselectOptions}
+                            checkedList={checkedList}></DataTableMultiselectPopover>
+                    </div>
+                </TableCell>
+            ) : (
+                <TableCell
+                    className={index === 1 ? classes.firstTableCell : classes.tableCell}
+                    key={key || index}>
+                    {renderHeader ? (
+                        renderHeader()
+                    ) : countNodes(filters) > 0 ? (
+                        <DataTableFiltersPopover
                             name={`${name} filters`}
                             mutuallyExclusive={column.mutuallyExclusiveFilters}
-                            onChange={filters =>
-                                onFiltersChange &&
-                                onFiltersChange(filters, column)}
+                            onChange={filters => onFiltersChange && onFiltersChange(filters, column)}
                             filters={filters}>
                             {name}
                         </DataTableFiltersPopover>
-                        : sort
-                            ? <TableSortLabel
-                                active={sort.direction !== SortDirection.NONE}
-                                direction={sort.direction !== SortDirection.NONE ? sort.direction : undefined}
-                                IconComponent={this.ArrowIcon}
-                                hideSortIcon
-                                onClick={() =>
-                                    onSortToggle &&
-                                    onSortToggle(column)}>
-                                {name}
-                            </TableSortLabel>
-                            : <span>
-                                {name}
-                            </span>}
-            </TableCell>;
-        }
+                    ) : sort ? (
+                        <TableSortLabel
+                            active={sort.direction !== SortDirection.NONE}
+                            direction={sort.direction !== SortDirection.NONE ? sort.direction : undefined}
+                            IconComponent={this.ArrowIcon}
+                            hideSortIcon
+                            onClick={() => onSortToggle && onSortToggle(column)}>
+                            {name}
+                        </TableSortLabel>
+                    ) : (
+                        <span>{name}</span>
+                    )}
+                </TableCell>
+            );
+        };
 
         ArrowIcon = ({ className, ...props }: SvgIconProps) => (
-            <IconButton component='span' className={this.props.classes.arrowButton} tabIndex={-1}>
-                <ArrowDownwardIcon {...props} className={classnames(className, this.props.classes.arrow)} />
+            <IconButton
+                component="span"
+                className={this.props.classes.arrowButton}
+                tabIndex={-1}>
+                <ArrowDownwardIcon
+                    {...props}
+                    className={classnames(className, this.props.classes.arrow)}
+                />
             </IconButton>
-        )
+        );
 
         renderBodyRow = (item: any, index: number) => {
             const { onRowClick, onRowDoubleClick, extractKey, classes, currentItemUuid, currentRoute } = this.props;
-            return <TableRow
-                hover
-                key={extractKey ? extractKey(item) : index}
-                onClick={event => onRowClick && onRowClick(event, item)}
-                onContextMenu={this.handleRowContextMenu(item)}
-                onDoubleClick={event => onRowDoubleClick && onRowDoubleClick(event, item)}
-                selected={item === currentItemUuid}>
-                {this.mapVisibleColumns((column, index) => <TableCell key={column.key || index} className={currentRoute === '/workflows' ? classes.tableCellWorkflows : classes.tableCell}>
-                        {column.render(item)}
-                    </TableCell>
-                )}
-            </TableRow>;
-        }
+            return (
+                <TableRow
+                    data-cy={'data-table-row'}
+                    hover
+                    key={extractKey ? extractKey(item) : index}
+                    onClick={event => onRowClick && onRowClick(event, item)}
+                    onContextMenu={this.handleRowContextMenu(item)}
+                    onDoubleClick={event => onRowDoubleClick && onRowDoubleClick(event, item)}
+                    selected={item === currentItemUuid}>
+                    {this.mapVisibleColumns((column, index) => (
+                        <TableCell
+                            key={column.key || index}
+                            className={
+                                currentRoute === "/workflows"
+                                    ? classes.tableCellWorkflows
+                                    : index === 0
+                                    ? classes.checkBoxCell
+                                    : `${classes.tableCell} ${index === 1 ? classes.firstTableCell : ""}`
+                            }>
+                            {column.render(item)}
+                        </TableCell>
+                    ))}
+                </TableRow>
+            );
+        };
 
         mapVisibleColumns = (fn: (column: DataColumn<T, any>, index: number) => React.ReactElement<any>) => {
             return this.props.columns.filter(column => column.selected).map(fn);
-        }
-
-        handleRowContextMenu = (item: T) =>
-            (event: React.MouseEvent<HTMLElement>) =>
-                this.props.onContextMenu(event, item)
+        };
 
+        handleRowContextMenu = (item: T) => (event: React.MouseEvent<HTMLElement>) => this.props.onContextMenu(event, item);
     }
 );
index bb661bc288b3ec0bb1c30bc02f67f41c255174ea..39cce0483496237bbd65202e6da4955836c8b687 100644 (file)
@@ -2,11 +2,11 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-import Menu from '@material-ui/core/Menu';
-import IconButton from '@material-ui/core/IconButton';
-import { PopoverOrigin } from '@material-ui/core/Popover';
-import { Tooltip } from '@material-ui/core';
+import React from "react";
+import Menu from "@material-ui/core/Menu";
+import IconButton from "@material-ui/core/IconButton";
+import { PopoverOrigin } from "@material-ui/core/Popover";
+import { Tooltip } from "@material-ui/core";
 
 interface DropdownMenuProps {
     id: string;
@@ -20,12 +20,12 @@ interface DropdownMenuState {
 
 export class DropdownMenu extends React.Component<DropdownMenuProps, DropdownMenuState> {
     state = {
-        anchorEl: undefined
+        anchorEl: undefined,
     };
 
     transformOrigin: PopoverOrigin = {
         vertical: -50,
-        horizontal: 0
+        horizontal: 0,
     };
 
     render() {
@@ -33,7 +33,9 @@ export class DropdownMenu extends React.Component<DropdownMenuProps, DropdownMen
         const { anchorEl } = this.state;
         return (
             <div>
-                <Tooltip title={title}>
+                <Tooltip
+                    title={title}
+                    disableFocusListener>
                     <IconButton
                         aria-owns={anchorEl ? id : undefined}
                         aria-haspopup="true"
@@ -57,9 +59,9 @@ export class DropdownMenu extends React.Component<DropdownMenuProps, DropdownMen
 
     handleClose = () => {
         this.setState({ anchorEl: undefined });
-    }
+    };
 
     handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
         this.setState({ anchorEl: event.currentTarget });
-    }
+    };
 }
index 20b87b20515e90de0fdaf281a5d9b75b25a789a1..2dd97c1663777cc5d64a7540351d8d8dfeef5b52 100644 (file)
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-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';
-import Build from '@material-ui/icons/Build';
-import Cached from '@material-ui/icons/Cached';
-import DescriptionIcon from '@material-ui/icons/Description';
-import ChevronLeft from '@material-ui/icons/ChevronLeft';
-import CloudUpload from '@material-ui/icons/CloudUpload';
-import Code from '@material-ui/icons/Code';
-import Create from '@material-ui/icons/Create';
-import ImportContacts from '@material-ui/icons/ImportContacts';
-import ChevronRight from '@material-ui/icons/ChevronRight';
-import Close from '@material-ui/icons/Close';
-import ContentCopy from '@material-ui/icons/FileCopyOutlined';
-import CreateNewFolder from '@material-ui/icons/CreateNewFolder';
-import Delete from '@material-ui/icons/Delete';
-import DeviceHub from '@material-ui/icons/DeviceHub';
-import Edit from '@material-ui/icons/Edit';
-import ErrorRoundedIcon from '@material-ui/icons/ErrorRounded';
-import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
-import FlipToFront from '@material-ui/icons/FlipToFront';
-import Folder from '@material-ui/icons/Folder';
-import FolderShared from '@material-ui/icons/FolderShared';
-import Pageview from '@material-ui/icons/Pageview';
-import GetApp from '@material-ui/icons/GetApp';
-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 Memory from '@material-ui/icons/Memory';
-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';
-import LastPage from '@material-ui/icons/LastPage';
-import LibraryBooks from '@material-ui/icons/LibraryBooks';
-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 Notifications from '@material-ui/icons/Notifications';
-import OpenInNew from '@material-ui/icons/OpenInNew';
-import People from '@material-ui/icons/People';
-import Person from '@material-ui/icons/Person';
-import PersonAdd from '@material-ui/icons/PersonAdd';
-import PlayArrow from '@material-ui/icons/PlayArrow';
-import Public from '@material-ui/icons/Public';
-import RateReview from '@material-ui/icons/RateReview';
-import RestoreFromTrash from '@material-ui/icons/History';
-import Search from '@material-ui/icons/Search';
-import SettingsApplications from '@material-ui/icons/SettingsApplications';
-import SettingsEthernet from '@material-ui/icons/SettingsEthernet';
-import Settings from '@material-ui/icons/Settings';
-import Star from '@material-ui/icons/Star';
-import StarBorder from '@material-ui/icons/StarBorder';
-import Warning from '@material-ui/icons/Warning';
-import VpnKey from '@material-ui/icons/VpnKey';
-import LinkOutlined from '@material-ui/icons/LinkOutlined';
-import RemoveRedEye from '@material-ui/icons/RemoveRedEye';
-import Computer from '@material-ui/icons/Computer';
-import WrapText from '@material-ui/icons/WrapText';
-import TextIncrease from '@material-ui/icons/ZoomIn';
-import TextDecrease from '@material-ui/icons/ZoomOut';
-import FullscreenSharp from '@material-ui/icons/FullscreenSharp';
-import FullscreenExitSharp from '@material-ui/icons/FullscreenExitSharp';
-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 Stop from '@material-ui/icons/Stop';
+import React from "react";
+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";
+import Build from "@material-ui/icons/Build";
+import Cached from "@material-ui/icons/Cached";
+import DescriptionIcon from "@material-ui/icons/Description";
+import ChevronLeft from "@material-ui/icons/ChevronLeft";
+import CloudUpload from "@material-ui/icons/CloudUpload";
+import Code from "@material-ui/icons/Code";
+import Create from "@material-ui/icons/Create";
+import ImportContacts from "@material-ui/icons/ImportContacts";
+import ChevronRight from "@material-ui/icons/ChevronRight";
+import Close from "@material-ui/icons/Close";
+import ContentCopy from "@material-ui/icons/FileCopyOutlined";
+import CreateNewFolder from "@material-ui/icons/CreateNewFolder";
+import Delete from "@material-ui/icons/Delete";
+import DeviceHub from "@material-ui/icons/DeviceHub";
+import Edit from "@material-ui/icons/Edit";
+import ErrorRoundedIcon from "@material-ui/icons/ErrorRounded";
+import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
+import FlipToFront from "@material-ui/icons/FlipToFront";
+import Folder from "@material-ui/icons/Folder";
+import FolderShared from "@material-ui/icons/FolderShared";
+import Pageview from "@material-ui/icons/Pageview";
+import GetApp from "@material-ui/icons/GetApp";
+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 Memory from "@material-ui/icons/Memory";
+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";
+import LastPage from "@material-ui/icons/LastPage";
+import LibraryBooks from "@material-ui/icons/LibraryBooks";
+import ListAlt from "@material-ui/icons/ListAlt";
+import Menu from "@material-ui/icons/Menu";
+import MoreVert from "@material-ui/icons/MoreVert";
+import MoreHoriz from "@material-ui/icons/MoreHoriz";
+import Mail from "@material-ui/icons/Mail";
+import Notifications from "@material-ui/icons/Notifications";
+import OpenInNew from "@material-ui/icons/OpenInNew";
+import People from "@material-ui/icons/People";
+import Person from "@material-ui/icons/Person";
+import PersonAdd from "@material-ui/icons/PersonAdd";
+import PlayArrow from "@material-ui/icons/PlayArrow";
+import Public from "@material-ui/icons/Public";
+import RateReview from "@material-ui/icons/RateReview";
+import RestoreFromTrash from "@material-ui/icons/History";
+import Search from "@material-ui/icons/Search";
+import SettingsApplications from "@material-ui/icons/SettingsApplications";
+import SettingsEthernet from "@material-ui/icons/SettingsEthernet";
+import Settings from "@material-ui/icons/Settings";
+import Star from "@material-ui/icons/Star";
+import StarBorder from "@material-ui/icons/StarBorder";
+import Warning from "@material-ui/icons/Warning";
+import VpnKey from "@material-ui/icons/VpnKey";
+import LinkOutlined from "@material-ui/icons/LinkOutlined";
+import RemoveRedEye from "@material-ui/icons/RemoveRedEye";
+import Computer from "@material-ui/icons/Computer";
+import WrapText from "@material-ui/icons/WrapText";
+import TextIncrease from "@material-ui/icons/ZoomIn";
+import TextDecrease from "@material-ui/icons/ZoomOut";
+import FullscreenSharp from "@material-ui/icons/FullscreenSharp";
+import FullscreenExitSharp from "@material-ui/icons/FullscreenExitSharp";
+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 Stop from "@material-ui/icons/Stop";
+import FileCopy from "@material-ui/icons/FileCopy";
 
 // Import FontAwesome icons
-import { library } from '@fortawesome/fontawesome-svg-core';
-import { faPencilAlt, faSlash, faUsers, faEllipsisH } from '@fortawesome/free-solid-svg-icons';
-import { FormatAlignLeft } from '@material-ui/icons';
-library.add(
-    faPencilAlt,
-    faSlash,
-    faUsers,
-    faEllipsisH,
-);
+import { library } from "@fortawesome/fontawesome-svg-core";
+import { faPencilAlt, faSlash, faUsers, faEllipsisH } from "@fortawesome/free-solid-svg-icons";
+import { FormatAlignLeft } from "@material-ui/icons";
+library.add(faPencilAlt, faSlash, faUsers, faEllipsisH);
 
-export const FreezeIcon = (props: any) =>
+export const FreezeIcon: IconType = (props: any) => (
     <SvgIcon {...props}>
         <path d="M20.79,13.95L18.46,14.57L16.46,13.44V10.56L18.46,9.43L20.79,10.05L21.31,8.12L19.54,7.65L20,5.88L18.07,5.36L17.45,7.69L15.45,8.82L13,7.38V5.12L14.71,3.41L13.29,2L12,3.29L10.71,2L9.29,3.41L11,5.12V7.38L8.5,8.82L6.5,7.69L5.92,5.36L4,5.88L4.47,7.65L2.7,8.12L3.22,10.05L5.55,9.43L7.55,10.56V13.45L5.55,14.58L3.22,13.96L2.7,15.89L4.47,16.36L4,18.12L5.93,18.64L6.55,16.31L8.55,15.18L11,16.62V18.88L9.29,20.59L10.71,22L12,20.71L13.29,22L14.7,20.59L13,18.88V16.62L15.5,15.17L17.5,16.3L18.12,18.63L20,18.12L19.53,16.35L21.3,15.88L20.79,13.95M9.5,10.56L12,9.11L14.5,10.56V13.44L12,14.89L9.5,13.44V10.56Z" />
     </SvgIcon>
+);
 
-export const UnfreezeIcon = (props: any) =>
+export const UnfreezeIcon: IconType = (props: any) => (
     <SvgIcon {...props}>
         <path d="M11 5.12L9.29 3.41L10.71 2L12 3.29L13.29 2L14.71 3.41L13 5.12V7.38L15.45 8.82L17.45 7.69L18.07 5.36L20 5.88L19.54 7.65L21.31 8.12L20.79 10.05L18.46 9.43L16.46 10.56V13.26L14.5 11.3V10.56L12.74 9.54L10.73 7.53L11 7.38V5.12M18.46 14.57L16.87 13.67L19.55 16.35L21.3 15.88L20.79 13.95L18.46 14.57M13 16.62V18.88L14.7 20.59L13.29 22L12 20.71L10.71 22L9.29 20.59L11 18.88V16.62L8.55 15.18L6.55 16.31L5.93 18.64L4 18.12L4.47 16.36L2.7 15.89L3.22 13.96L5.55 14.58L7.55 13.45V10.56L5.55 9.43L3.22 10.05L2.7 8.12L4.47 7.65L4 5.89L1.11 3L2.39 1.73L22.11 21.46L20.84 22.73L14.1 16L13 16.62M12 14.89L12.63 14.5L9.5 11.39V13.44L12 14.89Z" />
     </SvgIcon>
+);
 
-export const PendingIcon = (props: any) =>
+export const PendingIcon = (props: any) => (
     <span {...props}>
-        <span className='fas fa-ellipsis-h' />
+        <span className="fas fa-ellipsis-h" />
     </span>
+);
 
-export const ReadOnlyIcon = (props: any) =>
+export const ReadOnlyIcon = (props: any) => (
     <span {...props}>
         <div className="fa-layers fa-1x fa-fw">
-            <span className="fas fa-slash"
-                data-fa-mask="fas fa-pencil-alt" data-fa-transform="down-1.5" />
+            <span
+                className="fas fa-slash"
+                data-fa-mask="fas fa-pencil-alt"
+                data-fa-transform="down-1.5"
+            />
             <span className="fas fa-slash" />
         </div>
-    </span>;
+    </span>
+);
 
-export const GroupsIcon = (props: any) =>
+export const GroupsIcon = (props: any) => (
     <span {...props}>
         <span className="fas fa-users" />
-    </span>;
+    </span>
+);
 
-export const CollectionOldVersionIcon = (props: any) =>
-    <Tooltip title='Old version'>
-        <Badge badgeContent={<History fontSize='small' />}>
+export const CollectionOldVersionIcon = (props: any) => (
+    <Tooltip title="Old version">
+        <Badge badgeContent={<History fontSize="small" />}>
             <CollectionIcon {...props} />
         </Badge>
-    </Tooltip>;
+    </Tooltip>
+);
 
 // https://materialdesignicons.com/icon/image-off
-export const ImageOffIcon = (props: any) =>
+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>;
+    </SvgIcon>
+);
 
 // https://materialdesignicons.com/icon/inbox-arrow-up
-export const OutputIcon: IconType = (props: any) =>
+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} />;
-export const AddFavoriteIcon: IconType = (props) => <StarBorder {...props} />;
-export const AdminMenuIcon: IconType = (props) => <Build {...props} />;
-export const AdvancedIcon: IconType = (props) => <SettingsApplications {...props} />;
-export const AttributesIcon: IconType = (props) => <ListAlt {...props} />;
-export const BackIcon: IconType = (props) => <ArrowBack {...props} />;
-export const CustomizeTableIcon: IconType = (props) => <Menu {...props} />;
-export const CommandIcon: IconType = (props) => <LastPage {...props} />;
-export const CopyIcon: IconType = (props) => <ContentCopy {...props} />;
-export const CollectionIcon: IconType = (props) => <LibraryBooks {...props} />;
-export const CloseIcon: IconType = (props) => <Close {...props} />;
-export const CloudUploadIcon: IconType = (props) => <CloudUpload {...props} />;
-export const DefaultIcon: IconType = (props) => <RateReview {...props} />;
-export const DetailsIcon: IconType = (props) => <Info {...props} />;
-export const DirectoryIcon: IconType = (props) => <Folder {...props} />;
-export const DownloadIcon: IconType = (props) => <GetApp {...props} />;
-export const EditSavedQueryIcon: IconType = (props) => <Create {...props} />;
-export const ExpandIcon: IconType = (props) => <ExpandMoreIcon {...props} />;
-export const ErrorIcon: IconType = (props) => <ErrorRoundedIcon style={{ color: '#ff0000' }} {...props} />;
-export const FavoriteIcon: IconType = (props) => <Star {...props} />;
-export const FileIcon: IconType = (props) => <DescriptionIcon {...props} />;
-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 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} />;
-export const MaximizeIcon: IconType = (props) => <FullscreenSharp {...props} />;
-export const MemoryIcon: IconType = (props) => <Memory {...props} />;
-export const UnMaximizeIcon: IconType = (props) => <FullscreenExitSharp {...props} />;
-export const MoreOptionsIcon: IconType = (props) => <MoreVert {...props} />;
-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 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} />;
-export const ProcessIcon: IconType = (props) => <Settings {...props} />;
-export const ProjectIcon: IconType = (props) => <Folder {...props} />;
-export const FilterGroupIcon: IconType = (props) => <Pageview {...props} />;
-export const ProjectsIcon: IconType = (props) => <Inbox {...props} />;
-export const ProvenanceGraphIcon: IconType = (props) => <DeviceHub {...props} />;
-export const RemoveIcon: IconType = (props) => <Delete {...props} />;
-export const RemoveFavoriteIcon: IconType = (props) => <Star {...props} />;
-export const PublicFavoriteIcon: IconType = (props) => <Public {...props} />;
-export const RenameIcon: IconType = (props) => <Edit {...props} />;
-export const RestoreVersionIcon: IconType = (props) => <FlipToFront {...props} />;
-export const RestoreFromTrashIcon: IconType = (props) => <RestoreFromTrash {...props} />;
-export const ReRunProcessIcon: IconType = (props) => <Cached {...props} />;
-export const SearchIcon: IconType = (props) => <Search {...props} />;
-export const ShareIcon: IconType = (props) => <PersonAdd {...props} />;
-export const ShareMeIcon: IconType = (props) => <People {...props} />;
-export const SidePanelRightArrowIcon: IconType = (props) => <PlayArrow {...props} />;
-export const TrashIcon: IconType = (props) => <Delete {...props} />;
-export const UserPanelIcon: IconType = (props) => <Person {...props} />;
-export const UsedByIcon: IconType = (props) => <Folder {...props} />;
-export const WorkflowIcon: IconType = (props) => <Code {...props} />;
-export const WarningIcon: IconType = (props) => <Warning style={{ color: '#fbc02d', height: '30px', width: '30px' }} {...props} />;
-export const Link: IconType = (props) => <LinkOutlined {...props} />;
-export const FolderSharedIcon: IconType = (props) => <FolderShared {...props} />;
-export const CanReadIcon: IconType = (props) => <RemoveRedEye {...props} />;
-export const CanWriteIcon: IconType = (props) => <Edit {...props} />;
-export const CanManageIcon: IconType = (props) => <Computer {...props} />;
-export const AddUserIcon: IconType = (props) => <PersonAdd {...props} />;
-export const WordWrapOnIcon: IconType = (props) => <WrapText {...props} />;
-export const WordWrapOffIcon: IconType = (props) => <FormatAlignLeft {...props} />;
-export const TextIncreaseIcon: IconType = (props) => <TextIncrease {...props} />;
-export const TextDecreaseIcon: IconType = (props) => <TextDecrease {...props} />;
-export const DeactivateUserIcon: IconType = (props) => <NotInterested {...props} />;
-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} />;
-export const StartIcon: IconType = (props) => <PlayArrow {...props} />;
-export const StopIcon: IconType = (props) => <Stop {...props} />;
+    </SvgIcon>
+);
+
+// https://pictogrammers.com/library/mdi/icon/file-move/
+export const FileMoveIcon: IconType = (props: any) => (
+    <SvgIcon {...props}>
+        <path d="M14,17H18V14L23,18.5L18,23V20H14V17M13,9H18.5L13,3.5V9M6,2H14L20,8V12.34C19.37,12.12 18.7,12 18,12A6,6 0 0,0 12,18C12,19.54 12.58,20.94 13.53,22H6C4.89,22 4,21.1 4,20V4A2,2 0 0,1 6,2Z" />
+    </SvgIcon>
+);
+
+// https://pictogrammers.com/library/mdi/icon/checkbox-multiple-outline/
+export const CheckboxMultipleOutline: IconType = (props: any) => (
+    <SvgIcon {...props}>
+        <path d="M20,2H8A2,2 0 0,0 6,4V16A2,2 0 0,0 8,18H20A2,2 0 0,0 22,16V4A2,2 0 0,0 20,2M20,16H8V4H20V16M16,20V22H4A2,2 0 0,1 2,20V7H4V20H16M18.53,8.06L17.47,7L12.59,11.88L10.47,9.76L9.41,10.82L12.59,14L18.53,8.06Z" />
+    </SvgIcon>
+);
+
+// https://pictogrammers.com/library/mdi/icon/checkbox-multiple-blank-outline/
+export const CheckboxMultipleBlankOutline: IconType = (props: any) => (
+    <SvgIcon {...props}>
+        <path d="M20,16V4H8V16H20M22,16A2,2 0 0,1 20,18H8C6.89,18 6,17.1 6,16V4C6,2.89 6.89,2 8,2H20A2,2 0 0,1 22,4V16M16,20V22H4A2,2 0 0,1 2,20V7H4V20H16Z" />
+    </SvgIcon>
+);
+
+//https://pictogrammers.com/library/mdi/icon/console/
+export const TerminalIcon: IconType = (props: any) => (
+    <SvgIcon {...props}>
+        <path d="M20,19V7H4V19H20M20,3A2,2 0 0,1 22,5V19A2,2 0 0,1 20,21H4A2,2 0 0,1 2,19V5C2,3.89 2.9,3 4,3H20M13,17V15H18V17H13M9.58,13L5.57,9H8.4L11.7,12.3C12.09,12.69 12.09,13.33 11.7,13.72L8.42,17H5.59L9.58,13Z" />
+    </SvgIcon>
+)
+
+export type IconType = React.SFC<{ className?: string; style?: object }>;
+
+export const AddIcon: IconType = props => <Add {...props} />;
+export const AddFavoriteIcon: IconType = props => <StarBorder {...props} />;
+export const AdminMenuIcon: IconType = props => <Build {...props} />;
+export const AdvancedIcon: IconType = props => <SettingsApplications {...props} />;
+export const AttributesIcon: IconType = props => <ListAlt {...props} />;
+export const BackIcon: IconType = props => <ArrowBack {...props} />;
+export const CustomizeTableIcon: IconType = props => <Menu {...props} />;
+export const CommandIcon: IconType = props => <LastPage {...props} />;
+export const CopyIcon: IconType = props => <ContentCopy {...props} />;
+export const FileCopyIcon: IconType = props => <FileCopy {...props} />;
+export const CollectionIcon: IconType = props => <LibraryBooks {...props} />;
+export const CloseIcon: IconType = props => <Close {...props} />;
+export const CloudUploadIcon: IconType = props => <CloudUpload {...props} />;
+export const DefaultIcon: IconType = props => <RateReview {...props} />;
+export const DetailsIcon: IconType = props => <Info {...props} />;
+export const DirectoryIcon: IconType = props => <Folder {...props} />;
+export const DownloadIcon: IconType = props => <GetApp {...props} />;
+export const EditSavedQueryIcon: IconType = props => <Create {...props} />;
+export const ExpandIcon: IconType = props => <ExpandMoreIcon {...props} />;
+export const ErrorIcon: IconType = props => (
+    <ErrorRoundedIcon
+        style={{ color: "#ff0000" }}
+        {...props}
+    />
+);
+export const FavoriteIcon: IconType = props => <Star {...props} />;
+export const FileIcon: IconType = props => <DescriptionIcon {...props} />;
+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 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} />;
+export const MaximizeIcon: IconType = props => <FullscreenSharp {...props} />;
+export const MemoryIcon: IconType = props => <Memory {...props} />;
+export const UnMaximizeIcon: IconType = props => <FullscreenExitSharp {...props} />;
+export const MoreVerticalIcon: IconType = props => <MoreVert {...props} />;
+export const MoreHorizontalIcon: IconType = props => <MoreHoriz {...props} />;
+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 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} />;
+export const ProcessIcon: IconType = props => <Settings {...props} />;
+export const ProjectIcon: IconType = props => <Folder {...props} />;
+export const FilterGroupIcon: IconType = props => <Pageview {...props} />;
+export const ProjectsIcon: IconType = props => <Inbox {...props} />;
+export const ProvenanceGraphIcon: IconType = props => <DeviceHub {...props} />;
+export const RemoveIcon: IconType = props => <Delete {...props} />;
+export const RemoveFavoriteIcon: IconType = props => <Star {...props} />;
+export const PublicFavoriteIcon: IconType = props => <Public {...props} />;
+export const RenameIcon: IconType = props => <Edit {...props} />;
+export const RestoreVersionIcon: IconType = props => <FlipToFront {...props} />;
+export const RestoreFromTrashIcon: IconType = props => <RestoreFromTrash {...props} />;
+export const ReRunProcessIcon: IconType = props => <Cached {...props} />;
+export const SearchIcon: IconType = props => <Search {...props} />;
+export const ShareIcon: IconType = props => <PersonAdd {...props} />;
+export const ShareMeIcon: IconType = props => <People {...props} />;
+export const SidePanelRightArrowIcon: IconType = props => <PlayArrow {...props} />;
+export const TrashIcon: IconType = props => <Delete {...props} />;
+export const UserPanelIcon: IconType = props => <Person {...props} />;
+export const UsedByIcon: IconType = props => <Folder {...props} />;
+export const WorkflowIcon: IconType = props => <Code {...props} />;
+export const WarningIcon: IconType = props => (
+    <Warning
+        style={{ color: "#fbc02d", height: "30px", width: "30px" }}
+        {...props}
+    />
+);
+export const Link: IconType = props => <LinkOutlined {...props} />;
+export const FolderSharedIcon: IconType = props => <FolderShared {...props} />;
+export const CanReadIcon: IconType = props => <RemoveRedEye {...props} />;
+export const CanWriteIcon: IconType = props => <Edit {...props} />;
+export const CanManageIcon: IconType = props => <Computer {...props} />;
+export const AddUserIcon: IconType = props => <PersonAdd {...props} />;
+export const WordWrapOnIcon: IconType = props => <WrapText {...props} />;
+export const WordWrapOffIcon: IconType = props => <FormatAlignLeft {...props} />;
+export const TextIncreaseIcon: IconType = props => <TextIncrease {...props} />;
+export const TextDecreaseIcon: IconType = props => <TextDecrease {...props} />;
+export const DeactivateUserIcon: IconType = props => <NotInterested {...props} />;
+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} />;
+export const StartIcon: IconType = props => <PlayArrow {...props} />;
+export const StopIcon: IconType = props => <Stop {...props} />;
+export const SelectAllIcon: IconType = props => <CheckboxMultipleOutline {...props} />;
+export const SelectNoneIcon: IconType = props => <CheckboxMultipleBlankOutline {...props} />;
diff --git a/src/components/multiselect-toolbar/MultiselectToolbar.tsx b/src/components/multiselect-toolbar/MultiselectToolbar.tsx
new file mode 100644 (file)
index 0000000..f92c0dc
--- /dev/null
@@ -0,0 +1,362 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { useEffect, useState } from "react";
+import { connect } from "react-redux";
+import { StyleRulesCallback, withStyles, WithStyles, Toolbar, Tooltip, IconButton } from "@material-ui/core";
+import { ArvadosTheme } from "common/custom-theme";
+import { RootState } from "store/store";
+import { Dispatch } from "redux";
+import { TCheckedList } from "components/data-table/data-table";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
+import { Resource, ResourceKind, extractUuidKind } from "models/resource";
+import { getResource } from "store/resources/resources";
+import { ResourcesState } from "store/resources/resources";
+import { MultiSelectMenuAction, MultiSelectMenuActionSet } from "views-components/multiselect-toolbar/ms-menu-actions";
+import { MultiSelectMenuActionNames } from "views-components/multiselect-toolbar/ms-menu-actions";
+import { ContextMenuAction } from "views-components/context-menu/context-menu-action-set";
+import { multiselectActionsFilters, TMultiselectActionsFilters, msMenuResourceKind } from "./ms-toolbar-action-filters";
+import { kindToActionSet, findActionByName } from "./ms-kind-action-differentiator";
+import { msToggleTrashAction } from "views-components/multiselect-toolbar/ms-project-action-set";
+import { copyToClipboardAction } from "store/open-in-new-tab/open-in-new-tab.actions";
+import { ContainerRequestResource } from "models/container-request";
+import { FavoritesState } from "store/favorites/favorites-reducer";
+import { resourceIsFrozen } from "common/frozen-resources";
+import { getResourceWithEditableStatus } from "store/resources/resources";
+import { GroupResource } from "models/group";
+import { EditableResource } from "models/resource";
+import { User } from "models/user";
+import { GroupClass } from "models/group";
+import { isProcessCancelable } from "store/processes/process";
+import { CollectionResource } from "models/collection";
+import { getProcess } from "store/processes/process";
+import { Process } from "store/processes/process";
+import { PublicFavoritesState } from "store/public-favorites/public-favorites-reducer";
+import { isExactlyOneSelected } from "store/multiselect/multiselect-actions";
+
+const WIDTH_TRANSITION = 150
+
+type CssRules = "root" | "transition" | "button" | "iconContainer";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        display: "flex",
+        flexDirection: "row",
+        width: 0,
+        height: '2.7rem',
+        padding: 0,
+        margin: "1rem auto auto 0.5rem",
+        transition: `width ${WIDTH_TRANSITION}ms`,
+        overflowY: 'auto',
+        scrollBehavior: 'smooth',
+        '&::-webkit-scrollbar': {
+            width: 0,
+            height: 2
+        },
+        '&::-webkit-scrollbar-track': {
+            width: 0,
+            height: 2
+        },
+        '&::-webkit-scrollbar-thumb': {
+            backgroundColor: '#757575',
+            borderRadius: 2
+        }
+    },
+    transition: {
+        display: "flex",
+        flexDirection: "row",
+        width: 0,
+        height: '2.7rem',
+        padding: 0,
+        margin: "1rem auto auto 0.5rem",
+        overflow: 'hidden',
+        transition: `width ${WIDTH_TRANSITION}ms`,
+    },
+    button: {
+        width: "2.5rem",
+        height: "2.5rem ",
+    },
+    iconContainer: {
+        height: '100%'
+    }
+});
+
+export type MultiselectToolbarProps = {
+    checkedList: TCheckedList;
+    singleSelectedUuid: string | null
+    iconProps: IconProps
+    user: User | null
+    disabledButtons: Set<string>
+    executeMulti: (action: ContextMenuAction, checkedList: TCheckedList, resources: ResourcesState) => void;
+};
+
+type IconProps = {
+    resources: ResourcesState;
+    favorites: FavoritesState;
+    publicFavorites: PublicFavoritesState;
+}
+
+export const MultiselectToolbar = connect(
+    mapStateToProps,
+    mapDispatchToProps
+)(
+    withStyles(styles)((props: MultiselectToolbarProps & WithStyles<CssRules>) => {
+        const { classes, checkedList, singleSelectedUuid, iconProps, user, disabledButtons } = props;
+        const singleResourceKind = singleSelectedUuid ? [resourceToMsResourceKind(singleSelectedUuid, iconProps.resources, user)] : null
+        const currentResourceKinds = singleResourceKind ? singleResourceKind : Array.from(selectedToKindSet(checkedList));
+        const currentPathIsTrash = window.location.pathname === "/trash";
+        const [isTransitioning, setIsTransitioning] = useState(false);
+        
+        const handleTransition = () => {
+            setIsTransitioning(true)
+            setTimeout(() => {
+                setIsTransitioning(false)
+            }, WIDTH_TRANSITION);
+        }
+        
+        useEffect(()=>{
+                handleTransition()
+        }, [checkedList])
+
+        const actions =
+            currentPathIsTrash && selectedToKindSet(checkedList).size
+                ? [msToggleTrashAction]
+                : selectActionsByKind(currentResourceKinds as string[], multiselectActionsFilters).filter((action) =>
+                        singleSelectedUuid === null ? action.isForMulti : true
+                    );
+
+        return (
+            <React.Fragment>
+                <Toolbar
+                    className={isTransitioning ? classes.transition: classes.root}
+                    style={{ width: `${(actions.length * 2.5) + 1}rem` }}
+                    data-cy='multiselect-toolbar'
+                    >
+                    {actions.length ? (
+                        actions.map((action, i) =>{
+                            const { hasAlts, useAlts, name, altName, icon, altIcon } = action;
+                        return hasAlts ? (
+                            <Tooltip
+                                className={classes.button}
+                                title={currentPathIsTrash || (useAlts && useAlts(singleSelectedUuid, iconProps)) ? altName : name}
+                                key={i}
+                                disableFocusListener
+                            >
+                                <span className={classes.iconContainer}>
+                                    <IconButton
+                                        data-cy='multiselect-button'
+                                        disabled={disabledButtons.has(name)}
+                                        onClick={() => props.executeMulti(action, checkedList, iconProps.resources)}
+                                    >
+                                        {currentPathIsTrash || (useAlts && useAlts(singleSelectedUuid, iconProps)) ? altIcon && altIcon({}) : icon({})}
+                                    </IconButton>
+                                </span>
+                            </Tooltip>
+                        ) : (
+                            <Tooltip
+                                className={classes.button}
+                                title={action.name}
+                                key={i}
+                                disableFocusListener
+                            >
+                                <span className={classes.iconContainer}>
+                                    <IconButton
+                                        data-cy='multiselect-button'
+                                        onClick={() => props.executeMulti(action, checkedList, iconProps.resources)}
+                                    >
+                                        {action.icon({})}
+                                    </IconButton>
+                                </span>
+                            </Tooltip>
+                        );
+                        })
+                    ) : (
+                        <></>
+                    )}
+                </Toolbar>
+            </React.Fragment>
+        )
+    })
+);
+
+export function selectedToArray(checkedList: TCheckedList): Array<string> {
+    const arrayifiedSelectedList: Array<string> = [];
+    for (const [key, value] of Object.entries(checkedList)) {
+        if (value === true) {
+            arrayifiedSelectedList.push(key);
+        }
+    }
+    return arrayifiedSelectedList;
+}
+
+export function selectedToKindSet(checkedList: TCheckedList): Set<string> {
+    const setifiedList = new Set<string>();
+    for (const [key, value] of Object.entries(checkedList)) {
+        if (value === true) {
+            setifiedList.add(extractUuidKind(key) as string);
+        }
+    }
+    return setifiedList;
+}
+
+function groupByKind(checkedList: TCheckedList, resources: ResourcesState): Record<string, ContextMenuResource[]> {
+    const result = {};
+    selectedToArray(checkedList).forEach(uuid => {
+        const resource = getResource(uuid)(resources) as ContainerRequestResource | Resource;
+        if (!result[resource.kind]) result[resource.kind] = [];
+        result[resource.kind].push(resource);
+    });
+    return result;
+}
+
+function filterActions(actionArray: MultiSelectMenuActionSet, filters: Set<string>): Array<MultiSelectMenuAction> {
+    return actionArray[0].filter(action => filters.has(action.name as string));
+}
+
+const resourceToMsResourceKind = (uuid: string, resources: ResourcesState, user: User | null, readonly = false): (msMenuResourceKind | ResourceKind) | undefined => {
+    if (!user) return;
+    const resource = getResourceWithEditableStatus<GroupResource & EditableResource>(uuid, user.uuid)(resources);
+    const { isAdmin } = user;
+    const kind = extractUuidKind(uuid);
+
+    const isFrozen = resourceIsFrozen(resource, resources);
+    const isEditable = (user.isAdmin || (resource || ({} as EditableResource)).isEditable) && !readonly && !isFrozen;
+
+    switch (kind) {
+        case ResourceKind.PROJECT:
+            if (isFrozen) {
+                return isAdmin ? msMenuResourceKind.FROZEN_PROJECT_ADMIN : msMenuResourceKind.FROZEN_PROJECT;
+            }
+
+            return isAdmin && !readonly
+                ? resource && resource.groupClass !== GroupClass.FILTER
+                    ? msMenuResourceKind.PROJECT_ADMIN
+                    : msMenuResourceKind.FILTER_GROUP_ADMIN
+                : isEditable
+                ? resource && resource.groupClass !== GroupClass.FILTER
+                    ? msMenuResourceKind.PROJECT
+                    : msMenuResourceKind.FILTER_GROUP
+                : msMenuResourceKind.READONLY_PROJECT;
+        case ResourceKind.COLLECTION:
+            const c = getResource<CollectionResource>(uuid)(resources);
+            if (c === undefined) {
+                return;
+            }
+            const isOldVersion = c.uuid !== c.currentVersionUuid;
+            const isTrashed = c.isTrashed;
+            return isOldVersion
+                ? msMenuResourceKind.OLD_VERSION_COLLECTION
+                : isTrashed && isEditable
+                ? msMenuResourceKind.TRASHED_COLLECTION
+                : isAdmin && isEditable
+                ? msMenuResourceKind.COLLECTION_ADMIN
+                : isEditable
+                ? msMenuResourceKind.COLLECTION
+                : msMenuResourceKind.READONLY_COLLECTION;
+        case ResourceKind.PROCESS:
+            return isAdmin && isEditable
+                ? resource && isProcessCancelable(getProcess(resource.uuid)(resources) as Process)
+                    ? msMenuResourceKind.RUNNING_PROCESS_ADMIN
+                    : msMenuResourceKind.PROCESS_ADMIN
+                : readonly
+                ? msMenuResourceKind.READONLY_PROCESS_RESOURCE
+                : resource && isProcessCancelable(getProcess(resource.uuid)(resources) as Process)
+                ? msMenuResourceKind.RUNNING_PROCESS_RESOURCE
+                : msMenuResourceKind.PROCESS_RESOURCE;
+        case ResourceKind.USER:
+            return msMenuResourceKind.ROOT_PROJECT;
+        case ResourceKind.LINK:
+            return msMenuResourceKind.LINK;
+        case ResourceKind.WORKFLOW:
+            return isEditable ? msMenuResourceKind.WORKFLOW : msMenuResourceKind.READONLY_WORKFLOW;
+        default:
+            return;
+    }
+}; 
+
+function selectActionsByKind(currentResourceKinds: Array<string>, filterSet: TMultiselectActionsFilters) {
+    const rawResult: Set<MultiSelectMenuAction> = new Set();
+    const resultNames = new Set();
+    const allFiltersArray: MultiSelectMenuAction[][] = []
+    currentResourceKinds.forEach(kind => {
+        if (filterSet[kind]) {
+            const actions = filterActions(...filterSet[kind]);
+            allFiltersArray.push(actions);
+            actions.forEach(action => {
+                if (!resultNames.has(action.name)) {
+                    rawResult.add(action);
+                    resultNames.add(action.name);
+                }
+            });
+        }
+    });
+
+    const filteredNameSet = allFiltersArray.map(filterArray => {
+        const resultSet = new Set<string>();
+        filterArray.forEach(action => resultSet.add(action.name as string || ""));
+        return resultSet;
+    });
+
+    const filteredResult = Array.from(rawResult).filter(action => {
+        for (let i = 0; i < filteredNameSet.length; i++) {
+            if (!filteredNameSet[i].has(action.name as string)) return false;
+        }
+        return true;
+    });
+
+    return filteredResult.sort((a, b) => {
+        const nameA = a.name || "";
+        const nameB = b.name || "";
+        if (nameA < nameB) {
+            return -1;
+        }
+        if (nameA > nameB) {
+            return 1;
+        }
+        return 0;
+    });
+}
+
+
+//--------------------------------------------------//
+
+function mapStateToProps({auth, multiselect, resources, favorites, publicFavorites}: RootState) {
+    return {
+        checkedList: multiselect.checkedList as TCheckedList,
+        singleSelectedUuid: isExactlyOneSelected(multiselect.checkedList),
+        user: auth && auth.user ? auth.user : null,
+        disabledButtons: new Set<string>(multiselect.disabledButtons),
+        iconProps: {
+            resources,
+            favorites,
+            publicFavorites
+        }
+    }
+}
+
+function mapDispatchToProps(dispatch: Dispatch) {
+    return {
+        executeMulti: (selectedAction: ContextMenuAction, checkedList: TCheckedList, resources: ResourcesState): void => {
+            const kindGroups = groupByKind(checkedList, resources);
+            switch (selectedAction.name) {
+                case MultiSelectMenuActionNames.MOVE_TO:
+                case MultiSelectMenuActionNames.REMOVE:
+                    const firstResource = getResource(selectedToArray(checkedList)[0])(resources) as ContainerRequestResource | Resource;
+                    const action = findActionByName(selectedAction.name as string, kindToActionSet[firstResource.kind]);
+                    if (action) action.execute(dispatch, kindGroups[firstResource.kind]);
+                    break;
+                case MultiSelectMenuActionNames.COPY_TO_CLIPBOARD:
+                    const selectedResources = selectedToArray(checkedList).map(uuid => getResource(uuid)(resources));
+                    dispatch<any>(copyToClipboardAction(selectedResources));
+                    break;
+                default:
+                    for (const kind in kindGroups) {
+                        const action = findActionByName(selectedAction.name as string, kindToActionSet[kind]);
+                        if (action) action.execute(dispatch, kindGroups[kind]);
+                    }
+                    break;
+            }
+        },
+    };
+}
diff --git a/src/components/multiselect-toolbar/ms-kind-action-differentiator.ts b/src/components/multiselect-toolbar/ms-kind-action-differentiator.ts
new file mode 100644 (file)
index 0000000..5a84d4c
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ResourceKind } from "models/resource";
+import { MultiSelectMenuActionSet} from "views-components/multiselect-toolbar/ms-menu-actions";
+import { msCollectionActionSet } from "views-components/multiselect-toolbar/ms-collection-action-set";
+import { msProjectActionSet } from "views-components/multiselect-toolbar/ms-project-action-set";
+import { msProcessActionSet } from "views-components/multiselect-toolbar/ms-process-action-set";
+import { msWorkflowActionSet } from "views-components/multiselect-toolbar/ms-workflow-action-set";
+
+export function findActionByName(name: string, actionSet: MultiSelectMenuActionSet) {
+    return actionSet[0].find(action => action.name === name);
+}
+
+const { COLLECTION, PROCESS, PROJECT, WORKFLOW } = ResourceKind;
+
+export const kindToActionSet: Record<string, MultiSelectMenuActionSet> = {
+    [COLLECTION]: msCollectionActionSet,
+    [PROCESS]: msProcessActionSet,
+    [PROJECT]: msProjectActionSet,
+    [WORKFLOW]: msWorkflowActionSet,
+};
diff --git a/src/components/multiselect-toolbar/ms-toolbar-action-filters.ts b/src/components/multiselect-toolbar/ms-toolbar-action-filters.ts
new file mode 100644 (file)
index 0000000..b34cc22
--- /dev/null
@@ -0,0 +1,115 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { MultiSelectMenuActionSet } from 'views-components/multiselect-toolbar/ms-menu-actions';
+import { msCollectionActionSet, msCommonCollectionActionFilter, msReadOnlyCollectionActionFilter } from 'views-components/multiselect-toolbar/ms-collection-action-set';
+import {
+    msProjectActionSet,
+    msCommonProjectActionFilter,
+    msReadOnlyProjectActionFilter,
+    msFilterGroupActionFilter,
+    msAdminFilterGroupActionFilter,
+    msFrozenProjectActionFilter,
+    msAdminFrozenProjectActionFilter
+} from 'views-components/multiselect-toolbar/ms-project-action-set';
+import { msProcessActionSet, msCommonProcessActionFilter, msAdminProcessActionFilter, msRunningProcessActionFilter } from 'views-components/multiselect-toolbar/ms-process-action-set';
+import { msWorkflowActionSet, msWorkflowActionFilter, msReadOnlyWorkflowActionFilter } from 'views-components/multiselect-toolbar/ms-workflow-action-set';
+import { ResourceKind } from 'models/resource';
+
+export enum msMenuResourceKind {
+    API_CLIENT_AUTHORIZATION = 'ApiClientAuthorization',
+    ROOT_PROJECT = 'RootProject',
+    PROJECT = 'Project',
+    FILTER_GROUP = 'FilterGroup',
+    READONLY_PROJECT = 'ReadOnlyProject',
+    FROZEN_PROJECT = 'FrozenProject',
+    FROZEN_PROJECT_ADMIN = 'FrozenProjectAdmin',
+    PROJECT_ADMIN = 'ProjectAdmin',
+    FILTER_GROUP_ADMIN = 'FilterGroupAdmin',
+    RESOURCE = 'Resource',
+    FAVORITE = 'Favorite',
+    TRASH = 'Trash',
+    COLLECTION_FILES = 'CollectionFiles',
+    COLLECTION_FILES_MULTIPLE = 'CollectionFilesMultiple',
+    READONLY_COLLECTION_FILES = 'ReadOnlyCollectionFiles',
+    READONLY_COLLECTION_FILES_MULTIPLE = 'ReadOnlyCollectionFilesMultiple',
+    COLLECTION_FILES_NOT_SELECTED = 'CollectionFilesNotSelected',
+    COLLECTION_FILE_ITEM = 'CollectionFileItem',
+    COLLECTION_DIRECTORY_ITEM = 'CollectionDirectoryItem',
+    READONLY_COLLECTION_FILE_ITEM = 'ReadOnlyCollectionFileItem',
+    READONLY_COLLECTION_DIRECTORY_ITEM = 'ReadOnlyCollectionDirectoryItem',
+    COLLECTION = 'Collection',
+    COLLECTION_ADMIN = 'CollectionAdmin',
+    READONLY_COLLECTION = 'ReadOnlyCollection',
+    OLD_VERSION_COLLECTION = 'OldVersionCollection',
+    TRASHED_COLLECTION = 'TrashedCollection',
+    PROCESS = 'Process',
+    RUNNING_PROCESS_ADMIN = 'RunningProcessAdmin',
+    PROCESS_ADMIN = 'ProcessAdmin',
+    RUNNING_PROCESS_RESOURCE = 'RunningProcessResource',
+    PROCESS_RESOURCE = 'ProcessResource',
+    READONLY_PROCESS_RESOURCE = 'ReadOnlyProcessResource',
+    PROCESS_LOGS = 'ProcessLogs',
+    REPOSITORY = 'Repository',
+    SSH_KEY = 'SshKey',
+    VIRTUAL_MACHINE = 'VirtualMachine',
+    KEEP_SERVICE = 'KeepService',
+    USER = 'User',
+    GROUPS = 'Group',
+    GROUP_MEMBER = 'GroupMember',
+    PERMISSION_EDIT = 'PermissionEdit',
+    LINK = 'Link',
+    WORKFLOW = 'Workflow',
+    READONLY_WORKFLOW = 'ReadOnlyWorkflow',
+    SEARCH_RESULTS = 'SearchResults',
+}
+
+const {
+    COLLECTION,
+    COLLECTION_ADMIN,
+    READONLY_COLLECTION,
+    PROCESS_RESOURCE,
+    RUNNING_PROCESS_RESOURCE,
+    RUNNING_PROCESS_ADMIN,
+    PROCESS_ADMIN,
+    PROJECT,
+    PROJECT_ADMIN,
+    FROZEN_PROJECT,
+    FROZEN_PROJECT_ADMIN,
+    READONLY_PROJECT,
+    FILTER_GROUP,
+    FILTER_GROUP_ADMIN,
+    WORKFLOW,
+    READONLY_WORKFLOW,
+} = msMenuResourceKind;
+
+export type TMultiselectActionsFilters = Record<string, [MultiSelectMenuActionSet, Set<string>]>;
+
+const allActionNames = (actionSet: MultiSelectMenuActionSet): Set<string> => new Set(actionSet[0].map((action) => action.name));
+
+export const multiselectActionsFilters: TMultiselectActionsFilters = {
+    [COLLECTION]: [msCollectionActionSet, msCommonCollectionActionFilter],
+    [COLLECTION_ADMIN]: [msCollectionActionSet, allActionNames(msCollectionActionSet)],
+    [READONLY_COLLECTION]: [msCollectionActionSet, msReadOnlyCollectionActionFilter],
+    [ResourceKind.COLLECTION]: [msCollectionActionSet, msCommonCollectionActionFilter],
+
+    [PROCESS_RESOURCE]: [msProcessActionSet, msCommonProcessActionFilter],
+    [PROCESS_ADMIN]: [msProcessActionSet, msAdminProcessActionFilter],
+    [RUNNING_PROCESS_RESOURCE]: [msProcessActionSet, msRunningProcessActionFilter],
+    [RUNNING_PROCESS_ADMIN]: [msProcessActionSet, allActionNames(msProcessActionSet)],
+    [ResourceKind.PROCESS]: [msProcessActionSet, msCommonProcessActionFilter],
+    
+    [PROJECT]: [msProjectActionSet, msCommonProjectActionFilter],
+    [PROJECT_ADMIN]: [msProjectActionSet, allActionNames(msProjectActionSet)],
+    [FROZEN_PROJECT]: [msProjectActionSet, msFrozenProjectActionFilter],
+    [FROZEN_PROJECT_ADMIN]: [msProjectActionSet, msAdminFrozenProjectActionFilter], 
+    [READONLY_PROJECT]: [msProjectActionSet, msReadOnlyProjectActionFilter],
+    [ResourceKind.PROJECT]: [msProjectActionSet, msCommonProjectActionFilter],
+    
+    [FILTER_GROUP]: [msProjectActionSet, msFilterGroupActionFilter],
+    [FILTER_GROUP_ADMIN]: [msProjectActionSet, msAdminFilterGroupActionFilter],
+    
+    [WORKFLOW]: [msWorkflowActionSet, msWorkflowActionFilter],
+    [READONLY_WORKFLOW]: [msWorkflowActionSet, msReadOnlyWorkflowActionFilter],
+};
index 6fa7ddea626396494cc6f3cc5a2e01887a15da7d..bea0649632ecdafbb25fe81a467d03e3a15b5008 100644 (file)
@@ -11,55 +11,58 @@ type CssRules = 'formControl' | 'selectWrapper' | 'select' | 'option';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     formControl: {
-        width: '100%'
+        width: '100%',
     },
     selectWrapper: {
         backgroundColor: theme.palette.common.white,
         '&:before': {
-            borderBottomColor: 'rgba(0, 0, 0, 0.42)'
+            borderBottomColor: 'rgba(0, 0, 0, 0.42)',
         },
         '&:focus': {
-            outline: 'none'
-        }
+            outline: 'none',
+        },
     },
     select: {
         fontSize: '0.875rem',
         '&:focus': {
-            backgroundColor: 'rgba(0, 0, 0, 0.0)'
-        }
+            backgroundColor: 'rgba(0, 0, 0, 0.0)',
+        },
     },
     option: {
         fontSize: '0.875rem',
         backgroundColor: theme.palette.common.white,
-        height: '30px'
-    }
+        height: '30px',
+    },
 });
 
 interface NativeSelectFieldProps {
     disabled?: boolean;
 }
 
-export const NativeSelectField = withStyles(styles)
-    ((props: WrappedFieldProps & NativeSelectFieldProps & WithStyles<CssRules> & { items: any[] }) =>
-        <FormControl className={props.classes.formControl}>
-            <Select className={props.classes.selectWrapper}
-                native
-                value={props.input.value}
-                onChange={props.input.onChange}
-                disabled={props.meta.submitting || props.disabled}
-                name={props.input.name}
-                inputProps={{
-                    id: `id-${props.input.name}`,
-                    className: props.classes.select
-                }}>
-                {props.items.map(item => (
-                    <option key={item.key} value={item.key} className={props.classes.option}>
-                        {item.value}
-                    </option>
-                ))}
-            </Select>
-        </FormControl>
-    );
+export const NativeSelectField = withStyles(styles)((props: WrappedFieldProps & NativeSelectFieldProps & WithStyles<CssRules> & { items: any[] }) => (
+    <FormControl className={props.classes.formControl}>
+        <Select
+            className={props.classes.selectWrapper}
+            native
+            value={props.input.value}
+            onChange={props.input.onChange}
+            disabled={props.meta.submitting || props.disabled}
+            name={props.input.name}
+            inputProps={{
+                id: `id-${props.input.name}`,
+                className: props.classes.select,
+            }}>
+            {props.items.map(item => (
+                <option
+                    key={item.key}
+                    value={item.key}
+                    className={props.classes.option}>
+                    {item.value}
+                </option>
+            ))}
+        </Select>
+    </FormControl>
+));
 
 interface SelectFieldProps {
     children: React.ReactNode;
@@ -70,19 +73,15 @@ type SelectFieldCssRules = 'formControl';
 
 const selectFieldStyles: StyleRulesCallback<SelectFieldCssRules> = (theme: ArvadosTheme) => ({
     formControl: {
-        marginBottom: theme.spacing.unit * 3
+        marginBottom: theme.spacing.unit * 3,
     },
 });
-export const SelectField = withStyles(selectFieldStyles)(
-    (props: WrappedFieldProps & SelectFieldProps &  WithStyles<SelectFieldCssRules>) =>
-        <FormControl error={props.meta.invalid} className={props.classes.formControl}>
-            <InputLabel>
-                {props.label}
-            </InputLabel>
-            <Select
-                {...props.input}>
-                {props.children}
-            </Select>
-            <FormHelperText>{props.meta.error}</FormHelperText>
-        </FormControl>
-);
+export const SelectField = withStyles(selectFieldStyles)((props: WrappedFieldProps & SelectFieldProps & WithStyles<SelectFieldCssRules>) => (
+    <FormControl
+        error={props.meta.invalid}
+        className={props.classes.formControl}>
+        <InputLabel>{props.label}</InputLabel>
+        <Select {...props.input}>{props.children}</Select>
+        <FormHelperText>{props.meta.error}</FormHelperText>
+    </FormControl>
+));
diff --git a/src/components/subprocess-progress-bar/subprocess-progress-bar.test.tsx b/src/components/subprocess-progress-bar/subprocess-progress-bar.test.tsx
new file mode 100644 (file)
index 0000000..bd8603f
--- /dev/null
@@ -0,0 +1,165 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { configure, mount } from "enzyme";
+import { ServiceRepository, createServices } from "services/services";
+import { configureStore } from "store/store";
+import { createBrowserHistory } from "history";
+import { mockConfig } from 'common/config';
+import { ApiActions } from "services/api/api-actions";
+import Axios from "axios";
+import MockAdapter from "axios-mock-adapter";
+import { Process } from "store/processes/process";
+import { ContainerState } from "models/container";
+import Adapter from "enzyme-adapter-react-16";
+import { SubprocessProgressBar } from "./subprocess-progress-bar";
+import { Provider } from "react-redux";
+import { FilterBuilder } from 'services/api/filter-builder';
+import { ProcessStatusFilter, buildProcessStatusFilters } from 'store/resource-type-filters/resource-type-filters';
+import {act} from "react-dom/test-utils";
+
+configure({ adapter: new Adapter() });
+
+describe("<SubprocessProgressBar />", () => {
+    const axiosInst = Axios.create({ headers: {} });
+    const axiosMock = new MockAdapter(axiosInst);
+
+    let store;
+    let services: ServiceRepository;
+    const config: any = {};
+    const actions: ApiActions = {
+        progressFn: (id: string, working: boolean) => { },
+        errorFn: (id: string, message: string) => { }
+    };
+    let statusResponse = {
+        [ProcessStatusFilter.COMPLETED]: 0,
+        [ProcessStatusFilter.RUNNING]: 0,
+        [ProcessStatusFilter.FAILED]: 0,
+        [ProcessStatusFilter.QUEUED]: 0,
+    };
+
+    const createMockListFunc = (uuid: string) => jest.fn(async (args) => {
+        const baseFilter = new FilterBuilder().addEqual('requesting_container_uuid', uuid).getFilters();
+
+        const filterResponses = Object.keys(statusResponse)
+            .map(status => ({filters: buildProcessStatusFilters(new FilterBuilder(baseFilter), status).getFilters(), value: statusResponse[status]}));
+
+        const matchedFilter = filterResponses.find(response => response.filters === args.filters);
+        if (matchedFilter) {
+            return { itemsAvailable: matchedFilter.value };
+        } else {
+            return { itemsAvailable: 0 };
+        }
+    });
+
+    beforeEach(() => {
+        services = createServices(mockConfig({}), actions, axiosInst);
+        store = configureStore(createBrowserHistory(), services, config);
+    });
+
+    it("requests subprocess progress stats for stopped processes and displays progress", async () => {
+        // when
+        const process = {
+            container: {
+                state: ContainerState.COMPLETE,
+            },
+            containerRequest: {
+                containerUuid: 'zzzzz-dz642-000000000000000',
+            },
+        } as Process;
+
+        statusResponse = {
+            [ProcessStatusFilter.COMPLETED]: 100,
+            [ProcessStatusFilter.RUNNING]: 200,
+
+            // Combined into failed segment
+            [ProcessStatusFilter.FAILED]: 200,
+            [ProcessStatusFilter.CANCELLED]: 100,
+
+            // Combined into queued segment
+            [ProcessStatusFilter.QUEUED]: 300,
+            [ProcessStatusFilter.ONHOLD]: 100,
+        };
+
+        services.containerRequestService.list = createMockListFunc(process.containerRequest.containerUuid);
+
+        let progressBar;
+        await act(async () => {
+            progressBar = mount(
+                <Provider store={store}>
+                    <SubprocessProgressBar process={process} />
+                </Provider>);
+        });
+        await progressBar.update();
+
+        // expects 6 subprocess status list requests
+        const expectedFilters = [
+            ProcessStatusFilter.COMPLETED,
+            ProcessStatusFilter.RUNNING,
+            ProcessStatusFilter.FAILED,
+            ProcessStatusFilter.CANCELLED,
+            ProcessStatusFilter.QUEUED,
+            ProcessStatusFilter.ONHOLD,
+        ].map((state) =>
+            buildProcessStatusFilters(
+                new FilterBuilder().addEqual(
+                    "requesting_container_uuid",
+                    process.containerRequest.containerUuid
+                ),
+                state
+            ).getFilters()
+        );
+
+        expectedFilters.forEach((filter) => {
+            expect(services.containerRequestService.list).toHaveBeenCalledWith({limit: 0, offset: 0, filters: filter});
+        });
+
+        // Verify progress bar with correct degment widths
+        ['10%', '20%', '30%', '40%'].forEach((value, i) => {
+            const styles = progressBar.find('.progress').at(i).props().style;
+            expect(styles).toHaveProperty('width', value);
+        });
+    });
+
+    it("dislays correct progress bar widths with different values", async () => {
+        const process = {
+            container: {
+                state: ContainerState.COMPLETE,
+            },
+            containerRequest: {
+                containerUuid: 'zzzzz-dz642-000000000000001',
+            },
+        } as Process;
+
+        statusResponse = {
+            [ProcessStatusFilter.COMPLETED]: 50,
+            [ProcessStatusFilter.RUNNING]: 55,
+
+            [ProcessStatusFilter.FAILED]: 30,
+            [ProcessStatusFilter.CANCELLED]: 30,
+
+            [ProcessStatusFilter.QUEUED]: 235,
+            [ProcessStatusFilter.ONHOLD]: 100,
+        };
+
+        services.containerRequestService.list = createMockListFunc(process.containerRequest.containerUuid);
+
+        let progressBar;
+        await act(async () => {
+            progressBar = mount(
+                <Provider store={store}>
+                    <SubprocessProgressBar process={process} />
+                </Provider>);
+        });
+        await progressBar.update();
+
+        // Verify progress bar with correct degment widths
+        ['10%', '11%', '12%', '67%'].forEach((value, i) => {
+            const styles = progressBar.find('.progress').at(i).props().style;
+            expect(styles).toHaveProperty('width', value);
+        });
+    });
+
+});
diff --git a/src/components/subprocess-progress-bar/subprocess-progress-bar.tsx b/src/components/subprocess-progress-bar/subprocess-progress-bar.tsx
new file mode 100644 (file)
index 0000000..b21d879
--- /dev/null
@@ -0,0 +1,105 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { useEffect, useState } from "react";
+import { StyleRulesCallback, Tooltip, WithStyles, withStyles } from "@material-ui/core";
+import { CProgressStacked, CProgress } from '@coreui/react';
+import { useAsyncInterval } from "common/use-async-interval";
+import { Process, isProcessRunning } from "store/processes/process";
+import { connect } from "react-redux";
+import { Dispatch } from "redux";
+import { fetchSubprocessProgress } from "store/subprocess-panel/subprocess-panel-actions";
+import { ProcessStatusFilter } from "store/resource-type-filters/resource-type-filters";
+
+type CssRules = 'progressWrapper' | 'progressStacked' ;
+
+const styles: StyleRulesCallback<CssRules> = (theme) => ({
+    progressWrapper: {
+        margin: "28px 0 0",
+        flexGrow: 1,
+        flexBasis: "100px",
+    },
+    progressStacked: {
+        border: "1px solid gray",
+        height: "10px",
+        // Override stripe color to be close to white
+        "& .progress-bar-striped": {
+            backgroundImage:
+                "linear-gradient(45deg,rgba(255,255,255,.80) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.80) 50%,rgba(255,255,255,.80) 75%,transparent 75%,transparent)",
+        },
+    },
+});
+
+export interface ProgressBarDataProps {
+    process: Process;
+}
+
+export interface ProgressBarActionProps {
+    fetchSubprocessProgress: (requestingContainerUuid: string) => Promise<ProgressBarData | undefined>;
+}
+
+type ProgressBarProps = ProgressBarDataProps & ProgressBarActionProps & WithStyles<CssRules>;
+
+export type ProgressBarData = {
+    [ProcessStatusFilter.COMPLETED]: number;
+    [ProcessStatusFilter.RUNNING]: number;
+    [ProcessStatusFilter.FAILED]: number;
+    [ProcessStatusFilter.QUEUED]: number;
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): ProgressBarActionProps => ({
+    fetchSubprocessProgress: (requestingContainerUuid: string) => {
+        return dispatch<any>(fetchSubprocessProgress(requestingContainerUuid));
+    },
+});
+
+export const SubprocessProgressBar = connect(null, mapDispatchToProps)(withStyles(styles)(
+    ({process, classes, fetchSubprocessProgress}: ProgressBarProps) => {
+
+        const [progressData, setProgressData] = useState<ProgressBarData|undefined>(undefined);
+        const requestingContainerUuid = process.containerRequest.containerUuid;
+        const isRunning = isProcessRunning(process);
+
+        useAsyncInterval(async () => (
+            requestingContainerUuid && setProgressData(await fetchSubprocessProgress(requestingContainerUuid))
+        ), isRunning ? 5000 : null);
+
+        useEffect(() => {
+            if (!isRunning && requestingContainerUuid) {
+                fetchSubprocessProgress(requestingContainerUuid)
+                    .then(result => setProgressData(result));
+            }
+        }, [fetchSubprocessProgress, isRunning, requestingContainerUuid]);
+
+        return progressData !== undefined && getStatusTotal(progressData) > 0 ? <div className={classes.progressWrapper}>
+            <CProgressStacked className={classes.progressStacked}>
+                <Tooltip title={`${progressData[ProcessStatusFilter.COMPLETED]} Completed`}>
+                    <CProgress height={10} color="success"
+                        value={getStatusPercent(progressData, ProcessStatusFilter.COMPLETED)} />
+                </Tooltip>
+                <Tooltip title={`${progressData[ProcessStatusFilter.RUNNING]} Running`}>
+                    <CProgress height={10} color="success" variant="striped"
+                        value={getStatusPercent(progressData, ProcessStatusFilter.RUNNING)} />
+                </Tooltip>
+                <Tooltip title={`${progressData[ProcessStatusFilter.FAILED]} Failed`}>
+                    <CProgress height={10} color="danger"
+                        value={getStatusPercent(progressData, ProcessStatusFilter.FAILED)} />
+                </Tooltip>
+                <Tooltip title={`${progressData[ProcessStatusFilter.QUEUED]} Queued`}>
+                    <CProgress height={10} color="secondary" variant="striped"
+                        value={getStatusPercent(progressData, ProcessStatusFilter.QUEUED)} />
+                </Tooltip>
+            </CProgressStacked>
+        </div> : <></>;
+    }
+));
+
+const getStatusTotal = (progressData: ProgressBarData) =>
+    (Object.keys(progressData).reduce((accumulator, key) => (accumulator += progressData[key]), 0));
+
+/**
+ * Gets the integer percent value for process status
+ */
+const getStatusPercent = (progressData: ProgressBarData, status: keyof ProgressBarData) =>
+    (progressData[status] / getStatusTotal(progressData) * 100);
index e37086213eae94fc0a997537c810520ee5aaea25..11a9540290cc21eb9aa3995f93124608775f0254 100644 (file)
@@ -2,10 +2,10 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
+import React, { useCallback, useState } from 'react';
 import { List, ListItem, ListItemIcon, Checkbox, Radio, Collapse } from "@material-ui/core";
 import { StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core/styles';
-import { CollectionIcon, DefaultIcon, DirectoryIcon, FileIcon, ProjectIcon, FilterGroupIcon, FreezeIcon } from 'components/icon/icon';
+import { CollectionIcon, DefaultIcon, DirectoryIcon, FileIcon, ProjectIcon, ProcessIcon, FilterGroupIcon, FreezeIcon } from 'components/icon/icon';
 import { ReactElement } from "react";
 import CircularProgress from '@material-ui/core/CircularProgress';
 import classnames from "classnames";
@@ -14,6 +14,7 @@ import { ArvadosTheme } from 'common/custom-theme';
 import { SidePanelRightArrowIcon } from '../icon/icon';
 import { ResourceKind } from 'models/resource';
 import { GroupClass } from 'models/group';
+import { SidePanelTreeCategory } from 'store/side-panel-tree/side-panel-tree-actions';
 
 type CssRules = 'list'
     | 'listItem'
@@ -27,7 +28,8 @@ type CssRules = 'list'
     | 'checkbox'
     | 'childItem'
     | 'childItemIcon'
-    | 'frozenIcon';
+    | 'frozenIcon'
+    | 'indentSpacer';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     list: {
@@ -45,9 +47,10 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         color: theme.palette.grey["700"],
         height: '14px',
         width: '14px',
+        marginBottom: '0.4rem',
     },
     toggableIcon: {
-        fontSize: '14px'
+        fontSize: '14px',
     },
     renderContainer: {
         flex: 1
@@ -89,6 +92,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         color: theme.palette.grey["600"],
         marginLeft: '10px',
     },
+    indentSpacer: {
+        width: '0.25rem'
+    }
 });
 
 export enum TreeItemStatus {
@@ -99,6 +105,7 @@ export enum TreeItemStatus {
 
 export interface TreeItem<T> {
     data: T;
+    depth?: number;
     id: string;
     open: boolean;
     active: boolean;
@@ -125,6 +132,7 @@ export interface TreeProps<T> {
     toggleItemActive: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
     toggleItemOpen: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
     toggleItemSelection?: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
+    selectedRef?: (node: HTMLDivElement | null) => void;
 
     /**
      * When set to true use radio buttons instead of checkboxes for item selection.
@@ -154,6 +162,10 @@ const getActionAndId = (event: any, initAction: string | undefined = undefined)
     return [action, id];
 };
 
+const isInFavoritesTree = (item: TreeItem<any>): boolean => {
+    return item.id === SidePanelTreeCategory.FAVORITES || item.id === SidePanelTreeCategory.PUBLIC_FAVORITES;
+}
+
 interface FlatTreeProps {
     it: TreeItem<any>;
     levelIndentation: number;
@@ -167,6 +179,7 @@ interface FlatTreeProps {
     showSelection: any;
     useRadioButtons?: boolean;
     handleCheckboxChange: Function;
+    selectedRef?: (node: HTMLDivElement | null) => void;
 }
 
 const FLAT_TREE_ACTIONS = {
@@ -175,7 +188,7 @@ const FLAT_TREE_ACTIONS = {
     toggleActive: 'TOGGLE_ACTIVE',
 };
 
-const ItemIcon = React.memo(({ type, kind, active, groupClass, classes }: any) => {
+const ItemIcon = React.memo(({ type, kind, headKind, active, groupClass, classes }: any) => {
     let Icon = ProjectIcon;
 
     if (groupClass === GroupClass.FILTER) {
@@ -196,10 +209,14 @@ const ItemIcon = React.memo(({ type, kind, active, groupClass, classes }: any) =
     }
 
     if (kind) {
+        if(kind === ResourceKind.LINK && headKind) kind = headKind;
         switch (kind) {
             case ResourceKind.COLLECTION:
                 Icon = CollectionIcon;
                 break;
+            case ResourceKind.CONTAINER_REQUEST:
+                Icon = ProcessIcon;
+                break;
             default:
                 break;
         }
@@ -238,11 +255,14 @@ const FlatTree = (props: FlatTreeProps) =>
                 .map((item: any) => <div key={item.id} data-id={item.id}
                     className={classnames(props.classes.childItem, { [props.classes.active]: item.active })}
                     style={{ paddingLeft: `${item.depth * props.levelIndentation}px` }}>
-                    <i data-action={FLAT_TREE_ACTIONS.toggleOpen} className={props.classes.toggableIconContainer}>
-                        <ListItemIcon className={props.getToggableIconClassNames(item.open, item.active)}>
-                            {props.getProperArrowAnimation(item.status, item.items!)}
-                        </ListItemIcon>
-                    </i>
+                    {isInFavoritesTree(props.it) ? 
+                        <div className={props.classes.indentSpacer} />
+                        :
+                        <i data-action={FLAT_TREE_ACTIONS.toggleOpen} className={props.classes.toggableIconContainer}>
+                            <ListItemIcon className={props.getToggableIconClassNames(item.open, item.active)}>
+                                {props.getProperArrowAnimation(item.status, item.items!)}
+                            </ListItemIcon> 
+                        </i>}
                     {props.showSelection(item) && !props.useRadioButtons &&
                         <Checkbox
                             checked={item.selected}
@@ -254,9 +274,9 @@ const FlatTree = (props: FlatTreeProps) =>
                             checked={item.selected}
                             className={props.classes.checkbox}
                             color="primary" />}
-                    <div data-action={FLAT_TREE_ACTIONS.toggleActive} className={props.classes.renderContainer}>
+                    <div data-action={FLAT_TREE_ACTIONS.toggleActive} className={props.classes.renderContainer} ref={item.active ? props.selectedRef : undefined}>
                         <span style={{ display: 'flex', alignItems: 'center' }}>
-                            <ItemIcon type={item.data.type} active={item.active} kind={item.data.kind} groupClass={item.data.kind === ResourceKind.GROUP ? item.data.groupClass : ''} classes={props.classes} />
+                            <ItemIcon type={item.data.type} active={item.active} kind={item.data.kind} headKind={item.data.headKind || null} groupClass={item.data.kind === ResourceKind.GROUP ? item.data.groupClass : ''} classes={props.classes} />
                             <span style={{ fontSize: '0.875rem' }}>
                                 {item.data.name}
                             </span>
@@ -270,98 +290,26 @@ const FlatTree = (props: FlatTreeProps) =>
     </div>;
 
 export const Tree = withStyles(styles)(
-    class Component<T> extends React.Component<TreeProps<T> & WithStyles<CssRules>, {}> {
-        render(): ReactElement<any> {
-            const level = this.props.level ? this.props.level : 0;
-            const { classes, render, items, toggleItemActive, toggleItemOpen, disableRipple, currentItemUuid, useRadioButtons, itemsMap } = this.props;
-            const { list, listItem, loader, toggableIconContainer, renderContainer } = classes;
-            const showSelection = typeof this.props.showSelection === 'function'
-                ? this.props.showSelection
-                : () => this.props.showSelection ? true : false;
-
-            const { levelIndentation = 20, itemRightPadding = 20 } = this.props;
-            return <List className={list}>
-                {items && items.map((it: TreeItem<T>, idx: number) =>
-                    <div key={`item/${level}/${it.id}`}>
-                        <ListItem button className={listItem}
-                            style={{
-                                paddingLeft: (level + 1) * levelIndentation,
-                                paddingRight: itemRightPadding,
-                            }}
-                            disableRipple={disableRipple}
-                            onClick={event => toggleItemActive(event, it)}
-                            selected={showSelection(it) && it.id === currentItemUuid}
-                            onContextMenu={(event) => this.props.onContextMenu(event, it)}>
-                            {it.status === TreeItemStatus.PENDING ?
-                                <CircularProgress size={10} className={loader} /> : null}
-                            <i onClick={(e) => this.handleToggleItemOpen(it, e)}
-                                className={toggableIconContainer}>
-                                <ListItemIcon className={this.getToggableIconClassNames(it.open, it.active)}>
-                                    {this.getProperArrowAnimation(it.status, it.items!)}
-                                </ListItemIcon>
-                            </i>
-                            {showSelection(it) && !useRadioButtons &&
-                                <Checkbox
-                                    checked={it.selected}
-                                    indeterminate={!it.selected && it.indeterminate}
-                                    className={classes.checkbox}
-                                    color="primary"
-                                    onClick={this.handleCheckboxChange(it)} />}
-                            {showSelection(it) && useRadioButtons &&
-                                <Radio
-                                    checked={it.selected}
-                                    className={classes.checkbox}
-                                    color="primary" />}
-                            <div className={renderContainer}>
-                                {render(it, level)}
-                            </div>
-                        </ListItem>
-                        {
-                            it.open && it.items && it.items.length > 0 &&
-                                it.flatTree ?
-                                <FlatTree
-                                    it={it}
-                                    itemsMap={itemsMap}
-                                    showSelection={showSelection}
-                                    classes={this.props.classes}
-                                    useRadioButtons={useRadioButtons}
-                                    levelIndentation={levelIndentation}
-                                    handleCheckboxChange={this.handleCheckboxChange}
-                                    onContextMenu={this.props.onContextMenu}
-                                    handleToggleItemOpen={this.handleToggleItemOpen}
-                                    toggleItemActive={this.props.toggleItemActive}
-                                    getToggableIconClassNames={this.getToggableIconClassNames}
-                                    getProperArrowAnimation={this.getProperArrowAnimation}
-                                /> :
-                                <Collapse in={it.open} timeout="auto" unmountOnExit>
-                                    <Tree
-                                        showSelection={this.props.showSelection}
-                                        items={it.items}
-                                        render={render}
-                                        disableRipple={disableRipple}
-                                        toggleItemOpen={toggleItemOpen}
-                                        toggleItemActive={toggleItemActive}
-                                        level={level + 1}
-                                        onContextMenu={this.props.onContextMenu}
-                                        toggleItemSelection={this.props.toggleItemSelection} />
-                                </Collapse>
-                        }
-                    </div>)}
-            </List>;
-        }
+    function<T>(props: TreeProps<T> & WithStyles<CssRules>) {
+        const level = props.level ? props.level : 0;
+        const { classes, render, items, toggleItemActive, toggleItemOpen, disableRipple, currentItemUuid, useRadioButtons, itemsMap } = props;
+        const { list, listItem, loader, toggableIconContainer, renderContainer } = classes;
+        const showSelection = typeof props.showSelection === 'function'
+            ? props.showSelection
+            : () => props.showSelection ? true : false;
 
-        getProperArrowAnimation = (status: string, items: Array<TreeItem<T>>) => {
-            return this.isSidePanelIconNotNeeded(status, items) ? <span /> : <SidePanelRightArrowIcon style={{ fontSize: '14px' }} />;
+        const getProperArrowAnimation = (status: string, items: Array<TreeItem<T>>) => {
+            return isSidePanelIconNotNeeded(status, items) ? <span /> : <SidePanelRightArrowIcon style={{ fontSize: '14px' }} />;
         }
 
-        isSidePanelIconNotNeeded = (status: string, items: Array<TreeItem<T>>) => {
+        const isSidePanelIconNotNeeded = (status: string, items: Array<TreeItem<T>>) => {
             return status === TreeItemStatus.PENDING ||
                 (status === TreeItemStatus.LOADED && !items) ||
                 (status === TreeItemStatus.LOADED && items && items.length === 0);
         }
 
-        getToggableIconClassNames = (isOpen?: boolean, isActive?: boolean) => {
-            const { iconOpen, iconClose, active, toggableIcon } = this.props.classes;
+        const getToggableIconClassNames = (isOpen?: boolean, isActive?: boolean) => {
+            const { iconOpen, iconClose, active, toggableIcon } = props.classes;
             return classnames(toggableIcon, {
                 [iconOpen]: isOpen,
                 [iconClose]: !isOpen,
@@ -369,8 +317,8 @@ export const Tree = withStyles(styles)(
             });
         }
 
-        handleCheckboxChange = (item: TreeItem<T>) => {
-            const { toggleItemSelection } = this.props;
+        const handleCheckboxChange = (item: TreeItem<T>) => {
+            const { toggleItemSelection } = props;
             return toggleItemSelection
                 ? (event: React.MouseEvent<HTMLElement>) => {
                     event.stopPropagation();
@@ -379,9 +327,95 @@ export const Tree = withStyles(styles)(
                 : undefined;
         }
 
-        handleToggleItemOpen = (item: TreeItem<T>, event: React.MouseEvent<HTMLElement>) => {
+        const handleToggleItemOpen = (item: TreeItem<T>, event: React.MouseEvent<HTMLElement>) => {
             event.stopPropagation();
-            this.props.toggleItemOpen(event, item);
+            props.toggleItemOpen(event, item);
         }
+
+        // Scroll to selected item whenever it changes, accepts selectedRef from props for recursive trees
+        const [cachedSelectedRef, setCachedRef] = useState<HTMLDivElement | null>(null)
+        const selectedRef = props.selectedRef || useCallback((node: HTMLDivElement | null) => {
+            if (node && node.scrollIntoView && node !== cachedSelectedRef) {
+                node.scrollIntoView({ behavior: "smooth", block: "center" });
+            }
+            setCachedRef(node);
+        }, [cachedSelectedRef]);
+
+        const { levelIndentation = 20, itemRightPadding = 20 } = props;
+        return <List className={list}>
+            {items && items.map((it: TreeItem<T>, idx: number) => {
+                if (isInFavoritesTree(it) && it.open === true && it.items && it.items.length) {
+                    it = { ...it, items: it.items.filter(item => item.depth && item.depth < 3) }
+                }
+                return <div key={`item/${level}/${it.id}`}>
+                    <ListItem button className={listItem}
+                        style={{
+                            paddingLeft: (level + 1) * levelIndentation,
+                            paddingRight: itemRightPadding,
+                        }}
+                        disableRipple={disableRipple}
+                        onClick={event => toggleItemActive(event, it)}
+                        selected={showSelection(it) && it.id === currentItemUuid}
+                        onContextMenu={(event) => props.onContextMenu(event, it)}>
+                        {it.status === TreeItemStatus.PENDING ?
+                            <CircularProgress size={10} className={loader} /> : null}
+                        <i onClick={(e) => handleToggleItemOpen(it, e)}
+                            className={toggableIconContainer}>
+                            <ListItemIcon className={getToggableIconClassNames(it.open, it.active)}>
+                                {getProperArrowAnimation(it.status, it.items!)}
+                            </ListItemIcon>
+                        </i>
+                        {showSelection(it) && !useRadioButtons &&
+                            <Checkbox
+                                checked={it.selected}
+                                indeterminate={!it.selected && it.indeterminate}
+                                className={classes.checkbox}
+                                color="primary"
+                                onClick={handleCheckboxChange(it)} />}
+                        {showSelection(it) && useRadioButtons &&
+                            <Radio
+                                checked={it.selected}
+                                className={classes.checkbox}
+                                color="primary" />}
+                        <div className={renderContainer} ref={!!it.active ? selectedRef : undefined}>
+                            {render(it, level)}
+                        </div>
+                    </ListItem>
+                    {
+                        it.open && it.items && it.items.length > 0 &&
+                            it.flatTree ?
+                            <FlatTree
+                                it={it}
+                                itemsMap={itemsMap}
+                                showSelection={showSelection}
+                                classes={props.classes}
+                                useRadioButtons={useRadioButtons}
+                                levelIndentation={levelIndentation}
+                                handleCheckboxChange={handleCheckboxChange}
+                                onContextMenu={props.onContextMenu}
+                                handleToggleItemOpen={handleToggleItemOpen}
+                                toggleItemActive={props.toggleItemActive}
+                                getToggableIconClassNames={getToggableIconClassNames}
+                                getProperArrowAnimation={getProperArrowAnimation}
+                                selectedRef={selectedRef}
+                            /> :
+                            <Collapse in={it.open} timeout="auto" unmountOnExit>
+                                <Tree
+                                    showSelection={props.showSelection}
+                                    items={it.items}
+                                    render={render}
+                                    disableRipple={disableRipple}
+                                    toggleItemOpen={toggleItemOpen}
+                                    toggleItemActive={toggleItemActive}
+                                    level={level + 1}
+                                    onContextMenu={props.onContextMenu}
+                                    toggleItemSelection={props.toggleItemSelection}
+                                    selectedRef={selectedRef}
+                                />
+                            </Collapse>
+                    }
+                </div>;
+            })}
+        </List>;
     }
 );
index 244d1387307778de1f0c4ccd01412bdc85797ccd..ef9ff9c98693576880141b679db8aff6f675d24c 100644 (file)
@@ -2,71 +2,97 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-import ReactDOM from 'react-dom';
+import React from "react";
+import ReactDOM from "react-dom";
 import { Provider } from "react-redux";
-import { MainPanel } from 'views/main-panel/main-panel';
-import 'index.css';
-import { Route, Switch } from 'react-router';
+import { MainPanel } from "views/main-panel/main-panel";
+import "index.css";
+import { Route, Switch } from "react-router";
 import { createBrowserHistory } from "history";
 import { History } from "history";
-import { configureStore, RootStore } from 'store/store';
+import { configureStore, RootStore } from "store/store";
 import { ConnectedRouter } from "react-router-redux";
 import { ApiToken } from "views-components/api-token/api-token";
 import { AddSession } from "views-components/add-session/add-session";
 import { initAuth, logout } from "store/auth/auth-action";
 import { createServices } from "services/services";
-import { MuiThemeProvider } from '@material-ui/core/styles';
-import { CustomTheme } from 'common/custom-theme';
-import { fetchConfig } from 'common/config';
-import servicesProvider from 'common/service-provider';
-import { addMenuActionSet, ContextMenuKind } from 'views-components/context-menu/context-menu';
+import { MuiThemeProvider } from "@material-ui/core/styles";
+import { CustomTheme } from "common/custom-theme";
+import { fetchConfig } from "common/config";
+import servicesProvider from "common/service-provider";
+import { addMenuActionSet, ContextMenuKind } from "views-components/context-menu/context-menu";
 import { rootProjectActionSet } from "views-components/context-menu/action-sets/root-project-action-set";
-import { filterGroupActionSet, frozenActionSet, projectActionSet, readOnlyProjectActionSet } from "views-components/context-menu/action-sets/project-action-set";
-import { resourceActionSet } from 'views-components/context-menu/action-sets/resource-action-set';
+import {
+    filterGroupActionSet,
+    frozenActionSet,
+    projectActionSet,
+    readOnlyProjectActionSet,
+} from "views-components/context-menu/action-sets/project-action-set";
+import { resourceActionSet } from "views-components/context-menu/action-sets/resource-action-set";
 import { favoriteActionSet } from "views-components/context-menu/action-sets/favorite-action-set";
-import { collectionFilesActionSet, readOnlyCollectionFilesActionSet } from 'views-components/context-menu/action-sets/collection-files-action-set';
-import { collectionDirectoryItemActionSet, collectionFileItemActionSet, readOnlyCollectionDirectoryItemActionSet, readOnlyCollectionFileItemActionSet } from 'views-components/context-menu/action-sets/collection-files-item-action-set';
-import { collectionFilesNotSelectedActionSet } from 'views-components/context-menu/action-sets/collection-files-not-selected-action-set';
-import { collectionActionSet, collectionAdminActionSet, oldCollectionVersionActionSet, readOnlyCollectionActionSet } from 'views-components/context-menu/action-sets/collection-action-set';
-import { loadWorkbench } from 'store/workbench/workbench-actions';
-import { Routes } from 'routes/routes';
+import {
+    collectionFilesActionSet,
+    collectionFilesMultipleActionSet,
+    readOnlyCollectionFilesActionSet,
+    readOnlyCollectionFilesMultipleActionSet,
+} from "views-components/context-menu/action-sets/collection-files-action-set";
+import {
+    collectionDirectoryItemActionSet,
+    collectionFileItemActionSet,
+    readOnlyCollectionDirectoryItemActionSet,
+    readOnlyCollectionFileItemActionSet,
+} from "views-components/context-menu/action-sets/collection-files-item-action-set";
+import { collectionFilesNotSelectedActionSet } from "views-components/context-menu/action-sets/collection-files-not-selected-action-set";
+import {
+    collectionActionSet,
+    collectionAdminActionSet,
+    oldCollectionVersionActionSet,
+    readOnlyCollectionActionSet,
+} from "views-components/context-menu/action-sets/collection-action-set";
+import { loadWorkbench } from "store/workbench/workbench-actions";
+import { Routes } from "routes/routes";
 import { trashActionSet } from "views-components/context-menu/action-sets/trash-action-set";
-import { ServiceRepository } from 'services/services';
-import { initWebSocket } from 'websocket/websocket';
-import { Config } from 'common/config';
-import { addRouteChangeHandlers } from './routes/route-change-handlers';
-import { setTokenDialogApiHost } from 'store/token-dialog/token-dialog-actions';
+import { ServiceRepository } from "services/services";
+import { initWebSocket } from "websocket/websocket";
+import { Config } from "common/config";
+import { addRouteChangeHandlers } from "./routes/route-change-handlers";
+import { setTokenDialogApiHost } from "store/token-dialog/token-dialog-actions";
 import {
     processResourceActionSet,
+    runningProcessResourceActionSet,
     processResourceAdminActionSet,
-    readOnlyProcessResourceActionSet
-} from 'views-components/context-menu/action-sets/process-resource-action-set';
-import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
-import { trashedCollectionActionSet } from 'views-components/context-menu/action-sets/trashed-collection-action-set';
-import { setBuildInfo } from 'store/app-info/app-info-actions';
-import { getBuildInfo } from 'common/app-info';
-import { DragDropContextProvider } from 'react-dnd';
-import HTML5Backend from 'react-dnd-html5-backend';
-import { initAdvancedFormProjectsTree } from 'store/search-bar/search-bar-actions';
-import { repositoryActionSet } from 'views-components/context-menu/action-sets/repository-action-set';
-import { sshKeyActionSet } from 'views-components/context-menu/action-sets/ssh-key-action-set';
-import { keepServiceActionSet } from 'views-components/context-menu/action-sets/keep-service-action-set';
-import { loadVocabulary } from 'store/vocabulary/vocabulary-actions';
-import { virtualMachineActionSet } from 'views-components/context-menu/action-sets/virtual-machine-action-set';
-import { userActionSet } from 'views-components/context-menu/action-sets/user-action-set';
-import { apiClientAuthorizationActionSet } from 'views-components/context-menu/action-sets/api-client-authorization-action-set';
-import { groupActionSet } from 'views-components/context-menu/action-sets/group-action-set';
-import { groupMemberActionSet } from 'views-components/context-menu/action-sets/group-member-action-set';
-import { linkActionSet } from 'views-components/context-menu/action-sets/link-action-set';
-import { loadFileViewersConfig } from 'store/file-viewers/file-viewers-actions';
-import { filterGroupAdminActionSet, frozenAdminActionSet, projectAdminActionSet } from 'views-components/context-menu/action-sets/project-admin-action-set';
-import { permissionEditActionSet } from 'views-components/context-menu/action-sets/permission-edit-action-set';
-import { workflowActionSet } from 'views-components/context-menu/action-sets/workflow-action-set';
-import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
-import { openNotFoundDialog } from './store/not-found-panel/not-found-panel-action';
-import { storeRedirects } from './common/redirect-to';
-import { searchResultsActionSet } from 'views-components/context-menu/action-sets/search-results-action-set';
+    runningProcessResourceAdminActionSet,
+    readOnlyProcessResourceActionSet,
+} from "views-components/context-menu/action-sets/process-resource-action-set";
+import { trashedCollectionActionSet } from "views-components/context-menu/action-sets/trashed-collection-action-set";
+import { setBuildInfo } from "store/app-info/app-info-actions";
+import { getBuildInfo } from "common/app-info";
+import { DragDropContextProvider } from "react-dnd";
+import HTML5Backend from "react-dnd-html5-backend";
+import { initAdvancedFormProjectsTree } from "store/search-bar/search-bar-actions";
+import { repositoryActionSet } from "views-components/context-menu/action-sets/repository-action-set";
+import { sshKeyActionSet } from "views-components/context-menu/action-sets/ssh-key-action-set";
+import { keepServiceActionSet } from "views-components/context-menu/action-sets/keep-service-action-set";
+import { loadVocabulary } from "store/vocabulary/vocabulary-actions";
+import { virtualMachineActionSet } from "views-components/context-menu/action-sets/virtual-machine-action-set";
+import { userActionSet } from "views-components/context-menu/action-sets/user-action-set";
+import { apiClientAuthorizationActionSet } from "views-components/context-menu/action-sets/api-client-authorization-action-set";
+import { groupActionSet } from "views-components/context-menu/action-sets/group-action-set";
+import { groupMemberActionSet } from "views-components/context-menu/action-sets/group-member-action-set";
+import { linkActionSet } from "views-components/context-menu/action-sets/link-action-set";
+import { loadFileViewersConfig } from "store/file-viewers/file-viewers-actions";
+import {
+    filterGroupAdminActionSet,
+    frozenAdminActionSet,
+    projectAdminActionSet,
+} from "views-components/context-menu/action-sets/project-admin-action-set";
+import { permissionEditActionSet } from "views-components/context-menu/action-sets/permission-edit-action-set";
+import { workflowActionSet, readOnlyWorkflowActionSet } from "views-components/context-menu/action-sets/workflow-action-set";
+import { storeRedirects } from "./common/redirect-to";
+import { searchResultsActionSet } from "views-components/context-menu/action-sets/search-results-action-set";
+
+import 'bootstrap/dist/css/bootstrap.min.css';
+import '@coreui/coreui/dist/css/coreui.min.css';
 
 console.log(`Starting arvados [${getBuildInfo()}]`);
 
@@ -77,7 +103,9 @@ addMenuActionSet(ContextMenuKind.FILTER_GROUP, filterGroupActionSet);
 addMenuActionSet(ContextMenuKind.RESOURCE, resourceActionSet);
 addMenuActionSet(ContextMenuKind.FAVORITE, favoriteActionSet);
 addMenuActionSet(ContextMenuKind.COLLECTION_FILES, collectionFilesActionSet);
+addMenuActionSet(ContextMenuKind.COLLECTION_FILES_MULTIPLE, collectionFilesMultipleActionSet);
 addMenuActionSet(ContextMenuKind.READONLY_COLLECTION_FILES, readOnlyCollectionFilesActionSet);
+addMenuActionSet(ContextMenuKind.READONLY_COLLECTION_FILES_MULTIPLE, readOnlyCollectionFilesMultipleActionSet);
 addMenuActionSet(ContextMenuKind.COLLECTION_FILES_NOT_SELECTED, collectionFilesNotSelectedActionSet);
 addMenuActionSet(ContextMenuKind.COLLECTION_DIRECTORY_ITEM, collectionDirectoryItemActionSet);
 addMenuActionSet(ContextMenuKind.READONLY_COLLECTION_DIRECTORY_ITEM, readOnlyCollectionDirectoryItemActionSet);
@@ -88,6 +116,7 @@ addMenuActionSet(ContextMenuKind.READONLY_COLLECTION, readOnlyCollectionActionSe
 addMenuActionSet(ContextMenuKind.OLD_VERSION_COLLECTION, oldCollectionVersionActionSet);
 addMenuActionSet(ContextMenuKind.TRASHED_COLLECTION, trashedCollectionActionSet);
 addMenuActionSet(ContextMenuKind.PROCESS_RESOURCE, processResourceActionSet);
+addMenuActionSet(ContextMenuKind.RUNNING_PROCESS_RESOURCE, runningProcessResourceActionSet);
 addMenuActionSet(ContextMenuKind.READONLY_PROCESS_RESOURCE, readOnlyProcessResourceActionSet);
 addMenuActionSet(ContextMenuKind.TRASH, trashActionSet);
 addMenuActionSet(ContextMenuKind.REPOSITORY, repositoryActionSet);
@@ -101,94 +130,106 @@ addMenuActionSet(ContextMenuKind.GROUPS, groupActionSet);
 addMenuActionSet(ContextMenuKind.GROUP_MEMBER, groupMemberActionSet);
 addMenuActionSet(ContextMenuKind.COLLECTION_ADMIN, collectionAdminActionSet);
 addMenuActionSet(ContextMenuKind.PROCESS_ADMIN, processResourceAdminActionSet);
+addMenuActionSet(ContextMenuKind.RUNNING_PROCESS_ADMIN, runningProcessResourceAdminActionSet);
 addMenuActionSet(ContextMenuKind.PROJECT_ADMIN, projectAdminActionSet);
 addMenuActionSet(ContextMenuKind.FROZEN_PROJECT, frozenActionSet);
 addMenuActionSet(ContextMenuKind.FROZEN_PROJECT_ADMIN, frozenAdminActionSet);
 addMenuActionSet(ContextMenuKind.FILTER_GROUP_ADMIN, filterGroupAdminActionSet);
 addMenuActionSet(ContextMenuKind.PERMISSION_EDIT, permissionEditActionSet);
+addMenuActionSet(ContextMenuKind.READONLY_WORKFLOW, readOnlyWorkflowActionSet);
 addMenuActionSet(ContextMenuKind.WORKFLOW, workflowActionSet);
 addMenuActionSet(ContextMenuKind.SEARCH_RESULTS, searchResultsActionSet);
 
 storeRedirects();
 
-fetchConfig()
-    .then(({ config, apiHost }) => {
-        const history = createBrowserHistory();
-
-        // Provide browser's history access to Cypress to allow programmatic
-        // navigation.
-        if ((window as any).Cypress) {
-            (window as any).appHistory = history;
-        }
-
-        const services = createServices(config, {
-            progressFn: (id, working) => {
-                store.dispatch(progressIndicatorActions.TOGGLE_WORKING({ id, working }));
-            },
-            errorFn: (id, error, showSnackBar: boolean) => {
-                if (showSnackBar) {
-                    console.error("Backend error:", error);
-
-                    if (error.status === 404) {
-                        store.dispatch(openNotFoundDialog());
-                    } else if (error.status === 401 && error.errors[0].indexOf("Not logged in") > -1) {
-                        // Catch auth errors when navigating and redirect to login preserving url location
-                        store.dispatch(logout(false, true));
-                    } else {
-                        store.dispatch(snackbarActions.OPEN_SNACKBAR({
-                            message: `${error.errors
-                                ? error.errors[0]
-                                : error.message}`,
-                            kind: SnackbarKind.ERROR,
-                            hideDuration: 8000
-                        })
-                        );
-                    }
+fetchConfig().then(({ config, apiHost }) => {
+    const history = createBrowserHistory();
+
+    // Provide browser's history access to Cypress to allow programmatic
+    // navigation.
+    if ((window as any).Cypress) {
+        (window as any).appHistory = history;
+    }
+
+    const services = createServices(config, {
+        progressFn: (id, working) => {
+        },
+        errorFn: (id, error, showSnackBar: boolean) => {
+            if (showSnackBar) {
+                console.error("Backend error:", error);
+                if (error.status === 401 && error.errors[0].indexOf("Not logged in") > -1) {
+                    // Catch auth errors when navigating and redirect to login preserving url location
+                    store.dispatch(logout(false, true));
                 }
             }
-        });
-
-        // be sure this is initiated before the app starts
-        servicesProvider.setServices(services);
-
-        const store = configureStore(history, services, config);
-
-        servicesProvider.setStore(store);
-
-        store.subscribe(initListener(history, store, services, config));
-        store.dispatch(initAuth(config));
-        store.dispatch(setBuildInfo());
-        store.dispatch(setTokenDialogApiHost(apiHost));
-        store.dispatch(loadVocabulary);
-        store.dispatch(loadFileViewersConfig);
-
-        const TokenComponent = (props: any) => <ApiToken authService={services.authService} config={config} loadMainApp={true} {...props} />;
-        const AddSessionComponent = (props: any) => <AddSession {...props} />;
-        const FedTokenComponent = (props: any) => <ApiToken authService={services.authService} config={config} loadMainApp={false} {...props} />;
-        const MainPanelComponent = (props: any) => <MainPanel {...props} />;
-
-        const App = () =>
-            <MuiThemeProvider theme={CustomTheme}>
-                <DragDropContextProvider backend={HTML5Backend}>
-                    <Provider store={store}>
-                        <ConnectedRouter history={history}>
-                            <Switch>
-                                <Route path={Routes.TOKEN} component={TokenComponent} />
-                                <Route path={Routes.FED_LOGIN} component={FedTokenComponent} />
-                                <Route path={Routes.ADD_SESSION} component={AddSessionComponent} />
-                                <Route path={Routes.ROOT} component={MainPanelComponent} />
-                            </Switch>
-                        </ConnectedRouter>
-                    </Provider>
-                </DragDropContextProvider>
-            </MuiThemeProvider>;
-
-        ReactDOM.render(
-            <App />,
-            document.getElementById('root') as HTMLElement
-        );
+        },
     });
 
+    // be sure this is initiated before the app starts
+    servicesProvider.setServices(services);
+
+    const store = configureStore(history, services, config);
+
+    servicesProvider.setStore(store);
+
+    store.subscribe(initListener(history, store, services, config));
+    store.dispatch(initAuth(config));
+    store.dispatch(setBuildInfo());
+    store.dispatch(setTokenDialogApiHost(apiHost));
+    store.dispatch(loadVocabulary);
+    store.dispatch(loadFileViewersConfig);
+
+    const TokenComponent = (props: any) => (
+        <ApiToken
+            authService={services.authService}
+            config={config}
+            loadMainApp={true}
+            {...props}
+        />
+    );
+    const AddSessionComponent = (props: any) => <AddSession {...props} />;
+    const FedTokenComponent = (props: any) => (
+        <ApiToken
+            authService={services.authService}
+            config={config}
+            loadMainApp={false}
+            {...props}
+        />
+    );
+    const MainPanelComponent = (props: any) => <MainPanel {...props} />;
+
+    const App = () => (
+        <MuiThemeProvider theme={CustomTheme}>
+            <DragDropContextProvider backend={HTML5Backend}>
+                <Provider store={store}>
+                    <ConnectedRouter history={history}>
+                        <Switch>
+                            <Route
+                                path={Routes.TOKEN}
+                                component={TokenComponent}
+                            />
+                            <Route
+                                path={Routes.FED_LOGIN}
+                                component={FedTokenComponent}
+                            />
+                            <Route
+                                path={Routes.ADD_SESSION}
+                                component={AddSessionComponent}
+                            />
+                            <Route
+                                path={Routes.ROOT}
+                                component={MainPanelComponent}
+                            />
+                        </Switch>
+                    </ConnectedRouter>
+                </Provider>
+            </DragDropContextProvider>
+        </MuiThemeProvider>
+    );
+
+    ReactDOM.render(<App />, document.getElementById("root") as HTMLElement);
+});
+
 const initListener = (history: History, store: RootStore, services: ServiceRepository, config: Config) => {
     let initialized = false;
     return async () => {
index 91008d1fdaf15d33f824ae40c2a694dbb38f5efd..3688557a6154c8e8a3fc9dca87d486d004a6b754 100644 (file)
@@ -71,10 +71,10 @@ export const createCollectionFilesTree = (data: Array<CollectionDirectory | Coll
 
 const getParentId = (item: CollectionDirectory | CollectionFile) =>
     item.path
-        ? join('', [getCollectionId(item.id), item.path])
+        ? join('', [getCollectionResourceCollectionUuid(item.id), item.path])
         : item.path;
 
-const getCollectionId = pipe(
+export const getCollectionResourceCollectionUuid = pipe(
     split('/'),
     head,
-);
\ No newline at end of file
+);
index aa5e0f799934a935c2266c00f9882cfa4a3b5cad..d3adb03a92a435019f3a368cd36f0a822773f999 100644 (file)
@@ -8,41 +8,41 @@ import { RuntimeConstraints } from './runtime-constraints';
 import { SchedulingParameters } from './scheduling-parameters';
 
 export enum ContainerRequestState {
-  UNCOMMITTED = 'Uncommitted',
-  COMMITTED = 'Committed',
-  FINAL = 'Final',
+    UNCOMMITTED = 'Uncommitted',
+    COMMITTED = 'Committed',
+    FINAL = 'Final',
 }
 
 export interface ContainerRequestResource
-  extends Resource,
+    extends Resource,
     ResourceWithProperties {
-  command: string[];
-  containerCountMax: number;
-  containerCount: number;
-  containerImage: string;
-  containerUuid: string | null;
-  cumulativeCost: number;
-  cwd: string;
-  description: string;
-  environment: any;
-  expiresAt: string;
-  filters: string;
-  kind: ResourceKind.CONTAINER_REQUEST;
-  logUuid: string | null;
-  mounts: { [path: string]: MountType };
-  name: string;
-  outputName: string;
-  outputPath: string;
-  outputProperties: any;
-  outputStorageClasses: string[];
-  outputTtl: number;
-  outputUuid: string | null;
-  priority: number | null;
-  requestingContainerUuid: string | null;
-  runtimeConstraints: RuntimeConstraints;
-  schedulingParameters: SchedulingParameters;
-  state: ContainerRequestState;
-  useExisting: boolean;
+    command: string[];
+    containerCountMax: number;
+    containerCount: number;
+    containerImage: string;
+    containerUuid: string | null;
+    cumulativeCost: number;
+    cwd: string;
+    description: string;
+    environment: any;
+    expiresAt: string;
+    filters: string;
+    kind: ResourceKind.CONTAINER_REQUEST;
+    logUuid: string | null;
+    mounts: { [path: string]: MountType };
+    name: string;
+    outputName: string;
+    outputPath: string;
+    outputProperties: any;
+    outputStorageClasses: string[];
+    outputTtl: number;
+    outputUuid: string | null;
+    priority: number | null;
+    requestingContainerUuid: string | null;
+    runtimeConstraints: RuntimeConstraints;
+    schedulingParameters: SchedulingParameters;
+    state: ContainerRequestState;
+    useExisting: boolean;
 }
 
 // Until the api supports unselecting fields, we need a list of all other fields to omit mounts
@@ -53,6 +53,7 @@ export const containerRequestFieldsNoMounts = [
     "container_image",
     "container_uuid",
     "created_at",
+    "cumulative_cost",
     "cwd",
     "description",
     "environment",
index f6a72c538f9481392aabac0883077cbf2086a6b4..0932b3c95e63665e3cbe68e1c416a12e8803a470 100644 (file)
@@ -15,14 +15,15 @@ export interface GroupResource extends TrashableResource, ResourceWithProperties
     name: string;
     groupClass: GroupClass | null;
     description: string;
-    writableBy: string[];
     ensure_unique_name: boolean;
+    canWrite: boolean;
+    canManage: boolean;
 }
 
 export enum GroupClass {
     PROJECT = 'project',
-    FILTER  = 'filter',
-    ROLE  = 'role',
+    FILTER = 'filter',
+    ROLE = 'role',
 }
 
 export enum BuiltinGroups {
@@ -40,3 +41,17 @@ export const isBuiltinGroup = (uuid: string) => {
     const parts = match ? match[0].split('-') : [];
     return parts.length === 3 && parts[1] === ResourceObjectType.GROUP && Object.values<string>(BuiltinGroups).includes(parts[2]);
 };
+
+export const selectedFieldsOfGroup = [
+    "uuid",
+    "name",
+    "group_class",
+    "description",
+    "properties",
+    "can_write",
+    "can_manage",
+    "trash_at",
+    "delete_at",
+    "is_trashed",
+    "frozen_by_uuid"
+];
index 0109ad61459a549fc6d58e18aadd92d5c2137528..f5d351acb1176578a3f60a6115a9cb3aeafe66d9 100644 (file)
@@ -18,7 +18,6 @@ export enum LogEventType {
     STDERR = 'stderr',
     CONTAINER = 'container',
     KEEPSTORE = 'keepstore',
-    SNIP = 'snip-line', // This type is for internal use only. See #19851
 }
 
 export interface LogResource extends Resource, ResourceWithProperties {
index 04dae4d22622a9ae44c39039167e011d7ba807a7..8dd2e716e2400cfe6c15f855f310e8a0dadb97f3 100644 (file)
@@ -5,8 +5,7 @@
 import { GroupClass, GroupResource } from "./group";
 
 export interface ProjectResource extends GroupResource {
-    frozenByUuid: null|string;
-    canManage: boolean;
+    frozenByUuid: null | string;
     groupClass: GroupClass.PROJECT | GroupClass.FILTER | GroupClass.ROLE;
 }
 
index 1e1041a1d37f5ec9bc990efd87d98870dd1bc609..74667a915e9d15a3d7b1c08bda9cc1233fe8f2e5 100644 (file)
@@ -23,8 +23,9 @@ export const mockGroupResource = (data: Partial<GroupResource> = {}): GroupResou
     properties: "",
     trashAt: "",
     uuid: "",
-    writableBy: [],
     ensure_unique_name: true,
+    canWrite: false,
+    canManage: false,
     ...data
 });
 
index 3c7fdca9afdee6357b204ac4f4edad199155d79b..0e8063b045602212f683043ac603aec884cfa4cb 100644 (file)
@@ -99,4 +99,35 @@ describe('Tree', () => {
         const mappedTree = Tree.mapTreeValues<string, number>(value => parseInt(value.split(' ')[1], 10))(newTree);
         expect(Tree.getNode('Node 2')(mappedTree)).toEqual(initTreeNode({ id: 'Node 2', parent: 'Node 1', value: 2 }));
     });
+
+    it('expands node ancestor chains', () => {
+        const newTree = [
+            initTreeNode({ id: 'Root Node 1', parent: '', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 1.1', parent: 'Root Node 1', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 1.1.1', parent: 'Node 1.1', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 1.2', parent: 'Root Node 1', value: 'Value 1' }),
+
+            initTreeNode({ id: 'Root Node 2', parent: '', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 2.1', parent: 'Root Node 2', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 2.1.1', parent: 'Node 2.1', value: 'Value 1' }),
+
+            initTreeNode({ id: 'Root Node 3', parent: '', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 3.1', parent: 'Root Node 3', value: 'Value 1' }),
+        ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
+
+        const expandedTree = Tree.expandNodeAncestors(
+            'Node 1.1.1', // Expands 1.1 and 1
+            'Node 2.1', // Expands 2
+        )(newTree);
+
+        expect(Tree.getNode('Root Node 1')(expandedTree)?.expanded).toEqual(true);
+        expect(Tree.getNode('Node 1.1')(expandedTree)?.expanded).toEqual(true);
+        expect(Tree.getNode('Node 1.1.1')(expandedTree)?.expanded).toEqual(false);
+        expect(Tree.getNode('Node 1.2')(expandedTree)?.expanded).toEqual(false);
+        expect(Tree.getNode('Root Node 2')(expandedTree)?.expanded).toEqual(true);
+        expect(Tree.getNode('Node 2.1')(expandedTree)?.expanded).toEqual(false);
+        expect(Tree.getNode('Node 2.1.1')(expandedTree)?.expanded).toEqual(false);
+        expect(Tree.getNode('Root Node 3')(expandedTree)?.expanded).toEqual(false);
+        expect(Tree.getNode('Node 3.1')(expandedTree)?.expanded).toEqual(false);
+    });
 });
index 996f98a465865ee5fd0861bcc391a505735ef115..aeb415411e8102a66acfcf11fbc51c6cb42d29ac 100644 (file)
@@ -138,6 +138,11 @@ export const deactivateNode = <T>(tree: Tree<T>) =>
 export const expandNode = (...ids: string[]) => <T>(tree: Tree<T>) =>
     mapTree((node: TreeNode<T>) => ids.some(id => id === node.id) ? { ...node, expanded: true } : node)(tree);
 
+export const expandNodeAncestors = (...ids: string[]) => <T>(tree: Tree<T>) => {
+    const ancestors = ids.reduce((acc, id): string[] => ([...acc, ...getNodeAncestorsIds(id)(tree)]), [] as string[]);
+    return mapTree((node: TreeNode<T>) => ancestors.some(id => id === node.id) ? { ...node, expanded: true } : node)(tree);
+}
+
 export const collapseNode = (...ids: string[]) => <T>(tree: Tree<T>) =>
     mapTree((node: TreeNode<T>) => ids.some(id => id === node.id) ? { ...node, expanded: false } : node)(tree);
 
@@ -151,37 +156,40 @@ export const setNodeStatus = (id: string) => (status: TreeNodeStatus) => <T>(tre
         : tree;
 };
 
-export const toggleNodeSelection = (id: string) => <T>(tree: Tree<T>) => {
+export const toggleNodeSelection = (id: string, cascade: boolean) => <T>(tree: Tree<T>) => {
     const node = getNode(id)(tree);
+
     return node
-        ? pipe(
-            setNode({ ...node, selected: !node.selected }),
-            toggleAncestorsSelection(id),
-            toggleDescendantsSelection(id))(tree)
+        ? cascade
+            ? pipe(
+                setNode({ ...node, selected: !node.selected }),
+                toggleAncestorsSelection(id),
+                toggleDescendantsSelection(id))(tree)
+            : setNode({ ...node, selected: !node.selected })(tree)
         : tree;
 };
 
-export const selectNode = (id: string) => <T>(tree: Tree<T>) => {
+export const selectNode = (id: string, cascade: boolean) => <T>(tree: Tree<T>) => {
     const node = getNode(id)(tree);
     return node && node.selected
         ? tree
-        : toggleNodeSelection(id)(tree);
+        : toggleNodeSelection(id, cascade)(tree);
 };
 
-export const selectNodes = (id: string | string[]) => <T>(tree: Tree<T>) => {
+export const selectNodes = (id: string | string[], cascade: boolean) => <T>(tree: Tree<T>) => {
     const ids = typeof id === 'string' ? [id] : id;
-    return ids.reduce((tree, id) => selectNode(id)(tree), tree);
+    return ids.reduce((tree, id) => selectNode(id, cascade)(tree), tree);
 };
-export const deselectNode = (id: string) => <T>(tree: Tree<T>) => {
+export const deselectNode = (id: string, cascade: boolean) => <T>(tree: Tree<T>) => {
     const node = getNode(id)(tree);
     return node && node.selected
-        ? toggleNodeSelection(id)(tree)
+        ? toggleNodeSelection(id, cascade)(tree)
         : tree;
 };
 
-export const deselectNodes = (id: string | string[]) => <T>(tree: Tree<T>) => {
+export const deselectNodes = (id: string | string[], cascade: boolean) => <T>(tree: Tree<T>) => {
     const ids = typeof id === 'string' ? [id] : id;
-    return ids.reduce((tree, id) => deselectNode(id)(tree), tree);
+    return ids.reduce((tree, id) => deselectNode(id, cascade)(tree), tree);
 };
 
 export const getSelectedNodes = <T>(tree: Tree<T>) =>
index 87a2e8c13d472200bfdd97ebf333ed6defe8f57f..0df6eac24158809ce426560359bab96b0985fe1c 100644 (file)
@@ -24,6 +24,8 @@ export interface User {
     prefs: UserPrefs;
     isAdmin: boolean;
     isActive: boolean;
+    canWrite: boolean;
+    canManage: boolean;
 }
 
 export const getUserFullname = (user: User) => {
@@ -62,5 +64,4 @@ export const getUserClusterID = (user: User): string | undefined => {
 export interface UserResource extends Resource, User {
     kind: ResourceKind.USER;
     defaultOwnerUuid: string;
-    writableBy: string[];
 }
index e85dce7a6a02e697fe9b674630b530b9599d09cc..369db4c7c77a875dfea99ef5fe7e0a3a703d2526 100644 (file)
@@ -185,10 +185,31 @@ export const isPrimitiveOfType = (input: GenericCommandInputParameter<any, any>,
         : input.type === type;
 
 export const isArrayOfType = (input: GenericCommandInputParameter<any, any>, type: CWLType) =>
-    typeof input.type === 'object' &&
-        input.type.type === 'array'
-        ? input.type.items === type
-        : false;
+    input.type instanceof Array
+        ? (input.type.filter(t => typeof t === 'object' &&
+            t.type === 'array' &&
+            t.items === type).length > 0)
+        : (typeof input.type === 'object' &&
+            input.type.type === 'array' &&
+            input.type.items === type);
+
+export const getEnumType = (input: GenericCommandInputParameter<any, any>) => {
+    if (input.type instanceof Array) {
+        const f = input.type.filter(t => typeof t === 'object' &&
+            !(t instanceof Array) &&
+            t.type === 'enum');
+        if (f.length > 0) {
+            return f[0];
+        }
+    } else {
+        if ((typeof input.type === 'object' &&
+            !(input.type instanceof Array) &&
+            input.type.type === 'enum')) {
+            return input.type;
+        }
+    }
+    return null;
+};
 
 export const stringifyInputType = ({ type }: CommandInputParameter) => {
     if (typeof type === 'string') {
index 237b6d9611bd3d5c9b3f8ff994c92a9dc075e5b5..bdc1ddc06325bb473dc7493595df0f42fd56c5a7 100644 (file)
@@ -11,6 +11,7 @@ import { dialogActions } from 'store/dialog/dialog-actions';
 import { contextMenuActions } from 'store/context-menu/context-menu-actions';
 import { searchBarActions } from 'store/search-bar/search-bar-actions';
 import { pluginConfig } from 'plugins';
+import { openProjectPanel } from 'store/project-panel/project-panel-action';
 
 export const addRouteChangeHandlers = (history: History, store: RootStore) => {
     const handler = handleLocationChange(store);
@@ -47,6 +48,7 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
     const linksMatch = Routes.matchLinksRoute(pathname);
     const collectionsContentAddressMatch = Routes.matchCollectionsContentAddressRoute(pathname);
     const allProcessesMatch = Routes.matchAllProcessesRoute(pathname);
+    const registeredWorkflowMatch = Routes.matchRegisteredWorkflowRoute(pathname);
 
     store.dispatch(dialogActions.CLOSE_ALL_DIALOGS());
     store.dispatch(contextMenuActions.CLOSE_CONTEXT_MENU());
@@ -58,8 +60,10 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
         }
     }
 
+    document.title = `Arvados (${store.getState().auth.config.uuidPrefix}) - ${pathname.slice(1)}`;
+
     if (projectMatch) {
-        store.dispatch(WorkbenchActions.loadProject(projectMatch.params.id));
+        store.dispatch(openProjectPanel(projectMatch.params.id));
     } else if (collectionMatch) {
         store.dispatch(WorkbenchActions.loadCollection(collectionMatch.params.id));
     } else if (favoriteMatch) {
@@ -112,5 +116,7 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
         store.dispatch(WorkbenchActions.loadCollectionContentAddress);
     } else if (allProcessesMatch) {
         store.dispatch(WorkbenchActions.loadAllProcesses());
+    } else if (registeredWorkflowMatch) {
+        store.dispatch(WorkbenchActions.loadRegisteredWorkflow(registeredWorkflowMatch.params.id));
     }
 };
index 22c8f4c8e52414b8d84c99cc439facc4d4f9ecdf..4dfd998e8dadcc08450fc727b4389c03d199c5c0 100644 (file)
@@ -31,6 +31,7 @@ export const Routes = {
     VIRTUAL_MACHINES_ADMIN: '/virtual-machines-admin',
     VIRTUAL_MACHINES_USER: '/virtual-machines-user',
     WORKFLOWS: '/workflows',
+    REGISTEREDWORKFLOW: `/workflows/:id(${RESOURCE_UUID_PATTERN})`,
     SEARCH_RESULTS: '/search-results',
     SSH_KEYS_ADMIN: `/ssh-keys-admin`,
     SSH_KEYS_USER: `/ssh-keys-user`,
@@ -61,6 +62,8 @@ export const getResourceUrl = (uuid: string) => {
             return getCollectionUrl(uuid);
         case ResourceKind.PROCESS:
             return getProcessUrl(uuid);
+        case ResourceKind.WORKFLOW:
+            return getWorkflowUrl(uuid);
         default:
             return undefined;
     }
@@ -77,12 +80,12 @@ export const getNavUrl = (uuid: string, config: FederationConfig, includeToken:
     } else if (config.remoteHostsConfig[cls]) {
         let u: URL;
         if (config.remoteHostsConfig[cls].workbench2Url) {
-           /* NOTE: wb2 presently doesn't support passing api_token
-              to arbitrary page to set credentials, only through
-              api-token route.  So for navigation to work, user needs
-              to already be logged in.  In the future we want to just
-              request the records and display in the current
-              workbench instance making this redirect unnecessary. */
+            /* NOTE: wb2 presently doesn't support passing api_token
+               to arbitrary page to set credentials, only through
+               api-token route.  So for navigation to work, user needs
+               to already be logged in.  In the future we want to just
+               request the records and display in the current
+               workbench instance making this redirect unnecessary. */
             u = new URL(config.remoteHostsConfig[cls].workbench2Url);
         } else {
             u = new URL(config.remoteHostsConfig[cls].workbenchUrl);
@@ -100,6 +103,8 @@ export const getNavUrl = (uuid: string, config: FederationConfig, includeToken:
 
 export const getProcessUrl = (uuid: string) => `/processes/${uuid}`;
 
+export const getWorkflowUrl = (uuid: string) => `/workflows/${uuid}`;
+
 export const getGroupUrl = (uuid: string) => `/group/${uuid}`;
 
 export const getUserProfileUrl = (uuid: string) => `/user/${uuid}`;
@@ -120,6 +125,9 @@ export const matchTrashRoute = (route: string) =>
 export const matchAllProcessesRoute = (route: string) =>
     matchPath(route, { path: Routes.ALL_PROCESSES });
 
+export const matchRegisteredWorkflowRoute = (route: string) =>
+    matchPath<ResourceRouteParams>(route, { path: Routes.REGISTEREDWORKFLOW });
+
 export const matchProjectRoute = (route: string) =>
     matchPath<ResourceRouteParams>(route, { path: Routes.PROJECTS });
 
index 90a0bf840873533f5ff9e2a803322caad9fb536b..188c233e746b2d48489614baded270e388014121 100644 (file)
@@ -7,18 +7,21 @@ import { UserService } from '../user-service/user-service';
 import { GroupResource } from 'models/group';
 import { UserResource } from 'models/user';
 import { extractUuidObjectType, ResourceObjectType } from "models/resource";
+import { CollectionService } from "services/collection-service/collection-service";
+import { CollectionResource } from "models/collection";
 
 export class AncestorService {
     constructor(
         private groupsService: GroupsService,
-        private userService: UserService
+        private userService: UserService,
+        private collectionService: CollectionService,
     ) { }
 
-    async ancestors(startUuid: string, endUuid: string): Promise<Array<UserResource | GroupResource>> {
+    async ancestors(startUuid: string, endUuid: string): Promise<Array<UserResource | GroupResource | CollectionResource>> {
         return this._ancestors(startUuid, endUuid);
     }
 
-    private async _ancestors(startUuid: string, endUuid: string, previousUuid = ''): Promise<Array<UserResource | GroupResource>> {
+    private async _ancestors(startUuid: string, endUuid: string, previousUuid = ''): Promise<Array<UserResource | GroupResource | CollectionResource>> {
 
         if (startUuid === previousUuid) {
             return [];
@@ -49,6 +52,8 @@ export class AncestorService {
                 return this.groupsService;
             case ResourceObjectType.USER:
                 return this.userService;
+            case ResourceObjectType.COLLECTION:
+                return this.collectionService;
             default:
                 return undefined;
         }
index b530e4cd3e8d4933cf3e445fe6f306f8994b6d4c..79a6b7e1960be50cf06a078fb7bcd15f85fb19fc 100644 (file)
@@ -35,6 +35,8 @@ export interface UserDetailsResponse {
     is_active: boolean;
     username: string;
     prefs: UserPrefs;
+    can_write: boolean;
+    can_manage: boolean;
 }
 
 export class AuthService {
@@ -146,6 +148,8 @@ export class AuthService {
                     isAdmin: resp.data.is_admin,
                     isActive: resp.data.is_active,
                     username: resp.data.username,
+                    canWrite: resp.data.can_write,
+                    canManage: resp.data.can_manage,
                     prefs
                 };
             })
index b7e1f9c711e3cda968a1f39ffe78bfde7a0d7191..db56e317ff2474932c4d55702884d2f963e91860 100644 (file)
@@ -60,4 +60,4 @@ export const extractFilesData = (document: Document) => {
 
 export const getFileFullPath = ({ name, path }: CollectionFile | CollectionDirectory) => {
     return `${path}/${name}`;
-};
\ No newline at end of file
+};
index 304cbfd3110d056fa57f12cd0c455b1d53ffee6c..3b4f423a0f54d72c003442ddbb025fb7ba109f00 100644 (file)
@@ -13,14 +13,14 @@ describe('collection-service', () => {
     let collectionService: CollectionService;
     let serverApi: AxiosInstance;
     let axiosMock: MockAdapter;
-    let webdavClient: any;
+    let keepWebdavClient: any;
     let authService;
     let actions;
 
     beforeEach(() => {
         serverApi = axios.create();
         axiosMock = new MockAdapter(serverApi);
-        webdavClient = {
+        keepWebdavClient = {
             delete: jest.fn(),
             upload: jest.fn(),
             mkdir: jest.fn(),
@@ -30,7 +30,7 @@ describe('collection-service', () => {
             progressFn: jest.fn(),
             errorFn: jest.fn(),
         } as any;
-        collectionService = new CollectionService(serverApi, webdavClient, authService, actions);
+        collectionService = new CollectionService(serverApi, keepWebdavClient, authService, actions);
         collectionService.update = jest.fn();
     });
 
@@ -79,7 +79,7 @@ describe('collection-service', () => {
                 },
                 select: ['uuid', 'name', 'version', 'modified_at'],
             }
-            collectionService = new CollectionService(serverApi, webdavClient, authService, actions);
+            collectionService = new CollectionService(serverApi, keepWebdavClient, authService, actions);
             await collectionService.update('uuid', data);
             expect(serverApi.put).toHaveBeenCalledWith('/collections/uuid', expected);
         });
@@ -95,7 +95,7 @@ describe('collection-service', () => {
             await collectionService.uploadFiles(collectionUUID, files);
 
             // then
-            expect(webdavClient.upload).not.toHaveBeenCalled();
+            expect(keepWebdavClient.upload).not.toHaveBeenCalled();
         });
 
         it('should upload files', async () => {
@@ -107,8 +107,8 @@ describe('collection-service', () => {
             await collectionService.uploadFiles(collectionUUID, files);
 
             // then
-            expect(webdavClient.upload).toHaveBeenCalledTimes(1);
-            expect(webdavClient.upload.mock.calls[0][0]).toEqual("c=zzzzz-4zz18-0123456789abcde/test-file1");
+            expect(keepWebdavClient.upload).toHaveBeenCalledTimes(1);
+            expect(keepWebdavClient.upload.mock.calls[0][0]).toEqual("c=zzzzz-4zz18-0123456789abcde/test-file1");
         });
 
         it('should upload files with custom uplaod target', async () => {
@@ -121,8 +121,8 @@ describe('collection-service', () => {
             await collectionService.uploadFiles(collectionUUID, files, undefined, customTarget);
 
             // then
-            expect(webdavClient.upload).toHaveBeenCalledTimes(1);
-            expect(webdavClient.upload.mock.calls[0][0]).toEqual("c=zzzzz-4zz18-0123456789adddd/test-path/test-file1");
+            expect(keepWebdavClient.upload).toHaveBeenCalledTimes(1);
+            expect(keepWebdavClient.upload.mock.calls[0][0]).toEqual("c=zzzzz-4zz18-0123456789adddd/test-path/test-file1");
         });
     });
 
@@ -234,7 +234,7 @@ describe('collection-service', () => {
             const destinationPath = '/destinationPath';
 
             // when
-            await collectionService.copyFiles(sourcePdh, filePaths, destinationUuid, destinationPath);
+            await collectionService.copyFiles(sourcePdh, filePaths, {uuid: destinationUuid}, destinationPath);
 
             // then
             expect(serverApi.put).toHaveBeenCalledTimes(1);
@@ -261,7 +261,7 @@ describe('collection-service', () => {
             const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
             const destinationPath = '/destinationPath';
 
-            await collectionService.copyFiles(sourcePdh, filePaths, destinationUuid, destinationPath);
+            await collectionService.copyFiles(sourcePdh, filePaths, {uuid: destinationUuid}, destinationPath);
 
             expect(serverApi.put).toHaveBeenCalledTimes(1);
             expect(serverApi.put).toHaveBeenCalledWith(
@@ -285,7 +285,7 @@ describe('collection-service', () => {
             const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
             const destinationPath = '/';
 
-            await collectionService.copyFiles(sourcePdh, filePaths, destinationUuid, destinationPath);
+            await collectionService.copyFiles(sourcePdh, filePaths, {uuid: destinationUuid}, destinationPath);
 
             expect(serverApi.put).toHaveBeenCalledTimes(1);
             expect(serverApi.put).toHaveBeenCalledWith(
@@ -313,7 +313,7 @@ describe('collection-service', () => {
             const destinationPath = '/destinationPath';
 
             // when
-            await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, destinationUuid, destinationPath);
+            await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, {uuid: destinationUuid}, destinationPath);
 
             // then
             expect(serverApi.put).toHaveBeenCalledTimes(2);
@@ -357,7 +357,7 @@ describe('collection-service', () => {
             const destinationPath = '/destinationPath';
 
             // when
-            await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, srcCollectionUUID, destinationPath);
+            await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, {uuid: srcCollectionUUID}, destinationPath);
 
             // then
             expect(serverApi.put).toHaveBeenCalledTimes(1);
@@ -399,7 +399,7 @@ describe('collection-service', () => {
 
             // when
             try {
-                await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, destinationUuid, destinationPath);
+                await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, {uuid: destinationUuid}, destinationPath);
             } catch {}
 
             // then
index 74cf75956f7cec87a36abb8f0fdcaee5b7e0883e..e50e5ed35026403c6332865d6b897c32a01f5605 100644 (file)
@@ -12,22 +12,28 @@ import { TrashableResourceService } from "services/common-service/trashable-reso
 import { ApiActions } from "services/api/api-actions";
 import { Session } from "models/session";
 import { CommonService } from "services/common-service/common-service";
+import { snakeCase } from "lodash";
+import { CommonResourceServiceError } from "services/common-service/common-resource-service";
 
 export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void;
+type CollectionPartialUpdateOrCreate =
+    | (Partial<CollectionResource> & Pick<CollectionResource, "uuid">)
+    | (Partial<CollectionResource> & Pick<CollectionResource, "ownerUuid">);
 
-export const emptyCollectionPdh = 'd41d8cd98f00b204e9800998ecf8427e+0';
+export const emptyCollectionPdh = "d41d8cd98f00b204e9800998ecf8427e+0";
+export const SOURCE_DESTINATION_EQUAL_ERROR_MESSAGE = "Source and destination cannot be the same";
 
 export class CollectionService extends TrashableResourceService<CollectionResource> {
-    constructor(serverApi: AxiosInstance, private webdavClient: WebDAV, private authService: AuthService, actions: ApiActions) {
+    constructor(serverApi: AxiosInstance, private keepWebdavClient: WebDAV, private authService: AuthService, actions: ApiActions) {
         super(serverApi, "collections", actions, [
-            'fileCount',
-            'fileSizeTotal',
-            'replicationConfirmed',
-            'replicationConfirmedAt',
-            'storageClassesConfirmed',
-            'storageClassesConfirmedAt',
-            'unsignedManifestText',
-            'version',
+            "fileCount",
+            "fileSizeTotal",
+            "replicationConfirmed",
+            "replicationConfirmedAt",
+            "storageClassesConfirmed",
+            "storageClassesConfirmedAt",
+            "unsignedManifestText",
+            "version",
         ]);
     }
 
@@ -42,14 +48,18 @@ export class CollectionService extends TrashableResourceService<CollectionResour
     }
 
     update(uuid: string, data: Partial<CollectionResource>, showErrors?: boolean) {
-        const select = [...Object.keys(data), 'version', 'modifiedAt'];
+        const select = [...Object.keys(data), "version", "modifiedAt"];
         return super.update(uuid, { ...data, preserveVersion: true }, showErrors, select);
     }
 
     async files(uuid: string) {
-        const request = await this.webdavClient.propfind(`c=${uuid}`);
-        if (request.responseXML != null) {
-            return extractFilesData(request.responseXML);
+        try {
+            const request = await this.keepWebdavClient.propfind(`c=${uuid}`);
+            if (request.responseXML != null) {
+                return extractFilesData(request.responseXML);
+            }
+        } catch (e) {
+            return Promise.reject(e);
         }
         return Promise.reject();
     }
@@ -57,9 +67,9 @@ export class CollectionService extends TrashableResourceService<CollectionResour
     private combineFilePath(parts: string[]) {
         return parts.reduce((path, part) => {
             // Trim leading and trailing slashes
-            const trimmedPart = part.split('/').filter(Boolean).join('/');
+            const trimmedPart = part.split("/").filter(Boolean).join("/");
             if (trimmedPart.length) {
-                const separator = path.endsWith('/') ? '' : '/';
+                const separator = path.endsWith("/") ? "" : "/";
                 return `${path}${separator}${trimmedPart}`;
             } else {
                 return path;
@@ -67,25 +77,37 @@ export class CollectionService extends TrashableResourceService<CollectionResour
         }, "/");
     }
 
-    private replaceFiles(collectionUuid: string, fileMap: {}, showErrors?: boolean) {
+    private replaceFiles(data: CollectionPartialUpdateOrCreate, fileMap: {}, showErrors?: boolean) {
         const payload = {
             collection: {
-                preserve_version: true
+                preserve_version: true,
+                ...CommonService.mapKeys(snakeCase)(data),
+                // Don't send uuid in payload when creating
+                uuid: undefined,
             },
-            replace_files: fileMap
+            replace_files: fileMap,
         };
-
-        return CommonService.defaultResponse(
-            this.serverApi
-                .put<CollectionResource>(`/${this.resourceType}/${collectionUuid}`, payload),
-            this.actions,
-            true, // mapKeys
-            showErrors
-        );
+        if (data.uuid) {
+            return CommonService.defaultResponse(
+                this.serverApi.put<CollectionResource>(`/${this.resourceType}/${data.uuid}`, payload),
+                this.actions,
+                true, // mapKeys
+                showErrors
+            );
+        } else {
+            return CommonService.defaultResponse(
+                this.serverApi.post<CollectionResource>(`/${this.resourceType}`, payload),
+                this.actions,
+                true, // mapKeys
+                showErrors
+            );
+        }
     }
 
-    async uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress, targetLocation: string = '') {
-        if (collectionUuid === "" || files.length === 0) { return; }
+    async uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress, targetLocation: string = "") {
+        if (collectionUuid === "" || files.length === 0) {
+            return;
+        }
         // files have to be uploaded sequentially
         for (let idx = 0; idx < files.length; idx++) {
             await this.uploadFile(collectionUuid, files[idx], idx, onProgress, targetLocation);
@@ -94,49 +116,60 @@ export class CollectionService extends TrashableResourceService<CollectionResour
     }
 
     async renameFile(collectionUuid: string, collectionPdh: string, oldPath: string, newPath: string) {
-        return this.replaceFiles(collectionUuid, {
-            [this.combineFilePath([newPath])]: `${collectionPdh}${this.combineFilePath([oldPath])}`,
-            [this.combineFilePath([oldPath])]: '',
-        });
+        return this.replaceFiles(
+            { uuid: collectionUuid },
+            {
+                [this.combineFilePath([newPath])]: `${collectionPdh}${this.combineFilePath([oldPath])}`,
+                [this.combineFilePath([oldPath])]: "",
+            }
+        );
     }
 
     extendFileURL = (file: CollectionDirectory | CollectionFile) => {
-        const baseUrl = this.webdavClient.getBaseUrl().endsWith('/')
-            ? this.webdavClient.getBaseUrl().slice(0, -1)
-            : this.webdavClient.getBaseUrl();
+        const baseUrl = this.keepWebdavClient.getBaseUrl().endsWith("/")
+            ? this.keepWebdavClient.getBaseUrl().slice(0, -1)
+            : this.keepWebdavClient.getBaseUrl();
         const apiToken = this.authService.getApiToken();
-        const encodedApiToken = apiToken ? encodeURI(apiToken) : '';
+        const encodedApiToken = apiToken ? encodeURI(apiToken) : "";
         const userApiToken = `/t=${encodedApiToken}/`;
-        const splittedPrevFileUrl = file.url.split('/');
-        const url = `${baseUrl}/${splittedPrevFileUrl[1]}${userApiToken}${splittedPrevFileUrl.slice(2).join('/')}`;
+        const splittedPrevFileUrl = file.url.split("/");
+        const url = `${baseUrl}/${splittedPrevFileUrl[1]}${userApiToken}${splittedPrevFileUrl.slice(2).join("/")}`;
         return {
             ...file,
-            url
+            url,
         };
-    }
+    };
 
     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('//', '/');
+        return (await this.keepWebdavClient.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 = {
             headers: {
-                'Content-Type': 'text/octet-stream'
+                "Content-Type": "text/octet-stream",
             },
             onUploadProgress: (e: ProgressEvent) => {
                 onProgress(fileId, e.loaded, e.total, Date.now());
             },
         };
-        return this.webdavClient.upload(fileURL, [file], requestConfig);
+        return this.keepWebdavClient.upload(fileURL, [file], requestConfig);
     }
 
     deleteFiles(collectionUuid: string, files: string[], showErrors?: boolean) {
         const optimizedFiles = files
             .sort((a, b) => a.length - b.length)
             .reduce((acc, currentPath) => {
-                const parentPathFound = acc.find((parentPath) => currentPath.indexOf(`${parentPath}/`) > -1);
+                const parentPathFound = acc.find(parentPath => currentPath.indexOf(`${parentPath}/`) > -1);
 
                 if (!parentPathFound) {
                     return [...acc, currentPath];
@@ -148,49 +181,74 @@ export class CollectionService extends TrashableResourceService<CollectionResour
         const fileMap = optimizedFiles.reduce((obj, filePath) => {
             return {
                 ...obj,
-                [this.combineFilePath([filePath])]: ''
-            }
-        }, {})
+                [this.combineFilePath([filePath])]: "",
+            };
+        }, {});
 
-        return this.replaceFiles(collectionUuid, fileMap, showErrors);
+        return this.replaceFiles({ uuid: collectionUuid }, fileMap, showErrors);
     }
 
-    copyFiles(sourcePdh: string, files: string[], destinationCollectionUuid: string, destinationPath: string, showErrors?: boolean) {
+    copyFiles(
+        sourcePdh: string,
+        files: string[],
+        destinationCollection: CollectionPartialUpdateOrCreate,
+        destinationPath: string,
+        showErrors?: boolean
+    ) {
         const fileMap = files.reduce((obj, sourceFile) => {
-            const sourceFileName = sourceFile.split('/').filter(Boolean).slice(-1).join("");
+            const fileBasename = sourceFile.split("/").filter(Boolean).slice(-1).join("");
             return {
                 ...obj,
-                [this.combineFilePath([destinationPath, sourceFileName])]: `${sourcePdh}${this.combineFilePath([sourceFile])}`
+                [this.combineFilePath([destinationPath, fileBasename])]: `${sourcePdh}${this.combineFilePath([sourceFile])}`,
             };
         }, {});
 
-        return this.replaceFiles(destinationCollectionUuid, fileMap, showErrors);
+        return this.replaceFiles(destinationCollection, fileMap, showErrors);
     }
 
-    moveFiles(sourceUuid: string, sourcePdh: string, files: string[], destinationCollectionUuid: string, destinationPath: string, showErrors?: boolean) {
-        if (sourceUuid === destinationCollectionUuid) {
+    moveFiles(
+        sourceUuid: string,
+        sourcePdh: string,
+        files: string[],
+        destinationCollection: CollectionPartialUpdateOrCreate,
+        destinationPath: string,
+        showErrors?: boolean
+    ) {
+        if (sourceUuid === destinationCollection.uuid) {
+            let errors: CommonResourceServiceError[] = [];
             const fileMap = files.reduce((obj, sourceFile) => {
-                const sourceFileName = sourceFile.split('/').filter(Boolean).slice(-1).join("");
-                return {
-                    ...obj,
-                    [this.combineFilePath([destinationPath, sourceFileName])]: `${sourcePdh}${this.combineFilePath([sourceFile])}`,
-                    [this.combineFilePath([sourceFile])]: '',
-                };
+                const fileBasename = sourceFile.split("/").filter(Boolean).slice(-1).join("");
+                const fileDestinationPath = this.combineFilePath([destinationPath, fileBasename]);
+                const fileSourcePath = this.combineFilePath([sourceFile]);
+                const fileSourceUri = `${sourcePdh}${fileSourcePath}`;
+
+                if (fileDestinationPath !== fileSourcePath) {
+                    return {
+                        ...obj,
+                        [fileDestinationPath]: fileSourceUri,
+                        [fileSourcePath]: "",
+                    };
+                } else {
+                    errors.push(CommonResourceServiceError.SOURCE_DESTINATION_CANNOT_BE_SAME);
+                    return obj;
+                }
             }, {});
 
-            return this.replaceFiles(sourceUuid, fileMap, showErrors)
+            if (errors.length === 0) {
+                return this.replaceFiles({ uuid: sourceUuid }, fileMap, showErrors);
+            } else {
+                return Promise.reject({ errors });
+            }
         } else {
-            return this.copyFiles(sourcePdh, files, destinationCollectionUuid, destinationPath, showErrors)
-                .then(() => {
-                    return this.deleteFiles(sourceUuid, files, showErrors);
-                });
+            return this.copyFiles(sourcePdh, files, destinationCollection, destinationPath, showErrors).then(() => {
+                return this.deleteFiles(sourceUuid, files, showErrors);
+            });
         }
     }
 
     createDirectory(collectionUuid: string, path: string, showErrors?: boolean) {
-        const fileMap = {[this.combineFilePath([path])]: emptyCollectionPdh};
+        const fileMap = { [this.combineFilePath([path])]: emptyCollectionPdh };
 
-        return this.replaceFiles(collectionUuid, fileMap, showErrors);
+        return this.replaceFiles({ uuid: collectionUuid }, fileMap, showErrors);
     }
-
 }
index d9be8dae9f2a402268217cd8704c0e1d5f538a48..907f0081fdf6053fd983f073cefeb7eadbc77134 100644 (file)
@@ -13,6 +13,8 @@ export enum CommonResourceServiceError {
     OWNERSHIP_CYCLE = 'OwnershipCycle',
     MODIFYING_CONTAINER_REQUEST_FINAL_STATE = 'ModifyingContainerRequestFinalState',
     NAME_HAS_ALREADY_BEEN_TAKEN = 'NameHasAlreadyBeenTaken',
+    PERMISSION_ERROR_FORBIDDEN = 'PermissionErrorForbidden',
+    SOURCE_DESTINATION_CANNOT_BE_SAME = 'SourceDestinationCannotBeSame',
     UNKNOWN = 'Unknown',
     NONE = 'None'
 }
@@ -22,14 +24,20 @@ export class CommonResourceService<T extends Resource> extends CommonService<T>
         super(serverApi, resourceType, actions, readOnlyFields.concat([
             'uuid',
             'etag',
-            'kind'
+            'kind',
+            'canWrite',
+            'canManage',
+            'createdAt',
+            'modifiedAt',
+            'modifiedByClientUuid',
+            'modifiedByUserUuid'
         ]));
     }
 
     create(data?: Partial<T>, showErrors?: boolean) {
         let payload: any;
         if (data !== undefined) {
-            this.readOnlyFields.forEach( field => delete data[field] );
+            this.readOnlyFields.forEach(field => delete data[field]);
             payload = {
                 [this.resourceType.slice(0, -1)]: CommonService.mapKeys(snakeCase)(data),
             };
@@ -40,7 +48,7 @@ export class CommonResourceService<T extends Resource> extends CommonService<T>
     update(uuid: string, data: Partial<T>, showErrors?: boolean, select?: string[]) {
         let payload: any;
         if (data !== undefined) {
-            this.readOnlyFields.forEach( field => delete data[field] );
+            this.readOnlyFields.forEach(field => delete data[field]);
             payload = {
                 [this.resourceType.slice(0, -1)]: CommonService.mapKeys(snakeCase)(data),
             };
@@ -53,7 +61,7 @@ export class CommonResourceService<T extends Resource> extends CommonService<T>
 }
 
 export const getCommonResourceServiceError = (errorResponse: any) => {
-    if ('errors' in errorResponse) {
+    if (errorResponse && 'errors' in errorResponse) {
         const error = errorResponse.errors.join('');
         switch (true) {
             case /UniqueViolation/.test(error):
@@ -64,11 +72,13 @@ export const getCommonResourceServiceError = (errorResponse: any) => {
                 return CommonResourceServiceError.MODIFYING_CONTAINER_REQUEST_FINAL_STATE;
             case /Name has already been taken/.test(error):
                 return CommonResourceServiceError.NAME_HAS_ALREADY_BEEN_TAKEN;
+            case /403 Forbidden/.test(error):
+                return CommonResourceServiceError.PERMISSION_ERROR_FORBIDDEN;
+            case new RegExp(CommonResourceServiceError.SOURCE_DESTINATION_CANNOT_BE_SAME).test(error):
+                return CommonResourceServiceError.SOURCE_DESTINATION_CANNOT_BE_SAME;
             default:
                 return CommonResourceServiceError.UNKNOWN;
         }
     }
     return CommonResourceServiceError.NONE;
 };
-
-
index 4b857eddbb8233239d2d4d152928f0a93c86e9e1..8e9fe631701bd0877075774f6d3666dd9fa73ec1 100644 (file)
@@ -107,12 +107,14 @@ export class CommonService<T> {
         );
     }
 
-    delete(uuid: string): Promise<T> {
+    delete(uuid: string, showErrors?: boolean): Promise<T> {
         this.validateUuid(uuid);
         return CommonService.defaultResponse(
             this.serverApi
                 .delete(`/${this.resourceType}/${uuid}`),
-            this.actions
+            this.actions,
+            true, // mapKeys
+            showErrors
         );
     }
 
index 4d6b130b906d68a1f68c4e96e7e50a7b0fbeee9c..5e4704b64d7020852e5d314b18bb3270403c8fb8 100644 (file)
@@ -9,29 +9,25 @@ import { CommonResourceService } from "services/common-service/common-resource-s
 import { ApiActions } from "services/api/api-actions";
 
 export class TrashableResourceService<T extends TrashableResource> extends CommonResourceService<T> {
-
     constructor(serverApi: AxiosInstance, resourceType: string, actions: ApiActions, readOnlyFields: string[] = []) {
         super(serverApi, resourceType, actions, readOnlyFields);
     }
 
     trash(uuid: string): Promise<T> {
-        return CommonResourceService.defaultResponse(
-            this.serverApi
-                .post(this.resourceType + `/${uuid}/trash`),
-            this.actions
-        );
+        return CommonResourceService.defaultResponse(this.serverApi.post(this.resourceType + `/${uuid}/trash`), this.actions);
     }
 
     untrash(uuid: string): Promise<T> {
         const params = {
-            ensure_unique_name: true
+            ensure_unique_name: true,
         };
         return CommonResourceService.defaultResponse(
-            this.serverApi
-                .post(this.resourceType + `/${uuid}/untrash`, {
-                    params: CommonResourceService.mapKeys(snakeCase)(params)
-                }),
-            this.actions
+            this.serverApi.post(this.resourceType + `/${uuid}/untrash`, {
+                params: CommonResourceService.mapKeys(snakeCase)(params),
+            }),
+            this.actions,
+            undefined,
+            false
         );
     }
 }
index a36ddba894c84a987f8f672c1a370a627f89b13c..b9f47df0dbb97e01e7f020c84ebf2eb1139c4339 100644 (file)
@@ -6,8 +6,8 @@ import { CancelToken } from 'axios';
 import { snakeCase, camelCase } from "lodash";
 import { CommonResourceService } from 'services/common-service/common-resource-service';
 import {
-  ListResults,
-  ListArguments,
+    ListResults,
+    ListArguments,
 } from 'services/common-service/common-service';
 import { AxiosInstance, AxiosRequestConfig } from 'axios';
 import { CollectionResource } from 'models/collection';
@@ -20,78 +20,85 @@ import { GroupResource } from 'models/group';
 import { Session } from 'models/session';
 
 export interface ContentsArguments {
-  limit?: number;
-  offset?: number;
-  order?: string;
-  filters?: string;
-  recursive?: boolean;
-  includeTrash?: boolean;
-  excludeHomeProject?: boolean;
+    limit?: number;
+    offset?: number;
+    order?: string;
+    filters?: string;
+    recursive?: boolean;
+    includeTrash?: boolean;
+    excludeHomeProject?: boolean;
+    select?: string[];
 }
 
 export interface SharedArguments extends ListArguments {
-  include?: string;
+    include?: string;
 }
 
 export type GroupContentsResource =
-  | CollectionResource
-  | ProjectResource
-  | ProcessResource
-  | WorkflowResource;
+    | CollectionResource
+    | ProjectResource
+    | ProcessResource
+    | WorkflowResource;
 
 export class GroupsService<
-  T extends GroupResource = GroupResource
-> extends TrashableResourceService<T> {
-  constructor(serverApi: AxiosInstance, actions: ApiActions) {
-    super(serverApi, 'groups', actions);
-  }
+    T extends GroupResource = GroupResource
+    > extends TrashableResourceService<T> {
+    constructor(serverApi: AxiosInstance, actions: ApiActions) {
+        super(serverApi, 'groups', actions);
+    }
 
-async contents(uuid: string, args: ContentsArguments = {}, session?: Session, cancelToken?: CancelToken): Promise<ListResults<GroupContentsResource>> {
-    const { filters, order, ...other } = args;
-    const params = {
-        ...other,
-        filters: filters ? `[${filters}]` : undefined,
-        order: order ? order : undefined
-    };
-    const pathUrl = uuid ? `/${uuid}/contents` : '/contents';
-    const cfg: AxiosRequestConfig = {
-      params: CommonResourceService.mapKeys(snakeCase)(params),
-    };
+    async contents(uuid: string, args: ContentsArguments = {}, session?: Session, cancelToken?: CancelToken): Promise<ListResults<GroupContentsResource>> {
+        const { filters, order, select, ...other } = args;
+        const params = {
+            ...other,
+            filters: filters ? `[${filters}]` : undefined,
+            order: order ? order : undefined,
+            select: select
+                ? JSON.stringify(select.map(sel => {
+                    const sp = sel.split(".");
+                    return sp.length === 2 ? (sp[0] + "." + snakeCase(sp[1])) : snakeCase(sel);
+                }))
+                : undefined
+        };
+        const pathUrl = (uuid !== '') ? `/${uuid}/contents` : '/contents';
+        const cfg: AxiosRequestConfig = {
+            params: CommonResourceService.mapKeys(snakeCase)(params),
+        };
 
-    if (session) {
-      cfg.baseURL = session.baseUrl;
-      cfg.headers = { Authorization: 'Bearer ' + session.token };
-    }
+        if (session) {
+            cfg.baseURL = session.baseUrl;
+            cfg.headers = { Authorization: 'Bearer ' + session.token };
+        }
 
-    if (cancelToken) {
-      cfg.cancelToken = cancelToken;
-    }
+        if (cancelToken) {
+            cfg.cancelToken = cancelToken;
+        }
 
-    const response = await CommonResourceService.defaultResponse(
-      this.serverApi.get(this.resourceType + pathUrl, cfg),
-      this.actions,
-      false
-    );
+        const response = await CommonResourceService.defaultResponse(
+            this.serverApi.get(this.resourceType + pathUrl, cfg),
+            this.actions,
+            false
+        );
 
-    return {
-      ...TrashableResourceService.mapKeys(camelCase)(response),
-      clusterId: session && session.clusterId,
-    };
-  }
+        return {
+            ...TrashableResourceService.mapKeys(camelCase)(response),
+            clusterId: session && session.clusterId,
+        };
+    }
 
-  shared(
-    params: SharedArguments = {}
-  ): Promise<ListResults<GroupContentsResource>> {
-    return CommonResourceService.defaultResponse(
-      this.serverApi.get(this.resourceType + '/shared', { params }),
-      this.actions
-    );
-  }
+    shared(
+        params: SharedArguments = {}
+    ): Promise<ListResults<GroupContentsResource>> {
+        return CommonResourceService.defaultResponse(
+            this.serverApi.get(this.resourceType + '/shared', { params }),
+            this.actions
+        );
+    }
 }
 
 export enum GroupContentsResourcePrefix {
-  COLLECTION = 'collections',
-  PROJECT = 'groups',
-  PROCESS = 'container_requests',
-  WORKFLOW = 'workflows',
+    COLLECTION = 'collections',
+    PROJECT = 'groups',
+    PROCESS = 'container_requests',
+    WORKFLOW = 'workflows',
 }
diff --git a/src/services/log-service/log-service.test.ts b/src/services/log-service/log-service.test.ts
new file mode 100644 (file)
index 0000000..2519155
--- /dev/null
@@ -0,0 +1,168 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { LogService } from "./log-service";
+import { ApiActions } from "services/api/api-actions";
+import axios from "axios";
+import { WebDAVRequestConfig } from "common/webdav";
+import { LogEventType } from "models/log";
+
+describe("LogService", () => {
+
+    let apiWebdavClient: any;
+    const axiosInstance = axios.create();
+    const actions: ApiActions = {
+        progressFn: (id: string, working: boolean) => {},
+        errorFn: (id: string, message: string) => {}
+    };
+
+    beforeEach(() => {
+        apiWebdavClient = {
+            delete: jest.fn(),
+            upload: jest.fn(),
+            mkdir: jest.fn(),
+            get: jest.fn(),
+            propfind: jest.fn(),
+        } as any;
+    });
+
+    it("lists log files using propfind on live logs api endpoint", async () => {
+        const logService = new LogService(axiosInstance, apiWebdavClient, actions);
+
+        // given
+        const containerRequest = {uuid: 'zzzzz-xvhdp-000000000000000', containerUuid: 'zzzzz-dz642-000000000000000'};
+        const xmlData = `<?xml version="1.0" encoding="UTF-8"?>
+            <D:multistatus xmlns:D="DAV:">
+                    <D:response>
+                            <D:href>/arvados/v1/container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}/</D:href>
+                            <D:propstat>
+                                    <D:prop>
+                                            <D:resourcetype>
+                                                    <D:collection xmlns:D="DAV:" />
+                                            </D:resourcetype>
+                                            <D:getlastmodified>Tue, 15 Aug 2023 12:54:37 GMT</D:getlastmodified>
+                                            <D:displayname></D:displayname>
+                                            <D:supportedlock>
+                                                    <D:lockentry xmlns:D="DAV:">
+                                                            <D:lockscope>
+                                                                    <D:exclusive />
+                                                            </D:lockscope>
+                                                            <D:locktype>
+                                                                    <D:write />
+                                                            </D:locktype>
+                                                    </D:lockentry>
+                                            </D:supportedlock>
+                                    </D:prop>
+                                    <D:status>HTTP/1.1 200 OK</D:status>
+                            </D:propstat>
+                    </D:response>
+                    <D:response>
+                            <D:href>/arvados/v1/container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}/stdout.txt</D:href>
+                            <D:propstat>
+                                    <D:prop>
+                                            <D:displayname>stdout.txt</D:displayname>
+                                            <D:getcontentlength>15</D:getcontentlength>
+                                            <D:getcontenttype>text/plain; charset=utf-8</D:getcontenttype>
+                                            <D:getetag>"177b8fb161ff9f58f"</D:getetag>
+                                            <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:getlastmodified>Tue, 15 Aug 2023 12:54:37 GMT</D:getlastmodified>
+                                    </D:prop>
+                                    <D:status>HTTP/1.1 200 OK</D:status>
+                            </D:propstat>
+                    </D:response>
+                    <D:response>
+                            <D:href>/arvados/v1/container_requests/${containerRequest.uuid}/wrongpath.txt</D:href>
+                            <D:propstat>
+                                    <D:prop>
+                                            <D:displayname>wrongpath.txt</D:displayname>
+                                            <D:getcontentlength>15</D:getcontentlength>
+                                            <D:getcontenttype>text/plain; charset=utf-8</D:getcontenttype>
+                                            <D:getetag>"177b8fb161ff9f58f"</D:getetag>
+                                            <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:getlastmodified>Tue, 15 Aug 2023 12:54:37 GMT</D:getlastmodified>
+                                    </D:prop>
+                                    <D:status>HTTP/1.1 200 OK</D:status>
+                            </D:propstat>
+                    </D:response>
+            </D:multistatus>`;
+        const xmlDoc = (new DOMParser()).parseFromString(xmlData, "text/xml");
+        apiWebdavClient.propfind = jest.fn().mockReturnValue(Promise.resolve({responseXML: xmlDoc}));
+
+        // when
+        const logs = await logService.listLogFiles(containerRequest);
+
+        // then
+        expect(apiWebdavClient.propfind).toHaveBeenCalledWith(`container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}`);
+        expect(logs.length).toEqual(1);
+        expect(logs[0]).toHaveProperty('name', 'stdout.txt');
+        expect(logs[0]).toHaveProperty('type', 'file');
+    });
+
+    it("requests log file contents with correct range request", async () => {
+        const logService = new LogService(axiosInstance, apiWebdavClient, actions);
+
+        // given
+        const containerRequest = {uuid: 'zzzzz-xvhdp-000000000000000', containerUuid: 'zzzzz-dz642-000000000000000'};
+        const fileRecord = {name: `stdout.txt`};
+        const fileContents = `Line 1\nLine 2\nLine 3`;
+        apiWebdavClient.get = jest.fn().mockImplementation((path: string, options: WebDAVRequestConfig) => {
+            const matches = /bytes=([0-9]+)-([0-9]+)/.exec(options.headers?.Range || '');
+            if (matches?.length === 3) {
+                return Promise.resolve({responseText: fileContents.substring(Number(matches[1]), Number(matches[2]) + 1)})
+            }
+            return Promise.reject();
+        });
+
+        // when
+        let result = await logService.getLogFileContents(containerRequest, fileRecord, 0, 3);
+        // then
+        expect(apiWebdavClient.get).toHaveBeenCalledWith(
+            `container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}/${fileRecord.name}`,
+            {headers: {Range: `bytes=0-3`}}
+        );
+        expect(result.logType).toEqual(LogEventType.STDOUT);
+        expect(result.contents).toEqual(['Line']);
+
+        // when
+        result = await logService.getLogFileContents(containerRequest, fileRecord, 0, 10);
+        // then
+        expect(apiWebdavClient.get).toHaveBeenCalledWith(
+            `container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}/${fileRecord.name}`,
+            {headers: {Range: `bytes=0-10`}}
+        );
+        expect(result.logType).toEqual(LogEventType.STDOUT);
+        expect(result.contents).toEqual(['Line 1', 'Line']);
+
+        // when
+        result = await logService.getLogFileContents(containerRequest, fileRecord, 6, 14);
+        // then
+        expect(apiWebdavClient.get).toHaveBeenCalledWith(
+            `container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}/${fileRecord.name}`,
+            {headers: {Range: `bytes=6-14`}}
+        );
+        expect(result.logType).toEqual(LogEventType.STDOUT);
+        expect(result.contents).toEqual(['', 'Line 2', 'L']);
+    });
+
+});
index 9772e0b61ca0a9a08e5c2fc7494704cddd141012..f36044f425227f1b55d539b1903970e7b60b9230 100644 (file)
@@ -3,12 +3,59 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { AxiosInstance } from "axios";
-import { LogResource } from 'models/log';
+import { LogEventType, LogResource } from 'models/log';
 import { CommonResourceService } from "services/common-service/common-resource-service";
 import { ApiActions } from "services/api/api-actions";
+import { WebDAV } from "common/webdav";
+import { extractFilesData } from "services/collection-service/collection-service-files-response";
+import { CollectionFile } from "models/collection-file";
+import { ContainerRequestResource } from "models/container-request";
+
+export type LogFragment = {
+    logType: LogEventType;
+    contents: string[];
+}
 
 export class LogService extends CommonResourceService<LogResource> {
-    constructor(serverApi: AxiosInstance, actions: ApiActions) {
+    constructor(serverApi: AxiosInstance, private apiWebdavClient: WebDAV, actions: ApiActions) {
         super(serverApi, "logs", actions);
     }
+
+    async listLogFiles(containerRequest: Pick<ContainerRequestResource, 'uuid' | 'containerUuid'>) {
+        const request = await this.apiWebdavClient.propfind(`container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}`);
+        if (request?.responseXML != null) {
+            return extractFilesData(request.responseXML)
+                .filter((file) => (
+                    file.path === `/arvados/v1/container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}`
+                ));
+        }
+        return Promise.reject();
+    }
+
+    /**
+     * Fetches the specified log file contents from the given container request's container live logs endpoint
+     * @param containerRequest Container request to fetch logs for
+     * @param fileRecord Log file to fetch
+     * @param startByte First byte index of the log file to fetch
+     * @param endByte Last byte index to include in the response
+     * @returns A promise that resolves to the LogEventType and a string array of the log file contents
+     */
+    async getLogFileContents(containerRequest: Pick<ContainerRequestResource, 'uuid' | 'containerUuid'>, fileRecord: Pick<CollectionFile, 'name'>, startByte: number, endByte: number): Promise<LogFragment> {
+        const request = await this.apiWebdavClient.get(
+            `container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}/${fileRecord.name}`,
+            {headers: {Range: `bytes=${startByte}-${endByte}`}}
+        );
+        const logFileType = logFileToLogType(fileRecord);
+
+        if (request?.responseText && logFileType) {
+            return {
+                logType: logFileType,
+                contents: request.responseText.split(/\r?\n/),
+            };
+        } else {
+            return Promise.reject();
+        }
+    }
 }
+
+export const logFileToLogType = (file: Pick<CollectionFile, 'name'>) => (file.name.replace(/\.(txt|json)$/, '') as LogEventType);
index 4e4a682ebe065ed7247695c1ba760df5d0833f06..cd04a65feff5286be41bf030fdcf241da8bf0a14 100644 (file)
@@ -39,12 +39,14 @@ export function setAuthorizationHeader(services: ServiceRepository, token: strin
     services.apiClient.defaults.headers.common = {
         Authorization: `Bearer ${token}`
     };
-    services.webdavClient.setAuthorization(`Bearer ${token}`);
+    services.keepWebdavClient.setAuthorization(`Bearer ${token}`);
+    services.apiWebdavClient.setAuthorization(`Bearer ${token}`);
 }
 
 export function removeAuthorizationHeader(services: ServiceRepository) {
     delete services.apiClient.defaults.headers.common;
-    services.webdavClient.setAuthorization(undefined);
+    services.keepWebdavClient.setAuthorization(undefined);
+    services.apiWebdavClient.setAuthorization(undefined);
 }
 
 export const createServices = (config: Config, actions: ApiActions, useApiClient?: AxiosInstance) => {
@@ -55,10 +57,14 @@ export const createServices = (config: Config, actions: ApiActions, useApiClient
     const apiClient = useApiClient || Axios.create({ headers: {} });
     apiClient.defaults.baseURL = config.baseUrl;
 
-    const webdavClient = new WebDAV({
+    const keepWebdavClient = new WebDAV({
         baseURL: config.keepWebServiceUrl
     });
 
+    const apiWebdavClient = new WebDAV({
+        baseURL: config.baseUrl
+    });
+
     const apiClientAuthorizationService = new ApiClientAuthorizationService(apiClient, actions);
     const authorizedKeysService = new AuthorizedKeysService(apiClient, actions);
     const containerRequestService = new ContainerRequestService(apiClient, actions);
@@ -66,7 +72,7 @@ export const createServices = (config: Config, actions: ApiActions, useApiClient
     const groupsService = new GroupsService(apiClient, actions);
     const keepService = new KeepService(apiClient, actions);
     const linkService = new LinkService(apiClient, actions);
-    const logService = new LogService(apiClient, actions);
+    const logService = new LogService(apiClient, apiWebdavClient, actions);
     const permissionService = new PermissionService(apiClient, actions);
     const projectService = new ProjectService(apiClient, actions);
     const repositoriesService = new RepositoriesService(apiClient, actions);
@@ -75,13 +81,12 @@ export const createServices = (config: Config, actions: ApiActions, useApiClient
     const workflowService = new WorkflowService(apiClient, actions);
     const linkAccountService = new LinkAccountService(apiClient, actions);
 
-    const ancestorsService = new AncestorService(groupsService, userService);
-
     const idleTimeout = (config && config.clusterConfig && config.clusterConfig.Workbench.IdleTimeout) || '0s';
     const authService = new AuthService(apiClient, config.rootUrl, actions,
         (parse(idleTimeout, 's') || 0) > 0);
 
-    const collectionService = new CollectionService(apiClient, webdavClient, authService, actions);
+    const collectionService = new CollectionService(apiClient, keepWebdavClient, authService, actions);
+    const ancestorsService = new AncestorService(groupsService, userService, collectionService);
     const favoriteService = new FavoriteService(linkService, groupsService);
     const tagService = new TagService(linkService);
     const searchService = new SearchService();
@@ -110,7 +115,8 @@ export const createServices = (config: Config, actions: ApiActions, useApiClient
         tagService,
         userService,
         virtualMachineService,
-        webdavClient,
+        keepWebdavClient,
+        apiWebdavClient,
         workflowService,
         vocabularyService,
         linkAccountService
index 8581b26766715fe57ce1d50657c8e9131f90daa4..75131f922d1f54a65ca4c47ca99b1f13d4b132aa 100644 (file)
@@ -12,8 +12,7 @@ export class UserService extends CommonResourceService<UserResource> {
     constructor(serverApi: AxiosInstance, actions: ApiActions, readOnlyFields: string[] = []) {
         super(serverApi, "users", actions, readOnlyFields.concat([
             'fullName',
-            'isInvited',
-            'writableBy',
+            'isInvited'
         ]));
     }
 
index ac088f025b8cdd72374584748d3764bb5752df48..fedd551864e2c5c80ecb9af1e1a804bc912c29e8 100644 (file)
@@ -20,6 +20,7 @@ import { SshKeyResource } from 'models/ssh-key';
 import { VirtualMachinesResource } from 'models/virtual-machines';
 import { UserResource } from 'models/user';
 import { LinkResource } from 'models/link';
+import { WorkflowResource } from 'models/workflow';
 import { KeepServiceResource } from 'models/keep-services';
 import { ApiClientAuthorization } from 'models/api-client-authorization';
 import React from 'react';
@@ -101,9 +102,14 @@ enum LinkData {
     PROPERTIES = 'properties'
 }
 
-type AdvanceResourceKind = CollectionData | ProcessData | ProjectData | RepositoryData | SshKeyData | VirtualMachineData | KeepServiceData | ApiClientAuthorizationsData | UserData | LinkData;
+enum WorkflowData {
+    WORKFLOW = 'workflow',
+    CREATED_AT = 'created_at'
+}
+
+type AdvanceResourceKind = CollectionData | ProcessData | ProjectData | RepositoryData | SshKeyData | VirtualMachineData | KeepServiceData | ApiClientAuthorizationsData | UserData | LinkData | WorkflowData;
 type AdvanceResourcePrefix = GroupContentsResourcePrefix | ResourcePrefix;
-type AdvanceResponseData = ContainerRequestResource | ProjectResource | CollectionResource | RepositoryResource | SshKeyResource | VirtualMachinesResource | KeepServiceResource | ApiClientAuthorization | UserResource | LinkResource | undefined;
+type AdvanceResponseData = ContainerRequestResource | ProjectResource | CollectionResource | RepositoryResource | SshKeyResource | VirtualMachinesResource | KeepServiceResource | ApiClientAuthorization | UserResource | LinkResource | WorkflowResource | undefined;
 
 export const openAdvancedTabDialog = (uuid: string) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
@@ -267,6 +273,23 @@ export const openAdvancedTabDialog = (uuid: string) =>
                 });
                 dispatch<any>(initAdvancedTabDialog(advanceDataLink));
                 break;
+            case ResourceKind.WORKFLOW:
+                const wfResources = getState().resources;
+                const dataWf = getResource<WorkflowResource>(uuid)(wfResources);
+                const advanceDataWf = advancedTabData({
+                    uuid,
+                    metadata: '',
+                    user: '',
+                    apiResponseKind: wfApiResponse,
+                    data: dataWf,
+                    resourceKind: WorkflowData.WORKFLOW,
+                    resourcePrefix: GroupContentsResourcePrefix.WORKFLOW,
+                    resourceKindProperty: WorkflowData.CREATED_AT,
+                    property: dataWf!.createdAt
+                });
+                dispatch<any>(initAdvancedTabDialog(advanceDataWf));
+                break;
+
             default:
                 dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Could not open advanced tab for this resource.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
         }
@@ -444,7 +467,9 @@ const collectionApiResponse = (apiResponse: CollectionResource): JSX.Element =>
 };
 
 const groupRequestApiResponse = (apiResponse: ProjectResource): JSX.Element => {
-    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, description, groupClass, trashAt, isTrashed, deleteAt, properties, writableBy } = apiResponse;
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name,
+        description, groupClass, trashAt, isTrashed, deleteAt, properties,
+        canWrite, canManage } = apiResponse;
     const response = `
 "uuid": "${uuid}",
 "owner_uuid": "${ownerUuid}",
@@ -459,7 +484,8 @@ const groupRequestApiResponse = (apiResponse: ProjectResource): JSX.Element => {
 "is_trashed": ${stringify(isTrashed)},
 "delete_at": ${stringify(deleteAt)},
 "properties": ${stringifyObject(properties)},
-"writable_by": ${stringifyObject(writableBy)}`;
+"can_write": ${stringify(canWrite)},
+"can_manage": ${stringify(canManage)}`;
 
     return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
 };
@@ -600,3 +626,22 @@ const linkApiResponse = (apiResponse: LinkResource): JSX.Element => {
 
     return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
 };
+
+
+const wfApiResponse = (apiResponse: WorkflowResource): JSX.Element => {
+    const {
+        uuid, name,
+        ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, description
+    } = apiResponse;
+    const response = `
+"uuid": "${uuid}",
+"name": "${name}",
+"owner_uuid": "${ownerUuid}",
+"created_at": "${stringify(createdAt)}",
+"modified_at": ${stringify(modifiedAt)},
+"modified_by_client_uuid": ${stringify(modifiedByClientUuid)},
+"modified_by_user_uuid": ${stringify(modifiedByUserUuid)}
+"description": ${stringify(description)}`;
+
+    return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
+};
index 227d2fa09c6e9617aeb15d34d1593703fbc71813..955d9689afc7f02eb471d0e965ad376cc9af16a4 100644 (file)
@@ -27,13 +27,13 @@ export class AllProcessesPanelMiddlewareService extends DataExplorerMiddlewareSe
         super(id);
     }
 
-    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>, criteriaChanged?: boolean, background?: boolean) {
         const dataExplorer = getDataExplorer(api.getState().dataExplorer, this.getId());
         if (!dataExplorer) {
             api.dispatch(allProcessesPanelDataExplorerIsNotSet());
         } else {
             try {
-                api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
+                if (!background) { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); }
                 const processItems = await this.services.containerRequestService.list(
                     {
                         ...getParams(dataExplorer),
@@ -41,7 +41,7 @@ export class AllProcessesPanelMiddlewareService extends DataExplorerMiddlewareSe
                         select: containerRequestFieldsNoMounts,
                     });
 
-                api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+                if (!background) { api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); }
                 api.dispatch(resourcesActions.SET_RESOURCES(processItems.items));
                 await api.dispatch<any>(loadMissingProcessesInformation(processItems.items));
                 api.dispatch(allProcessesPanelActions.SET_ITEMS({
@@ -51,7 +51,7 @@ export class AllProcessesPanelMiddlewareService extends DataExplorerMiddlewareSe
                     rowsPerPage: processItems.limit
                 }));
             } catch {
-                api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+                if (!background) { api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); }
                 api.dispatch(allProcessesPanelActions.SET_ITEMS({
                     items: [],
                     itemsAvailable: 0,
@@ -64,13 +64,13 @@ export class AllProcessesPanelMiddlewareService extends DataExplorerMiddlewareSe
     }
 }
 
-const getParams = ( dataExplorer: DataExplorer ) => ({
+const getParams = (dataExplorer: DataExplorer) => ({
     ...dataExplorerToListParams(dataExplorer),
     order: getOrder<ContainerRequestResource>(dataExplorer),
     filters: getFilters(dataExplorer)
 });
 
-const getFilters = ( dataExplorer: DataExplorer ) => {
+const getFilters = (dataExplorer: DataExplorer) => {
     const columns = dataExplorer.columns as DataColumns<string, ContainerRequestResource>;
     const statusColumnFilters = getDataExplorerColumnFilters(columns, 'Status');
     const activeStatusFilter = Object.keys(statusColumnFilters).find(
index cba93965892d037dc49f67e3c9e8126c49096db7..ede419251bc052cf3cd6f231b3461c31dd67f945 100644 (file)
@@ -83,10 +83,11 @@ describe('auth-actions', () => {
         const config: any = {
             rootUrl: "https://zzzzz.example.com",
             uuidPrefix: "zzzzz",
-            remoteHosts: { },
+            remoteHosts: {},
             apiRevision: 12345678,
             clusterConfig: {
                 Login: { LoginCluster: "" },
+                Workbench: { UserProfileFormFields: {} }
             },
         };
 
@@ -162,6 +163,7 @@ describe('auth-actions', () => {
             apiRevision: 12345678,
             clusterConfig: {
                 Login: { LoginCluster: "zzzz1" },
+                Workbench: { UserProfileFormFields: {} }
             },
         };
 
@@ -226,6 +228,7 @@ describe('auth-actions', () => {
             apiRevision: 12345678,
             clusterConfig: {
                 Login: { LoginCluster: "" },
+                Workbench: { UserProfileFormFields: {} }
             },
         };
 
@@ -249,6 +252,7 @@ describe('auth-actions', () => {
                                 Login: {
                                     LoginCluster: "",
                                 },
+                                Workbench: { UserProfileFormFields: {} }
                             },
                             remoteHosts: {
                                 "xc59z": "xc59z.example.com",
@@ -269,6 +273,7 @@ describe('auth-actions', () => {
                                     "Login": {
                                         "LoginCluster": "",
                                     },
+                                    Workbench: { UserProfileFormFields: {} }
                                 },
                                 "remoteHosts": {
                                     "xc59z": "xc59z.example.com",
index eb1e42b5de74c475452647489482dc1892754740..1658431302278d4e3ebea9bdb4b35cddfb34f8e6 100644 (file)
@@ -10,6 +10,7 @@ import { User } from "models/user";
 import { RootState } from 'store/store';
 import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
 import { WORKBENCH_LOADING_SCREEN } from 'store/workbench/workbench-actions';
+import { navigateToMyAccount } from 'store/navigation/navigation-action';
 
 export const authMiddleware = (services: ServiceRepository): Middleware => store => next => action => {
     // Middleware to update external state (local storage, window
@@ -35,6 +36,15 @@ export const authMiddleware = (services: ServiceRepository): Middleware => store
             }
 
             store.dispatch<any>(initSessions(services.authService, state.auth.remoteHostsConfig[state.auth.localCluster], user));
+            if (Object.keys(state.auth.config.clusterConfig.Workbench.UserProfileFormFields).length > 0 &&
+                user.isActive &&
+                (Object.keys(user.prefs).length === 0 ||
+                    user.prefs.profile === undefined ||
+                    Object.keys(user.prefs.profile!).length === 0)) {
+                // If the user doesn't have a profile set, send them
+                // to the user profile page to encourage them to fill it out.
+                store.dispatch(navigateToMyAccount);
+            }
             if (!user.isActive) {
                 // As a special case, if the user is inactive, they
                 // may be able to self-activate using the "activate"
@@ -56,7 +66,7 @@ export const authMiddleware = (services: ServiceRepository): Middleware => store
             }
         },
         SET_CONFIG: ({ config }) => {
-            document.title = `Arvados Workbench (${config.uuidPrefix})`;
+            document.title = `Arvados (${config.uuidPrefix})`;
             next(action);
         },
         LOGOUT: ({ deleteLinkData, preservePath }) => {
index 74cfde00300d545e80db987aab4e7fe0e5d7696c..9aebeb904c64115e574624163718d2fea43bcb82 100644 (file)
@@ -6,8 +6,6 @@ import { Dispatch } from 'redux';
 import { RootState } from 'store/store';
 import { getUserUuid } from "common/getuser";
 import { getResource } from 'store/resources/resources';
-import { TreePicker } from '../tree-picker/tree-picker';
-import { getSidePanelTreeBranch, getSidePanelTreeNodeAncestorsIds } from '../side-panel-tree/side-panel-tree-actions';
 import { propertiesActions } from '../properties/properties-actions';
 import { getProcess } from 'store/processes/process';
 import { ServiceRepository } from 'services/services';
@@ -22,20 +20,22 @@ import { ProcessResource } from 'models/process';
 import { OrderBuilder } from 'services/api/order-builder';
 import { Breadcrumb } from 'components/breadcrumbs/breadcrumbs';
 import { ContainerRequestResource, containerRequestFieldsNoMounts } from 'models/container-request';
-import { CollectionIcon, IconType, ProcessIcon, ProjectIcon } from 'components/icon/icon';
+import { CollectionIcon, IconType, ProcessIcon, ProjectIcon, WorkflowIcon } from 'components/icon/icon';
 import { CollectionResource } from 'models/collection';
 import { getSidePanelIcon } from 'views-components/side-panel-tree/side-panel-tree';
+import { WorkflowResource } from 'models/workflow';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
 
 export const BREADCRUMBS = 'breadcrumbs';
 
-export const setBreadcrumbs = (breadcrumbs: any, currentItem?: CollectionResource | ContainerRequestResource | GroupResource) => {
+export const setBreadcrumbs = (breadcrumbs: any, currentItem?: CollectionResource | ContainerRequestResource | GroupResource | WorkflowResource) => {
     if (currentItem) {
         breadcrumbs.push(resourceToBreadcrumb(currentItem));
     }
     return propertiesActions.SET_PROPERTY({ key: BREADCRUMBS, value: breadcrumbs });
 };
 
-const resourceToBreadcrumbIcon = (resource: CollectionResource | ContainerRequestResource | GroupResource): IconType | undefined => {
+const resourceToBreadcrumbIcon = (resource: CollectionResource | ContainerRequestResource | GroupResource | WorkflowResource): IconType | undefined => {
     switch (resource.kind) {
         case ResourceKind.PROJECT:
             return ProjectIcon;
@@ -43,55 +43,80 @@ const resourceToBreadcrumbIcon = (resource: CollectionResource | ContainerReques
             return ProcessIcon;
         case ResourceKind.COLLECTION:
             return CollectionIcon;
+        case ResourceKind.WORKFLOW:
+            return WorkflowIcon;
         default:
             return undefined;
     }
 }
 
-const resourceToBreadcrumb = (resource: CollectionResource | ContainerRequestResource | GroupResource): Breadcrumb => ({
+const resourceToBreadcrumb = (resource: CollectionResource | ContainerRequestResource | GroupResource | WorkflowResource): Breadcrumb => ({
     label: resource.name,
     uuid: resource.uuid,
     icon: resourceToBreadcrumbIcon(resource),
 })
 
-const getSidePanelTreeBreadcrumbs = (uuid: string) => (treePicker: TreePicker): Breadcrumb[] => {
-    const nodes = getSidePanelTreeBranch(uuid)(treePicker);
-    return nodes.map(node =>
-        typeof node.value === 'string'
-            ? {
-                label: node.value,
-                uuid: node.id,
-                icon: getSidePanelIcon(node.value)
-            }
-            : resourceToBreadcrumb(node.value));
-};
-
 export const setSidePanelBreadcrumbs = (uuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const { treePicker, collectionPanel: { item } } = getState();
-        const breadcrumbs = getSidePanelTreeBreadcrumbs(uuid)(treePicker);
-        const path = getState().router.location!.pathname;
-        const currentUuid = path.split('/')[2];
-        const uuidKind = extractUuidKind(currentUuid);
+        try {
+            dispatch(progressIndicatorActions.START_WORKING(uuid + "-breadcrumbs"));
+            const ancestors = await services.ancestorsService.ancestors(uuid, '');
+            dispatch(updateResources(ancestors));
+
+            let breadcrumbs: Breadcrumb[] = [];
+            const { collectionPanel: { item } } = getState();
 
-        if (uuidKind === ResourceKind.COLLECTION) {
-            const collectionItem = item ? item : await services.collectionService.get(currentUuid);
-            const parentProcessItem = await getCollectionParent(collectionItem)(services);
-            if (parentProcessItem) {
-                const mainProcessItem = await getProcessParent(parentProcessItem)(services);
-                mainProcessItem && breadcrumbs.push(resourceToBreadcrumb(mainProcessItem));
-                breadcrumbs.push(resourceToBreadcrumb(parentProcessItem));
+            const path = getState().router.location!.pathname;
+            const currentUuid = path.split('/')[2];
+            const uuidKind = extractUuidKind(currentUuid);
+            const rootUuid = getUserUuid(getState());
+
+            if (ancestors.find(ancestor => ancestor.uuid === rootUuid)) {
+                // Handle home project uuid root
+                breadcrumbs.push({
+                    label: SidePanelTreeCategory.PROJECTS,
+                    uuid: SidePanelTreeCategory.PROJECTS,
+                    icon: getSidePanelIcon(SidePanelTreeCategory.PROJECTS)
+                });
+            } else if (Object.values(SidePanelTreeCategory).includes(uuid as SidePanelTreeCategory)) {
+                // Handle SidePanelTreeCategory root
+                breadcrumbs.push({
+                    label: uuid,
+                    uuid: uuid,
+                    icon: getSidePanelIcon(uuid)
+                });
             }
-            dispatch(setBreadcrumbs(breadcrumbs, collectionItem));
-        } else if (uuidKind === ResourceKind.PROCESS) {
-            const processItem = await services.containerRequestService.get(currentUuid);
-            const parentProcessItem = await getProcessParent(processItem)(services);
-            if (parentProcessItem) {
-                breadcrumbs.push(resourceToBreadcrumb(parentProcessItem));
+
+            breadcrumbs = ancestors.reduce((breadcrumbs, ancestor) =>
+                ancestor.kind === ResourceKind.GROUP
+                    ? [...breadcrumbs, resourceToBreadcrumb(ancestor)]
+                    : breadcrumbs,
+                breadcrumbs);
+
+            if (uuidKind === ResourceKind.COLLECTION) {
+                const collectionItem = item ? item : await services.collectionService.get(currentUuid);
+                const parentProcessItem = await getCollectionParent(collectionItem)(services);
+                if (parentProcessItem) {
+                    const mainProcessItem = await getProcessParent(parentProcessItem)(services);
+                    mainProcessItem && breadcrumbs.push(resourceToBreadcrumb(mainProcessItem));
+                    breadcrumbs.push(resourceToBreadcrumb(parentProcessItem));
+                }
+                dispatch(setBreadcrumbs(breadcrumbs, collectionItem));
+            } else if (uuidKind === ResourceKind.PROCESS) {
+                const processItem = await services.containerRequestService.get(currentUuid);
+                const parentProcessItem = await getProcessParent(processItem)(services);
+                if (parentProcessItem) {
+                    breadcrumbs.push(resourceToBreadcrumb(parentProcessItem));
+                }
+                dispatch(setBreadcrumbs(breadcrumbs, processItem));
+            } else if (uuidKind === ResourceKind.WORKFLOW) {
+                const workflowItem = await services.workflowService.get(currentUuid);
+                dispatch(setBreadcrumbs(breadcrumbs, workflowItem));
             }
-            dispatch(setBreadcrumbs(breadcrumbs, processItem));
+            dispatch(setBreadcrumbs(breadcrumbs));
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(uuid + "-breadcrumbs"));
         }
-        dispatch(setBreadcrumbs(breadcrumbs));
     };
 
 export const setSharedWithMeBreadcrumbs = (uuid: string) =>
@@ -102,42 +127,50 @@ export const setTrashBreadcrumbs = (uuid: string) =>
 
 export const setCategoryBreadcrumbs = (uuid: string, category: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const ancestors = await services.ancestorsService.ancestors(uuid, '');
-        dispatch(updateResources(ancestors));
-        const initialBreadcrumbs: Breadcrumb[] = [
-            {
-                label: category,
-                uuid: category,
-                icon: getSidePanelIcon(category)
-            }
-        ];
-        const { collectionPanel: { item } } = getState();
-        const path = getState().router.location!.pathname;
-        const currentUuid = path.split('/')[2];
-        const uuidKind = extractUuidKind(currentUuid);
-        let breadcrumbs = ancestors.reduce((breadcrumbs, ancestor) =>
-            ancestor.kind === ResourceKind.GROUP
-                ? [...breadcrumbs, resourceToBreadcrumb(ancestor)]
-                : breadcrumbs,
-            initialBreadcrumbs);
-        if (uuidKind === ResourceKind.COLLECTION) {
-            const collectionItem = item ? item : await services.collectionService.get(currentUuid);
-            const parentProcessItem = await getCollectionParent(collectionItem)(services);
-            if (parentProcessItem) {
-                const mainProcessItem = await getProcessParent(parentProcessItem)(services);
-                mainProcessItem && breadcrumbs.push(resourceToBreadcrumb(mainProcessItem));
-                breadcrumbs.push(resourceToBreadcrumb(parentProcessItem));
-            }
-            dispatch(setBreadcrumbs(breadcrumbs, collectionItem));
-        } else if (uuidKind === ResourceKind.PROCESS) {
-            const processItem = await services.containerRequestService.get(currentUuid);
-            const parentProcessItem = await getProcessParent(processItem)(services);
-            if (parentProcessItem) {
-                breadcrumbs.push(resourceToBreadcrumb(parentProcessItem));
+        try {
+            dispatch(progressIndicatorActions.START_WORKING(uuid + "-breadcrumbs"));
+            const ancestors = await services.ancestorsService.ancestors(uuid, '');
+            dispatch(updateResources(ancestors));
+            const initialBreadcrumbs: Breadcrumb[] = [
+                {
+                    label: category,
+                    uuid: category,
+                    icon: getSidePanelIcon(category)
+                }
+            ];
+            const { collectionPanel: { item } } = getState();
+            const path = getState().router.location!.pathname;
+            const currentUuid = path.split('/')[2];
+            const uuidKind = extractUuidKind(currentUuid);
+            let breadcrumbs = ancestors.reduce((breadcrumbs, ancestor) =>
+                ancestor.kind === ResourceKind.GROUP
+                    ? [...breadcrumbs, resourceToBreadcrumb(ancestor)]
+                    : breadcrumbs,
+                initialBreadcrumbs);
+            if (uuidKind === ResourceKind.COLLECTION) {
+                const collectionItem = item ? item : await services.collectionService.get(currentUuid);
+                const parentProcessItem = await getCollectionParent(collectionItem)(services);
+                if (parentProcessItem) {
+                    const mainProcessItem = await getProcessParent(parentProcessItem)(services);
+                    mainProcessItem && breadcrumbs.push(resourceToBreadcrumb(mainProcessItem));
+                    breadcrumbs.push(resourceToBreadcrumb(parentProcessItem));
+                }
+                dispatch(setBreadcrumbs(breadcrumbs, collectionItem));
+            } else if (uuidKind === ResourceKind.PROCESS) {
+                const processItem = await services.containerRequestService.get(currentUuid);
+                const parentProcessItem = await getProcessParent(processItem)(services);
+                if (parentProcessItem) {
+                    breadcrumbs.push(resourceToBreadcrumb(parentProcessItem));
+                }
+                dispatch(setBreadcrumbs(breadcrumbs, processItem));
+            } else if (uuidKind === ResourceKind.WORKFLOW) {
+                const workflowItem = await services.workflowService.get(currentUuid);
+                dispatch(setBreadcrumbs(breadcrumbs, workflowItem));
             }
-            dispatch(setBreadcrumbs(breadcrumbs, processItem));
+            dispatch(setBreadcrumbs(breadcrumbs));
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(uuid + "-breadcrumbs"));
         }
-        dispatch(setBreadcrumbs(breadcrumbs));
     };
 
 const getProcessParent = (childProcess: ContainerRequestResource) =>
@@ -172,18 +205,18 @@ const getCollectionParent = (collection: CollectionResource) =>
         });
         const [parentOutput, parentLog] = await Promise.all([parentOutputPromise, parentLogPromise]);
         return parentOutput.items.length > 0 ?
-                parentOutput.items[0] :
-                parentLog.items.length > 0 ?
-                    parentLog.items[0] :
-                    undefined;
+            parentOutput.items[0] :
+            parentLog.items.length > 0 ?
+                parentLog.items[0] :
+                undefined;
     }
 
 
 export const setProjectBreadcrumbs = (uuid: string) =>
-    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        const ancestors = getSidePanelTreeNodeAncestorsIds(uuid)(getState().treePicker);
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const ancestors = await services.ancestorsService.ancestors(uuid, '');
         const rootUuid = getUserUuid(getState());
-        if (uuid === rootUuid || ancestors.find(uuid => uuid === rootUuid)) {
+        if (uuid === rootUuid || ancestors.find(ancestor => ancestor.uuid === rootUuid)) {
             dispatch(setSidePanelBreadcrumbs(uuid));
         } else {
             dispatch(setSharedWithMeBreadcrumbs(uuid));
@@ -234,7 +267,7 @@ export const setUserProfileBreadcrumbs = (userUuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         try {
             const user = getResource<UserResource>(userUuid)(getState().resources)
-                        || await services.userService.get(userUuid, false);
+                || await services.userService.get(userUuid, false);
             const breadcrumbs: Breadcrumb[] = [
                 { label: USERS_PANEL_LABEL, uuid: USERS_PANEL_LABEL },
                 { label: user ? user.username : userUuid, uuid: userUuid },
index 7bab86320da1e00c2a3f2a1706b824722c87e14c..49573215af9224d55942d2dc2e33c8c177d017e5 100644 (file)
@@ -12,6 +12,7 @@ import { unionize, ofType, UnionOf } from 'common/unionize';
 import { SnackbarKind } from 'store/snackbar/snackbar-actions';
 import { navigateTo } from 'store/navigation/navigation-action';
 import { loadDetailsPanel } from 'store/details-panel/details-panel-action';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
 
 export const collectionPanelActions = unionize({
     SET_COLLECTION: ofType<CollectionResource>(),
@@ -24,9 +25,14 @@ export const loadCollectionPanel = (uuid: string, forceReload = false) =>
         const { collectionPanel: { item } } = getState();
         let collection: CollectionResource | null = null;
         if (!item || item.uuid !== uuid || forceReload) {
-            collection = await services.collectionService.get(uuid);
-            dispatch(collectionPanelActions.SET_COLLECTION(collection));
-            dispatch(resourcesActions.SET_RESOURCES([collection]));
+            try {
+                dispatch(progressIndicatorActions.START_WORKING(uuid + "-panel"));
+                collection = await services.collectionService.get(uuid);
+                dispatch(collectionPanelActions.SET_COLLECTION(collection));
+                dispatch(resourcesActions.SET_RESOURCES([collection]));
+            } finally {
+                dispatch(progressIndicatorActions.STOP_WORKING(uuid + "-panel"));
+            }
         } else {
             collection = item;
         }
index 0044a66d84b201dca481a1028a54a9d9604106a1..298a5a1efe4a21be6f3be936ab353a001bc7d97d 100644 (file)
@@ -4,6 +4,8 @@
 
 import { Tree, TreeNode, mapTreeValues, getNodeValue, getNodeDescendants } from 'models/tree';
 import { CollectionFile, CollectionDirectory, CollectionFileType } from 'models/collection-file';
+import { ContextMenuResource } from 'store/context-menu/context-menu-actions';
+import { CollectionResource } from 'models/collection';
 
 export type CollectionPanelFilesState = Tree<CollectionPanelDirectory | CollectionPanelFile>;
 
@@ -16,6 +18,11 @@ export interface CollectionPanelFile extends CollectionFile {
     selected: boolean;
 }
 
+export interface CollectionFileSelection {
+    collection: CollectionResource;
+    selectedPaths: string[];
+}
+
 export const mapCollectionFileToCollectionPanelFile = (node: TreeNode<CollectionDirectory | CollectionFile>): TreeNode<CollectionPanelDirectory | CollectionPanelFile> => {
     return {
         ...node,
@@ -36,9 +43,21 @@ export const mergeCollectionPanelFilesStates = (oldState: CollectionPanelFilesSt
     })(newState);
 };
 
-export const filterCollectionFilesBySelection = (tree: CollectionPanelFilesState, selected: boolean) => {
+export const filterCollectionFilesBySelection = (tree: CollectionPanelFilesState, selected: boolean): (CollectionPanelFile | CollectionPanelDirectory)[] => {
     const allFiles = getNodeDescendants('')(tree).map(node => node.value);
     const selectedDirectories = allFiles.filter(file => file.selected === selected && file.type === CollectionFileType.DIRECTORY);
     const selectedFiles = allFiles.filter(file => file.selected === selected && !selectedDirectories.some(dir => dir.id === file.path));
-    return [...selectedDirectories, ...selectedFiles];
+    return [...selectedDirectories, ...selectedFiles]
+        .filter((value, index, array) => (
+            array.indexOf(value) === index
+        ));
 };
+
+export const getCollectionSelection = (sourceCollection: CollectionResource, selectedItems: (CollectionPanelDirectory | CollectionPanelFile | ContextMenuResource)[]) => ({
+    collection: sourceCollection,
+    selectedPaths: selectedItems.map(itemsToPaths).map(trimPathUuids(sourceCollection.uuid)),
+})
+
+const itemsToPaths = (item: (CollectionPanelDirectory | CollectionPanelFile | ContextMenuResource)): string => ('uuid' in item) ? item.uuid : item.id;
+
+const trimPathUuids = (parentCollectionUuid: string) => (path: string) => path.replace(new RegExp(`(^${parentCollectionUuid})`), '');
index 18023affdb4f449202336a8b28c53addb81b34fa..2d89cccd020072b11545691231107ed5a510c7a2 100644 (file)
@@ -13,7 +13,6 @@ import { resourcesActions } from 'store/resources/resources-actions';
 import { FilterBuilder } from 'services/api/filter-builder';
 import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
 import { collectionsContentAddressActions } from './collections-content-address-panel-actions';
-import { navigateTo } from 'store/navigation/navigation-action';
 import { updateFavorites } from 'store/favorites/favorites-actions';
 import { updatePublicFavorites } from 'store/public-favorites/public-favorites-actions';
 import { setBreadcrumbs } from '../breadcrumbs/breadcrumbs-actions';
@@ -21,6 +20,8 @@ import { ResourceKind, extractUuidKind } from 'models/resource';
 import { ownerNameActions } from 'store/owner-name/owner-name-actions';
 import { getUserDisplayName } from 'models/user';
 import { CollectionResource } from 'models/collection';
+import { replace } from "react-router-redux";
+import { getNavUrl } from 'routes/routes';
 
 export class CollectionsWithSameContentAddressMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
@@ -89,7 +90,7 @@ export class CollectionsWithSameContentAddressMiddlewareService extends DataExpl
                 api.dispatch<any>(updateFavorites(response.items.map(item => item.uuid)));
                 api.dispatch<any>(updatePublicFavorites(response.items.map(item => item.uuid)));
                 if (response.itemsAvailable === 1) {
-                    api.dispatch<any>(navigateTo(response.items[0].uuid));
+                    api.dispatch<any>(replace(getNavUrl(response.items[0].uuid, api.getState().auth)));
                     api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
                 } else {
                     api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
index eb9c64fdcc576a6cdb7a44b5af051b99b85977cb..c332ef5faf2bb3496cbf3a8e57fc082c68b502b3 100644 (file)
@@ -4,55 +4,81 @@
 
 import { Dispatch } from "redux";
 import { dialogActions } from "store/dialog/dialog-actions";
-import { FormErrors, initialize, startSubmit, stopSubmit } from 'redux-form';
-import { resetPickerProjectTree } from 'store/project-tree-picker/project-tree-picker-actions';
-import { RootState } from 'store/store';
-import { ServiceRepository } from 'services/services';
-import { getCommonResourceServiceError, CommonResourceServiceError } from 'services/common-service/common-resource-service';
-import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog';
+import { FormErrors, initialize, startSubmit, stopSubmit } from "redux-form";
+import { resetPickerProjectTree } from "store/project-tree-picker/project-tree-picker-actions";
+import { RootState } from "store/store";
+import { ServiceRepository } from "services/services";
+import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
+import { CopyFormDialogData } from "store/copy-dialog/copy-dialog";
 import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
-import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
+import { initProjectsTreePicker } from "store/tree-picker/tree-picker-actions";
 import { getResource } from "store/resources/resources";
 import { CollectionResource } from "models/collection";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
 
-export const COLLECTION_COPY_FORM_NAME = 'collectionCopyFormName';
+export const COLLECTION_COPY_FORM_NAME = "collectionCopyFormName";
+export const COLLECTION_MULTI_COPY_FORM_NAME = "collectionMultiCopyFormName";
 
-export const openCollectionCopyDialog = (resource: { name: string, uuid: string }) =>
-    (dispatch: Dispatch) => {
-        dispatch<any>(resetPickerProjectTree());
-        dispatch<any>(initProjectsTreePicker(COLLECTION_COPY_FORM_NAME));
-        const initialData: CopyFormDialogData = { name: `Copy of: ${resource.name}`, ownerUuid: '', uuid: resource.uuid };
-        dispatch<any>(initialize(COLLECTION_COPY_FORM_NAME, initialData));
-        dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_COPY_FORM_NAME, data: {} }));
-    };
+export const openCollectionCopyDialog = (resource: { name: string; uuid: string; fromContextMenu?: boolean }) => (dispatch: Dispatch) => {
+    dispatch<any>(resetPickerProjectTree());
+    dispatch<any>(initProjectsTreePicker(COLLECTION_COPY_FORM_NAME));
+    const initialData: CopyFormDialogData = { name: `Copy of: ${resource.name}`, ownerUuid: "", uuid: resource.uuid, fromContextMenu: resource.fromContextMenu };
+    dispatch<any>(initialize(COLLECTION_COPY_FORM_NAME, initialData));
+    dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_COPY_FORM_NAME, data: {} }));
+};
+
+export const openMultiCollectionCopyDialog = (resource: { name: string; uuid: string; fromContextMenu?: boolean }) => (dispatch: Dispatch) => {
+    dispatch<any>(resetPickerProjectTree());
+    dispatch<any>(initProjectsTreePicker(COLLECTION_MULTI_COPY_FORM_NAME));
+    const initialData: CopyFormDialogData = { name: `Copy of: ${resource.name}`, ownerUuid: "", uuid: resource.uuid, fromContextMenu: resource.fromContextMenu };
+    dispatch<any>(initialize(COLLECTION_MULTI_COPY_FORM_NAME, initialData));
+    dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_MULTI_COPY_FORM_NAME, data: {} }));
+};
 
-export const copyCollection = (resource: CopyFormDialogData) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(startSubmit(COLLECTION_COPY_FORM_NAME));
+export const copyCollection =
+    (resource: CopyFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const formName = resource.fromContextMenu ? COLLECTION_COPY_FORM_NAME : COLLECTION_MULTI_COPY_FORM_NAME;
+        dispatch(startSubmit(formName));
         let collection = getResource<CollectionResource>(resource.uuid)(getState().resources);
         try {
             if (!collection) {
                 collection = await services.collectionService.get(resource.uuid);
             }
-            const collManifestText = await services.collectionService.get(resource.uuid, undefined, ['manifestText']);
+            const collManifestText = await services.collectionService.get(resource.uuid, undefined, ["manifestText"]);
             collection.manifestText = collManifestText.manifestText;
-            const {href, ...collectionRecord} = collection;
-            const newCollection = await services.collectionService.create({ ...collectionRecord, ownerUuid: resource.ownerUuid, name: resource.name });
-            dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_COPY_FORM_NAME }));
+            const { href, ...collectionRecord } = collection;
+            const newCollection = await services.collectionService.create(
+                {
+                    ...collectionRecord,
+                    ownerUuid: resource.ownerUuid,
+                    name: resource.name,
+                },
+                false
+            );
+            dispatch(dialogActions.CLOSE_DIALOG({ id: formName }));
             return newCollection;
         } catch (e) {
+            console.error("Error while copying collection: ", e);
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
-                dispatch(stopSubmit(
-                    COLLECTION_COPY_FORM_NAME,
-                    { ownerUuid: 'A collection with the same name already exists in the target project.' } as FormErrors
-                ));
+                dispatch(
+                    stopSubmit(formName, {
+                        ownerUuid: "A collection with the same name already exists in the target project.",
+                    } as FormErrors)
+                );
+                dispatch(
+                    snackbarActions.OPEN_SNACKBAR({
+                        message: "Could not copy the collection.",
+                        hideDuration: 2000,
+                        kind: SnackbarKind.ERROR,
+                    })
+                );
             } else {
-                dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_COPY_FORM_NAME }));
-                throw new Error('Could not copy the collection.');
+                dispatch(dialogActions.CLOSE_DIALOG({ id: formName }));
+                throw new Error("Could not copy the collection.");
             }
             return;
         } finally {
-            dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_COPY_FORM_NAME));
+            dispatch(progressIndicatorActions.STOP_WORKING(formName));
         }
     };
index 6107c40972d690df30ce36a02ed589f24d67fc13..772def29d05e9610ec53561234e8de523af3a18d 100644 (file)
@@ -2,12 +2,18 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+import { ofType, unionize } from 'common/unionize';
 import { Dispatch } from "redux";
 import { RootState } from "store/store";
 import { ServiceRepository } from "services/services";
 import { dialogActions } from 'store/dialog/dialog-actions';
-import { getNewExtraToken } from "../auth/auth-action";
 import { CollectionResource } from "models/collection";
+import { SshKeyResource } from 'models/ssh-key';
+import { User } from "models/user";
+import { Session } from "models/session";
+import { Config } from 'common/config';
+import { createServices, setAuthorizationHeader } from "services/services";
+import { getTokenV2 } from 'models/api-client-authorization';
 
 export const COLLECTION_WEBDAV_S3_DIALOG_NAME = 'collectionWebdavS3Dialog';
 
@@ -42,3 +48,77 @@ export const openWebDavS3InfoDialog = (uuid: string, activeTab?: number) =>
             }
         }));
     };
+
+const authActions = unionize({
+    LOGIN: {},
+    LOGOUT: ofType<{ deleteLinkData: boolean, preservePath: boolean }>(),
+    SET_CONFIG: ofType<{ config: Config }>(),
+    SET_EXTRA_TOKEN: ofType<{ extraApiToken: string, extraApiTokenExpiration?: Date }>(),
+    RESET_EXTRA_TOKEN: {},
+    INIT_USER: ofType<{ user: User, token: string, tokenExpiration?: Date, tokenLocation?: string }>(),
+    USER_DETAILS_REQUEST: {},
+    USER_DETAILS_SUCCESS: ofType<User>(),
+    SET_SSH_KEYS: ofType<SshKeyResource[]>(),
+    ADD_SSH_KEY: ofType<SshKeyResource>(),
+    REMOVE_SSH_KEY: ofType<string>(),
+    SET_HOME_CLUSTER: ofType<string>(),
+    SET_SESSIONS: ofType<Session[]>(),
+    ADD_SESSION: ofType<Session>(),
+    REMOVE_SESSION: ofType<string>(),
+    UPDATE_SESSION: ofType<Session>(),
+    REMOTE_CLUSTER_CONFIG: ofType<{ config: Config }>(),
+});
+
+const getConfig = (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Config => {
+    const state = getState().auth;
+    return state.remoteHostsConfig[state.localCluster];
+};
+
+const getNewExtraToken =
+    (reuseStored: boolean = false) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const extraToken = getState().auth.extraApiToken;
+        if (reuseStored && extraToken !== undefined) {
+            const config = dispatch<any>(getConfig);
+            const svc = createServices(config, { progressFn: () => {}, errorFn: () => {} });
+            setAuthorizationHeader(svc, extraToken);
+            try {
+                // Check the extra token's validity before using it. Refresh its
+                // expiration date just in case it changed.
+                const client = await svc.apiClientAuthorizationService.get('current');
+                dispatch(
+                    authActions.SET_EXTRA_TOKEN({
+                        extraApiToken: extraToken,
+                        extraApiTokenExpiration: client.expiresAt ? new Date(client.expiresAt) : undefined,
+                    })
+                );
+                return extraToken;
+            } catch (e) {
+                dispatch(authActions.RESET_EXTRA_TOKEN());
+            }
+        }
+        const user = getState().auth.user;
+        const loginCluster = getState().auth.config.clusterConfig.Login.LoginCluster;
+        if (user === undefined) {
+            return;
+        }
+        if (loginCluster !== '' && getState().auth.homeCluster !== loginCluster) {
+            return;
+        }
+        try {
+            // Do not show errors on the create call, cluster security configuration may not
+            // allow token creation and there's no way to know that from workbench2 side in advance.
+            const client = await services.apiClientAuthorizationService.create(undefined, false);
+            const newExtraToken = getTokenV2(client);
+            dispatch(
+                authActions.SET_EXTRA_TOKEN({
+                    extraApiToken: newExtraToken,
+                    extraApiTokenExpiration: client.expiresAt ? new Date(client.expiresAt) : undefined,
+                })
+            );
+            return newExtraToken;
+        } catch {
+            console.warn("Cannot create new tokens with the current token, probably because of cluster's security settings.");
+            return;
+        }
+    };
\ No newline at end of file
index 929f1612f7b8c3baa6ded53a46091e811cdc2a38..56c7b24c60e0ca555e097df8c58072447bae098a 100644 (file)
@@ -4,31 +4,30 @@
 
 import { Dispatch } from "redux";
 import { dialogActions } from "store/dialog/dialog-actions";
-import { startSubmit, stopSubmit, initialize, FormErrors } from 'redux-form';
-import { ServiceRepository } from 'services/services';
-import { RootState } from 'store/store';
+import { startSubmit, stopSubmit, initialize, FormErrors } from "redux-form";
+import { ServiceRepository } from "services/services";
+import { RootState } from "store/store";
 import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
-import {snackbarActions, SnackbarKind} from 'store/snackbar/snackbar-actions';
-import { projectPanelActions } from 'store/project-panel/project-panel-action';
-import { MoveToFormDialogData } from 'store/move-to-dialog/move-to-dialog';
-import { resetPickerProjectTree } from 'store/project-tree-picker/project-tree-picker-actions';
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { MoveToFormDialogData } from "store/move-to-dialog/move-to-dialog";
+import { resetPickerProjectTree } from "store/project-tree-picker/project-tree-picker-actions";
 import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
-import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
+import { initProjectsTreePicker } from "store/tree-picker/tree-picker-actions";
 import { getResource } from "store/resources/resources";
 import { CollectionResource } from "models/collection";
 
-export const COLLECTION_MOVE_FORM_NAME = 'collectionMoveFormName';
+export const COLLECTION_MOVE_FORM_NAME = "collectionMoveFormName";
 
-export const openMoveCollectionDialog = (resource: { name: string, uuid: string }) =>
-    (dispatch: Dispatch) => {
-        dispatch<any>(resetPickerProjectTree());
-        dispatch<any>(initProjectsTreePicker(COLLECTION_MOVE_FORM_NAME));
-        dispatch(initialize(COLLECTION_MOVE_FORM_NAME, resource));
-        dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_MOVE_FORM_NAME, data: {} }));
-    };
+export const openMoveCollectionDialog = (resource: { name: string; uuid: string }) => (dispatch: Dispatch) => {
+    dispatch<any>(resetPickerProjectTree());
+    dispatch<any>(initProjectsTreePicker(COLLECTION_MOVE_FORM_NAME));
+    dispatch(initialize(COLLECTION_MOVE_FORM_NAME, resource));
+    dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_MOVE_FORM_NAME, data: {} }));
+};
 
-export const moveCollection = (resource: MoveToFormDialogData) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+export const moveCollection =
+    (resource: MoveToFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(startSubmit(COLLECTION_MOVE_FORM_NAME));
         let cachedCollection = getResource<CollectionResource>(resource.uuid)(getState().resources);
         try {
@@ -40,14 +39,18 @@ export const moveCollection = (resource: MoveToFormDialogData) =>
             dispatch(projectPanelActions.REQUEST_ITEMS());
             dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_MOVE_FORM_NAME }));
             dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_MOVE_FORM_NAME));
-            return {...cachedCollection, ...collection};
+            return { ...cachedCollection, ...collection };
         } catch (e) {
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
-                dispatch(stopSubmit(COLLECTION_MOVE_FORM_NAME, { ownerUuid: 'A collection with the same name already exists in the target project.' } as FormErrors));
+                dispatch(
+                    stopSubmit(COLLECTION_MOVE_FORM_NAME, {
+                        ownerUuid: "A collection with the same name already exists in the target project.",
+                    } as FormErrors)
+                );
             } else {
                 dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_MOVE_FORM_NAME }));
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not move the collection.', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Could not move the collection.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
             }
             dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_MOVE_FORM_NAME));
             return;
index a0c4e4e490d3476bea7ef247d076c6b23a269289..a0933c64da0ffb715d28df82db9472624346a7e7 100644 (file)
 // SPDX-License-Identifier: AGPL-3.0
 
 import { Dispatch } from 'redux';
-import { difference } from "lodash";
 import { RootState } from 'store/store';
-import { FormErrors, initialize, startSubmit, stopSubmit } from 'redux-form';
+import { initialize, startSubmit, stopSubmit } from 'redux-form';
 import { resetPickerProjectTree } from 'store/project-tree-picker/project-tree-picker-actions';
 import { dialogActions } from 'store/dialog/dialog-actions';
 import { ServiceRepository } from 'services/services';
-import { filterCollectionFilesBySelection } from '../collection-panel/collection-panel-files/collection-panel-files-state';
+import { CollectionFileSelection, CollectionPanelDirectory, CollectionPanelFile, filterCollectionFilesBySelection, getCollectionSelection } from '../collection-panel/collection-panel-files/collection-panel-files-state';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 import { getCommonResourceServiceError, CommonResourceServiceError } from 'services/common-service/common-resource-service';
 import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
-import { initProjectsTreePicker } from "store/tree-picker/tree-picker-actions";
+import { FileOperationLocation } from "store/tree-picker/tree-picker-actions";
+import { updateResources } from 'store/resources/resources-actions';
+import { navigateTo } from 'store/navigation/navigation-action';
+import { ContextMenuResource } from 'store/context-menu/context-menu-actions';
+import { CollectionResource } from 'models/collection';
 
 export const COLLECTION_PARTIAL_COPY_FORM_NAME = 'COLLECTION_PARTIAL_COPY_DIALOG';
 export const COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION = 'COLLECTION_PARTIAL_COPY_TO_SELECTED_DIALOG';
+export const COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS = 'COLLECTION_PARTIAL_COPY_TO_SEPARATE_DIALOG';
 
-export interface CollectionPartialCopyFormData {
+export interface CollectionPartialCopyToNewCollectionFormData {
     name: string;
     description: string;
     projectUuid: string;
 }
 
-export interface CollectionPartialCopyToSelectedCollectionFormData {
-    collectionUuid: string;
+export interface CollectionPartialCopyToExistingCollectionFormData {
+    destination: FileOperationLocation;
 }
 
-export const openCollectionPartialCopyDialog = () =>
+export interface CollectionPartialCopyToSeparateCollectionsFormData {
+    name: string;
+    projectUuid: string;
+}
+
+export const openCollectionPartialCopyToNewCollectionDialog = (resource: ContextMenuResource) =>
     (dispatch: Dispatch, getState: () => RootState) => {
-        const currentCollection = getState().collectionPanel.item;
-        if (currentCollection) {
-            const initialData = {
-                name: `Files extracted from: ${currentCollection.name}`,
-                description: currentCollection.description,
-                projectUuid: undefined
-            };
-            dispatch(initialize(COLLECTION_PARTIAL_COPY_FORM_NAME, initialData));
-            dispatch<any>(resetPickerProjectTree());
-            dispatch<any>(initProjectsTreePicker(COLLECTION_PARTIAL_COPY_FORM_NAME));
-            dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_PARTIAL_COPY_FORM_NAME, data: {} }));
+        const sourceCollection = getState().collectionPanel.item;
+
+        if (sourceCollection) {
+            openCopyToNewDialog(dispatch, sourceCollection, [resource]);
         }
     };
 
-export const copyCollectionPartial = ({ name, description, projectUuid }: CollectionPartialCopyFormData) =>
+export const openCollectionPartialCopyMultipleToNewCollectionDialog = () =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const sourceCollection = getState().collectionPanel.item;
+        const selectedItems = filterCollectionFilesBySelection(getState().collectionPanelFiles, true);
+
+        if (sourceCollection && selectedItems.length) {
+            openCopyToNewDialog(dispatch, sourceCollection, selectedItems);
+        }
+    };
+
+const openCopyToNewDialog = (dispatch: Dispatch, sourceCollection: CollectionResource, selectedItems: (CollectionPanelDirectory | CollectionPanelFile | ContextMenuResource)[]) => {
+    // Get selected files
+    const collectionFileSelection = getCollectionSelection(sourceCollection, selectedItems);
+    // Populate form initial state
+    const initialFormData = {
+        name: `Files extracted from: ${sourceCollection.name}`,
+        description: sourceCollection.description,
+        projectUuid: undefined
+    };
+    dispatch(initialize(COLLECTION_PARTIAL_COPY_FORM_NAME, initialFormData));
+    dispatch<any>(resetPickerProjectTree());
+    dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_PARTIAL_COPY_FORM_NAME, data: collectionFileSelection }));
+};
+
+export const copyCollectionPartialToNewCollection = (fileSelection: CollectionFileSelection, formData: CollectionPartialCopyToNewCollectionFormData) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(startSubmit(COLLECTION_PARTIAL_COPY_FORM_NAME));
-        const state = getState();
-        const currentCollection = state.collectionPanel.item;
-        if (currentCollection) {
+        if (fileSelection.collection) {
             try {
+                dispatch(startSubmit(COLLECTION_PARTIAL_COPY_FORM_NAME));
                 dispatch(progressIndicatorActions.START_WORKING(COLLECTION_PARTIAL_COPY_FORM_NAME));
-                const collectionManifestText = await services.collectionService.get(currentCollection.uuid, undefined, ['manifestText']);
-                const collectionCopy = {
-                    name,
-                    description,
-                    ownerUuid: projectUuid,
-                    uuid: undefined,
-                    manifestText: collectionManifestText.manifestText,
-                };
-                const newCollection = await services.collectionService.create(collectionCopy);
-                const copiedFiles = await services.collectionService.files(newCollection.uuid);
-                const paths = filterCollectionFilesBySelection(state.collectionPanelFiles, true).map(file => file.id);
-                const filesToDelete = copiedFiles.map(({ id }) => id).filter(file => {
-                    return !paths.find(path => path.indexOf(file.replace(newCollection.uuid, '')) > -1);
-                });
-                await services.collectionService.deleteFiles(
-                    newCollection.uuid,
-                    filesToDelete
+
+                // Copy files
+                const updatedCollection = await services.collectionService.copyFiles(
+                    fileSelection.collection.portableDataHash,
+                    fileSelection.selectedPaths,
+                    {
+                        name: formData.name,
+                        description: formData.description,
+                        ownerUuid: formData.projectUuid,
+                        uuid: undefined,
+                    },
+                    '/',
+                    false
                 );
+                dispatch(updateResources([updatedCollection]));
+                dispatch<any>(navigateTo(updatedCollection.uuid));
+
                 dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_FORM_NAME }));
                 dispatch(snackbarActions.OPEN_SNACKBAR({
                     message: 'New collection created.',
                     hideDuration: 2000,
                     kind: SnackbarKind.SUCCESS
                 }));
-                dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_PARTIAL_COPY_FORM_NAME));
             } catch (e) {
                 const error = getCommonResourceServiceError(e);
                 if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
-                    dispatch(stopSubmit(COLLECTION_PARTIAL_COPY_FORM_NAME, { name: 'Collection with this name already exists.' } as FormErrors));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection with this name already exists', hideDuration: 2000, kind: SnackbarKind.ERROR }));
                 } else if (error === CommonResourceServiceError.UNKNOWN) {
                     dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_FORM_NAME }));
                     dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not create a copy of collection', hideDuration: 2000, kind: SnackbarKind.ERROR }));
@@ -88,70 +110,143 @@ export const copyCollectionPartial = ({ name, description, projectUuid }: Collec
                     dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_FORM_NAME }));
                     dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection has been copied but may contain incorrect files.', hideDuration: 2000, kind: SnackbarKind.ERROR }));
                 }
+            } finally {
+                dispatch(stopSubmit(COLLECTION_PARTIAL_COPY_FORM_NAME));
                 dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_PARTIAL_COPY_FORM_NAME));
             }
         }
     };
 
-export const openCollectionPartialCopyToSelectedCollectionDialog = () =>
+export const openCollectionPartialCopyToExistingCollectionDialog = (resource: ContextMenuResource) =>
     (dispatch: Dispatch, getState: () => RootState) => {
-        const currentCollection = getState().collectionPanel.item;
-        if (currentCollection) {
-            const initialData = {
-                collectionUuid: ''
-            };
-            dispatch(initialize(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION, initialData));
-            dispatch<any>(resetPickerProjectTree());
-            dispatch<any>(initProjectsTreePicker(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION));
-            dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION, data: {} }));
+        const sourceCollection = getState().collectionPanel.item;
+
+        if (sourceCollection) {
+            openCopyToExistingDialog(dispatch, sourceCollection, [resource]);
         }
     };
 
-export const copyCollectionPartialToSelectedCollection = ({ collectionUuid }: CollectionPartialCopyToSelectedCollectionFormData) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(startSubmit(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION));
-        const state = getState();
-        const currentCollection = state.collectionPanel.item;
-
-        if (currentCollection && !currentCollection.manifestText) {
-            const fetchedCurrentCollection = await services.collectionService.get(currentCollection.uuid, undefined, ['manifestText']);
-            currentCollection.manifestText = fetchedCurrentCollection.manifestText;
-            currentCollection.unsignedManifestText = fetchedCurrentCollection.unsignedManifestText;
+export const openCollectionPartialCopyMultipleToExistingCollectionDialog = () =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const sourceCollection = getState().collectionPanel.item;
+        const selectedItems = filterCollectionFilesBySelection(getState().collectionPanelFiles, true);
+
+        if (sourceCollection && selectedItems.length) {
+            openCopyToExistingDialog(dispatch, sourceCollection, selectedItems);
         }
+    };
+
+const openCopyToExistingDialog = (dispatch: Dispatch, sourceCollection: CollectionResource, selectedItems: (CollectionPanelDirectory | CollectionPanelFile | ContextMenuResource)[]) => {
+    // Get selected files
+    const collectionFileSelection = getCollectionSelection(sourceCollection, selectedItems);
+    // Populate form initial state
+    const initialFormData = {
+        destination: {uuid: sourceCollection.uuid, destinationPath: ''}
+    };
+    dispatch(initialize(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION, initialFormData));
+    dispatch<any>(resetPickerProjectTree());
+    dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION, data: collectionFileSelection }));
+}
 
-        if (currentCollection) {
+export const copyCollectionPartialToExistingCollection = (fileSelection: CollectionFileSelection, formData: CollectionPartialCopyToExistingCollectionFormData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        if (fileSelection.collection && formData.destination && formData.destination.uuid) {
             try {
+                dispatch(startSubmit(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION));
                 dispatch(progressIndicatorActions.START_WORKING(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION));
-                const selectedCollection = await services.collectionService.get(collectionUuid);
-                const paths = filterCollectionFilesBySelection(state.collectionPanelFiles, false).map(file => file.id);
-                const pathsToRemove = paths.filter(path => {
-                    const a = path.split('/');
-                    const fileExistsInSelectedCollection = selectedCollection.manifestText.includes(a[1]);
-                    if (fileExistsInSelectedCollection) {
-                        return path;
-                    } else {
-                        return null;
-                    }
-                });
-                const diffPathToRemove = difference(paths, pathsToRemove);
-                await services.collectionService.deleteFiles(selectedCollection.uuid, pathsToRemove.map(path => path.replace(currentCollection.uuid, collectionUuid)));
-                const collectionWithDeletedFiles = await services.collectionService.get(collectionUuid, undefined, ['uuid', 'manifestText']);
-                await services.collectionService.update(collectionUuid, { manifestText: `${collectionWithDeletedFiles.manifestText}${(currentCollection.manifestText ? currentCollection.manifestText : currentCollection.unsignedManifestText) || ''}` });
-                await services.collectionService.deleteFiles(collectionWithDeletedFiles.uuid, diffPathToRemove.map(path => path.replace(currentCollection.uuid, collectionUuid)));
+
+                // Copy files
+                const updatedCollection = await services.collectionService.copyFiles(
+                    fileSelection.collection.portableDataHash,
+                    fileSelection.selectedPaths,
+                    {uuid: formData.destination.uuid},
+                    formData.destination.subpath || '/',
+                    false
+                );
+                dispatch(updateResources([updatedCollection]));
+
                 dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION }));
                 dispatch(snackbarActions.OPEN_SNACKBAR({
                     message: 'Files has been copied to selected collection.',
                     hideDuration: 2000,
                     kind: SnackbarKind.SUCCESS
                 }));
-                dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION));
             } catch (e) {
                 const error = getCommonResourceServiceError(e);
                 if (error === CommonResourceServiceError.UNKNOWN) {
                     dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION }));
                     dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not copy this files to selected collection', hideDuration: 2000, kind: SnackbarKind.ERROR }));
                 }
+            } finally {
+                dispatch(stopSubmit(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION));
                 dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION));
             }
         }
-    };
\ No newline at end of file
+    };
+
+export const openCollectionPartialCopyToSeparateCollectionsDialog = () =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const sourceCollection = getState().collectionPanel.item;
+        const selectedItems = filterCollectionFilesBySelection(getState().collectionPanelFiles, true);
+
+        if (sourceCollection && selectedItems.length) {
+            // Get selected files
+            const collectionFileSelection = getCollectionSelection(sourceCollection, selectedItems);
+            // Populate form initial state
+            const initialFormData = {
+                name: sourceCollection.name,
+                projectUuid: undefined
+            };
+            dispatch(initialize(COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS, initialFormData));
+            dispatch<any>(resetPickerProjectTree());
+            dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS, data: collectionFileSelection }));
+        }
+    };
+
+export const copyCollectionPartialToSeparateCollections = (fileSelection: CollectionFileSelection, formData: CollectionPartialCopyToSeparateCollectionsFormData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        if (fileSelection.collection) {
+            try {
+                dispatch(startSubmit(COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS));
+                dispatch(progressIndicatorActions.START_WORKING(COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS));
+
+                // Copy files
+                const collections = await Promise.all(fileSelection.selectedPaths.map((path) =>
+                    services.collectionService.copyFiles(
+                        fileSelection.collection.portableDataHash,
+                        [path],
+                        {
+                            name: `File copied from collection ${formData.name}${path}`,
+                            ownerUuid: formData.projectUuid,
+                            uuid: undefined,
+                        },
+                        '/',
+                        false
+                    )
+                ));
+                dispatch(updateResources(collections));
+                dispatch<any>(navigateTo(formData.projectUuid));
+
+                dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'New collections created.',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS
+                }));
+            } catch (e) {
+                const error = getCommonResourceServiceError(e);
+                if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection from one or more files already exists', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                } else if (error === CommonResourceServiceError.UNKNOWN) {
+                    dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS }));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not create a copy of collection', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                } else {
+                    dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS }));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection has been copied but may contain incorrect files.', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                }
+            } finally {
+                dispatch(stopSubmit(COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS));
+                dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS));
+            }
+        }
+    };
diff --git a/src/store/collections/collection-partial-move-actions.ts b/src/store/collections/collection-partial-move-actions.ts
new file mode 100644 (file)
index 0000000..56f7302
--- /dev/null
@@ -0,0 +1,252 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { initialize, startSubmit, stopSubmit } from "redux-form";
+import { CommonResourceServiceError, getCommonResourceServiceError } from "services/common-service/common-resource-service";
+import { ServiceRepository } from "services/services";
+import { CollectionFileSelection, CollectionPanelDirectory, CollectionPanelFile, filterCollectionFilesBySelection, getCollectionSelection } from "store/collection-panel/collection-panel-files/collection-panel-files-state";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
+import { dialogActions } from "store/dialog/dialog-actions";
+import { navigateTo } from "store/navigation/navigation-action";
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { resetPickerProjectTree } from "store/project-tree-picker/project-tree-picker-actions";
+import { updateResources } from "store/resources/resources-actions";
+import { SnackbarKind, snackbarActions } from "store/snackbar/snackbar-actions";
+import { RootState } from "store/store";
+import { FileOperationLocation } from "store/tree-picker/tree-picker-actions";
+import { CollectionResource } from "models/collection";
+import { SOURCE_DESTINATION_EQUAL_ERROR_MESSAGE } from "services/collection-service/collection-service";
+
+export const COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION = 'COLLECTION_PARTIAL_MOVE_TO_NEW_DIALOG';
+export const COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION = 'COLLECTION_PARTIAL_MOVE_TO_SELECTED_DIALOG';
+export const COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS = 'COLLECTION_PARTIAL_MOVE_TO_SEPARATE_DIALOG';
+
+export interface CollectionPartialMoveToNewCollectionFormData {
+    name: string;
+    description: string;
+    projectUuid: string;
+}
+
+export interface CollectionPartialMoveToExistingCollectionFormData {
+    destination: FileOperationLocation;
+}
+
+export interface CollectionPartialMoveToSeparateCollectionsFormData {
+    name: string;
+    projectUuid: string;
+}
+
+export const openCollectionPartialMoveToNewCollectionDialog = (resource: ContextMenuResource) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const sourceCollection = getState().collectionPanel.item;
+
+        if (sourceCollection) {
+            openMoveToNewDialog(dispatch, sourceCollection, [resource]);
+        }
+    };
+
+export const openCollectionPartialMoveMultipleToNewCollectionDialog = () =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const sourceCollection = getState().collectionPanel.item;
+        const selectedItems = filterCollectionFilesBySelection(getState().collectionPanelFiles, true);
+
+        if (sourceCollection && selectedItems.length) {
+            openMoveToNewDialog(dispatch, sourceCollection, selectedItems);
+        }
+    };
+
+const openMoveToNewDialog = (dispatch: Dispatch, sourceCollection: CollectionResource, selectedItems: (CollectionPanelDirectory | CollectionPanelFile | ContextMenuResource)[]) => {
+    // Get selected files
+    const collectionFileSelection = getCollectionSelection(sourceCollection, selectedItems);
+    // Populate form initial state
+    const initialFormData = {
+        name: `Files moved from: ${sourceCollection.name}`,
+        description: sourceCollection.description,
+        projectUuid: undefined
+    };
+    dispatch(initialize(COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION, initialFormData));
+    dispatch<any>(resetPickerProjectTree());
+    dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION, data: collectionFileSelection }));
+}
+
+export const moveCollectionPartialToNewCollection = (fileSelection: CollectionFileSelection, formData: CollectionPartialMoveToNewCollectionFormData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        if (fileSelection.collection) {
+            try {
+                dispatch(startSubmit(COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION));
+                dispatch(progressIndicatorActions.START_WORKING(COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION));
+
+                // Move files
+                const updatedCollection = await services.collectionService.moveFiles(
+                    fileSelection.collection.uuid,
+                    fileSelection.collection.portableDataHash,
+                    fileSelection.selectedPaths,
+                    {
+                        name: formData.name,
+                        description: formData.description,
+                        ownerUuid: formData.projectUuid,
+                        uuid: undefined,
+                    },
+                    '/',
+                    false
+                );
+                dispatch(updateResources([updatedCollection]));
+                dispatch<any>(navigateTo(updatedCollection.uuid));
+
+                dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Files have been moved to selected collection.',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS
+                }));
+            } catch (e) {
+                const error = getCommonResourceServiceError(e);
+                if (error === CommonResourceServiceError.UNKNOWN) {
+                    dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION }));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not move files to selected collection', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                }
+            } finally {
+                dispatch(stopSubmit(COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION));
+                dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION));
+            }
+        }
+    };
+
+export const openCollectionPartialMoveToExistingCollectionDialog = (resource: ContextMenuResource) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const sourceCollection = getState().collectionPanel.item;
+
+        if (sourceCollection) {
+            openMoveToExistingDialog(dispatch, sourceCollection, [resource]);
+        }
+    };
+
+export const openCollectionPartialMoveMultipleToExistingCollectionDialog = () =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const sourceCollection = getState().collectionPanel.item;
+        const selectedItems = filterCollectionFilesBySelection(getState().collectionPanelFiles, true);
+
+        if (sourceCollection && selectedItems.length) {
+            openMoveToExistingDialog(dispatch, sourceCollection, selectedItems);
+        }
+    };
+
+const openMoveToExistingDialog = (dispatch: Dispatch, sourceCollection: CollectionResource, selectedItems: (CollectionPanelDirectory | CollectionPanelFile | ContextMenuResource)[]) => {
+    // Get selected files
+    const collectionFileSelection = getCollectionSelection(sourceCollection, selectedItems);
+    // Populate form initial state
+    const initialFormData = {
+        destination: {uuid: sourceCollection.uuid, path: ''}
+    };
+    dispatch(initialize(COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION, initialFormData));
+    dispatch<any>(resetPickerProjectTree());
+    dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION, data: collectionFileSelection }));
+}
+
+export const moveCollectionPartialToExistingCollection = (fileSelection: CollectionFileSelection, formData: CollectionPartialMoveToExistingCollectionFormData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        if (fileSelection.collection && formData.destination && formData.destination.uuid) {
+            try {
+                dispatch(startSubmit(COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION));
+                dispatch(progressIndicatorActions.START_WORKING(COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION));
+
+                // Move files
+                const updatedCollection = await services.collectionService.moveFiles(
+                    fileSelection.collection.uuid,
+                    fileSelection.collection.portableDataHash,
+                    fileSelection.selectedPaths,
+                    {uuid: formData.destination.uuid},
+                    formData.destination.subpath || '/', false
+                );
+                dispatch(updateResources([updatedCollection]));
+
+                dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Files have been moved to selected collection.',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS
+                }));
+            } catch (e) {
+                const error = getCommonResourceServiceError(e);
+                if (error === CommonResourceServiceError.SOURCE_DESTINATION_CANNOT_BE_SAME) {
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: SOURCE_DESTINATION_EQUAL_ERROR_MESSAGE, hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                } else if (error === CommonResourceServiceError.UNKNOWN) {
+                    dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION }));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not copy this files to selected collection', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                }
+            } finally {
+                dispatch(stopSubmit(COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION));
+                dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION));
+            }
+        }
+    };
+
+export const openCollectionPartialMoveToSeparateCollectionsDialog = () =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const sourceCollection = getState().collectionPanel.item;
+        const selectedItems = filterCollectionFilesBySelection(getState().collectionPanelFiles, true);
+
+        if (sourceCollection && selectedItems.length) {
+            // Get selected files
+            const collectionFileSelection = getCollectionSelection(sourceCollection, selectedItems);
+            // Populate form initial state
+            const initialData = {
+                name: sourceCollection.name,
+                projectUuid: undefined
+            };
+            dispatch(initialize(COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS, initialData));
+            dispatch<any>(resetPickerProjectTree());
+            dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS, data: collectionFileSelection }));
+        }
+    };
+
+export const moveCollectionPartialToSeparateCollections = (fileSelection: CollectionFileSelection, formData: CollectionPartialMoveToSeparateCollectionsFormData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        if (fileSelection.collection) {
+            try {
+                dispatch(startSubmit(COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS));
+                dispatch(progressIndicatorActions.START_WORKING(COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS));
+
+                // Move files
+                const collections = await Promise.all(fileSelection.selectedPaths.map((path) =>
+                    services.collectionService.moveFiles(
+                        fileSelection.collection.uuid,
+                        fileSelection.collection.portableDataHash,
+                        [path],
+                        {
+                            name: `File moved from collection ${formData.name}${path}`,
+                            ownerUuid: formData.projectUuid,
+                            uuid: undefined,
+                        },
+                        '/',
+                        false
+                    )
+                ));
+                dispatch(updateResources(collections));
+                dispatch<any>(navigateTo(formData.projectUuid));
+
+                dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'New collections created.',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS
+                }));
+            } catch (e) {
+                const error = getCommonResourceServiceError(e);
+                if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection from one or more files already exists', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                } else if (error === CommonResourceServiceError.UNKNOWN) {
+                    dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS }));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not create a copy of collection', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                } else {
+                    dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS }));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection has been copied but may contain incorrect files.', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                }
+            } finally {
+                dispatch(stopSubmit(COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS));
+                dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS));
+            }
+        }
+    };
index d9e87b1a763760102d0d32c96cbe64c92cbd7f42..623c45088cc83922f137896effccf4969e19dc0b 100644 (file)
@@ -107,13 +107,13 @@ describe('context-menu-actions', () => {
                         [projectUuid]: {
                             uuid: projectUuid,
                             ownerUuid: isEditable ? userUuid : otherUserUuid,
-                            writableBy: isEditable ? [userUuid] : [otherUserUuid],
+                            canWrite: isEditable,
                             groupClass: GroupClass.PROJECT,
                         },
                         [filterGroupUuid]: {
                             uuid: filterGroupUuid,
                             ownerUuid: isEditable ? userUuid : otherUserUuid,
-                            writableBy: isEditable ? [userUuid] : [otherUserUuid],
+                            canWrite: isEditable,
                             groupClass: GroupClass.FILTER,
                         },
                         [linkUuid]: {
index 3bc91ae0c74c1464aecb5e5c7d0ba7b3b56a0353..464314877ff645328d838f2ddbbb1e4cd2a99ec7 100644 (file)
@@ -2,31 +2,33 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { unionize, ofType, UnionOf } from 'common/unionize';
+import { unionize, ofType, UnionOf } from "common/unionize";
 import { ContextMenuPosition } from "./context-menu-reducer";
-import { ContextMenuKind } from 'views-components/context-menu/context-menu';
-import { Dispatch } from 'redux';
-import { RootState } from 'store/store';
-import { getResource, getResourceWithEditableStatus } from '../resources/resources';
-import { UserResource } from 'models/user';
-import { isSidePanelTreeCategory } from 'store/side-panel-tree/side-panel-tree-actions';
-import { extractUuidKind, ResourceKind, EditableResource, Resource } from 'models/resource';
-import { Process } from 'store/processes/process';
-import { RepositoryResource } from 'models/repositories';
-import { SshKeyResource } from 'models/ssh-key';
-import { VirtualMachinesResource } from 'models/virtual-machines';
-import { KeepServiceResource } from 'models/keep-services';
-import { ProcessResource } from 'models/process';
-import { CollectionResource } from 'models/collection';
-import { GroupClass, GroupResource } from 'models/group';
-import { GroupContentsResource } from 'services/groups-service/groups-service';
-import { LinkResource } from 'models/link';
-import { resourceIsFrozen } from 'common/frozen-resources';
-import { ProjectResource } from 'models/project';
+import { ContextMenuKind } from "views-components/context-menu/context-menu";
+import { Dispatch } from "redux";
+import { RootState } from "store/store";
+import { getResource, getResourceWithEditableStatus } from "../resources/resources";
+import { UserResource } from "models/user";
+import { isSidePanelTreeCategory } from "store/side-panel-tree/side-panel-tree-actions";
+import { extractUuidKind, ResourceKind, EditableResource, Resource } from "models/resource";
+import { Process, isProcessCancelable } from "store/processes/process";
+import { RepositoryResource } from "models/repositories";
+import { SshKeyResource } from "models/ssh-key";
+import { VirtualMachinesResource } from "models/virtual-machines";
+import { KeepServiceResource } from "models/keep-services";
+import { ProcessResource } from "models/process";
+import { CollectionResource } from "models/collection";
+import { GroupClass, GroupResource } from "models/group";
+import { GroupContentsResource } from "services/groups-service/groups-service";
+import { LinkResource } from "models/link";
+import { resourceIsFrozen } from "common/frozen-resources";
+import { ProjectResource } from "models/project";
+import { getProcess } from "store/processes/process";
+import { filterCollectionFilesBySelection } from "store/collection-panel/collection-panel-files/collection-panel-files-state";
 
 export const contextMenuActions = unionize({
-    OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition, resource: ContextMenuResource }>(),
-    CLOSE_CONTEXT_MENU: ofType<{}>()
+    OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition; resource: ContextMenuResource }>(),
+    CLOSE_CONTEXT_MENU: ofType<{}>(),
 });
 
 export type ContextMenuAction = UnionOf<typeof contextMenuActions>;
@@ -36,7 +38,7 @@ export type ContextMenuResource = {
     uuid: string;
     ownerUuid: string;
     description?: string;
-    kind: ResourceKind,
+    kind: ResourceKind;
     menuKind: ContextMenuKind | string;
     isTrashed?: boolean;
     isEditable?: boolean;
@@ -46,191 +48,214 @@ export type ContextMenuResource = {
     isFrozen?: boolean;
     storageClassesDesired?: string[];
     properties?: { [key: string]: string | string[] };
+    isMulti?: boolean;
+    fromContextMenu?: boolean;
 };
 
 export const isKeyboardClick = (event: React.MouseEvent<HTMLElement>) => event.nativeEvent.detail === 0;
 
-export const openContextMenu = (event: React.MouseEvent<HTMLElement>, resource: ContextMenuResource) =>
-    (dispatch: Dispatch) => {
-        event.preventDefault();
-        const { left, top } = event.currentTarget.getBoundingClientRect();
-        dispatch(
-            contextMenuActions.OPEN_CONTEXT_MENU({
-                position: {
-                    x: event.clientX || left,
-                    y: event.clientY || top,
-                },
-                resource
+export const openContextMenu = (event: React.MouseEvent<HTMLElement>, resource: ContextMenuResource) => (dispatch: Dispatch) => {
+    event.preventDefault();
+    const { left, top } = event.currentTarget.getBoundingClientRect();
+    dispatch(
+        contextMenuActions.OPEN_CONTEXT_MENU({
+            position: {
+                x: event.clientX || left,
+                y: event.clientY || top,
+            },
+            resource,
+        })
+    );
+};
+
+export const openCollectionFilesContextMenu =
+    (event: React.MouseEvent<HTMLElement>, isWritable: boolean) => (dispatch: Dispatch, getState: () => RootState) => {
+        const selectedCount = filterCollectionFilesBySelection(getState().collectionPanelFiles, true).length;
+        const multiple = selectedCount > 1;
+        dispatch<any>(
+            openContextMenu(event, {
+                name: "",
+                uuid: "",
+                ownerUuid: "",
+                description: "",
+                kind: ResourceKind.COLLECTION,
+                menuKind:
+                    selectedCount > 0
+                        ? isWritable
+                            ? multiple
+                                ? ContextMenuKind.COLLECTION_FILES_MULTIPLE
+                                : ContextMenuKind.COLLECTION_FILES
+                            : multiple
+                            ? ContextMenuKind.READONLY_COLLECTION_FILES_MULTIPLE
+                            : ContextMenuKind.READONLY_COLLECTION_FILES
+                        : ContextMenuKind.COLLECTION_FILES_NOT_SELECTED,
             })
         );
     };
 
-export const openCollectionFilesContextMenu = (event: React.MouseEvent<HTMLElement>, isWritable: boolean) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
-        const isCollectionFileSelected = JSON.stringify(getState().collectionPanelFiles).includes('"selected":true');
-        dispatch<any>(openContextMenu(event, {
-            name: '',
-            uuid: '',
-            ownerUuid: '',
-            description: '',
-            kind: ResourceKind.COLLECTION,
-            menuKind: isCollectionFileSelected
-                ? isWritable
-                    ? ContextMenuKind.COLLECTION_FILES
-                    : ContextMenuKind.READONLY_COLLECTION_FILES
-                : ContextMenuKind.COLLECTION_FILES_NOT_SELECTED
-        }));
-    };
-
-export const openRepositoryContextMenu = (event: React.MouseEvent<HTMLElement>, repository: RepositoryResource) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
-        dispatch<any>(openContextMenu(event, {
-            name: '',
-            uuid: repository.uuid,
-            ownerUuid: repository.ownerUuid,
-            kind: ResourceKind.REPOSITORY,
-            menuKind: ContextMenuKind.REPOSITORY
-        }));
+export const openRepositoryContextMenu =
+    (event: React.MouseEvent<HTMLElement>, repository: RepositoryResource) => (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch<any>(
+            openContextMenu(event, {
+                name: "",
+                uuid: repository.uuid,
+                ownerUuid: repository.ownerUuid,
+                kind: ResourceKind.REPOSITORY,
+                menuKind: ContextMenuKind.REPOSITORY,
+            })
+        );
     };
 
-export const openVirtualMachinesContextMenu = (event: React.MouseEvent<HTMLElement>, repository: VirtualMachinesResource) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
-        dispatch<any>(openContextMenu(event, {
-            name: '',
-            uuid: repository.uuid,
-            ownerUuid: repository.ownerUuid,
-            kind: ResourceKind.VIRTUAL_MACHINE,
-            menuKind: ContextMenuKind.VIRTUAL_MACHINE
-        }));
+export const openVirtualMachinesContextMenu =
+    (event: React.MouseEvent<HTMLElement>, repository: VirtualMachinesResource) => (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch<any>(
+            openContextMenu(event, {
+                name: "",
+                uuid: repository.uuid,
+                ownerUuid: repository.ownerUuid,
+                kind: ResourceKind.VIRTUAL_MACHINE,
+                menuKind: ContextMenuKind.VIRTUAL_MACHINE,
+            })
+        );
     };
 
-export const openSshKeyContextMenu = (event: React.MouseEvent<HTMLElement>, sshKey: SshKeyResource) =>
-    (dispatch: Dispatch) => {
-        dispatch<any>(openContextMenu(event, {
-            name: '',
+export const openSshKeyContextMenu = (event: React.MouseEvent<HTMLElement>, sshKey: SshKeyResource) => (dispatch: Dispatch) => {
+    dispatch<any>(
+        openContextMenu(event, {
+            name: "",
             uuid: sshKey.uuid,
             ownerUuid: sshKey.ownerUuid,
             kind: ResourceKind.SSH_KEY,
-            menuKind: ContextMenuKind.SSH_KEY
-        }));
-    };
+            menuKind: ContextMenuKind.SSH_KEY,
+        })
+    );
+};
 
-export const openKeepServiceContextMenu = (event: React.MouseEvent<HTMLElement>, keepService: KeepServiceResource) =>
-    (dispatch: Dispatch) => {
-        dispatch<any>(openContextMenu(event, {
-            name: '',
+export const openKeepServiceContextMenu = (event: React.MouseEvent<HTMLElement>, keepService: KeepServiceResource) => (dispatch: Dispatch) => {
+    dispatch<any>(
+        openContextMenu(event, {
+            name: "",
             uuid: keepService.uuid,
             ownerUuid: keepService.ownerUuid,
             kind: ResourceKind.KEEP_SERVICE,
-            menuKind: ContextMenuKind.KEEP_SERVICE
-        }));
-    };
+            menuKind: ContextMenuKind.KEEP_SERVICE,
+        })
+    );
+};
 
-export const openApiClientAuthorizationContextMenu =
-    (event: React.MouseEvent<HTMLElement>, resourceUuid: string) =>
-        (dispatch: Dispatch) => {
-            dispatch<any>(openContextMenu(event, {
-                name: '',
-                uuid: resourceUuid,
-                ownerUuid: '',
-                kind: ResourceKind.API_CLIENT_AUTHORIZATION,
-                menuKind: ContextMenuKind.API_CLIENT_AUTHORIZATION
-            }));
-        };
+export const openApiClientAuthorizationContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => (dispatch: Dispatch) => {
+    dispatch<any>(
+        openContextMenu(event, {
+            name: "",
+            uuid: resourceUuid,
+            ownerUuid: "",
+            kind: ResourceKind.API_CLIENT_AUTHORIZATION,
+            menuKind: ContextMenuKind.API_CLIENT_AUTHORIZATION,
+        })
+    );
+};
 
-export const openRootProjectContextMenu = (event: React.MouseEvent<HTMLElement>, projectUuid: string) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
+export const openRootProjectContextMenu =
+    (event: React.MouseEvent<HTMLElement>, projectUuid: string) => (dispatch: Dispatch, getState: () => RootState) => {
         const res = getResource<UserResource>(projectUuid)(getState().resources);
         if (res) {
-            dispatch<any>(openContextMenu(event, {
-                name: '',
-                uuid: res.uuid,
-                ownerUuid: res.uuid,
-                kind: res.kind,
-                menuKind: ContextMenuKind.ROOT_PROJECT,
-                isTrashed: false
-            }));
+            dispatch<any>(
+                openContextMenu(event, {
+                    name: "",
+                    uuid: res.uuid,
+                    ownerUuid: res.uuid,
+                    kind: res.kind,
+                    menuKind: ContextMenuKind.ROOT_PROJECT,
+                    isTrashed: false,
+                })
+            );
         }
     };
 
-export const openProjectContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
+export const openProjectContextMenu =
+    (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => (dispatch: Dispatch, getState: () => RootState) => {
         const res = getResource<GroupContentsResource>(resourceUuid)(getState().resources);
         const menuKind = dispatch<any>(resourceUuidToContextMenuKind(resourceUuid));
         if (res && menuKind) {
-            dispatch<any>(openContextMenu(event, {
-                name: res.name,
-                uuid: res.uuid,
-                kind: res.kind,
-                menuKind,
-                description: res.description,
-                ownerUuid: res.ownerUuid,
-                isTrashed: ('isTrashed' in res) ? res.isTrashed : false,
-                isFrozen: !!(res as ProjectResource).frozenByUuid,
-            }));
+            dispatch<any>(
+                openContextMenu(event, {
+                    name: res.name,
+                    uuid: res.uuid,
+                    kind: res.kind,
+                    menuKind,
+                    description: res.description,
+                    ownerUuid: res.ownerUuid,
+                    isTrashed: "isTrashed" in res ? res.isTrashed : false,
+                    isFrozen: !!(res as ProjectResource).frozenByUuid,
+                })
+            );
         }
     };
 
-export const openSidePanelContextMenu = (event: React.MouseEvent<HTMLElement>, id: string) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
-        if (!isSidePanelTreeCategory(id)) {
-            const kind = extractUuidKind(id);
-            if (kind === ResourceKind.USER) {
-                dispatch<any>(openRootProjectContextMenu(event, id));
-            } else if (kind === ResourceKind.PROJECT) {
-                dispatch<any>(openProjectContextMenu(event, id));
-            }
+export const openSidePanelContextMenu = (event: React.MouseEvent<HTMLElement>, id: string) => (dispatch: Dispatch, getState: () => RootState) => {
+    if (!isSidePanelTreeCategory(id)) {
+        const kind = extractUuidKind(id);
+        if (kind === ResourceKind.USER) {
+            dispatch<any>(openRootProjectContextMenu(event, id));
+        } else if (kind === ResourceKind.PROJECT) {
+            dispatch<any>(openProjectContextMenu(event, id));
         }
-    };
+    }
+};
 
-export const openProcessContextMenu = (event: React.MouseEvent<HTMLElement>, process: Process) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
-        const res = getResource<ProcessResource>(process.containerRequest.uuid)(getState().resources);
-        if (res) {
-            dispatch<any>(openContextMenu(event, {
+export const openProcessContextMenu = (event: React.MouseEvent<HTMLElement>, process: Process) => (dispatch: Dispatch, getState: () => RootState) => {
+    const res = getResource<ProcessResource>(process.containerRequest.uuid)(getState().resources);
+    if (res) {
+        dispatch<any>(
+            openContextMenu(event, {
                 uuid: res.uuid,
                 ownerUuid: res.ownerUuid,
                 kind: ResourceKind.PROCESS,
                 name: res.name,
                 description: res.description,
-                outputUuid: res.outputUuid || '',
-                workflowUuid: res.properties.template_uuid || '',
-                menuKind: ContextMenuKind.PROCESS_RESOURCE
-            }));
-        }
-    };
+                outputUuid: res.outputUuid || "",
+                workflowUuid: res.properties.template_uuid || "",
+                menuKind: isProcessCancelable(process) ? ContextMenuKind.RUNNING_PROCESS_RESOURCE : ContextMenuKind.PROCESS_RESOURCE
+            })
+        );
+    }
+};
 
-export const openPermissionEditContextMenu = (event: React.MouseEvent<HTMLElement>, link: LinkResource) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
+export const openPermissionEditContextMenu =
+    (event: React.MouseEvent<HTMLElement>, link: LinkResource) => (dispatch: Dispatch, getState: () => RootState) => {
         if (link) {
-            dispatch<any>(openContextMenu(event, {
-                name: link.name,
-                uuid: link.uuid,
-                kind: link.kind,
-                menuKind: ContextMenuKind.PERMISSION_EDIT,
-                ownerUuid: link.ownerUuid,
-            }));
+            dispatch<any>(
+                openContextMenu(event, {
+                    name: link.name,
+                    uuid: link.uuid,
+                    kind: link.kind,
+                    menuKind: ContextMenuKind.PERMISSION_EDIT,
+                    ownerUuid: link.ownerUuid,
+                })
+            );
         }
     };
 
-export const openUserContextMenu = (event: React.MouseEvent<HTMLElement>, user: UserResource) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
-        dispatch<any>(openContextMenu(event, {
-            name: '',
+export const openUserContextMenu = (event: React.MouseEvent<HTMLElement>, user: UserResource) => (dispatch: Dispatch, getState: () => RootState) => {
+    dispatch<any>(
+        openContextMenu(event, {
+            name: "",
             uuid: user.uuid,
             ownerUuid: user.ownerUuid,
             kind: user.kind,
-            menuKind: ContextMenuKind.USER
-        }));
-    };
+            menuKind: ContextMenuKind.USER,
+        })
+    );
+};
 
-export const resourceUuidToContextMenuKind = (uuid: string, readonly = false) =>
+export const resourceUuidToContextMenuKind =
+    (uuid: string, readonly = false) =>
     (dispatch: Dispatch, getState: () => RootState) => {
         const { isAdmin: isAdminUser, uuid: userUuid } = getState().auth.user!;
         const kind = extractUuidKind(uuid);
         const resource = getResourceWithEditableStatus<GroupResource & EditableResource>(uuid, userUuid)(getState().resources);
         const isFrozen = resourceIsFrozen(resource, getState().resources);
-        const isEditable = (isAdminUser || (resource || {} as EditableResource).isEditable) && !readonly && !isFrozen;
+        const isEditable = (isAdminUser || (resource || ({} as EditableResource)).isEditable) && !readonly && !isFrozen;
 
         switch (kind) {
             case ResourceKind.PROJECT:
@@ -238,56 +263,64 @@ export const resourceUuidToContextMenuKind = (uuid: string, readonly = false) =>
                     return isAdminUser ? ContextMenuKind.FROZEN_PROJECT_ADMIN : ContextMenuKind.FROZEN_PROJECT;
                 }
 
-                return (isAdminUser && !readonly)
-                    ? (resource && resource.groupClass !== GroupClass.FILTER)
+                return isAdminUser && !readonly
+                    ? resource && resource.groupClass !== GroupClass.FILTER
                         ? ContextMenuKind.PROJECT_ADMIN
                         : ContextMenuKind.FILTER_GROUP_ADMIN
                     : isEditable
-                        ? (resource && resource.groupClass !== GroupClass.FILTER)
-                            ? ContextMenuKind.PROJECT
-                            : ContextMenuKind.FILTER_GROUP
-                        : ContextMenuKind.READONLY_PROJECT;
+                    ? resource && resource.groupClass !== GroupClass.FILTER
+                        ? ContextMenuKind.PROJECT
+                        : ContextMenuKind.FILTER_GROUP
+                    : ContextMenuKind.READONLY_PROJECT;
             case ResourceKind.COLLECTION:
                 const c = getResource<CollectionResource>(uuid)(getState().resources);
-                if (c === undefined) { return; }
+                if (c === undefined) {
+                    return;
+                }
                 const isOldVersion = c.uuid !== c.currentVersionUuid;
                 const isTrashed = c.isTrashed;
                 return isOldVersion
                     ? ContextMenuKind.OLD_VERSION_COLLECTION
-                    : (isTrashed && isEditable)
-                        ? ContextMenuKind.TRASHED_COLLECTION
-                        : (isAdminUser && isEditable)
-                            ? ContextMenuKind.COLLECTION_ADMIN
-                            : isEditable
-                                ? ContextMenuKind.COLLECTION
-                                : ContextMenuKind.READONLY_COLLECTION;
+                    : isTrashed && isEditable
+                    ? ContextMenuKind.TRASHED_COLLECTION
+                    : isAdminUser && isEditable
+                    ? ContextMenuKind.COLLECTION_ADMIN
+                    : isEditable
+                    ? ContextMenuKind.COLLECTION
+                    : ContextMenuKind.READONLY_COLLECTION;
             case ResourceKind.PROCESS:
-                return (isAdminUser && isEditable)
-                    ? ContextMenuKind.PROCESS_ADMIN
+                return isAdminUser && isEditable
+                    ? resource && isProcessCancelable(getProcess(resource.uuid)(getState().resources) as Process)
+                        ? ContextMenuKind.RUNNING_PROCESS_ADMIN
+                        : ContextMenuKind.PROCESS_ADMIN
                     : readonly
-                        ? ContextMenuKind.READONLY_PROCESS_RESOURCE
-                        : ContextMenuKind.PROCESS_RESOURCE;
+                    ? ContextMenuKind.READONLY_PROCESS_RESOURCE
+                    : resource && isProcessCancelable(getProcess(resource.uuid)(getState().resources) as Process)
+                    ? ContextMenuKind.RUNNING_PROCESS_RESOURCE
+                    : ContextMenuKind.PROCESS_RESOURCE;
             case ResourceKind.USER:
                 return ContextMenuKind.ROOT_PROJECT;
             case ResourceKind.LINK:
                 return ContextMenuKind.LINK;
             case ResourceKind.WORKFLOW:
-                return ContextMenuKind.WORKFLOW;
+                return isEditable ? ContextMenuKind.WORKFLOW : ContextMenuKind.READONLY_WORKFLOW;
             default:
                 return;
         }
     };
 
-export const openSearchResultsContextMenu = (event: React.MouseEvent<HTMLElement>, uuid: string) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
+export const openSearchResultsContextMenu =
+    (event: React.MouseEvent<HTMLElement>, uuid: string) => (dispatch: Dispatch, getState: () => RootState) => {
         const res = getResource<Resource>(uuid)(getState().resources);
         if (res) {
-            dispatch<any>(openContextMenu(event, {
-                name: '',
-                uuid: res.uuid,
-                ownerUuid: '',
-                kind: res.kind,
-                menuKind: ContextMenuKind.SEARCH_RESULTS,
-            }));
+            dispatch<any>(
+                openContextMenu(event, {
+                    name: "",
+                    uuid: res.uuid,
+                    ownerUuid: "",
+                    kind: res.kind,
+                    menuKind: ContextMenuKind.SEARCH_RESULTS,
+                })
+            );
         }
     };
index 4450cfc6bf5b0f5ae5a481b71a9a5f7eea3d8e7d..dfae5c2cf0101eb5bd0dad71504568f1168309e7 100644 (file)
@@ -6,4 +6,5 @@ export interface CopyFormDialogData {
     name: string;
     uuid: string;
     ownerUuid: string;
-}
\ No newline at end of file
+    fromContextMenu?: boolean;
+}
index 22b786fd186c69fdf3f962e3ea67fb471c1eab31..ea050e609f558a91decb73ac7badd65fe18f7d3f 100644 (file)
@@ -4,64 +4,51 @@
 
 import { unionize, ofType, UnionOf } from "common/unionize";
 import { DataColumns, DataTableFetchMode } from "components/data-table/data-table";
-import { DataTableFilters } from 'components/data-table-filters/data-table-filters-tree';
+import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree";
 
 export enum DataTableRequestState {
     IDLE,
     PENDING,
-    NEED_REFRESH
+    NEED_REFRESH,
 }
 
 export const dataExplorerActions = unionize({
     CLEAR: ofType<{ id: string }>(),
     RESET_PAGINATION: ofType<{ id: string }>(),
-    REQUEST_ITEMS: ofType<{ id: string, criteriaChanged?: boolean }>(),
-    REQUEST_STATE: ofType<{ id: string, criteriaChanged?: boolean }>(),
-    SET_FETCH_MODE: ofType<({ id: string, fetchMode: DataTableFetchMode })>(),
-    SET_COLUMNS: ofType<{ id: string, columns: DataColumns<any, any> }>(),
-    SET_FILTERS: ofType<{ id: string, columnName: string, filters: DataTableFilters }>(),
-    SET_ITEMS: ofType<{ id: string, items: any[], page: number, rowsPerPage: number, itemsAvailable: number }>(),
-    APPEND_ITEMS: ofType<{ id: string, items: any[], page: number, rowsPerPage: number, itemsAvailable: number }>(),
-    SET_PAGE: ofType<{ id: string, page: number }>(),
-    SET_ROWS_PER_PAGE: ofType<{ id: string, rowsPerPage: number }>(),
-    TOGGLE_COLUMN: ofType<{ id: string, columnName: string }>(),
-    TOGGLE_SORT: ofType<{ id: string, columnName: string }>(),
-    SET_EXPLORER_SEARCH_VALUE: ofType<{ id: string, searchValue: string }>(),
+    REQUEST_ITEMS: ofType<{ id: string; criteriaChanged?: boolean, background?: boolean }>(),
+    REQUEST_STATE: ofType<{ id: string; criteriaChanged?: boolean }>(),
+    SET_FETCH_MODE: ofType<{ id: string; fetchMode: DataTableFetchMode }>(),
+    SET_COLUMNS: ofType<{ id: string; columns: DataColumns<any, any> }>(),
+    SET_FILTERS: ofType<{ id: string; columnName: string; filters: DataTableFilters }>(),
+    SET_ITEMS: ofType<{ id: string; items: any[]; page: number; rowsPerPage: number; itemsAvailable: number }>(),
+    APPEND_ITEMS: ofType<{ id: string; items: any[]; page: number; rowsPerPage: number; itemsAvailable: number }>(),
+    SET_PAGE: ofType<{ id: string; page: number }>(),
+    SET_ROWS_PER_PAGE: ofType<{ id: string; rowsPerPage: number }>(),
+    TOGGLE_COLUMN: ofType<{ id: string; columnName: string }>(),
+    TOGGLE_SORT: ofType<{ id: string; columnName: string }>(),
+    SET_EXPLORER_SEARCH_VALUE: ofType<{ id: string; searchValue: string }>(),
     RESET_EXPLORER_SEARCH_VALUE: ofType<{ id: string }>(),
-    SET_REQUEST_STATE: ofType<{ id: string, requestState: DataTableRequestState }>(),
+    SET_REQUEST_STATE: ofType<{ id: string; requestState: DataTableRequestState }>(),
 });
 
 export type DataExplorerAction = UnionOf<typeof dataExplorerActions>;
 
 export const bindDataExplorerActions = (id: string) => ({
-    CLEAR: () =>
-        dataExplorerActions.CLEAR({ id }),
-    RESET_PAGINATION: () =>
-        dataExplorerActions.RESET_PAGINATION({ id }),
-    REQUEST_ITEMS: (criteriaChanged?: boolean) =>
-        dataExplorerActions.REQUEST_ITEMS({ id, criteriaChanged }),
-    SET_FETCH_MODE: (payload: { fetchMode: DataTableFetchMode }) =>
-        dataExplorerActions.SET_FETCH_MODE({ ...payload, id }),
-    SET_COLUMNS: (payload: { columns: DataColumns<any, any> }) =>
-        dataExplorerActions.SET_COLUMNS({ ...payload, id }),
-    SET_FILTERS: (payload: { columnName: string, filters: DataTableFilters }) =>
-        dataExplorerActions.SET_FILTERS({ ...payload, id }),
-    SET_ITEMS: (payload: { items: any[], page: number, rowsPerPage: number, itemsAvailable: number }) =>
+    CLEAR: () => dataExplorerActions.CLEAR({ id }),
+    RESET_PAGINATION: () => dataExplorerActions.RESET_PAGINATION({ id }),
+    REQUEST_ITEMS: (criteriaChanged?: boolean, background?: boolean) => dataExplorerActions.REQUEST_ITEMS({ id, criteriaChanged, background }),
+    SET_FETCH_MODE: (payload: { fetchMode: DataTableFetchMode }) => dataExplorerActions.SET_FETCH_MODE({ ...payload, id }),
+    SET_COLUMNS: (payload: { columns: DataColumns<any, any> }) => dataExplorerActions.SET_COLUMNS({ ...payload, id }),
+    SET_FILTERS: (payload: { columnName: string; filters: DataTableFilters }) => dataExplorerActions.SET_FILTERS({ ...payload, id }),
+    SET_ITEMS: (payload: { items: any[]; page: number; rowsPerPage: number; itemsAvailable: number }) =>
         dataExplorerActions.SET_ITEMS({ ...payload, id }),
-    APPEND_ITEMS: (payload: { items: any[], page: number, rowsPerPage: number, itemsAvailable: number }) =>
+    APPEND_ITEMS: (payload: { items: any[]; page: number; rowsPerPage: number; itemsAvailable: number }) =>
         dataExplorerActions.APPEND_ITEMS({ ...payload, id }),
-    SET_PAGE: (payload: { page: number }) =>
-        dataExplorerActions.SET_PAGE({ ...payload, id }),
-    SET_ROWS_PER_PAGE: (payload: { rowsPerPage: number }) =>
-        dataExplorerActions.SET_ROWS_PER_PAGE({ ...payload, id }),
-    TOGGLE_COLUMN: (payload: { columnName: string }) =>
-        dataExplorerActions.TOGGLE_COLUMN({ ...payload, id }),
-    TOGGLE_SORT: (payload: { columnName: string }) =>
-        dataExplorerActions.TOGGLE_SORT({ ...payload, id }),
-    SET_EXPLORER_SEARCH_VALUE: (payload: { searchValue: string }) =>
-        dataExplorerActions.SET_EXPLORER_SEARCH_VALUE({ ...payload, id }),
-    RESET_EXPLORER_SEARCH_VALUE: () =>
-        dataExplorerActions.RESET_EXPLORER_SEARCH_VALUE({ id }),
-    SET_REQUEST_STATE: (payload: { requestState: DataTableRequestState }) =>
-        dataExplorerActions.SET_REQUEST_STATE({ ...payload, id })
+    SET_PAGE: (payload: { page: number }) => dataExplorerActions.SET_PAGE({ ...payload, id }),
+    SET_ROWS_PER_PAGE: (payload: { rowsPerPage: number }) => dataExplorerActions.SET_ROWS_PER_PAGE({ ...payload, id }),
+    TOGGLE_COLUMN: (payload: { columnName: string }) => dataExplorerActions.TOGGLE_COLUMN({ ...payload, id }),
+    TOGGLE_SORT: (payload: { columnName: string }) => dataExplorerActions.TOGGLE_SORT({ ...payload, id }),
+    SET_EXPLORER_SEARCH_VALUE: (payload: { searchValue: string }) => dataExplorerActions.SET_EXPLORER_SEARCH_VALUE({ ...payload, id }),
+    RESET_EXPLORER_SEARCH_VALUE: () => dataExplorerActions.RESET_EXPLORER_SEARCH_VALUE({ id }),
+    SET_REQUEST_STATE: (payload: { requestState: DataTableRequestState }) => dataExplorerActions.SET_REQUEST_STATE({ ...payload, id }),
 });
index 01964fa48a9f260442205ab14e33a9f5dc9b09f2..6bb95a9a6c05b1e6d14bb3607b3dbfdded1b0e90 100644 (file)
@@ -33,7 +33,8 @@ export abstract class DataExplorerMiddlewareService {
 
     abstract requestItems(
         api: MiddlewareAPI<Dispatch, RootState>,
-        criteriaChanged?: boolean
+        criteriaChanged?: boolean,
+        background?: boolean
     ): Promise<void>;
 }
 
@@ -58,8 +59,10 @@ export const getOrder = <T extends Resource = Resource>(dataExplorer: DataExplor
             ? OrderDirection.ASC
             : OrderDirection.DESC;
 
+        // Use createdAt as a secondary sort column so we break ties consistently.
         return order
             .addOrder(sortDirection, sortColumn.sort.field)
+            .addOrder(OrderDirection.DESC, "createdAt")
             .getOrder();
     } else {
         return order.getOrder();
index f83b064641ee82f6d7922ba801f4c85fff5b29e4..3404b375a86b3e6a48c779a441a3c89043443754 100644 (file)
@@ -16,98 +16,98 @@ import { DataExplorerMiddlewareService } from './data-explorer-middleware-servic
 
 export const dataExplorerMiddleware =
     (service: DataExplorerMiddlewareService): Middleware =>
-    (api) =>
-    (next) => {
-        const actions = bindDataExplorerActions(service.getId());
+        (api) =>
+            (next) => {
+                const actions = bindDataExplorerActions(service.getId());
 
-        return (action) => {
-            const handleAction =
-                <T extends { id: string }>(handler: (data: T) => void) =>
-                    (data: T) => {
-                        next(action);
-                        if (data.id === service.getId()) {
-                            handler(data);
-                        }
-                    };
-            dataExplorerActions.match(action, {
-                SET_PAGE: handleAction(() => {
-                    api.dispatch(actions.REQUEST_ITEMS(false));
-                }),
-                SET_ROWS_PER_PAGE: handleAction(() => {
-                    api.dispatch(actions.REQUEST_ITEMS(true));
-                }),
-                SET_FILTERS: handleAction(() => {
-                    api.dispatch(actions.RESET_PAGINATION());
-                    api.dispatch(actions.REQUEST_ITEMS(true));
-                }),
-                TOGGLE_SORT: handleAction(() => {
-                    api.dispatch(actions.REQUEST_ITEMS(true));
-                }),
-                SET_EXPLORER_SEARCH_VALUE: handleAction(() => {
-                    api.dispatch(actions.RESET_PAGINATION());
-                    api.dispatch(actions.REQUEST_ITEMS(true));
-                }),
-                REQUEST_ITEMS: handleAction(({ criteriaChanged }) => {
-                    api.dispatch<any>(async (
-                        dispatch: Dispatch,
-                        getState: () => RootState,
-                        services: ServiceRepository
-                    ) => {
-                        while (true) {
-                            let de = getDataExplorer(
-                                getState().dataExplorer,
-                                service.getId()
-                            );
-                            switch (de.requestState) {
-                                case DataTableRequestState.IDLE:
-                                    // Start a new request.
-                                    try {
-                                        dispatch(
-                                            actions.SET_REQUEST_STATE({
-                                                requestState: DataTableRequestState.PENDING,
-                                            })
-                                        );
-                                        await service.requestItems(api, criteriaChanged);
-                                    } catch {
-                                        dispatch(
-                                            actions.SET_REQUEST_STATE({
-                                                requestState: DataTableRequestState.NEED_REFRESH,
-                                            })
-                                        );
-                                    }
-                                    // Now check if the state is still PENDING, if it moved to NEED_REFRESH
-                                    // then we need to reissue requestItems
-                                    de = getDataExplorer(
+                return (action) => {
+                    const handleAction =
+                        <T extends { id: string }>(handler: (data: T) => void) =>
+                            (data: T) => {
+                                next(action);
+                                if (data.id === service.getId()) {
+                                    handler(data);
+                                }
+                            };
+                    dataExplorerActions.match(action, {
+                        SET_PAGE: handleAction(() => {
+                            api.dispatch(actions.REQUEST_ITEMS(false));
+                        }),
+                        SET_ROWS_PER_PAGE: handleAction(() => {
+                            api.dispatch(actions.REQUEST_ITEMS(true));
+                        }),
+                        SET_FILTERS: handleAction(() => {
+                            api.dispatch(actions.RESET_PAGINATION());
+                            api.dispatch(actions.REQUEST_ITEMS(true));
+                        }),
+                        TOGGLE_SORT: handleAction(() => {
+                            api.dispatch(actions.REQUEST_ITEMS(true));
+                        }),
+                        SET_EXPLORER_SEARCH_VALUE: handleAction(() => {
+                            api.dispatch(actions.RESET_PAGINATION());
+                            api.dispatch(actions.REQUEST_ITEMS(true));
+                        }),
+                        REQUEST_ITEMS: handleAction(({ criteriaChanged, background }) => {
+                            api.dispatch<any>(async (
+                                dispatch: Dispatch,
+                                getState: () => RootState,
+                                services: ServiceRepository
+                            ) => {
+                                while (true) {
+                                    let de = getDataExplorer(
                                         getState().dataExplorer,
                                         service.getId()
                                     );
-                                    const complete =
-                                        de.requestState === DataTableRequestState.PENDING;
-                                    dispatch(
-                                        actions.SET_REQUEST_STATE({
-                                            requestState: DataTableRequestState.IDLE,
-                                        })
-                                    );
-                                    if (complete) {
-                                        return;
+                                    switch (de.requestState) {
+                                        case DataTableRequestState.IDLE:
+                                            // Start a new request.
+                                            try {
+                                                dispatch(
+                                                    actions.SET_REQUEST_STATE({
+                                                        requestState: DataTableRequestState.PENDING,
+                                                    })
+                                                );
+                                                await service.requestItems(api, criteriaChanged, background);
+                                            } catch {
+                                                dispatch(
+                                                    actions.SET_REQUEST_STATE({
+                                                        requestState: DataTableRequestState.NEED_REFRESH,
+                                                    })
+                                                );
+                                            }
+                                            // Now check if the state is still PENDING, if it moved to NEED_REFRESH
+                                            // then we need to reissue requestItems
+                                            de = getDataExplorer(
+                                                getState().dataExplorer,
+                                                service.getId()
+                                            );
+                                            const complete =
+                                                de.requestState === DataTableRequestState.PENDING;
+                                            dispatch(
+                                                actions.SET_REQUEST_STATE({
+                                                    requestState: DataTableRequestState.IDLE,
+                                                })
+                                            );
+                                            if (complete) {
+                                                return;
+                                            }
+                                            break;
+                                        case DataTableRequestState.PENDING:
+                                            // State is PENDING, move it to NEED_REFRESH so that when the current request finishes it starts a new one.
+                                            dispatch(
+                                                actions.SET_REQUEST_STATE({
+                                                    requestState: DataTableRequestState.NEED_REFRESH,
+                                                })
+                                            );
+                                            return;
+                                        case DataTableRequestState.NEED_REFRESH:
+                                            // Nothing to do right now.
+                                            return;
                                     }
-                                    break;
-                                case DataTableRequestState.PENDING:
-                                    // State is PENDING, move it to NEED_REFRESH so that when the current request finishes it starts a new one.
-                                    dispatch(
-                                        actions.SET_REQUEST_STATE({
-                                            requestState: DataTableRequestState.NEED_REFRESH,
-                                        })
-                                    );
-                                    return;
-                                case DataTableRequestState.NEED_REFRESH:
-                                    // Nothing to do right now.
-                                    return;
-                            }
-                        }
+                                }
+                            });
+                        }),
+                        default: () => next(action),
                     });
-                }),
-                default: () => next(action),
-            });
-        };
-    };
+                };
+            };
index e93d291d5dcaed367ca8c07d5e2c596fd4e06b01..a0a7eb6400b1160f0702d2e4243b94912c85bfa1 100644 (file)
@@ -70,14 +70,24 @@ export const dataExplorerReducer = (
         SET_FILTERS: ({ id, columnName, filters }) =>
             update(state, id, mapColumns(setFilters(columnName, filters))),
 
-        SET_ITEMS: ({ id, items, itemsAvailable, page, rowsPerPage }) =>
-            update(state, id, (explorer) => ({
-                ...explorer,
-                items,
-                itemsAvailable,
-                page: page || 0,
-                rowsPerPage,
-            })),
+        SET_ITEMS: ({ id, items, itemsAvailable, page, rowsPerPage }) => (
+            update(state, id, (explorer) => {
+                // Reject updates to pages other than current,
+                //  DataExplorer middleware should retry
+                const updatedPage = page || 0;
+                if (explorer.page === updatedPage) {
+                    return {
+                        ...explorer,
+                        items,
+                        itemsAvailable,
+                        page: updatedPage,
+                        rowsPerPage,
+                    }
+                } else {
+                    return explorer;
+                }
+            })
+        ),
 
         APPEND_ITEMS: ({ id, items, itemsAvailable, page, rowsPerPage }) =>
             update(state, id, (explorer) => ({
index 30368685064dfe6f9837d7c20d0cc4808b0cbf01..548d0a7897f99ba48a27c9826bf02bc18ad8d9ca 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { DialogAction, dialogActions } from "./dialog-actions";
+import { DialogAction, dialogActions } from './dialog-actions';
 
 export type DialogState = Record<string, Dialog<any>>;
 
@@ -12,16 +12,14 @@ export interface Dialog<T> {
 }
 
 export const dialogReducer = (state: DialogState = {}, action: DialogAction) =>
-
     dialogActions.match(action, {
         OPEN_DIALOG: ({ id, data }) => ({ ...state, [id]: { open: true, data } }),
         CLOSE_DIALOG: ({ id }) => ({
             ...state,
-            [id]: state[id] ? { ...state[id], open: false } : { open: false, data: {} }
+            [id]: state[id] ? { ...state[id], open: false } : { open: false, data: {} },
         }),
-        CLOSE_ALL_DIALOGS: () => ({ }),
+        CLOSE_ALL_DIALOGS: () => ({}),
         default: () => state,
     });
 
-export const getDialog = <T>(state: DialogState, id: string) =>
-    state[id] ? state[id] as Dialog<T> : undefined;
+export const getDialog = <T>(state: DialogState, id: string) => (state[id] ? (state[id] as Dialog<T>) : undefined);
index ea96ca0d7621b1959c38e3fb5bc25eaaded59a98..7a253860429b948779bb538552e0c8bac79aa61a 100644 (file)
@@ -18,7 +18,8 @@ export type WithDialogDispatchProps = {
 };
 
 export type WithDialogProps<T> = WithDialogStateProps<T> & WithDialogDispatchProps;
-export const withDialog = (id: string) =>
+export const withDialog =
+    (id: string) =>
     // TODO: How to make compiler happy with & P instead of & any?
     // eslint-disable-next-line
     <T, P>(component: React.ComponentType<WithDialogProps<T> & any>) =>
@@ -26,13 +27,17 @@ export const withDialog = (id: string) =>
 
 const emptyData = {};
 
-export const mapStateToProps = (id: string) => <T>(state: { dialog: DialogState }): WithDialogStateProps<T> => {
-    const dialog = state.dialog[id];
-    return dialog ? dialog : { open: false, data: emptyData };
-};
+export const mapStateToProps =
+    (id: string) =>
+    <T>(state: { dialog: DialogState }): WithDialogStateProps<T> => {
+        const dialog = state.dialog[id];
+        return dialog ? dialog : { open: false, data: emptyData };
+    };
 
-export const mapDispatchToProps = (id: string) => (dispatch: Dispatch): WithDialogDispatchProps => ({
-    closeDialog: () => {
-        dispatch(dialogActions.CLOSE_DIALOG({ id }));
-    }
-});
+export const mapDispatchToProps =
+    (id: string) =>
+    (dispatch: Dispatch): WithDialogDispatchProps => ({
+        closeDialog: () => {
+            dispatch(dialogActions.CLOSE_DIALOG({ id }));
+        },
+    });
index bd4d878ed59d95556033b36a1a144082268c6d97..da454ed77dbc9561116cf43c6de5fc25ae8edc95 100644 (file)
@@ -10,6 +10,9 @@ import { checkFavorite } from "./favorites-reducer";
 import { snackbarActions, SnackbarKind } from "../snackbar/snackbar-actions";
 import { ServiceRepository } from "services/services";
 import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { MultiSelectMenuActionNames } from "views-components/multiselect-toolbar/ms-menu-actions";
+import { addDisabledButton, removeDisabledButton } from "store/multiselect/multiselect-actions";
+import { loadFavoritesTree} from "store/side-panel-tree/side-panel-tree-actions";
 
 export const favoritesActions = unionize({
     TOGGLE_FAVORITE: ofType<{ resourceUuid: string }>(),
@@ -26,6 +29,7 @@ export const toggleFavorite = (resource: { uuid: string; name: string }) =>
             return Promise.reject("No user");
         }
         dispatch(progressIndicatorActions.START_WORKING("toggleFavorite"));
+        dispatch<any>(addDisabledButton(MultiSelectMenuActionNames.ADD_TO_FAVORITES))
         dispatch(favoritesActions.TOGGLE_FAVORITE({ resourceUuid: resource.uuid }));
         const isFavorite = checkFavorite(resource.uuid, getState().favorites);
         dispatch(snackbarActions.OPEN_SNACKBAR({
@@ -50,7 +54,9 @@ export const toggleFavorite = (resource: { uuid: string; name: string }) =>
                     hideDuration: 2000,
                     kind: SnackbarKind.SUCCESS
                 }));
+                dispatch<any>(removeDisabledButton(MultiSelectMenuActionNames.ADD_TO_FAVORITES))
                 dispatch(progressIndicatorActions.STOP_WORKING("toggleFavorite"));
+                dispatch<any>(loadFavoritesTree())
             })
             .catch((e: any) => {
                 dispatch(progressIndicatorActions.STOP_WORKING("toggleFavorite"));
index 3a58927aac7cd3da6a3c41d13aef2fa3161dc82a..507b4eb30fb2aaa1fdd5d38add18f7b56b03c32a 100644 (file)
@@ -13,6 +13,7 @@ import { updateResources } from 'store/resources/resources-actions';
 import { getCurrentGroupDetailsPanelUuid, GroupMembersPanelActions } from 'store/group-details-panel/group-details-panel-actions';
 import { LinkClass } from 'models/link';
 import { ResourceKind } from 'models/resource';
+import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
 
 export class GroupDetailsPanelMembersMiddlewareService extends DataExplorerMiddlewareService {
 
@@ -28,6 +29,7 @@ export class GroupDetailsPanelMembersMiddlewareService extends DataExplorerMiddl
             return;
         } else {
             try {
+                api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
                 const groupResource = await this.services.groupsService.get(groupUuid);
                 api.dispatch(updateResources([groupResource]));
 
@@ -65,6 +67,8 @@ export class GroupDetailsPanelMembersMiddlewareService extends DataExplorerMiddl
                 api.dispatch(updateResources(projectsIn.items));
             } catch (e) {
                 api.dispatch(couldNotFetchGroupDetailsContents());
+            } finally {
+                api.dispatch(progressIndicatorActions.STOP_WORKING(this.getId()));
             }
         }
     }
index 87bcba0cbc24cf182db6d1b72feb809c4d56474e..cc6ea8cf389ebdd09573630f419477920792997a 100644 (file)
@@ -12,6 +12,7 @@ import { updateResources } from 'store/resources/resources-actions';
 import { ListResults } from 'services/common-service/common-service';
 import { LinkResource } from 'models/link';
 import { linkPanelActions } from 'store/link-panel/link-panel-actions';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
 
 export class LinkMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
@@ -22,11 +23,14 @@ export class LinkMiddlewareService extends DataExplorerMiddlewareService {
         const state = api.getState();
         const dataExplorer = getDataExplorer(state.dataExplorer, this.getId());
         try {
+            api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
             const response = await this.services.linkService.list(getParams(dataExplorer));
             api.dispatch(updateResources(response.items));
             api.dispatch(setItems(response));
         } catch {
             api.dispatch(couldNotFetchLinks());
+        } finally {
+            api.dispatch(progressIndicatorActions.STOP_WORKING(this.getId()));
         }
     }
 }
index 6261a795d9b1d1563755b5dc32eb5e10269ebc9b..e58f3984766cbb0f60989c1fdfe1da14af361308 100644 (file)
@@ -6,4 +6,5 @@ export interface MoveToFormDialogData {
     name: string;
     uuid: string;
     ownerUuid: string;
-}
\ No newline at end of file
+    fromContextMenu?: boolean;
+}
diff --git a/src/store/multiselect/multiselect-actions.tsx b/src/store/multiselect/multiselect-actions.tsx
new file mode 100644 (file)
index 0000000..a246ddb
--- /dev/null
@@ -0,0 +1,105 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { TCheckedList } from "components/data-table/data-table";
+import { ContainerRequestResource } from "models/container-request";
+import { Dispatch } from "redux";
+import { navigateTo } from "store/navigation/navigation-action";
+import { snackbarActions } from "store/snackbar/snackbar-actions";
+import { RootState } from "store/store";
+import { ServiceRepository } from "services/services";
+import { SnackbarKind } from "store/snackbar/snackbar-actions";
+import { ContextMenuResource } from 'store/context-menu/context-menu-actions';
+
+export const multiselectActionContants = {
+    TOGGLE_VISIBLITY: "TOGGLE_VISIBLITY",
+    SET_CHECKEDLIST: "SET_CHECKEDLIST",
+    SELECT_ONE: 'SELECT_ONE',
+    DESELECT_ONE: "DESELECT_ONE",
+    TOGGLE_ONE: 'TOGGLE_ONE',
+    SET_SELECTED_UUID: 'SET_SELECTED_UUID',
+    ADD_DISABLED: 'ADD_DISABLED',
+    REMOVE_DISABLED: 'REMOVE_DISABLED',
+};
+
+export const msNavigateToOutput = (resource: ContextMenuResource | ContainerRequestResource) => async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+    try {
+        await services.collectionService.get(resource.outputUuid || '');
+        dispatch<any>(navigateTo(resource.outputUuid || ''));
+    } catch {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Output collection was trashed or deleted.", hideDuration: 4000, kind: SnackbarKind.WARNING }));
+    }
+};
+
+export const isExactlyOneSelected = (checkedList: TCheckedList) => {
+    let tally = 0;
+    let current = '';
+    for (const uuid in checkedList) {
+        if (checkedList[uuid] === true) {
+            tally++;
+            current = uuid;
+        }
+    }
+    return tally === 1 ? current : null
+};
+
+export const toggleMSToolbar = (isVisible: boolean) => {
+    return dispatch => {
+        dispatch({ type: multiselectActionContants.TOGGLE_VISIBLITY, payload: isVisible });
+    };
+};
+
+export const setCheckedListOnStore = (checkedList: TCheckedList) => {
+    return dispatch => {
+        dispatch(setSelectedUuid(isExactlyOneSelected(checkedList)))
+        dispatch({ type: multiselectActionContants.SET_CHECKEDLIST, payload: checkedList });
+    };
+};
+
+export const selectOne = (uuid: string) => {
+    return dispatch => {
+        dispatch({ type: multiselectActionContants.SELECT_ONE, payload: uuid });
+    };
+};
+
+export const deselectOne = (uuid: string) => {
+    return dispatch => {
+        dispatch({ type: multiselectActionContants.DESELECT_ONE, payload: uuid });
+    };
+};
+
+export const toggleOne = (uuid: string) => {
+    return dispatch => {
+        dispatch({ type: multiselectActionContants.TOGGLE_ONE, payload: uuid });
+    };
+};
+
+export const setSelectedUuid = (uuid: string | null) => {
+    return dispatch => {
+        dispatch({ type: multiselectActionContants.SET_SELECTED_UUID, payload: uuid });
+    };
+};
+
+export const addDisabledButton = (buttonName: string) => {
+    return dispatch => {
+        dispatch({ type: multiselectActionContants.ADD_DISABLED, payload: buttonName });
+    };
+};
+
+export const removeDisabledButton = (buttonName: string) => {
+    return dispatch => {
+        dispatch({ type: multiselectActionContants.REMOVE_DISABLED, payload: buttonName });
+    };
+};
+
+export const multiselectActions = {
+    toggleMSToolbar,
+    setCheckedListOnStore,
+    selectOne,
+    deselectOne,
+    toggleOne,
+    setSelectedUuid,
+    addDisabledButton,
+    removeDisabledButton,
+};
diff --git a/src/store/multiselect/multiselect-reducer.tsx b/src/store/multiselect/multiselect-reducer.tsx
new file mode 100644 (file)
index 0000000..26b8539
--- /dev/null
@@ -0,0 +1,45 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { multiselectActionContants } from "./multiselect-actions";
+import { TCheckedList } from "components/data-table/data-table";
+
+type MultiselectToolbarState = {
+    isVisible: boolean;
+    checkedList: TCheckedList;
+    selectedUuid: string;
+    disabledButtons: string[]
+};
+
+const multiselectToolbarInitialState = {
+    isVisible: false,
+    checkedList: {},
+    selectedUuid: '',
+    disabledButtons: []
+};
+
+const { TOGGLE_VISIBLITY, SET_CHECKEDLIST, SELECT_ONE, DESELECT_ONE, TOGGLE_ONE, SET_SELECTED_UUID, ADD_DISABLED, REMOVE_DISABLED } = multiselectActionContants;
+
+export const multiselectReducer = (state: MultiselectToolbarState = multiselectToolbarInitialState, action) => {
+    switch (action.type) {
+        case TOGGLE_VISIBLITY:
+            return { ...state, isVisible: action.payload };
+        case SET_CHECKEDLIST:
+            return { ...state, checkedList: action.payload };
+        case SELECT_ONE:
+            return { ...state, checkedList: { ...state.checkedList, [action.payload]: true } };
+        case DESELECT_ONE:
+            return { ...state, checkedList: { ...state.checkedList, [action.payload]: false } };
+        case TOGGLE_ONE:
+            return { ...state, checkedList: { ...state.checkedList, [action.payload]: !state.checkedList[action.payload] } };
+        case SET_SELECTED_UUID:
+            return {...state, selectedUuid: action.payload || ''}
+        case ADD_DISABLED:
+            return { ...state, disabledButtons: [...state.disabledButtons, action.payload]}
+        case REMOVE_DISABLED:
+            return { ...state, disabledButtons: state.disabledButtons.filter((button) => button !== action.payload) };
+        default:
+            return state;
+    }
+};
index 146530cae8e3ffaf530da19ca3be07743026c78f..55112fb0ae44ab153e89ce41058205d5fb0d5ca6 100644 (file)
@@ -2,86 +2,86 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Dispatch, compose, AnyAction } from 'redux';
+import { Dispatch, compose, AnyAction } from "redux";
 import { push } from "react-router-redux";
-import { ResourceKind, extractUuidKind } from 'models/resource';
-import { SidePanelTreeCategory } from '../side-panel-tree/side-panel-tree-actions';
-import { Routes, getGroupUrl, getNavUrl, getUserProfileUrl } from 'routes/routes';
-import { RootState } from 'store/store';
-import { openDetailsPanel } from 'store/details-panel/details-panel-action';
-import { ServiceRepository } from 'services/services';
-import { pluginConfig } from 'plugins';
-import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
-import { USERS_PANEL_LABEL, MY_ACCOUNT_PANEL_LABEL } from 'store/breadcrumbs/breadcrumbs-actions';
+import { ResourceKind, extractUuidKind } from "models/resource";
+import { SidePanelTreeCategory } from "../side-panel-tree/side-panel-tree-actions";
+import { Routes, getGroupUrl, getNavUrl, getUserProfileUrl } from "routes/routes";
+import { RootState } from "store/store";
+import { ServiceRepository } from "services/services";
+import { pluginConfig } from "plugins";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { USERS_PANEL_LABEL, MY_ACCOUNT_PANEL_LABEL } from "store/breadcrumbs/breadcrumbs-actions";
 
 export const navigationNotAvailable = (id: string) =>
     snackbarActions.OPEN_SNACKBAR({
         message: `${id} not available`,
         hideDuration: 3000,
-        kind: SnackbarKind.ERROR
+        kind: SnackbarKind.ERROR,
     });
 
-export const navigateTo = (uuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState) => {
-
-        for (const navToFn of pluginConfig.navigateToHandlers) {
-            if (navToFn(dispatch, getState, uuid)) {
-                return;
-            }
-        }
-
-        const kind = extractUuidKind(uuid);
-        switch (kind) {
-            case ResourceKind.PROJECT:
-            case ResourceKind.USER:
-            case ResourceKind.COLLECTION:
-            case ResourceKind.CONTAINER_REQUEST:
-                dispatch<any>(pushOrGoto(getNavUrl(uuid, getState().auth)));
-                return;
-            case ResourceKind.VIRTUAL_MACHINE:
-                dispatch<any>(navigateToAdminVirtualMachines);
-                return;
-            case ResourceKind.WORKFLOW:
-                dispatch<any>(openDetailsPanel(uuid));
-                return;
+export const navigateTo = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState) => {
+    for (const navToFn of pluginConfig.navigateToHandlers) {
+        if (navToFn(dispatch, getState, uuid)) {
+            return;
         }
+    }
 
-        switch (uuid) {
-            case SidePanelTreeCategory.PROJECTS:
-                const usr = getState().auth.user;
-                if (usr) {
-                    dispatch<any>(pushOrGoto(getNavUrl(usr.uuid, getState().auth)));
-                }
-                return;
-            case SidePanelTreeCategory.FAVORITES:
-                dispatch<any>(navigateToFavorites);
-                return;
-            case SidePanelTreeCategory.PUBLIC_FAVORITES:
-                dispatch(navigateToPublicFavorites);
-                return;
-            case SidePanelTreeCategory.SHARED_WITH_ME:
-                dispatch(navigateToSharedWithMe);
-                return;
-            case SidePanelTreeCategory.TRASH:
-                dispatch(navigateToTrash);
-                return;
-            case SidePanelTreeCategory.GROUPS:
-                dispatch(navigateToGroups);
-                return;
-            case SidePanelTreeCategory.ALL_PROCESSES:
-                dispatch(navigateToAllProcesses);
-                return;
-            case USERS_PANEL_LABEL:
-                dispatch(navigateToUsers);
-                return;
-            case MY_ACCOUNT_PANEL_LABEL:
-                dispatch(navigateToMyAccount);
-                return;
-        }
+    const kind = extractUuidKind(uuid);
+    switch (kind) {
+        case ResourceKind.PROJECT:
+        case ResourceKind.USER:
+        case ResourceKind.COLLECTION:
+        case ResourceKind.CONTAINER_REQUEST:
+            dispatch<any>(pushOrGoto(getNavUrl(uuid, getState().auth)));
+            return;
+        case ResourceKind.VIRTUAL_MACHINE:
+            dispatch<any>(navigateToAdminVirtualMachines);
+            return;
+        case ResourceKind.WORKFLOW:
+            dispatch<any>(pushOrGoto(getNavUrl(uuid, getState().auth)));
+            // dispatch<any>(openDetailsPanel(uuid));
+            return;
+    }
 
-        dispatch(navigationNotAvailable(uuid));
-    };
+    switch (uuid) {
+        case SidePanelTreeCategory.PROJECTS:
+            const usr = getState().auth.user;
+            if (usr) {
+                dispatch<any>(pushOrGoto(getNavUrl(usr.uuid, getState().auth)));
+            }
+            return;
+        case SidePanelTreeCategory.FAVORITES:
+            dispatch<any>(navigateToFavorites);
+            return;
+        case SidePanelTreeCategory.PUBLIC_FAVORITES:
+            dispatch(navigateToPublicFavorites);
+            return;
+        case SidePanelTreeCategory.SHARED_WITH_ME:
+            dispatch(navigateToSharedWithMe);
+            return;
+        case SidePanelTreeCategory.TRASH:
+            dispatch(navigateToTrash);
+            return;
+        case SidePanelTreeCategory.GROUPS:
+            dispatch(navigateToGroups);
+            return;
+        case SidePanelTreeCategory.ALL_PROCESSES:
+            dispatch(navigateToAllProcesses);
+            return;
+        case SidePanelTreeCategory.SHELL_ACCESS:
+            dispatch(navigateToUserVirtualMachines)
+            return;
+        case USERS_PANEL_LABEL:
+            dispatch(navigateToUsers);
+            return;
+        case MY_ACCOUNT_PANEL_LABEL:
+            dispatch(navigateToMyAccount);
+            return;
+    }
 
+    dispatch(navigationNotAvailable(uuid));
+};
 
 export const navigateToNotFound = push(Routes.NO_MATCH);
 
@@ -98,7 +98,7 @@ export const navigateToWorkflows = push(Routes.WORKFLOWS);
 export const pushOrGoto = (url: string): AnyAction => {
     if (url === "") {
         return { type: "noop" };
-    } else if (url[0] === '/') {
+    } else if (url[0] === "/") {
         return push(url);
     } else {
         window.location.href = url;
@@ -106,7 +106,6 @@ export const pushOrGoto = (url: string): AnyAction => {
     }
 };
 
-
 export const navigateToRootProject = (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
     navigateTo(SidePanelTreeCategory.PROJECTS)(dispatch, getState);
 };
@@ -117,7 +116,7 @@ export const navigateToRunProcess = push(Routes.RUN_PROCESS);
 
 export const navigateToSearchResults = (searchValue: string) => {
     if (searchValue !== "") {
-        return push({ pathname: Routes.SEARCH_RESULTS, search: '?q=' + encodeURIComponent(searchValue) });
+        return push({ pathname: Routes.SEARCH_RESULTS, search: "?q=" + encodeURIComponent(searchValue) });
     } else {
         return push({ pathname: Routes.SEARCH_RESULTS });
     }
index 6b9db6a538e1322004f14138b4e45b0bd8b53a73..83055e32fcbd3750e50b648c4166f6d618b469de 100644 (file)
@@ -2,28 +2,39 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import copy from 'copy-to-clipboard';
-import { Dispatch } from 'redux';
-import { getNavUrl } from 'routes/routes';
-import { RootState } from 'store/store';
+import copy from "copy-to-clipboard";
+import { Dispatch } from "redux";
+import { getNavUrl } from "routes/routes";
+import { RootState } from "store/store";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
 
 export const openInNewTabAction = (resource: any) => (dispatch: Dispatch, getState: () => RootState) => {
     const url = getNavUrl(resource.uuid, getState().auth);
 
-    if (url[0] === '/') {
-        window.open(`${window.location.origin}${url}`, '_blank');
+    if (url[0] === "/") {
+        window.open(`${window.location.origin}${url}`, "_blank");
     } else if (url.length) {
-        window.open(url, '_blank');
+        window.open(url, "_blank");
     }
 };
 
-export const copyToClipboardAction = (resource: any) => (dispatch: Dispatch, getState: () => RootState) => {
+export const copyToClipboardAction = (resources: Array<any>) => (dispatch: Dispatch, getState: () => RootState) => {
     // Copy to clipboard omits token to avoid accidental sharing
-    const url = getNavUrl(resource.uuid, getState().auth, false);
 
-    if (url[0] === '/') {
-        copy(`${window.location.origin}${url}`);
-    } else if (url.length) {
-        copy(url);
+    let url = getNavUrl(resources[0].uuid, getState().auth, false);
+    let wasCopied;
+
+    if (url[0] === "/") wasCopied = copy(`${window.location.origin}${url}`);
+    else if (url.length) {
+        wasCopied = copy(url);
     }
+
+    if (wasCopied)
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: "Copied",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
 };
index 16177f18a62edaba2db7ee12f75e921dd6ebe946..88b56a2c324379c2b9be44c859da7ad9e05eb28e 100644 (file)
@@ -3,28 +3,42 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { unionize, ofType, UnionOf } from "common/unionize";
-import { ProcessLogs, getProcessLogsPanelCurrentUuid } from './process-logs-panel';
+import { ProcessLogs } from './process-logs-panel';
 import { LogEventType } from 'models/log';
 import { RootState } from 'store/store';
 import { ServiceRepository } from 'services/services';
 import { Dispatch } from 'redux';
-import { groupBy, min, reverse } from 'lodash';
-import { LogResource } from 'models/log';
-import { LogService } from 'services/log-service/log-service';
-import { ResourceEventMessage } from 'websocket/resource-event-message';
-import { getProcess } from 'store/processes/process';
-import { FilterBuilder } from "services/api/filter-builder";
-import { OrderBuilder } from "services/api/order-builder";
+import { LogFragment, LogService, logFileToLogType } from 'services/log-service/log-service';
+import { Process, getProcess } from 'store/processes/process';
 import { navigateTo } from 'store/navigation/navigation-action';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { CollectionFile, CollectionFileType } from "models/collection-file";
+import { ContainerRequestResource, ContainerRequestState } from "models/container-request";
+
+const SNIPLINE = `================ ✀ ================ ✀ ========= Some log(s) were skipped ========= ✀ ================ ✀ ================`;
+const LOG_TIMESTAMP_PATTERN = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{9}Z/;
 
 export const processLogsPanelActions = unionize({
     RESET_PROCESS_LOGS_PANEL: ofType<{}>(),
     INIT_PROCESS_LOGS_PANEL: ofType<{ filters: string[], logs: ProcessLogs }>(),
     SET_PROCESS_LOGS_PANEL_FILTER: ofType<string>(),
-    ADD_PROCESS_LOGS_PANEL_ITEM: ofType<{ logType: string, log: string }>(),
+    ADD_PROCESS_LOGS_PANEL_ITEM: ofType<ProcessLogs>(),
 });
 
+// Max size of logs to fetch in bytes
+const maxLogFetchSize: number = 128 * 1000;
+
+type FileWithProgress = {
+    file: CollectionFile;
+    lastByte: number;
+}
+
+type SortableLine = {
+    logType: LogEventType,
+    timestamp: string;
+    contents: string;
+}
+
 export type ProcessLogsPanelAction = UnionOf<typeof processLogsPanelActions>;
 
 export const setProcessLogsPanelFilter = (filter: string) =>
@@ -32,112 +46,289 @@ export const setProcessLogsPanelFilter = (filter: string) =>
 
 export const initProcessLogsPanel = (processUuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, { logService }: ServiceRepository) => {
-        dispatch(processLogsPanelActions.RESET_PROCESS_LOGS_PANEL());
-        const process = getProcess(processUuid)(getState().resources);
-        const maxPageSize = getState().auth.config.clusterConfig.API.MaxItemsPerResponse;
-        if (process && process.container) {
-            const logResources = await loadContainerLogs(process.container.uuid, logService, maxPageSize);
-            const initialState = createInitialLogPanelState(logResources);
+        let process: Process | undefined;
+        try {
+            dispatch(processLogsPanelActions.RESET_PROCESS_LOGS_PANEL());
+            process = getProcess(processUuid)(getState().resources);
+            if (process?.containerRequest?.uuid) {
+                // Get log file size info
+                const logFiles = await loadContainerLogFileList(process.containerRequest, logService);
+
+                // Populate lastbyte 0 for each file
+                const filesWithProgress = logFiles.map((file) => ({ file, lastByte: 0 }));
+
+                // Fetch array of LogFragments
+                const logLines = await loadContainerLogFileContents(filesWithProgress, logService, process);
+
+                // Populate initial state with filters
+                const initialState = createInitialLogPanelState(logFiles, logLines);
+                dispatch(processLogsPanelActions.INIT_PROCESS_LOGS_PANEL(initialState));
+            }
+        } catch (e) {
+            // On error, populate empty state to allow polling to start
+            const initialState = createInitialLogPanelState([], []);
             dispatch(processLogsPanelActions.INIT_PROCESS_LOGS_PANEL(initialState));
+            // Only show toast on errors other than 404 since 404 is expected when logs do not exist yet
+            if (e.status !== 404) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Error loading process logs', hideDuration: 4000, kind: SnackbarKind.ERROR }));
+            }
+            if (e.status === 404 && process?.containerRequest.state === ContainerRequestState.FINAL) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Log collection was trashed or deleted.', hideDuration: 4000, kind: SnackbarKind.WARNING }));
+            }
         }
     };
 
-export const addProcessLogsPanelItem = (message: ResourceEventMessage<{ text: string }>) =>
+export const pollProcessLogs = (processUuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, { logService }: ServiceRepository) => {
-        if (PROCESS_PANEL_LOG_EVENT_TYPES.indexOf(message.eventType) > -1) {
-            const uuid = getProcessLogsPanelCurrentUuid(getState().router);
-            if (!uuid) { return }
-            const process = getProcess(uuid)(getState().resources);
-            if (!process) { return }
-            const { containerRequest, container } = process;
-            if (message.objectUuid === containerRequest.uuid
-                || (container && message.objectUuid === container.uuid)) {
-                dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM({
-                    logType: ALL_FILTER_TYPE,
-                    log: message.properties.text
-                }));
-                dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM({
-                    logType: message.eventType,
-                    log: message.properties.text
-                }));
-                if (MAIN_EVENT_TYPES.indexOf(message.eventType) > -1) {
-                    dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM({
-                        logType: MAIN_FILTER_TYPE,
-                        log: message.properties.text
-                    }));
+        try {
+            // Get log panel state and process from store
+            const currentState = getState().processLogsPanel;
+            const process = getProcess(processUuid)(getState().resources);
+
+            // Check if container request is present and initial logs state loaded
+            if (process?.containerRequest?.uuid && Object.keys(currentState.logs).length > 0) {
+                const logFiles = await loadContainerLogFileList(process.containerRequest, logService);
+
+                // Determine byte to fetch from while filtering unchanged files
+                const filesToUpdateWithProgress = logFiles.reduce((acc, updatedFile) => {
+                    // Fetch last byte or 0 for new log files
+                    const currentStateLogLastByte = currentState.logs[logFileToLogType(updatedFile)]?.lastByte || 0;
+
+                    const isNew = !Object.keys(currentState.logs).find((currentStateLogName) => (updatedFile.name.startsWith(currentStateLogName)));
+                    const isChanged = !isNew && currentStateLogLastByte < updatedFile.size;
+
+                    if (isNew || isChanged) {
+                        return acc.concat({ file: updatedFile, lastByte: currentStateLogLastByte });
+                    } else {
+                        return acc;
+                    }
+                }, [] as FileWithProgress[]);
+
+                // Perform range request(s) for each file
+                const logFragments = await loadContainerLogFileContents(filesToUpdateWithProgress, logService, process);
+
+                if (logFragments.length) {
+                    // Convert LogFragments to ProcessLogs with All/Main sorting & line-merging
+                    const groupedLogs = groupLogs(logFiles, logFragments);
+                    await dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM(groupedLogs));
                 }
             }
+            return Promise.resolve();
+        } catch (e) {
+            // Remove log when polling error is handled in some way instead of being ignored
+            console.error("Error occurred in pollProcessLogs:", e);
+            return Promise.reject();
         }
     };
 
-const loadContainerLogs = async (containerUuid: string, logService: LogService, maxPageSize: number) => {
-    const requestFilters = new FilterBuilder()
-        .addEqual('object_uuid', containerUuid)
-        .addIn('event_type', PROCESS_PANEL_LOG_EVENT_TYPES)
-        .getFilters();
-    const requestOrderAsc = new OrderBuilder<LogResource>()
-        .addAsc('eventAt')
-        .getOrder();
-    const requestOrderDesc = new OrderBuilder<LogResource>()
-        .addDesc('eventAt')
-        .getOrder();
-    const { items, itemsAvailable } = await logService.list({
-        limit: maxPageSize,
-        filters: requestFilters,
-        order: requestOrderAsc,
-    });
-
-    // Request additional logs if necessary
-    const remainingLogs = itemsAvailable - items.length;
-    if (remainingLogs > 0) {
-        const { items: itemsLast } = await logService.list({
-            limit: min([maxPageSize, remainingLogs]),
-            filters: requestFilters,
-            order: requestOrderDesc,
-            count: 'none',
-        })
-        if (remainingLogs - itemsLast.length > 0) {
-            const snipLine = {
-                ...items[items.length - 1],
-                eventType: LogEventType.SNIP,
-                properties: {
-                    text: `================ 8< ================ 8< ========= Some log(s) were skipped ========= 8< ================ 8< ================`
-                },
-            }
-            return [...items, snipLine, ...reverse(itemsLast)];
+const loadContainerLogFileList = async (containerRequest: ContainerRequestResource, logService: LogService) => {
+    const logCollectionContents = await logService.listLogFiles(containerRequest);
+
+    // Filter only root directory files matching log event types which have bytes
+    return logCollectionContents.filter((file): file is CollectionFile => (
+        file.type === CollectionFileType.FILE &&
+        PROCESS_PANEL_LOG_EVENT_TYPES.indexOf(logFileToLogType(file)) > -1 &&
+        file.size > 0
+    ));
+};
+
+/**
+ * Loads the contents of each file from each file's lastByte simultaneously
+ *   while respecting the maxLogFetchSize by requesting the start and end
+ *   of the desired block and inserting a snipline.
+ * @param logFilesWithProgress CollectionFiles with the last byte previously loaded
+ * @param logService
+ * @param process
+ * @returns LogFragment[] containing a single LogFragment corresponding to each input file
+ */
+const loadContainerLogFileContents = async (logFilesWithProgress: FileWithProgress[], logService: LogService, process: Process) => (
+    (await Promise.allSettled(logFilesWithProgress.filter(({ file }) => file.size > 0).map(({ file, lastByte }) => {
+        const requestSize = file.size - lastByte;
+        if (requestSize > maxLogFetchSize) {
+            const chunkSize = Math.floor(maxLogFetchSize / 2);
+            const firstChunkEnd = lastByte + chunkSize - 1;
+            return Promise.all([
+                logService.getLogFileContents(process.containerRequest, file, lastByte, firstChunkEnd),
+                logService.getLogFileContents(process.containerRequest, file, file.size - chunkSize, file.size - 1)
+            ] as Promise<(LogFragment)>[]);
+        } else {
+            return Promise.all([logService.getLogFileContents(process.containerRequest, file, lastByte, file.size - 1)]);
         }
-        return [...items, ...reverse(itemsLast)];
+    })).then((res) => {
+        if (res.length && res.every(promiseResult => (promiseResult.status === 'rejected'))) {
+            // Since allSettled does not pass promise rejection we throw an
+            //   error if every request failed
+            const error = res.find(
+                (promiseResult): promiseResult is PromiseRejectedResult => promiseResult.status === 'rejected'
+            )?.reason;
+            return Promise.reject(error);
+        }
+        return res.filter((promiseResult): promiseResult is PromiseFulfilledResult<LogFragment[]> => (
+            // Filter out log files with rejected promises
+            //   (Promise.all rejects on any failure)
+            promiseResult.status === 'fulfilled' &&
+            // Filter out files where any fragment is empty
+            //   (prevent incorrect snipline generation or an un-resumable situation)
+            !!promiseResult.value.every(logFragment => logFragment.contents.length)
+        )).map(one => one.value)
+    })).map((logResponseSet) => {
+        // For any multi fragment response set, modify the last line of non-final chunks to include a line break and snip line
+        //   Don't add snip line as a separate line so that sorting won't reorder it
+        for (let i = 1; i < logResponseSet.length; i++) {
+            const fragment = logResponseSet[i - 1];
+            const lastLineIndex = fragment.contents.length - 1;
+            const lastLineContents = fragment.contents[lastLineIndex];
+            const newLastLine = `${lastLineContents}\n${SNIPLINE}`;
+
+            logResponseSet[i - 1].contents[lastLineIndex] = newLastLine;
+        }
+
+        // Merge LogFragment Array (representing multiple log line arrays) into single LogLine[] / LogFragment
+        return logResponseSet.reduce((acc, curr: LogFragment) => ({
+            logType: curr.logType,
+            contents: [...(acc.contents || []), ...curr.contents]
+        }), {} as LogFragment);
+    })
+);
+
+const createInitialLogPanelState = (logFiles: CollectionFile[], logFragments: LogFragment[]): { filters: string[], logs: ProcessLogs } => {
+    const logs = groupLogs(logFiles, logFragments);
+    const filters = Object.keys(logs);
+    return { filters, logs };
+}
+
+/**
+ * Converts LogFragments into ProcessLogs, grouping and sorting All/Main logs
+ * @param logFiles
+ * @param logFragments
+ * @returns ProcessLogs for the store
+ */
+const groupLogs = (logFiles: CollectionFile[], logFragments: LogFragment[]): ProcessLogs => {
+    const sortableLogFragments = mergeMultilineLoglines(logFragments);
+
+    const allLogs = mergeSortLogFragments(sortableLogFragments);
+    const mainLogs = mergeSortLogFragments(sortableLogFragments.filter((fragment) => (MAIN_EVENT_TYPES.includes(fragment.logType))));
+
+    const groupedLogs = logFragments.reduce((grouped, fragment) => ({
+        ...grouped,
+        [fragment.logType as string]: { lastByte: fetchLastByteNumber(logFiles, fragment.logType), contents: fragment.contents }
+    }), {});
+
+    return {
+        [MAIN_FILTER_TYPE]: { lastByte: undefined, contents: mainLogs },
+        [ALL_FILTER_TYPE]: { lastByte: undefined, contents: allLogs },
+        ...groupedLogs,
     }
-    return items;
 };
 
-const createInitialLogPanelState = (logResources: LogResource[]) => {
-    const allLogs = logsToLines(logResources);
-    const mainLogs = logsToLines(logResources.filter(
-        e => MAIN_EVENT_TYPES.indexOf(e.eventType) > -1
-    ));
-    const groupedLogResources = groupBy(logResources, log => log.eventType);
-    const groupedLogs = Object
-        .keys(groupedLogResources)
-        .reduce((grouped, key) => ({
-            ...grouped,
-            [key]: logsToLines(groupedLogResources[key])
-        }), {});
-    const filters = [
-        MAIN_FILTER_TYPE,
-        ALL_FILTER_TYPE,
-        ...Object.keys(groupedLogs)
-    ].filter(e => e !== LogEventType.SNIP);
-    const logs = {
-        [MAIN_FILTER_TYPE]: mainLogs,
-        [ALL_FILTER_TYPE]: allLogs,
-        ...groupedLogs
-    };
-    return { filters, logs };
+/**
+ * Checks for non-timestamped log lines and merges them with the previous line, assumes they are multi-line logs
+ *   If there is no previous line (first line has no timestamp), the line is deleted.
+ *   Only used for combined logs that need sorting by timestamp after merging
+ * @param logFragments
+ * @returns Modified LogFragment[]
+ */
+const mergeMultilineLoglines = (logFragments: LogFragment[]) => (
+    logFragments.map((fragment) => {
+        // Avoid altering the original fragment copy
+        let fragmentCopy: LogFragment = {
+            logType: fragment.logType,
+            contents: [...fragment.contents],
+        }
+        // Merge any non-timestamped lines in sortable log types with previous line
+        if (fragmentCopy.contents.length && !NON_SORTED_LOG_TYPES.includes(fragmentCopy.logType)) {
+            for (let i = 0; i < fragmentCopy.contents.length; i++) {
+                const lineContents = fragmentCopy.contents[i];
+                if (!lineContents.match(LOG_TIMESTAMP_PATTERN)) {
+                    // Partial line without timestamp detected
+                    if (i > 0) {
+                        // If not first line, copy line to previous line
+                        const previousLineContents = fragmentCopy.contents[i - 1];
+                        const newPreviousLineContents = `${previousLineContents}\n${lineContents}`;
+                        fragmentCopy.contents[i - 1] = newPreviousLineContents;
+                    }
+                    // Delete the current line and prevent iterating
+                    fragmentCopy.contents.splice(i, 1);
+                    i--;
+                }
+            }
+        }
+        return fragmentCopy;
+    })
+);
+
+/**
+ * Merges log lines of different types and sorts types that contain timestamps (are sortable)
+ * @param logFragments
+ * @returns string[] of merged and sorted log lines
+ */
+const mergeSortLogFragments = (logFragments: LogFragment[]): string[] => {
+    const sortableFragments = logFragments
+        .filter((fragment) => (!NON_SORTED_LOG_TYPES.includes(fragment.logType)));
+
+    const nonSortableLines = fragmentsToLines(logFragments
+        .filter((fragment) => (NON_SORTED_LOG_TYPES.includes(fragment.logType)))
+        .sort((a, b) => (a.logType.localeCompare(b.logType))));
+
+    return [...nonSortableLines, ...sortLogFragments(sortableFragments)];
+};
+
+/**
+ * Performs merge and sort of input log fragment lines
+ * @param logFragments set of sortable log fragments to be merged and sorted
+ * @returns A string array containing all lines, sorted by timestamp and
+ *          preserving line ordering and type grouping when timestamps match
+ */
+const sortLogFragments = (logFragments: LogFragment[]): string[] => {
+    const linesWithType: SortableLine[] = logFragments
+        // Map each logFragment into an array of SortableLine
+        .map((fragment: LogFragment): SortableLine[] => (
+            fragment.contents.map((singleLine: string) => {
+                const timestampMatch = singleLine.match(LOG_TIMESTAMP_PATTERN);
+                const timestamp = timestampMatch && timestampMatch[0] ? timestampMatch[0] : "";
+                return {
+                    logType: fragment.logType,
+                    timestamp: timestamp,
+                    contents: singleLine,
+                };
+            })
+        // Merge each array of SortableLine into single array
+        )).reduce((acc: SortableLine[], lines: SortableLine[]) => (
+            [...acc, ...lines]
+        ), [] as SortableLine[]);
+
+    return linesWithType
+        .sort(sortableLineSortFunc)
+        .map(lineWithType => lineWithType.contents);
+};
+
+/**
+ * Sort func to sort lines
+ *   Preserves original ordering of lines from the same source
+ *   Stably orders lines of differing type but same timestamp
+ *     (produces a block of same-timestamped lines of one type before a block
+ *     of same timestamped lines of another type for readability)
+ *   Sorts all other lines by contents (ie by timestamp)
+ */
+const sortableLineSortFunc = (a: SortableLine, b: SortableLine) => {
+    if (a.logType === b.logType) {
+        return 0;
+    } else if (a.timestamp === b.timestamp) {
+        return a.logType.localeCompare(b.logType);
+    } else {
+        return a.contents.localeCompare(b.contents);
+    }
 };
 
-const logsToLines = (logs: LogResource[]) =>
-    logs.map(({ properties }) => properties.text);
+const fragmentsToLines = (fragments: LogFragment[]): string[] => (
+    fragments.reduce((acc, fragment: LogFragment) => (
+        acc.concat(...fragment.contents)
+    ), [] as string[])
+);
+
+const fetchLastByteNumber = (logFiles: CollectionFile[], key: string) => {
+    return logFiles.find((file) => (file.name.startsWith(key)))?.size
+};
 
 export const navigateToLogCollection = (uuid: string) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
@@ -145,7 +336,7 @@ export const navigateToLogCollection = (uuid: string) =>
             await services.collectionService.get(uuid);
             dispatch<any>(navigateTo(uuid));
         } catch {
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not request collection', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Log collection was trashed or deleted.', hideDuration: 4000, kind: SnackbarKind.WARNING }));
         }
     };
 
@@ -156,7 +347,6 @@ const MAIN_EVENT_TYPES = [
     LogEventType.CRUNCH_RUN,
     LogEventType.STDERR,
     LogEventType.STDOUT,
-    LogEventType.SNIP,
 ];
 
 const PROCESS_PANEL_LOG_EVENT_TYPES = [
@@ -170,5 +360,9 @@ const PROCESS_PANEL_LOG_EVENT_TYPES = [
     LogEventType.STDOUT,
     LogEventType.CONTAINER,
     LogEventType.KEEPSTORE,
-    LogEventType.SNIP,
+];
+
+const NON_SORTED_LOG_TYPES = [
+    LogEventType.NODE_INFO,
+    LogEventType.CONTAINER,
 ];
index c502f1b1ff47f18ee97873917e513f3c3daabc6e..e7dd35623c0979225118570d88d5368167a450db 100644 (file)
@@ -2,13 +2,13 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ProcessLogsPanel } from './process-logs-panel';
+import { ProcessLogs, ProcessLogsPanel } from './process-logs-panel';
 import { ProcessLogsPanelAction, processLogsPanelActions } from './process-logs-panel-actions';
 
 const initialState: ProcessLogsPanel = {
     filters: [],
     selectedFilter: '',
-    logs: { '': [] },
+    logs: {},
 };
 
 export const processLogsPanelReducer = (state = initialState, action: ProcessLogsPanelAction): ProcessLogsPanel =>
@@ -23,13 +23,24 @@ export const processLogsPanelReducer = (state = initialState, action: ProcessLog
             ...state,
             selectedFilter
         }),
-        ADD_PROCESS_LOGS_PANEL_ITEM: ({ logType, log }) => {
-            const filters = state.filters.indexOf(logType) > -1
-                ? state.filters
-                : [...state.filters, logType];
-            const currentLogs = state.logs[logType] || [];
-            const logsOfType = [...currentLogs, log];
-            const logs = { ...state.logs, [logType]: logsOfType };
+        ADD_PROCESS_LOGS_PANEL_ITEM: (groupedLogs: ProcessLogs) => {
+            // Update filters
+            const newFilters = Object.keys(groupedLogs).filter((logType) => (!state.filters.includes(logType)));
+            const filters = [...state.filters, ...newFilters];
+
+            // Append new log lines
+            const logs = Object.keys(groupedLogs).reduce((acc, logType) => {
+                if (Object.keys(acc).includes(logType)) {
+                    // If log type exists, append lines and update lastByte
+                    return {...acc, [logType]: {
+                        lastByte: groupedLogs[logType].lastByte,
+                        contents: [...acc[logType].contents, ...groupedLogs[logType].contents]
+                    }};
+                } else {
+                    return {...acc, [logType]: groupedLogs[logType]};
+                }
+            }, state.logs);
+
             return { ...state, logs, filters };
         },
         default: () => state,
index 0ca5d679c9caa14b0e2c2fe8847d56d57f61de29..531d3723c63fb93e97eb3b7e81a9aaf5bfdae186 100644 (file)
@@ -12,11 +12,11 @@ export interface ProcessLogsPanel {
 }
 
 export interface ProcessLogs {
-    [logType: string]: string[];
+    [logType: string]: {lastByte: number | undefined, contents: string[]};
 }
 
-export const getProcessPanelLogs = ({ selectedFilter, logs }: ProcessLogsPanel) => {
-    return logs[selectedFilter];
+export const getProcessPanelLogs = ({ selectedFilter, logs }: ProcessLogsPanel): string[] => {
+    return logs[selectedFilter]?.contents || [];
 };
 
 export const getProcessLogsPanelCurrentUuid = (router: RouterState) => {
index b361f7acae0cbd8478fc7ed4896be92467863601..2111afdb2fc89d05eaba56ad46add0c43beccf19 100644 (file)
@@ -3,24 +3,24 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { unionize, ofType, UnionOf } from "common/unionize";
-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';
+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";
 import { ServiceRepository } from "services/services";
-import { navigateTo, navigateToWorkflows } from 'store/navigation/navigation-action';
-import { snackbarActions } from 'store/snackbar/snackbar-actions';
-import { SnackbarKind } from '../snackbar/snackbar-actions';
-import { showWorkflowDetails } from 'store/workflow-panel/workflow-panel-actions';
-import { loadSubprocessPanel } from "../subprocess-panel/subprocess-panel-actions";
+import { navigateTo } from "store/navigation/navigation-action";
+import { snackbarActions } from "store/snackbar/snackbar-actions";
+import { SnackbarKind } from "../snackbar/snackbar-actions";
+import { loadSubprocessPanel, subprocessPanelActions } 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, WorkflowInputsData } from 'models/workflow';
+import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter";
+import { CommandInputParameter, getIOParamId, WorkflowInputsData } from "models/workflow";
 import { getIOParamDisplayValue, ProcessIOParameter } from "views/process-panel/process-io-card";
 import { OutputDetails, NodeInstanceType, NodeInfo } from "./process-panel";
 import { AuthState } from "store/auth/auth-reducer";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
 
 export const processPanelActions = unionize({
     RESET_PROCESS_PANEL: ofType<{}>(),
@@ -39,40 +39,44 @@ export type ProcessPanelAction = UnionOf<typeof processPanelActions>;
 
 export const toggleProcessPanelFilter = processPanelActions.TOGGLE_PROCESS_PANEL_FILTER;
 
-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));
-        dispatch(initProcessPanelFilters);
-        dispatch<any>(initProcessLogsPanel(uuid));
-        dispatch<any>(loadSubprocessPanel());
-    };
+export const loadProcessPanel = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState) => {
+    // Reset subprocess data explorer if navigating to new process
+    //  Avoids resetting pagination when refreshing same process
+    if (getState().processPanel.containerRequestUuid !== uuid) {
+        dispatch(subprocessPanelActions.CLEAR());
+    }
+    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));
+    dispatch(initProcessPanelFilters);
+    dispatch<any>(initProcessLogsPanel(uuid));
+    dispatch<any>(loadSubprocessPanel());
+};
 
-export const navigateToOutput = (uuid: string) =>
-    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        try {
-            await services.collectionService.get(uuid);
-            dispatch<any>(navigateTo(uuid));
-        } catch {
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'This collection does not exists!', hideDuration: 2000, kind: SnackbarKind.ERROR }));
-        }
-    };
+export const navigateToOutput = (resource: ContextMenuResource | ContainerRequestResource) => async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+    try {
+        await services.collectionService.get(resource.outputUuid || '');
+        dispatch<any>(navigateTo(resource.outputUuid || ''));
+    } catch {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Output collection was trashed or deleted.", hideDuration: 4000, kind: SnackbarKind.WARNING }));
+    }
+};
 
-export const loadInputs = (containerRequest: ContainerRequestResource) =>
-    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+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) => {
+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));
+            dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW({ uuid: containerRequest.uuid, outputRaw: noOutputs }));
             return;
-        };
+        }
         try {
             const propsOutputs = getRawOutputs(containerRequest);
             const filesPromise = services.collectionService.files(containerRequest.outputUuid);
@@ -81,48 +85,53 @@ export const loadOutputs = (containerRequest: ContainerRequestResource) =>
 
             // If has propsOutput, skip fetching cwl.output.json
             if (propsOutputs !== undefined) {
-                dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW({
-                    rawOutputs: propsOutputs,
-                    pdh: collection.portableDataHash
-                }));
+                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;
+                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,
-                    }));
+                    dispatch<ProcessPanelAction>(
+                        processPanelActions.SET_OUTPUT_RAW({
+                            uuid: containerRequest.uuid,
+                            outputRaw: { rawOutputs: outputData, pdh: collection.portableDataHash },
+                        })
+                    );
                 } else {
-                    dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW(noOutputs));
+                    dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW({ uuid: containerRequest.uuid, outputRaw: noOutputs }));
                 }
             }
         } catch {
-            dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW(noOutputs));
+            dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW({ uuid: containerRequest.uuid, outputRaw: noOutputs }));
         }
     };
 
-
-export const loadNodeJson = (containerRequest: ContainerRequestResource) =>
-    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+export const loadNodeJson =
+    (containerRequest: ContainerRequestResource) => async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         const noLog = { nodeInfo: null };
         if (!containerRequest.logUuid) {
             dispatch<ProcessPanelAction>(processPanelActions.SET_NODE_INFO(noLog));
             return;
-        };
+        }
         try {
             const filesPromise = services.collectionService.files(containerRequest.logUuid);
             const collectionPromise = services.collectionService.get(containerRequest.logUuid);
             const [files] = await Promise.all([filesPromise, collectionPromise]);
 
             // Fetch node.json from keep
-            const nodeFile = files.find((file) => file.name === 'node.json') as CollectionFile | undefined;
+            const nodeFile = files.find(file => file.name === "node.json") as CollectionFile | undefined;
             let nodeData = nodeFile ? await services.collectionService.getFileContents(nodeFile) : undefined;
             if (nodeData && (nodeData = JSON.parse(nodeData))) {
-                dispatch<ProcessPanelAction>(processPanelActions.SET_NODE_INFO({
-                    nodeInfo: nodeData as NodeInstanceType
-                }));
+                dispatch<ProcessPanelAction>(
+                    processPanelActions.SET_NODE_INFO({
+                        nodeInfo: nodeData as NodeInstanceType,
+                    })
+                );
             } else {
                 dispatch<ProcessPanelAction>(processPanelActions.SET_NODE_INFO(noLog));
             }
@@ -131,28 +140,27 @@ export const loadNodeJson = (containerRequest: ContainerRequestResource) =>
         }
     };
 
-export const loadOutputDefinitions = (containerRequest: ContainerRequestResource) =>
-    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+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;
+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)));
-        }
-    };
+    if (outputRaw && 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);
-        dispatch<any>(showWorkflowDetails(uuid));
-    };
+export const openWorkflow = (uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch<any>(navigateTo(uuid));
+};
 
 export const initProcessPanelFilters = processPanelActions.SET_PROCESS_PANEL_FILTERS([
     ProcessStatus.QUEUED,
@@ -162,25 +170,30 @@ export const initProcessPanelFilters = processPanelActions.SET_PROCESS_PANEL_FIL
     ProcessStatus.ONHOLD,
     ProcessStatus.FAILING,
     ProcessStatus.WARNING,
-    ProcessStatus.CANCELLED
+    ProcessStatus.CANCELLED,
 ]);
 
-const formatInputData = (inputs: CommandInputParameter[], auth: AuthState): ProcessIOParameter[] => {
+export const formatInputData = (inputs: CommandInputParameter[], auth: AuthState): ProcessIOParameter[] => {
     return inputs.map(input => {
         return {
             id: getIOParamId(input),
             label: input.label || "",
-            value: getIOParamDisplayValue(auth, input)
+            value: getIOParamDisplayValue(auth, input),
         };
     });
 };
 
-const formatOutputData = (definitions: CommandOutputParameter[], values: any, pdh: string | undefined, auth: AuthState): ProcessIOParameter[] => {
+export 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)
+            value: getIOParamDisplayValue(auth, Object.assign(output, { value: values[getIOParamId(output)] || [] }), pdh),
         };
     });
 };
index 8e190ead37aefa838b55c6cd960fb7f48af34ccb..ea6de66db415294d183fb208af0b4ee7f68acfc9 100644 (file)
@@ -2,8 +2,8 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ProcessPanel } from 'store/process-panel/process-panel';
-import { ProcessPanelAction, processPanelActions } from 'store/process-panel/process-panel-actions';
+import { ProcessPanel } from "store/process-panel/process-panel";
+import { ProcessPanelAction, processPanelActions } from "store/process-panel/process-panel-actions";
 
 const initialState: ProcessPanel = {
     containerRequestUuid: "",
@@ -20,7 +20,8 @@ export const processPanelReducer = (state = initialState, action: ProcessPanelAc
     processPanelActions.match(action, {
         RESET_PROCESS_PANEL: () => initialState,
         SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID: containerRequestUuid => ({
-            ...state, containerRequestUuid
+            ...state,
+            containerRequestUuid,
         }),
         SET_PROCESS_PANEL_FILTERS: statuses => {
             const filters = statuses.reduce((filters, status) => ({ ...filters, [status]: true }), {});
@@ -48,8 +49,12 @@ export const processPanelReducer = (state = initialState, action: ProcessPanelAc
                 return state;
             }
         },
-        SET_OUTPUT_RAW: outputRaw => {
-            return { ...state, outputRaw };
+        SET_OUTPUT_RAW: (data: any) => {
+            //never set output to {} unless initializing
+            if (state.outputRaw?.rawOutputs && Object.keys(state.outputRaw?.rawOutputs).length && state.containerRequestUuid === data.uuid) {
+                return state;
+            }
+            return { ...state, outputRaw: data.outputRaw };
         },
         SET_NODE_INFO: ({ nodeInfo }) => {
             return { ...state, nodeInfo };
@@ -57,7 +62,7 @@ export const processPanelReducer = (state = initialState, action: ProcessPanelAc
         SET_OUTPUT_DEFINITIONS: outputDefinitions => {
             // Set output definitions is only additive to avoid clearing when mounts go temporarily missing
             if (outputDefinitions.length) {
-                return { ...state, outputDefinitions }
+                return { ...state, outputDefinitions };
             } else {
                 return state;
             }
index 3c55a9adddb2946f908d8ac43e4594779956aba1..36d73940b13d0a873fac46d5f3df6db1f0d8948d 100644 (file)
@@ -2,22 +2,23 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Dispatch } from "redux";
-import { dialogActions } from "store/dialog/dialog-actions";
+import { Dispatch } from 'redux';
+import { dialogActions } from 'store/dialog/dialog-actions';
 import { initialize, startSubmit } from 'redux-form';
 import { resetPickerProjectTree } from 'store/project-tree-picker/project-tree-picker-actions';
 import { RootState } from 'store/store';
 import { ServiceRepository } from 'services/services';
 import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog';
 import { getProcess } from 'store/processes/process';
-import {snackbarActions, SnackbarKind} from 'store/snackbar/snackbar-actions';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
-import { ContainerRequestState } from "models/container-request";
+import { ContainerRequestState } from 'models/container-request';
 
 export const PROCESS_COPY_FORM_NAME = 'processCopyFormName';
+export const MULTI_PROCESS_COPY_FORM_NAME = 'multiProcessCopyFormName';
 
-export const openCopyProcessDialog = (resource: { name: string, uuid: string }) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+export const openCopyProcessDialog =
+    (resource: { name: string; uuid: string }) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const process = getProcess(resource.uuid)(getState().resources);
         if (process) {
             dispatch<any>(resetPickerProjectTree());
@@ -30,57 +31,56 @@ export const openCopyProcessDialog = (resource: { name: string, uuid: string })
         }
     };
 
-export const copyProcess = (resource: CopyFormDialogData) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(startSubmit(PROCESS_COPY_FORM_NAME));
-        try {
-            const process = await services.containerRequestService.get(resource.uuid);
-            const {
-                command,
-                containerCountMax,
-                containerImage,
-                cwd,
-                description,
-                environment,
-                kind,
-                mounts,
-                outputName,
-                outputPath,
-                outputProperties,
-                outputStorageClasses,
-                outputTtl,
-                properties,
-                runtimeConstraints,
-                schedulingParameters,
-                useExisting,
-            } = process;
-            const newProcess = await services.containerRequestService.create({
-                command,
-                containerCountMax,
-                containerImage,
-                cwd,
-                description,
-                environment,
-                kind,
-                mounts,
-                name: resource.name,
-                outputName,
-                outputPath,
-                outputProperties,
-                outputStorageClasses,
-                outputTtl,
-                ownerUuid: resource.ownerUuid,
-                priority: 500,
-                properties,
-                runtimeConstraints,
-                schedulingParameters,
-                state: ContainerRequestState.UNCOMMITTED,
-                useExisting,
-            });
-            dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME }));
-            return newProcess;
-        } catch (e) {
-            dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME }));
-            throw new Error('Could not copy the process.');
-        }
-    };
+export const copyProcess = (resource: CopyFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch(startSubmit(PROCESS_COPY_FORM_NAME));
+    try {
+        const process = await services.containerRequestService.get(resource.uuid);
+        const {
+            command,
+            containerCountMax,
+            containerImage,
+            cwd,
+            description,
+            environment,
+            kind,
+            mounts,
+            outputName,
+            outputPath,
+            outputProperties,
+            outputStorageClasses,
+            outputTtl,
+            properties,
+            runtimeConstraints,
+            schedulingParameters,
+            useExisting,
+        } = process;
+        const newProcess = await services.containerRequestService.create({
+            command,
+            containerCountMax,
+            containerImage,
+            cwd,
+            description,
+            environment,
+            kind,
+            mounts,
+            name: resource.name,
+            outputName,
+            outputPath,
+            outputProperties,
+            outputStorageClasses,
+            outputTtl,
+            ownerUuid: resource.ownerUuid,
+            priority: 500,
+            properties,
+            runtimeConstraints,
+            schedulingParameters,
+            state: ContainerRequestState.UNCOMMITTED,
+            useExisting,
+        });
+        dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME }));
+        return newProcess;
+    } catch (e) {
+        dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME }));
+        throw new Error('Could not copy the process.');
+    }
+};
index 78703e197ff7aeda35ae117624cdb39d23adca1a..c3ac75f99571cbae600d71a00d5064626a11b925 100644 (file)
@@ -4,21 +4,21 @@
 
 import { Dispatch } from "redux";
 import { dialogActions } from "store/dialog/dialog-actions";
-import { startSubmit, stopSubmit, initialize, FormErrors } from 'redux-form';
-import { ServiceRepository } from 'services/services';
-import { RootState } from 'store/store';
+import { startSubmit, stopSubmit, initialize, FormErrors } from "redux-form";
+import { ServiceRepository } from "services/services";
+import { RootState } from "store/store";
 import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
-import {snackbarActions, SnackbarKind} from 'store/snackbar/snackbar-actions';
-import { MoveToFormDialogData } from 'store/move-to-dialog/move-to-dialog';
-import { resetPickerProjectTree } from 'store/project-tree-picker/project-tree-picker-actions';
-import { projectPanelActions } from 'store/project-panel/project-panel-action';
-import { getProcess } from 'store/processes/process';
-import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { MoveToFormDialogData } from "store/move-to-dialog/move-to-dialog";
+import { resetPickerProjectTree } from "store/project-tree-picker/project-tree-picker-actions";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { getProcess } from "store/processes/process";
+import { initProjectsTreePicker } from "store/tree-picker/tree-picker-actions";
 
-export const PROCESS_MOVE_FORM_NAME = 'processMoveFormName';
+export const PROCESS_MOVE_FORM_NAME = "processMoveFormName";
 
-export const openMoveProcessDialog = (resource: { name: string, uuid: string }) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+export const openMoveProcessDialog =
+    (resource: { name: string; uuid: string }) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const process = getProcess(resource.uuid)(getState().resources);
         if (process) {
             dispatch<any>(resetPickerProjectTree());
@@ -26,27 +26,28 @@ export const openMoveProcessDialog = (resource: { name: string, uuid: string })
             dispatch(initialize(PROCESS_MOVE_FORM_NAME, resource));
             dispatch(dialogActions.OPEN_DIALOG({ id: PROCESS_MOVE_FORM_NAME, data: {} }));
         } else {
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Process not found', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Process not found", hideDuration: 2000, kind: SnackbarKind.ERROR }));
         }
     };
 
-export const moveProcess = (resource: MoveToFormDialogData) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(startSubmit(PROCESS_MOVE_FORM_NAME));
-        try {
-            const process = await services.containerRequestService.get(resource.uuid);
-            await services.containerRequestService.update(resource.uuid, { ownerUuid: resource.ownerUuid });
-            dispatch(projectPanelActions.REQUEST_ITEMS());
+export const moveProcess = (resource: MoveToFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch(startSubmit(PROCESS_MOVE_FORM_NAME));
+    try {
+        const process = await services.containerRequestService.get(resource.uuid);
+        await services.containerRequestService.update(resource.uuid, { ownerUuid: resource.ownerUuid });
+        dispatch(projectPanelActions.REQUEST_ITEMS());
+        dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_MOVE_FORM_NAME }));
+        return process;
+    } catch (e) {
+        const error = getCommonResourceServiceError(e);
+        if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+            dispatch(
+                stopSubmit(PROCESS_MOVE_FORM_NAME, { ownerUuid: "A process with the same name already exists in the target project." } as FormErrors)
+            );
+        } else {
             dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_MOVE_FORM_NAME }));
-            return process;
-        } catch (e) {
-            const error = getCommonResourceServiceError(e);
-            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
-                dispatch(stopSubmit(PROCESS_MOVE_FORM_NAME, { ownerUuid: 'A process with the same name already exists in the target project.' } as FormErrors));
-            } else {
-                dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_MOVE_FORM_NAME }));
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not move the process.', hideDuration: 2000, kind: SnackbarKind.ERROR }));
-            }
-            return;
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Could not move the process.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
         }
-    };
+        return;
+    }
+};
index c7fd1b55d4baf9ff5bde6f33dcae9b572c2bcf3c..c7bd2c7beeac61febd3d1747eacd26d025a86493 100644 (file)
@@ -3,14 +3,14 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { Dispatch } from "redux";
-import { FormErrors, initialize, startSubmit, stopSubmit } from 'redux-form';
+import { FormErrors, initialize, startSubmit, stopSubmit } from "redux-form";
 import { RootState } from "store/store";
 import { dialogActions } from "store/dialog/dialog-actions";
 import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
 import { ServiceRepository } from "services/services";
-import { getProcess } from 'store/processes/process';
-import { projectPanelActions } from 'store/project-panel/project-panel-action';
-import {snackbarActions, SnackbarKind} from 'store/snackbar/snackbar-actions';
+import { getProcess } from "store/processes/process";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
 
 export interface ProcessUpdateFormDialogData {
     uuid: string;
@@ -18,34 +18,37 @@ export interface ProcessUpdateFormDialogData {
     description?: string;
 }
 
-export const PROCESS_UPDATE_FORM_NAME = 'processUpdateFormName';
+export const PROCESS_UPDATE_FORM_NAME = "processUpdateFormName";
 
-export const openProcessUpdateDialog = (resource: ProcessUpdateFormDialogData) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+export const openProcessUpdateDialog =
+    (resource: ProcessUpdateFormDialogData) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const process = getProcess(resource.uuid)(getState().resources);
-        if(process) {
+        if (process) {
             dispatch(initialize(PROCESS_UPDATE_FORM_NAME, { ...resource, name: process.containerRequest.name }));
             dispatch(dialogActions.OPEN_DIALOG({ id: PROCESS_UPDATE_FORM_NAME, data: {} }));
         } else {
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Process not found', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Process not found", hideDuration: 2000, kind: SnackbarKind.ERROR }));
         }
     };
 
-export const updateProcess = (resource: ProcessUpdateFormDialogData) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+export const updateProcess =
+    (resource: ProcessUpdateFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(startSubmit(PROCESS_UPDATE_FORM_NAME));
         try {
-            const updatedProcess = await services.containerRequestService.update(resource.uuid, { name: resource.name, description: resource.description });
+            const updatedProcess = await services.containerRequestService.update(resource.uuid, {
+                name: resource.name,
+                description: resource.description,
+            });
             dispatch(projectPanelActions.REQUEST_ITEMS());
             dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_UPDATE_FORM_NAME }));
             return updatedProcess;
         } catch (e) {
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
-                dispatch(stopSubmit(PROCESS_UPDATE_FORM_NAME, { name: 'Process with the same name already exists.' } as FormErrors));
+                dispatch(stopSubmit(PROCESS_UPDATE_FORM_NAME, { name: "Process with the same name already exists." } as FormErrors));
             } else {
                 dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_UPDATE_FORM_NAME }));
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not update the process.', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Could not update the process.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
             }
             return;
         }
index ad0a14c72ec172b887cc6be0bf0df3b14443b114..a31fd9eac8b12b9cf8eea5d3956d588d47f8cc79 100644 (file)
@@ -26,6 +26,8 @@ export enum ProcessStatus {
     RUNNING = 'Running',
     WARNING = 'Warning',
     UNKNOWN = 'Unknown',
+    REUSED = 'Reused',
+    CANCELLING = 'Cancelling',
 }
 
 export const getProcess = (uuid: string) => (resources: ResourcesState): Process | undefined => {
@@ -83,6 +85,7 @@ export const getProcessStatusStyles = (status: string, theme: ArvadosTheme): Rea
             running = true;
             break;
         case ProcessStatus.COMPLETED:
+        case ProcessStatus.REUSED:
             color = theme.customs.colors.green800;
             break;
         case ProcessStatus.WARNING:
@@ -93,6 +96,10 @@ export const getProcessStatusStyles = (status: string, theme: ArvadosTheme): Rea
             color = theme.customs.colors.red900;
             running = true;
             break;
+        case ProcessStatus.CANCELLING:
+            color = theme.customs.colors.red900;
+            running = true;
+            break;
         case ProcessStatus.CANCELLED:
         case ProcessStatus.FAILED:
             color = theme.customs.colors.red900;
@@ -113,7 +120,7 @@ export const getProcessStatusStyles = (status: string, theme: ArvadosTheme): Rea
         // Set text color to status color when running, else use white text for solid button
         color: running ? color : theme.palette.common.white,
         // Set border color when running, else omit the style entirely
-        ...(running ? {border: `2px solid ${color}`} : {}),
+        ...(running ? { border: `2px solid ${color}` } : {}),
     };
 };
 
@@ -122,17 +129,39 @@ export const getProcessStatus = ({ containerRequest, container }: Process): Proc
         case containerRequest.containerUuid && !container:
             return ProcessStatus.UNKNOWN;
 
+        case containerRequest.state === ContainerRequestState.UNCOMMITTED:
+            return ProcessStatus.DRAFT;
+
+        case containerRequest.state === ContainerRequestState.FINAL &&
+            container?.state === ContainerState.RUNNING:
+            // It is about to be completed but we haven't
+            // gotten the updated container record yet,
+            // if we don't catch this and show it as "Running"
+            // it will flicker "Cancelled" briefly
+            return ProcessStatus.RUNNING;
+
         case containerRequest.state === ContainerRequestState.FINAL &&
             container?.state !== ContainerState.COMPLETE:
             // Request was finalized before its container started (or the
             // container was cancelled)
             return ProcessStatus.CANCELLED;
 
-        case containerRequest.state === ContainerRequestState.UNCOMMITTED:
-            return ProcessStatus.DRAFT;
-
-        case container?.state === ContainerState.COMPLETE:
+        case container && container.state === ContainerState.COMPLETE:
             if (container?.exitCode === 0) {
+                if (containerRequest && container.finishedAt) {
+                    // don't compare on createdAt because the container can
+                    // have a slightly earlier creation time when it is created
+                    // in the same transaction as the container request.
+                    // use finishedAt because most people will assume "reused" means
+                    // no additional work needed to be done, it's possible
+                    // to share a running container but calling it "reused" in that case
+                    // is more likely to just be confusing.
+                    const finishedAt = new Date(container.finishedAt).getTime();
+                    const createdAt = new Date(containerRequest.createdAt).getTime();
+                    if (finishedAt < createdAt) {
+                        return ProcessStatus.REUSED;
+                    }
+                }
                 return ProcessStatus.COMPLETED;
             }
             return ProcessStatus.FAILED;
@@ -148,6 +177,9 @@ export const getProcessStatus = ({ containerRequest, container }: Process): Proc
             return ProcessStatus.QUEUED;
 
         case container?.state === ContainerState.RUNNING:
+            if (container?.priority === 0) {
+                return ProcessStatus.CANCELLING;
+            }
             if (!!container?.runtimeStatus.error) {
                 return ProcessStatus.FAILING;
             }
@@ -161,6 +193,10 @@ export const getProcessStatus = ({ containerRequest, container }: Process): Proc
     }
 };
 
+export const isProcessRunning = ({ container }: Process): boolean => (
+    container?.state === ContainerState.RUNNING
+);
+
 export const isProcessRunnable = ({ containerRequest }: Process): boolean => (
     containerRequest.state === ContainerRequestState.UNCOMMITTED
 );
@@ -170,15 +206,15 @@ export const isProcessResumable = ({ containerRequest, container }: Process): bo
     containerRequest.priority === 0 &&
     // Don't show run button when container is present & running or cancelled
     !(container && (container.state === ContainerState.RUNNING ||
-                            container.state === ContainerState.CANCELLED ||
-                            container.state === ContainerState.COMPLETE))
+        container.state === ContainerState.CANCELLED ||
+        container.state === ContainerState.COMPLETE))
 );
 
 export const isProcessCancelable = ({ containerRequest, container }: Process): boolean => (
     containerRequest.priority !== null &&
     containerRequest.priority > 0 &&
     container !== undefined &&
-        (container.state === ContainerState.QUEUED ||
+    (container.state === ContainerState.QUEUED ||
         container.state === ContainerState.LOCKED ||
         container.state === ContainerState.RUNNING)
 );
index 0b2de8373ca83769f9ee0d548e95530cba053ea2..eadb05e5e1460ff47db6b12d687b36394a9c624e 100644 (file)
@@ -3,28 +3,34 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { Dispatch } from "redux";
-import { RootState } from 'store/store';
-import { ServiceRepository } from 'services/services';
-import { updateResources } from 'store/resources/resources-actions';
-import { Process } from './process';
-import { dialogActions } from 'store/dialog/dialog-actions';
-import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
-import { projectPanelActions } from 'store/project-panel/project-panel-action';
-import { navigateToRunProcess } from 'store/navigation/navigation-action';
-import { goToStep, runProcessPanelActions } from 'store/run-process-panel/run-process-panel-actions';
-import { getResource } from 'store/resources/resources';
+import { RootState } from "store/store";
+import { ServiceRepository } from "services/services";
+import { updateResources } from "store/resources/resources-actions";
+import { Process } from "./process";
+import { dialogActions } from "store/dialog/dialog-actions";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { navigateToRunProcess } from "store/navigation/navigation-action";
+import { goToStep, runProcessPanelActions } from "store/run-process-panel/run-process-panel-actions";
+import { getResource } from "store/resources/resources";
 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 { MOUNT_PATH_CWL_WORKFLOW, MOUNT_PATH_CWL_INPUT } from "models/process";
 import { CommandInputParameter, getWorkflow, getWorkflowInputs, getWorkflowOutputs, WorkflowInputsData } from "models/workflow";
 import { ProjectResource } from "models/project";
 import { UserResource } from "models/user";
 import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter";
 import { ContainerResource } from "models/container";
 import { ContainerRequestResource, ContainerRequestState } from "models/container-request";
+import { FilterBuilder } from "services/api/filter-builder";
+import { selectedToArray } from "components/multiselect-toolbar/MultiselectToolbar";
+import { Resource, ResourceKind } from "models/resource";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
+import { CommonResourceServiceError, getCommonResourceServiceError } from "services/common-service/common-resource-service";
 
-export const loadProcess = (containerRequestUuid: string) =>
+export const loadProcess =
+    (containerRequestUuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<Process | undefined> => {
         let containerRequest: ContainerRequestResource | undefined = undefined;
         try {
@@ -48,7 +54,7 @@ export const loadProcess = (containerRequestUuid: string) =>
                 dispatch<any>(updateResources([container]));
             } catch {}
 
-            try{
+            try {
                 if (container && container.runtimeUserUuid) {
                     const runtimeUser = await services.userService.get(container.runtimeUserUuid, false);
                     dispatch<any>(updateResources([runtimeUser]));
@@ -60,9 +66,13 @@ export const loadProcess = (containerRequestUuid: string) =>
         return { containerRequest };
     };
 
-export const loadContainers = (filters: string, loadMounts: boolean = true) =>
+export const loadContainers =
+    (containerUuids: string[], loadMounts: boolean = true) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        let args: any = { filters };
+        let args: any = {
+            filters: new FilterBuilder().addIn("uuid", containerUuids).getFilters(),
+            limit: containerUuids.length,
+        };
         if (!loadMounts) {
             args.select = containerFieldsNoMounts;
         }
@@ -107,62 +117,62 @@ const containerFieldsNoMounts = [
     "scheduling_parameters",
     "started_at",
     "state",
+    "subrequests_cost",
     "uuid",
-]
+];
 
-export const cancelRunningWorkflow = (uuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        try {
-            const process = await services.containerRequestService.update(uuid, { priority: 0 });
-            dispatch<any>(updateResources([process]));
-            if (process.containerUuid) {
-                const container = await services.containerService.get(process.containerUuid, false);
-                dispatch<any>(updateResources([container]));
-            }
-            return process;
-        } catch (e) {
-            throw new Error('Could not cancel the process.');
+export const cancelRunningWorkflow = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    try {
+        const process = await services.containerRequestService.update(uuid, { priority: 0 });
+        dispatch<any>(updateResources([process]));
+        if (process.containerUuid) {
+            const container = await services.containerService.get(process.containerUuid, false);
+            dispatch<any>(updateResources([container]));
         }
-    };
+        return process;
+    } catch (e) {
+        throw new Error("Could not cancel the process.");
+    }
+};
 
-export const resumeOnHoldWorkflow = (uuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        try {
-            const process = await services.containerRequestService.update(uuid, { priority: 500 });
-            dispatch<any>(updateResources([process]));
-            if (process.containerUuid) {
-                const container = await services.containerService.get(process.containerUuid, false);
-                dispatch<any>(updateResources([container]));
-            }
-            return process;
-        } catch (e) {
-            throw new Error('Could not resume the process.');
+export const resumeOnHoldWorkflow = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    try {
+        const process = await services.containerRequestService.update(uuid, { priority: 500 });
+        dispatch<any>(updateResources([process]));
+        if (process.containerUuid) {
+            const container = await services.containerService.get(process.containerUuid, false);
+            dispatch<any>(updateResources([container]));
         }
-    };
+        return process;
+    } catch (e) {
+        throw new Error("Could not resume the process.");
+    }
+};
 
-export const startWorkflow = (uuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        try {
-            const process = await services.containerRequestService.update(uuid, { state: ContainerRequestState.COMMITTED });
-            if (process) {
-                dispatch<any>(updateResources([process]));
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Process started', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
-            } else {
-                dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Failed to start process`, kind: SnackbarKind.ERROR }));
-            }
-        } catch (e) {
+export const startWorkflow = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    try {
+        const process = await services.containerRequestService.update(uuid, { state: ContainerRequestState.COMMITTED });
+        if (process) {
+            dispatch<any>(updateResources([process]));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Process started", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+        } else {
             dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Failed to start process`, kind: SnackbarKind.ERROR }));
         }
-    };
+    } catch (e) {
+        dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Failed to start process`, kind: SnackbarKind.ERROR }));
+    }
+};
 
-export const reRunProcess = (processUuid: string, workflowUuid: string) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+export const reRunProcess =
+    (processUuid: string, workflowUuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const process = getResource<any>(processUuid)(getState().resources);
         const workflows = getState().runProcessPanel.searchWorkflows;
         const workflow = workflows.find(workflow => workflow.uuid === workflowUuid);
         if (workflow && process) {
             const mainWf = getWorkflow(process.mounts[MOUNT_PATH_CWL_WORKFLOW]);
-            if (mainWf) { mainWf.inputs = getInputs(process); }
+            if (mainWf) {
+                mainWf.inputs = getInputs(process);
+            }
             const stringifiedDefinition = JSON.stringify(process.mounts[MOUNT_PATH_CWL_WORKFLOW].content);
             const newWorkflow = { ...workflow, definition: stringifiedDefinition };
 
@@ -176,7 +186,7 @@ export const reRunProcess = (processUuid: string, workflowUuid: string) =>
                 ram: process.runtimeConstraints.ram,
                 vcpus: process.runtimeConstraints.vcpus,
                 keep_cache_ram: process.runtimeConstraints.keep_cache_ram,
-                acr_container_image: process.containerImage
+                acr_container_image: process.containerImage,
             };
             dispatch<any>(initialize(RUN_PROCESS_ADVANCED_FORM, advancedInitialData));
 
@@ -195,34 +205,40 @@ export const reRunProcess = (processUuid: string, workflowUuid: string) =>
  * Returns {} if inputs not found in mounts or props
  */
 export const getRawInputs = (data: any): WorkflowInputsData | undefined => {
-    if (!data) { return 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);
-}
+    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 (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) {
+        return [];
+    }
+    const content = getRawInputs(data) as any;
     // Only escape if content is falsy to allow displaying definitions if no inputs are present
     // (Don't check raw content length)
-    if (!content) { return []; }
+    if (!content) {
+        return [];
+    }
 
     const inputs = getWorkflowInputs(data.mounts[MOUNT_PATH_CWL_WORKFLOW].content);
-    return inputs ? inputs.map(
-        (it: any) => (
-            {
-                type: it.type,
-                id: it.id,
-                label: it.label,
-                default: content[it.id],
-                value: content[it.id.split('/').pop()] || [],
-                doc: it.doc
-            }
-        )
-    ) : [];
+    return inputs
+        ? inputs.map((it: any) => ({
+              type: it.type,
+              id: it.id,
+              label: it.label,
+              default: content[it.id],
+              value: content[it.id.split("/").pop()] || [],
+              doc: it.doc,
+          }))
+        : [];
 };
 
 /*
@@ -230,25 +246,27 @@ export const getInputs = (data: any): CommandInputParameter[] => {
  * 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);
-}
+    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 []; }
+    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)
+        .filter(mount => mount.kind === "collection" && mount.portable_data_hash && mount.path)
         .map(mount => ({
             path: mount.path,
             pdh: mount.portable_data_hash,
@@ -256,39 +274,70 @@ export const getInputCollectionMounts = (data: any): InputCollectionMount[] => {
 };
 
 export const getOutputParameters = (data: any): CommandOutputParameter[] => {
-    if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) { return []; }
+    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
-            }
-        )
-    ) : [];
+    return outputs
+        ? outputs.map((it: any) => ({
+              type: it.type,
+              id: it.id,
+              label: it.label,
+              doc: it.doc,
+          }))
+        : [];
 };
 
-export const openRemoveProcessDialog = (uuid: string) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(dialogActions.OPEN_DIALOG({
-            id: REMOVE_PROCESS_DIALOG,
-            data: {
-                title: 'Remove process permanently',
-                text: 'Are you sure you want to remove this process?',
-                confirmButtonLabel: 'Remove',
-                uuid
-            }
-        }));
+export const openRemoveProcessDialog =
+    (resource: ContextMenuResource, numOfProcesses: Number) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const confirmationText =
+            numOfProcesses === 1
+                ? "Are you sure you want to remove this process?"
+                : `Are you sure you want to remove these ${numOfProcesses} processes?`;
+        const titleText = numOfProcesses === 1 ? "Remove process permanently" : "Remove processes permanently";
+
+        dispatch(
+            dialogActions.OPEN_DIALOG({
+                id: REMOVE_PROCESS_DIALOG,
+                data: {
+                    title: titleText,
+                    text: confirmationText,
+                    confirmButtonLabel: "Remove",
+                    uuid: resource.uuid,
+                    resource,
+                },
+            })
+        );
     };
 
-export const REMOVE_PROCESS_DIALOG = 'removeProcessDialog';
+export const REMOVE_PROCESS_DIALOG = "removeProcessDialog";
 
-export const removeProcessPermanently = (uuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...', kind: SnackbarKind.INFO }));
-        await services.containerRequestService.delete(uuid);
-        dispatch(projectPanelActions.REQUEST_ITEMS());
-        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
-    };
+export const removeProcessPermanently = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    const resource = getState().dialog.removeProcessDialog.data.resource;
+    const checkedList = getState().multiselect.checkedList;
+
+    const uuidsToRemove: string[] = resource.fromContextMenu ? [resource.uuid] : selectedToArray(checkedList);
+
+    //if no items in checkedlist, default to normal context menu behavior
+    if (!uuidsToRemove.length) uuidsToRemove.push(uuid);
+
+    const processesToRemove = uuidsToRemove
+        .map(uuid => getResource(uuid)(getState().resources) as Resource)
+        .filter(resource => resource.kind === ResourceKind.PROCESS);
+
+    for (const process of processesToRemove) {
+        try {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Removing ...", kind: SnackbarKind.INFO }));
+            await services.containerRequestService.delete(process.uuid, false);
+            dispatch(projectPanelActions.REQUEST_ITEMS());
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Removed.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+        } catch (e) {
+            const error = getCommonResourceServiceError(e);
+            if (error === CommonResourceServiceError.PERMISSION_ERROR_FORBIDDEN) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: `Access denied`, hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            } else {
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: `Deletion failed`, hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            }
+        }
+    }
+};
diff --git a/src/store/project-panel/project-panel-action-bind.ts b/src/store/project-panel/project-panel-action-bind.ts
new file mode 100644 (file)
index 0000000..31a5f8d
--- /dev/null
@@ -0,0 +1,9 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { bindDataExplorerActions } from "store/data-explorer/data-explorer-action";
+
+const PROJECT_PANEL_ID = "projectPanel";
+
+export const projectPanelActions = bindDataExplorerActions(PROJECT_PANEL_ID);
index 3b30f4aab5e3577fdc5c3f3632b7fb0dd60f9f50..305799e820f6c070a1797d457989ae022ff591c5 100644 (file)
@@ -2,25 +2,24 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Dispatch } from 'redux';
-import { bindDataExplorerActions } from "store/data-explorer/data-explorer-action";
+import { Dispatch } from "redux";
 import { propertiesActions } from "store/properties/properties-actions";
-import { RootState } from 'store/store';
+import { RootState } from "store/store";
 import { getProperty } from "store/properties/properties";
+import { loadProject } from "store/workbench/workbench-actions";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
 
 export const PROJECT_PANEL_ID = "projectPanel";
 export const PROJECT_PANEL_CURRENT_UUID = "projectPanelCurrentUuid";
-export const IS_PROJECT_PANEL_TRASHED = 'isProjectPanelTrashed';
-export const projectPanelActions = bindDataExplorerActions(PROJECT_PANEL_ID);
+export const IS_PROJECT_PANEL_TRASHED = "isProjectPanelTrashed";
 
-export const openProjectPanel = (projectUuid: string) =>
-    (dispatch: Dispatch) => {
-        dispatch(propertiesActions.SET_PROPERTY({ key: PROJECT_PANEL_CURRENT_UUID, value: projectUuid }));
-        dispatch(projectPanelActions.RESET_EXPLORER_SEARCH_VALUE());
-        dispatch(projectPanelActions.REQUEST_ITEMS());
-    };
+export const openProjectPanel = (projectUuid: string) => async (dispatch: Dispatch) => {
+    await dispatch<any>(loadProject(projectUuid));
+    dispatch(propertiesActions.SET_PROPERTY({ key: PROJECT_PANEL_CURRENT_UUID, value: projectUuid }));
+    dispatch(projectPanelActions.RESET_EXPLORER_SEARCH_VALUE());
+    dispatch(projectPanelActions.REQUEST_ITEMS());
+};
 
 export const getProjectPanelCurrentUuid = (state: RootState) => getProperty<string>(PROJECT_PANEL_CURRENT_UUID)(state.properties);
 
-export const setIsProjectPanelTrashed = (isTrashed: boolean) =>
-    propertiesActions.SET_PROPERTY({ key: IS_PROJECT_PANEL_TRASHED, value: isTrashed });
+export const setIsProjectPanelTrashed = (isTrashed: boolean) => propertiesActions.SET_PROPERTY({ key: IS_PROJECT_PANEL_TRASHED, value: isTrashed });
index cc8511a4c7e6ed997cb1fb74533cd95cc468f711..366e15ae04759e2a774563d8307fa8c4449eb8fe 100644 (file)
@@ -6,8 +6,8 @@ import {
     DataExplorerMiddlewareService,
     dataExplorerToListParams,
     getDataExplorerColumnFilters,
-    listResultsToDataExplorerItemsMeta
-} from 'store/data-explorer/data-explorer-middleware-service';
+    listResultsToDataExplorerItemsMeta,
+} from "store/data-explorer/data-explorer-middleware-service";
 import { ProjectPanelColumnNames } from "views/project-panel/project-panel";
 import { RootState } from "store/store";
 import { DataColumns } from "components/data-table/data-table";
@@ -17,34 +17,33 @@ import { OrderBuilder, OrderDirection } from "services/api/order-builder";
 import { FilterBuilder, joinFilters } from "services/api/filter-builder";
 import { GroupContentsResource, GroupContentsResourcePrefix } from "services/groups-service/groups-service";
 import { updateFavorites } from "store/favorites/favorites-actions";
-import {
-    IS_PROJECT_PANEL_TRASHED,
-    projectPanelActions,
-    getProjectPanelCurrentUuid
-} from 'store/project-panel/project-panel-action';
+import { IS_PROJECT_PANEL_TRASHED, getProjectPanelCurrentUuid } from "store/project-panel/project-panel-action";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
 import { Dispatch, MiddlewareAPI } from "redux";
 import { ProjectResource } from "models/project";
 import { updateResources } from "store/resources/resources-actions";
 import { getProperty } from "store/properties/properties";
-import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
-import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
-import { DataExplorer, getDataExplorer } from 'store/data-explorer/data-explorer-reducer';
-import { ListResults } from 'services/common-service/common-service';
-import { loadContainers } from 'store/processes/processes-actions';
-import { ResourceKind } from 'models/resource';
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { DataExplorer, getDataExplorer } from "store/data-explorer/data-explorer-reducer";
+import { ListResults } from "services/common-service/common-service";
+import { loadContainers } from "store/processes/processes-actions";
+import { ResourceKind } from "models/resource";
 import { getSortColumn } from "store/data-explorer/data-explorer-reducer";
-import {
-    serializeResourceTypeFilters,
-    buildProcessStatusFilters
-} from 'store/resource-type-filters/resource-type-filters';
-import { updatePublicFavorites } from 'store/public-favorites/public-favorites-actions';
+import { serializeResourceTypeFilters, buildProcessStatusFilters } from "store/resource-type-filters/resource-type-filters";
+import { updatePublicFavorites } from "store/public-favorites/public-favorites-actions";
+import { selectedFieldsOfGroup } from "models/group";
+import { defaultCollectionSelectedFields } from "models/collection";
+import { containerRequestFieldsNoMounts } from "models/container-request";
+import { MultiSelectMenuActionNames } from "views-components/multiselect-toolbar/ms-menu-actions";
+import { removeDisabledButton } from "store/multiselect/multiselect-actions";
 
 export class ProjectPanelMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
         super(id);
     }
 
-    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>, criteriaChanged?: boolean, background?: boolean) {
         const state = api.getState();
         const dataExplorer = getDataExplorer(state.dataExplorer, this.getId());
         const projectUuid = getProjectPanelCurrentUuid(state);
@@ -55,7 +54,7 @@ export class ProjectPanelMiddlewareService extends DataExplorerMiddlewareService
             api.dispatch(projectPanelDataExplorerIsNotSet());
         } else {
             try {
-                api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
+                if (!background) { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); }
                 const response = await this.services.groupsService.contents(projectUuid, getParams(dataExplorer, !!isProjectTrashed));
                 const resourceUuids = response.items.map(item => item.uuid);
                 api.dispatch<any>(updateFavorites(resourceUuids));
@@ -64,36 +63,40 @@ export class ProjectPanelMiddlewareService extends DataExplorerMiddlewareService
                 await api.dispatch<any>(loadMissingProcessesInformation(response.items));
                 api.dispatch(setItems(response));
             } catch (e) {
-                api.dispatch(projectPanelActions.SET_ITEMS({
-                    items: [],
-                    itemsAvailable: 0,
-                    page: 0,
-                    rowsPerPage: dataExplorer.rowsPerPage
-                }));
-                api.dispatch(couldNotFetchProjectContents());
+                api.dispatch(
+                    projectPanelActions.SET_ITEMS({
+                        items: [],
+                        itemsAvailable: 0,
+                        page: 0,
+                        rowsPerPage: dataExplorer.rowsPerPage,
+                    })
+                );
+                if (e.status === 404) {
+                    // It'll just show up as not found
+                }
+                else {
+                    api.dispatch(couldNotFetchProjectContents());
+                }
             } finally {
-                api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+                if (!background) { 
+                    api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+                    api.dispatch<any>(removeDisabledButton(MultiSelectMenuActionNames.MOVE_TO_TRASH))
+                }
             }
         }
     }
 }
 
-export const loadMissingProcessesInformation = (resources: GroupContentsResource[]) =>
-    async (dispatch: Dispatch) => {
-        const containerUuids = resources.reduce((uuids, resource) => {
-            return resource.kind === ResourceKind.CONTAINER_REQUEST
-                ? resource.containerUuid
-                    ? [...uuids, resource.containerUuid]
-                    : uuids
-                : uuids;
-        }, []);
-        if (containerUuids.length > 0) {
-            await dispatch<any>(loadContainers(
-                new FilterBuilder().addIn('uuid', containerUuids).getFilters(),
-                false
-            ));
-        }
-    };
+export const loadMissingProcessesInformation = (resources: GroupContentsResource[]) => async (dispatch: Dispatch) => {
+    const containerUuids = resources.reduce((uuids, resource) => {
+        return resource.kind === ResourceKind.CONTAINER_REQUEST && resource.containerUuid && !uuids.includes(resource.containerUuid)
+            ? [...uuids, resource.containerUuid]
+            : uuids;
+    }, [] as string[]);
+    if (containerUuids.length > 0) {
+        await dispatch<any>(loadContainers(containerUuids, false));
+    }
+};
 
 export const setItems = (listResults: ListResults<GroupContentsResource>) =>
     projectPanelActions.SET_ITEMS({
@@ -105,16 +108,15 @@ export const getParams = (dataExplorer: DataExplorer, isProjectTrashed: boolean)
     ...dataExplorerToListParams(dataExplorer),
     order: getOrder(dataExplorer),
     filters: getFilters(dataExplorer),
-    includeTrash: isProjectTrashed
+    includeTrash: isProjectTrashed,
+    select: selectedFieldsOfGroup.concat(defaultCollectionSelectedFields, containerRequestFieldsNoMounts),
 });
 
 export const getFilters = (dataExplorer: DataExplorer) => {
     const columns = dataExplorer.columns as DataColumns<string, ProjectResource>;
     const typeFilters = serializeResourceTypeFilters(getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.TYPE));
-    const statusColumnFilters = getDataExplorerColumnFilters(columns, 'Status');
-    const activeStatusFilter = Object.keys(statusColumnFilters).find(
-        filterName => statusColumnFilters[filterName].selected
-    );
+    const statusColumnFilters = getDataExplorerColumnFilters(columns, "Status");
+    const activeStatusFilter = Object.keys(statusColumnFilters).find(filterName => statusColumnFilters[filterName].selected);
 
     // TODO: Extract group contents name filter
     const nameFilters = new FilterBuilder()
@@ -124,30 +126,23 @@ export const getFilters = (dataExplorer: DataExplorer) => {
         .getFilters();
 
     // Filter by container status
-    const statusFilters = buildProcessStatusFilters(
-        new FilterBuilder(),
-        activeStatusFilter || '',
-        GroupContentsResourcePrefix.PROCESS).getFilters();
+    const statusFilters = buildProcessStatusFilters(new FilterBuilder(), activeStatusFilter || "", GroupContentsResourcePrefix.PROCESS).getFilters();
 
-    return joinFilters(
-        statusFilters,
-        typeFilters,
-        nameFilters,
-    );
+    return joinFilters(statusFilters, typeFilters, nameFilters);
 };
 
 const getOrder = (dataExplorer: DataExplorer) => {
     const sortColumn = getSortColumn<ProjectResource>(dataExplorer);
     const order = new OrderBuilder<ProjectResource>();
     if (sortColumn && sortColumn.sort) {
-        const sortDirection = sortColumn.sort.direction === SortDirection.ASC
-            ? OrderDirection.ASC
-            : OrderDirection.DESC;
+        const sortDirection = sortColumn.sort.direction === SortDirection.ASC ? OrderDirection.ASC : OrderDirection.DESC;
 
+        // Use createdAt as a secondary sort column so we break ties consistently.
         return order
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.COLLECTION)
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROCESS)
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROJECT)
+            .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS)
             .getOrder();
     } else {
         return order.getOrder();
@@ -156,18 +151,18 @@ const getOrder = (dataExplorer: DataExplorer) => {
 
 const projectPanelCurrentUuidIsNotSet = () =>
     snackbarActions.OPEN_SNACKBAR({
-        message: 'Project panel is not opened.',
-        kind: SnackbarKind.ERROR
+        message: "Project panel is not opened.",
+        kind: SnackbarKind.ERROR,
     });
 
 const couldNotFetchProjectContents = () =>
     snackbarActions.OPEN_SNACKBAR({
-        message: 'Could not fetch project contents.',
-        kind: SnackbarKind.ERROR
+        message: "Could not fetch project contents.",
+        kind: SnackbarKind.ERROR,
     });
 
 const projectPanelDataExplorerIsNotSet = () =>
     snackbarActions.OPEN_SNACKBAR({
-        message: 'Project panel is not ready.',
-        kind: SnackbarKind.ERROR
+        message: "Project panel is not ready.",
+        kind: SnackbarKind.ERROR,
     });
index 98ebb3849535352769cc32e50fbf0ddc954c0318..28e934d1f85ff988f5d5fc73df6d2faadca3d450 100644 (file)
@@ -4,31 +4,34 @@
 
 import { Dispatch } from "redux";
 import { ServiceRepository } from "services/services";
-import { projectPanelActions } from "store/project-panel/project-panel-action";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
 import { loadResource } from "store/resources/resources-actions";
 import { RootState } from "store/store";
+import { MultiSelectMenuActionNames } from "views-components/multiselect-toolbar/ms-menu-actions";
+import { addDisabledButton, removeDisabledButton } from "store/multiselect/multiselect-actions";
 
-export const freezeProject = (uuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const userUUID = getState().auth.user!.uuid;
+export const freezeProject = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch<any>(addDisabledButton(MultiSelectMenuActionNames.FREEZE_PROJECT))
+    const userUUID = getState().auth.user!.uuid;
+    
+    const updatedProject = await services.projectService.update(uuid, {
+        frozenByUuid: userUUID,
+    });
+    
+    dispatch(projectPanelActions.REQUEST_ITEMS());
+    dispatch<any>(loadResource(uuid, false));
+    dispatch<any>(removeDisabledButton(MultiSelectMenuActionNames.FREEZE_PROJECT))
+    return updatedProject;
+};
 
-        const updatedProject = await services.projectService.update(uuid, {
-            frozenByUuid: userUUID
-        });
+export const unfreezeProject = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch<any>(addDisabledButton(MultiSelectMenuActionNames.FREEZE_PROJECT))
+    const updatedProject = await services.projectService.update(uuid, {
+        frozenByUuid: null,
+    });
 
-        dispatch(projectPanelActions.REQUEST_ITEMS());
-        dispatch<any>(loadResource(uuid, false));
-        return updatedProject;
-    };
-
-export const unfreezeProject = (uuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-
-        const updatedProject = await services.projectService.update(uuid, {
-            frozenByUuid: null
-        });
-
-        dispatch(projectPanelActions.REQUEST_ITEMS());
-        dispatch<any>(loadResource(uuid, false));
-        return updatedProject;
-    };
\ No newline at end of file
+    dispatch(projectPanelActions.REQUEST_ITEMS());
+    dispatch<any>(loadResource(uuid, false));
+    dispatch<any>(removeDisabledButton(MultiSelectMenuActionNames.FREEZE_PROJECT))
+    return updatedProject;
+};
index 963070cad92c2fb8748a04630d056c532d3aad85..97cd5dbe71280b464387ed83436e7d4d3e98f2cd 100644 (file)
@@ -4,48 +4,53 @@
 
 import { Dispatch } from "redux";
 import { dialogActions } from "store/dialog/dialog-actions";
-import { startSubmit, stopSubmit, initialize, FormErrors } from 'redux-form';
-import { ServiceRepository } from 'services/services';
-import { RootState } from 'store/store';
+import { startSubmit, stopSubmit, initialize, FormErrors } from "redux-form";
+import { ServiceRepository } from "services/services";
+import { RootState } from "store/store";
 import { getUserUuid } from "common/getuser";
 import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
-import { MoveToFormDialogData } from 'store/move-to-dialog/move-to-dialog';
-import { resetPickerProjectTree } from 'store/project-tree-picker/project-tree-picker-actions';
-import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
-import { projectPanelActions } from 'store/project-panel/project-panel-action';
-import { loadSidePanelTreeProjects } from '../side-panel-tree/side-panel-tree-actions';
+import { MoveToFormDialogData } from "store/move-to-dialog/move-to-dialog";
+import { resetPickerProjectTree } from "store/project-tree-picker/project-tree-picker-actions";
+import { initProjectsTreePicker } from "store/tree-picker/tree-picker-actions";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { loadSidePanelTreeProjects } from "../side-panel-tree/side-panel-tree-actions";
 
-export const PROJECT_MOVE_FORM_NAME = 'projectMoveFormName';
+export const PROJECT_MOVE_FORM_NAME = "projectMoveFormName";
 
-export const openMoveProjectDialog = (resource: { name: string, uuid: string }) =>
-    (dispatch: Dispatch) => {
+export const openMoveProjectDialog = (resource: any) => {
+    return (dispatch: Dispatch) => {
         dispatch<any>(resetPickerProjectTree());
         dispatch<any>(initProjectsTreePicker(PROJECT_MOVE_FORM_NAME));
         dispatch(initialize(PROJECT_MOVE_FORM_NAME, resource));
         dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_MOVE_FORM_NAME, data: {} }));
     };
+};
 
-export const moveProject = (resource: MoveToFormDialogData) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const userUuid = getUserUuid(getState());
-        if (!userUuid) { return; }
-        dispatch(startSubmit(PROJECT_MOVE_FORM_NAME));
-        try {
-            const newProject = await services.projectService.update(resource.uuid, { ownerUuid: resource.ownerUuid });
-            dispatch(projectPanelActions.REQUEST_ITEMS());
+export const moveProject = (resource: MoveToFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    const userUuid = getUserUuid(getState());
+    if (!userUuid) {
+        return;
+    }
+    dispatch(startSubmit(PROJECT_MOVE_FORM_NAME));
+    try {
+        const newProject = await services.projectService.update(resource.uuid, { ownerUuid: resource.ownerUuid });
+        dispatch(projectPanelActions.REQUEST_ITEMS());
+
+        dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_MOVE_FORM_NAME }));
+        await dispatch<any>(loadSidePanelTreeProjects(userUuid));
+        return newProject;
+    } catch (e) {
+        const error = getCommonResourceServiceError(e);
+        if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+            dispatch(
+                stopSubmit(PROJECT_MOVE_FORM_NAME, { ownerUuid: "A project with the same name already exists in the target project." } as FormErrors)
+            );
+        } else if (error === CommonResourceServiceError.OWNERSHIP_CYCLE) {
+            dispatch(stopSubmit(PROJECT_MOVE_FORM_NAME, { ownerUuid: "Cannot move a project into itself." } as FormErrors));
+        } else {
             dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_MOVE_FORM_NAME }));
-            await dispatch<any>(loadSidePanelTreeProjects(userUuid));
-            return newProject;
-        } catch (e) {
-            const error = getCommonResourceServiceError(e);
-            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
-                dispatch(stopSubmit(PROJECT_MOVE_FORM_NAME, { ownerUuid: 'A project with the same name already exists in the target project.' } as FormErrors));
-            } else if (error === CommonResourceServiceError.OWNERSHIP_CYCLE) {
-                dispatch(stopSubmit(PROJECT_MOVE_FORM_NAME, { ownerUuid: 'Cannot move a project into itself.' } as FormErrors));
-            } else {
-                dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_MOVE_FORM_NAME }));
-                throw new Error('Could not move the project.');
-            }
-            return;
+            throw new Error("Could not move the project.");
         }
-    };
+        return;
+    }
+};
index 057c7cfac59794b95dc7259b66c965551df5c28e..812490319aadd47620a142cc345cbe808d15c9ec 100644 (file)
@@ -3,22 +3,12 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { Dispatch } from "redux";
-import {
-    FormErrors,
-    formValueSelector,
-    initialize,
-    reset,
-    startSubmit,
-    stopSubmit
-} from 'redux-form';
+import { FormErrors, formValueSelector, initialize, reset, startSubmit, stopSubmit } from "redux-form";
 import { RootState } from "store/store";
 import { dialogActions } from "store/dialog/dialog-actions";
-import {
-    getCommonResourceServiceError,
-    CommonResourceServiceError
-} from "services/common-service/common-resource-service";
+import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
 import { ServiceRepository } from "services/services";
-import { projectPanelActions } from 'store/project-panel/project-panel-action';
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
 import { GroupClass } from "models/group";
 import { Participant } from "views-components/sharing-dialog/participant-select";
 import { ProjectProperties } from "./project-create-actions";
@@ -34,26 +24,27 @@ export interface ProjectUpdateFormDialogData {
     properties?: ProjectProperties;
 }
 
-export const PROJECT_UPDATE_FORM_NAME = 'projectUpdateFormName';
-export const PROJECT_UPDATE_PROPERTIES_FORM_NAME = 'projectUpdatePropertiesFormName';
+export const PROJECT_UPDATE_FORM_NAME = "projectUpdateFormName";
+export const PROJECT_UPDATE_PROPERTIES_FORM_NAME = "projectUpdatePropertiesFormName";
 export const PROJECT_UPDATE_FORM_SELECTOR = formValueSelector(PROJECT_UPDATE_FORM_NAME);
 
-export const openProjectUpdateDialog = (resource: ProjectUpdateFormDialogData) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
-        // Get complete project resource from store to handle consumers passing in partial resources
-        const project = getResource<ProjectResource>(resource.uuid)(getState().resources);
-        dispatch(initialize(PROJECT_UPDATE_FORM_NAME, project));
-        dispatch(dialogActions.OPEN_DIALOG({
+export const openProjectUpdateDialog = (resource: ProjectUpdateFormDialogData) => (dispatch: Dispatch, getState: () => RootState) => {
+    // Get complete project resource from store to handle consumers passing in partial resources
+    const project = getResource<ProjectResource>(resource.uuid)(getState().resources);
+    dispatch(initialize(PROJECT_UPDATE_FORM_NAME, project));
+    dispatch(
+        dialogActions.OPEN_DIALOG({
             id: PROJECT_UPDATE_FORM_NAME,
             data: {
                 sourcePanel: GroupClass.PROJECT,
-            }
-        }));
-    };
+            },
+        })
+    );
+};
 
-export const updateProject = (project: ProjectUpdateFormDialogData) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const uuid = project.uuid || '';
+export const updateProject =
+    (project: ProjectUpdateFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const uuid = project.uuid || "";
         dispatch(startSubmit(PROJECT_UPDATE_FORM_NAME));
         try {
             const updatedProject = await services.projectService.update(
@@ -63,7 +54,8 @@ export const updateProject = (project: ProjectUpdateFormDialogData) =>
                     description: project.description,
                     properties: project.properties,
                 },
-                false);
+                false
+            );
             dispatch(projectPanelActions.REQUEST_ITEMS());
             dispatch(reset(PROJECT_UPDATE_FORM_NAME));
             dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_UPDATE_FORM_NAME }));
@@ -71,16 +63,17 @@ export const updateProject = (project: ProjectUpdateFormDialogData) =>
         } catch (e) {
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
-                dispatch(stopSubmit(PROJECT_UPDATE_FORM_NAME, { name: 'Project with the same name already exists.' } as FormErrors));
+                dispatch(stopSubmit(PROJECT_UPDATE_FORM_NAME, { name: "Project with the same name already exists." } as FormErrors));
             } else {
                 dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_UPDATE_FORM_NAME }));
-                const errMsg = e.errors
-                    ? e.errors.join('')
-                    : 'There was an error while updating the project';
-                dispatch(snackbarActions.OPEN_SNACKBAR({
-                    message: errMsg,
-                    hideDuration: 2000,
-                    kind: SnackbarKind.ERROR }));
+                const errMsg = e.errors ? e.errors.join("") : "There was an error while updating the project";
+                dispatch(
+                    snackbarActions.OPEN_SNACKBAR({
+                        message: errMsg,
+                        hideDuration: 2000,
+                        kind: SnackbarKind.ERROR,
+                    })
+                );
             }
             return;
         }
index 2d4539ada5c3699782b568ed1ce4876a554b2078..0f8ed6c2611c72e55fc769a2d5f4b7ab3d4c6408 100644 (file)
@@ -9,6 +9,9 @@ import { checkPublicFavorite } from "./public-favorites-reducer";
 import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
 import { ServiceRepository } from "services/services";
 import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { addDisabledButton, removeDisabledButton } from "store/multiselect/multiselect-actions";
+import { MultiSelectMenuActionNames } from "views-components/multiselect-toolbar/ms-menu-actions";
+import { loadPublicFavoritesTree } from "store/side-panel-tree/side-panel-tree-actions";
 
 export const publicFavoritesActions = unionize({
     TOGGLE_PUBLIC_FAVORITE: ofType<{ resourceUuid: string }>(),
@@ -21,6 +24,7 @@ export type PublicFavoritesAction = UnionOf<typeof publicFavoritesActions>;
 export const togglePublicFavorite = (resource: { uuid: string; name: string }) =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
         dispatch(progressIndicatorActions.START_WORKING("togglePublicFavorite"));
+        dispatch<any>(addDisabledButton(MultiSelectMenuActionNames.ADD_TO_PUBLIC_FAVORITES))
         const uuidPrefix = getState().auth.config.uuidPrefix;
         const uuid = `${uuidPrefix}-j7d0g-publicfavorites`;
         dispatch(publicFavoritesActions.TOGGLE_PUBLIC_FAVORITE({ resourceUuid: resource.uuid }));
@@ -47,7 +51,9 @@ export const togglePublicFavorite = (resource: { uuid: string; name: string }) =
                     hideDuration: 2000,
                     kind: SnackbarKind.SUCCESS
                 }));
+                dispatch<any>(removeDisabledButton(MultiSelectMenuActionNames.ADD_TO_PUBLIC_FAVORITES))
                 dispatch(progressIndicatorActions.STOP_WORKING("togglePublicFavorite"));
+                dispatch<any>(loadPublicFavoritesTree())
             })
             .catch((e: any) => {
                 dispatch(progressIndicatorActions.STOP_WORKING("togglePublicFavorite"));
index 5972f60c1121a8a5da578272b467024cbaa5889d..216a59c72c2236ab3fcbb820e4d2ec836b0ecdf7 100644 (file)
@@ -14,7 +14,7 @@ describe("buildProcessStatusFilters", () => {
         [ProcessStatusFilter.ONHOLD, `["state","!=","Final"],["priority","=","0"],["container.state","in",["Queued","Locked"]]`],
         [ProcessStatusFilter.COMPLETED, `["container.state","=","Complete"],["container.exit_code","=","0"]`],
         [ProcessStatusFilter.FAILED, `["container.state","=","Complete"],["container.exit_code","!=","0"]`],
-        [ProcessStatusFilter.QUEUED, `["container.state","=","Queued"],["priority","!=","0"]`],
+        [ProcessStatusFilter.QUEUED, `["container.state","in",["Queued","Locked"]],["priority","!=","0"]`],
         [ProcessStatusFilter.CANCELLED, `["container.state","=","Cancelled"]`],
         [ProcessStatusFilter.RUNNING, `["container.state","=","Running"]`],
     ].forEach(([status, expected]) => {
@@ -31,11 +31,11 @@ describe("serializeResourceTypeFilters", () => {
         const filters = getInitialResourceTypeFilters();
         const serializedFilters = serializeResourceTypeFilters(filters);
         expect(serializedFilters)
-            .toEqual(`["uuid","is_a",["${ResourceKind.PROJECT}","${ResourceKind.COLLECTION}","${ResourceKind.WORKFLOW}","${ResourceKind.PROCESS}"]],["container_requests.requesting_container_uuid","=",null]`);
+            .toEqual(`["uuid","is_a",["${ResourceKind.PROJECT}","${ResourceKind.COLLECTION}","${ResourceKind.WORKFLOW}","${ResourceKind.PROCESS}"]],["collections.properties.type","not in",["log","intermediate"]],["container_requests.requesting_container_uuid","=",null]`);
     });
 
     it("should serialize all but collection filters", () => {
-        const filters = deselectNode(ObjectTypeFilter.COLLECTION)(getInitialResourceTypeFilters());
+        const filters = deselectNode(ObjectTypeFilter.COLLECTION, true)(getInitialResourceTypeFilters());
         const serializedFilters = serializeResourceTypeFilters(filters);
         expect(serializedFilters)
             .toEqual(`["uuid","is_a",["${ResourceKind.PROJECT}","${ResourceKind.WORKFLOW}","${ResourceKind.PROCESS}"]],["container_requests.requesting_container_uuid","=",null]`);
@@ -44,11 +44,11 @@ describe("serializeResourceTypeFilters", () => {
     it("should serialize output collections and projects", () => {
         const filters = pipe(
             () => getInitialResourceTypeFilters(),
-            deselectNode(ObjectTypeFilter.DEFINITION),
-            deselectNode(ProcessTypeFilter.MAIN_PROCESS),
-            deselectNode(CollectionTypeFilter.GENERAL_COLLECTION),
-            deselectNode(CollectionTypeFilter.LOG_COLLECTION),
-            deselectNode(CollectionTypeFilter.INTERMEDIATE_COLLECTION),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
+            deselectNode(CollectionTypeFilter.GENERAL_COLLECTION, true),
+            deselectNode(CollectionTypeFilter.LOG_COLLECTION, true),
+            deselectNode(CollectionTypeFilter.INTERMEDIATE_COLLECTION, true),
         )();
 
         const serializedFilters = serializeResourceTypeFilters(filters);
@@ -56,42 +56,42 @@ describe("serializeResourceTypeFilters", () => {
             .toEqual(`["uuid","is_a",["${ResourceKind.PROJECT}","${ResourceKind.COLLECTION}"]],["collections.properties.type","in",["output"]]`);
     });
 
-    it("should serialize intermediate collections and projects", () => {
+    it("should serialize output collections and projects", () => {
         const filters = pipe(
             () => getInitialResourceTypeFilters(),
-            deselectNode(ObjectTypeFilter.DEFINITION),
-            deselectNode(ProcessTypeFilter.MAIN_PROCESS),
-            deselectNode(CollectionTypeFilter.GENERAL_COLLECTION),
-            deselectNode(CollectionTypeFilter.LOG_COLLECTION),
-            deselectNode(CollectionTypeFilter.OUTPUT_COLLECTION),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
+            deselectNode(CollectionTypeFilter.GENERAL_COLLECTION, true),
+            deselectNode(CollectionTypeFilter.LOG_COLLECTION, true),
+            deselectNode(CollectionTypeFilter.INTERMEDIATE_COLLECTION, true),
         )();
 
         const serializedFilters = serializeResourceTypeFilters(filters);
         expect(serializedFilters)
-            .toEqual(`["uuid","is_a",["${ResourceKind.PROJECT}","${ResourceKind.COLLECTION}"]],["collections.properties.type","in",["intermediate"]]`);
+            .toEqual(`["uuid","is_a",["${ResourceKind.PROJECT}","${ResourceKind.COLLECTION}"]],["collections.properties.type","in",["output"]]`);
     });
 
-    it("should serialize general and log collections", () => {
+    it("should serialize general collections", () => {
         const filters = pipe(
             () => getInitialResourceTypeFilters(),
-            deselectNode(ObjectTypeFilter.PROJECT),
-            deselectNode(ObjectTypeFilter.DEFINITION),
-            deselectNode(ProcessTypeFilter.MAIN_PROCESS),
-            deselectNode(CollectionTypeFilter.OUTPUT_COLLECTION)
+            deselectNode(ObjectTypeFilter.PROJECT, true),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
+            deselectNode(CollectionTypeFilter.OUTPUT_COLLECTION, true)
         )();
 
         const serializedFilters = serializeResourceTypeFilters(filters);
         expect(serializedFilters)
-            .toEqual(`["uuid","is_a",["${ResourceKind.COLLECTION}"]],["collections.properties.type","not in",["output"]]`);
+            .toEqual(`["uuid","is_a",["${ResourceKind.COLLECTION}"]],["collections.properties.type","not in",["output","log","intermediate"]]`);
     });
 
     it("should serialize only main processes", () => {
         const filters = pipe(
             () => getInitialResourceTypeFilters(),
-            deselectNode(ObjectTypeFilter.PROJECT),
-            deselectNode(ProcessTypeFilter.CHILD_PROCESS),
-            deselectNode(ObjectTypeFilter.COLLECTION),
-            deselectNode(ObjectTypeFilter.DEFINITION),
+            deselectNode(ObjectTypeFilter.PROJECT, true),
+            deselectNode(ProcessTypeFilter.CHILD_PROCESS, true),
+            deselectNode(ObjectTypeFilter.COLLECTION, true),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
         )();
 
         const serializedFilters = serializeResourceTypeFilters(filters);
@@ -102,12 +102,12 @@ describe("serializeResourceTypeFilters", () => {
     it("should serialize only child processes", () => {
         const filters = pipe(
             () => getInitialResourceTypeFilters(),
-            deselectNode(ObjectTypeFilter.PROJECT),
-            deselectNode(ProcessTypeFilter.MAIN_PROCESS),
-            deselectNode(ObjectTypeFilter.DEFINITION),
-            deselectNode(ObjectTypeFilter.COLLECTION),
+            deselectNode(ObjectTypeFilter.PROJECT, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ObjectTypeFilter.COLLECTION, true),
 
-            selectNode(ProcessTypeFilter.CHILD_PROCESS),
+            selectNode(ProcessTypeFilter.CHILD_PROCESS, true),
         )();
 
         const serializedFilters = serializeResourceTypeFilters(filters);
@@ -118,9 +118,9 @@ describe("serializeResourceTypeFilters", () => {
     it("should serialize all project types", () => {
         const filters = pipe(
             () => getInitialResourceTypeFilters(),
-            deselectNode(ObjectTypeFilter.COLLECTION),
-            deselectNode(ObjectTypeFilter.DEFINITION),
-            deselectNode(ProcessTypeFilter.MAIN_PROCESS),
+            deselectNode(ObjectTypeFilter.COLLECTION, true),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
         )();
 
         const serializedFilters = serializeResourceTypeFilters(filters);
@@ -131,10 +131,10 @@ describe("serializeResourceTypeFilters", () => {
     it("should serialize filter groups", () => {
         const filters = pipe(
             () => getInitialResourceTypeFilters(),
-            deselectNode(GroupTypeFilter.PROJECT),
-            deselectNode(ObjectTypeFilter.DEFINITION),
-            deselectNode(ProcessTypeFilter.MAIN_PROCESS),
-            deselectNode(ObjectTypeFilter.COLLECTION),
+            deselectNode(GroupTypeFilter.PROJECT, true),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
+            deselectNode(ObjectTypeFilter.COLLECTION, true),
         )();
 
         const serializedFilters = serializeResourceTypeFilters(filters);
@@ -145,10 +145,10 @@ describe("serializeResourceTypeFilters", () => {
     it("should serialize projects (normal)", () => {
         const filters = pipe(
             () => getInitialResourceTypeFilters(),
-            deselectNode(GroupTypeFilter.FILTER_GROUP),
-            deselectNode(ObjectTypeFilter.DEFINITION),
-            deselectNode(ProcessTypeFilter.MAIN_PROCESS),
-            deselectNode(ObjectTypeFilter.COLLECTION),
+            deselectNode(GroupTypeFilter.FILTER_GROUP, true),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
+            deselectNode(ObjectTypeFilter.COLLECTION, true),
         )();
 
         const serializedFilters = serializeResourceTypeFilters(filters);
index 361b52a6ae47f1449dcd99ffbb6f0028f52df01a..e1448f640b98b14287557514a0ecc96f4883e99b 100644 (file)
@@ -79,16 +79,16 @@ export const getInitialResourceTypeFilters = pipe(
     ),
     pipe(
         initFilter(ObjectTypeFilter.WORKFLOW, '', false, true),
-        initFilter(ObjectTypeFilter.DEFINITION, ObjectTypeFilter.WORKFLOW),
         initFilter(ProcessTypeFilter.MAIN_PROCESS, ObjectTypeFilter.WORKFLOW),
         initFilter(ProcessTypeFilter.CHILD_PROCESS, ObjectTypeFilter.WORKFLOW, false),
+        initFilter(ObjectTypeFilter.DEFINITION, ObjectTypeFilter.WORKFLOW),
     ),
     pipe(
         initFilter(ObjectTypeFilter.COLLECTION, '', true, true),
         initFilter(CollectionTypeFilter.GENERAL_COLLECTION, ObjectTypeFilter.COLLECTION),
         initFilter(CollectionTypeFilter.OUTPUT_COLLECTION, ObjectTypeFilter.COLLECTION),
-        initFilter(CollectionTypeFilter.INTERMEDIATE_COLLECTION, ObjectTypeFilter.COLLECTION),
-        initFilter(CollectionTypeFilter.LOG_COLLECTION, ObjectTypeFilter.COLLECTION),
+        initFilter(CollectionTypeFilter.INTERMEDIATE_COLLECTION, ObjectTypeFilter.COLLECTION, false),
+        initFilter(CollectionTypeFilter.LOG_COLLECTION, ObjectTypeFilter.COLLECTION, false),
     ),
 
 );
@@ -306,7 +306,7 @@ export const buildProcessStatusFilters = (fb: FilterBuilder, activeStatusFilter:
             break;
         }
         case ProcessStatusFilter.QUEUED: {
-            fb.addEqual('container.state', ContainerState.QUEUED, resourcePrefix);
+            fb.addIn('container.state', [ContainerState.QUEUED, ContainerState.LOCKED], resourcePrefix);
             fb.addDistinct('priority', '0', resourcePrefix);
             break;
         }
index 1d1355a8ae457e5ba6fb95e87cc357589d4210a6..aff338f0b48540a5c666852aa40dcad565cc9a8f 100644 (file)
@@ -15,8 +15,10 @@ import { TagProperty } from 'models/tag';
 import { change, formValueSelector } from 'redux-form';
 import { ResourcePropertiesFormData } from 'views-components/resource-properties-form/resource-properties-form';
 
+export type ResourceWithDescription = Resource & { description?: string }
+
 export const resourcesActions = unionize({
-    SET_RESOURCES: ofType<Resource[]>(),
+    SET_RESOURCES: ofType<ResourceWithDescription[] >(),
     DELETE_RESOURCES: ofType<string[]>()
 });
 
index bb0cd383d8f6f7f7741506b9a2a0cbf8735f6980..02b8f38f4c8eec3dc1698d6c689cc9b9931dfc12 100644 (file)
@@ -2,16 +2,22 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+import { sanitizeHTML } from 'common/html-sanitize';
 import { ResourcesState, setResource, deleteResource } from './resources';
 import { ResourcesAction, resourcesActions } from './resources-actions';
 
-export const resourcesReducer = (state: ResourcesState = {}, action: ResourcesAction) =>
-    resourcesActions.match(action, {
-        SET_RESOURCES: resources => resources.reduce(
-            (state, resource) => setResource(resource.uuid, resource)(state),
-            state),
-        DELETE_RESOURCES: ids => ids.reduce(
-            (state, id) => deleteResource(id)(state),
-            state),
+export const resourcesReducer = (state: ResourcesState = {}, action: ResourcesAction) => {
+    if (Array.isArray(action.payload)) {
+        for (const item of action.payload) {
+            if (typeof item === 'object' && item.description) {
+                item.description = sanitizeHTML(item.description);
+            }
+        }
+    }
+
+    return resourcesActions.match(action, {
+        SET_RESOURCES: resources => resources.reduce((state, resource) => setResource(resource.uuid, resource)(state), state),
+        DELETE_RESOURCES: ids => ids.reduce((state, id) => deleteResource(id)(state), state),
         default: () => state,
-    });
\ No newline at end of file
+    });
+};
\ No newline at end of file
index 300e21d1068e6640b4acac29378e5bb28b0c8659..64e19fe501a483e35d3b9c1637c013d9a588792d 100644 (file)
@@ -20,14 +20,14 @@ describe('resources', () => {
         const resourcesState = {
             [groupFixtures.editable_project_resource_uuid]: {
                 uuid: groupFixtures.editable_project_resource_uuid,
-                ownerUuid: groupFixtures.user_uuid,
+                ownerUuid: groupFixtures.user_resource_uuid,
                 createdAt: 'string',
                 modifiedByClientUuid: 'string',
                 modifiedByUserUuid: 'string',
                 modifiedAt: 'string',
                 href: 'string',
                 kind: ResourceKind.PROJECT,
-                writableBy: [groupFixtures.user_uuid],
+                canWrite: true,
                 etag: 'string',
             },
             [groupFixtures.editable_collection_resource_uuid]: {
@@ -50,7 +50,7 @@ describe('resources', () => {
                 modifiedAt: 'string',
                 href: 'string',
                 kind: ResourceKind.PROJECT,
-                writableBy: [groupFixtures.unknown_user_resource_uuid],
+                canWrite: false,
                 etag: 'string',
             },
             [groupFixtures.not_editable_collection_resource_uuid]: {
@@ -74,6 +74,7 @@ describe('resources', () => {
                 href: 'string',
                 kind: ResourceKind.USER,
                 etag: 'string',
+                canWrite: true
             }
         };
 
@@ -137,4 +138,4 @@ describe('resources', () => {
             expect(result!.isEditable).toBeFalsy();
         });
     });
-});
\ No newline at end of file
+});
index 6f7acadae0f64cfac8ac2957e0dafb14eb1f3b76..bf82fac12d13d7b608319f9e611c968ba97127fa 100644 (file)
@@ -4,39 +4,22 @@
 
 import { Resource, EditableResource } from "models/resource";
 import { ResourceKind } from 'models/resource';
-import { ProjectResource } from "models/project";
 import { GroupResource } from "models/group";
 
 export type ResourcesState = { [key: string]: Resource };
 
-const getResourceWritableBy = (state: ResourcesState, id: string, userUuid: string): string[] => {
-    if (!id) {
-        return [];
-    }
-
-    if (id === userUuid) {
-        return [userUuid];
-    }
-
-    const resource = (state[id] as ProjectResource);
-
-    if (!resource) {
-        return [];
-    }
-
-    const { writableBy } = resource;
-
-    return writableBy || getResourceWritableBy(state, resource.ownerUuid, userUuid);
-};
-
-export const getResourceWithEditableStatus = <T extends EditableResource & GroupResource>(id: string, userUuid?: string) =>
+export const getResourceWithEditableStatus = <T extends GroupResource & EditableResource>(id: string, userUuid?: string) =>
     (state: ResourcesState): T | undefined => {
         if (state[id] === undefined) { return; }
 
-        const resource = JSON.parse(JSON.stringify(state[id] as T));
+        const resource = JSON.parse(JSON.stringify(state[id])) as T;
 
         if (resource) {
-            resource.isEditable = userUuid ? getResourceWritableBy(state, id, userUuid).indexOf(userUuid) > -1 : false;
+            if (resource.canWrite === undefined) {
+                resource.isEditable = (state[resource.ownerUuid] as GroupResource)?.canWrite;
+            } else {
+                resource.isEditable = resource.canWrite;
+            }
         }
 
         return resource;
index c615f2162d0de02e1c35ce6b708edf116d85c1b1..77c8c4a7e20903e2ce4ab033bfb57c4a0c81204b 100644 (file)
@@ -119,7 +119,7 @@ describe("run-process-panel-actions", () => {
                 outputName: "Output from basicFormTestName",
                 outputPath: "/var/spool/cwl",
                 ownerUuid: "zzzzz-tpzed-yid70bw31f51234",
-                priority: 1,
+                priority: 500,
                 properties: {
                     workflowName: "revsort.cwl",
                     template_uuid: "zzzzz-7fd4e-2tlnerdkxnl4fjt",
index e0dada5c053c340148c55ff5f5a6918adf778c93..000f0cd9758cc20d3e8f4ce2c3014ea341d7c8aa 100644 (file)
@@ -103,8 +103,7 @@ export const setWorkflow = (workflow: WorkflowResource, isWorkflowChanged = true
         const advancedFormValues = getWorkflowRunnerSettings(workflow);
 
         let owner = getResource<ProjectResource | UserResource>(getState().runProcessPanel.processOwnerUuid)(getState().resources);
-        const userUuid = getUserUuid(getState());
-        if (!owner || !userUuid || owner.writableBy.indexOf(userUuid) === -1) {
+        if (!owner || !owner.canWrite) {
             owner = undefined;
         }
 
@@ -183,7 +182,7 @@ export const runProcess = async (dispatch: Dispatch<any>, getState: () => RootSt
                 '/var/lib/cwl/cwl.input.json'
             ],
             outputPath: '/var/spool/cwl',
-            priority: 1,
+            priority: 500,
             outputName: advancedForm[OUTPUT_FIELD] ? advancedForm[OUTPUT_FIELD] : `Output from ${basicForm.name}`,
             properties: {
                 template_uuid: selectedWorkflow.uuid,
index c13092d4851e34a8c035de7662d5516a327f5300..00a69cd2e308f733b617ec5a5b35dadebe42c01c 100644 (file)
@@ -76,7 +76,7 @@ export class SearchResultsMiddlewareService extends DataExplorerMiddlewareServic
                 }).catch(() => {
                     api.dispatch(couldNotFetchSearchResults(session.clusterId));
                 });
-            }
+        }
         );
     }
 }
@@ -102,10 +102,12 @@ const getOrder = (dataExplorer: DataExplorer) => {
             ? OrderDirection.ASC
             : OrderDirection.DESC;
 
+        // Use createdAt as a secondary sort column so we break ties consistently.
         return order
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.COLLECTION)
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROCESS)
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROJECT)
+            .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS)
             .getOrder();
     } else {
         return order.getOrder();
index a41978707dec090a3eb5df316ff19a6e087c291c..1a2bdabab3d7f0325579525dc41e66fa2028377a 100644 (file)
@@ -19,8 +19,9 @@ import { OrderBuilder, OrderDirection } from 'services/api/order-builder';
 import { ProjectResource } from 'models/project';
 import { getSortColumn } from "store/data-explorer/data-explorer-reducer";
 import { updatePublicFavorites } from 'store/public-favorites/public-favorites-actions';
-import { FilterBuilder } from 'services/api/filter-builder';
+import { FilterBuilder, joinFilters } from 'services/api/filter-builder';
 import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
+import { AuthState } from 'store/auth/auth-reducer';
 
 export class SharedWithMeMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
@@ -33,11 +34,7 @@ export class SharedWithMeMiddlewareService extends DataExplorerMiddlewareService
         try {
             api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
             const response = await this.services.groupsService
-                .contents('', {
-                    ...getParams(dataExplorer),
-                    excludeHomeProject: true,
-                    filters: new FilterBuilder().addDistinct('uuid', `${state.auth.config.uuidPrefix}-j7d0g-publicfavorites`).getFilters()
-                });
+                .contents('', getParams(dataExplorer, state.auth));
             api.dispatch<any>(updateFavorites(response.items.map(item => item.uuid)));
             api.dispatch<any>(updatePublicFavorites(response.items.map(item => item.uuid)));
             api.dispatch(updateResources(response.items));
@@ -51,10 +48,14 @@ export class SharedWithMeMiddlewareService extends DataExplorerMiddlewareService
     }
 }
 
-export const getParams = (dataExplorer: DataExplorer) => ({
+export const getParams = (dataExplorer: DataExplorer, authState: AuthState) => ({
     ...dataExplorerToListParams(dataExplorer),
     order: getOrder(dataExplorer),
-    filters: getFilters(dataExplorer),
+    filters: joinFilters(
+        getFilters(dataExplorer),
+        new FilterBuilder().addDistinct('uuid', `${authState.config.uuidPrefix}-j7d0g-publicfavorites`).getFilters(),
+    ),
+    excludeHomeProject: true,
 });
 
 const getOrder = (dataExplorer: DataExplorer) => {
@@ -65,10 +66,12 @@ const getOrder = (dataExplorer: DataExplorer) => {
             ? OrderDirection.ASC
             : OrderDirection.DESC;
 
+        // Use createdAt as a secondary sort column so we break ties consistently.
         return order
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.COLLECTION)
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROCESS)
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROJECT)
+            .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS)
             .getOrder();
     } else {
         return order.getOrder();
index c0fdeda5a74a546ee7c8c350f72f3d9b3c413dba..fb34398e8dfd6ff5b29dceec941aafddf1f108d4 100644 (file)
@@ -31,12 +31,12 @@ import {
     ResourceObjectType
 } from "models/resource";
 import { resourcesActions } from "store/resources/resources-actions";
-import { getPublicGroupUuid } from "store/workflow-panel/workflow-panel-actions";
+import { getPublicGroupUuid, getAllUsersGroupUuid } from "store/workflow-panel/workflow-panel-actions";
 import { getSharingPublicAccessFormData } from './sharing-dialog-types';
 
 export const openSharingDialog = (resourceUuid: string, refresh?: () => void) =>
     (dispatch: Dispatch) => {
-        dispatch(dialogActions.OPEN_DIALOG({ id: SHARING_DIALOG_NAME, data: {resourceUuid, refresh} }));
+        dispatch(dialogActions.OPEN_DIALOG({ id: SHARING_DIALOG_NAME, data: { resourceUuid, refresh } }));
         dispatch<any>(loadSharingDialog);
     };
 
@@ -133,7 +133,8 @@ const loadSharingDialog = async (dispatch: Dispatch, getState: () => RootState,
             dispatch(snackbarActions.OPEN_SNACKBAR({
                 message: 'You do not have access to share this item',
                 hideDuration: 2000,
-                kind: SnackbarKind.ERROR }));
+                kind: SnackbarKind.ERROR
+            }));
             dispatch(dialogActions.CLOSE_DIALOG({ id: SHARING_DIALOG_NAME }));
         } finally {
             dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
@@ -143,64 +144,86 @@ const loadSharingDialog = async (dispatch: Dispatch, getState: () => RootState,
 
 export const initializeManagementForm = async (dispatch: Dispatch, getState: () => RootState, { userService, groupsService, permissionService }: ServiceRepository) => {
 
-        const dialog = getDialog<SharingDialogData>(getState().dialog, SHARING_DIALOG_NAME);
-        if (!dialog) {
-            return;
-        }
-        dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
-        const resourceUuid = dialog?.data.resourceUuid;
-        const { items: permissionLinks } = await permissionService.listResourcePermissions(resourceUuid);
-        dispatch<any>(initializePublicAccessForm(permissionLinks));
-        const filters = new FilterBuilder()
-            .addIn('uuid', Array.from(new Set(permissionLinks.map(({ tailUuid }) => tailUuid))))
-            .getFilters();
-
-        const { items: users } = await userService.list({ filters, count: "none", limit: 1000 });
-        const { items: groups } = await groupsService.list({ filters, count: "none", limit: 1000 });
+    const dialog = getDialog<SharingDialogData>(getState().dialog, SHARING_DIALOG_NAME);
+    if (!dialog) {
+        return;
+    }
+    dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
+    const resourceUuid = dialog?.data.resourceUuid;
+    const { items: permissionLinks } = await permissionService.listResourcePermissions(resourceUuid);
+    dispatch<any>(initializePublicAccessForm(permissionLinks));
+    const filters = new FilterBuilder()
+        .addIn('uuid', Array.from(new Set(permissionLinks.map(({ tailUuid }) => tailUuid))))
+        .getFilters();
 
-        const getEmail = (tailUuid: string) => {
-            const user = users.find(({ uuid }) => uuid === tailUuid);
-            const group = groups.find(({ uuid }) => uuid === tailUuid);
-            return user
-                ? user.email
-                : group
-                    ? group.name
-                    : tailUuid;
-        };
+    const { items: users } = await userService.list({ filters, count: "none", limit: 1000 });
+    const { items: groups } = await groupsService.list({ filters, count: "none", limit: 1000 });
 
-        const managementPermissions = permissionLinks
-            .filter(item =>
-                item.tailUuid !== getPublicGroupUuid(getState()))
-            .map(({ tailUuid, name, uuid }) => ({
-                email: getEmail(tailUuid),
-                permissions: name as PermissionLevel,
-                permissionUuid: uuid,
-            }));
+    const getEmail = (tailUuid: string) => {
+        const user = users.find(({ uuid }) => uuid === tailUuid);
+        const group = groups.find(({ uuid }) => uuid === tailUuid);
+        return user
+            ? user.email
+            : group
+                ? group.name
+                : tailUuid;
+    };
 
-        const managementFormData: SharingManagementFormData = {
-            permissions: managementPermissions,
-            initialPermissions: managementPermissions,
-        };
+    const managementPermissions = permissionLinks
+        .map(({ tailUuid, name, uuid }) => ({
+            email: getEmail(tailUuid),
+            permissions: name as PermissionLevel,
+            permissionUuid: uuid,
+        }));
 
-        dispatch(initialize(SHARING_MANAGEMENT_FORM_NAME, managementFormData));
-        dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
+    const managementFormData: SharingManagementFormData = {
+        permissions: managementPermissions,
+        initialPermissions: managementPermissions,
     };
 
+    dispatch(initialize(SHARING_MANAGEMENT_FORM_NAME, managementFormData));
+    dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
+};
+
 const initializePublicAccessForm = (permissionLinks: PermissionResource[]) =>
-    (dispatch: Dispatch, getState: () => RootState, ) => {
+    (dispatch: Dispatch, getState: () => RootState,) => {
+
+        const state = getState();
+
         const [publicPermission] = permissionLinks
-            .filter(item => item.tailUuid === getPublicGroupUuid(getState()));
-        const publicAccessFormData: SharingPublicAccessFormData = publicPermission
-            ? {
+            .filter(item => item.tailUuid === getPublicGroupUuid(state));
+
+        const [allUsersPermission] = permissionLinks
+            .filter(item => item.tailUuid === getAllUsersGroupUuid(state));
+
+        let publicAccessFormData: SharingPublicAccessFormData;
+
+        if (publicPermission) {
+            publicAccessFormData = {
                 visibility: VisibilityLevel.PUBLIC,
-                permissionUuid: publicPermission.uuid,
-            }
-            : {
-                visibility: permissionLinks.length > 0
-                    ? VisibilityLevel.SHARED
-                    : VisibilityLevel.PRIVATE,
-                permissionUuid: '',
+                initialVisibility: VisibilityLevel.PUBLIC,
+                permissionUuid: publicPermission.uuid
+            };
+        } else if (allUsersPermission) {
+            publicAccessFormData = {
+                visibility: VisibilityLevel.ALL_USERS,
+                initialVisibility: VisibilityLevel.ALL_USERS,
+                permissionUuid: allUsersPermission.uuid
+            };
+        } else if (permissionLinks.length > 0) {
+            publicAccessFormData = {
+                visibility: VisibilityLevel.SHARED,
+                initialVisibility: VisibilityLevel.SHARED,
+                permissionUuid: ''
             };
+        } else {
+            publicAccessFormData = {
+                visibility: VisibilityLevel.PRIVATE,
+                initialVisibility: VisibilityLevel.PRIVATE,
+                permissionUuid: ''
+            };
+        }
+
         dispatch(initialize(SHARING_PUBLIC_ACCESS_FORM_NAME, publicAccessFormData));
     };
 
@@ -209,15 +232,20 @@ const savePublicPermissionChanges = async (_: Dispatch, getState: () => RootStat
     const { user } = state.auth;
     const dialog = getDialog<SharingDialogData>(state.dialog, SHARING_DIALOG_NAME);
     if (dialog && user) {
-        const { permissionUuid, visibility } = getSharingPublicAccessFormData(state);
-        if (permissionUuid) {
-            if (visibility === VisibilityLevel.PUBLIC) {
-                await permissionService.update(permissionUuid, {
-                    name: PermissionLevel.CAN_READ
-                });
-            } else {
-                await permissionService.delete(permissionUuid);
-            }
+        const { permissionUuid, visibility, initialVisibility } = getSharingPublicAccessFormData(state);
+        // If visibility level changed, delete the previous link to public/all users.
+        // On PRIVATE this link will be deleted by saveManagementChanges
+        // so don't double delete (which would show an error dialog).
+        if (permissionUuid !== "" && visibility !== initialVisibility) {
+            await permissionService.delete(permissionUuid);
+        }
+        if (visibility === VisibilityLevel.ALL_USERS) {
+            await permissionService.create({
+                ownerUuid: user.uuid,
+                headUuid: dialog.data.resourceUuid,
+                tailUuid: getAllUsersGroupUuid(state),
+                name: PermissionLevel.CAN_READ,
+            });
         } else if (visibility === VisibilityLevel.PUBLIC) {
             await permissionService.create({
                 ownerUuid: user.uuid,
@@ -244,10 +272,16 @@ const saveManagementChanges = async (_: Dispatch, getState: () => RootState, { p
                 (a, b) => a.permissionUuid === b.permissionUuid
             );
 
-        const deletions = cancelledPermissions.map(({ permissionUuid }) =>
-            permissionService.delete(permissionUuid));
-        const updates = permissions.map(update =>
-            permissionService.update(update.permissionUuid, { name: update.permissions }));
+        const deletions = cancelledPermissions.map(async ({ permissionUuid }) => {
+            try {
+                await permissionService.delete(permissionUuid, false);
+            } catch (e) { }
+        });
+        const updates = permissions.map(async update => {
+            try {
+                await permissionService.update(update.permissionUuid, { name: update.permissions }, false);
+            } catch (e) { }
+        });
         await Promise.all([...deletions, ...updates]);
     }
 };
@@ -264,7 +298,7 @@ const sendInvitations = async (_: Dispatch, getState: () => RootState, { permiss
             tailUuid: invitee.uuid,
             name: invitations.permissions
         }));
-        const changes = data.map( invitation => permissionService.create(invitation));
+        const changes = data.map(invitation => permissionService.create(invitation));
         await Promise.all(changes);
     }
 };
index a05224e2373753a705821d4d639368545df9d8d2..58ce3f0fbb2929a1d54706d99684d84ce3d6402e 100644 (file)
@@ -14,11 +14,13 @@ export const SHARING_INVITATION_FORM_NAME = 'SHARING_INVITATION_FORM_NAME';
 export enum VisibilityLevel {
     PRIVATE = 'Private',
     SHARED = 'Shared',
+    ALL_USERS = 'All users',
     PUBLIC = 'Public',
 }
 
 export interface SharingPublicAccessFormData {
     visibility: VisibilityLevel;
+    initialVisibility: VisibilityLevel;
     permissionUuid: string;
 }
 
@@ -53,4 +55,4 @@ export const getSharingPublicAccessFormData = (state: any) =>
 export const hasChanges = (state: RootState) =>
     isDirty(SHARING_PUBLIC_ACCESS_FORM_NAME)(state) ||
     isDirty(SHARING_MANAGEMENT_FORM_NAME)(state) ||
-    isDirty(SHARING_INVITATION_FORM_NAME)(state);
+    (isDirty(SHARING_INVITATION_FORM_NAME)(state) && !!state.form[SHARING_INVITATION_FORM_NAME].values?.invitedPeople.length);
index f6015fbfa0c9d4f6201373eb4bed1f5c71708a05..44dfe869389fc5598b5fb3a205ebd36390b3037d 100644 (file)
@@ -14,22 +14,23 @@ import { getNodeAncestors, getNodeAncestorsIds, getNode, TreeNode, initTreeNode,
 import { ProjectResource } from 'models/project';
 import { OrderBuilder } from 'services/api/order-builder';
 import { ResourceKind } from 'models/resource';
-import { GroupContentsResourcePrefix } from 'services/groups-service/groups-service';
-import { GroupClass } from 'models/group';
 import { CategoriesListReducer } from 'common/plugintypes';
 import { pluginConfig } from 'plugins';
+import { LinkClass } from 'models/link';
 
 export enum SidePanelTreeCategory {
     PROJECTS = 'Home Projects',
-    SHARED_WITH_ME = 'Shared with me',
-    PUBLIC_FAVORITES = 'Public Favorites',
     FAVORITES = 'My Favorites',
-    TRASH = 'Trash',
+    PUBLIC_FAVORITES = 'Public Favorites',
+    SHARED_WITH_ME = 'Shared with me',
     ALL_PROCESSES = 'All Processes',
+    SHELL_ACCESS = 'Shell Access',
     GROUPS = 'Groups',
+    TRASH = 'Trash',
 }
 
 export const SIDE_PANEL_TREE = 'sidePanelTree';
+const SIDEPANEL_TREE_NODE_LIMIT = 50
 
 export const getSidePanelTree = (treePicker: TreePicker) =>
     getTreePicker<ProjectResource | string>(SIDE_PANEL_TREE)(treePicker);
@@ -48,11 +49,12 @@ export const getSidePanelTreeBranch = (uuid: string) => (treePicker: TreePicker)
 
 let SIDE_PANEL_CATEGORIES: string[] = [
     SidePanelTreeCategory.PROJECTS,
-    SidePanelTreeCategory.SHARED_WITH_ME,
-    SidePanelTreeCategory.PUBLIC_FAVORITES,
     SidePanelTreeCategory.FAVORITES,
-    SidePanelTreeCategory.GROUPS,
+    SidePanelTreeCategory.PUBLIC_FAVORITES,
+    SidePanelTreeCategory.SHARED_WITH_ME,
     SidePanelTreeCategory.ALL_PROCESSES,
+    SidePanelTreeCategory.SHELL_ACCESS,
+    SidePanelTreeCategory.GROUPS,
     SidePanelTreeCategory.TRASH
 ];
 
@@ -81,7 +83,7 @@ export const initSidePanelTree = () =>
             nodes
         }));
         SIDE_PANEL_CATEGORIES.forEach(category => {
-            if (category !== SidePanelTreeCategory.PROJECTS && category !== SidePanelTreeCategory.SHARED_WITH_ME) {
+                if (category !== SidePanelTreeCategory.PROJECTS && category !== SidePanelTreeCategory.FAVORITES && category !== SidePanelTreeCategory.PUBLIC_FAVORITES ) {
                 dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
                     id: category,
                     pickerId: SIDE_PANEL_TREE,
@@ -95,8 +97,10 @@ export const loadSidePanelTreeProjects = (projectUuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const treePicker = getTreePicker(SIDE_PANEL_TREE)(getState().treePicker);
         const node = treePicker ? getNode(projectUuid)(treePicker) : undefined;
-        if (projectUuid === SidePanelTreeCategory.SHARED_WITH_ME) {
-            await dispatch<any>(loadSharedRoot);
+        if (projectUuid === SidePanelTreeCategory.PUBLIC_FAVORITES) {
+            await dispatch<any>(loadPublicFavoritesTree());
+        } else if (projectUuid === SidePanelTreeCategory.FAVORITES) {
+            await dispatch<any>(loadFavoritesTree());
         } else if (node || projectUuid !== '') {
             await dispatch<any>(loadProject(projectUuid));
         }
@@ -110,10 +114,13 @@ const loadProject = (projectUuid: string) =>
                 .addEqual('owner_uuid', projectUuid)
                 .getFilters(),
             order: new OrderBuilder<ProjectResource>()
-                .addAsc('name')
-                .getOrder()
+                .addDesc('createdAt')
+                .getOrder(),
+            limit: SIDEPANEL_TREE_NODE_LIMIT,
         };
+
         const { items } = await services.projectService.list(params);
+        
         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
             id: projectUuid,
             pickerId: SIDE_PANEL_TREE,
@@ -122,28 +129,58 @@ const loadProject = (projectUuid: string) =>
         dispatch(resourcesActions.SET_RESOURCES(items));
     };
 
-const loadSharedRoot = async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-    dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: SidePanelTreeCategory.SHARED_WITH_ME, pickerId: SIDE_PANEL_TREE }));
+export const loadFavoritesTree = () => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: SidePanelTreeCategory.FAVORITES, pickerId: SIDE_PANEL_TREE }));
+
+    const params = {
+        filters: new FilterBuilder()
+            .addEqual('link_class', LinkClass.STAR)
+            .addEqual('tail_uuid', getUserUuid(getState()))
+            .addEqual('tail_kind', ResourceKind.USER)
+            .getFilters(),
+        order: new OrderBuilder<ProjectResource>().addDesc('createdAt').getOrder(),
+        limit: SIDEPANEL_TREE_NODE_LIMIT,
+    };
+
+    const { items } = await services.linkService.list(params);
+
+    dispatch(
+        treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
+            id: SidePanelTreeCategory.FAVORITES,
+            pickerId: SIDE_PANEL_TREE,
+            nodes: items.map(item => initTreeNode({ id: item.headUuid, value: item })),
+        })
+    );
+
+    dispatch(resourcesActions.SET_RESOURCES(items));
+};
+
+export const loadPublicFavoritesTree = () => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id: SidePanelTreeCategory.PUBLIC_FAVORITES, pickerId: SIDE_PANEL_TREE }));
+
+    const uuidPrefix = getState().auth.config.uuidPrefix;
+    const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`;
+    const typeFilters = [ResourceKind.COLLECTION, ResourceKind.CONTAINER_REQUEST, ResourceKind.GROUP, ResourceKind.WORKFLOW];
 
     const params = {
-        filters: `[${new FilterBuilder()
-            .addIsA('uuid', ResourceKind.PROJECT)
-            .addIn('group_class', [GroupClass.PROJECT, GroupClass.FILTER])
-            .addDistinct('uuid', getState().auth.config.uuidPrefix + '-j7d0g-publicfavorites')
-            .getFilters()}]`,
-        order: new OrderBuilder<ProjectResource>()
-            .addAsc('name', GroupContentsResourcePrefix.PROJECT)
-            .getOrder(),
-        limit: 1000
+        filters: new FilterBuilder()
+            .addEqual('link_class', LinkClass.STAR)
+            .addEqual('owner_uuid', publicProjectUuid)
+            .addIsA('head_uuid', typeFilters)
+            .getFilters(),
+        order: new OrderBuilder<ProjectResource>().addDesc('createdAt').getOrder(),
+        limit: SIDEPANEL_TREE_NODE_LIMIT,
     };
 
-    const { items } = await services.groupsService.shared(params);
+    const { items } = await services.linkService.list(params);
 
-    dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
-        id: SidePanelTreeCategory.SHARED_WITH_ME,
-        pickerId: SIDE_PANEL_TREE,
-        nodes: items.map(item => initTreeNode({ id: item.uuid, value: item })),
-    }));
+    dispatch(
+        treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
+            id: SidePanelTreeCategory.PUBLIC_FAVORITES,
+            pickerId: SIDE_PANEL_TREE,
+            nodes: items.map(item => initTreeNode({ id: item.headUuid, value: item })),
+        })
+    );
 
     dispatch(resourcesActions.SET_RESOURCES(items));
 };
@@ -152,9 +189,9 @@ export const activateSidePanelTreeItem = (id: string) =>
     async (dispatch: Dispatch, getState: () => RootState) => {
         const node = getSidePanelTreeNode(id)(getState().treePicker);
         if (node && !node.active) {
-            dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id, pickerId: SIDE_PANEL_TREE }));
-        }
-        if (!isSidePanelTreeCategory(id)) {
+        dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id, pickerId: SIDE_PANEL_TREE }));
+    }
+    if (!isSidePanelTreeCategory(id)) {
             await dispatch<any>(activateSidePanelTreeProject(id));
         }
     };
@@ -180,18 +217,11 @@ export const activateSidePanelTreeBranch = (id: string) =>
         const userUuid = getUserUuid(getState());
         if (!userUuid) { return; }
         const ancestors = await services.ancestorsService.ancestors(id, userUuid);
-        const isShared = ancestors.every(({ uuid }) => uuid !== userUuid);
-        if (isShared) {
-            await dispatch<any>(loadSidePanelTreeProjects(SidePanelTreeCategory.SHARED_WITH_ME));
-        }
         for (const ancestor of ancestors) {
             await dispatch<any>(loadSidePanelTreeProjects(ancestor.uuid));
         }
         dispatch(treePickerActions.EXPAND_TREE_PICKER_NODES({
-            ids: [
-                ...(isShared ? [SidePanelTreeCategory.SHARED_WITH_ME] : []),
-                ...ancestors.map(ancestor => ancestor.uuid)
-            ],
+            ids: ancestors.map(ancestor => ancestor.uuid),
             pickerId: SIDE_PANEL_TREE
         }));
         dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id, pickerId: SIDE_PANEL_TREE }));
@@ -203,7 +233,7 @@ export const toggleSidePanelTreeItemCollapse = (id: string) =>
         if (node && node.status === TreeNodeStatus.INITIAL) {
             await dispatch<any>(loadSidePanelTreeProjects(node.id));
         }
-        dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId: SIDE_PANEL_TREE }));
+            dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId: SIDE_PANEL_TREE }));
     };
 
 export const expandSidePanelTreeItem = (id: string) =>
index 1501fd4fb5be80db4e03d9f832e59116ec95b6f9..daa9812e729900fd23fcb2bd04966f6997e764ae 100644 (file)
@@ -2,81 +2,83 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { createStore, applyMiddleware, compose, Middleware, combineReducers, Store, Action, Dispatch } from 'redux';
+import { createStore, applyMiddleware, compose, Middleware, combineReducers, Store, Action, Dispatch } from "redux";
 import { routerMiddleware, routerReducer } from "react-router-redux";
-import thunkMiddleware from 'redux-thunk';
+import thunkMiddleware from "redux-thunk";
 import { History } from "history";
-import { handleRedirects } from '../common/redirect-to';
+import { handleRedirects } from "../common/redirect-to";
 
 import { authReducer } from "./auth/auth-reducer";
 import { authMiddleware } from "./auth/auth-middleware";
-import { dataExplorerReducer } from './data-explorer/data-explorer-reducer';
-import { detailsPanelReducer } from './details-panel/details-panel-reducer';
-import { contextMenuReducer } from './context-menu/context-menu-reducer';
-import { reducer as formReducer } from 'redux-form';
-import { favoritesReducer } from './favorites/favorites-reducer';
-import { snackbarReducer } from './snackbar/snackbar-reducer';
-import { collectionPanelFilesReducer } from './collection-panel/collection-panel-files/collection-panel-files-reducer';
+import { dataExplorerReducer } from "./data-explorer/data-explorer-reducer";
+import { detailsPanelReducer } from "./details-panel/details-panel-reducer";
+import { contextMenuReducer } from "./context-menu/context-menu-reducer";
+import { reducer as formReducer } from "redux-form";
+import { favoritesReducer } from "./favorites/favorites-reducer";
+import { snackbarReducer } from "./snackbar/snackbar-reducer";
+import { collectionPanelFilesReducer } from "./collection-panel/collection-panel-files/collection-panel-files-reducer";
 import { dataExplorerMiddleware } from "./data-explorer/data-explorer-middleware";
 import { FAVORITE_PANEL_ID } from "./favorite-panel/favorite-panel-action";
 import { PROJECT_PANEL_ID } from "./project-panel/project-panel-action";
 import { ProjectPanelMiddlewareService } from "./project-panel/project-panel-middleware-service";
 import { FavoritePanelMiddlewareService } from "./favorite-panel/favorite-panel-middleware-service";
 import { AllProcessesPanelMiddlewareService } from "./all-processes-panel/all-processes-panel-middleware-service";
-import { collectionPanelReducer } from './collection-panel/collection-panel-reducer';
-import { dialogReducer } from './dialog/dialog-reducer';
+import { collectionPanelReducer } from "./collection-panel/collection-panel-reducer";
+import { dialogReducer } from "./dialog/dialog-reducer";
 import { ServiceRepository } from "services/services";
-import { treePickerReducer, treePickerSearchReducer } from './tree-picker/tree-picker-reducer';
-import { treePickerSearchMiddleware } from './tree-picker/tree-picker-middleware';
-import { resourcesReducer } from 'store/resources/resources-reducer';
-import { propertiesReducer } from './properties/properties-reducer';
-import { fileUploaderReducer } from './file-uploader/file-uploader-reducer';
+import { treePickerReducer, treePickerSearchReducer } from "./tree-picker/tree-picker-reducer";
+import { treePickerSearchMiddleware } from "./tree-picker/tree-picker-middleware";
+import { resourcesReducer } from "store/resources/resources-reducer";
+import { propertiesReducer } from "./properties/properties-reducer";
+import { fileUploaderReducer } from "./file-uploader/file-uploader-reducer";
 import { TrashPanelMiddlewareService } from "store/trash-panel/trash-panel-middleware-service";
 import { TRASH_PANEL_ID } from "store/trash-panel/trash-panel-action";
-import { processLogsPanelReducer } from './process-logs-panel/process-logs-panel-reducer';
-import { processPanelReducer } from 'store/process-panel/process-panel-reducer';
-import { SHARED_WITH_ME_PANEL_ID } from 'store/shared-with-me-panel/shared-with-me-panel-actions';
-import { SharedWithMeMiddlewareService } from './shared-with-me-panel/shared-with-me-middleware-service';
-import { progressIndicatorReducer } from './progress-indicator/progress-indicator-reducer';
-import { runProcessPanelReducer } from 'store/run-process-panel/run-process-panel-reducer';
-import { WorkflowMiddlewareService } from './workflow-panel/workflow-middleware-service';
-import { WORKFLOW_PANEL_ID } from './workflow-panel/workflow-panel-actions';
-import { appInfoReducer } from 'store/app-info/app-info-reducer';
-import { searchBarReducer } from './search-bar/search-bar-reducer';
-import { SEARCH_RESULTS_PANEL_ID } from 'store/search-results-panel/search-results-panel-actions';
-import { SearchResultsMiddlewareService } from './search-results-panel/search-results-middleware-service';
+import { processLogsPanelReducer } from "./process-logs-panel/process-logs-panel-reducer";
+import { processPanelReducer } from "store/process-panel/process-panel-reducer";
+import { SHARED_WITH_ME_PANEL_ID } from "store/shared-with-me-panel/shared-with-me-panel-actions";
+import { SharedWithMeMiddlewareService } from "./shared-with-me-panel/shared-with-me-middleware-service";
+import { progressIndicatorReducer } from "./progress-indicator/progress-indicator-reducer";
+import { runProcessPanelReducer } from "store/run-process-panel/run-process-panel-reducer";
+import { WorkflowMiddlewareService } from "./workflow-panel/workflow-middleware-service";
+import { WORKFLOW_PANEL_ID } from "./workflow-panel/workflow-panel-actions";
+import { appInfoReducer } from "store/app-info/app-info-reducer";
+import { searchBarReducer } from "./search-bar/search-bar-reducer";
+import { SEARCH_RESULTS_PANEL_ID } from "store/search-results-panel/search-results-panel-actions";
+import { SearchResultsMiddlewareService } from "./search-results-panel/search-results-middleware-service";
 import { virtualMachinesReducer } from "store/virtual-machines/virtual-machines-reducer";
-import { repositoriesReducer } from 'store/repositories/repositories-reducer';
-import { keepServicesReducer } from 'store/keep-services/keep-services-reducer';
-import { UserMiddlewareService } from 'store/users/user-panel-middleware-service';
-import { USERS_PANEL_ID } from 'store/users/users-actions';
-import { UserProfileGroupsMiddlewareService } from 'store/user-profile/user-profile-groups-middleware-service';
-import { USER_PROFILE_PANEL_ID } from 'store/user-profile/user-profile-actions'
-import { GroupsPanelMiddlewareService } from 'store/groups-panel/groups-panel-middleware-service';
-import { GROUPS_PANEL_ID } from 'store/groups-panel/groups-panel-actions';
-import { GroupDetailsPanelMembersMiddlewareService } from 'store/group-details-panel/group-details-panel-members-middleware-service';
-import { GroupDetailsPanelPermissionsMiddlewareService } from 'store/group-details-panel/group-details-panel-permissions-middleware-service';
-import { GROUP_DETAILS_MEMBERS_PANEL_ID, GROUP_DETAILS_PERMISSIONS_PANEL_ID } from 'store/group-details-panel/group-details-panel-actions';
-import { LINK_PANEL_ID } from 'store/link-panel/link-panel-actions';
-import { LinkMiddlewareService } from 'store/link-panel/link-panel-middleware-service';
-import { API_CLIENT_AUTHORIZATION_PANEL_ID } from 'store/api-client-authorizations/api-client-authorizations-actions';
-import { ApiClientAuthorizationMiddlewareService } from 'store/api-client-authorizations/api-client-authorizations-middleware-service';
-import { PublicFavoritesMiddlewareService } from 'store/public-favorites-panel/public-favorites-middleware-service';
-import { PUBLIC_FAVORITE_PANEL_ID } from 'store/public-favorites-panel/public-favorites-action';
-import { publicFavoritesReducer } from 'store/public-favorites/public-favorites-reducer';
-import { linkAccountPanelReducer } from './link-account-panel/link-account-panel-reducer';
-import { CollectionsWithSameContentAddressMiddlewareService } from 'store/collections-content-address-panel/collections-content-address-middleware-service';
-import { COLLECTIONS_CONTENT_ADDRESS_PANEL_ID } from 'store/collections-content-address-panel/collections-content-address-panel-actions';
-import { ownerNameReducer } from 'store/owner-name/owner-name-reducer';
-import { SubprocessMiddlewareService } from 'store/subprocess-panel/subprocess-panel-middleware-service';
-import { SUBPROCESS_PANEL_ID } from 'store/subprocess-panel/subprocess-panel-actions';
-import { ALL_PROCESSES_PANEL_ID } from './all-processes-panel/all-processes-panel-action';
-import { Config } from 'common/config';
-import { pluginConfig } from 'plugins';
-import { MiddlewareListReducer } from 'common/plugintypes';
-import { tooltipsMiddleware } from './tooltips/tooltips-middleware';
-import { sidePanelReducer } from './side-panel/side-panel-reducer'
-import { bannerReducer } from './banner/banner-reducer';
+import { repositoriesReducer } from "store/repositories/repositories-reducer";
+import { keepServicesReducer } from "store/keep-services/keep-services-reducer";
+import { UserMiddlewareService } from "store/users/user-panel-middleware-service";
+import { USERS_PANEL_ID } from "store/users/users-actions";
+import { UserProfileGroupsMiddlewareService } from "store/user-profile/user-profile-groups-middleware-service";
+import { USER_PROFILE_PANEL_ID } from "store/user-profile/user-profile-actions";
+import { GroupsPanelMiddlewareService } from "store/groups-panel/groups-panel-middleware-service";
+import { GROUPS_PANEL_ID } from "store/groups-panel/groups-panel-actions";
+import { GroupDetailsPanelMembersMiddlewareService } from "store/group-details-panel/group-details-panel-members-middleware-service";
+import { GroupDetailsPanelPermissionsMiddlewareService } from "store/group-details-panel/group-details-panel-permissions-middleware-service";
+import { GROUP_DETAILS_MEMBERS_PANEL_ID, GROUP_DETAILS_PERMISSIONS_PANEL_ID } from "store/group-details-panel/group-details-panel-actions";
+import { LINK_PANEL_ID } from "store/link-panel/link-panel-actions";
+import { LinkMiddlewareService } from "store/link-panel/link-panel-middleware-service";
+import { API_CLIENT_AUTHORIZATION_PANEL_ID } from "store/api-client-authorizations/api-client-authorizations-actions";
+import { ApiClientAuthorizationMiddlewareService } from "store/api-client-authorizations/api-client-authorizations-middleware-service";
+import { PublicFavoritesMiddlewareService } from "store/public-favorites-panel/public-favorites-middleware-service";
+import { PUBLIC_FAVORITE_PANEL_ID } from "store/public-favorites-panel/public-favorites-action";
+import { publicFavoritesReducer } from "store/public-favorites/public-favorites-reducer";
+import { linkAccountPanelReducer } from "./link-account-panel/link-account-panel-reducer";
+import { CollectionsWithSameContentAddressMiddlewareService } from "store/collections-content-address-panel/collections-content-address-middleware-service";
+import { COLLECTIONS_CONTENT_ADDRESS_PANEL_ID } from "store/collections-content-address-panel/collections-content-address-panel-actions";
+import { ownerNameReducer } from "store/owner-name/owner-name-reducer";
+import { SubprocessMiddlewareService } from "store/subprocess-panel/subprocess-panel-middleware-service";
+import { SUBPROCESS_PANEL_ID } from "store/subprocess-panel/subprocess-panel-actions";
+import { ALL_PROCESSES_PANEL_ID } from "./all-processes-panel/all-processes-panel-action";
+import { Config } from "common/config";
+import { pluginConfig } from "plugins";
+import { MiddlewareListReducer } from "common/plugintypes";
+import { tooltipsMiddleware } from "./tooltips/tooltips-middleware";
+import { sidePanelReducer } from "./side-panel/side-panel-reducer";
+import { bannerReducer } from "./banner/banner-reducer";
+import { multiselectReducer } from "./multiselect/multiselect-reducer";
+import { composeWithDevTools } from "redux-devtools-extension";
 
 declare global {
     interface Window {
@@ -84,11 +86,6 @@ declare global {
     }
 }
 
-const composeEnhancers =
-    (process.env.NODE_ENV === 'development' &&
-        window && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) ||
-    compose;
-
 export type RootState = ReturnType<ReturnType<typeof createRootReducer>>;
 
 export type RootStore = Store<RootState, Action> & { dispatch: Dispatch<any> };
@@ -96,57 +93,32 @@ export type RootStore = Store<RootState, Action> & { dispatch: Dispatch<any> };
 export function configureStore(history: History, services: ServiceRepository, config: Config): RootStore {
     const rootReducer = createRootReducer(services);
 
-    const projectPanelMiddleware = dataExplorerMiddleware(
-        new ProjectPanelMiddlewareService(services, PROJECT_PANEL_ID)
-    );
-    const favoritePanelMiddleware = dataExplorerMiddleware(
-        new FavoritePanelMiddlewareService(services, FAVORITE_PANEL_ID)
-    );
-    const allProcessessPanelMiddleware = dataExplorerMiddleware(
-        new AllProcessesPanelMiddlewareService(services, ALL_PROCESSES_PANEL_ID)
-    );
-    const trashPanelMiddleware = dataExplorerMiddleware(
-        new TrashPanelMiddlewareService(services, TRASH_PANEL_ID)
-    );
-    const searchResultsPanelMiddleware = dataExplorerMiddleware(
-        new SearchResultsMiddlewareService(services, SEARCH_RESULTS_PANEL_ID)
-    );
-    const sharedWithMePanelMiddleware = dataExplorerMiddleware(
-        new SharedWithMeMiddlewareService(services, SHARED_WITH_ME_PANEL_ID)
-    );
-    const workflowPanelMiddleware = dataExplorerMiddleware(
-        new WorkflowMiddlewareService(services, WORKFLOW_PANEL_ID)
-    );
-    const userPanelMiddleware = dataExplorerMiddleware(
-        new UserMiddlewareService(services, USERS_PANEL_ID)
-    );
-    const userProfileGroupsMiddleware = dataExplorerMiddleware(
-        new UserProfileGroupsMiddlewareService(services, USER_PROFILE_PANEL_ID)
-    );
-    const groupsPanelMiddleware = dataExplorerMiddleware(
-        new GroupsPanelMiddlewareService(services, GROUPS_PANEL_ID)
-    );
+    const projectPanelMiddleware = dataExplorerMiddleware(new ProjectPanelMiddlewareService(services, PROJECT_PANEL_ID));
+    const favoritePanelMiddleware = dataExplorerMiddleware(new FavoritePanelMiddlewareService(services, FAVORITE_PANEL_ID));
+    const allProcessessPanelMiddleware = dataExplorerMiddleware(new AllProcessesPanelMiddlewareService(services, ALL_PROCESSES_PANEL_ID));
+    const trashPanelMiddleware = dataExplorerMiddleware(new TrashPanelMiddlewareService(services, TRASH_PANEL_ID));
+    const searchResultsPanelMiddleware = dataExplorerMiddleware(new SearchResultsMiddlewareService(services, SEARCH_RESULTS_PANEL_ID));
+    const sharedWithMePanelMiddleware = dataExplorerMiddleware(new SharedWithMeMiddlewareService(services, SHARED_WITH_ME_PANEL_ID));
+    const workflowPanelMiddleware = dataExplorerMiddleware(new WorkflowMiddlewareService(services, WORKFLOW_PANEL_ID));
+    const userPanelMiddleware = dataExplorerMiddleware(new UserMiddlewareService(services, USERS_PANEL_ID));
+    const userProfileGroupsMiddleware = dataExplorerMiddleware(new UserProfileGroupsMiddlewareService(services, USER_PROFILE_PANEL_ID));
+    const groupsPanelMiddleware = dataExplorerMiddleware(new GroupsPanelMiddlewareService(services, GROUPS_PANEL_ID));
     const groupDetailsPanelMembersMiddleware = dataExplorerMiddleware(
         new GroupDetailsPanelMembersMiddlewareService(services, GROUP_DETAILS_MEMBERS_PANEL_ID)
     );
     const groupDetailsPanelPermissionsMiddleware = dataExplorerMiddleware(
         new GroupDetailsPanelPermissionsMiddlewareService(services, GROUP_DETAILS_PERMISSIONS_PANEL_ID)
     );
-    const linkPanelMiddleware = dataExplorerMiddleware(
-        new LinkMiddlewareService(services, LINK_PANEL_ID)
-    );
+    const linkPanelMiddleware = dataExplorerMiddleware(new LinkMiddlewareService(services, LINK_PANEL_ID));
     const apiClientAuthorizationMiddlewareService = dataExplorerMiddleware(
         new ApiClientAuthorizationMiddlewareService(services, API_CLIENT_AUTHORIZATION_PANEL_ID)
     );
-    const publicFavoritesMiddleware = dataExplorerMiddleware(
-        new PublicFavoritesMiddlewareService(services, PUBLIC_FAVORITE_PANEL_ID)
-    );
+    const publicFavoritesMiddleware = dataExplorerMiddleware(new PublicFavoritesMiddlewareService(services, PUBLIC_FAVORITE_PANEL_ID));
     const collectionsContentAddress = dataExplorerMiddleware(
         new CollectionsWithSameContentAddressMiddlewareService(services, COLLECTIONS_CONTENT_ADDRESS_PANEL_ID)
     );
-    const subprocessMiddleware = dataExplorerMiddleware(
-        new SubprocessMiddlewareService(services, SUBPROCESS_PANEL_ID)
-    );
+    const subprocessMiddleware = dataExplorerMiddleware(new SubprocessMiddlewareService(services, SUBPROCESS_PANEL_ID));
+
     const redirectToMiddleware = (store: any) => (next: any) => (action: any) => {
         const state = store.getState();
 
@@ -179,47 +151,50 @@ export function configureStore(history: History, services: ServiceRepository, co
         publicFavoritesMiddleware,
         collectionsContentAddress,
         subprocessMiddleware,
-        treePickerSearchMiddleware
+        treePickerSearchMiddleware,
     ];
 
-    const reduceMiddlewaresFn: (a: Middleware[],
-        b: MiddlewareListReducer) => Middleware[] = (a, b) => b(a, services);
+    const reduceMiddlewaresFn: (a: Middleware[], b: MiddlewareListReducer) => Middleware[] = (a, b) => b(a, services);
 
     middlewares = pluginConfig.middlewares.reduce(reduceMiddlewaresFn, middlewares);
 
-    const enhancer = composeEnhancers(applyMiddleware(redirectToMiddleware, ...middlewares));
+    const enhancer = composeWithDevTools({
+        /* options */
+    })(applyMiddleware(redirectToMiddleware, ...middlewares));
     return createStore(rootReducer, enhancer);
 }
 
-const createRootReducer = (services: ServiceRepository) => combineReducers({
-    auth: authReducer(services),
-    banner: bannerReducer,
-    collectionPanel: collectionPanelReducer,
-    collectionPanelFiles: collectionPanelFilesReducer,
-    contextMenu: contextMenuReducer,
-    dataExplorer: dataExplorerReducer,
-    detailsPanel: detailsPanelReducer,
-    dialog: dialogReducer,
-    favorites: favoritesReducer,
-    ownerName: ownerNameReducer,
-    publicFavorites: publicFavoritesReducer,
-    form: formReducer,
-    processLogsPanel: processLogsPanelReducer,
-    properties: propertiesReducer,
-    resources: resourcesReducer,
-    router: routerReducer,
-    snackbar: snackbarReducer,
-    treePicker: treePickerReducer,
-    treePickerSearch: treePickerSearchReducer,
-    fileUploader: fileUploaderReducer,
-    processPanel: processPanelReducer,
-    progressIndicator: progressIndicatorReducer,
-    runProcessPanel: runProcessPanelReducer,
-    appInfo: appInfoReducer,
-    searchBar: searchBarReducer,
-    virtualMachines: virtualMachinesReducer,
-    repositories: repositoriesReducer,
-    keepServices: keepServicesReducer,
-    linkAccountPanel: linkAccountPanelReducer,
-    sidePanel: sidePanelReducer
-});
+const createRootReducer = (services: ServiceRepository) =>
+    combineReducers({
+        auth: authReducer(services),
+        banner: bannerReducer,
+        collectionPanel: collectionPanelReducer,
+        collectionPanelFiles: collectionPanelFilesReducer,
+        contextMenu: contextMenuReducer,
+        dataExplorer: dataExplorerReducer,
+        detailsPanel: detailsPanelReducer,
+        dialog: dialogReducer,
+        favorites: favoritesReducer,
+        ownerName: ownerNameReducer,
+        publicFavorites: publicFavoritesReducer,
+        form: formReducer,
+        processLogsPanel: processLogsPanelReducer,
+        properties: propertiesReducer,
+        resources: resourcesReducer,
+        router: routerReducer,
+        snackbar: snackbarReducer,
+        treePicker: treePickerReducer,
+        treePickerSearch: treePickerSearchReducer,
+        fileUploader: fileUploaderReducer,
+        processPanel: processPanelReducer,
+        progressIndicator: progressIndicatorReducer,
+        runProcessPanel: runProcessPanelReducer,
+        appInfo: appInfoReducer,
+        searchBar: searchBarReducer,
+        virtualMachines: virtualMachinesReducer,
+        repositories: repositoriesReducer,
+        keepServices: keepServicesReducer,
+        linkAccountPanel: linkAccountPanelReducer,
+        sidePanel: sidePanelReducer,
+        multiselect: multiselectReducer,
+    });
index 0df89d6e19a2a2ac5d34264413b3bf5eb9010e8b..a67dd1f436441fa1cbf9d7ec4abc46c06910adc1 100644 (file)
@@ -6,12 +6,92 @@ import { Dispatch } from 'redux';
 import { RootState } from 'store/store';
 import { ServiceRepository } from 'services/services';
 import { bindDataExplorerActions } from 'store/data-explorer/data-explorer-action';
+import { FilterBuilder } from 'services/api/filter-builder';
+import { ProgressBarData } from 'components/subprocess-progress-bar/subprocess-progress-bar';
+import { ProcessStatusFilter, buildProcessStatusFilters } from 'store/resource-type-filters/resource-type-filters';
 export const SUBPROCESS_PANEL_ID = "subprocessPanel";
 export const SUBPROCESS_ATTRIBUTES_DIALOG = 'subprocessAttributesDialog';
 export const subprocessPanelActions = bindDataExplorerActions(SUBPROCESS_PANEL_ID);
 
 export const loadSubprocessPanel = () =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(subprocessPanelActions.CLEAR());
         dispatch(subprocessPanelActions.REQUEST_ITEMS());
     };
+
+/**
+ * Holds a ProgressBarData status type and process count result
+ */
+type ProcessStatusBarCount = {
+    status: keyof ProgressBarData;
+    count: number;
+};
+
+/**
+ * Associates each of the limited progress bar segment types with an array of
+ * ProcessStatusFilterTypes to be combined when displayed
+ */
+type ProcessStatusMap = Record<keyof ProgressBarData, ProcessStatusFilter[]>;
+
+const statusMap: ProcessStatusMap = {
+        [ProcessStatusFilter.COMPLETED]: [ProcessStatusFilter.COMPLETED],
+        [ProcessStatusFilter.RUNNING]: [ProcessStatusFilter.RUNNING],
+        [ProcessStatusFilter.FAILED]: [ProcessStatusFilter.FAILED, ProcessStatusFilter.CANCELLED],
+        [ProcessStatusFilter.QUEUED]: [ProcessStatusFilter.QUEUED, ProcessStatusFilter.ONHOLD],
+};
+
+/**
+ * Utility type to hold a pair of associated progress bar status and process status
+ */
+type ProgressBarStatusPair = {
+    barStatus: keyof ProcessStatusMap;
+    processStatus: ProcessStatusFilter;
+};
+
+export const fetchSubprocessProgress = (requestingContainerUuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<ProgressBarData | undefined> => {
+
+        const requestContainerStatusCount = async (fb: FilterBuilder) => {
+            return await services.containerRequestService.list({
+                limit: 0,
+                offset: 0,
+                filters: fb.getFilters(),
+            });
+        }
+
+        if (requestingContainerUuid) {
+            try {
+                const baseFilter = new FilterBuilder().addEqual('requesting_container_uuid', requestingContainerUuid).getFilters();
+
+                // Create return object
+                let result: ProgressBarData = {
+                    [ProcessStatusFilter.COMPLETED]: 0,
+                    [ProcessStatusFilter.RUNNING]: 0,
+                    [ProcessStatusFilter.FAILED]: 0,
+                    [ProcessStatusFilter.QUEUED]: 0,
+                }
+
+                // Create array of promises that returns the status associated with the item count
+                // Helps to make the requests simultaneously while preserving the association with the status key as a typed key
+                const promises = (Object.keys(statusMap) as Array<keyof ProcessStatusMap>)
+                    // Split statusMap into pairs of progress bar status and process status
+                    .reduce((acc, curr) => [...acc, ...statusMap[curr].map(processStatus => ({barStatus: curr, processStatus}))], [] as ProgressBarStatusPair[])
+                    .map(async (statusPair: ProgressBarStatusPair): Promise<ProcessStatusBarCount> => {
+                        // For each status pair, request count and return bar status and count
+                        const { barStatus, processStatus } = statusPair;
+                        const filter = buildProcessStatusFilters(new FilterBuilder(baseFilter), processStatus);
+                        const count = (await requestContainerStatusCount(filter)).itemsAvailable;
+                        return {status: barStatus, count};
+                    });
+
+                // Simultaneously requests each status count and apply them to the return object
+                (await Promise.all(promises)).forEach((singleResult) => {
+                    result[singleResult.status] += singleResult.count;
+                });
+                return result;
+            } catch (e) {
+                return undefined;
+            }
+        } else {
+            return undefined;
+        }
+    };
index 986c6ebde6c3b06d515a01d81dbab05129dc815d..5124c8346a6951fe656cf0ae255038a7cfc344bd 100644 (file)
@@ -26,19 +26,19 @@ export class SubprocessMiddlewareService extends DataExplorerMiddlewareService {
         super(id);
     }
 
-    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>, criteriaChanged?: boolean, background?: boolean) {
         const state = api.getState();
         const parentContainerRequestUuid = state.processPanel.containerRequestUuid;
         if (parentContainerRequestUuid === "") { return; }
         const dataExplorer = getDataExplorer(state.dataExplorer, this.getId());
 
         try {
-            api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
+            if (!background) { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); }
             const parentContainerRequest = await this.services.containerRequestService.get(parentContainerRequestUuid);
             if (parentContainerRequest.containerUuid) {
                 const containerRequests = await this.services.containerRequestService.list(
                     {
-                        ...getParams(dataExplorer, parentContainerRequest) ,
+                        ...getParams(dataExplorer, parentContainerRequest),
                         select: containerRequestFieldsNoMounts
                     });
                 api.dispatch(updateResources(containerRequests.items));
@@ -46,9 +46,9 @@ export class SubprocessMiddlewareService extends DataExplorerMiddlewareService {
                 // Populate the actual user view
                 api.dispatch(setItems(containerRequests));
             }
-            api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+            if (!background) { api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); }
         } catch {
-            api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+            if (!background) { api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); }
             api.dispatch(couldNotFetchSubprocesses());
         }
     }
@@ -65,27 +65,27 @@ export const getParams = (
 export const getFilters = (
     dataExplorer: DataExplorer,
     parentContainerRequest: ContainerRequestResource) => {
-        const columns = dataExplorer.columns as DataColumns<string, ProcessResource>;
-        const statusColumnFilters = getDataExplorerColumnFilters(columns, 'Status');
-        const activeStatusFilter = Object.keys(statusColumnFilters).find(
-            filterName => statusColumnFilters[filterName].selected
-        ) || ProcessStatusFilter.ALL;
+    const columns = dataExplorer.columns as DataColumns<string, ProcessResource>;
+    const statusColumnFilters = getDataExplorerColumnFilters(columns, 'Status');
+    const activeStatusFilter = Object.keys(statusColumnFilters).find(
+        filterName => statusColumnFilters[filterName].selected
+    ) || ProcessStatusFilter.ALL;
 
-        // Get all the subprocess' container requests and containers.
-        const fb = new FilterBuilder().addEqual('requesting_container_uuid', parentContainerRequest.containerUuid);
-        const statusFilters = buildProcessStatusFilters(fb, activeStatusFilter).getFilters();
+    // Get all the subprocess' container requests and containers.
+    const fb = new FilterBuilder().addEqual('requesting_container_uuid', parentContainerRequest.containerUuid);
+    const statusFilters = buildProcessStatusFilters(fb, activeStatusFilter).getFilters();
 
-        const nameFilters = dataExplorer.searchValue
-            ? new FilterBuilder()
-                .addILike("name", dataExplorer.searchValue)
-                .getFilters()
-            : '';
+    const nameFilters = dataExplorer.searchValue
+        ? new FilterBuilder()
+            .addILike("name", dataExplorer.searchValue)
+            .getFilters()
+        : '';
 
-        return joinFilters(
-            nameFilters,
-            statusFilters
-        );
-    };
+    return joinFilters(
+        nameFilters,
+        statusFilters
+    );
+};
 
 export const setItems = (listResults: ListResults<ProcessResource>) =>
     subprocessPanelActions.SET_ITEMS({
index bed3e62859b73c52bae07259599e2066afe8ef1f..c822cece8742856330a7d7734cd655cae923aec7 100644 (file)
@@ -27,7 +27,8 @@ import { serializeResourceTypeFilters } from 'store//resource-type-filters/resou
 import { getDataExplorerColumnFilters } from 'store/data-explorer/data-explorer-middleware-service';
 import { joinFilters } from 'services/api/filter-builder';
 import { CollectionResource } from "models/collection";
-
+import { MultiSelectMenuActionNames } from "views-components/multiselect-toolbar/ms-menu-actions";
+import { removeDisabledButton } from "store/multiselect/multiselect-actions";
 export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
         super(id);
@@ -56,7 +57,7 @@ export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService {
         try {
             api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
             const listResults = await this.services.groupsService
-                .contents(userUuid, {
+                .contents('', {
                     ...dataExplorerToListParams(dataExplorer),
                     order: getOrder(dataExplorer),
                     filters,
@@ -84,6 +85,7 @@ export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService {
             }));
             api.dispatch(couldNotFetchTrashContents());
         }
+        api.dispatch<any>(removeDisabledButton(MultiSelectMenuActionNames.MOVE_TO_TRASH))
     }
 }
 
@@ -95,9 +97,11 @@ const getOrder = (dataExplorer: DataExplorer) => {
             ? OrderDirection.ASC
             : OrderDirection.DESC;
 
+        // Use createdAt as a secondary sort column so we break ties consistently.
         return order
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.COLLECTION)
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROJECT)
+            .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS)
             .getOrder();
     } else {
         return order.getOrder();
index 85ffd4a0bed5681424a39d24d948aa648bc11683..f4e3d3f0c4de225406cff2a8c4b6e1c9eed61fe9 100644 (file)
@@ -8,80 +8,122 @@ import { ServiceRepository } from "services/services";
 import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
 import { trashPanelActions } from "store/trash-panel/trash-panel-action";
 import { activateSidePanelTreeItem, loadSidePanelTreeProjects } from "store/side-panel-tree/side-panel-tree-actions";
-import { projectPanelActions } from "store/project-panel/project-panel-action";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { sharedWithMePanelActions } from "store/shared-with-me-panel/shared-with-me-panel-actions";
 import { ResourceKind } from "models/resource";
-import { navigateTo, navigateToTrash } from 'store/navigation/navigation-action';
-import { matchCollectionRoute } from 'routes/routes';
+import { navigateTo, navigateToTrash } from "store/navigation/navigation-action";
+import { matchCollectionRoute, matchSharedWithMeRoute } from "routes/routes";
+import { MultiSelectMenuActionNames } from "views-components/multiselect-toolbar/ms-menu-actions";
+import { addDisabledButton } from "store/multiselect/multiselect-actions";
 
-export const toggleProjectTrashed = (uuid: string, ownerUuid: string, isTrashed: boolean) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
-        let errorMessage = '';
-        let successMessage = '';
-        try {
-            if (isTrashed) {
-                errorMessage = "Could not restore project from trash";
-                successMessage = "Restored from trash";
-                await services.groupsService.untrash(uuid);
-                dispatch<any>(navigateTo(uuid));
-                dispatch<any>(activateSidePanelTreeItem(uuid));
-            } else {
-                errorMessage = "Could not move project to trash";
-                successMessage = "Added to trash";
-                await services.groupsService.trash(uuid);
-                dispatch<any>(loadSidePanelTreeProjects(ownerUuid));
-                dispatch<any>(navigateTo(ownerUuid));
+export const toggleProjectTrashed =
+    (uuid: string, ownerUuid: string, isTrashed: boolean, isMulti: boolean) =>
+        async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
+            let errorMessage = "";
+            let successMessage = "";
+            let untrashedResource;
+            dispatch<any>(addDisabledButton(MultiSelectMenuActionNames.MOVE_TO_TRASH))
+            try {
+                if (isTrashed) {
+                    errorMessage = "Could not restore project from trash";
+                    successMessage = "Restored project from trash";
+                     untrashedResource = await services.groupsService.untrash(uuid);
+                    dispatch<any>(isMulti || !untrashedResource ? navigateToTrash : navigateTo(uuid));
+                    dispatch<any>(activateSidePanelTreeItem(uuid));
+                } else {
+                    errorMessage = "Could not move project to trash";
+                    successMessage = "Added project to trash";
+                    await services.groupsService.trash(uuid);
+                    dispatch<any>(loadSidePanelTreeProjects(ownerUuid));
+                    
+                    const { location } = getState().router;
+                    if (matchSharedWithMeRoute(location ? location.pathname : "")) {
+                        dispatch(sharedWithMePanelActions.REQUEST_ITEMS());
+                    }
+                    else {
+                        dispatch<any>(navigateTo(ownerUuid));
+                    }
+                }
+                if (untrashedResource) {
+                        dispatch(
+                        snackbarActions.OPEN_SNACKBAR({
+                            message: successMessage,
+                            hideDuration: 2000,
+                            kind: SnackbarKind.SUCCESS,
+                        })
+                    );
+                }
+            } catch (e) {
+                if (e.status === 422) {
+                    dispatch(
+                        snackbarActions.OPEN_SNACKBAR({
+                            message: "Could not restore project from trash: Duplicate name at destination",
+                            kind: SnackbarKind.ERROR,
+                        })
+                    );
+                } else {
+                    dispatch(
+                        snackbarActions.OPEN_SNACKBAR({
+                            message: errorMessage,
+                            kind: SnackbarKind.ERROR,
+                        })
+                    );
+                }
             }
-        } catch (e) {
-            dispatch(snackbarActions.OPEN_SNACKBAR({
-                message: errorMessage,
-                kind: SnackbarKind.ERROR
-            }));
-        }
-        dispatch(snackbarActions.OPEN_SNACKBAR({
-            message: successMessage,
-            hideDuration: 2000,
-            kind: SnackbarKind.SUCCESS
-        }));
-    };
+        };
 
-export const toggleCollectionTrashed = (uuid: string, isTrashed: boolean) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
-        let errorMessage = '';
-        let successMessage = '';
-        try {
-            if (isTrashed) {
-                const { location } = getState().router;
-                errorMessage = "Could not restore collection from trash";
-                successMessage = "Restored from trash";
-                await services.collectionService.untrash(uuid);
-                if (matchCollectionRoute(location ? location.pathname : '')) {
-                    dispatch(navigateToTrash);
+export const toggleCollectionTrashed =
+    (uuid: string, isTrashed: boolean) =>
+        async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
+            let errorMessage = "";
+            let successMessage = "";
+            dispatch<any>(addDisabledButton(MultiSelectMenuActionNames.MOVE_TO_TRASH))
+            try {
+                if (isTrashed) {
+                    const { location } = getState().router;
+                    errorMessage = "Could not restore collection from trash";
+                    successMessage = "Restored from trash";
+                    await services.collectionService.untrash(uuid);
+                    if (matchCollectionRoute(location ? location.pathname : "")) {
+                        dispatch(navigateToTrash);
+                    }
+                    dispatch(trashPanelActions.REQUEST_ITEMS());
+                } else {
+                    errorMessage = "Could not move collection to trash";
+                    successMessage = "Added to trash";
+                    await services.collectionService.trash(uuid);
+                    dispatch(projectPanelActions.REQUEST_ITEMS());
+                }
+                dispatch(
+                    snackbarActions.OPEN_SNACKBAR({
+                        message: successMessage,
+                        hideDuration: 2000,
+                        kind: SnackbarKind.SUCCESS,
+                    })
+                );
+            } catch (e) {
+                if (e.status === 422) {
+                    dispatch(
+                        snackbarActions.OPEN_SNACKBAR({
+                            message: "Could not restore collection from trash: Duplicate name at destination",
+                            kind: SnackbarKind.ERROR,
+                        })
+                    );
+                } else {
+                    dispatch(
+                        snackbarActions.OPEN_SNACKBAR({
+                            message: errorMessage,
+                            kind: SnackbarKind.ERROR,
+                        })
+                    );
                 }
-                dispatch(trashPanelActions.REQUEST_ITEMS());
-            } else {
-                errorMessage = "Could not move collection to trash";
-                successMessage = "Added to trash";
-                await services.collectionService.trash(uuid);
-                dispatch(projectPanelActions.REQUEST_ITEMS());
             }
-        } catch (e) {
-            dispatch(snackbarActions.OPEN_SNACKBAR({
-                message: errorMessage,
-                kind: SnackbarKind.ERROR
-            }));
-        }
-        dispatch(snackbarActions.OPEN_SNACKBAR({
-            message: successMessage,
-            hideDuration: 2000,
-            kind: SnackbarKind.SUCCESS
-        }));
-    };
+        };
 
-export const toggleTrashed = (kind: ResourceKind, uuid: string, ownerUuid: string, isTrashed: boolean) =>
-    (dispatch: Dispatch) => {
-        if (kind === ResourceKind.PROJECT) {
-            dispatch<any>(toggleProjectTrashed(uuid, ownerUuid, isTrashed!!));
-        } else if (kind === ResourceKind.COLLECTION) {
-            dispatch<any>(toggleCollectionTrashed(uuid, isTrashed!!));
-        }
-    };
+export const toggleTrashed = (kind: ResourceKind, uuid: string, ownerUuid: string, isTrashed: boolean) => (dispatch: Dispatch) => {
+    if (kind === ResourceKind.PROJECT) {
+        dispatch<any>(toggleProjectTrashed(uuid, ownerUuid, isTrashed!!, false));
+    } else if (kind === ResourceKind.COLLECTION) {
+        dispatch<any>(toggleCollectionTrashed(uuid, isTrashed!!));
+    }
+};
index b0d5e353eff3446d99c5371cc76385de6185671e..5734ad70c61df76f1f99f25314003ed07ae2fab3 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
+import React from "react";
 
 export interface PickerIdProp {
     pickerId: string;
@@ -10,7 +10,12 @@ export interface PickerIdProp {
 
 export const pickerId =
     (id: string) =>
-        <P extends PickerIdProp>(Component: React.ComponentType<P>) =>
-            (props: P) =>
-                <Component {...props} pickerId={id} />;
-                
\ No newline at end of file
+    <P extends PickerIdProp>(Component: React.ComponentType<P>) =>
+    (props: P) => {
+        return (
+            <Component
+                {...props}
+                pickerId={id}
+            />
+        );
+    };
diff --git a/src/store/tree-picker/tree-picker-actions.test.ts b/src/store/tree-picker/tree-picker-actions.test.ts
new file mode 100644 (file)
index 0000000..7a55503
--- /dev/null
@@ -0,0 +1,189 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ServiceRepository, createServices } from "services/services";
+import { configureStore, RootStore } from "../store";
+import { createBrowserHistory } from "history";
+import { mockConfig } from 'common/config';
+import { ApiActions } from "services/api/api-actions";
+import Axios from "axios";
+import MockAdapter from "axios-mock-adapter";
+import { ResourceKind } from 'models/resource';
+import { SHARED_PROJECT_ID, initProjectsTreePicker } from "./tree-picker-actions";
+import { CollectionResource } from "models/collection";
+import { GroupResource } from "models/group";
+import { CollectionDirectory, CollectionFile, CollectionFileType } from "models/collection-file";
+import { GroupContentsResource } from "services/groups-service/groups-service";
+import { ListResults } from "services/common-service/common-service";
+
+describe('tree-picker-actions', () => {
+    const axiosInst = Axios.create({ headers: {} });
+    const axiosMock = new MockAdapter(axiosInst);
+
+    let store: RootStore;
+    let services: ServiceRepository;
+    const config: any = {};
+    const actions: ApiActions = {
+        progressFn: (id: string, working: boolean) => { },
+        errorFn: (id: string, message: string) => { }
+    };
+    let importMocks: any[];
+
+    beforeEach(() => {
+        axiosMock.reset();
+        services = createServices(mockConfig({}), actions, axiosInst);
+        store = configureStore(createBrowserHistory(), services, config);
+        localStorage.clear();
+        importMocks = [];
+    });
+
+    afterEach(() => {
+        importMocks.map(m => m.restore());
+    });
+
+    it('initializes preselected tree picker nodes', async () => {
+        const dispatchMock = jest.fn();
+        const dispatchWrapper = (action: any) => {
+            dispatchMock(action);
+            return store.dispatch(action);
+        };
+
+        const emptyCollectionUuid = "zzzzz-4zz18-000000000000000";
+        const collectionUuid = "zzzzz-4zz18-111111111111111";
+        const parentProjectUuid = "zzzzz-j7d0g-000000000000000";
+        const childCollectionUuid = "zzzzz-4zz18-222222222222222";
+
+        const fakeResources = {
+            [emptyCollectionUuid]: {
+                kind: ResourceKind.COLLECTION,
+                ownerUuid: '',
+                files: [],
+            },
+            [collectionUuid]: {
+                kind: ResourceKind.COLLECTION,
+                ownerUuid: '',
+                files: [{
+                    id: `${collectionUuid}/directory`,
+                    name: "directory",
+                    path: "",
+                    type: CollectionFileType.DIRECTORY,
+                    url: `/c=${collectionUuid}/directory/`,
+                }]
+            },
+            [parentProjectUuid]: {
+                kind: ResourceKind.GROUP,
+                ownerUuid: '',
+            },
+            [childCollectionUuid]: {
+                kind: ResourceKind.COLLECTION,
+                ownerUuid: parentProjectUuid,
+                files: [
+                    {
+                        id: `${childCollectionUuid}/mainDir`,
+                        name: "mainDir",
+                        path: "",
+                        type: CollectionFileType.DIRECTORY,
+                        url: `/c=${childCollectionUuid}/mainDir/`,
+                    },
+                    {
+                        id: `${childCollectionUuid}/mainDir/subDir`,
+                        name: "subDir",
+                        path: "/mainDir",
+                        type: CollectionFileType.DIRECTORY,
+                        url: `/c=${childCollectionUuid}/mainDir/subDir`,
+                    }
+                ],
+            },
+        };
+
+        services.ancestorsService.ancestors = jest.fn(async (startUuid, endUuid) => {
+            let ancestors: (GroupResource | CollectionResource)[] = [];
+            let uuid = startUuid;
+            while (uuid?.length && fakeResources[uuid]) {
+                const resource = fakeResources[uuid];
+                if (resource.kind === ResourceKind.COLLECTION) {
+                    ancestors.unshift({
+                        uuid, kind: resource.kind,
+                        ownerUuid: resource.ownerUuid,
+                    } as CollectionResource);
+                } else if (resource.kind === ResourceKind.GROUP) {
+                    ancestors.unshift({
+                        uuid, kind: resource.kind,
+                        ownerUuid: resource.ownerUuid,
+                    } as GroupResource);
+                }
+                uuid = resource.ownerUuid;
+            }
+            return ancestors;
+        });
+
+        services.collectionService.files = jest.fn(async (uuid): Promise<(CollectionDirectory | CollectionFile)[]> => {
+            return fakeResources[uuid]?.files || [];
+        });
+
+        services.groupsService.contents = jest.fn(async (uuid, args) => {
+            const items = Object.keys(fakeResources).map(uuid => ({...fakeResources[uuid], uuid})).filter(item => item.ownerUuid === uuid);
+            return {items: items as GroupContentsResource[], itemsAvailable: items.length} as ListResults<GroupContentsResource>;
+        });
+
+        const pickerId = "pickerId";
+
+        // When collection preselected
+        await initProjectsTreePicker(pickerId, {
+            selectedItemUuids: [emptyCollectionUuid],
+            includeDirectories: true,
+            includeFiles: false,
+            multi: true,
+        })(dispatchWrapper, store.getState, services);
+
+        // Expect ancestor service to be called
+        expect(services.ancestorsService.ancestors).toHaveBeenCalledWith(emptyCollectionUuid, '');
+        // Expect top level to be expanded and node to be selected
+        expect(store.getState().treePicker["pickerId_shared"][SHARED_PROJECT_ID].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][emptyCollectionUuid].selected).toBe(true);
+
+
+        // When collection subdirectory is preselected
+        await initProjectsTreePicker(pickerId, {
+            selectedItemUuids: [`${collectionUuid}/directory`],
+            includeDirectories: true,
+            includeFiles: false,
+            multi: true,
+        })(dispatchWrapper, store.getState, services);
+
+        // Expect ancestor service to be called
+        expect(services.ancestorsService.ancestors).toHaveBeenCalledWith(collectionUuid, '');
+        // Expect top level to be expanded and node to be selected
+        expect(store.getState().treePicker["pickerId_shared"][SHARED_PROJECT_ID].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][collectionUuid].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][collectionUuid].selected).toBe(false);
+        expect(store.getState().treePicker["pickerId_shared"][`${collectionUuid}/directory`].selected).toBe(true);
+
+
+        // When subdirectory of collection inside project is preselected
+        await initProjectsTreePicker(pickerId, {
+            selectedItemUuids: [`${childCollectionUuid}/mainDir/subDir`],
+            includeDirectories: true,
+            includeFiles: false,
+            multi: true,
+        })(dispatchWrapper, store.getState, services);
+
+        // Expect ancestor service to be called
+        expect(services.ancestorsService.ancestors).toHaveBeenCalledWith(childCollectionUuid, '');
+        // Expect parent project and collection to be expanded
+        expect(store.getState().treePicker["pickerId_shared"][SHARED_PROJECT_ID].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][parentProjectUuid].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][parentProjectUuid].selected).toBe(false);
+        expect(store.getState().treePicker["pickerId_shared"][childCollectionUuid].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][childCollectionUuid].selected).toBe(false);
+        // Expect main directory to be expanded
+        expect(store.getState().treePicker["pickerId_shared"][`${childCollectionUuid}/mainDir`].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][`${childCollectionUuid}/mainDir`].selected).toBe(false);
+        // Expect sub directory to be selected
+        expect(store.getState().treePicker["pickerId_shared"][`${childCollectionUuid}/mainDir/subDir`].expanded).toBe(false);
+        expect(store.getState().treePicker["pickerId_shared"][`${childCollectionUuid}/mainDir/subDir`].selected).toBe(true);
+
+
+    });
+});
index 460a23e3d778c0d3670e5d360ef06bdb00a0f408..883847d85464e7f374504118c50bba935872de4f 100644 (file)
@@ -3,8 +3,8 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { unionize, ofType, UnionOf } from "common/unionize";
-import { TreeNode, initTreeNode, getNodeDescendants, TreeNodeStatus, getNode, TreePickerId, Tree } from 'models/tree';
-import { createCollectionFilesTree } from "models/collection-file";
+import { TreeNode, initTreeNode, getNodeDescendants, TreeNodeStatus, getNode, TreePickerId, Tree, setNode, createTree } from 'models/tree';
+import { CollectionFileType, createCollectionFilesTree, getCollectionResourceCollectionUuid } from "models/collection-file";
 import { Dispatch } from 'redux';
 import { RootState } from 'store/store';
 import { getUserUuid } from "common/getuser";
@@ -22,6 +22,10 @@ import { LinkResource, LinkClass } from "models/link";
 import { mapTreeValues } from "models/tree";
 import { sortFilesTree } from "services/collection-service/collection-service-files-response";
 import { GroupClass, GroupResource } from "models/group";
+import { CollectionResource } from "models/collection";
+import { getResource } from "store/resources/resources";
+import { updateResources } from "store/resources/resources-actions";
+import { SnackbarKind, snackbarActions } from "store/snackbar/snackbar-actions";
 
 export const treePickerActions = unionize({
     LOAD_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
@@ -29,11 +33,12 @@ export const treePickerActions = unionize({
     APPEND_TREE_PICKER_NODE_SUBTREE: ofType<{ id: string, subtree: Tree<any>, pickerId: string }>(),
     TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ id: string, pickerId: string }>(),
     EXPAND_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
+    EXPAND_TREE_PICKER_NODE_ANCESTORS: ofType<{ id: string, pickerId: string }>(),
     ACTIVATE_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string, relatedTreePickers?: string[] }>(),
     DEACTIVATE_TREE_PICKER_NODE: ofType<{ pickerId: string }>(),
-    TOGGLE_TREE_PICKER_NODE_SELECTION: ofType<{ id: string, pickerId: string }>(),
-    SELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string }>(),
-    DESELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string }>(),
+    TOGGLE_TREE_PICKER_NODE_SELECTION: ofType<{ id: string, pickerId: string, cascade: boolean }>(),
+    SELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string, cascade: boolean }>(),
+    DESELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string, cascade: boolean }>(),
     EXPAND_TREE_PICKER_NODES: ofType<{ ids: string[], pickerId: string }>(),
     RESET_TREE_PICKER: ofType<{ pickerId: string }>()
 });
@@ -42,6 +47,7 @@ export type TreePickerAction = UnionOf<typeof treePickerActions>;
 
 export interface LoadProjectParams {
     includeCollections?: boolean;
+    includeDirectories?: boolean;
     includeFiles?: boolean;
     includeFilterGroups?: boolean;
     options?: { showOnlyOwned: boolean; showOnlyWritable: boolean; };
@@ -51,6 +57,7 @@ export const treePickerSearchActions = unionize({
     SET_TREE_PICKER_PROJECT_SEARCH: ofType<{ pickerId: string, projectSearchValue: string }>(),
     SET_TREE_PICKER_COLLECTION_FILTER: ofType<{ pickerId: string, collectionFilterValue: string }>(),
     SET_TREE_PICKER_LOAD_PARAMS: ofType<{ pickerId: string, params: LoadProjectParams }>(),
+    REFRESH_TREE_PICKER: ofType<{ pickerId: string }>(),
 });
 
 export type TreePickerSearchAction = UnionOf<typeof treePickerSearchActions>;
@@ -86,14 +93,31 @@ export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Va
 export const getSelectedNodes = <Value>(pickerId: string) => (state: TreePicker) =>
     getAllNodes<Value>(pickerId, node => node.selected)(state);
 
-export const initProjectsTreePicker = (pickerId: string) =>
-    async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => {
+interface TreePickerPreloadParams {
+    selectedItemUuids: string[];
+    includeDirectories: boolean;
+    includeFiles: boolean;
+    multi: boolean;
+}
+
+export const initProjectsTreePicker = (pickerId: string, preloadParams?: TreePickerPreloadParams) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId);
         dispatch<any>(initUserProject(home));
         dispatch<any>(initSharedProject(shared));
         dispatch<any>(initFavoritesProject(favorites));
         dispatch<any>(initPublicFavoritesProject(publicFavorites));
         dispatch<any>(initSearchProject(search));
+
+        if (preloadParams && preloadParams.selectedItemUuids.length) {
+            await dispatch<any>(loadInitialValue(
+                preloadParams.selectedItemUuids,
+                pickerId,
+                preloadParams.includeDirectories,
+                preloadParams.includeFiles,
+                preloadParams.multi
+            ));
+        }
     };
 
 interface ReceiveTreePickerDataParams<T> {
@@ -121,9 +145,23 @@ interface LoadProjectParamsWithId extends LoadProjectParams {
     searchProjects?: boolean;
 }
 
+/**
+ * loadProject is used to load or refresh a project node in a tree picker
+ *   Errors are caught and a toast is shown if the project fails to load
+ */
 export const loadProject = (params: LoadProjectParamsWithId) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const { id, pickerId, includeCollections = false, includeFiles = false, includeFilterGroups = false, loadShared = false, options, searchProjects = false } = params;
+        const {
+            id,
+            pickerId,
+            includeCollections = false,
+            includeDirectories = false,
+            includeFiles = false,
+            includeFilterGroups = false,
+            loadShared = false,
+            options,
+            searchProjects = false
+        } = params;
 
         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
 
@@ -149,59 +187,65 @@ export const loadProject = (params: LoadProjectParamsWithId) =>
 
         const itemLimit = 200;
 
-        const { items, itemsAvailable } = await services.groupsService.contents((loadShared || searchProjects) ? '' : id, { filters, excludeHomeProject: loadShared || undefined, limit: itemLimit });
-
-        if (itemsAvailable > itemLimit) {
-            items.push({
-                uuid: "more-items-available",
-                kind: ResourceKind.WORKFLOW,
-                name: `*** Not all items listed (${items.length} out of ${itemsAvailable}), reduce item count with search or filter ***`,
-                description: "",
-                definition: "",
-                ownerUuid: "",
-                createdAt: "",
-                modifiedByClientUuid: "",
-                modifiedByUserUuid: "",
-                modifiedAt: "",
-                href: "",
-                etag: ""
-            });
-        }
-
-        dispatch<any>(receiveTreePickerData<GroupContentsResource>({
-            id,
-            pickerId,
-            data: items.filter((item) => {
-                if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
-                    return false;
-                }
+        try {
+            const { items, itemsAvailable } = await services.groupsService.contents((loadShared || searchProjects) ? '' : id, { filters, excludeHomeProject: loadShared || undefined, limit: itemLimit });
+            dispatch<any>(updateResources(items));
+
+            if (itemsAvailable > itemLimit) {
+                items.push({
+                    uuid: "more-items-available",
+                    kind: ResourceKind.WORKFLOW,
+                    name: `*** Not all items listed (${items.length} out of ${itemsAvailable}), reduce item count with search or filter ***`,
+                    description: "",
+                    definition: "",
+                    ownerUuid: "",
+                    createdAt: "",
+                    modifiedByClientUuid: "",
+                    modifiedByUserUuid: "",
+                    modifiedAt: "",
+                    href: "",
+                    etag: ""
+                });
+            }
 
-                if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
-                    return false;
-                }
+            dispatch<any>(receiveTreePickerData<GroupContentsResource>({
+                id,
+                pickerId,
+                data: items.filter((item) => {
+                    if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
+                        return false;
+                    }
 
-                return true;
-            }),
-            extractNodeData: item => (
-                item.uuid === "more-items-available" ?
-                    {
-                        id: item.uuid,
-                        value: item,
-                        status: TreeNodeStatus.LOADED
+                    if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
+                        return false;
                     }
-                    : {
-                        id: item.uuid,
-                        value: item,
-                        status: item.kind === ResourceKind.PROJECT
-                            ? TreeNodeStatus.INITIAL
-                            : includeFiles
+
+                    return true;
+                }),
+                extractNodeData: item => (
+                    item.uuid === "more-items-available" ?
+                        {
+                            id: item.uuid,
+                            value: item,
+                            status: TreeNodeStatus.LOADED
+                        }
+                        : {
+                            id: item.uuid,
+                            value: item,
+                            status: item.kind === ResourceKind.PROJECT
                                 ? TreeNodeStatus.INITIAL
-                                : TreeNodeStatus.LOADED
-                    }),
-        }));
+                                : includeDirectories || includeFiles
+                                    ? TreeNodeStatus.INITIAL
+                                    : TreeNodeStatus.LOADED
+                        }),
+            }));
+        } catch(e) {
+            console.error("Failed to load project into tree picker:", e);;
+            dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Failed to load project`, kind: SnackbarKind.ERROR }));
+        }
     };
 
-export const loadCollection = (id: string, pickerId: string) =>
+export const loadCollection = (id: string, pickerId: string, includeDirectories?: boolean, includeFiles?: boolean) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
 
@@ -210,24 +254,30 @@ export const loadCollection = (id: string, pickerId: string) =>
 
             const node = getNode(id)(picker);
             if (node && 'kind' in node.value && node.value.kind === ResourceKind.COLLECTION) {
-                const files = await services.collectionService.files(node.value.uuid);
+                const files = (await services.collectionService.files(node.value.uuid))
+                    .filter((file) => (
+                        (includeFiles) ||
+                        (includeDirectories && file.type === CollectionFileType.DIRECTORY)
+                    ));
                 const tree = createCollectionFilesTree(files);
                 const sorted = sortFilesTree(tree);
                 const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted);
 
-                dispatch(
+                // await tree modifications so that consumers can guarantee node presence
+                await dispatch(
                     treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
                         id,
                         pickerId,
                         subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree)
                     }));
 
+                // Expand collection root node
                 dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
             }
         }
     };
 
-
+export const HOME_PROJECT_ID = 'Home Projects';
 export const initUserProject = (pickerId: string) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         const uuid = getUserUuid(getState());
@@ -235,7 +285,7 @@ export const initUserProject = (pickerId: string) =>
             dispatch(receiveTreePickerData({
                 id: '',
                 pickerId,
-                data: [{ uuid, name: 'Home Projects' }],
+                data: [{ uuid, name: HOME_PROJECT_ID }],
                 extractNodeData: value => ({
                     id: value.uuid,
                     status: TreeNodeStatus.INITIAL,
@@ -244,11 +294,11 @@ export const initUserProject = (pickerId: string) =>
             }));
         }
     };
-export const loadUserProject = (pickerId: string, includeCollections = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) =>
+export const loadUserProject = (pickerId: string, includeCollections = false, includeDirectories = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         const uuid = getUserUuid(getState());
         if (uuid) {
-            dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeFiles, options }));
+            dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeDirectories, includeFiles, options }));
         }
     };
 
@@ -267,6 +317,134 @@ export const initSharedProject = (pickerId: string) =>
         }));
     };
 
+type PickerItemPreloadData = {
+    itemId: string;
+    mainItemUuid: string;
+    ancestors: (GroupResource | CollectionResource)[];
+    isHomeProjectItem: boolean;
+}
+
+type PickerTreePreloadData = {
+    tree: Tree<GroupResource | CollectionResource>;
+    pickerTreeId: string;
+    pickerTreeRootUuid: string;
+};
+
+export const loadInitialValue = (pickerItemIds: string[], pickerId: string, includeDirectories: boolean, includeFiles: boolean, multi: boolean,) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const homeUuid = getUserUuid(getState());
+
+        // Request ancestor trees in paralell and save home project status
+        const pickerItemsData: PickerItemPreloadData[] = await Promise.allSettled(pickerItemIds.map(async itemId => {
+            const mainItemUuid = itemId.includes('/') ? itemId.split('/')[0] : itemId;
+
+            const ancestors = (await services.ancestorsService.ancestors(mainItemUuid, ''))
+            .filter(item =>
+                item.kind === ResourceKind.GROUP ||
+                item.kind === ResourceKind.COLLECTION
+            ) as (GroupResource | CollectionResource)[];
+
+            if (ancestors.length === 0) {
+                return Promise.reject({item: itemId});
+            }
+
+            const isHomeProjectItem = !!(homeUuid && ancestors.some(item => item.ownerUuid === homeUuid));
+
+            return {
+                itemId,
+                mainItemUuid,
+                ancestors,
+                isHomeProjectItem,
+            };
+        })).then((res) => {
+            // Show toast if any selections failed to restore
+            const rejectedPromises = res.filter((promiseResult): promiseResult is PromiseRejectedResult => (promiseResult.status === 'rejected'));
+            if (rejectedPromises.length) {
+                rejectedPromises.forEach(item => {
+                    console.error("The following item failed to load into the tree picker", item.reason);
+                });
+                dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Some selections failed to load and were removed. See console for details.`, kind: SnackbarKind.ERROR }));
+            }
+            // Filter out any failed promises and map to resulting preload data with ancestors
+            return res.filter((promiseResult): promiseResult is PromiseFulfilledResult<PickerItemPreloadData> => (
+                promiseResult.status === 'fulfilled'
+            )).map(res => res.value)
+        });
+
+        // Group items to preload / ancestor data by home/shared picker and create initial Trees to preload
+        const initialTreePreloadData: PickerTreePreloadData[] = [
+            pickerItemsData.filter((item) => item.isHomeProjectItem),
+            pickerItemsData.filter((item) => !item.isHomeProjectItem),
+        ]
+            .filter((items) => items.length > 0)
+            .map((itemGroup) =>
+                itemGroup.reduce(
+                    (preloadTree, itemData) => ({
+                        tree: createInitialPickerTree(
+                            itemData.ancestors,
+                            itemData.mainItemUuid,
+                            preloadTree.tree
+                        ),
+                        pickerTreeId: getPickerItemTreeId(itemData, homeUuid, pickerId),
+                        pickerTreeRootUuid: getPickerItemRootUuid(itemData, homeUuid),
+                    }),
+                    {
+                        tree: createTree<GroupResource | CollectionResource>(),
+                        pickerTreeId: '',
+                        pickerTreeRootUuid: '',
+                    } as PickerTreePreloadData
+                )
+            );
+
+        // Load initial trees into corresponding picker store
+        await Promise.all(initialTreePreloadData.map(preloadTree => (
+            dispatch(
+                treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
+                    id: preloadTree.pickerTreeRootUuid,
+                    pickerId: preloadTree.pickerTreeId,
+                    subtree: preloadTree.tree,
+                })
+            )
+        )));
+
+        // Await loading collection before attempting to select items
+        await Promise.all(pickerItemsData.map(async itemData => {
+            const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
+
+            // Selected item resides in collection subpath
+            if (itemData.itemId.includes('/')) {
+                // Load collection into tree
+                // loadCollection includes more than dispatched actions and must be awaited
+                await dispatch(loadCollection(itemData.mainItemUuid, pickerTreeId, includeDirectories, includeFiles));
+            }
+            // Expand nodes down to destination
+            dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE_ANCESTORS({ id: itemData.itemId, pickerId: pickerTreeId }));
+        }));
+
+        // Select or activate nodes
+        pickerItemsData.forEach(itemData => {
+            const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
+
+            if (multi) {
+                dispatch(treePickerActions.SELECT_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId, cascade: false}));
+            } else {
+                dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId }));
+            }
+        });
+
+        // Refresh triggers loading in all adjacent items that were not included in the ancestor tree
+        await initialTreePreloadData.map(preloadTree => dispatch(treePickerSearchActions.REFRESH_TREE_PICKER({ pickerId: preloadTree.pickerTreeId })));
+    }
+
+const getPickerItemTreeId = (itemData: PickerItemPreloadData, homeUuid: string | undefined, pickerId: string) => {
+    const { home, shared } = getProjectsTreePickerIds(pickerId);
+    return ((itemData.isHomeProjectItem && homeUuid) ? home : shared);
+};
+
+const getPickerItemRootUuid = (itemData: PickerItemPreloadData, homeUuid: string | undefined) => {
+    return (itemData.isHomeProjectItem && homeUuid) ? homeUuid : SHARED_PROJECT_ID;
+};
+
 export const FAVORITES_PROJECT_ID = 'Favorites';
 export const initFavoritesProject = (pickerId: string) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
@@ -316,6 +494,7 @@ export const initSearchProject = (pickerId: string) =>
 interface LoadFavoritesProjectParams {
     pickerId: string;
     includeCollections?: boolean;
+    includeDirectories?: boolean;
     includeFiles?: boolean;
     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
 }
@@ -323,7 +502,7 @@ interface LoadFavoritesProjectParams {
 export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
     options: { showOnlyOwned: boolean, showOnlyWritable: boolean } = { showOnlyOwned: true, showOnlyWritable: false }) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        const { pickerId, includeCollections = false, includeFiles = false } = params;
+        const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
         const uuid = getUserUuid(getState());
         if (uuid) {
             const filters = pipe(
@@ -339,7 +518,7 @@ export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
                 id: 'Favorites',
                 pickerId,
                 data: items.filter((item) => {
-                    if (options.showOnlyWritable && (item as GroupResource).writableBy && (item as GroupResource).writableBy.indexOf(uuid) === -1) {
+                    if (options.showOnlyWritable && !(item as GroupResource).canWrite) {
                         return false;
                     }
 
@@ -354,7 +533,7 @@ export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
                     value: item,
                     status: item.kind === ResourceKind.PROJECT
                         ? TreeNodeStatus.INITIAL
-                        : includeFiles
+                        : includeDirectories || includeFiles
                             ? TreeNodeStatus.INITIAL
                             : TreeNodeStatus.LOADED
                 }),
@@ -364,7 +543,7 @@ export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
 
 export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        const { pickerId, includeCollections = false, includeFiles = false } = params;
+        const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params;
         const uuidPrefix = getState().auth.config.uuidPrefix;
         const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`;
 
@@ -395,7 +574,7 @@ export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) =
                 value: item,
                 status: item.headKind === ResourceKind.PROJECT
                     ? TreeNodeStatus.INITIAL
-                    : includeFiles
+                    : includeDirectories || includeFiles
                         ? TreeNodeStatus.INITIAL
                         : TreeNodeStatus.LOADED
             }),
@@ -466,3 +645,74 @@ const buildParams = (ownerUuid: string) => {
             .getOrder()
     };
 };
+
+/**
+ * Given a tree picker item, return collection uuid and path
+ *   if the item represents a valid target/destination location
+ */
+export type FileOperationLocation = {
+    name: string;
+    uuid: string;
+    pdh?: string;
+    subpath: string;
+}
+export const getFileOperationLocation = (item: ProjectsTreePickerItem) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<FileOperationLocation | undefined> => {
+        if ('kind' in item && item.kind === ResourceKind.COLLECTION) {
+            return {
+                name: item.name,
+                uuid: item.uuid,
+                pdh: item.portableDataHash,
+                subpath: '/',
+            };
+        } else if ('type' in item && item.type === CollectionFileType.DIRECTORY) {
+            const uuid = getCollectionResourceCollectionUuid(item.id);
+            if (uuid) {
+                const collection = getResource<CollectionResource>(uuid)(getState().resources);
+                if (collection) {
+                    const itemPath = [item.path, item.name].join('/');
+
+                    return {
+                        name: item.name,
+                        uuid,
+                        pdh: collection.portableDataHash,
+                        subpath: itemPath,
+                    };
+                }
+            }
+        }
+        return undefined;
+    };
+
+/**
+ * Create an expanded tree picker subtree from array of nested projects/collection
+ *   First item is assumed to be root and gets empty parent id
+ *   Nodes must be sorted from top down to prevent orphaned nodes
+ */
+export const createInitialPickerTree = (sortedAncestors: Array<GroupResource | CollectionResource>, tailUuid: string, initialTree: Tree<GroupResource | CollectionResource>) => {
+    return sortedAncestors
+        .reduce((tree, item, index) => {
+            if (getNode(item.uuid)(tree)) {
+                return tree;
+            } else {
+                return setNode({
+                    children: [],
+                    id: item.uuid,
+                    parent: index === 0 ? '' : item.ownerUuid,
+                    value: item,
+                    active: false,
+                    selected: false,
+                    expanded: false,
+                    status: item.uuid !== tailUuid ? TreeNodeStatus.LOADED : TreeNodeStatus.INITIAL,
+                })(tree);
+            }
+        }, initialTree);
+};
+
+export const fileOperationLocationToPickerId = (location: FileOperationLocation): string => {
+    let id = location.uuid;
+    if (location.subpath.length && location.subpath !== '/') {
+        id = id + location.subpath;
+    }
+    return id;
+}
index 8fa3ee4a162c0de98ac8a70e34eb251ae4d452d4..6f748a99b47cf2ab431973237d73f56fbcfbcab0 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Dispatch } from 'redux';
+import { Dispatch, MiddlewareAPI } from 'redux';
 import { RootState } from 'store/store';
 import { ServiceRepository } from 'services/services';
 import { Middleware } from "redux";
@@ -37,6 +37,8 @@ export const treePickerSearchMiddleware: Middleware = store => next => action =>
             isSearchAction = true;
             searchChanged = store.getState().treePickerSearch.collectionFilterValues[pickerId] !== collectionFilterValue;
         },
+
+        REFRESH_TREE_PICKER: refreshPickers(store),
         default: () => { }
     });
 
@@ -62,57 +64,59 @@ export const treePickerSearchMiddleware: Middleware = store => next => action =>
                 }
             }),
 
-        SET_TREE_PICKER_COLLECTION_FILTER: ({ pickerId }) =>
-            store.dispatch<any>((dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-                const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(getState().treePicker);
-                if (picker) {
-                    const loadParams = getState().treePickerSearch.loadProjectParams[pickerId];
-                    getNodeDescendantsIds('')(picker)
-                        .map(id => {
-                            const node = getNode(id)(picker);
-                            if (node && node.status !== TreeNodeStatus.INITIAL) {
-                                if (node.id.substring(6, 11) === 'tpzed' || node.id.substring(6, 11) === 'j7d0g') {
-                                    dispatch<any>(loadProject({
-                                        ...loadParams,
-                                        id: node.id,
-                                        pickerId: pickerId,
-                                    }));
-                                }
-                                if (node.id === SHARED_PROJECT_ID) {
-                                    dispatch<any>(loadProject({
-                                        ...loadParams,
-                                        id: node.id,
-                                        pickerId: pickerId,
-                                        loadShared: true
-                                    }));
-                                }
-                                if (node.id === SEARCH_PROJECT_ID) {
-                                    dispatch<any>(loadProject({
-                                        ...loadParams,
-                                        id: node.id,
-                                        pickerId: pickerId,
-                                        searchProjects: true
-                                    }));
-                                }
-                                if (node.id === FAVORITES_PROJECT_ID) {
-                                    dispatch<any>(loadFavoritesProject({
-                                        ...loadParams,
-                                        pickerId: pickerId,
-                                    }));
-                                }
-                                if (node.id === PUBLIC_FAVORITES_PROJECT_ID) {
-                                    dispatch<any>(loadPublicFavoritesProject({
-                                        ...loadParams,
-                                        pickerId: pickerId,
-                                    }));
-                                }
-                            }
-                            return id;
-                        });
-                }
-            }),
+        SET_TREE_PICKER_COLLECTION_FILTER: refreshPickers(store),
         default: () => { }
     });
 
     return r;
 }
+
+const refreshPickers = (store: MiddlewareAPI) => ({ pickerId }) =>
+    store.dispatch<any>((dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const picker = getTreePicker<ProjectsTreePickerItem>(pickerId)(getState().treePicker);
+        if (picker) {
+            const loadParams = getState().treePickerSearch.loadProjectParams[pickerId];
+            getNodeDescendantsIds('')(picker)
+                .map(id => {
+                    const node = getNode(id)(picker);
+                    if (node && node.status !== TreeNodeStatus.INITIAL) {
+                        if (node.id.substring(6, 11) === 'tpzed' || node.id.substring(6, 11) === 'j7d0g') {
+                            dispatch<any>(loadProject({
+                                ...loadParams,
+                                id: node.id,
+                                pickerId: pickerId,
+                            }));
+                        }
+                        if (node.id === SHARED_PROJECT_ID) {
+                            dispatch<any>(loadProject({
+                                ...loadParams,
+                                id: node.id,
+                                pickerId: pickerId,
+                                loadShared: true
+                            }));
+                        }
+                        if (node.id === SEARCH_PROJECT_ID) {
+                            dispatch<any>(loadProject({
+                                ...loadParams,
+                                id: node.id,
+                                pickerId: pickerId,
+                                searchProjects: true
+                            }));
+                        }
+                        if (node.id === FAVORITES_PROJECT_ID) {
+                            dispatch<any>(loadFavoritesProject({
+                                ...loadParams,
+                                pickerId: pickerId,
+                            }));
+                        }
+                        if (node.id === PUBLIC_FAVORITES_PROJECT_ID) {
+                            dispatch<any>(loadPublicFavoritesProject({
+                                ...loadParams,
+                                pickerId: pickerId,
+                            }));
+                        }
+                    }
+                    return id;
+                });
+        }
+    })
index 25973bf6b5945e1505bc15a527dc8827feafcb83..2a5229ca5b567707665c9c189452370fcdda221e 100644 (file)
@@ -93,7 +93,7 @@ describe('TreePickerReducer', () => {
         const newState = pipe(
             (state: TreePicker) => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node], pickerId: "projects" })),
             state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '1', nodes: [subNode], pickerId: "projects" })),
-            state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECTION({ id: '1.1', pickerId: "projects" })),
+            state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECTION({ id: '1.1', pickerId: "projects", cascade: true })),
         )({ projects: createTree<{}>() });
         expect(getNode('1')(newState.projects)).toEqual({
             ...initTreeNode({ id: '1', value: '1' }),
index df0ee0ad167376af2eec2a81293ade246714ba96..84d5ed0ca729013f9d9215d1c7dcffd21f1730ed 100644 (file)
@@ -5,7 +5,7 @@
 import {
     createTree, TreeNode, setNode, Tree, TreeNodeStatus, setNodeStatus,
     expandNode, deactivateNode, selectNodes, deselectNodes,
-    activateNode, getNode, toggleNodeCollapse, toggleNodeSelection, appendSubtree
+    activateNode, getNode, toggleNodeCollapse, toggleNodeSelection, appendSubtree, expandNodeAncestors
 } from 'models/tree';
 import { TreePicker } from "./tree-picker";
 import { treePickerActions, treePickerSearchActions, TreePickerAction, TreePickerSearchAction, LoadProjectParams } from "./tree-picker-actions";
@@ -29,6 +29,9 @@ export const treePickerReducer = (state: TreePicker = {}, action: TreePickerActi
         EXPAND_TREE_PICKER_NODE: ({ id, pickerId }) =>
             updateOrCreatePicker(state, pickerId, expandNode(id)),
 
+        EXPAND_TREE_PICKER_NODE_ANCESTORS: ({ id, pickerId }) =>
+            updateOrCreatePicker(state, pickerId, expandNodeAncestors(id)),
+
         ACTIVATE_TREE_PICKER_NODE: ({ id, pickerId, relatedTreePickers = [] }) =>
             pipe(
                 () => relatedTreePickers.reduce(
@@ -41,14 +44,14 @@ export const treePickerReducer = (state: TreePicker = {}, action: TreePickerActi
         DEACTIVATE_TREE_PICKER_NODE: ({ pickerId }) =>
             updateOrCreatePicker(state, pickerId, deactivateNode),
 
-        TOGGLE_TREE_PICKER_NODE_SELECTION: ({ id, pickerId }) =>
-            updateOrCreatePicker(state, pickerId, toggleNodeSelection(id)),
+        TOGGLE_TREE_PICKER_NODE_SELECTION: ({ id, pickerId, cascade }) =>
+            updateOrCreatePicker(state, pickerId, toggleNodeSelection(id, cascade)),
 
-        SELECT_TREE_PICKER_NODE: ({ id, pickerId }) =>
-            updateOrCreatePicker(state, pickerId, selectNodes(id)),
+        SELECT_TREE_PICKER_NODE: ({ id, pickerId, cascade }) =>
+            updateOrCreatePicker(state, pickerId, selectNodes(id, cascade)),
 
-        DESELECT_TREE_PICKER_NODE: ({ id, pickerId }) =>
-            updateOrCreatePicker(state, pickerId, deselectNodes(id)),
+        DESELECT_TREE_PICKER_NODE: ({ id, pickerId, cascade }) =>
+            updateOrCreatePicker(state, pickerId, deselectNodes(id, cascade)),
 
         RESET_TREE_PICKER: ({ pickerId }) =>
             updateOrCreatePicker(state, pickerId, createTree),
index 9935518b98286e083ca29df33f8390a03e5e23b8..44b17c6045b2c6f9fc790f0ea27d270ac5079232 100644 (file)
@@ -29,149 +29,149 @@ export const getCurrentUserProfilePanelUuid = getProperty<string>(USER_PROFILE_P
 export const getUserProfileIsInaccessible = getProperty<boolean>(IS_PROFILE_INACCESSIBLE);
 
 export const loadUserProfilePanel = (userUuid?: string) =>
-  async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-    // Reset isInacessible to ensure error screen is hidden
-    dispatch(propertiesActions.SET_PROPERTY({ key: IS_PROFILE_INACCESSIBLE, value: false }));
-    // Get user uuid from route or use current user uuid
-    const uuid = userUuid || getState().auth.user?.uuid;
-    if (uuid) {
-      await dispatch(propertiesActions.SET_PROPERTY({ key: USER_PROFILE_PANEL_ID, value: uuid }));
-      try {
-        const user = await services.userService.get(uuid, false);
-        dispatch(initialize(USER_PROFILE_FORM, user));
-        dispatch(updateResources([user]));
-        dispatch(UserProfileGroupsActions.REQUEST_ITEMS());
-      } catch (e) {
-        if (e.status === 404) {
-          await dispatch(propertiesActions.SET_PROPERTY({ key: IS_PROFILE_INACCESSIBLE, value: true }));
-          dispatch(reset(USER_PROFILE_FORM));
-        } else {
-          dispatch(snackbarActions.OPEN_SNACKBAR({
-            message: 'Could not load user profile',
-            kind: SnackbarKind.ERROR
-          }));
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        // Reset isInacessible to ensure error screen is hidden
+        dispatch(propertiesActions.SET_PROPERTY({ key: IS_PROFILE_INACCESSIBLE, value: false }));
+        // Get user uuid from route or use current user uuid
+        const uuid = userUuid || getState().auth.user?.uuid;
+        if (uuid) {
+            await dispatch(propertiesActions.SET_PROPERTY({ key: USER_PROFILE_PANEL_ID, value: uuid }));
+            try {
+                const user = await services.userService.get(uuid, false, ["uuid", "first_name", "last_name", "email", "username", "prefs", "is_admin", "is_active"]);
+                dispatch(initialize(USER_PROFILE_FORM, user));
+                dispatch(updateResources([user]));
+                dispatch(UserProfileGroupsActions.REQUEST_ITEMS());
+            } catch (e) {
+                if (e.status === 404) {
+                    await dispatch(propertiesActions.SET_PROPERTY({ key: IS_PROFILE_INACCESSIBLE, value: true }));
+                    dispatch(reset(USER_PROFILE_FORM));
+                } else {
+                    dispatch(snackbarActions.OPEN_SNACKBAR({
+                        message: 'Could not load user profile',
+                        kind: SnackbarKind.ERROR
+                    }));
+                }
+            }
         }
-      }
     }
-  }
 
 export const saveEditedUser = (resource: any) =>
-  async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-      try {
-          const user = await services.userService.update(resource.uuid, resource);
-          dispatch(updateResources([user]));
-          dispatch(initialize(USER_PROFILE_FORM, user));
-          dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Profile has been updated.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
-      } catch (e) {
-          dispatch(snackbarActions.OPEN_SNACKBAR({
-              message: "Could not update profile",
-              kind: SnackbarKind.ERROR,
-          }));
-      }
-  };
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            const user = await services.userService.update(resource.uuid, resource);
+            dispatch(updateResources([user]));
+            dispatch(initialize(USER_PROFILE_FORM, user));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Profile has been updated.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: "Could not update profile",
+                kind: SnackbarKind.ERROR,
+            }));
+        }
+    };
 
 export const openSetupDialog = (uuid: string) =>
-  (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-    dispatch(dialogActions.OPEN_DIALOG({
-      id: SETUP_DIALOG,
-      data: {
-        title: 'Setup user',
-        text: 'Are you sure you want to setup this user?',
-        confirmButtonLabel: 'Confirm',
-        uuid
-      }
-    }));
-  };
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: SETUP_DIALOG,
+            data: {
+                title: 'Setup user',
+                text: 'Are you sure you want to setup this user?',
+                confirmButtonLabel: 'Confirm',
+                uuid
+            }
+        }));
+    };
 
 export const openActivateDialog = (uuid: string) =>
-  (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-    dispatch(dialogActions.OPEN_DIALOG({
-      id: ACTIVATE_DIALOG,
-      data: {
-        title: 'Activate user',
-        text: 'Are you sure you want to activate this user?',
-        confirmButtonLabel: 'Confirm',
-        uuid
-      }
-    }));
-  };
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: ACTIVATE_DIALOG,
+            data: {
+                title: 'Activate user',
+                text: 'Are you sure you want to activate this user?',
+                confirmButtonLabel: 'Confirm',
+                uuid
+            }
+        }));
+    };
 
 export const openDeactivateDialog = (uuid: string) =>
-  (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-    dispatch(dialogActions.OPEN_DIALOG({
-      id: DEACTIVATE_DIALOG,
-      data: {
-        title: 'Deactivate user',
-        text: 'Are you sure you want to deactivate this user?',
-        confirmButtonLabel: 'Confirm',
-        uuid
-      }
-    }));
-  };
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: DEACTIVATE_DIALOG,
+            data: {
+                title: 'Deactivate user',
+                text: 'Are you sure you want to deactivate this user?',
+                confirmButtonLabel: 'Confirm',
+                uuid
+            }
+        }));
+    };
 
 export const setup = (uuid: string) =>
-  async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-    try {
-      const resources = await services.userService.setup(uuid);
-      dispatch(updateResources(resources.items));
-
-      // Refresh data explorer
-      dispatch(UserProfileGroupsActions.REQUEST_ITEMS());
-
-      dispatch(snackbarActions.OPEN_SNACKBAR({ message: "User has been setup", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
-    } catch (e) {
-      dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR }));
-    } finally {
-      dispatch(dialogActions.CLOSE_DIALOG({ id: SETUP_DIALOG }));
-    }
-  };
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            const resources = await services.userService.setup(uuid);
+            dispatch(updateResources(resources.items));
+
+            // Refresh data explorer
+            dispatch(UserProfileGroupsActions.REQUEST_ITEMS());
+
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "User has been setup", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        } finally {
+            dispatch(dialogActions.CLOSE_DIALOG({ id: SETUP_DIALOG }));
+        }
+    };
 
 export const activate = (uuid: string) =>
-  async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-    try {
-      const user = await services.userService.activate(uuid);
-      dispatch(updateResources([user]));
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            const user = await services.userService.activate(uuid);
+            dispatch(updateResources([user]));
 
-      // Refresh data explorer
-      dispatch(UserProfileGroupsActions.REQUEST_ITEMS());
+            // Refresh data explorer
+            dispatch(UserProfileGroupsActions.REQUEST_ITEMS());
 
-      dispatch(snackbarActions.OPEN_SNACKBAR({ message: "User has been activated", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
-    } catch (e) {
-      dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR }));
-    }
-  };
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "User has been activated", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        }
+    };
 
 export const deactivate = (uuid: string) =>
-  async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-    try {
-      const { resources, auth } = getState();
-      // Call unsetup
-      const user = await services.userService.unsetup(uuid);
-      dispatch(updateResources([user]));
-
-      // Find and remove all users membership
-      const allUsersGroupUuid = getBuiltinGroupUuid(auth.localCluster, BuiltinGroups.ALL);
-      const memberships = filterResources((resource: LinkResource) =>
-          resource.kind === ResourceKind.LINK &&
-          resource.linkClass === LinkClass.PERMISSION &&
-          resource.headUuid === allUsersGroupUuid &&
-          resource.tailUuid === uuid
-      )(resources);
-      // Remove all users membership locally
-      dispatch<any>(deleteResources(memberships.map(link => link.uuid)));
-
-      // Refresh data explorer
-      dispatch(UserProfileGroupsActions.REQUEST_ITEMS());
-
-      dispatch(snackbarActions.OPEN_SNACKBAR({
-        message: "User has been deactivated.",
-        hideDuration: 2000,
-        kind: SnackbarKind.SUCCESS
-      }));
-    } catch (e) {
-      dispatch(snackbarActions.OPEN_SNACKBAR({
-        message: "Could not deactivate user",
-        kind: SnackbarKind.ERROR,
-      }));
-    }
-  };
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            const { resources, auth } = getState();
+            // Call unsetup
+            const user = await services.userService.unsetup(uuid);
+            dispatch(updateResources([user]));
+
+            // Find and remove all users membership
+            const allUsersGroupUuid = getBuiltinGroupUuid(auth.localCluster, BuiltinGroups.ALL);
+            const memberships = filterResources((resource: LinkResource) =>
+                resource.kind === ResourceKind.LINK &&
+                resource.linkClass === LinkClass.PERMISSION &&
+                resource.headUuid === allUsersGroupUuid &&
+                resource.tailUuid === uuid
+            )(resources);
+            // Remove all users membership locally
+            dispatch<any>(deleteResources(memberships.map(link => link.uuid)));
+
+            // Refresh data explorer
+            dispatch(UserProfileGroupsActions.REQUEST_ITEMS());
+
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: "User has been deactivated.",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS
+            }));
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: "Could not deactivate user",
+                kind: SnackbarKind.ERROR,
+            }));
+        }
+    };
index e965cd00580f85ce20944d12d760f089b7984aec..b8b914c93e0c234dc354df2cb8d5242cf48701e9 100644 (file)
@@ -19,6 +19,7 @@ import { UserResource } from 'models/user';
 import { UserPanelColumnNames } from 'views/user-panel/user-panel';
 import { BuiltinGroups, getBuiltinGroupUuid } from 'models/group';
 import { LinkClass } from 'models/link';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
 
 export class UserMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
@@ -29,6 +30,7 @@ export class UserMiddlewareService extends DataExplorerMiddlewareService {
         const state = api.getState();
         const dataExplorer = getDataExplorer(state.dataExplorer, this.getId());
         try {
+            api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
             const users = await this.services.userService.list(getParams(dataExplorer));
             api.dispatch(updateResources(users.items));
             api.dispatch(setItems(users));
@@ -44,6 +46,8 @@ export class UserMiddlewareService extends DataExplorerMiddlewareService {
             api.dispatch(updateResources(allUserMemberships.items));
         } catch {
             api.dispatch(couldNotFetchUsers());
+        } finally {
+            api.dispatch(progressIndicatorActions.STOP_WORKING(this.getId()));
         }
     }
 }
@@ -70,6 +74,9 @@ const getOrder = (dataExplorer: DataExplorer) => {
         } else {
             order.addOrder(sortDirection, sortColumn.sort.field);
         }
+
+        // Use createdAt as a secondary sort column so we break ties consistently.
+        order.addOrder(OrderDirection.DESC, "createdAt");
     }
     return order.getOrder();
 };
index bd07efb6409f3f09368b10eaf60c479234257bdb..12172e7fe32d5739a0c78fee6cdb13a4b07cb10a 100644 (file)
@@ -19,6 +19,7 @@ import { deleteResources, updateResources } from 'store/resources/resources-acti
 import { Participant } from "views-components/sharing-dialog/participant-select";
 import { initialize, reset } from "redux-form";
 import { getUserDisplayName, UserResource } from "models/user";
+import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
 
 export const virtualMachinesActions = unionize({
     SET_REQUESTED_DATE: ofType<string>(),
@@ -72,50 +73,61 @@ const loadRequestedDate = () =>
 
 export const loadVirtualMachinesAdminData = () =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch<any>(loadRequestedDate());
-
-        const virtualMachines = await services.virtualMachineService.list();
-        dispatch(updateResources(virtualMachines.items));
-        dispatch(virtualMachinesActions.SET_VIRTUAL_MACHINES(virtualMachines));
-
-
-        const logins = await services.permissionService.list({
-            filters: new FilterBuilder()
-            .addIn('head_uuid', virtualMachines.items.map(item => item.uuid))
-            .addEqual('name', PermissionLevel.CAN_LOGIN)
-            .getFilters(),
-            limit: 1000
-        });
-        dispatch(updateResources(logins.items));
-        dispatch(virtualMachinesActions.SET_LINKS(logins));
-
-        const users = await services.userService.list({
-            filters: new FilterBuilder()
-            .addIn('uuid', logins.items.map(item => item.tailUuid))
-            .getFilters(),
-            count: "none", // Necessary for federated queries
-            limit: 1000
-        });
-        dispatch(updateResources(users.items));
-
-        const getAllLogins = await services.virtualMachineService.getAllLogins();
-        dispatch(virtualMachinesActions.SET_LOGINS(getAllLogins));
+        try {
+            dispatch(progressIndicatorActions.START_WORKING("virtual-machines-admin"));
+            dispatch<any>(loadRequestedDate());
+
+            const virtualMachines = await services.virtualMachineService.list();
+            dispatch(updateResources(virtualMachines.items));
+            dispatch(virtualMachinesActions.SET_VIRTUAL_MACHINES(virtualMachines));
+
+
+            const logins = await services.permissionService.list({
+                filters: new FilterBuilder()
+                    .addIn('head_uuid', virtualMachines.items.map(item => item.uuid))
+                    .addEqual('name', PermissionLevel.CAN_LOGIN)
+                    .getFilters(),
+                limit: 1000
+            });
+            dispatch(updateResources(logins.items));
+            dispatch(virtualMachinesActions.SET_LINKS(logins));
+
+            const users = await services.userService.list({
+                filters: new FilterBuilder()
+                    .addIn('uuid', logins.items.map(item => item.tailUuid))
+                    .getFilters(),
+                count: "none", // Necessary for federated queries
+                limit: 1000
+            });
+            dispatch(updateResources(users.items));
+
+            const getAllLogins = await services.virtualMachineService.getAllLogins();
+            dispatch(virtualMachinesActions.SET_LOGINS(getAllLogins));
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING("virtual-machines-admin"));
+        }
     };
 
 export const loadVirtualMachinesUserData = () =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch<any>(loadRequestedDate());
-        const user = getState().auth.user;
-        const virtualMachines = await services.virtualMachineService.list();
-        const virtualMachinesUuids = virtualMachines.items.map(it => it.uuid);
-        const links = await services.linkService.list({
-            filters: new FilterBuilder()
-                .addIn("head_uuid", virtualMachinesUuids)
-                .addEqual("tail_uuid", user?.uuid)
-                .getFilters()
-        });
-        dispatch(virtualMachinesActions.SET_VIRTUAL_MACHINES(virtualMachines));
-        dispatch(virtualMachinesActions.SET_LINKS(links));
+        try {
+            dispatch(progressIndicatorActions.START_WORKING("virtual-machines-user"));
+
+            dispatch<any>(loadRequestedDate());
+            const user = getState().auth.user;
+            const virtualMachines = await services.virtualMachineService.list();
+            const virtualMachinesUuids = virtualMachines.items.map(it => it.uuid);
+            const links = await services.linkService.list({
+                filters: new FilterBuilder()
+                    .addIn("head_uuid", virtualMachinesUuids)
+                    .addEqual("tail_uuid", user?.uuid)
+                    .getFilters()
+            });
+            dispatch(virtualMachinesActions.SET_VIRTUAL_MACHINES(virtualMachines));
+            dispatch(virtualMachinesActions.SET_LINKS(links));
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING("virtual-machines-user"));
+        }
     };
 
 export const openAddVirtualMachineLoginDialog = (vmUuid: string) =>
@@ -125,17 +137,17 @@ export const openAddVirtualMachineLoginDialog = (vmUuid: string) =>
         dispatch(updateResources(virtualMachines.items));
         const logins = await services.permissionService.list({
             filters: new FilterBuilder()
-            .addIn('head_uuid', virtualMachines.items.map(item => item.uuid))
-            .addEqual('name', PermissionLevel.CAN_LOGIN)
-            .getFilters()
+                .addIn('head_uuid', virtualMachines.items.map(item => item.uuid))
+                .addEqual('name', PermissionLevel.CAN_LOGIN)
+                .getFilters()
         });
         dispatch(updateResources(logins.items));
 
         dispatch(initialize(VIRTUAL_MACHINE_ADD_LOGIN_FORM, {
-                [VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD]: vmUuid,
-                [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: [],
-            }));
-        dispatch(dialogActions.OPEN_DIALOG( {id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: {excludedParticipants: logins.items.map(it => it.tailUuid)}} ));
+            [VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD]: vmUuid,
+            [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: [],
+        }));
+        dispatch(dialogActions.OPEN_DIALOG({ id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: { excludedParticipants: logins.items.map(it => it.tailUuid) } }));
     }
 
 export const openEditVirtualMachineLoginDialog = (permissionUuid: string) =>
@@ -143,11 +155,11 @@ export const openEditVirtualMachineLoginDialog = (permissionUuid: string) =>
         const login = await services.permissionService.get(permissionUuid);
         const user = await services.userService.get(login.tailUuid);
         dispatch(initialize(VIRTUAL_MACHINE_ADD_LOGIN_FORM, {
-                [VIRTUAL_MACHINE_UPDATE_LOGIN_UUID_FIELD]: permissionUuid,
-                [VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD]: {name: getUserDisplayName(user, true, true), uuid: login.tailUuid},
-                [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: login.properties.groups,
-            }));
-        dispatch(dialogActions.OPEN_DIALOG( {id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: {updating: true}} ));
+            [VIRTUAL_MACHINE_UPDATE_LOGIN_UUID_FIELD]: permissionUuid,
+            [VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD]: { name: getUserDisplayName(user, true, true), uuid: login.tailUuid },
+            [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: login.properties.groups,
+        }));
+        dispatch(dialogActions.OPEN_DIALOG({ id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: { updating: true } }));
     }
 
 export interface AddLoginFormData {
@@ -158,15 +170,15 @@ export interface AddLoginFormData {
 }
 
 
-export const addUpdateVirtualMachineLogin = ({uuid, vmUuid, user, groups}: AddLoginFormData) =>
+export const addUpdateVirtualMachineLogin = ({ uuid, vmUuid, user, groups }: AddLoginFormData) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         let userResource: UserResource | undefined = undefined;
         try {
             // Get user
             userResource = await services.userService.get(user.uuid, false);
         } catch (e) {
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Failed to get user details.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
-                return;
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Failed to get user details.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            return;
         }
         try {
             if (uuid) {
index 1cf71706420fc6c7be736b9d8e4282cdf94ace47..188dba05689edf9be63c7da67ed6c35c2db1df55 100644 (file)
@@ -2,31 +2,24 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Dispatch } from 'redux';
-import { RootState } from 'store/store';
-import { getUserUuid } from 'common/getuser';
-import { loadDetailsPanel } from 'store/details-panel/details-panel-action';
-import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
-import {
-    favoritePanelActions,
-    loadFavoritePanel,
-} from 'store/favorite-panel/favorite-panel-action';
-import {
-    getProjectPanelCurrentUuid,
-    openProjectPanel,
-    projectPanelActions,
-    setIsProjectPanelTrashed,
-} from 'store/project-panel/project-panel-action';
+import { Dispatch } from "redux";
+import { RootState } from "store/store";
+import { getUserUuid } from "common/getuser";
+import { loadDetailsPanel } from "store/details-panel/details-panel-action";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { favoritePanelActions, loadFavoritePanel } from "store/favorite-panel/favorite-panel-action";
+import { getProjectPanelCurrentUuid, setIsProjectPanelTrashed } from "store/project-panel/project-panel-action";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
 import {
     activateSidePanelTreeItem,
     initSidePanelTree,
     loadSidePanelTreeProjects,
     SidePanelTreeCategory,
-} from 'store/side-panel-tree/side-panel-tree-actions';
-import { updateResources } from 'store/resources/resources-actions';
-import { projectPanelColumns } from 'views/project-panel/project-panel';
-import { favoritePanelColumns } from 'views/favorite-panel/favorite-panel';
-import { matchRootRoute } from 'routes/routes';
+} from "store/side-panel-tree/side-panel-tree-actions";
+import { updateResources } from "store/resources/resources-actions";
+import { projectPanelColumns } from "views/project-panel/project-panel";
+import { favoritePanelColumns } from "views/favorite-panel/favorite-panel";
+import { matchRootRoute } from "routes/routes";
 import {
     setBreadcrumbs,
     setGroupDetailsBreadcrumbs,
@@ -38,222 +31,177 @@ import {
     setUsersBreadcrumbs,
     setMyAccountBreadcrumbs,
     setUserProfileBreadcrumbs,
-} from 'store/breadcrumbs/breadcrumbs-actions';
-import {
-    navigateTo,
-    navigateToRootProject,
-} from 'store/navigation/navigation-action';
-import { MoveToFormDialogData } from 'store/move-to-dialog/move-to-dialog';
-import { ServiceRepository } from 'services/services';
-import { getResource } from 'store/resources/resources';
-import * as projectCreateActions from 'store/projects/project-create-actions';
-import * as projectMoveActions from 'store/projects/project-move-actions';
-import * as projectUpdateActions from 'store/projects/project-update-actions';
-import * as collectionCreateActions from 'store/collections/collection-create-actions';
-import * as collectionCopyActions from 'store/collections/collection-copy-actions';
-import * as collectionMoveActions from 'store/collections/collection-move-actions';
-import * as processesActions from 'store/processes/processes-actions';
-import * as processMoveActions from 'store/processes/process-move-actions';
-import * as processUpdateActions from 'store/processes/process-update-actions';
-import * as processCopyActions from 'store/processes/process-copy-actions';
-import { trashPanelColumns } from 'views/trash-panel/trash-panel';
-import {
-    loadTrashPanel,
-    trashPanelActions,
-} from 'store/trash-panel/trash-panel-action';
-import { loadProcessPanel } from 'store/process-panel/process-panel-actions';
-import {
-    loadSharedWithMePanel,
-    sharedWithMePanelActions,
-} from 'store/shared-with-me-panel/shared-with-me-panel-actions';
-import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog';
-import { workflowPanelActions } from 'store/workflow-panel/workflow-panel-actions';
-import { loadSshKeysPanel } from 'store/auth/auth-action-ssh';
-import {
-    loadLinkAccountPanel,
-    linkAccountPanelActions,
-} from 'store/link-account-panel/link-account-panel-actions';
-import { loadSiteManagerPanel } from 'store/auth/auth-action-session';
-import { workflowPanelColumns } from 'views/workflow-panel/workflow-panel-view';
-import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
-import { getProgressIndicator } from 'store/progress-indicator/progress-indicator-reducer';
-import { extractUuidKind, ResourceKind } from 'models/resource';
-import { FilterBuilder } from 'services/api/filter-builder';
-import { GroupContentsResource } from 'services/groups-service/groups-service';
-import { MatchCases, ofType, unionize, UnionOf } from 'common/unionize';
-import { loadRunProcessPanel } from 'store/run-process-panel/run-process-panel-actions';
-import {
-    collectionPanelActions,
-    loadCollectionPanel,
-} from 'store/collection-panel/collection-panel-action';
-import { CollectionResource } from 'models/collection';
-import {
-    loadSearchResultsPanel,
-    searchResultsPanelActions,
-} from 'store/search-results-panel/search-results-panel-actions';
-import { searchResultsPanelColumns } from 'views/search-results-panel/search-results-panel-view';
-import { loadVirtualMachinesPanel } from 'store/virtual-machines/virtual-machines-actions';
-import { loadRepositoriesPanel } from 'store/repositories/repositories-actions';
-import { loadKeepServicesPanel } from 'store/keep-services/keep-services-actions';
-import { loadUsersPanel, userBindedActions } from 'store/users/users-actions';
-import * as userProfilePanelActions from 'store/user-profile/user-profile-actions';
-import {
-    linkPanelActions,
-    loadLinkPanel,
-} from 'store/link-panel/link-panel-actions';
-import { linkPanelColumns } from 'views/link-panel/link-panel-root';
-import { userPanelColumns } from 'views/user-panel/user-panel';
-import {
-    loadApiClientAuthorizationsPanel,
-    apiClientAuthorizationsActions,
-} from 'store/api-client-authorizations/api-client-authorizations-actions';
-import { apiClientAuthorizationPanelColumns } from 'views/api-client-authorization-panel/api-client-authorization-panel-root';
-import * as groupPanelActions from 'store/groups-panel/groups-panel-actions';
-import { groupsPanelColumns } from 'views/groups-panel/groups-panel';
-import * as groupDetailsPanelActions from 'store/group-details-panel/group-details-panel-actions';
-import {
-    groupDetailsMembersPanelColumns,
-    groupDetailsPermissionsPanelColumns,
-} from 'views/group-details-panel/group-details-panel';
-import { DataTableFetchMode } from 'components/data-table/data-table';
-import {
-    loadPublicFavoritePanel,
-    publicFavoritePanelActions,
-} from 'store/public-favorites-panel/public-favorites-action';
-import { publicFavoritePanelColumns } from 'views/public-favorites-panel/public-favorites-panel';
+} from "store/breadcrumbs/breadcrumbs-actions";
+import { navigateTo, navigateToRootProject } from "store/navigation/navigation-action";
+import { MoveToFormDialogData } from "store/move-to-dialog/move-to-dialog";
+import { ServiceRepository } from "services/services";
+import { getResource } from "store/resources/resources";
+import * as projectCreateActions from "store/projects/project-create-actions";
+import * as projectMoveActions from "store/projects/project-move-actions";
+import * as projectUpdateActions from "store/projects/project-update-actions";
+import * as collectionCreateActions from "store/collections/collection-create-actions";
+import * as collectionCopyActions from "store/collections/collection-copy-actions";
+import * as collectionMoveActions from "store/collections/collection-move-actions";
+import * as processesActions from "store/processes/processes-actions";
+import * as processMoveActions from "store/processes/process-move-actions";
+import * as processUpdateActions from "store/processes/process-update-actions";
+import * as processCopyActions from "store/processes/process-copy-actions";
+import { trashPanelColumns } from "views/trash-panel/trash-panel";
+import { loadTrashPanel, trashPanelActions } from "store/trash-panel/trash-panel-action";
+import { loadProcessPanel } from "store/process-panel/process-panel-actions";
+import { loadSharedWithMePanel, sharedWithMePanelActions } from "store/shared-with-me-panel/shared-with-me-panel-actions";
+import { sharedWithMePanelColumns } from "views/shared-with-me-panel/shared-with-me-panel";
+import { CopyFormDialogData } from "store/copy-dialog/copy-dialog";
+import { workflowPanelActions } from "store/workflow-panel/workflow-panel-actions";
+import { loadSshKeysPanel } from "store/auth/auth-action-ssh";
+import { loadLinkAccountPanel, linkAccountPanelActions } from "store/link-account-panel/link-account-panel-actions";
+import { loadSiteManagerPanel } from "store/auth/auth-action-session";
+import { workflowPanelColumns } from "views/workflow-panel/workflow-panel-view";
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { getProgressIndicator } from "store/progress-indicator/progress-indicator-reducer";
+import { extractUuidKind, Resource, ResourceKind } from "models/resource";
+import { FilterBuilder } from "services/api/filter-builder";
+import { GroupContentsResource } from "services/groups-service/groups-service";
+import { MatchCases, ofType, unionize, UnionOf } from "common/unionize";
+import { loadRunProcessPanel } from "store/run-process-panel/run-process-panel-actions";
+import { collectionPanelActions, loadCollectionPanel } from "store/collection-panel/collection-panel-action";
+import { CollectionResource } from "models/collection";
+import { WorkflowResource } from "models/workflow";
+import { loadSearchResultsPanel, searchResultsPanelActions } from "store/search-results-panel/search-results-panel-actions";
+import { searchResultsPanelColumns } from "views/search-results-panel/search-results-panel-view";
+import { loadVirtualMachinesPanel } from "store/virtual-machines/virtual-machines-actions";
+import { loadRepositoriesPanel } from "store/repositories/repositories-actions";
+import { loadKeepServicesPanel } from "store/keep-services/keep-services-actions";
+import { loadUsersPanel, userBindedActions } from "store/users/users-actions";
+import * as userProfilePanelActions from "store/user-profile/user-profile-actions";
+import { linkPanelActions, loadLinkPanel } from "store/link-panel/link-panel-actions";
+import { linkPanelColumns } from "views/link-panel/link-panel-root";
+import { userPanelColumns } from "views/user-panel/user-panel";
+import { loadApiClientAuthorizationsPanel, apiClientAuthorizationsActions } from "store/api-client-authorizations/api-client-authorizations-actions";
+import { apiClientAuthorizationPanelColumns } from "views/api-client-authorization-panel/api-client-authorization-panel-root";
+import * as groupPanelActions from "store/groups-panel/groups-panel-actions";
+import { groupsPanelColumns } from "views/groups-panel/groups-panel";
+import * as groupDetailsPanelActions from "store/group-details-panel/group-details-panel-actions";
+import { groupDetailsMembersPanelColumns, groupDetailsPermissionsPanelColumns } from "views/group-details-panel/group-details-panel";
+import { DataTableFetchMode } from "components/data-table/data-table";
+import { loadPublicFavoritePanel, publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action";
+import { publicFavoritePanelColumns } from "views/public-favorites-panel/public-favorites-panel";
 import {
     loadCollectionsContentAddressPanel,
     collectionsContentAddressActions,
-} from 'store/collections-content-address-panel/collections-content-address-panel-actions';
-import { collectionContentAddressPanelColumns } from 'views/collection-content-address-panel/collection-content-address-panel';
-import { subprocessPanelActions } from 'store/subprocess-panel/subprocess-panel-actions';
-import { subprocessPanelColumns } from 'views/subprocess-panel/subprocess-panel-root';
-import {
-    loadAllProcessesPanel,
-    allProcessesPanelActions,
-} from '../all-processes-panel/all-processes-panel-action';
-import { allProcessesPanelColumns } from 'views/all-processes-panel/all-processes-panel';
-import { AdminMenuIcon } from 'components/icon/icon';
-import { userProfileGroupsColumns } from 'views/user-profile-panel/user-profile-panel-root';
-
-export const WORKBENCH_LOADING_SCREEN = 'workbenchLoadingScreen';
+} from "store/collections-content-address-panel/collections-content-address-panel-actions";
+import { collectionContentAddressPanelColumns } from "views/collection-content-address-panel/collection-content-address-panel";
+import { subprocessPanelActions } from "store/subprocess-panel/subprocess-panel-actions";
+import { subprocessPanelColumns } from "views/subprocess-panel/subprocess-panel-root";
+import { loadAllProcessesPanel, allProcessesPanelActions } from "../all-processes-panel/all-processes-panel-action";
+import { allProcessesPanelColumns } from "views/all-processes-panel/all-processes-panel";
+import { AdminMenuIcon } from "components/icon/icon";
+import { userProfileGroupsColumns } from "views/user-profile-panel/user-profile-panel-root";
+import { selectedToArray, selectedToKindSet } from "components/multiselect-toolbar/MultiselectToolbar";
+import { multiselectActions } from "store/multiselect/multiselect-actions";
+
+export const WORKBENCH_LOADING_SCREEN = "workbenchLoadingScreen";
 
 export const isWorkbenchLoading = (state: RootState) => {
-    const progress = getProgressIndicator(WORKBENCH_LOADING_SCREEN)(
-        state.progressIndicator
-    );
+    const progress = getProgressIndicator(WORKBENCH_LOADING_SCREEN)(state.progressIndicator);
     return progress ? progress.working : false;
 };
 
-export const handleFirstTimeLoad =
-    (action: any) =>
-        async (dispatch: Dispatch<any>, getState: () => RootState) => {
-            try {
-                await dispatch(action);
-            } finally {
-                if (isWorkbenchLoading(getState())) {
-                    dispatch(
-                        progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN)
-                    );
-                }
-            }
-        };
+export const handleFirstTimeLoad = (action: any) => async (dispatch: Dispatch<any>, getState: () => RootState) => {
+    try {
+        await dispatch(action);
+    } catch (e) {
+        snackbarActions.OPEN_SNACKBAR({
+            message: "Error " + e,
+            hideDuration: 8000,
+            kind: SnackbarKind.WARNING,
+        })
+    } finally {
+        if (isWorkbenchLoading(getState())) {
+            dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
+        }
+    }
+};
 
-export const loadWorkbench =
-    () =>
-        async (
-            dispatch: Dispatch,
-            getState: () => RootState,
-            services: ServiceRepository
-        ) => {
-            dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN));
-            const { auth, router } = getState();
-            const { user } = auth;
-            if (user) {
-                dispatch(
-                    projectPanelActions.SET_COLUMNS({ columns: projectPanelColumns })
-                );
-                dispatch(
-                    favoritePanelActions.SET_COLUMNS({ columns: favoritePanelColumns })
-                );
-                dispatch(
-                    allProcessesPanelActions.SET_COLUMNS({
-                        columns: allProcessesPanelColumns,
-                    })
-                );
-                dispatch(
-                    publicFavoritePanelActions.SET_COLUMNS({
-                        columns: publicFavoritePanelColumns,
-                    })
-                );
-                dispatch(trashPanelActions.SET_COLUMNS({ columns: trashPanelColumns }));
-                dispatch(
-                    sharedWithMePanelActions.SET_COLUMNS({ columns: projectPanelColumns })
-                );
-                dispatch(
-                    workflowPanelActions.SET_COLUMNS({ columns: workflowPanelColumns })
-                );
-                dispatch(
-                    searchResultsPanelActions.SET_FETCH_MODE({
-                        fetchMode: DataTableFetchMode.INFINITE,
-                    })
-                );
-                dispatch(
-                    searchResultsPanelActions.SET_COLUMNS({
-                        columns: searchResultsPanelColumns,
-                    })
-                );
-                dispatch(userBindedActions.SET_COLUMNS({ columns: userPanelColumns }));
-                dispatch(
-                    groupPanelActions.GroupsPanelActions.SET_COLUMNS({
-                        columns: groupsPanelColumns,
-                    })
-                );
-                dispatch(
-                    groupDetailsPanelActions.GroupMembersPanelActions.SET_COLUMNS({
-                        columns: groupDetailsMembersPanelColumns,
-                    })
-                );
-                dispatch(
-                    groupDetailsPanelActions.GroupPermissionsPanelActions.SET_COLUMNS({
-                        columns: groupDetailsPermissionsPanelColumns,
-                    })
-                );
-                dispatch(
-                    userProfilePanelActions.UserProfileGroupsActions.SET_COLUMNS({
-                        columns: userProfileGroupsColumns,
-                    })
-                );
-                dispatch(linkPanelActions.SET_COLUMNS({ columns: linkPanelColumns }));
-                dispatch(
-                    apiClientAuthorizationsActions.SET_COLUMNS({
-                        columns: apiClientAuthorizationPanelColumns,
-                    })
-                );
-                dispatch(
-                    collectionsContentAddressActions.SET_COLUMNS({
-                        columns: collectionContentAddressPanelColumns,
-                    })
-                );
-                dispatch(
-                    subprocessPanelActions.SET_COLUMNS({ columns: subprocessPanelColumns })
-                );
+export const loadWorkbench = () => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN));
+    const { auth, router } = getState();
+    const { user } = auth;
+    if (user) {
+        dispatch(projectPanelActions.SET_COLUMNS({ columns: projectPanelColumns }));
+        dispatch(favoritePanelActions.SET_COLUMNS({ columns: favoritePanelColumns }));
+        dispatch(
+            allProcessesPanelActions.SET_COLUMNS({
+                columns: allProcessesPanelColumns,
+            })
+        );
+        dispatch(
+            publicFavoritePanelActions.SET_COLUMNS({
+                columns: publicFavoritePanelColumns,
+            })
+        );
+        dispatch(trashPanelActions.SET_COLUMNS({ columns: trashPanelColumns }));
+        dispatch(sharedWithMePanelActions.SET_COLUMNS({ columns: sharedWithMePanelColumns }));
+        dispatch(workflowPanelActions.SET_COLUMNS({ columns: workflowPanelColumns }));
+        dispatch(
+            searchResultsPanelActions.SET_FETCH_MODE({
+                fetchMode: DataTableFetchMode.INFINITE,
+            })
+        );
+        dispatch(
+            searchResultsPanelActions.SET_COLUMNS({
+                columns: searchResultsPanelColumns,
+            })
+        );
+        dispatch(userBindedActions.SET_COLUMNS({ columns: userPanelColumns }));
+        dispatch(
+            groupPanelActions.GroupsPanelActions.SET_COLUMNS({
+                columns: groupsPanelColumns,
+            })
+        );
+        dispatch(
+            groupDetailsPanelActions.GroupMembersPanelActions.SET_COLUMNS({
+                columns: groupDetailsMembersPanelColumns,
+            })
+        );
+        dispatch(
+            groupDetailsPanelActions.GroupPermissionsPanelActions.SET_COLUMNS({
+                columns: groupDetailsPermissionsPanelColumns,
+            })
+        );
+        dispatch(
+            userProfilePanelActions.UserProfileGroupsActions.SET_COLUMNS({
+                columns: userProfileGroupsColumns,
+            })
+        );
+        dispatch(linkPanelActions.SET_COLUMNS({ columns: linkPanelColumns }));
+        dispatch(
+            apiClientAuthorizationsActions.SET_COLUMNS({
+                columns: apiClientAuthorizationPanelColumns,
+            })
+        );
+        dispatch(
+            collectionsContentAddressActions.SET_COLUMNS({
+                columns: collectionContentAddressPanelColumns,
+            })
+        );
+        dispatch(subprocessPanelActions.SET_COLUMNS({ columns: subprocessPanelColumns }));
 
-                if (services.linkAccountService.getAccountToLink()) {
-                    dispatch(linkAccountPanelActions.HAS_SESSION_DATA());
-                }
+        if (services.linkAccountService.getAccountToLink()) {
+            dispatch(linkAccountPanelActions.HAS_SESSION_DATA());
+        }
 
-                dispatch<any>(initSidePanelTree());
-                if (router.location) {
-                    const match = matchRootRoute(router.location.pathname);
-                    if (match) {
-                        dispatch<any>(navigateToRootProject);
-                    }
-                }
-            } else {
-                dispatch(userIsNotAuthenticated);
+        dispatch<any>(initSidePanelTree());
+        if (router.location) {
+            const match = matchRootRoute(router.location.pathname);
+            if (match) {
+                dispatch<any>(navigateToRootProject);
             }
-        };
+        }
+    } else {
+        dispatch(userIsNotAuthenticated);
+    }
+};
 
 export const loadFavorites = () =>
     handleFirstTimeLoad((dispatch: Dispatch) => {
@@ -262,11 +210,9 @@ export const loadFavorites = () =>
         dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.FAVORITES));
     });
 
-export const loadCollectionContentAddress = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadCollectionsContentAddressPanel());
-    }
-);
+export const loadCollectionContentAddress = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadCollectionsContentAddressPanel());
+});
 
 export const loadTrash = () =>
     handleFirstTimeLoad((dispatch: Dispatch) => {
@@ -277,25 +223,20 @@ export const loadTrash = () =>
 
 export const loadAllProcesses = () =>
     handleFirstTimeLoad((dispatch: Dispatch) => {
-        dispatch<any>(
-            activateSidePanelTreeItem(SidePanelTreeCategory.ALL_PROCESSES)
-        );
+        dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.ALL_PROCESSES));
         dispatch<any>(loadAllProcessesPanel());
         dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.ALL_PROCESSES));
     });
 
 export const loadProject = (uuid: string) =>
-    handleFirstTimeLoad(
-        async (
-            dispatch: Dispatch<any>,
-            getState: () => RootState,
-            services: ServiceRepository
-        ) => {
-            const userUuid = getUserUuid(getState());
-            dispatch(setIsProjectPanelTrashed(false));
-            if (!userUuid) {
-                return;
-            }
+    handleFirstTimeLoad(async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = getUserUuid(getState());
+        dispatch(setIsProjectPanelTrashed(false));
+        if (!userUuid) {
+            return;
+        }
+        try {
+            dispatch(progressIndicatorActions.START_WORKING(uuid));
             if (extractUuidKind(uuid) === ResourceKind.USER && userUuid !== uuid) {
                 // Load another users home projects
                 dispatch(finishLoadingProject(uuid));
@@ -316,9 +257,7 @@ export const loadProject = (uuid: string) =>
                         dispatch<any>(setSharedWithMeBreadcrumbs(uuid));
                     },
                     TRASHED: async () => {
-                        await dispatch(
-                            activateSidePanelTreeItem(SidePanelTreeCategory.TRASH)
-                        );
+                        await dispatch(activateSidePanelTreeItem(SidePanelTreeCategory.TRASH));
                         dispatch<any>(setTrashBreadcrumbs(uuid));
                         dispatch(setIsProjectPanelTrashed(true));
                     },
@@ -328,353 +267,429 @@ export const loadProject = (uuid: string) =>
                 await dispatch(activateSidePanelTreeItem(userUuid));
                 dispatch<any>(setSidePanelBreadcrumbs(userUuid));
             }
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(uuid));
         }
-    );
+    });
 
-export const createProject =
-    (data: projectCreateActions.ProjectCreateFormDialogData) =>
-        async (dispatch: Dispatch) => {
-            const newProject = await dispatch<any>(
-                projectCreateActions.createProject(data)
-            );
-            if (newProject) {
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: 'Project has been successfully created.',
-                        hideDuration: 2000,
-                        kind: SnackbarKind.SUCCESS,
-                    })
-                );
-                await dispatch<any>(loadSidePanelTreeProjects(newProject.ownerUuid));
-                dispatch<any>(navigateTo(newProject.uuid));
-            }
-        };
+export const createProject = (data: projectCreateActions.ProjectCreateFormDialogData) => async (dispatch: Dispatch) => {
+    const newProject = await dispatch<any>(projectCreateActions.createProject(data));
+    if (newProject) {
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: "Project has been successfully created.",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
+        await dispatch<any>(loadSidePanelTreeProjects(newProject.ownerUuid));
+        dispatch<any>(navigateTo(newProject.uuid));
+    }
+};
 
 export const moveProject =
-    (data: MoveToFormDialogData) =>
-        async (
-            dispatch: Dispatch,
-            getState: () => RootState,
-            services: ServiceRepository
-        ) => {
-            try {
-                const oldProject = getResource(data.uuid)(getState().resources);
-                const oldOwnerUuid = oldProject ? oldProject.ownerUuid : '';
-                const movedProject = await dispatch<any>(
-                    projectMoveActions.moveProject(data)
-                );
-                if (movedProject) {
+    (data: MoveToFormDialogData, isSecondaryMove = false) =>
+        async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+            const checkedList = getState().multiselect.checkedList;
+            const uuidsToMove: string[] = data.fromContextMenu ? [data.uuid] : selectedToArray(checkedList);
+
+            //if no items in checkedlist default to normal context menu behavior
+            if (!isSecondaryMove && !uuidsToMove.length) uuidsToMove.push(data.uuid);
+
+            const sourceUuid = getResource(data.uuid)(getState().resources)?.ownerUuid;
+            const destinationUuid = data.ownerUuid;
+
+            const projectsToMove: MoveableResource[] = uuidsToMove
+                .map(uuid => getResource(uuid)(getState().resources) as MoveableResource)
+                .filter(resource => resource.kind === ResourceKind.PROJECT);
+
+            for (const project of projectsToMove) {
+                await moveSingleProject(project);
+            }
+
+            //omly propagate if this call is the original
+            if (!isSecondaryMove) {
+                const kindsToMove: Set<string> = selectedToKindSet(checkedList);
+                kindsToMove.delete(ResourceKind.PROJECT);
+
+                kindsToMove.forEach(kind => {
+                    secondaryMove[kind](data, true)(dispatch, getState, services);
+                });
+            }
+
+            async function moveSingleProject(project: MoveableResource) {
+                try {
+                    const oldProject: MoveToFormDialogData = { name: project.name, uuid: project.uuid, ownerUuid: data.ownerUuid };
+                    const oldOwnerUuid = oldProject ? oldProject.ownerUuid : "";
+                    const movedProject = await dispatch<any>(projectMoveActions.moveProject(oldProject));
+                    if (movedProject) {
+                        dispatch(
+                            snackbarActions.OPEN_SNACKBAR({
+                                message: "Project has been moved",
+                                hideDuration: 2000,
+                                kind: SnackbarKind.SUCCESS,
+                            })
+                        );
+                        await dispatch<any>(reloadProjectMatchingUuid([oldOwnerUuid, movedProject.ownerUuid, movedProject.uuid]));
+                    }
+                } catch (e) {
                     dispatch(
                         snackbarActions.OPEN_SNACKBAR({
-                            message: 'Project has been moved',
+                            message: !!(project as any).frozenByUuid ? 'Could not move frozen project.' : e.message,
                             hideDuration: 2000,
-                            kind: SnackbarKind.SUCCESS,
+                            kind: SnackbarKind.ERROR,
                         })
                     );
-                    if (oldProject) {
-                        await dispatch<any>(loadSidePanelTreeProjects(oldProject.ownerUuid));
-                    }
-                    dispatch<any>(
-                        reloadProjectMatchingUuid([
-                            oldOwnerUuid,
-                            movedProject.ownerUuid,
-                            movedProject.uuid,
-                        ])
-                    );
                 }
-            } catch (e) {
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: e.message,
-                        hideDuration: 2000,
-                        kind: SnackbarKind.ERROR,
-                    })
-                );
             }
+            if (sourceUuid) await dispatch<any>(loadSidePanelTreeProjects(sourceUuid));
+            await dispatch<any>(loadSidePanelTreeProjects(destinationUuid));
         };
 
-export const updateProject =
-    (data: projectUpdateActions.ProjectUpdateFormDialogData) =>
-        async (dispatch: Dispatch) => {
-            const updatedProject = await dispatch<any>(
-                projectUpdateActions.updateProject(data)
-            );
-            if (updatedProject) {
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: 'Project has been successfully updated.',
-                        hideDuration: 2000,
-                        kind: SnackbarKind.SUCCESS,
-                    })
-                );
-                await dispatch<any>(loadSidePanelTreeProjects(updatedProject.ownerUuid));
-                dispatch<any>(
-                    reloadProjectMatchingUuid([
-                        updatedProject.ownerUuid,
-                        updatedProject.uuid,
-                    ])
-                );
-            }
-        };
+export const updateProject = (data: projectUpdateActions.ProjectUpdateFormDialogData) => async (dispatch: Dispatch) => {
+    const updatedProject = await dispatch<any>(projectUpdateActions.updateProject(data));
+    if (updatedProject) {
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: "Project has been successfully updated.",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
+        await dispatch<any>(loadSidePanelTreeProjects(updatedProject.ownerUuid));
+        dispatch<any>(reloadProjectMatchingUuid([updatedProject.ownerUuid, updatedProject.uuid]));
+    }
+};
 
-export const updateGroup =
-    (data: projectUpdateActions.ProjectUpdateFormDialogData) =>
-        async (dispatch: Dispatch) => {
-            const updatedGroup = await dispatch<any>(
-                groupPanelActions.updateGroup(data)
-            );
-            if (updatedGroup) {
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: 'Group has been successfully updated.',
-                        hideDuration: 2000,
-                        kind: SnackbarKind.SUCCESS,
-                    })
-                );
-                await dispatch<any>(loadSidePanelTreeProjects(updatedGroup.ownerUuid));
-                dispatch<any>(
-                    reloadProjectMatchingUuid([updatedGroup.ownerUuid, updatedGroup.uuid])
-                );
-            }
-        };
+export const updateGroup = (data: projectUpdateActions.ProjectUpdateFormDialogData) => async (dispatch: Dispatch) => {
+    const updatedGroup = await dispatch<any>(groupPanelActions.updateGroup(data));
+    if (updatedGroup) {
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: "Group has been successfully updated.",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
+        await dispatch<any>(loadSidePanelTreeProjects(updatedGroup.ownerUuid));
+        dispatch<any>(reloadProjectMatchingUuid([updatedGroup.ownerUuid, updatedGroup.uuid]));
+    }
+};
 
 export const loadCollection = (uuid: string) =>
-    handleFirstTimeLoad(
-        async (
-            dispatch: Dispatch<any>,
-            getState: () => RootState,
-            services: ServiceRepository
-        ) => {
-            const userUuid = getUserUuid(getState());
+    handleFirstTimeLoad(async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = getUserUuid(getState());
+        try {
+            dispatch(progressIndicatorActions.START_WORKING(uuid));
             if (userUuid) {
                 const match = await loadGroupContentsResource({
                     uuid,
                     userUuid,
                     services,
                 });
+                let collection: CollectionResource | undefined;
+                let breadcrumbfunc:
+                    | ((uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => Promise<void>)
+                    | undefined;
+                let sidepanel: string | undefined;
                 match({
-                    OWNED: (collection) => {
-                        dispatch(
-                            collectionPanelActions.SET_COLLECTION(
-                                collection as CollectionResource
-                            )
-                        );
-                        dispatch(updateResources([collection]));
-                        dispatch(activateSidePanelTreeItem(collection.ownerUuid));
-                        dispatch(setSidePanelBreadcrumbs(collection.ownerUuid));
-                        dispatch(loadCollectionPanel(collection.uuid));
+                    OWNED: thecollection => {
+                        collection = thecollection as CollectionResource;
+                        sidepanel = collection.ownerUuid;
+                        breadcrumbfunc = setSidePanelBreadcrumbs;
                     },
-                    SHARED: (collection) => {
-                        dispatch(
-                            collectionPanelActions.SET_COLLECTION(
-                                collection as CollectionResource
-                            )
-                        );
-                        dispatch(updateResources([collection]));
-                        dispatch<any>(setSharedWithMeBreadcrumbs(collection.ownerUuid));
-                        dispatch(activateSidePanelTreeItem(collection.ownerUuid));
-                        dispatch(loadCollectionPanel(collection.uuid));
+                    SHARED: thecollection => {
+                        collection = thecollection as CollectionResource;
+                        sidepanel = collection.ownerUuid;
+                        breadcrumbfunc = setSharedWithMeBreadcrumbs;
                     },
-                    TRASHED: (collection) => {
-                        dispatch(
-                            collectionPanelActions.SET_COLLECTION(
-                                collection as CollectionResource
-                            )
-                        );
-                        dispatch(updateResources([collection]));
-                        dispatch(setTrashBreadcrumbs(''));
-                        dispatch(activateSidePanelTreeItem(SidePanelTreeCategory.TRASH));
-                        dispatch(loadCollectionPanel(collection.uuid));
+                    TRASHED: thecollection => {
+                        collection = thecollection as CollectionResource;
+                        sidepanel = SidePanelTreeCategory.TRASH;
+                        breadcrumbfunc = () => setTrashBreadcrumbs("");
                     },
                 });
+                if (collection && breadcrumbfunc && sidepanel) {
+                    dispatch(updateResources([collection]));
+                    await dispatch<any>(finishLoadingProject(collection.ownerUuid));
+                    dispatch(collectionPanelActions.SET_COLLECTION(collection));
+                    await dispatch(activateSidePanelTreeItem(sidepanel));
+                    dispatch(breadcrumbfunc(collection.ownerUuid));
+                    dispatch(loadCollectionPanel(collection.uuid));
+                }
             }
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(uuid));
         }
-    );
+    });
+
+export const createCollection = (data: collectionCreateActions.CollectionCreateFormDialogData) => async (dispatch: Dispatch) => {
+    const collection = await dispatch<any>(collectionCreateActions.createCollection(data));
+    if (collection) {
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: "Collection has been successfully created.",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
+        dispatch<any>(updateResources([collection]));
+        dispatch<any>(navigateTo(collection.uuid));
+    }
+};
+
+export const copyCollection = (data: CopyFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    const checkedList = getState().multiselect.checkedList;
+    const uuidsToCopy: string[] = data.fromContextMenu ? [data.uuid] : selectedToArray(checkedList);
+
+    //if no items in checkedlist && no items passed in, default to normal context menu behavior
+    if (!uuidsToCopy.length) uuidsToCopy.push(data.uuid);
+
+    const collectionsToCopy: CollectionCopyResource[] = uuidsToCopy
+        .map(uuid => getResource(uuid)(getState().resources) as CollectionCopyResource)
+        .filter(resource => resource.kind === ResourceKind.COLLECTION);
+
+    for (const collection of collectionsToCopy) {
+        await copySingleCollection({ ...collection, ownerUuid: data.ownerUuid } as CollectionCopyResource);
+    }
 
-export const createCollection =
-    (data: collectionCreateActions.CollectionCreateFormDialogData) =>
-        async (dispatch: Dispatch) => {
+    async function copySingleCollection(copyToProject: CollectionCopyResource) {
+        const newName = data.fromContextMenu || collectionsToCopy.length === 1 ? data.name : `Copy of: ${copyToProject.name}`;
+        try {
             const collection = await dispatch<any>(
-                collectionCreateActions.createCollection(data)
+                collectionCopyActions.copyCollection({
+                    ...copyToProject,
+                    name: newName,
+                    fromContextMenu: collectionsToCopy.length === 1 ? true : data.fromContextMenu,
+                })
             );
-            if (collection) {
+            if (copyToProject && collection) {
+                await dispatch<any>(reloadProjectMatchingUuid([copyToProject.uuid]));
                 dispatch(
                     snackbarActions.OPEN_SNACKBAR({
-                        message: 'Collection has been successfully created.',
-                        hideDuration: 2000,
+                        message: "Collection has been copied.",
+                        hideDuration: 3000,
                         kind: SnackbarKind.SUCCESS,
+                        link: collection.ownerUuid,
                     })
                 );
-                dispatch<any>(updateResources([collection]));
-                dispatch<any>(navigateTo(collection.uuid));
+                dispatch<any>(multiselectActions.deselectOne(copyToProject.uuid));
             }
-        };
+        } catch (e) {
+            dispatch(
+                snackbarActions.OPEN_SNACKBAR({
+                    message: e.message,
+                    hideDuration: 2000,
+                    kind: SnackbarKind.ERROR,
+                })
+            );
+        }
+    }
+    dispatch(projectPanelActions.REQUEST_ITEMS());
+};
 
-export const copyCollection =
-    (data: CopyFormDialogData) =>
-        async (
-            dispatch: Dispatch,
-            getState: () => RootState,
-            services: ServiceRepository
-        ) => {
-            try {
-                const copyToProject = getResource(data.ownerUuid)(getState().resources);
-                const collection = await dispatch<any>(
-                    collectionCopyActions.copyCollection(data)
-                );
-                if (copyToProject && collection) {
-                    dispatch<any>(reloadProjectMatchingUuid([copyToProject.uuid]));
+export const moveCollection =
+    (data: MoveToFormDialogData, isSecondaryMove = false) =>
+        async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+            const checkedList = getState().multiselect.checkedList;
+            const uuidsToMove: string[] = data.fromContextMenu ? [data.uuid] : selectedToArray(checkedList);
+
+            //if no items in checkedlist && no items passed in, default to normal context menu behavior
+            if (!isSecondaryMove && !uuidsToMove.length) uuidsToMove.push(data.uuid);
+
+            const collectionsToMove: MoveableResource[] = uuidsToMove
+                .map(uuid => getResource(uuid)(getState().resources) as MoveableResource)
+                .filter(resource => resource.kind === ResourceKind.COLLECTION);
+
+            for (const collection of collectionsToMove) {
+                await moveSingleCollection(collection);
+            }
+
+            //omly propagate if this call is the original
+            if (!isSecondaryMove) {
+                const kindsToMove: Set<string> = selectedToKindSet(checkedList);
+                kindsToMove.delete(ResourceKind.COLLECTION);
+
+                kindsToMove.forEach(kind => {
+                    secondaryMove[kind](data, true)(dispatch, getState, services);
+                });
+            }
+
+            async function moveSingleCollection(collection: MoveableResource) {
+                try {
+                    const oldCollection: MoveToFormDialogData = { name: collection.name, uuid: collection.uuid, ownerUuid: data.ownerUuid };
+                    const movedCollection = await dispatch<any>(collectionMoveActions.moveCollection(oldCollection));
+                    dispatch<any>(updateResources([movedCollection]));
+                    dispatch<any>(reloadProjectMatchingUuid([movedCollection.ownerUuid]));
                     dispatch(
                         snackbarActions.OPEN_SNACKBAR({
-                            message: 'Collection has been copied.',
-                            hideDuration: 3000,
+                            message: "Collection has been moved.",
+                            hideDuration: 2000,
                             kind: SnackbarKind.SUCCESS,
-                            link: collection.ownerUuid,
+                        })
+                    );
+                } catch (e) {
+                    dispatch(
+                        snackbarActions.OPEN_SNACKBAR({
+                            message: e.message,
+                            hideDuration: 2000,
+                            kind: SnackbarKind.ERROR,
                         })
                     );
                 }
-            } catch (e) {
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: e.message,
-                        hideDuration: 2000,
-                        kind: SnackbarKind.ERROR,
-                    })
-                );
-            }
-        };
-
-export const moveCollection =
-    (data: MoveToFormDialogData) =>
-        async (
-            dispatch: Dispatch,
-            getState: () => RootState,
-            services: ServiceRepository
-        ) => {
-            try {
-                const collection = await dispatch<any>(
-                    collectionMoveActions.moveCollection(data)
-                );
-                dispatch<any>(updateResources([collection]));
-                dispatch<any>(reloadProjectMatchingUuid([collection.ownerUuid]));
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: 'Collection has been moved.',
-                        hideDuration: 2000,
-                        kind: SnackbarKind.SUCCESS,
-                    })
-                );
-            } catch (e) {
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: e.message,
-                        hideDuration: 2000,
-                        kind: SnackbarKind.ERROR,
-                    })
-                );
             }
         };
 
 export const loadProcess = (uuid: string) =>
     handleFirstTimeLoad(async (dispatch: Dispatch, getState: () => RootState) => {
-        dispatch<any>(loadProcessPanel(uuid));
-        const process = await dispatch<any>(processesActions.loadProcess(uuid));
+        try {
+            dispatch(progressIndicatorActions.START_WORKING(uuid));
+            dispatch<any>(loadProcessPanel(uuid));
+            const process = await dispatch<any>(processesActions.loadProcess(uuid));
+            if (process) {
+                await dispatch<any>(finishLoadingProject(process.containerRequest.ownerUuid));
+                await dispatch<any>(activateSidePanelTreeItem(process.containerRequest.ownerUuid));
+                dispatch<any>(setProcessBreadcrumbs(uuid));
+                dispatch<any>(loadDetailsPanel(uuid));
+            }
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(uuid));
+        }
+    });
+
+export const loadRegisteredWorkflow = (uuid: string) =>
+    handleFirstTimeLoad(async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = getUserUuid(getState());
+        if (userUuid) {
+            const match = await loadGroupContentsResource({
+                uuid,
+                userUuid,
+                services,
+            });
+            let workflow: WorkflowResource | undefined;
+            let breadcrumbfunc:
+                | ((uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => Promise<void>)
+                | undefined;
+            match({
+                OWNED: async theworkflow => {
+                    workflow = theworkflow as WorkflowResource;
+                    breadcrumbfunc = setSidePanelBreadcrumbs;
+                },
+                SHARED: async theworkflow => {
+                    workflow = theworkflow as WorkflowResource;
+                    breadcrumbfunc = setSharedWithMeBreadcrumbs;
+                },
+                TRASHED: () => { },
+            });
+            if (workflow && breadcrumbfunc) {
+                dispatch(updateResources([workflow]));
+                await dispatch<any>(finishLoadingProject(workflow.ownerUuid));
+                await dispatch<any>(activateSidePanelTreeItem(workflow.ownerUuid));
+                dispatch<any>(breadcrumbfunc(workflow.ownerUuid));
+            }
+        }
+    });
+
+export const updateProcess = (data: processUpdateActions.ProcessUpdateFormDialogData) => async (dispatch: Dispatch) => {
+    try {
+        const process = await dispatch<any>(processUpdateActions.updateProcess(data));
         if (process) {
-            await dispatch<any>(
-                activateSidePanelTreeItem(process.containerRequest.ownerUuid)
+            dispatch(
+                snackbarActions.OPEN_SNACKBAR({
+                    message: "Process has been successfully updated.",
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS,
+                })
             );
-            dispatch<any>(setProcessBreadcrumbs(uuid));
-            dispatch<any>(loadDetailsPanel(uuid));
+            dispatch<any>(updateResources([process]));
+            dispatch<any>(reloadProjectMatchingUuid([process.ownerUuid]));
         }
-    });
+    } catch (e) {
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: e.message,
+                hideDuration: 2000,
+                kind: SnackbarKind.ERROR,
+            })
+        );
+    }
+};
 
-export const updateProcess =
-    (data: processUpdateActions.ProcessUpdateFormDialogData) =>
-        async (dispatch: Dispatch) => {
-            try {
-                const process = await dispatch<any>(
-                    processUpdateActions.updateProcess(data)
-                );
-                if (process) {
+export const moveProcess =
+    (data: MoveToFormDialogData, isSecondaryMove = false) =>
+        async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+            const checkedList = getState().multiselect.checkedList;
+            const uuidsToMove: string[] = data.fromContextMenu ? [data.uuid] : selectedToArray(checkedList);
+
+            //if no items in checkedlist && no items passed in, default to normal context menu behavior
+            if (!isSecondaryMove && !uuidsToMove.length) uuidsToMove.push(data.uuid);
+
+            const processesToMove: MoveableResource[] = uuidsToMove
+                .map(uuid => getResource(uuid)(getState().resources) as MoveableResource)
+                .filter(resource => resource.kind === ResourceKind.PROCESS);
+
+            for (const process of processesToMove) {
+                await moveSingleProcess(process);
+            }
+
+            //omly propagate if this call is the original
+            if (!isSecondaryMove) {
+                const kindsToMove: Set<string> = selectedToKindSet(checkedList);
+                kindsToMove.delete(ResourceKind.PROCESS);
+
+                kindsToMove.forEach(kind => {
+                    secondaryMove[kind](data, true)(dispatch, getState, services);
+                });
+            }
+
+            async function moveSingleProcess(process: MoveableResource) {
+                try {
+                    const oldProcess: MoveToFormDialogData = { name: process.name, uuid: process.uuid, ownerUuid: data.ownerUuid };
+                    const movedProcess = await dispatch<any>(processMoveActions.moveProcess(oldProcess));
+                    dispatch<any>(updateResources([movedProcess]));
+                    dispatch<any>(reloadProjectMatchingUuid([movedProcess.ownerUuid]));
                     dispatch(
                         snackbarActions.OPEN_SNACKBAR({
-                            message: 'Process has been successfully updated.',
+                            message: "Process has been moved.",
                             hideDuration: 2000,
                             kind: SnackbarKind.SUCCESS,
                         })
                     );
-                    dispatch<any>(updateResources([process]));
-                    dispatch<any>(reloadProjectMatchingUuid([process.ownerUuid]));
+                } catch (e) {
+                    dispatch(
+                        snackbarActions.OPEN_SNACKBAR({
+                            message: e.message,
+                            hideDuration: 2000,
+                            kind: SnackbarKind.ERROR,
+                        })
+                    );
                 }
-            } catch (e) {
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: e.message,
-                        hideDuration: 2000,
-                        kind: SnackbarKind.ERROR,
-                    })
-                );
             }
         };
 
-export const moveProcess =
-    (data: MoveToFormDialogData) =>
-        async (
-            dispatch: Dispatch,
-            getState: () => RootState,
-            services: ServiceRepository
-        ) => {
-            try {
-                const process = await dispatch<any>(processMoveActions.moveProcess(data));
-                dispatch<any>(updateResources([process]));
-                dispatch<any>(reloadProjectMatchingUuid([process.ownerUuid]));
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: 'Process has been moved.',
-                        hideDuration: 2000,
-                        kind: SnackbarKind.SUCCESS,
-                    })
-                );
-            } catch (e) {
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: e.message,
-                        hideDuration: 2000,
-                        kind: SnackbarKind.ERROR,
-                    })
-                );
-            }
-        };
-
-export const copyProcess =
-    (data: CopyFormDialogData) =>
-        async (
-            dispatch: Dispatch,
-            getState: () => RootState,
-            services: ServiceRepository
-        ) => {
-            try {
-                const process = await dispatch<any>(processCopyActions.copyProcess(data));
-                dispatch<any>(updateResources([process]));
-                dispatch<any>(reloadProjectMatchingUuid([process.ownerUuid]));
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: 'Process has been copied.',
-                        hideDuration: 2000,
-                        kind: SnackbarKind.SUCCESS,
-                    })
-                );
-                dispatch<any>(navigateTo(process.uuid));
-            } catch (e) {
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: e.message,
-                        hideDuration: 2000,
-                        kind: SnackbarKind.ERROR,
-                    })
-                );
-            }
-        };
+export const copyProcess = (data: CopyFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    try {
+        const process = await dispatch<any>(processCopyActions.copyProcess(data));
+        dispatch<any>(updateResources([process]));
+        dispatch<any>(reloadProjectMatchingUuid([process.ownerUuid]));
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: "Process has been copied.",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
+        dispatch<any>(navigateTo(process.uuid));
+    } catch (e) {
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: e.message,
+                hideDuration: 2000,
+                kind: SnackbarKind.ERROR,
+            })
+        );
+    }
+};
 
 export const resourceIsNotLoaded = (uuid: string) =>
     snackbarActions.OPEN_SNACKBAR({
@@ -683,106 +698,70 @@ export const resourceIsNotLoaded = (uuid: string) =>
     });
 
 export const userIsNotAuthenticated = snackbarActions.OPEN_SNACKBAR({
-    message: 'User is not authenticated',
+    message: "User is not authenticated",
     kind: SnackbarKind.ERROR,
 });
 
 export const couldNotLoadUser = snackbarActions.OPEN_SNACKBAR({
-    message: 'Could not load user',
+    message: "Could not load user",
     kind: SnackbarKind.ERROR,
 });
 
 export const reloadProjectMatchingUuid =
-    (matchingUuids: string[]) =>
-        async (
-            dispatch: Dispatch,
-            getState: () => RootState,
-            services: ServiceRepository
-        ) => {
-            const currentProjectPanelUuid = getProjectPanelCurrentUuid(getState());
-            if (
-                currentProjectPanelUuid &&
-                matchingUuids.some((uuid) => uuid === currentProjectPanelUuid)
-            ) {
-                dispatch<any>(loadProject(currentProjectPanelUuid));
-            }
-        };
+    (matchingUuids: string[]) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const currentProjectPanelUuid = getProjectPanelCurrentUuid(getState());
+        if (currentProjectPanelUuid && matchingUuids.some(uuid => uuid === currentProjectPanelUuid)) {
+            dispatch<any>(loadProject(currentProjectPanelUuid));
+        }
+    };
 
-export const loadSharedWithMe = handleFirstTimeLoad(
-    async (dispatch: Dispatch) => {
-        dispatch<any>(loadSharedWithMePanel());
-        await dispatch<any>(
-            activateSidePanelTreeItem(SidePanelTreeCategory.SHARED_WITH_ME)
-        );
-        await dispatch<any>(
-            setSidePanelBreadcrumbs(SidePanelTreeCategory.SHARED_WITH_ME)
-        );
-    }
-);
+export const loadSharedWithMe = handleFirstTimeLoad(async (dispatch: Dispatch) => {
+    dispatch<any>(loadSharedWithMePanel());
+    await dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.SHARED_WITH_ME));
+    await dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.SHARED_WITH_ME));
+});
 
-export const loadRunProcess = handleFirstTimeLoad(
-    async (dispatch: Dispatch) => {
-        await dispatch<any>(loadRunProcessPanel());
-    }
-);
+export const loadRunProcess = handleFirstTimeLoad(async (dispatch: Dispatch) => {
+    await dispatch<any>(loadRunProcessPanel());
+});
 
 export const loadPublicFavorites = () =>
     handleFirstTimeLoad((dispatch: Dispatch) => {
-        dispatch<any>(
-            activateSidePanelTreeItem(SidePanelTreeCategory.PUBLIC_FAVORITES)
-        );
+        dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.PUBLIC_FAVORITES));
         dispatch<any>(loadPublicFavoritePanel());
-        dispatch<any>(
-            setSidePanelBreadcrumbs(SidePanelTreeCategory.PUBLIC_FAVORITES)
-        );
+        dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.PUBLIC_FAVORITES));
     });
 
-export const loadSearchResults = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadSearchResultsPanel());
-    }
-);
+export const loadSearchResults = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadSearchResultsPanel());
+});
 
-export const loadLinks = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadLinkPanel());
-    }
-);
+export const loadLinks = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadLinkPanel());
+});
 
-export const loadVirtualMachines = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadVirtualMachinesPanel());
-        dispatch(setBreadcrumbs([{ label: 'Virtual Machines' }]));
-    }
-);
+export const loadVirtualMachines = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadVirtualMachinesPanel());
+    dispatch(setBreadcrumbs([{ label: "Virtual Machines" }]));
+});
 
-export const loadVirtualMachinesAdmin = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadVirtualMachinesPanel());
-        dispatch(
-            setBreadcrumbs([{ label: 'Virtual Machines Admin', icon: AdminMenuIcon }])
-        );
-    }
-);
+export const loadVirtualMachinesAdmin = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadVirtualMachinesPanel());
+    dispatch(setBreadcrumbs([{ label: "Virtual Machines Admin", icon: AdminMenuIcon }]));
+});
 
-export const loadRepositories = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadRepositoriesPanel());
-        dispatch(setBreadcrumbs([{ label: 'Repositories' }]));
-    }
-);
+export const loadRepositories = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadRepositoriesPanel());
+    dispatch(setBreadcrumbs([{ label: "Repositories" }]));
+});
 
-export const loadSshKeys = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadSshKeysPanel());
-    }
-);
+export const loadSshKeys = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadSshKeysPanel());
+});
 
-export const loadSiteManager = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadSiteManagerPanel());
-    }
-);
+export const loadSiteManager = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadSiteManagerPanel());
+});
 
 export const loadUserProfile = (userUuid?: string) =>
     handleFirstTimeLoad((dispatch: Dispatch<any>) => {
@@ -795,37 +774,27 @@ export const loadUserProfile = (userUuid?: string) =>
         }
     });
 
-export const loadLinkAccount = handleFirstTimeLoad(
-    (dispatch: Dispatch<any>) => {
-        dispatch(loadLinkAccountPanel());
-    }
-);
+export const loadLinkAccount = handleFirstTimeLoad((dispatch: Dispatch<any>) => {
+    dispatch(loadLinkAccountPanel());
+});
 
-export const loadKeepServices = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadKeepServicesPanel());
-    }
-);
+export const loadKeepServices = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadKeepServicesPanel());
+});
 
-export const loadUsers = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadUsersPanel());
-        dispatch(setUsersBreadcrumbs());
-    }
-);
+export const loadUsers = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadUsersPanel());
+    dispatch(setUsersBreadcrumbs());
+});
 
-export const loadApiClientAuthorizations = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadApiClientAuthorizationsPanel());
-    }
-);
+export const loadApiClientAuthorizations = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadApiClientAuthorizationsPanel());
+});
 
-export const loadGroupsPanel = handleFirstTimeLoad(
-    (dispatch: Dispatch<any>) => {
-        dispatch(setGroupsBreadcrumbs());
-        dispatch(groupPanelActions.loadGroupsPanel());
-    }
-);
+export const loadGroupsPanel = handleFirstTimeLoad((dispatch: Dispatch<any>) => {
+    dispatch(setGroupsBreadcrumbs());
+    dispatch(groupPanelActions.loadGroupsPanel());
+});
 
 export const loadGroupDetailsPanel = (groupUuid: string) =>
     handleFirstTimeLoad((dispatch: Dispatch<any>) => {
@@ -833,40 +802,26 @@ export const loadGroupDetailsPanel = (groupUuid: string) =>
         dispatch(groupDetailsPanelActions.loadGroupDetailsPanel(groupUuid));
     });
 
-const finishLoadingProject =
-    (project: GroupContentsResource | string) =>
-        async (dispatch: Dispatch<any>) => {
-            const uuid = typeof project === 'string' ? project : project.uuid;
-            dispatch(openProjectPanel(uuid));
-            dispatch(loadDetailsPanel(uuid));
-            if (typeof project !== 'string') {
-                dispatch(updateResources([project]));
-            }
-        };
+const finishLoadingProject = (project: GroupContentsResource | string) => async (dispatch: Dispatch<any>) => {
+    const uuid = typeof project === "string" ? project : project.uuid;
+    dispatch(loadDetailsPanel(uuid));
+    if (typeof project !== "string") {
+        dispatch(updateResources([project]));
+    }
+};
 
-const loadGroupContentsResource = async (params: {
-    uuid: string;
-    userUuid: string;
-    services: ServiceRepository;
-}) => {
-    const filters = new FilterBuilder()
-        .addEqual('uuid', params.uuid)
-        .getFilters();
-    const { items } = await params.services.groupsService.contents(
-        params.userUuid,
-        {
-            filters,
-            recursive: true,
-            includeTrash: true,
-        }
-    );
+const loadGroupContentsResource = async (params: { uuid: string; userUuid: string; services: ServiceRepository }) => {
+    const filters = new FilterBuilder().addEqual("uuid", params.uuid).getFilters();
+    const { items } = await params.services.groupsService.contents(params.userUuid, {
+        filters,
+        recursive: true,
+        includeTrash: true,
+    });
     const resource = items.shift();
     let handler: GroupContentsHandler;
     if (resource) {
         handler =
-            (resource.kind === ResourceKind.COLLECTION ||
-                resource.kind === ResourceKind.PROJECT) &&
-                resource.isTrashed
+            (resource.kind === ResourceKind.COLLECTION || resource.kind === ResourceKind.PROJECT) && resource.isTrashed
                 ? groupContentsHandlers.TRASHED(resource)
                 : groupContentsHandlers.OWNED(resource);
     } else {
@@ -876,18 +831,16 @@ const loadGroupContentsResource = async (params: {
             resource = await params.services.collectionService.get(params.uuid);
         } else if (kind === ResourceKind.PROJECT) {
             resource = await params.services.projectService.get(params.uuid);
-        } else {
+        } else if (kind === ResourceKind.WORKFLOW) {
+            resource = await params.services.workflowService.get(params.uuid);
+        } else if (kind === ResourceKind.CONTAINER_REQUEST) {
             resource = await params.services.containerRequestService.get(params.uuid);
+        } else {
+            throw new Error("loadGroupContentsResource unsupported kind " + kind);
         }
         handler = groupContentsHandlers.SHARED(resource);
     }
-    return (
-        cases: MatchCases<
-            typeof groupContentsHandlersRecord,
-            GroupContentsHandler,
-            void
-        >
-    ) => groupContentsHandlers.match(handler, cases);
+    return (cases: MatchCases<typeof groupContentsHandlersRecord, GroupContentsHandler, void>) => groupContentsHandlers.match(handler, cases);
 };
 
 const groupContentsHandlersRecord = {
@@ -899,3 +852,18 @@ const groupContentsHandlersRecord = {
 const groupContentsHandlers = unionize(groupContentsHandlersRecord);
 
 type GroupContentsHandler = UnionOf<typeof groupContentsHandlers>;
+
+type CollectionCopyResource = Resource & { name: string; fromContextMenu: boolean };
+
+type MoveableResource = Resource & { name: string };
+
+type MoveFunc = (
+    data: MoveToFormDialogData,
+    isSecondaryMove?: boolean
+) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => Promise<void>;
+
+const secondaryMove: Record<string, MoveFunc> = {
+    [ResourceKind.PROJECT]: moveProject,
+    [ResourceKind.PROCESS]: moveProcess,
+    [ResourceKind.COLLECTION]: moveCollection,
+};
index 66a15a9ee09be464022b9d0f969b087823dd12e1..d8c3b6514135414404e6b1132be5d9302483173e 100644 (file)
@@ -8,14 +8,14 @@ import { ServiceRepository } from 'services/services';
 import { bindDataExplorerActions } from 'store/data-explorer/data-explorer-action';
 import { propertiesActions } from 'store/properties/properties-actions';
 import { getProperty } from 'store/properties/properties';
-import { navigateToRunProcess } from 'store/navigation/navigation-action';
+import { navigateToRunProcess, navigateTo } from 'store/navigation/navigation-action';
 import {
     goToStep,
     runProcessPanelActions,
     loadPresets,
     getWorkflowRunnerSettings
 } from 'store/run-process-panel/run-process-panel-actions';
-import { snackbarActions } from 'store/snackbar/snackbar-actions';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 import { initialize } from 'redux-form';
 import { RUN_PROCESS_BASIC_FORM } from 'views/run-process-panel/run-process-basic-form';
 import { RUN_PROCESS_INPUTS_FORM } from 'views/run-process-panel/run-process-inputs-form';
@@ -23,7 +23,6 @@ import { RUN_PROCESS_ADVANCED_FORM } from 'views/run-process-panel/run-process-a
 import { getResource } from 'store/resources/resources';
 import { ProjectResource } from 'models/project';
 import { UserResource } from 'models/user';
-import { getUserUuid } from "common/getuser";
 import { getWorkflowInputs, parseWorkflowDefinition } from 'models/workflow';
 
 export const WORKFLOW_PANEL_ID = "workflowPanel";
@@ -63,9 +62,8 @@ export const openRunProcess = (workflowUuid: string, ownerUuid?: string, name?:
             let owner;
             if (ownerUuid) {
                 // Must be writable.
-                const userUuid = getUserUuid(getState());
                 owner = getResource<ProjectResource | UserResource>(ownerUuid)(getState().resources);
-                if (!owner || !userUuid || owner.writableBy.indexOf(userUuid) === -1) {
+                if (!owner || !owner.canWrite) {
                     owner = undefined;
                 }
             }
@@ -103,6 +101,10 @@ export const getPublicGroupUuid = (state: RootState) => {
     const prefix = state.auth.localCluster;
     return `${prefix}-j7d0g-anonymouspublic`;
 };
+export const getAllUsersGroupUuid = (state: RootState) => {
+    const prefix = state.auth.localCluster;
+    return `${prefix}-j7d0g-fffffffffffffff`;
+};
 
 export const showWorkflowDetails = (uuid: string) =>
     propertiesActions.SET_PROPERTY({ key: WORKFLOW_PANEL_DETAILS_UUID, value: uuid });
@@ -113,3 +115,11 @@ export const getWorkflowDetails = (state: RootState) => {
     const workflow = workflows.find(workflow => workflow.uuid === uuid);
     return workflow || undefined;
 };
+
+export const deleteWorkflow = (workflowUuid: string, ownerUuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch<any>(navigateTo(ownerUuid));
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...', kind: SnackbarKind.INFO }));
+        await services.workflowService.delete(workflowUuid);
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+    };
index bc84ed2cf3aeef617ff3cafcbacab432536a027a..3505faed4366b1518d6986176bad278d7a005fef 100644 (file)
@@ -120,6 +120,6 @@ const dialogContentExample = (example: JSX.Element | string, classes: any) => {
         className={classes.codeSnippet}
         lines={stringData ? [stringData] : []}
     >
-        {example as JSX.Element || null}
+        {React.isValidElement(example) ? (example as JSX.Element) : undefined}
     </DefaultCodeSnippet>;
 }
index 9fae638107e5aba50e07e4a77427ce50c2b962a1..ac5b89439cfa10e1505b879c099b732b12d97f35 100644 (file)
@@ -2,39 +2,41 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React, { useState, useCallback, useEffect } from 'react';
+import React, { useState, useCallback, useEffect } from "react";
 import { Dialog, DialogContent, DialogActions, Button, StyleRulesCallback, withStyles, WithStyles } from "@material-ui/core";
 import { connect } from "react-redux";
 import { RootState } from "store/store";
 import bannerActions from "store/banner/banner-action";
-import { ArvadosTheme } from 'common/custom-theme';
-import servicesProvider from 'common/service-provider';
-import { Dispatch } from 'redux';
+import { ArvadosTheme } from "common/custom-theme";
+import servicesProvider from "common/service-provider";
+import { Dispatch } from "redux";
+import { sanitizeHTML } from "common/html-sanitize";
 
-type CssRules = 'dialogContent' | 'dialogContentIframe';
+type CssRules = "dialogContent" | "dialogContentIframe";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     dialogContent: {
-        minWidth: '550px',
-        minHeight: '500px',
-        display: 'block'
+        minWidth: "550px",
+        minHeight: "500px",
+        display: "block",
     },
     dialogContentIframe: {
-        minWidth: '550px',
-        minHeight: '500px'
-    }
+        minWidth: "550px",
+        minHeight: "500px",
+    },
 });
 
 interface BannerProps {
     isOpen: boolean;
     bannerUUID?: string;
     keepWebInlineServiceUrl: string;
-};
+}
 
-type BannerComponentProps = BannerProps & WithStyles<CssRules> & {
-    openBanner: Function,
-    closeBanner: Function,
-};
+type BannerComponentProps = BannerProps &
+    WithStyles<CssRules> & {
+        openBanner: Function;
+        closeBanner: Function;
+    };
 
 const mapStateToProps = (state: RootState): BannerProps => ({
     isOpen: state.banner.isOpen,
@@ -47,27 +49,23 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({
     closeBanner: () => dispatch<any>(bannerActions.closeBanner()),
 });
 
-export const BANNER_LOCAL_STORAGE_KEY = 'bannerFileData';
+export const BANNER_LOCAL_STORAGE_KEY = "bannerFileData";
 
 export const BannerComponent = (props: BannerComponentProps) => {
-    const { 
-        isOpen,
-        openBanner,
-        closeBanner,
-        bannerUUID,
-        keepWebInlineServiceUrl
-    } = props;
-    const [bannerContents, setBannerContents] = useState(`<h1>Loading ...</h1>`)
+    const { isOpen, openBanner, closeBanner, bannerUUID, keepWebInlineServiceUrl } = props;
+    const [bannerContents, setBannerContents] = useState(`<h1>Loading ...</h1>`);
 
     const onConfirm = useCallback(() => {
         closeBanner();
-    }, [closeBanner])
+    }, [closeBanner]);
 
     useEffect(() => {
         if (!!bannerUUID && bannerUUID !== "") {
-            servicesProvider.getServices().collectionService.files(bannerUUID)
+            servicesProvider
+                .getServices()
+                .collectionService.files(bannerUUID)
                 .then(results => {
-                    const bannerFileData = results.find(({name}) => name === 'banner.html');
+                    const bannerFileData = results.find(({ name }) => name === "banner.html");
                     const result = localStorage.getItem(BANNER_LOCAL_STORAGE_KEY);
 
                     if (result && result === JSON.stringify(bannerFileData) && !isOpen) {
@@ -75,7 +73,8 @@ export const BannerComponent = (props: BannerComponentProps) => {
                     }
 
                     if (bannerFileData) {
-                        servicesProvider.getServices()
+                        servicesProvider
+                            .getServices()
                             .collectionService.getFileContents(bannerFileData)
                             .then(data => {
                                 setBannerContents(data);
@@ -88,24 +87,28 @@ export const BannerComponent = (props: BannerComponentProps) => {
     }, [bannerUUID, keepWebInlineServiceUrl, openBanner, isOpen]);
 
     return (
-        <Dialog open={isOpen}>
-            <div data-cy='confirmation-dialog'>
+        <Dialog
+            open={isOpen}
+            maxWidth="md"
+        >
+            <div data-cy="confirmation-dialog">
                 <DialogContent className={props.classes.dialogContent}>
-                    <div dangerouslySetInnerHTML={{ __html: bannerContents }}></div>
+                    <div dangerouslySetInnerHTML={{ __html: sanitizeHTML(bannerContents) }}></div>
                 </DialogContent>
-                <DialogActions style={{ margin: '0px 24px 24px' }}>
+                <DialogActions style={{ margin: "0px 24px 24px" }}>
                     <Button
-                        data-cy='confirmation-dialog-ok-btn'
-                        variant='contained'
-                        color='primary'
-                        type='submit'
-                        onClick={onConfirm}>
+                        data-cy="confirmation-dialog-ok-btn"
+                        variant="contained"
+                        color="primary"
+                        type="submit"
+                        onClick={onConfirm}
+                    >
                         Close
                     </Button>
                 </DialogActions>
             </div>
         </Dialog>
     );
-}
+};
 
 export const Banner = withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(BannerComponent));
index aeaa6a22f85e50c3a6f39cfea891507c03114b63..8e75d22f6714d97853f2d7061aa9167dc793a072 100644 (file)
@@ -4,28 +4,34 @@
 
 import {
     openApiClientAuthorizationAttributesDialog,
-    openApiClientAuthorizationRemoveDialog
-} from 'store/api-client-authorizations/api-client-authorizations-actions';
-import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
+    openApiClientAuthorizationRemoveDialog,
+} from "store/api-client-authorizations/api-client-authorizations-actions";
+import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
 import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
 import { AdvancedIcon, RemoveIcon, AttributesIcon } from "components/icon/icon";
 
-export const apiClientAuthorizationActionSet: ContextMenuActionSet = [[{
-    name: "Attributes",
-    icon: AttributesIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openApiClientAuthorizationAttributesDialog(uuid));
-    }
-}, {
-    name: "API Details",
-    icon: AdvancedIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openAdvancedTabDialog(uuid));
-    }
-}, {
-    name: "Remove",
-    icon: RemoveIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openApiClientAuthorizationRemoveDialog(uuid));
-    }
-}]];
+export const apiClientAuthorizationActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: "Attributes",
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                    dispatch<any>(openApiClientAuthorizationAttributesDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: "API Details",
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                    dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: "Remove",
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                    dispatch<any>(openApiClientAuthorizationRemoveDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
index edfaa3cdf0b84d609eab951ac380dabaca73c5ce..95aec9c7c94f476be3de1aa2f595040211b8d6b6 100644 (file)
@@ -2,10 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import {
-    ContextMenuAction,
-    ContextMenuActionSet
-} from "../context-menu-action-set";
+import { ContextMenuAction, ContextMenuActionSet } from "../context-menu-action-set";
 import { ToggleFavoriteAction } from "../actions/favorite-action";
 import { toggleFavorite } from "store/favorites/favorites-actions";
 import {
@@ -18,84 +15,90 @@ import {
     OpenIcon,
     Link,
     RestoreVersionIcon,
-    FolderSharedIcon
+    FolderSharedIcon,
 } from "components/icon/icon";
 import { openCollectionUpdateDialog } from "store/collections/collection-update-actions";
 import { favoritePanelActions } from "store/favorite-panel/favorite-panel-action";
-import { openMoveCollectionDialog } from 'store/collections/collection-move-actions';
-import { openCollectionCopyDialog } from "store/collections/collection-copy-actions";
+import { openMoveCollectionDialog } from "store/collections/collection-move-actions";
+import { openCollectionCopyDialog, openMultiCollectionCopyDialog } from "store/collections/collection-copy-actions";
 import { openWebDavS3InfoDialog } from "store/collections/collection-info-actions";
 import { ToggleTrashAction } from "views-components/context-menu/actions/trash-action";
 import { toggleCollectionTrashed } from "store/trash/trash-actions";
-import { openSharingDialog } from 'store/sharing-dialog/sharing-dialog-actions';
+import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions";
 import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
-import { toggleDetailsPanel } from 'store/details-panel/details-panel-action';
+import { toggleDetailsPanel } from "store/details-panel/details-panel-action";
 import { copyToClipboardAction, openInNewTabAction } from "store/open-in-new-tab/open-in-new-tab.actions";
 import { openRestoreCollectionVersionDialog } from "store/collections/collection-version-actions";
 import { TogglePublicFavoriteAction } from "../actions/public-favorite-action";
 import { togglePublicFavorite } from "store/public-favorites/public-favorites-actions";
 import { publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
 
 const toggleFavoriteAction: ContextMenuAction = {
     component: ToggleFavoriteAction,
-    name: 'ToggleFavoriteAction',
-    execute: (dispatch, resource) => {
-        dispatch<any>(toggleFavorite(resource)).then(() => {
-            dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
-        });
-    }
-};
-
-const commonActionSet: ContextMenuActionSet = [[
-    {
-        icon: OpenIcon,
-        name: "Open in new tab",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openInNewTabAction(resource));
-        }
-    },
-    {
-        icon: Link,
-        name: "Copy to clipboard",
-        execute: (dispatch, resource) => {
-            dispatch<any>(copyToClipboardAction(resource));
-        }
-    },
-    {
-        icon: CopyIcon,
-        name: "Make a copy",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openCollectionCopyDialog(resource));
-        }
-
-    },
-    {
-        icon: DetailsIcon,
-        name: "View details",
-        execute: dispatch => {
-            dispatch<any>(toggleDetailsPanel());
-        }
-    },
-    {
-        icon: AdvancedIcon,
-        name: "API Details",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openAdvancedTabDialog(resource.uuid));
+    name: "ToggleFavoriteAction",
+    execute: (dispatch, resources) => {
+        for (const resource of [...resources]) {
+            dispatch<any>(toggleFavorite(resource)).then(() => {
+                dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
+            });
         }
     },
-]];
+};
+const commonActionSet: ContextMenuActionSet = [
+    [
+        {
+            icon: OpenIcon,
+            name: "Open in new tab",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openInNewTabAction(resources[0]));
+            },
+        },
+        {
+            icon: Link,
+            name: "Copy to clipboard",
+            execute: (dispatch, resources) => {
+                dispatch<any>(copyToClipboardAction(resources));
+            },
+        },
+        {
+            icon: CopyIcon,
+            name: "Make a copy",
+            execute: (dispatch, resources) => {
+                if (resources[0].fromContextMenu || resources.length === 1) dispatch<any>(openCollectionCopyDialog(resources[0]));
+                else dispatch<any>(openMultiCollectionCopyDialog(resources[0]));
+            },
+        },
+        {
+            icon: DetailsIcon,
+            name: "View details",
+            execute: dispatch => {
+                dispatch<any>(toggleDetailsPanel());
+            },
+        },
+        {
+            icon: AdvancedIcon,
+            name: "API Details",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
 
-export const readOnlyCollectionActionSet: ContextMenuActionSet = [[
-    ...commonActionSet.reduce((prev, next) => prev.concat(next), []),
-    toggleFavoriteAction,
-    {
-        icon: FolderSharedIcon,
-        name: "Open with 3rd party client",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openWebDavS3InfoDialog(resource.uuid));
-        }
-    },
-]];
+export const readOnlyCollectionActionSet: ContextMenuActionSet = [
+    [
+        ...commonActionSet.reduce((prev, next) => prev.concat(next), []),
+        toggleFavoriteAction,
+        {
+            icon: FolderSharedIcon,
+            name: "Open with 3rd party client",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openWebDavS3InfoDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
 
 export const collectionActionSet: ContextMenuActionSet = [
     [
@@ -103,30 +106,32 @@ export const collectionActionSet: ContextMenuActionSet = [
         {
             icon: RenameIcon,
             name: "Edit collection",
-            execute: (dispatch, resource) => {
-                dispatch<any>(openCollectionUpdateDialog(resource));
-            }
+            execute: (dispatch, resources) => {
+                dispatch<any>(openCollectionUpdateDialog(resources[0]));
+            },
         },
         {
             icon: ShareIcon,
             name: "Share",
-            execute: (dispatch, { uuid }) => {
-                dispatch<any>(openSharingDialog(uuid));
-            }
+            execute: (dispatch, resources) => {
+                dispatch<any>(openSharingDialog(resources[0].uuid));
+            },
         },
         {
             icon: MoveToIcon,
             name: "Move to",
-            execute: (dispatch, resource) => dispatch<any>(openMoveCollectionDialog(resource))
+            execute: (dispatch, resources) => dispatch<any>(openMoveCollectionDialog(resources[0])),
         },
         {
             component: ToggleTrashAction,
-            name: 'ToggleTrashAction',
-            execute: (dispatch, resource) => {
-                dispatch<any>(toggleCollectionTrashed(resource.uuid, resource.isTrashed!!));
-            }
+            name: "ToggleTrashAction",
+            execute: (dispatch, resources: ContextMenuResource[]) => {
+                for (const resource of [...resources]) {
+                    dispatch<any>(toggleCollectionTrashed(resource.uuid, resource.isTrashed!!));
+                }
+            },
         },
-    ]
+    ],
 ];
 
 export const collectionAdminActionSet: ContextMenuActionSet = [
@@ -134,14 +139,16 @@ export const collectionAdminActionSet: ContextMenuActionSet = [
         ...collectionActionSet.reduce((prev, next) => prev.concat(next), []),
         {
             component: TogglePublicFavoriteAction,
-            name: 'TogglePublicFavoriteAction',
-            execute: (dispatch, resource) => {
-                dispatch<any>(togglePublicFavorite(resource)).then(() => {
-                    dispatch<any>(publicFavoritePanelActions.REQUEST_ITEMS());
-                });
-            }
+            name: "TogglePublicFavoriteAction",
+            execute: (dispatch, resources) => {
+                for (const resource of [...resources]) {
+                    dispatch<any>(togglePublicFavorite(resource)).then(() => {
+                        dispatch<any>(publicFavoritePanelActions.REQUEST_ITEMS());
+                    });
+                }
+            },
         },
-    ]
+    ],
 ];
 
 export const oldCollectionVersionActionSet: ContextMenuActionSet = [
@@ -149,10 +156,12 @@ export const oldCollectionVersionActionSet: ContextMenuActionSet = [
         ...commonActionSet.reduce((prev, next) => prev.concat(next), []),
         {
             icon: RestoreVersionIcon,
-            name: 'Restore version',
-            execute: (dispatch, { uuid }) => {
-                dispatch<any>(openRestoreCollectionVersionDialog(uuid));
-            }
+            name: "Restore version",
+            execute: (dispatch, resources) => {
+                for (const resource of [...resources]) {
+                    dispatch<any>(openRestoreCollectionVersionDialog(resource.uuid));
+                }
+            },
         },
-    ]
+    ],
 ];
index f34f286840c362dbc29d9cea96428df3d7da38cc..80deb37cade38c6768421f80187ee7ac6f5a0fe7 100644 (file)
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
+import { ContextMenuAction, ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
 import { collectionPanelFilesAction, openMultipleFilesRemoveDialog } from "store/collection-panel/collection-panel-files/collection-panel-files-actions";
 import {
-    openCollectionPartialCopyDialog,
-    // Disabled while addressing #18587
-    // openCollectionPartialCopyToSelectedCollectionDialog
+    openCollectionPartialCopyMultipleToNewCollectionDialog,
+    openCollectionPartialCopyMultipleToExistingCollectionDialog,
+    openCollectionPartialCopyToSeparateCollectionsDialog
 } from 'store/collections/collection-partial-copy-actions';
+import { openCollectionPartialMoveMultipleToExistingCollectionDialog, openCollectionPartialMoveMultipleToNewCollectionDialog, openCollectionPartialMoveToSeparateCollectionsDialog } from "store/collections/collection-partial-move-actions";
+import { FileCopyIcon, FileMoveIcon, RemoveIcon, SelectAllIcon, SelectNoneIcon } from "components/icon/icon";
 
-// These action sets are used on the multi-select actions button.
-export const readOnlyCollectionFilesActionSet: ContextMenuActionSet = [[
+const copyActions: ContextMenuAction[] = [
     {
-        name: "Select all",
+        name: "Copy selected into new collection",
+        icon: FileCopyIcon,
         execute: dispatch => {
-            dispatch(collectionPanelFilesAction.SELECT_ALL_COLLECTION_FILES());
+            dispatch<any>(openCollectionPartialCopyMultipleToNewCollectionDialog());
         }
     },
     {
-        name: "Unselect all",
+        name: "Copy selected into existing collection",
+        icon: FileCopyIcon,
         execute: dispatch => {
-            dispatch(collectionPanelFilesAction.UNSELECT_ALL_COLLECTION_FILES());
+            dispatch<any>(openCollectionPartialCopyMultipleToExistingCollectionDialog());
+        }
+    },
+];
+
+const copyActionsMultiple: ContextMenuAction[] = [
+    ...copyActions,
+    {
+        name: "Copy selected into separate collections",
+        icon: FileCopyIcon,
+        execute: dispatch => {
+            dispatch<any>(openCollectionPartialCopyToSeparateCollectionsDialog());
+        }
+    }
+];
+
+const moveActions: ContextMenuAction[] = [
+    {
+        name: "Move selected into new collection",
+        icon: FileMoveIcon,
+        execute: dispatch => {
+            dispatch<any>(openCollectionPartialMoveMultipleToNewCollectionDialog());
         }
     },
     {
-        name: "Create a new collection with selected",
+        name: "Move selected into existing collection",
+        icon: FileMoveIcon,
         execute: dispatch => {
-            dispatch<any>(openCollectionPartialCopyDialog());
+            dispatch<any>(openCollectionPartialMoveMultipleToExistingCollectionDialog());
         }
     },
-    // Disabled while addressing #18587
-    // {
-    //     name: "Copy selected into the collection",
-    //     execute: dispatch => {
-    //         dispatch<any>(openCollectionPartialCopyToSelectedCollectionDialog());
-    //     }
-    // }
-]];
+];
 
-export const collectionFilesActionSet: ContextMenuActionSet = readOnlyCollectionFilesActionSet.concat([[
+const moveActionsMultiple: ContextMenuAction[] = [
+    ...moveActions,
     {
-        name: "Remove selected",
+        name: "Move selected into separate collections",
+        icon: FileMoveIcon,
         execute: dispatch => {
-            dispatch(openMultipleFilesRemoveDialog());
+            dispatch<any>(openCollectionPartialMoveToSeparateCollectionsDialog());
+        }
+    }
+];
+
+const selectActions: ContextMenuAction[] = [
+    {
+        name: "Select all",
+        icon: SelectAllIcon,
+        execute: dispatch => {
+            dispatch(collectionPanelFilesAction.SELECT_ALL_COLLECTION_FILES());
+        }
+    },
+    {
+        name: "Unselect all",
+        icon: SelectNoneIcon,
+        execute: dispatch => {
+            dispatch(collectionPanelFilesAction.UNSELECT_ALL_COLLECTION_FILES());
         }
     },
+];
+
+const removeAction: ContextMenuAction = {
+    name: "Remove selected",
+    icon: RemoveIcon,
+    execute: dispatch => {
+        dispatch(openMultipleFilesRemoveDialog());
+    }
+};
+
+// These action sets are used on the multi-select actions button.
+export const readOnlyCollectionFilesActionSet: ContextMenuActionSet = [
+    selectActions,
+    copyActions,
+];
+
+export const readOnlyCollectionFilesMultipleActionSet: ContextMenuActionSet = [
+    selectActions,
+    copyActionsMultiple,
+];
+
+export const collectionFilesActionSet: ContextMenuActionSet = readOnlyCollectionFilesActionSet.concat([[
+    removeAction,
+    ...moveActions
+]]);
+
+export const collectionFilesMultipleActionSet: ContextMenuActionSet = readOnlyCollectionFilesMultipleActionSet.concat([[
+    removeAction,
+    ...moveActionsMultiple
 ]]);
index 4cb9ebda4c02a4c421a6aa9f44bd92cf05255b84..fb158a826d58904dd055d8b5b1e4b22aa3f2e469 100644 (file)
 // SPDX-License-Identifier: AGPL-3.0
 
 import { ContextMenuActionSet } from "../context-menu-action-set";
-import { RemoveIcon, RenameIcon } from "components/icon/icon";
+import { FileCopyIcon, FileMoveIcon, RemoveIcon, RenameIcon } from "components/icon/icon";
 import { DownloadCollectionFileAction } from "../actions/download-collection-file-action";
-import { openFileRemoveDialog, openRenameFileDialog } from 'store/collection-panel/collection-panel-files/collection-panel-files-actions';
-import { CollectionFileViewerAction } from 'views-components/context-menu/actions/collection-file-viewer-action';
+import { openFileRemoveDialog, openRenameFileDialog } from "store/collection-panel/collection-panel-files/collection-panel-files-actions";
+import { CollectionFileViewerAction } from "views-components/context-menu/actions/collection-file-viewer-action";
 import { CollectionCopyToClipboardAction } from "../actions/collection-copy-to-clipboard-action";
+import {
+    openCollectionPartialMoveToExistingCollectionDialog,
+    openCollectionPartialMoveToNewCollectionDialog,
+} from "store/collections/collection-partial-move-actions";
+import {
+    openCollectionPartialCopyToExistingCollectionDialog,
+    openCollectionPartialCopyToNewCollectionDialog,
+} from "store/collections/collection-partial-copy-actions";
 
-export const readOnlyCollectionDirectoryItemActionSet: ContextMenuActionSet = [[
-    {
-        component: CollectionFileViewerAction,
-        execute: () => { return; },
-    },
-    {
-        component: CollectionCopyToClipboardAction,
-        execute: () => { return; },
-    }
-]];
+export const readOnlyCollectionDirectoryItemActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: "Copy item into new collection",
+            icon: FileCopyIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openCollectionPartialCopyToNewCollectionDialog(resources[0]));
+            },
+        },
+        {
+            name: "Copy item into existing collection",
+            icon: FileCopyIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openCollectionPartialCopyToExistingCollectionDialog(resources[0]));
+            },
+        },
+        {
+            component: CollectionFileViewerAction,
+            execute: () => {
+                return;
+            },
+        },
+        {
+            component: CollectionCopyToClipboardAction,
+            execute: () => {
+                return;
+            },
+        },
+    ],
+];
 
-export const readOnlyCollectionFileItemActionSet: ContextMenuActionSet = [[
-    {
-        component: DownloadCollectionFileAction,
-        execute: () => { return; }
-    },
-    ...readOnlyCollectionDirectoryItemActionSet.reduce((prev, next) => prev.concat(next), []),
-]];
+export const readOnlyCollectionFileItemActionSet: ContextMenuActionSet = [
+    [
+        {
+            component: DownloadCollectionFileAction,
+            execute: () => {
+                return;
+            },
+        },
+        ...readOnlyCollectionDirectoryItemActionSet.reduce((prev, next) => prev.concat(next), []),
+    ],
+];
 
-const writableActionSet: ContextMenuActionSet = [[
-    {
-        name: "Rename",
-        icon: RenameIcon,
-        execute: (dispatch, resource) => {
-            dispatch<any>(openRenameFileDialog({
-                name: resource.name,
-                id: resource.uuid,
-                path: resource.uuid.split('/').slice(1).join('/') }));
-        }
-    },
-    {
-        name: "Remove",
-        icon: RemoveIcon,
-        execute: (dispatch, resource) => {
-            dispatch<any>(openFileRemoveDialog(resource.uuid));
-        }
-    }
-]];
+const writableActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: "Move item into new collection",
+            icon: FileMoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openCollectionPartialMoveToNewCollectionDialog(resources[0]));
+            },
+        },
+        {
+            name: "Move item into existing collection",
+            icon: FileMoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openCollectionPartialMoveToExistingCollectionDialog(resources[0]));
+            },
+        },
+        {
+            name: "Rename",
+            icon: RenameIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(
+                    openRenameFileDialog({
+                        name: resources[0].name,
+                        id: resources[0].uuid,
+                        path: resources[0].uuid.split("/").slice(1).join("/"),
+                    })
+                );
+            },
+        },
+        {
+            name: "Remove",
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openFileRemoveDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
 
 export const collectionDirectoryItemActionSet: ContextMenuActionSet = readOnlyCollectionDirectoryItemActionSet.concat(writableActionSet);
 
-export const collectionFileItemActionSet: ContextMenuActionSet = readOnlyCollectionFileItemActionSet.concat(writableActionSet);
\ No newline at end of file
+export const collectionFileItemActionSet: ContextMenuActionSet = readOnlyCollectionFileItemActionSet.concat(writableActionSet);
index 1ad13e74188cdfe249d28a14479e16821606c996..1e31d11c800742eac41659e502513aeb39d4d86f 100644 (file)
@@ -4,10 +4,12 @@
 
 import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
 import { collectionPanelFilesAction } from "store/collection-panel/collection-panel-files/collection-panel-files-actions";
+import { SelectAllIcon } from "components/icon/icon";
 
 export const collectionFilesNotSelectedActionSet: ContextMenuActionSet = [[{
     name: "Select all",
+    icon: SelectAllIcon,
     execute: dispatch => {
         dispatch(collectionPanelFilesAction.SELECT_ALL_COLLECTION_FILES());
     }
-}]];
\ No newline at end of file
+}]];
index ee012fb185991ede5950601f68fac37542f46693..bdc4b07a2452658569830103a99b1a58134ca982 100644 (file)
@@ -2,16 +2,22 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "../context-menu-action-set";
-import { ToggleFavoriteAction } from "../actions/favorite-action";
-import { toggleFavorite } from "store/favorites/favorites-actions";
-import { favoritePanelActions } from "store/favorite-panel/favorite-panel-action";
+import { ContextMenuActionSet } from '../context-menu-action-set';
+import { ToggleFavoriteAction } from '../actions/favorite-action';
+import { toggleFavorite } from 'store/favorites/favorites-actions';
+import { favoritePanelActions } from 'store/favorite-panel/favorite-panel-action';
 
-export const favoriteActionSet: ContextMenuActionSet = [[{
-    component: ToggleFavoriteAction,
-    execute: (dispatch, resource) => {
-        dispatch<any>(toggleFavorite(resource)).then(() => {
-            dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
-        });
-    }
-}]];
+export const favoriteActionSet: ContextMenuActionSet = [
+    [
+        {
+            component: ToggleFavoriteAction,
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) =>
+                    dispatch<any>(toggleFavorite(resource)).then(() => {
+                        dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
+                    })
+                );
+            },
+        },
+    ],
+];
index f573af69a7807450dcff03fdb663d27a3322ac5a..816583faa9f05e3c2d5291362dc12449b503dc92 100644 (file)
@@ -2,33 +2,40 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
-import { RenameIcon, AdvancedIcon, RemoveIcon, AttributesIcon } from "components/icon/icon";
-import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
-import { openGroupAttributes, openRemoveGroupDialog, openGroupUpdateDialog } from "store/groups-panel/groups-panel-actions";
+import { ContextMenuActionSet } from 'views-components/context-menu/context-menu-action-set';
+import { RenameIcon, AdvancedIcon, RemoveIcon, AttributesIcon } from 'components/icon/icon';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
+import { openGroupAttributes, openRemoveGroupDialog, openGroupUpdateDialog } from 'store/groups-panel/groups-panel-actions';
 
-export const groupActionSet: ContextMenuActionSet = [[{
-    name: "Rename",
-    icon: RenameIcon,
-    execute: (dispatch, resource) => {
-        dispatch<any>(openGroupUpdateDialog(resource));
-    }
-}, {
-    name: "Attributes",
-    icon: AttributesIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openGroupAttributes(uuid));
-    }
-}, {
-    name: "API Details",
-    icon: AdvancedIcon,
-    execute: (dispatch, resource) => {
-        dispatch<any>(openAdvancedTabDialog(resource.uuid));
-    }
-}, {
-    name: "Remove",
-    icon: RemoveIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openRemoveGroupDialog(uuid));
-    }
-}]];
+export const groupActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: 'Rename',
+            icon: RenameIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openGroupUpdateDialog(resources[0]))
+            },
+        },
+        {
+            name: 'Attributes',
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openGroupAttributes(resources[0].uuid))
+            },
+        },
+        {
+            name: 'API Details',
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'Remove',
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openRemoveGroupDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
index 37aa35c0fd26b0ba2f71520d9c19901a263e294b..ad1ce97c2dcb8ba3ee238d7ee10b1a39809f81e7 100644 (file)
@@ -2,27 +2,33 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
-import { AdvancedIcon, RemoveIcon, AttributesIcon } from "components/icon/icon";
-import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
+import { ContextMenuActionSet } from 'views-components/context-menu/context-menu-action-set';
+import { AdvancedIcon, RemoveIcon, AttributesIcon } from 'components/icon/icon';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
 import { openGroupMemberAttributes, openRemoveGroupMemberDialog } from 'store/group-details-panel/group-details-panel-actions';
 
-export const groupMemberActionSet: ContextMenuActionSet = [[{
-    name: "Attributes",
-    icon: AttributesIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openGroupMemberAttributes(uuid));
-    }
-}, {
-    name: "API Details",
-    icon: AdvancedIcon,
-    execute: (dispatch, resource) => {
-        dispatch<any>(openAdvancedTabDialog(resource.uuid));
-    }
-}, {
-    name: "Remove",
-    icon: RemoveIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openRemoveGroupMemberDialog(uuid));
-    }
-}]];
+export const groupMemberActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: 'Attributes',
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openGroupMemberAttributes(resources[0].uuid));
+            },
+        },
+        {
+            name: 'API Details',
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'Remove',
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openRemoveGroupMemberDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
index 820d1978fd37b9a16ff0796827a0ff8801fadeba..2957f008cd055f04fea2533e3a5e9e28ee96dfac 100644 (file)
@@ -4,25 +4,31 @@
 
 import { openKeepServiceAttributesDialog, openKeepServiceRemoveDialog } from 'store/keep-services/keep-services-actions';
 import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
-import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
-import { AdvancedIcon, RemoveIcon, AttributesIcon } from "components/icon/icon";
+import { ContextMenuActionSet } from 'views-components/context-menu/context-menu-action-set';
+import { AdvancedIcon, RemoveIcon, AttributesIcon } from 'components/icon/icon';
 
-export const keepServiceActionSet: ContextMenuActionSet = [[{
-    name: "Attributes",
-    icon: AttributesIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openKeepServiceAttributesDialog(uuid));
-    }
-}, {
-    name: "API Details",
-    icon: AdvancedIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openAdvancedTabDialog(uuid));
-    }
-}, {
-    name: "Remove",
-    icon: RemoveIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openKeepServiceRemoveDialog(uuid));
-    }
-}]];
+export const keepServiceActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: 'Attributes',
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openKeepServiceAttributesDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'API Details',
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'Remove',
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openKeepServiceRemoveDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
index 929a65a974e32724af766ffb9bbc16a72459c315..86458423c2e09e489016d3dd8d226c72dd370b8e 100644 (file)
@@ -4,25 +4,31 @@
 
 import { openLinkAttributesDialog, openLinkRemoveDialog } from 'store/link-panel/link-panel-actions';
 import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
-import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
-import { AdvancedIcon, RemoveIcon, AttributesIcon } from "components/icon/icon";
+import { ContextMenuActionSet } from 'views-components/context-menu/context-menu-action-set';
+import { AdvancedIcon, RemoveIcon, AttributesIcon } from 'components/icon/icon';
 
-export const linkActionSet: ContextMenuActionSet = [[{
-    name: "Attributes",
-    icon: AttributesIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openLinkAttributesDialog(uuid));
-    }
-}, {
-    name: "API Details",
-    icon: AdvancedIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openAdvancedTabDialog(uuid));
-    }
-}, {
-    name: "Remove",
-    icon: RemoveIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openLinkRemoveDialog(uuid));
-    }
-}]];
+export const linkActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: 'Attributes',
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openLinkAttributesDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'API Details',
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'Remove',
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openLinkRemoveDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
index 8663d3c7a061ceeb5cf37eabb94a36da310cbae2..4b6950ee24e17b1019afb72bfdd84fc314ca3176 100644 (file)
@@ -2,27 +2,33 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
-import { CanReadIcon, CanManageIcon, CanWriteIcon } from "components/icon/icon";
+import { ContextMenuActionSet } from 'views-components/context-menu/context-menu-action-set';
+import { CanReadIcon, CanManageIcon, CanWriteIcon } from 'components/icon/icon';
 import { editPermissionLevel } from 'store/group-details-panel/group-details-panel-actions';
-import { PermissionLevel } from "models/permission";
+import { PermissionLevel } from 'models/permission';
 
-export const permissionEditActionSet: ContextMenuActionSet = [[{
-    name: "Read",
-    icon: CanReadIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(editPermissionLevel(uuid, PermissionLevel.CAN_READ));
-    }
-}, {
-    name: "Write",
-    icon: CanWriteIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(editPermissionLevel(uuid, PermissionLevel.CAN_WRITE));
-    }
-}, {
-    name: "Manage",
-    icon: CanManageIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(editPermissionLevel(uuid, PermissionLevel.CAN_MANAGE));
-    }
-}]];
+export const permissionEditActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: 'Read',
+            icon: CanReadIcon,
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(editPermissionLevel(resource.uuid, PermissionLevel.CAN_READ)));
+            },
+        },
+        {
+            name: 'Write',
+            icon: CanWriteIcon,
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(editPermissionLevel(resource.uuid, PermissionLevel.CAN_WRITE)));
+            },
+        },
+        {
+            name: 'Manage',
+            icon: CanManageIcon,
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(editPermissionLevel(resource.uuid, PermissionLevel.CAN_MANAGE)));
+            },
+        },
+    ],
+];
index 7d593ee4b4f72978b1f5c7aa285d435c7df0cf6e..2aa7faa1242369be4ea985bad80805b94529b72f 100644 (file)
@@ -6,114 +6,153 @@ import { ContextMenuActionSet } from "../context-menu-action-set";
 import { ToggleFavoriteAction } from "../actions/favorite-action";
 import { toggleFavorite } from "store/favorites/favorites-actions";
 import {
-    RenameIcon, ShareIcon, MoveToIcon, DetailsIcon,
-    RemoveIcon, ReRunProcessIcon, OutputIcon,
+    RenameIcon,
+    ShareIcon,
+    MoveToIcon,
+    DetailsIcon,
+    RemoveIcon,
+    ReRunProcessIcon,
+    OutputIcon,
     AdvancedIcon,
-    OpenIcon
+    OpenIcon,
+    StopIcon,
 } from "components/icon/icon";
 import { favoritePanelActions } from "store/favorite-panel/favorite-panel-action";
-import { openMoveProcessDialog } from 'store/processes/process-move-actions';
+import { openMoveProcessDialog } from "store/processes/process-move-actions";
 import { openProcessUpdateDialog } from "store/processes/process-update-actions";
-import { openCopyProcessDialog } from 'store/processes/process-copy-actions';
+import { openCopyProcessDialog } from "store/processes/process-copy-actions";
 import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions";
 import { openRemoveProcessDialog } from "store/processes/processes-actions";
-import { toggleDetailsPanel } from 'store/details-panel/details-panel-action';
+import { toggleDetailsPanel } from "store/details-panel/details-panel-action";
 import { navigateToOutput } from "store/process-panel/process-panel-actions";
 import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
 import { TogglePublicFavoriteAction } from "../actions/public-favorite-action";
 import { togglePublicFavorite } from "store/public-favorites/public-favorites-actions";
 import { publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action";
 import { openInNewTabAction } from "store/open-in-new-tab/open-in-new-tab.actions";
+import { cancelRunningWorkflow } from "store/processes/processes-actions";
 
-export const readOnlyProcessResourceActionSet: ContextMenuActionSet = [[
-    {
-        component: ToggleFavoriteAction,
-        execute: (dispatch, resource) => {
-            dispatch<any>(toggleFavorite(resource)).then(() => {
-                dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
-            });
-        }
-    },
-    {
-        icon: OpenIcon,
-        name: "Open in new tab",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openInNewTabAction(resource));
-        }
-    },
-    {
-        icon: ReRunProcessIcon,
-        name: "Copy and re-run process",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openCopyProcessDialog(resource));
-        }
-    },
-    {
-        icon: OutputIcon,
-        name: "Outputs",
-        execute: (dispatch, resource) => {
-            if(resource.outputUuid){
-                dispatch<any>(navigateToOutput(resource.outputUuid));
-            }
-        }
-    },
-    {
-        icon: DetailsIcon,
-        name: "View details",
-        execute: dispatch => {
-            dispatch<any>(toggleDetailsPanel());
-        }
-    },
-    {
-        icon: AdvancedIcon,
-        name: "API Details",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openAdvancedTabDialog(resource.uuid));
-        }
-    },
-]];
+export const readOnlyProcessResourceActionSet: ContextMenuActionSet = [
+    [
+        {
+            component: ToggleFavoriteAction,
+            execute: (dispatch, resources) => {
+                dispatch<any>(toggleFavorite(resources[0])).then(() => {
+                    dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
+                });
+            },
+        },
+        {
+            icon: OpenIcon,
+            name: "Open in new tab",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openInNewTabAction(resources[0]));
+            },
+        },
+        {
+            icon: ReRunProcessIcon,
+            name: "Copy and re-run process",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openCopyProcessDialog(resources[0]));
+            },
+        },
+        {
+            icon: OutputIcon,
+            name: "Outputs",
+            execute: (dispatch, resources) => {
+                if (resources[0]) {
+                    dispatch<any>(navigateToOutput(resources[0]));
+                }
+            },
+        },
+        {
+            icon: DetailsIcon,
+            name: "View details",
+            execute: dispatch => {
+                dispatch<any>(toggleDetailsPanel());
+            },
+        },
+        {
+            icon: AdvancedIcon,
+            name: "API Details",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
 
-export const processResourceActionSet: ContextMenuActionSet = [[
-    ...readOnlyProcessResourceActionSet.reduce((prev, next) => prev.concat(next), []),
-    {
-        icon: RenameIcon,
-        name: "Edit process",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openProcessUpdateDialog(resource));
-        }
-    },
-    {
-        icon: ShareIcon,
-        name: "Share",
-        execute: (dispatch, { uuid }) => {
-            dispatch<any>(openSharingDialog(uuid));
-        }
-    },
-    {
-        icon: MoveToIcon,
-        name: "Move to",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openMoveProcessDialog(resource));
-        }
-    },
-    {
-        name: "Remove",
-        icon: RemoveIcon,
-        execute: (dispatch, resource) => {
-            dispatch<any>(openRemoveProcessDialog(resource.uuid));
-        }
-    }
-]];
+export const processResourceActionSet: ContextMenuActionSet = [
+    [
+        ...readOnlyProcessResourceActionSet.reduce((prev, next) => prev.concat(next), []),
+        {
+            icon: RenameIcon,
+            name: "Edit process",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openProcessUpdateDialog(resources[0]));
+            },
+        },
+        {
+            icon: ShareIcon,
+            name: "Share",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openSharingDialog(resources[0].uuid));
+            },
+        },
+        {
+            icon: MoveToIcon,
+            name: "Move to",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openMoveProcessDialog(resources[0]));
+            },
+        },
+        {
+            name: "Remove",
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openRemoveProcessDialog(resources[0], resources.length));
+            },
+        },
+    ],
+];
 
-export const processResourceAdminActionSet: ContextMenuActionSet = [[
-    ...processResourceActionSet.reduce((prev, next) => prev.concat(next), []),
-    {
-        component: TogglePublicFavoriteAction,
-        name: "Add to public favorites",
-        execute: (dispatch, resource) => {
-            dispatch<any>(togglePublicFavorite(resource)).then(() => {
-                dispatch<any>(publicFavoritePanelActions.REQUEST_ITEMS());
-            });
-        }
-    },
-]];
+const runningProcessOnlyActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: "CANCEL",
+            icon: StopIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(cancelRunningWorkflow(resources[0].uuid));
+            },
+        },
+    ]
+];
+
+export const processResourceAdminActionSet: ContextMenuActionSet = [
+    [
+        ...processResourceActionSet.reduce((prev, next) => prev.concat(next), []),
+        {
+            component: TogglePublicFavoriteAction,
+            name: "Add to public favorites",
+            execute: (dispatch, resources) => {
+                dispatch<any>(togglePublicFavorite(resources[0])).then(() => {
+                    dispatch<any>(publicFavoritePanelActions.REQUEST_ITEMS());
+                });
+            },
+        },
+    ],
+];
+
+export const runningProcessResourceActionSet = [
+    [
+        ...processResourceActionSet.reduce((prev, next) => prev.concat(next), []),
+        ...runningProcessOnlyActionSet.reduce((prev, next) => prev.concat(next), []),
+    ],
+];
+
+export const runningProcessResourceAdminActionSet: ContextMenuActionSet = [
+    [
+        ...processResourceAdminActionSet.reduce((prev, next) => prev.concat(next), []),
+        ...runningProcessOnlyActionSet.reduce((prev, next) => prev.concat(next), []),
+    ],
+];
index 8181045ca3346d1e378aeffedd2c014a90545a35..2706315179b718124d61bb0eaf3b1bb708c13607 100644 (file)
@@ -3,19 +3,19 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { ContextMenuActionSet } from "../context-menu-action-set";
-import { NewProjectIcon, RenameIcon, MoveToIcon, DetailsIcon, AdvancedIcon, OpenIcon, Link, FolderSharedIcon } from 'components/icon/icon';
+import { NewProjectIcon, RenameIcon, MoveToIcon, DetailsIcon, AdvancedIcon, OpenIcon, Link, FolderSharedIcon } from "components/icon/icon";
 import { ToggleFavoriteAction } from "../actions/favorite-action";
 import { toggleFavorite } from "store/favorites/favorites-actions";
 import { favoritePanelActions } from "store/favorite-panel/favorite-panel-action";
-import { openMoveProjectDialog } from 'store/projects/project-move-actions';
-import { openProjectCreateDialog } from 'store/projects/project-create-actions';
-import { openProjectUpdateDialog } from 'store/projects/project-update-actions';
+import { openMoveProjectDialog } from "store/projects/project-move-actions";
+import { openProjectCreateDialog } from "store/projects/project-create-actions";
+import { openProjectUpdateDialog } from "store/projects/project-update-actions";
 import { ToggleTrashAction } from "views-components/context-menu/actions/trash-action";
 import { toggleProjectTrashed } from "store/trash/trash-actions";
-import { ShareIcon } from 'components/icon/icon';
+import { ShareIcon } from "components/icon/icon";
 import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions";
 import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
-import { toggleDetailsPanel } from 'store/details-panel/details-panel-action';
+import { toggleDetailsPanel } from "store/details-panel/details-panel-action";
 import { copyToClipboardAction, openInNewTabAction } from "store/open-in-new-tab/open-in-new-tab.actions";
 import { openWebDavS3InfoDialog } from "store/collections/collection-info-actions";
 import { ToggleLockAction } from "../actions/lock-action";
@@ -23,28 +23,28 @@ import { freezeProject, unfreezeProject } from "store/projects/project-lock-acti
 
 export const toggleFavoriteAction = {
     component: ToggleFavoriteAction,
-    name: 'ToggleFavoriteAction',
-    execute: (dispatch, resource) => {
-        dispatch(toggleFavorite(resource)).then(() => {
+    name: "ToggleFavoriteAction",
+    execute: (dispatch, resources) => {
+        dispatch(toggleFavorite(resources[0])).then(() => {
             dispatch(favoritePanelActions.REQUEST_ITEMS());
         });
-    }
+    },
 };
 
 export const openInNewTabMenuAction = {
     icon: OpenIcon,
     name: "Open in new tab",
-    execute: (dispatch, resource) => {
-        dispatch(openInNewTabAction(resource));
-    }
+    execute: (dispatch, resources) => {
+        dispatch(openInNewTabAction(resources[0]));
+    },
 };
 
 export const copyToClipboardMenuAction = {
     icon: Link,
     name: "Copy to clipboard",
-    execute: (dispatch, resource) => {
-        dispatch(copyToClipboardAction(resource));
-    }
+    execute: (dispatch, resources) => {
+        dispatch(copyToClipboardAction(resources));
+    },
 };
 
 export const viewDetailsAction = {
@@ -52,121 +52,122 @@ export const viewDetailsAction = {
     name: "View details",
     execute: dispatch => {
         dispatch(toggleDetailsPanel());
-    }
-}
+    },
+};
 
 export const advancedAction = {
     icon: AdvancedIcon,
     name: "API Details",
-    execute: (dispatch, resource) => {
-        dispatch(openAdvancedTabDialog(resource.uuid));
-    }
-}
+    execute: (dispatch, resources) => {
+        dispatch(openAdvancedTabDialog(resources[0].uuid));
+    },
+};
 
 export const openWith3rdPartyClientAction = {
     icon: FolderSharedIcon,
     name: "Open with 3rd party client",
-    execute: (dispatch, resource) => {
-        dispatch(openWebDavS3InfoDialog(resource.uuid));
-    }
-}
+    execute: (dispatch, resources) => {
+        dispatch(openWebDavS3InfoDialog(resources[0].uuid));
+    },
+};
 
 export const editProjectAction = {
     icon: RenameIcon,
     name: "Edit project",
-    execute: (dispatch, resource) => {
-        dispatch(openProjectUpdateDialog(resource));
-    }
-}
+    execute: (dispatch, resources) => {
+        dispatch(openProjectUpdateDialog(resources[0]));
+    },
+};
 
 export const shareAction = {
     icon: ShareIcon,
     name: "Share",
-    execute: (dispatch, { uuid }) => {
-        dispatch(openSharingDialog(uuid));
-    }
-}
+    execute: (dispatch, resources) => {
+        dispatch(openSharingDialog(resources[0].uuid));
+    },
+};
 
 export const moveToAction = {
     icon: MoveToIcon,
     name: "Move to",
     execute: (dispatch, resource) => {
-        dispatch(openMoveProjectDialog(resource));
-    }
-}
+        dispatch(openMoveProjectDialog(resource[0]));
+    },
+};
 
 export const toggleTrashAction = {
     component: ToggleTrashAction,
-    name: 'ToggleTrashAction',
-    execute: (dispatch, resource) => {
-        dispatch(toggleProjectTrashed(resource.uuid, resource.ownerUuid, resource.isTrashed!!));
-    }
-}
+    name: "ToggleTrashAction",
+    execute: (dispatch, resources) => {
+        dispatch(toggleProjectTrashed(resources[0].uuid, resources[0].ownerUuid, resources[0].isTrashed!!, resources.length > 1));
+    },
+};
 
 export const freezeProjectAction = {
     component: ToggleLockAction,
-    name: 'ToggleLockAction',
-    execute: (dispatch, resource) => {
-        if (resource.isFrozen) {
-            dispatch(unfreezeProject(resource.uuid));
+    name: "ToggleLockAction",
+    execute: (dispatch, resources) => {
+        if (resources[0].isFrozen) {
+            dispatch(unfreezeProject(resources[0].uuid));
         } else {
-            dispatch(freezeProject(resource.uuid));
+            dispatch(freezeProject(resources[0].uuid));
         }
-    }
-}
+    },
+};
 
 export const newProjectAction: any = {
     icon: NewProjectIcon,
     name: "New project",
     execute: (dispatch, resource): void => {
         dispatch(openProjectCreateDialog(resource.uuid));
-    }
-}
-
-export const readOnlyProjectActionSet: ContextMenuActionSet = [[
-    toggleFavoriteAction,
-    openInNewTabMenuAction,
-    copyToClipboardMenuAction,
-    viewDetailsAction,
-    advancedAction,
-    openWith3rdPartyClientAction,
-]];
-
-export const filterGroupActionSet: ContextMenuActionSet = [[
-    toggleFavoriteAction,
-    openInNewTabMenuAction,
-    copyToClipboardMenuAction,
-    viewDetailsAction,
-    advancedAction,
-    openWith3rdPartyClientAction,
-    editProjectAction,
-    shareAction,
-    moveToAction,
-    toggleTrashAction,
-]];
-
-export const frozenActionSet: ContextMenuActionSet = [[
-    shareAction,
-    toggleFavoriteAction,
-    openInNewTabMenuAction,
-    copyToClipboardMenuAction,
-    viewDetailsAction,
-    advancedAction,
-    openWith3rdPartyClientAction,
-    freezeProjectAction
-]];
-
-export const projectActionSet: ContextMenuActionSet = [[
-    toggleFavoriteAction,
-    openInNewTabMenuAction,
-    copyToClipboardMenuAction,
-    viewDetailsAction,
-    advancedAction,
-    openWith3rdPartyClientAction,
-    editProjectAction,
-    shareAction,
-    moveToAction,
-    toggleTrashAction,
-    newProjectAction,
-    freezeProjectAction,
-]];
+    },
+};
+
+export const readOnlyProjectActionSet: ContextMenuActionSet = [
+    [toggleFavoriteAction, openInNewTabMenuAction, copyToClipboardMenuAction, viewDetailsAction, advancedAction, openWith3rdPartyClientAction],
+];
+
+export const filterGroupActionSet: ContextMenuActionSet = [
+    [
+        toggleFavoriteAction,
+        openInNewTabMenuAction,
+        copyToClipboardMenuAction,
+        viewDetailsAction,
+        advancedAction,
+        openWith3rdPartyClientAction,
+        editProjectAction,
+        shareAction,
+        moveToAction,
+        toggleTrashAction,
+    ],
+];
+
+export const frozenActionSet: ContextMenuActionSet = [
+    [
+        shareAction,
+        toggleFavoriteAction,
+        openInNewTabMenuAction,
+        copyToClipboardMenuAction,
+        viewDetailsAction,
+        advancedAction,
+        openWith3rdPartyClientAction,
+        freezeProjectAction,
+    ],
+];
+
+export const projectActionSet: ContextMenuActionSet = [
+    [
+        toggleFavoriteAction,
+        openInNewTabMenuAction,
+        copyToClipboardMenuAction,
+        viewDetailsAction,
+        advancedAction,
+        openWith3rdPartyClientAction,
+        editProjectAction,
+        shareAction,
+        moveToAction,
+        toggleTrashAction,
+        newProjectAction,
+        freezeProjectAction,
+    ],
+];
index 3faf675d94259f762e22d823201d88d15f0c63c3..490bf3e30a9e649f85165a988751aff4357be40f 100644 (file)
@@ -7,56 +7,75 @@ import { TogglePublicFavoriteAction } from "views-components/context-menu/action
 import { togglePublicFavorite } from "store/public-favorites/public-favorites-actions";
 import { publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action";
 
-import { shareAction, toggleFavoriteAction, openInNewTabMenuAction, copyToClipboardMenuAction, viewDetailsAction, advancedAction, openWith3rdPartyClientAction, freezeProjectAction, editProjectAction, moveToAction, toggleTrashAction, newProjectAction } from "views-components/context-menu/action-sets/project-action-set";
-
-export const togglePublicFavoriteAction = {
-    component: TogglePublicFavoriteAction,
-    name: 'TogglePublicFavoriteAction',
-    execute: (dispatch, resource) => {
-        dispatch(togglePublicFavorite(resource)).then(() => {
-            dispatch(publicFavoritePanelActions.REQUEST_ITEMS());
-        });
-}}
-
-export const projectAdminActionSet: ContextMenuActionSet = [[
+import {
+    shareAction,
     toggleFavoriteAction,
     openInNewTabMenuAction,
     copyToClipboardMenuAction,
     viewDetailsAction,
     advancedAction,
     openWith3rdPartyClientAction,
+    freezeProjectAction,
     editProjectAction,
-    shareAction,
     moveToAction,
     toggleTrashAction,
     newProjectAction,
-    freezeProjectAction,
-    togglePublicFavoriteAction
-]];
+} from "views-components/context-menu/action-sets/project-action-set";
 
-export const filterGroupAdminActionSet: ContextMenuActionSet = [[
-    toggleFavoriteAction,
-    openInNewTabMenuAction,
-    copyToClipboardMenuAction,
-    viewDetailsAction,
-    advancedAction,
-    openWith3rdPartyClientAction,
-    editProjectAction,
-    shareAction,
-    moveToAction,
-    toggleTrashAction,
-    togglePublicFavoriteAction
-]];
+export const togglePublicFavoriteAction = {
+    component: TogglePublicFavoriteAction,
+    name: "TogglePublicFavoriteAction",
+    execute: (dispatch, resources) => {
+        dispatch(togglePublicFavorite(resources[0])).then(() => {
+            dispatch(publicFavoritePanelActions.REQUEST_ITEMS());
+        });
+    },
+};
 
+export const projectAdminActionSet: ContextMenuActionSet = [
+    [
+        toggleFavoriteAction,
+        openInNewTabMenuAction,
+        copyToClipboardMenuAction,
+        viewDetailsAction,
+        advancedAction,
+        openWith3rdPartyClientAction,
+        editProjectAction,
+        shareAction,
+        moveToAction,
+        toggleTrashAction,
+        newProjectAction,
+        freezeProjectAction,
+        togglePublicFavoriteAction,
+    ],
+];
 
-export const frozenAdminActionSet: ContextMenuActionSet = [[
-    shareAction,
-    togglePublicFavoriteAction,
-    toggleFavoriteAction,
-    openInNewTabMenuAction,
-    copyToClipboardMenuAction,
-    viewDetailsAction,
-    advancedAction,
-    openWith3rdPartyClientAction,
-    freezeProjectAction
-]];
+export const filterGroupAdminActionSet: ContextMenuActionSet = [
+    [
+        toggleFavoriteAction,
+        openInNewTabMenuAction,
+        copyToClipboardMenuAction,
+        viewDetailsAction,
+        advancedAction,
+        openWith3rdPartyClientAction,
+        editProjectAction,
+        shareAction,
+        moveToAction,
+        toggleTrashAction,
+        togglePublicFavoriteAction,
+    ],
+];
+
+export const frozenAdminActionSet: ContextMenuActionSet = [
+    [
+        shareAction,
+        togglePublicFavoriteAction,
+        toggleFavoriteAction,
+        openInNewTabMenuAction,
+        copyToClipboardMenuAction,
+        viewDetailsAction,
+        advancedAction,
+        openWith3rdPartyClientAction,
+        freezeProjectAction,
+    ],
+];
index 12fec7c4024611b82ace644afc27ea7101f44a7a..cbdcd004288780cbdbd3c5cfe2e41449d966fae5 100644 (file)
@@ -2,34 +2,41 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
-import { AdvancedIcon, RemoveIcon, ShareIcon, AttributesIcon } from "components/icon/icon";
-import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
-import { openRepositoryAttributes, openRemoveRepositoryDialog } from "store/repositories/repositories-actions";
-import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions";
+import { ContextMenuActionSet } from 'views-components/context-menu/context-menu-action-set';
+import { AdvancedIcon, RemoveIcon, ShareIcon, AttributesIcon } from 'components/icon/icon';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
+import { openRepositoryAttributes, openRemoveRepositoryDialog } from 'store/repositories/repositories-actions';
+import { openSharingDialog } from 'store/sharing-dialog/sharing-dialog-actions';
 
-export const repositoryActionSet: ContextMenuActionSet = [[{
-    name: "Attributes",
-    icon: AttributesIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openRepositoryAttributes(uuid));
-    }
-}, {
-    name: "Share",
-    icon: ShareIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openSharingDialog(uuid));
-    }
-}, {
-    name: "API Details",
-    icon: AdvancedIcon,
-    execute: (dispatch, resource) => {
-        dispatch<any>(openAdvancedTabDialog(resource.uuid));
-    }
-}, {
-    name: "Remove",
-    icon: RemoveIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openRemoveRepositoryDialog(uuid));
-    }
-}]];
+export const repositoryActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: 'Attributes',
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openRepositoryAttributes(resources[0].uuid));
+            },
+        },
+        {
+            name: 'Share',
+            icon: ShareIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openSharingDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'API Details',
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'Remove',
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openRemoveRepositoryDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
index ea8c53c5e59ae1fb4d56983ca99e8c933b8f0628..401e9634d93c3db56533f4450298f6e7f9acb380 100644 (file)
@@ -2,13 +2,17 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "../context-menu-action-set";
-import { ToggleFavoriteAction } from "../actions/favorite-action";
-import { toggleFavorite } from "store/favorites/favorites-actions";
+import { ContextMenuActionSet } from '../context-menu-action-set';
+import { ToggleFavoriteAction } from '../actions/favorite-action';
+import { toggleFavorite } from 'store/favorites/favorites-actions';
 
-export const resourceActionSet: ContextMenuActionSet = [[{
-    component: ToggleFavoriteAction,
-    execute: (dispatch, resource) => {
-        dispatch<any>(toggleFavorite(resource));
-    }
-}]];
+export const resourceActionSet: ContextMenuActionSet = [
+    [
+        {
+            component: ToggleFavoriteAction,
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(toggleFavorite(resource)));
+            },
+        },
+    ],
+];
index 9cf5bf031a4718943e60a7f2e6943ade98e87b23..a779d1eb2967877c766d7166b63ae00697a4bfb4 100644 (file)
@@ -2,24 +2,26 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "../context-menu-action-set";
+import { ContextMenuActionSet } from '../context-menu-action-set';
 import { openCollectionCreateDialog } from 'store/collections/collection-create-actions';
-import { NewProjectIcon, CollectionIcon } from "components/icon/icon";
+import { NewProjectIcon, CollectionIcon } from 'components/icon/icon';
 import { openProjectCreateDialog } from 'store/projects/project-create-actions';
 
-export const rootProjectActionSet: ContextMenuActionSet =  [[
-    {
-        icon: NewProjectIcon,
-        name: "New project",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openProjectCreateDialog(resource.uuid));
-        }
-    },
-    {
-        icon: CollectionIcon,
-        name: "New Collection",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openCollectionCreateDialog(resource.uuid));
-        }
-    }
-]];
+export const rootProjectActionSet: ContextMenuActionSet = [
+    [
+        {
+            icon: NewProjectIcon,
+            name: 'New project',
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openProjectCreateDialog(resources[0].uuid));
+            },
+        },
+        {
+            icon: CollectionIcon,
+            name: 'New Collection',
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openCollectionCreateDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
index aeb6d15501f782e20c29d22ac65b56cfb5f37a8c..dcc9eae20700160c3fdd3bf72224754b9bdded81 100644 (file)
@@ -2,41 +2,41 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "../context-menu-action-set";
+import { ContextMenuActionSet } from '../context-menu-action-set';
 import { DetailsIcon, AdvancedIcon, OpenIcon, Link } from 'components/icon/icon';
-import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
 import { toggleDetailsPanel } from 'store/details-panel/details-panel-action';
-import { copyToClipboardAction, openInNewTabAction } from "store/open-in-new-tab/open-in-new-tab.actions";
+import { copyToClipboardAction, openInNewTabAction } from 'store/open-in-new-tab/open-in-new-tab.actions';
 
 export const searchResultsActionSet: ContextMenuActionSet = [
     [
         {
             icon: OpenIcon,
-            name: "Open in new tab",
-            execute: (dispatch, resource) => {
-                dispatch<any>(openInNewTabAction(resource));
-            }
+            name: 'Open in new tab',
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(openInNewTabAction(resource)));
+            },
         },
         {
             icon: Link,
-            name: "Copy to clipboard",
-            execute: (dispatch, resource) => {
-                dispatch<any>(copyToClipboardAction(resource));
-            }
+            name: 'Copy to clipboard',
+            execute: (dispatch, resources) => {
+                dispatch<any>(copyToClipboardAction(resources));
+            },
         },
         {
             icon: DetailsIcon,
-            name: "View details",
-            execute: dispatch => {
+            name: 'View details',
+            execute: (dispatch) => {
                 dispatch<any>(toggleDetailsPanel());
-            }
+            },
         },
         {
             icon: AdvancedIcon,
-            name: "API Details",
-            execute: (dispatch, resource) => {
-                dispatch<any>(openAdvancedTabDialog(resource.uuid));
-            }
+            name: 'API Details',
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
         },
-    ]
+    ],
 ];
index d1a94cd311318e76cdbf0bfd9f61fada1a549838..c31e1681a4f88bd861c12ae6158837db982ccdbb 100644 (file)
@@ -2,27 +2,33 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
-import { AdvancedIcon, RemoveIcon, AttributesIcon } from "components/icon/icon";
+import { ContextMenuActionSet } from 'views-components/context-menu/context-menu-action-set';
+import { AdvancedIcon, RemoveIcon, AttributesIcon } from 'components/icon/icon';
 import { openSshKeyRemoveDialog, openSshKeyAttributesDialog } from 'store/auth/auth-action-ssh';
 import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
 
-export const sshKeyActionSet: ContextMenuActionSet = [[{
-    name: "Attributes",
-    icon: AttributesIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openSshKeyAttributesDialog(uuid));
-    }
-}, {
-    name: "API Details",
-    icon: AdvancedIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openAdvancedTabDialog(uuid));
-    }
-}, {
-    name: "Remove",
-    icon: RemoveIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openSshKeyRemoveDialog(uuid));
-    }
-}]];
+export const sshKeyActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: 'Attributes',
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openSshKeyAttributesDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'API Details',
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'Remove',
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openSshKeyRemoveDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
index c0afd36af98b234f6872027e027a9143301d3716..82e00df6cbbb9fbf26229f012370f8dba568c242 100644 (file)
@@ -2,15 +2,17 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "../context-menu-action-set";
-import { ToggleTrashAction } from "views-components/context-menu/actions/trash-action";
-import { toggleTrashed } from "store/trash/trash-actions";
+import { ContextMenuActionSet } from '../context-menu-action-set';
+import { ToggleTrashAction } from 'views-components/context-menu/actions/trash-action';
+import { toggleTrashed } from 'store/trash/trash-actions';
 
-export const trashActionSet: ContextMenuActionSet = [[
-    {
-        component: ToggleTrashAction,
-        execute: (dispatch, resource) => {
-            dispatch<any>(toggleTrashed(resource.kind, resource.uuid, resource.ownerUuid, resource.isTrashed!!));
-        }
-    },
-]];
+export const trashActionSet: ContextMenuActionSet = [
+    [
+        {
+            component: ToggleTrashAction,
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(toggleTrashed(resource.kind, resource.uuid, resource.ownerUuid, resource.isTrashed!!)));
+            },
+        },
+    ],
+];
index 020ff5c747487c776ba11a040419853d656e9931..3e8f0cb647e38e8f73b35e33faf562ccc9caa155 100644 (file)
@@ -2,39 +2,41 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "../context-menu-action-set";
+import { ContextMenuActionSet } from '../context-menu-action-set';
 import { DetailsIcon, ProvenanceGraphIcon, AdvancedIcon, RestoreFromTrashIcon } from 'components/icon/icon';
-import { toggleCollectionTrashed } from "store/trash/trash-actions";
-import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
+import { toggleCollectionTrashed } from 'store/trash/trash-actions';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
 import { toggleDetailsPanel } from 'store/details-panel/details-panel-action';
 
-export const trashedCollectionActionSet: ContextMenuActionSet = [[
-    {
-        icon: DetailsIcon,
-        name: "View details",
-        execute: dispatch => {
-            dispatch<any>(toggleDetailsPanel());
-        }
-    },
-    {
-        icon: ProvenanceGraphIcon,
-        name: "Provenance graph",
-        execute: (dispatch, resource) => {
-            // add code
-        }
-    },
-    {
-        icon: AdvancedIcon,
-        name: "API Details",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openAdvancedTabDialog(resource.uuid));
-        }
-    },
-    {
-        icon: RestoreFromTrashIcon,
-        name: "Restore",
-        execute: (dispatch, resource) => {
-            dispatch<any>(toggleCollectionTrashed(resource.uuid, true));
-        }
-    },
-]];
+export const trashedCollectionActionSet: ContextMenuActionSet = [
+    [
+        {
+            icon: DetailsIcon,
+            name: 'View details',
+            execute: (dispatch) => {
+                dispatch<any>(toggleDetailsPanel());
+            },
+        },
+        {
+            icon: ProvenanceGraphIcon,
+            name: 'Provenance graph',
+            execute: (dispatch, resource) => {
+                // add code
+            },
+        },
+        {
+            icon: AdvancedIcon,
+            name: 'API Details',
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            icon: RestoreFromTrashIcon,
+            name: 'Restore',
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(toggleCollectionTrashed(resource.uuid, true)));
+            },
+        },
+    ],
+];
index c00b7f1f285c157bd95e2d2ef5e386a0e94fb7d8..0108ff7e50ec1a3cf164ba77019449b1039a0fb2 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
+import { ContextMenuActionSet } from 'views-components/context-menu/context-menu-action-set';
 import {
     AdvancedIcon,
     ProjectIcon,
@@ -12,76 +12,84 @@ import {
     LoginAsIcon,
     AdminMenuIcon,
     ActiveIcon,
-} from "components/icon/icon";
+} from 'components/icon/icon';
 import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
-import { loginAs, openUserAttributes, openUserProjects } from "store/users/users-actions";
-import { openSetupDialog, openDeactivateDialog, openActivateDialog } from "store/user-profile/user-profile-actions";
-import { navigateToUserProfile } from "store/navigation/navigation-action";
-import { canActivateUser, canDeactivateUser, canSetupUser, isAdmin, needsUserProfileLink, isOtherUser } from "store/context-menu/context-menu-filters";
+import { loginAs, openUserAttributes, openUserProjects } from 'store/users/users-actions';
+import { openSetupDialog, openDeactivateDialog, openActivateDialog } from 'store/user-profile/user-profile-actions';
+import { navigateToUserProfile } from 'store/navigation/navigation-action';
+import {
+    canActivateUser,
+    canDeactivateUser,
+    canSetupUser,
+    isAdmin,
+    needsUserProfileLink,
+    isOtherUser,
+} from 'store/context-menu/context-menu-filters';
 
-export const userActionSet: ContextMenuActionSet = [[{
-    name: "Attributes",
-    icon: AttributesIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openUserAttributes(uuid));
-    }
-}, {
-    name: "Project",
-    icon: ProjectIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openUserProjects(uuid));
-    }
-}, {
-    name: "API Details",
-    icon: AdvancedIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openAdvancedTabDialog(uuid));
-    }
-}, {
-    name: "Account Settings",
-    icon: UserPanelIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(navigateToUserProfile(uuid));
-    },
-    filters: [needsUserProfileLink]
-}],[{
-    name: "Activate User",
-    icon: ActiveIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openActivateDialog(uuid));
-    },
-    filters: [
-        isAdmin,
-        canActivateUser,
-    ],
-}, {
-    name: "Setup User",
-    icon: AdminMenuIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openSetupDialog(uuid));
-    },
-    filters: [
-        isAdmin,
-        canSetupUser,
-    ],
-}, {
-    name: "Deactivate User",
-    icon: DeactivateUserIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openDeactivateDialog(uuid));
-    },
-    filters: [
-        isAdmin,
-        canDeactivateUser,
+export const userActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: 'Attributes',
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openUserAttributes(resources[0].uuid));
+            },
+        },
+        {
+            name: 'Project',
+            icon: ProjectIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openUserProjects(resources[0].uuid));
+            },
+        },
+        {
+            name: 'API Details',
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'Account Settings',
+            icon: UserPanelIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(navigateToUserProfile(resources[0].uuid));
+            },
+            filters: [needsUserProfileLink],
+        },
     ],
-}, {
-    name: "Login As User",
-    icon: LoginAsIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(loginAs(uuid));
-    },
-    filters: [
-        isAdmin,
-        isOtherUser,
+    [
+        {
+            name: 'Activate User',
+            icon: ActiveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openActivateDialog(resources[0].uuid));
+            },
+            filters: [isAdmin, canActivateUser],
+        },
+        {
+            name: 'Setup User',
+            icon: AdminMenuIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openSetupDialog(resources[0].uuid));
+            },
+            filters: [isAdmin, canSetupUser],
+        },
+        {
+            name: 'Deactivate User',
+            icon: DeactivateUserIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openDeactivateDialog(resources[0].uuid));
+            },
+            filters: [isAdmin, canDeactivateUser],
+        },
+        {
+            name: 'Login As User',
+            icon: LoginAsIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(loginAs(resources[0].uuid));
+            },
+            filters: [isAdmin, isOtherUser],
+        },
     ],
-}]];
+];
index be9567cd035a2a244352f392d955ea915b61e3a2..a26cbe1368d4aa0c8b435af7db26f65b8c138b19 100644 (file)
@@ -2,27 +2,33 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
-import { AdvancedIcon, RemoveIcon, AttributesIcon } from "components/icon/icon";
+import { ContextMenuActionSet } from 'views-components/context-menu/context-menu-action-set';
+import { AdvancedIcon, RemoveIcon, AttributesIcon } from 'components/icon/icon';
 import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
-import { openVirtualMachineAttributes, openRemoveVirtualMachineDialog } from "store/virtual-machines/virtual-machines-actions";
+import { openVirtualMachineAttributes, openRemoveVirtualMachineDialog } from 'store/virtual-machines/virtual-machines-actions';
 
-export const virtualMachineActionSet: ContextMenuActionSet = [[{
-    name: "Attributes",
-    icon: AttributesIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openVirtualMachineAttributes(uuid));
-    }
-}, {
-    name: "API Details",
-    icon: AdvancedIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openAdvancedTabDialog(uuid));
-    }
-}, {
-    name: "Remove",
-    icon: RemoveIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openRemoveVirtualMachineDialog(uuid));
-    }
-}]];
+export const virtualMachineActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: 'Attributes',
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openVirtualMachineAttributes(resources[0].uuid));
+            },
+        },
+        {
+            name: 'API Details',
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'Remove',
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openRemoveVirtualMachineDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
index 2aa78904e4cfa8938aa5f8e2de3e77554bea1013..4a1460bfc94f81552283595f7d31ffd08d97517a 100644 (file)
@@ -3,13 +3,61 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
-import { openRunProcess } from "store/workflow-panel/workflow-panel-actions";
+import { openRunProcess, deleteWorkflow } from "store/workflow-panel/workflow-panel-actions";
+import { DetailsIcon, AdvancedIcon, OpenIcon, Link, StartIcon, TrashIcon } from "components/icon/icon";
+import { copyToClipboardAction, openInNewTabAction } from "store/open-in-new-tab/open-in-new-tab.actions";
+import { toggleDetailsPanel } from "store/details-panel/details-panel-action";
+import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
 
-export const workflowActionSet: ContextMenuActionSet = [[
-    {
-        name: "Run",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openRunProcess(resource.uuid, resource.ownerUuid, resource.name));
-        }
-    },
-]];
+export const readOnlyWorkflowActionSet: ContextMenuActionSet = [
+    [
+        {
+            icon: OpenIcon,
+            name: "Open in new tab",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openInNewTabAction(resources[0]));
+            },
+        },
+        {
+            icon: Link,
+            name: "Copy to clipboard",
+            execute: (dispatch, resources) => {
+                dispatch<any>(copyToClipboardAction(resources));
+            },
+        },
+        {
+            icon: DetailsIcon,
+            name: "View details",
+            execute: dispatch => {
+                dispatch<any>(toggleDetailsPanel());
+            },
+        },
+        {
+            icon: AdvancedIcon,
+            name: "API Details",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            icon: StartIcon,
+            name: "Run Workflow",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openRunProcess(resources[0].uuid, resources[0].ownerUuid, resources[0].name));
+            },
+        },
+    ],
+];
+
+export const workflowActionSet: ContextMenuActionSet = [
+    [
+        ...readOnlyWorkflowActionSet[0],
+        {
+            icon: TrashIcon,
+            name: "Delete Workflow",
+            execute: (dispatch, resources) => {
+                dispatch<any>(deleteWorkflow(resources[0].uuid, resources[0].ownerUuid));
+            },
+        },
+    ],
+];
index abef7ec0d47e711fa48adb109f4d5b60e629fffb..a953500b3ae7a49f9216bce9544b24b3771a9982 100644 (file)
@@ -5,10 +5,9 @@
 import { Dispatch } from "redux";
 import { ContextMenuItem } from "components/context-menu/context-menu";
 import { ContextMenuResource } from "store/context-menu/context-menu-actions";
-import { RootState } from "store/store";
 
 export interface ContextMenuAction extends ContextMenuItem {
-    execute(dispatch: Dispatch, resource: ContextMenuResource, state?: any): void;
+    execute(dispatch: Dispatch, resources: ContextMenuResource[], state?: any): void;
 }
 
 export type ContextMenuActionSet = Array<Array<ContextMenuAction>>;
index c659b7c508a7fd7af4cc2887742aabd4a773edc5..aeb69de7624bf3e27a82f7a911f7de1d57e03d4c 100644 (file)
@@ -9,26 +9,29 @@ import { ContextMenu as ContextMenuComponent, ContextMenuProps, ContextMenuItem
 import { createAnchorAt } from "components/popover/helpers";
 import { ContextMenuActionSet, ContextMenuAction } from "./context-menu-action-set";
 import { Dispatch } from "redux";
-import { memoize } from 'lodash';
+import { memoize } from "lodash";
 import { sortByProperty } from "common/array-utils";
+
 type DataProps = Pick<ContextMenuProps, "anchorEl" | "items" | "open"> & { resource?: ContextMenuResource };
+
 const mapStateToProps = (state: RootState): DataProps => {
     const { open, position, resource } = state.contextMenu;
-
-    const filteredItems = getMenuActionSet(resource).map((group) => (group.filter((item) => {
-        if (resource && item.filters) {
-            // Execute all filters on this item, every returns true IFF all filters return true
-            return item.filters.every((filter) => filter(state, resource));
-        } else {
-            return true;
-        }
-    })));
+    const filteredItems = getMenuActionSet(resource).map(group =>
+        group.filter(item => {
+            if (resource && item.filters) {
+                // Execute all filters on this item, every returns true IFF all filters return true
+                return item.filters.every(filter => filter(state, resource));
+            } else {
+                return true;
+            }
+        })
+    );
 
     return {
         anchorEl: resource ? createAnchorAt(position) : undefined,
         items: filteredItems,
         open,
-        resource
+        resource,
     };
 };
 
@@ -40,68 +43,70 @@ const mapDispatchToProps = (dispatch: Dispatch): ActionProps => ({
     onItemClick: (action: ContextMenuAction, resource?: ContextMenuResource) => {
         dispatch(contextMenuActions.CLOSE_CONTEXT_MENU());
         if (resource) {
-            action.execute(dispatch, resource);
+            action.execute(dispatch, [resource]);
         }
-    }
+    },
 });
 
 const handleItemClick = memoize(
-    (resource: DataProps['resource'], onItemClick: ActionProps['onItemClick']): ContextMenuProps['onItemClick'] =>
+    (resource: DataProps["resource"], onItemClick: ActionProps["onItemClick"]): ContextMenuProps["onItemClick"] =>
         item => {
-            onItemClick(item, resource);
+            onItemClick(item, { ...resource, fromContextMenu: true } as ContextMenuResource);
         }
 );
 
 const mergeProps = ({ resource, ...dataProps }: DataProps, actionProps: ActionProps): ContextMenuProps => ({
     ...dataProps,
     ...actionProps,
-    onItemClick: handleItemClick(resource, actionProps.onItemClick)
+    onItemClick: handleItemClick(resource, actionProps.onItemClick),
 });
 
-
 export const ContextMenu = connect(mapStateToProps, mapDispatchToProps, mergeProps)(ContextMenuComponent);
 
 const menuActionSets = new Map<string, ContextMenuActionSet>();
 
 export const addMenuActionSet = (name: string, itemSet: ContextMenuActionSet) => {
-    const sorted = itemSet.map(items => items.sort(sortByProperty('name')));
+    const sorted = itemSet.map(items => items.sort(sortByProperty("name")));
     menuActionSets.set(name, sorted);
 };
 
 const emptyActionSet: ContextMenuActionSet = [];
-const getMenuActionSet = (resource?: ContextMenuResource): ContextMenuActionSet => (
-    resource ? menuActionSets.get(resource.menuKind) || emptyActionSet : emptyActionSet
-);
+const getMenuActionSet = (resource?: ContextMenuResource): ContextMenuActionSet =>
+    resource ? menuActionSets.get(resource.menuKind) || emptyActionSet : emptyActionSet;
 
 export enum ContextMenuKind {
     API_CLIENT_AUTHORIZATION = "ApiClientAuthorization",
     ROOT_PROJECT = "RootProject",
     PROJECT = "Project",
     FILTER_GROUP = "FilterGroup",
-    READONLY_PROJECT = 'ReadOnlyProject',
-    FROZEN_PROJECT = 'FrozenProject',
-    FROZEN_PROJECT_ADMIN = 'FrozenProjectAdmin',
+    READONLY_PROJECT = "ReadOnlyProject",
+    FROZEN_PROJECT = "FrozenProject",
+    FROZEN_PROJECT_ADMIN = "FrozenProjectAdmin",
     PROJECT_ADMIN = "ProjectAdmin",
     FILTER_GROUP_ADMIN = "FilterGroupAdmin",
     RESOURCE = "Resource",
     FAVORITE = "Favorite",
     TRASH = "Trash",
     COLLECTION_FILES = "CollectionFiles",
+    COLLECTION_FILES_MULTIPLE = "CollectionFilesMultiple",
     READONLY_COLLECTION_FILES = "ReadOnlyCollectionFiles",
+    READONLY_COLLECTION_FILES_MULTIPLE = "ReadOnlyCollectionFilesMultiple",
+    COLLECTION_FILES_NOT_SELECTED = "CollectionFilesNotSelected",
     COLLECTION_FILE_ITEM = "CollectionFileItem",
     COLLECTION_DIRECTORY_ITEM = "CollectionDirectoryItem",
     READONLY_COLLECTION_FILE_ITEM = "ReadOnlyCollectionFileItem",
     READONLY_COLLECTION_DIRECTORY_ITEM = "ReadOnlyCollectionDirectoryItem",
-    COLLECTION_FILES_NOT_SELECTED = "CollectionFilesNotSelected",
-    COLLECTION = 'Collection',
-    COLLECTION_ADMIN = 'CollectionAdmin',
-    READONLY_COLLECTION = 'ReadOnlyCollection',
-    OLD_VERSION_COLLECTION = 'OldVersionCollection',
-    TRASHED_COLLECTION = 'TrashedCollection',
+    COLLECTION = "Collection",
+    COLLECTION_ADMIN = "CollectionAdmin",
+    READONLY_COLLECTION = "ReadOnlyCollection",
+    OLD_VERSION_COLLECTION = "OldVersionCollection",
+    TRASHED_COLLECTION = "TrashedCollection",
     PROCESS = "Process",
-    PROCESS_ADMIN = 'ProcessAdmin',
-    PROCESS_RESOURCE = 'ProcessResource',
-    READONLY_PROCESS_RESOURCE = 'ReadOnlyProcessResource',
+    RUNNING_PROCESS_ADMIN = "RunningProcessAdmin",
+    PROCESS_ADMIN = "ProcessAdmin",
+    RUNNING_PROCESS_RESOURCE = "RunningProcessResource",
+    PROCESS_RESOURCE = "ProcessResource",
+    READONLY_PROCESS_RESOURCE = "ReadOnlyProcessResource",
     PROCESS_LOGS = "ProcessLogs",
     REPOSITORY = "Repository",
     SSH_KEY = "SshKey",
@@ -113,5 +118,6 @@ export enum ContextMenuKind {
     PERMISSION_EDIT = "PermissionEdit",
     LINK = "Link",
     WORKFLOW = "Workflow",
-    SEARCH_RESULTS = "SearchResults"
+    READONLY_WORKFLOW = "ReadOnlyWorkflow",
+    SEARCH_RESULTS = "SearchResults",
 }
index 59c389ac573cbff0b90634aec8777a1ef9d4cf80..2e316f68598b87b923122c905e633a1ad09ebba3 100644 (file)
@@ -9,9 +9,10 @@ import { getDataExplorer } from "store/data-explorer/data-explorer-reducer";
 import { Dispatch } from "redux";
 import { dataExplorerActions } from "store/data-explorer/data-explorer-action";
 import { DataColumn } from "components/data-table/data-column";
-import { DataColumns } from "components/data-table/data-table";
-import { DataTableFilters } from 'components/data-table-filters/data-table-filters-tree';
+import { DataColumns, TCheckedList } from "components/data-table/data-table";
+import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree";
 import { LAST_REFRESH_TIMESTAMP } from "components/refresh-button/refresh-button";
+import { toggleMSToolbar, setCheckedListOnStore } from "store/multiselect/multiselect-actions";
 
 interface Props {
     id: string;
@@ -21,12 +22,14 @@ interface Props {
     extractKey?: (item: any) => React.Key;
 }
 
-const mapStateToProps = (state: RootState, { id }: Props) => {
-    const progress = state.progressIndicator.find(p => p.id === id);
-    const dataExplorerState = getDataExplorer(state.dataExplorer, id);
-    const currentRoute = state.router.location ? state.router.location.pathname : '';
-    const currentRefresh = localStorage.getItem(LAST_REFRESH_TIMESTAMP) || '';
-    const currentItemUuid = currentRoute === '/workflows' ? state.properties.workflowPanelDetailsUuid : state.detailsPanel.resourceUuid;
+const mapStateToProps = ({ progressIndicator, dataExplorer, router, multiselect, detailsPanel, properties}: RootState, { id }: Props) => {
+    const progress = progressIndicator.find(p => p.id === id);
+    const dataExplorerState = getDataExplorer(dataExplorer, id);
+    const currentRoute = router.location ? router.location.pathname : "";
+    const currentRefresh = localStorage.getItem(LAST_REFRESH_TIMESTAMP) || "";
+    const isDetailsResourceChecked = multiselect.checkedList[detailsPanel.resourceUuid]
+    const currentItemUuid = currentRoute === "/workflows" ? properties.workflowPanelDetailsUuid : isDetailsResourceChecked ? detailsPanel.resourceUuid : multiselect.selectedUuid;
+    const isMSToolbarVisible = multiselect.isVisible;
     return {
         ...dataExplorerState,
         working: !!progress?.working,
@@ -34,6 +37,8 @@ const mapStateToProps = (state: RootState, { id }: Props) => {
         currentRoute: currentRoute,
         paperKey: currentRoute,
         currentItemUuid,
+        isMSToolbarVisible,
+        checkedList: multiselect.checkedList,
     };
 };
 
@@ -71,6 +76,14 @@ const mapDispatchToProps = () => {
             dispatch(dataExplorerActions.SET_PAGE({ id, page }));
         },
 
+        toggleMSToolbar: (isVisible: boolean) => {
+            dispatch<any>(toggleMSToolbar(isVisible));
+        },
+
+        setCheckedListOnStore: (checkedList: TCheckedList) => {
+            dispatch<any>(setCheckedListOnStore(checkedList));
+        },
+
         onRowClick,
 
         onRowDoubleClick,
index d274157c48e2b1cd22804179fa33954c4b8fb361..059aad4344fad0f49d55349f79e076a3f92d92e9 100644 (file)
@@ -2,18 +2,10 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-import {
-    Grid,
-    Typography,
-    withStyles,
-    Tooltip,
-    IconButton,
-    Checkbox,
-    Chip
-} from '@material-ui/core';
-import { FavoriteStar, PublicFavoriteStar } from '../favorite-star/favorite-star';
-import { Resource, ResourceKind, TrashableResource } from 'models/resource';
+import React from "react";
+import { Grid, Typography, withStyles, Tooltip, IconButton, Checkbox, Chip } from "@material-ui/core";
+import { FavoriteStar, PublicFavoriteStar } from "../favorite-star/favorite-star";
+import { Resource, ResourceKind, TrashableResource } from "models/resource";
 import {
     FreezeIcon,
     ProjectIcon,
@@ -29,93 +21,101 @@ import {
     ActiveIcon,
     SetupIcon,
     InactiveIcon,
-} from 'components/icon/icon';
-import { formatDate, formatFileSize, formatTime } from 'common/formatters';
-import { resourceLabel } from 'common/labels';
-import { connect, DispatchProp } from 'react-redux';
-import { RootState } from 'store/store';
-import { getResource, filterResources } from 'store/resources/resources';
-import { GroupContentsResource } from 'services/groups-service/groups-service';
-import { getProcess, Process, getProcessStatus, getProcessStatusStyles, getProcessRuntime } from 'store/processes/process';
-import { ArvadosTheme } from 'common/custom-theme';
-import { compose, Dispatch } from 'redux';
-import { WorkflowResource } from 'models/workflow';
-import { ResourceStatus as WorkflowStatus } from 'views/workflow-panel/workflow-panel-view';
-import { getUuidPrefix, openRunProcess } from 'store/workflow-panel/workflow-panel-actions';
-import { openSharingDialog } from 'store/sharing-dialog/sharing-dialog-actions';
-import { getUserFullname, getUserDisplayName, User, UserResource } from 'models/user';
-import { toggleIsAdmin } from 'store/users/users-actions';
-import { LinkClass, LinkResource } from 'models/link';
-import { navigateTo, navigateToGroupDetails, navigateToUserProfile } from 'store/navigation/navigation-action';
-import { withResourceData } from 'views-components/data-explorer/with-resources';
-import { CollectionResource } from 'models/collection';
-import { IllegalNamingWarning } from 'components/warning/warning';
-import { loadResource } from 'store/resources/resources-actions';
-import { BuiltinGroups, getBuiltinGroupUuid, GroupClass, GroupResource, isBuiltinGroup } from 'models/group';
-import { openRemoveGroupMemberDialog } from 'store/group-details-panel/group-details-panel-actions';
-import { setMemberIsHidden } from 'store/group-details-panel/group-details-panel-actions';
-import { formatPermissionLevel } from 'views-components/sharing-dialog/permission-select';
-import { PermissionLevel } from 'models/permission';
-import { openPermissionEditContextMenu } from 'store/context-menu/context-menu-actions';
-import { getUserUuid } from 'common/getuser';
-import { VirtualMachinesResource } from 'models/virtual-machines';
-import { CopyToClipboardSnackbar } from 'components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar';
-import { ProjectResource } from 'models/project';
-import { ProcessResource } from 'models/process';
-
+} from "components/icon/icon";
+import { formatDate, formatFileSize, formatTime } from "common/formatters";
+import { resourceLabel } from "common/labels";
+import { connect, DispatchProp } from "react-redux";
+import { RootState } from "store/store";
+import { getResource, filterResources } from "store/resources/resources";
+import { GroupContentsResource } from "services/groups-service/groups-service";
+import { getProcess, Process, getProcessStatus, getProcessStatusStyles, getProcessRuntime } from "store/processes/process";
+import { ArvadosTheme } from "common/custom-theme";
+import { compose, Dispatch } from "redux";
+import { WorkflowResource } from "models/workflow";
+import { ResourceStatus as WorkflowStatus } from "views/workflow-panel/workflow-panel-view";
+import { getUuidPrefix, openRunProcess } from "store/workflow-panel/workflow-panel-actions";
+import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions";
+import { getUserFullname, getUserDisplayName, User, UserResource } from "models/user";
+import { toggleIsAdmin } from "store/users/users-actions";
+import { LinkClass, LinkResource } from "models/link";
+import { navigateTo, navigateToGroupDetails, navigateToUserProfile } from "store/navigation/navigation-action";
+import { withResourceData } from "views-components/data-explorer/with-resources";
+import { CollectionResource } from "models/collection";
+import { IllegalNamingWarning } from "components/warning/warning";
+import { loadResource } from "store/resources/resources-actions";
+import { BuiltinGroups, getBuiltinGroupUuid, GroupClass, GroupResource, isBuiltinGroup } from "models/group";
+import { openRemoveGroupMemberDialog } from "store/group-details-panel/group-details-panel-actions";
+import { setMemberIsHidden } from "store/group-details-panel/group-details-panel-actions";
+import { formatPermissionLevel } from "views-components/sharing-dialog/permission-select";
+import { PermissionLevel } from "models/permission";
+import { openPermissionEditContextMenu } from "store/context-menu/context-menu-actions";
+import { VirtualMachinesResource } from "models/virtual-machines";
+import { CopyToClipboardSnackbar } from "components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar";
+import { ProjectResource } from "models/project";
+import { ProcessResource } from "models/process";
 
 const renderName = (dispatch: Dispatch, item: GroupContentsResource) => {
-    const navFunc = ("groupClass" in item && item.groupClass === GroupClass.ROLE ? navigateToGroupDetails : navigateTo);
-    return <Grid container alignItems="center" wrap="nowrap" spacing={16}>
-        <Grid item>
-            {renderIcon(item)}
-        </Grid>
-        <Grid item>
-            <Typography color="primary" style={{ width: 'auto', cursor: 'pointer' }} onClick={() => dispatch<any>(navFunc(item.uuid))}>
-                {item.kind === ResourceKind.PROJECT || item.kind === ResourceKind.COLLECTION
-                    ? <IllegalNamingWarning name={item.name} />
-                    : null}
-                {item.name}
-            </Typography>
-        </Grid>
-        <Grid item>
-            <Typography variant="caption">
-                <FavoriteStar resourceUuid={item.uuid} />
-                <PublicFavoriteStar resourceUuid={item.uuid} />
-                {
-                    item.kind === ResourceKind.PROJECT && <FrozenProject item={item} />
-                }
-            </Typography>
+    const navFunc = "groupClass" in item && item.groupClass === GroupClass.ROLE ? navigateToGroupDetails : navigateTo;
+    return (
+        <Grid
+            container
+            alignItems="center"
+            wrap="nowrap"
+            spacing={16}
+        >
+            <Grid item>{renderIcon(item)}</Grid>
+            <Grid item>
+                <Typography
+                    color="primary"
+                    style={{ width: "auto", cursor: "pointer" }}
+                    onClick={(ev) => {
+                        ev.stopPropagation()
+                        dispatch<any>(navFunc(item.uuid))
+                    }}
+                >
+                    {item.kind === ResourceKind.PROJECT || item.kind === ResourceKind.COLLECTION ? <IllegalNamingWarning name={item.name} /> : null}
+                    {item.name}
+                </Typography>
+            </Grid>
+            <Grid item>
+                <Typography variant="caption">
+                    <FavoriteStar resourceUuid={item.uuid} />
+                    <PublicFavoriteStar resourceUuid={item.uuid} />
+                    {item.kind === ResourceKind.PROJECT && <FrozenProject item={item} />}
+                </Typography>
+            </Grid>
         </Grid>
-    </Grid>;
+    );
 };
 
-
-const FrozenProject = (props: {item: ProjectResource}) => {
+const FrozenProject = (props: { item: ProjectResource }) => {
     const [fullUsername, setFullusername] = React.useState<any>(null);
     const getFullName = React.useCallback(() => {
         if (props.item.frozenByUuid) {
             setFullusername(<UserNameFromID uuid={props.item.frozenByUuid} />);
         }
-    }, [props.item, setFullusername])
+    }, [props.item, setFullusername]);
 
     if (props.item.frozenByUuid) {
-
-        return <Tooltip onOpen={getFullName} enterDelay={500} title={<span>Project was frozen by {fullUsername}</span>}>
-            <FreezeIcon style={{ fontSize: "inherit" }}/>
-        </Tooltip>;
+        return (
+            <Tooltip
+                onOpen={getFullName}
+                enterDelay={500}
+                title={<span>Project was frozen by {fullUsername}</span>}
+            >
+                <FreezeIcon style={{ fontSize: "inherit" }} />
+            </Tooltip>
+        );
     } else {
         return null;
     }
-}
+};
 
-export const ResourceName = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
-        return resource;
-    })((resource: GroupContentsResource & DispatchProp<any>) => renderName(resource.dispatch, resource));
+export const ResourceName = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
+    return resource;
+})((resource: GroupContentsResource & DispatchProp<any>) => renderName(resource.dispatch, resource));
 
-    
 const renderIcon = (item: GroupContentsResource) => {
     switch (item.kind) {
         case ResourceKind.PROJECT:
@@ -138,26 +138,39 @@ const renderIcon = (item: GroupContentsResource) => {
 };
 
 const renderDate = (date?: string) => {
-    return <Typography noWrap style={{ minWidth: '100px' }}>{formatDate(date)}</Typography>;
+    return (
+        <Typography
+            noWrap
+            style={{ minWidth: "100px" }}
+        >
+            {formatDate(date)}
+        </Typography>
+    );
 };
 
-const renderWorkflowName = (item: WorkflowResource) =>
-    <Grid container alignItems="center" wrap="nowrap" spacing={16}>
+const renderWorkflowName = (item: WorkflowResource) => (
+    <Grid
+        container
+        alignItems="center"
+        wrap="nowrap"
+        spacing={16}
+    >
+        <Grid item>{renderIcon(item)}</Grid>
         <Grid item>
-            {renderIcon(item)}
-        </Grid>
-        <Grid item>
-            <Typography color="primary" style={{ width: '100px' }}>
+            <Typography
+                color="primary"
+                style={{ width: "100px" }}
+            >
                 {item.name}
             </Typography>
         </Grid>
-    </Grid>;
+    </Grid>
+);
 
-export const ResourceWorkflowName = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<WorkflowResource>(props.uuid)(state.resources);
-        return resource;
-    })(renderWorkflowName);
+export const ResourceWorkflowName = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<WorkflowResource>(props.uuid)(state.resources);
+    return resource;
+})(renderWorkflowName);
 
 const getPublicUuid = (uuidPrefix: string) => {
     return `${uuidPrefix}-tpzed-anonymouspublic`;
@@ -167,489 +180,517 @@ const resourceShare = (dispatch: Dispatch, uuidPrefix: string, ownerUuid?: strin
     const isPublic = ownerUuid === getPublicUuid(uuidPrefix);
     return (
         <div>
-            {!isPublic && uuid &&
+            {!isPublic && uuid && (
                 <Tooltip title="Share">
                     <IconButton onClick={() => dispatch<any>(openSharingDialog(uuid))}>
                         <ShareIcon />
                     </IconButton>
                 </Tooltip>
-            }
+            )}
         </div>
     );
 };
 
-export const ResourceShare = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<WorkflowResource>(props.uuid)(state.resources);
-        const uuidPrefix = getUuidPrefix(state);
-        return {
-            uuid: resource ? resource.uuid : '',
-            ownerUuid: resource ? resource.ownerUuid : '',
-            uuidPrefix
-        };
-    })((props: { ownerUuid?: string, uuidPrefix: string, uuid?: string } & DispatchProp<any>) =>
-        resourceShare(props.dispatch, props.uuidPrefix, props.ownerUuid, props.uuid));
+export const ResourceShare = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<WorkflowResource>(props.uuid)(state.resources);
+    const uuidPrefix = getUuidPrefix(state);
+    return {
+        uuid: resource ? resource.uuid : "",
+        ownerUuid: resource ? resource.ownerUuid : "",
+        uuidPrefix,
+    };
+})((props: { ownerUuid?: string; uuidPrefix: string; uuid?: string } & DispatchProp<any>) =>
+    resourceShare(props.dispatch, props.uuidPrefix, props.ownerUuid, props.uuid)
+);
 
 // User Resources
 const renderFirstName = (item: { firstName: string }) => {
     return <Typography noWrap>{item.firstName}</Typography>;
 };
 
-export const ResourceFirstName = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<UserResource>(props.uuid)(state.resources);
-        return resource || { firstName: '' };
-    })(renderFirstName);
+export const ResourceFirstName = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<UserResource>(props.uuid)(state.resources);
+    return resource || { firstName: "" };
+})(renderFirstName);
 
-const renderLastName = (item: { lastName: string }) =>
-    <Typography noWrap>{item.lastName}</Typography>;
+const renderLastName = (item: { lastName: string }) => <Typography noWrap>{item.lastName}</Typography>;
 
-export const ResourceLastName = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<UserResource>(props.uuid)(state.resources);
-        return resource || { lastName: '' };
-    })(renderLastName);
+export const ResourceLastName = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<UserResource>(props.uuid)(state.resources);
+    return resource || { lastName: "" };
+})(renderLastName);
 
-const renderFullName = (dispatch: Dispatch, item: { uuid: string, firstName: string, lastName: string }, link?: boolean) => {
+const renderFullName = (dispatch: Dispatch, item: { uuid: string; firstName: string; lastName: string }, link?: boolean) => {
     const displayName = (item.firstName + " " + item.lastName).trim() || item.uuid;
-    return link ? <Typography noWrap
-        color="primary"
-        style={{ 'cursor': 'pointer' }}
-        onClick={() => dispatch<any>(navigateToUserProfile(item.uuid))}>
-        {displayName}
-    </Typography> :
-        <Typography noWrap>{displayName}</Typography>;
-}
+    return link ? (
+        <Typography
+            noWrap
+            color="primary"
+            style={{ cursor: "pointer" }}
+            onClick={() => dispatch<any>(navigateToUserProfile(item.uuid))}
+        >
+            {displayName}
+        </Typography>
+    ) : (
+        <Typography noWrap>{displayName}</Typography>
+    );
+};
 
-export const UserResourceFullName = connect(
-    (state: RootState, props: { uuid: string, link?: boolean }) => {
-        const resource = getResource<UserResource>(props.uuid)(state.resources);
-        return { item: resource || { uuid: '', firstName: '', lastName: '' }, link: props.link };
-    })((props: { item: { uuid: string, firstName: string, lastName: string }, link?: boolean } & DispatchProp<any>) => renderFullName(props.dispatch, props.item, props.link));
+export const UserResourceFullName = connect((state: RootState, props: { uuid: string; link?: boolean }) => {
+    const resource = getResource<UserResource>(props.uuid)(state.resources);
+    return { item: resource || { uuid: "", firstName: "", lastName: "" }, link: props.link };
+})((props: { item: { uuid: string; firstName: string; lastName: string }; link?: boolean } & DispatchProp<any>) =>
+    renderFullName(props.dispatch, props.item, props.link)
+);
 
-const renderUuid = (item: { uuid: string }) =>
-    <Typography data-cy="uuid" noWrap>
+const renderUuid = (item: { uuid: string }) => (
+    <Typography
+        data-cy="uuid"
+        noWrap
+    >
         {item.uuid}
-        {(item.uuid && <CopyToClipboardSnackbar value={item.uuid} />) || '-' }
-    </Typography>;
+        {(item.uuid && <CopyToClipboardSnackbar value={item.uuid} />) || "-"}
+    </Typography>
+);
 
-const renderUuidCopyIcon = (item: { uuid: string }) =>
-    <Typography data-cy="uuid" noWrap>
-        {(item.uuid && <CopyToClipboardSnackbar value={item.uuid} />) || '-' }
-    </Typography>;
+const renderUuidCopyIcon = (item: { uuid: string }) => (
+    <Typography
+        data-cy="uuid"
+        noWrap
+    >
+        {(item.uuid && <CopyToClipboardSnackbar value={item.uuid} />) || "-"}
+    </Typography>
+);
 
-export const ResourceUuid = connect((state: RootState, props: { uuid: string }) => (
-    getResource<UserResource>(props.uuid)(state.resources) || { uuid: '' }
-))(renderUuid);
+export const ResourceUuid = connect(
+    (state: RootState, props: { uuid: string }) => getResource<UserResource>(props.uuid)(state.resources) || { uuid: "" }
+)(renderUuid);
 
-const renderEmail = (item: { email: string }) =>
-    <Typography noWrap>{item.email}</Typography>;
+const renderEmail = (item: { email: string }) => <Typography noWrap>{item.email}</Typography>;
 
-export const ResourceEmail = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<UserResource>(props.uuid)(state.resources);
-        return resource || { email: '' };
-    })(renderEmail);
+export const ResourceEmail = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<UserResource>(props.uuid)(state.resources);
+    return resource || { email: "" };
+})(renderEmail);
 
 enum UserAccountStatus {
-    ACTIVE = 'Active',
-    INACTIVE = 'Inactive',
-    SETUP = 'Setup',
-    UNKNOWN = ''
+    ACTIVE = "Active",
+    INACTIVE = "Inactive",
+    SETUP = "Setup",
+    UNKNOWN = "",
 }
 
-const renderAccountStatus = (props: { status: UserAccountStatus }) =>
-    <Grid container alignItems="center" wrap="nowrap" spacing={8} data-cy="account-status">
+const renderAccountStatus = (props: { status: UserAccountStatus }) => (
+    <Grid
+        container
+        alignItems="center"
+        wrap="nowrap"
+        spacing={8}
+        data-cy="account-status"
+    >
         <Grid item>
             {(() => {
                 switch (props.status) {
                     case UserAccountStatus.ACTIVE:
-                        return <ActiveIcon style={{ color: '#4caf50', verticalAlign: "middle" }} />;
+                        return <ActiveIcon style={{ color: "#4caf50", verticalAlign: "middle" }} />;
                     case UserAccountStatus.SETUP:
-                        return <SetupIcon style={{ color: '#2196f3', verticalAlign: "middle" }} />;
+                        return <SetupIcon style={{ color: "#2196f3", verticalAlign: "middle" }} />;
                     case UserAccountStatus.INACTIVE:
-                        return <InactiveIcon style={{ color: '#9e9e9e', verticalAlign: "middle" }} />;
+                        return <InactiveIcon style={{ color: "#9e9e9e", verticalAlign: "middle" }} />;
                     default:
                         return <></>;
                 }
             })()}
         </Grid>
         <Grid item>
-            <Typography noWrap>
-                {props.status}
-            </Typography>
+            <Typography noWrap>{props.status}</Typography>
         </Grid>
-    </Grid>;
+    </Grid>
+);
 
 const getUserAccountStatus = (state: RootState, props: { uuid: string }) => {
     const user = getResource<UserResource>(props.uuid)(state.resources);
     // Get membership links for all users group
     const allUsersGroupUuid = getBuiltinGroupUuid(state.auth.localCluster, BuiltinGroups.ALL);
-    const permissions = filterResources((resource: LinkResource) =>
-        resource.kind === ResourceKind.LINK &&
-        resource.linkClass === LinkClass.PERMISSION &&
-        resource.headUuid === allUsersGroupUuid &&
-        resource.tailUuid === props.uuid
+    const permissions = filterResources(
+        (resource: LinkResource) =>
+            resource.kind === ResourceKind.LINK &&
+            resource.linkClass === LinkClass.PERMISSION &&
+            resource.headUuid === allUsersGroupUuid &&
+            resource.tailUuid === props.uuid
     )(state.resources);
 
     if (user) {
-        return user.isActive ? { status: UserAccountStatus.ACTIVE } : permissions.length > 0 ? { status: UserAccountStatus.SETUP } : { status: UserAccountStatus.INACTIVE };
+        return user.isActive
+            ? { status: UserAccountStatus.ACTIVE }
+            : permissions.length > 0
+            ? { status: UserAccountStatus.SETUP }
+            : { status: UserAccountStatus.INACTIVE };
     } else {
         return { status: UserAccountStatus.UNKNOWN };
     }
-}
+};
 
-export const ResourceLinkTailAccountStatus = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const link = getResource<LinkResource>(props.uuid)(state.resources);
-        return link && link.tailKind === ResourceKind.USER ? getUserAccountStatus(state, { uuid: link.tailUuid }) : { status: UserAccountStatus.UNKNOWN };
-    })(renderAccountStatus);
+export const ResourceLinkTailAccountStatus = connect((state: RootState, props: { uuid: string }) => {
+    const link = getResource<LinkResource>(props.uuid)(state.resources);
+    return link && link.tailKind === ResourceKind.USER ? getUserAccountStatus(state, { uuid: link.tailUuid }) : { status: UserAccountStatus.UNKNOWN };
+})(renderAccountStatus);
 
 export const UserResourceAccountStatus = connect(getUserAccountStatus)(renderAccountStatus);
 
 const renderIsHidden = (props: {
-    memberLinkUuid: string,
-    permissionLinkUuid: string,
-    visible: boolean,
-    canManage: boolean,
-    setMemberIsHidden: (memberLinkUuid: string, permissionLinkUuid: string, hide: boolean) => void
+    memberLinkUuid: string;
+    permissionLinkUuid: string;
+    visible: boolean;
+    canManage: boolean;
+    setMemberIsHidden: (memberLinkUuid: string, permissionLinkUuid: string, hide: boolean) => void;
 }) => {
     if (props.memberLinkUuid) {
-        return <Checkbox
-            data-cy="user-visible-checkbox"
-            color="primary"
-            checked={props.visible}
-            disabled={!props.canManage}
-            onClick={(e) => {
-                e.stopPropagation();
-                props.setMemberIsHidden(props.memberLinkUuid, props.permissionLinkUuid, !props.visible);
-            }} />;
+        return (
+            <Checkbox
+                data-cy="user-visible-checkbox"
+                color="primary"
+                checked={props.visible}
+                disabled={!props.canManage}
+                onClick={e => {
+                    e.stopPropagation();
+                    props.setMemberIsHidden(props.memberLinkUuid, props.permissionLinkUuid, !props.visible);
+                }}
+            />
+        );
     } else {
         return <Typography />;
     }
-}
+};
 
 export const ResourceLinkTailIsVisible = connect(
     (state: RootState, props: { uuid: string }) => {
         const link = getResource<LinkResource>(props.uuid)(state.resources);
-        const member = getResource<Resource>(link?.tailUuid || '')(state.resources);
-        const group = getResource<GroupResource>(link?.headUuid || '')(state.resources);
+        const member = getResource<Resource>(link?.tailUuid || "")(state.resources);
+        const group = getResource<GroupResource>(link?.headUuid || "")(state.resources);
         const permissions = filterResources((resource: LinkResource) => {
-            return resource.linkClass === LinkClass.PERMISSION
-                && resource.headUuid === link?.tailUuid
-                && resource.tailUuid === group?.uuid
-                && resource.name === PermissionLevel.CAN_READ;
+            return (
+                resource.linkClass === LinkClass.PERMISSION &&
+                resource.headUuid === link?.tailUuid &&
+                resource.tailUuid === group?.uuid &&
+                resource.name === PermissionLevel.CAN_READ
+            );
         })(state.resources);
 
-        const permissionLinkUuid = permissions.length > 0 ? permissions[0].uuid : '';
+        const permissionLinkUuid = permissions.length > 0 ? permissions[0].uuid : "";
         const isVisible = link && group && permissions.length > 0;
         // Consider whether the current user canManage this resurce in addition when it's possible
-        const isBuiltin = isBuiltinGroup(link?.headUuid || '');
+        const isBuiltin = isBuiltinGroup(link?.headUuid || "");
 
         return member?.kind === ResourceKind.USER
             ? { memberLinkUuid: link?.uuid, permissionLinkUuid, visible: isVisible, canManage: !isBuiltin }
-            : { memberLinkUuid: '', permissionLinkUuid: '', visible: false, canManage: false };
-    }, { setMemberIsHidden }
+            : { memberLinkUuid: "", permissionLinkUuid: "", visible: false, canManage: false };
+    },
+    { setMemberIsHidden }
 )(renderIsHidden);
 
-const renderIsAdmin = (props: { uuid: string, isAdmin: boolean, toggleIsAdmin: (uuid: string) => void }) =>
+const renderIsAdmin = (props: { uuid: string; isAdmin: boolean; toggleIsAdmin: (uuid: string) => void }) => (
     <Checkbox
         color="primary"
         checked={props.isAdmin}
-        onClick={(e) => {
+        onClick={e => {
             e.stopPropagation();
             props.toggleIsAdmin(props.uuid);
-        }} />;
+        }}
+    />
+);
 
 export const ResourceIsAdmin = connect(
     (state: RootState, props: { uuid: string }) => {
         const resource = getResource<UserResource>(props.uuid)(state.resources);
         return resource || { isAdmin: false };
-    }, { toggleIsAdmin }
+    },
+    { toggleIsAdmin }
 )(renderIsAdmin);
 
-const renderUsername = (item: { username: string, uuid: string }) =>
-    <Typography noWrap>{item.username || item.uuid}</Typography>;
+const renderUsername = (item: { username: string; uuid: string }) => <Typography noWrap>{item.username || item.uuid}</Typography>;
 
-export const ResourceUsername = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<UserResource>(props.uuid)(state.resources);
-        return resource || { username: '', uuid: props.uuid };
-    })(renderUsername);
+export const ResourceUsername = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<UserResource>(props.uuid)(state.resources);
+    return resource || { username: "", uuid: props.uuid };
+})(renderUsername);
 
 // Virtual machine resource
 
-const renderHostname = (item: { hostname: string }) =>
-    <Typography noWrap>{item.hostname}</Typography>;
+const renderHostname = (item: { hostname: string }) => <Typography noWrap>{item.hostname}</Typography>;
 
-export const VirtualMachineHostname = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<VirtualMachinesResource>(props.uuid)(state.resources);
-        return resource || { hostname: '' };
-    })(renderHostname);
+export const VirtualMachineHostname = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<VirtualMachinesResource>(props.uuid)(state.resources);
+    return resource || { hostname: "" };
+})(renderHostname);
 
-const renderVirtualMachineLogin = (login: { user: string }) =>
-    <Typography noWrap>{login.user}</Typography>
+const renderVirtualMachineLogin = (login: { user: string }) => <Typography noWrap>{login.user}</Typography>;
 
-export const VirtualMachineLogin = connect(
-    (state: RootState, props: { linkUuid: string }) => {
-        const permission = getResource<LinkResource>(props.linkUuid)(state.resources);
-        const user = getResource<UserResource>(permission?.tailUuid || '')(state.resources);
+export const VirtualMachineLogin = connect((state: RootState, props: { linkUuid: string }) => {
+    const permission = getResource<LinkResource>(props.linkUuid)(state.resources);
+    const user = getResource<UserResource>(permission?.tailUuid || "")(state.resources);
 
-        return { user: user?.username || permission?.tailUuid || '' };
-    })(renderVirtualMachineLogin);
+    return { user: user?.username || permission?.tailUuid || "" };
+})(renderVirtualMachineLogin);
 
 // Common methods
-const renderCommonData = (data: string) =>
-    <Typography noWrap>{data}</Typography>;
+const renderCommonData = (data: string) => <Typography noWrap>{data}</Typography>;
 
-const renderCommonDate = (date: string) =>
-    <Typography noWrap>{formatDate(date)}</Typography>;
+const renderCommonDate = (date: string) => <Typography noWrap>{formatDate(date)}</Typography>;
 
-export const CommonUuid = withResourceData('uuid', renderCommonData);
+export const CommonUuid = withResourceData("uuid", renderCommonData);
 
 // Api Client Authorizations
-export const TokenApiClientId = withResourceData('apiClientId', renderCommonData);
+export const TokenApiClientId = withResourceData("apiClientId", renderCommonData);
 
-export const TokenApiToken = withResourceData('apiToken', renderCommonData);
+export const TokenApiToken = withResourceData("apiToken", renderCommonData);
 
-export const TokenCreatedByIpAddress = withResourceData('createdByIpAddress', renderCommonDate);
+export const TokenCreatedByIpAddress = withResourceData("createdByIpAddress", renderCommonDate);
 
-export const TokenDefaultOwnerUuid = withResourceData('defaultOwnerUuid', renderCommonData);
+export const TokenDefaultOwnerUuid = withResourceData("defaultOwnerUuid", renderCommonData);
 
-export const TokenExpiresAt = withResourceData('expiresAt', renderCommonDate);
+export const TokenExpiresAt = withResourceData("expiresAt", renderCommonDate);
 
-export const TokenLastUsedAt = withResourceData('lastUsedAt', renderCommonDate);
+export const TokenLastUsedAt = withResourceData("lastUsedAt", renderCommonDate);
 
-export const TokenLastUsedByIpAddress = withResourceData('lastUsedByIpAddress', renderCommonData);
+export const TokenLastUsedByIpAddress = withResourceData("lastUsedByIpAddress", renderCommonData);
 
-export const TokenScopes = withResourceData('scopes', renderCommonData);
+export const TokenScopes = withResourceData("scopes", renderCommonData);
 
-export const TokenUserId = withResourceData('userId', renderCommonData);
+export const TokenUserId = withResourceData("userId", renderCommonData);
 
 const clusterColors = [
-    ['#f44336', '#fff'],
-    ['#2196f3', '#fff'],
-    ['#009688', '#fff'],
-    ['#cddc39', '#fff'],
-    ['#ff9800', '#fff']
+    ["#f44336", "#fff"],
+    ["#2196f3", "#fff"],
+    ["#009688", "#fff"],
+    ["#cddc39", "#fff"],
+    ["#ff9800", "#fff"],
 ];
 
 export const ResourceCluster = (props: { uuid: string }) => {
     const CLUSTER_ID_LENGTH = 5;
-    const pos = props.uuid.length > CLUSTER_ID_LENGTH ? props.uuid.indexOf('-') : 5;
-    const clusterId = pos >= CLUSTER_ID_LENGTH ? props.uuid.substring(0, pos) : '';
-    const ci = pos >= CLUSTER_ID_LENGTH ? (((((
-        (props.uuid.charCodeAt(0) * props.uuid.charCodeAt(1))
-        + props.uuid.charCodeAt(2))
-        * props.uuid.charCodeAt(3))
-        + props.uuid.charCodeAt(4))) % clusterColors.length) : 0;
-    return <span style={{
-        backgroundColor: clusterColors[ci][0],
-        color: clusterColors[ci][1],
-        padding: "2px 7px",
-        borderRadius: 3
-    }}>{clusterId}</span>;
+    const pos = props.uuid.length > CLUSTER_ID_LENGTH ? props.uuid.indexOf("-") : 5;
+    const clusterId = pos >= CLUSTER_ID_LENGTH ? props.uuid.substring(0, pos) : "";
+    const ci =
+        pos >= CLUSTER_ID_LENGTH
+            ? ((props.uuid.charCodeAt(0) * props.uuid.charCodeAt(1) + props.uuid.charCodeAt(2)) * props.uuid.charCodeAt(3) +
+                  props.uuid.charCodeAt(4)) %
+              clusterColors.length
+            : 0;
+    return (
+        <span
+            style={{
+                backgroundColor: clusterColors[ci][0],
+                color: clusterColors[ci][1],
+                padding: "2px 7px",
+                borderRadius: 3,
+            }}
+        >
+            {clusterId}
+        </span>
+    );
 };
 
 // Links Resources
-const renderLinkName = (item: { name: string }) =>
-    <Typography noWrap>{item.name || '-'}</Typography>;
+const renderLinkName = (item: { name: string }) => <Typography noWrap>{item.name || "-"}</Typography>;
 
-export const ResourceLinkName = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<LinkResource>(props.uuid)(state.resources);
-        return resource || { name: '' };
-    })(renderLinkName);
+export const ResourceLinkName = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<LinkResource>(props.uuid)(state.resources);
+    return resource || { name: "" };
+})(renderLinkName);
 
-const renderLinkClass = (item: { linkClass: string }) =>
-    <Typography noWrap>{item.linkClass}</Typography>;
+const renderLinkClass = (item: { linkClass: string }) => <Typography noWrap>{item.linkClass}</Typography>;
 
-export const ResourceLinkClass = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<LinkResource>(props.uuid)(state.resources);
-        return resource || { linkClass: '' };
-    })(renderLinkClass);
+export const ResourceLinkClass = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<LinkResource>(props.uuid)(state.resources);
+    return resource || { linkClass: "" };
+})(renderLinkClass);
 
 const getResourceDisplayName = (resource: Resource): string => {
-    if ((resource as UserResource).kind === ResourceKind.USER
-        && typeof (resource as UserResource).firstName !== 'undefined') {
+    if ((resource as UserResource).kind === ResourceKind.USER && typeof (resource as UserResource).firstName !== "undefined") {
         // We can be sure the resource is UserResource
         return getUserDisplayName(resource as UserResource);
     } else {
         return (resource as GroupContentsResource).name;
     }
-}
+};
 
 const renderResourceLink = (dispatch: Dispatch, item: Resource) => {
     var displayName = getResourceDisplayName(item);
 
-    return <Typography noWrap color="primary" style={{ 'cursor': 'pointer' }} onClick={() => dispatch<any>(navigateTo(item.uuid))}>
-        {resourceLabel(item.kind, item && item.kind === ResourceKind.GROUP ? (item as GroupResource).groupClass || '' : '')}: {displayName || item.uuid}
-    </Typography>;
+    return (
+        <Typography
+            noWrap
+            color="primary"
+            style={{ cursor: "pointer" }}
+            onClick={() => {
+                item.kind === ResourceKind.GROUP && (item as GroupResource).groupClass === "role"
+                    ? dispatch<any>(navigateToGroupDetails(item.uuid))
+                    : dispatch<any>(navigateTo(item.uuid));
+            }}
+        >
+            {resourceLabel(item.kind, item && item.kind === ResourceKind.GROUP ? (item as GroupResource).groupClass || "" : "")}:{" "}
+            {displayName || item.uuid}
+        </Typography>
+    );
 };
 
-export const ResourceLinkTail = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<LinkResource>(props.uuid)(state.resources);
-        const tailResource = getResource<Resource>(resource?.tailUuid || '')(state.resources);
+export const ResourceLinkTail = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<LinkResource>(props.uuid)(state.resources);
+    const tailResource = getResource<Resource>(resource?.tailUuid || "")(state.resources);
 
-        return {
-            item: tailResource || { uuid: resource?.tailUuid || '', kind: resource?.tailKind || ResourceKind.NONE }
-        };
-    })((props: { item: Resource } & DispatchProp<any>) =>
-        renderResourceLink(props.dispatch, props.item));
+    return {
+        item: tailResource || { uuid: resource?.tailUuid || "", kind: resource?.tailKind || ResourceKind.NONE },
+    };
+})((props: { item: Resource } & DispatchProp<any>) => renderResourceLink(props.dispatch, props.item));
 
-export const ResourceLinkHead = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<LinkResource>(props.uuid)(state.resources);
-        const headResource = getResource<Resource>(resource?.headUuid || '')(state.resources);
+export const ResourceLinkHead = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<LinkResource>(props.uuid)(state.resources);
+    const headResource = getResource<Resource>(resource?.headUuid || "")(state.resources);
 
-        return {
-            item: headResource || { uuid: resource?.headUuid || '', kind: resource?.headKind || ResourceKind.NONE }
-        };
-    })((props: { item: Resource } & DispatchProp<any>) =>
-        renderResourceLink(props.dispatch, props.item));
+    return {
+        item: headResource || { uuid: resource?.headUuid || "", kind: resource?.headKind || ResourceKind.NONE },
+    };
+})((props: { item: Resource } & DispatchProp<any>) => renderResourceLink(props.dispatch, props.item));
 
-export const ResourceLinkUuid = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<LinkResource>(props.uuid)(state.resources);
-        return resource || { uuid: '' };
-    })(renderUuid);
+export const ResourceLinkUuid = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<LinkResource>(props.uuid)(state.resources);
+    return resource || { uuid: "" };
+})(renderUuid);
 
-export const ResourceLinkHeadUuid = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const link = getResource<LinkResource>(props.uuid)(state.resources);
-        const headResource = getResource<Resource>(link?.headUuid || '')(state.resources);
+export const ResourceLinkHeadUuid = connect((state: RootState, props: { uuid: string }) => {
+    const link = getResource<LinkResource>(props.uuid)(state.resources);
+    const headResource = getResource<Resource>(link?.headUuid || "")(state.resources);
 
-        return headResource || { uuid: '' };
-    })(renderUuid);
+    return headResource || { uuid: "" };
+})(renderUuid);
 
-export const ResourceLinkTailUuid = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const link = getResource<LinkResource>(props.uuid)(state.resources);
-        const tailResource = getResource<Resource>(link?.tailUuid || '')(state.resources);
+export const ResourceLinkTailUuid = connect((state: RootState, props: { uuid: string }) => {
+    const link = getResource<LinkResource>(props.uuid)(state.resources);
+    const tailResource = getResource<Resource>(link?.tailUuid || "")(state.resources);
 
-        return tailResource || { uuid: '' };
-    })(renderUuid);
+    return tailResource || { uuid: "" };
+})(renderUuid);
 
 const renderLinkDelete = (dispatch: Dispatch, item: LinkResource, canManage: boolean) => {
     if (item.uuid) {
-        return canManage ?
+        return canManage ? (
             <Typography noWrap>
-                <IconButton data-cy="resource-delete-button" onClick={() => dispatch<any>(openRemoveGroupMemberDialog(item.uuid))}>
+                <IconButton
+                    data-cy="resource-delete-button"
+                    onClick={() => dispatch<any>(openRemoveGroupMemberDialog(item.uuid))}
+                >
                     <RemoveIcon />
                 </IconButton>
-            </Typography> :
+            </Typography>
+        ) : (
             <Typography noWrap>
-                <IconButton disabled data-cy="resource-delete-button">
+                <IconButton
+                    disabled
+                    data-cy="resource-delete-button"
+                >
                     <RemoveIcon />
                 </IconButton>
-            </Typography>;
+            </Typography>
+        );
     } else {
         return <Typography noWrap></Typography>;
     }
-}
+};
 
-export const ResourceLinkDelete = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const link = getResource<LinkResource>(props.uuid)(state.resources);
-        const isBuiltin = isBuiltinGroup(link?.headUuid || '') || isBuiltinGroup(link?.tailUuid || '');
+export const ResourceLinkDelete = connect((state: RootState, props: { uuid: string }) => {
+    const link = getResource<LinkResource>(props.uuid)(state.resources);
+    const isBuiltin = isBuiltinGroup(link?.headUuid || "") || isBuiltinGroup(link?.tailUuid || "");
 
-        return {
-            item: link || { uuid: '', kind: ResourceKind.NONE },
-            canManage: link && getResourceLinkCanManage(state, link) && !isBuiltin,
-        };
-    })((props: { item: LinkResource, canManage: boolean } & DispatchProp<any>) =>
-        renderLinkDelete(props.dispatch, props.item, props.canManage));
+    return {
+        item: link || { uuid: "", kind: ResourceKind.NONE },
+        canManage: link && getResourceLinkCanManage(state, link) && !isBuiltin,
+    };
+})((props: { item: LinkResource; canManage: boolean } & DispatchProp<any>) => renderLinkDelete(props.dispatch, props.item, props.canManage));
 
-export const ResourceLinkTailEmail = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const link = getResource<LinkResource>(props.uuid)(state.resources);
-        const resource = getResource<UserResource>(link?.tailUuid || '')(state.resources);
+export const ResourceLinkTailEmail = connect((state: RootState, props: { uuid: string }) => {
+    const link = getResource<LinkResource>(props.uuid)(state.resources);
+    const resource = getResource<UserResource>(link?.tailUuid || "")(state.resources);
 
-        return resource || { email: '' };
-    })(renderEmail);
+    return resource || { email: "" };
+})(renderEmail);
 
-export const ResourceLinkTailUsername = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const link = getResource<LinkResource>(props.uuid)(state.resources);
-        const resource = getResource<UserResource>(link?.tailUuid || '')(state.resources);
+export const ResourceLinkTailUsername = connect((state: RootState, props: { uuid: string }) => {
+    const link = getResource<LinkResource>(props.uuid)(state.resources);
+    const resource = getResource<UserResource>(link?.tailUuid || "")(state.resources);
 
-        return resource || { username: '' };
-    })(renderUsername);
+    return resource || { username: "" };
+})(renderUsername);
 
 const renderPermissionLevel = (dispatch: Dispatch, link: LinkResource, canManage: boolean) => {
-    return <Typography noWrap>
-        {formatPermissionLevel(link.name as PermissionLevel)}
-        {canManage ?
-            <IconButton data-cy="edit-permission-button" onClick={(event) => dispatch<any>(openPermissionEditContextMenu(event, link))}>
-                <RenameIcon />
-            </IconButton> :
-            ''
-        }
-    </Typography>;
-}
+    return (
+        <Typography noWrap>
+            {formatPermissionLevel(link.name as PermissionLevel)}
+            {canManage ? (
+                <IconButton
+                    data-cy="edit-permission-button"
+                    onClick={event => dispatch<any>(openPermissionEditContextMenu(event, link))}
+                >
+                    <RenameIcon />
+                </IconButton>
+            ) : (
+                ""
+            )}
+        </Typography>
+    );
+};
 
-export const ResourceLinkHeadPermissionLevel = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const link = getResource<LinkResource>(props.uuid)(state.resources);
-        const isBuiltin = isBuiltinGroup(link?.headUuid || '') || isBuiltinGroup(link?.tailUuid || '');
+export const ResourceLinkHeadPermissionLevel = connect((state: RootState, props: { uuid: string }) => {
+    const link = getResource<LinkResource>(props.uuid)(state.resources);
+    const isBuiltin = isBuiltinGroup(link?.headUuid || "") || isBuiltinGroup(link?.tailUuid || "");
 
-        return {
-            link: link || { uuid: '', name: '', kind: ResourceKind.NONE },
-            canManage: link && getResourceLinkCanManage(state, link) && !isBuiltin,
-        };
-    })((props: { link: LinkResource, canManage: boolean } & DispatchProp<any>) =>
-        renderPermissionLevel(props.dispatch, props.link, props.canManage));
+    return {
+        link: link || { uuid: "", name: "", kind: ResourceKind.NONE },
+        canManage: link && getResourceLinkCanManage(state, link) && !isBuiltin,
+    };
+})((props: { link: LinkResource; canManage: boolean } & DispatchProp<any>) => renderPermissionLevel(props.dispatch, props.link, props.canManage));
 
-export const ResourceLinkTailPermissionLevel = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const link = getResource<LinkResource>(props.uuid)(state.resources);
-        const isBuiltin = isBuiltinGroup(link?.headUuid || '') || isBuiltinGroup(link?.tailUuid || '');
+export const ResourceLinkTailPermissionLevel = connect((state: RootState, props: { uuid: string }) => {
+    const link = getResource<LinkResource>(props.uuid)(state.resources);
+    const isBuiltin = isBuiltinGroup(link?.headUuid || "") || isBuiltinGroup(link?.tailUuid || "");
 
-        return {
-            link: link || { uuid: '', name: '', kind: ResourceKind.NONE },
-            canManage: link && getResourceLinkCanManage(state, link) && !isBuiltin,
-        };
-    })((props: { link: LinkResource, canManage: boolean } & DispatchProp<any>) =>
-        renderPermissionLevel(props.dispatch, props.link, props.canManage));
+    return {
+        link: link || { uuid: "", name: "", kind: ResourceKind.NONE },
+        canManage: link && getResourceLinkCanManage(state, link) && !isBuiltin,
+    };
+})((props: { link: LinkResource; canManage: boolean } & DispatchProp<any>) => renderPermissionLevel(props.dispatch, props.link, props.canManage));
 
 const getResourceLinkCanManage = (state: RootState, link: LinkResource) => {
     const headResource = getResource<Resource>(link.headUuid)(state.resources);
-    // const tailResource = getResource<Resource>(link.tailUuid)(state.resources);
-    const userUuid = getUserUuid(state);
-
     if (headResource && headResource.kind === ResourceKind.GROUP) {
-        return userUuid ? (headResource as GroupResource).writableBy?.includes(userUuid) : false;
+        return (headResource as GroupResource).canManage;
     } else {
         // true for now
         return true;
     }
-}
+};
 
 // Process Resources
 const resourceRunProcess = (dispatch: Dispatch, uuid: string) => {
     return (
         <div>
-            {uuid &&
+            {uuid && (
                 <Tooltip title="Run process">
                     <IconButton onClick={() => dispatch<any>(openRunProcess(uuid))}>
                         <ProcessIcon />
                     </IconButton>
-                </Tooltip>}
+                </Tooltip>
+            )}
         </div>
     );
 };
 
-export const ResourceRunProcess = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<WorkflowResource>(props.uuid)(state.resources);
-        return {
-            uuid: resource ? resource.uuid : ''
-        };
-    })((props: { uuid: string } & DispatchProp<any>) =>
-        resourceRunProcess(props.dispatch, props.uuid));
+export const ResourceRunProcess = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<WorkflowResource>(props.uuid)(state.resources);
+    return {
+        uuid: resource ? resource.uuid : "",
+    };
+})((props: { uuid: string } & DispatchProp<any>) => resourceRunProcess(props.dispatch, props.uuid));
 
 const renderWorkflowStatus = (uuidPrefix: string, ownerUuid?: string) => {
     if (ownerUuid === getPublicUuid(uuidPrefix)) {
@@ -659,353 +700,398 @@ const renderWorkflowStatus = (uuidPrefix: string, ownerUuid?: string) => {
     }
 };
 
-const renderStatus = (status: string) =>
-    <Typography noWrap style={{ width: '60px' }}>{status}</Typography>;
+const renderStatus = (status: string) => (
+    <Typography
+        noWrap
+        style={{ width: "60px" }}
+    >
+        {status}
+    </Typography>
+);
 
-export const ResourceWorkflowStatus = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<WorkflowResource>(props.uuid)(state.resources);
-        const uuidPrefix = getUuidPrefix(state);
-        return {
-            ownerUuid: resource ? resource.ownerUuid : '',
-            uuidPrefix
-        };
-    })((props: { ownerUuid?: string, uuidPrefix: string }) => renderWorkflowStatus(props.uuidPrefix, props.ownerUuid));
-
-export const ResourceContainerUuid = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const process = getProcess(props.uuid)(state.resources)
-        return { uuid: process?.container?.uuid ? process?.container?.uuid : '' };
-    })((props: { uuid: string }) => renderUuid({ uuid: props.uuid }));
+export const ResourceWorkflowStatus = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<WorkflowResource>(props.uuid)(state.resources);
+    const uuidPrefix = getUuidPrefix(state);
+    return {
+        ownerUuid: resource ? resource.ownerUuid : "",
+        uuidPrefix,
+    };
+})((props: { ownerUuid?: string; uuidPrefix: string }) => renderWorkflowStatus(props.uuidPrefix, props.ownerUuid));
+
+export const ResourceContainerUuid = connect((state: RootState, props: { uuid: string }) => {
+    const process = getProcess(props.uuid)(state.resources);
+    return { uuid: process?.container?.uuid ? process?.container?.uuid : "" };
+})((props: { uuid: string }) => renderUuid({ uuid: props.uuid }));
 
 enum ColumnSelection {
-    OUTPUT_UUID = 'outputUuid',
-    LOG_UUID = 'logUuid'
+    OUTPUT_UUID = "outputUuid",
+    LOG_UUID = "logUuid",
 }
 
 const renderUuidLinkWithCopyIcon = (dispatch: Dispatch, item: ProcessResource, column: string) => {
-    const selectedColumnUuid = item[column]
-    return <Grid container alignItems="center" wrap="nowrap" >
-        <Grid item>
-            {selectedColumnUuid ? 
-                <Typography color="primary" style={{ width: 'auto', cursor: 'pointer' }} noWrap 
-                    onClick={() => dispatch<any>(navigateTo(selectedColumnUuid))}>
-                    {selectedColumnUuid} 
-                </Typography> 
-            : '-' }
-        </Grid>
-        <Grid item>
-            {selectedColumnUuid && renderUuidCopyIcon({ uuid: selectedColumnUuid })}
+    const selectedColumnUuid = item[column];
+    return (
+        <Grid
+            container
+            alignItems="center"
+            wrap="nowrap"
+        >
+            <Grid item>
+                {selectedColumnUuid ? (
+                    <Typography
+                        color="primary"
+                        style={{ width: "auto", cursor: "pointer" }}
+                        noWrap
+                        onClick={() => dispatch<any>(navigateTo(selectedColumnUuid))}
+                    >
+                        {selectedColumnUuid}
+                    </Typography>
+                ) : (
+                    "-"
+                )}
+            </Grid>
+            <Grid item>{selectedColumnUuid && renderUuidCopyIcon({ uuid: selectedColumnUuid })}</Grid>
         </Grid>
-    </Grid>;
+    );
 };
 
-export const ResourceOutputUuid = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<ProcessResource>(props.uuid)(state.resources);
-        return resource;
-    })((process: ProcessResource & DispatchProp<any>) => renderUuidLinkWithCopyIcon(process.dispatch, process, ColumnSelection.OUTPUT_UUID));
-
-export const ResourceLogUuid = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<ProcessResource>(props.uuid)(state.resources);
-        return resource;
-    })((process: ProcessResource & DispatchProp<any>) => renderUuidLinkWithCopyIcon(process.dispatch, process, ColumnSelection.LOG_UUID));
+export const ResourceOutputUuid = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<ProcessResource>(props.uuid)(state.resources);
+    return resource;
+})((process: ProcessResource & DispatchProp<any>) => renderUuidLinkWithCopyIcon(process.dispatch, process, ColumnSelection.OUTPUT_UUID));
+
+export const ResourceLogUuid = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<ProcessResource>(props.uuid)(state.resources);
+    return resource;
+})((process: ProcessResource & DispatchProp<any>) => renderUuidLinkWithCopyIcon(process.dispatch, process, ColumnSelection.LOG_UUID));
+
+export const ResourceParentProcess = connect((state: RootState, props: { uuid: string }) => {
+    const process = getProcess(props.uuid)(state.resources);
+    return { parentProcess: process?.containerRequest?.requestingContainerUuid || "" };
+})((props: { parentProcess: string }) => renderUuid({ uuid: props.parentProcess }));
+
+export const ResourceModifiedByUserUuid = connect((state: RootState, props: { uuid: string }) => {
+    const process = getProcess(props.uuid)(state.resources);
+    return { userUuid: process?.containerRequest?.modifiedByUserUuid || "" };
+})((props: { userUuid: string }) => renderUuid({ uuid: props.userUuid }));
+
+export const ResourceCreatedAtDate = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
+    return { date: resource ? resource.createdAt : "" };
+})((props: { date: string }) => renderDate(props.date));
+
+export const ResourceLastModifiedDate = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
+    return { date: resource ? resource.modifiedAt : "" };
+})((props: { date: string }) => renderDate(props.date));
+
+export const ResourceTrashDate = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<TrashableResource>(props.uuid)(state.resources);
+    return { date: resource ? resource.trashAt : "" };
+})((props: { date: string }) => renderDate(props.date));
+
+export const ResourceDeleteDate = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<TrashableResource>(props.uuid)(state.resources);
+    return { date: resource ? resource.deleteAt : "" };
+})((props: { date: string }) => renderDate(props.date));
+
+export const renderFileSize = (fileSize?: number) => (
+    <Typography
+        noWrap
+        style={{ minWidth: "45px" }}
+    >
+        {formatFileSize(fileSize)}
+    </Typography>
+);
 
-export const ResourceParentProcess = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const process = getProcess(props.uuid)(state.resources)
-        return { parentProcess: process?.containerRequest?.requestingContainerUuid || '' };
-    })((props: { parentProcess: string }) => renderUuid({uuid: props.parentProcess}));
+export const ResourceFileSize = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<CollectionResource>(props.uuid)(state.resources);
 
-export const ResourceModifiedByUserUuid = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const process = getProcess(props.uuid)(state.resources)
-        return { userUuid: process?.containerRequest?.modifiedByUserUuid || '' };
-    })((props: { userUuid: string }) => renderUuid({uuid: props.userUuid}));
+    if (resource && resource.kind !== ResourceKind.COLLECTION) {
+        return { fileSize: "" };
+    }
 
-    export const ResourceCreatedAtDate = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
-        return { date: resource ? resource.createdAt : '' };
-    })((props: { date: string }) => renderDate(props.date));
-    
-export const ResourceLastModifiedDate = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
-        return { date: resource ? resource.modifiedAt : '' };
-    })((props: { date: string }) => renderDate(props.date));
+    return { fileSize: resource ? resource.fileSizeTotal : 0 };
+})((props: { fileSize?: number }) => renderFileSize(props.fileSize));
 
-export const ResourceTrashDate = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<TrashableResource>(props.uuid)(state.resources);
-        return { date: resource ? resource.trashAt : '' };
-    })((props: { date: string }) => renderDate(props.date));
+const renderOwner = (owner: string) => <Typography noWrap>{owner || "-"}</Typography>;
 
-export const ResourceDeleteDate = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<TrashableResource>(props.uuid)(state.resources);
-        return { date: resource ? resource.deleteAt : '' };
-    })((props: { date: string }) => renderDate(props.date));
+export const ResourceOwner = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
+    return { owner: resource ? resource.ownerUuid : "" };
+})((props: { owner: string }) => renderOwner(props.owner));
 
-export const renderFileSize = (fileSize?: number) =>
-    <Typography noWrap style={{ minWidth: '45px' }}>
-        {formatFileSize(fileSize)}
-    </Typography>;
+export const ResourceOwnerName = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
+    const ownerNameState = state.ownerName;
+    const ownerName = ownerNameState.find(it => it.uuid === resource!.ownerUuid);
+    return { owner: ownerName ? ownerName!.name : resource!.ownerUuid };
+})((props: { owner: string }) => renderOwner(props.owner));
 
-export const ResourceFileSize = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<CollectionResource>(props.uuid)(state.resources);
+export const ResourceUUID = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<CollectionResource>(props.uuid)(state.resources);
+    return { uuid: resource ? resource.uuid : "" };
+})((props: { uuid: string }) => renderUuid({ uuid: props.uuid }));
 
-        if (resource && resource.kind !== ResourceKind.COLLECTION) {
-            return { fileSize: '' };
-        }
+const renderVersion = (version: number) => {
+    return <Typography>{version ?? "-"}</Typography>;
+};
 
-        return { fileSize: resource ? resource.fileSizeTotal : 0 };
-    })((props: { fileSize?: number }) => renderFileSize(props.fileSize));
+export const ResourceVersion = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<CollectionResource>(props.uuid)(state.resources);
+    return { version: resource ? resource.version : "" };
+})((props: { version: number }) => renderVersion(props.version));
 
-const renderOwner = (owner: string) =>
+const renderPortableDataHash = (portableDataHash: string | null) => (
     <Typography noWrap>
-        {owner || '-'}
-    </Typography>;
-
-export const ResourceOwner = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
-        return { owner: resource ? resource.ownerUuid : '' };
-    })((props: { owner: string }) => renderOwner(props.owner));
+        {portableDataHash ? (
+            <>
+                {portableDataHash}
+                <CopyToClipboardSnackbar value={portableDataHash} />
+            </>
+        ) : (
+            "-"
+        )}
+    </Typography>
+);
 
-export const ResourceOwnerName = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
-        const ownerNameState = state.ownerName;
-        const ownerName = ownerNameState.find(it => it.uuid === resource!.ownerUuid);
-        return { owner: ownerName ? ownerName!.name : resource!.ownerUuid };
-    })((props: { owner: string }) => renderOwner(props.owner));
+export const ResourcePortableDataHash = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<CollectionResource>(props.uuid)(state.resources);
+    return { portableDataHash: resource ? resource.portableDataHash : "" };
+})((props: { portableDataHash: string }) => renderPortableDataHash(props.portableDataHash));
 
-export const ResourceUUID = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<CollectionResource>(props.uuid)(state.resources);
-        return { uuid: resource ? resource.uuid : '' };
-    })((props: { uuid: string }) => renderUuid({uuid: props.uuid}));
+const renderFileCount = (fileCount: number) => {
+    return <Typography>{fileCount ?? "-"}</Typography>;
+};
 
-const renderVersion = (version: number) =>{
-    return <Typography>{version ?? '-'}</Typography>
-}
+export const ResourceFileCount = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<CollectionResource>(props.uuid)(state.resources);
+    return { fileCount: resource ? resource.fileCount : "" };
+})((props: { fileCount: number }) => renderFileCount(props.fileCount));
 
-export const ResourceVersion = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<CollectionResource>(props.uuid)(state.resources);
-        return { version: resource ? resource.version: '' };
-    })((props: { version: number }) => renderVersion(props.version));
-    
-const renderPortableDataHash = (portableDataHash:string | null) => 
-    <Typography noWrap>
-        {portableDataHash ? <>{portableDataHash}
-        <CopyToClipboardSnackbar value={portableDataHash} /></> : '-' }
-    </Typography>
-    
-export const ResourcePortableDataHash = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<CollectionResource>(props.uuid)(state.resources);
-        return { portableDataHash: resource ? resource.portableDataHash : '' };    
-    })((props: { portableDataHash: string }) => renderPortableDataHash(props.portableDataHash));
+const userFromID = connect((state: RootState, props: { uuid: string }) => {
+    let userFullname = "";
+    const resource = getResource<GroupContentsResource & UserResource>(props.uuid)(state.resources);
 
+    if (resource) {
+        userFullname = getUserFullname(resource as User) || (resource as GroupContentsResource).name;
+    }
 
-const renderFileCount = (fileCount: number) =>{
-    return <Typography>{fileCount ?? '-'}</Typography>
-}
+    return { uuid: props.uuid, userFullname };
+});
 
-export const ResourceFileCount = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<CollectionResource>(props.uuid)(state.resources);
-        return { fileCount: resource ? resource.fileCount: '' };
-    })((props: { fileCount: number }) => renderFileCount(props.fileCount));
-
-const userFromID =
-    connect(
-        (state: RootState, props: { uuid: string }) => {
-            let userFullname = '';
-            const resource = getResource<GroupContentsResource & UserResource>(props.uuid)(state.resources);
-
-            if (resource) {
-                userFullname = getUserFullname(resource as User) || (resource as GroupContentsResource).name;
-            }
+const ownerFromResourceId = compose(
+    connect((state: RootState, props: { uuid: string }) => {
+        const childResource = getResource<GroupContentsResource & UserResource>(props.uuid)(state.resources);
+        return { uuid: childResource ? (childResource as Resource).ownerUuid : "" };
+    }),
+    userFromID
+);
 
-            return { uuid: props.uuid, userFullname };
-        });
+const _resourceWithName = withStyles(
+    {},
+    { withTheme: true }
+)((props: { uuid: string; userFullname: string; dispatch: Dispatch; theme: ArvadosTheme }) => {
+    const { uuid, userFullname, dispatch, theme } = props;
+    if (userFullname === "") {
+        dispatch<any>(loadResource(uuid, false));
+        return (
+            <Typography
+                style={{ color: theme.palette.primary.main }}
+                inline
+                noWrap
+            >
+                {uuid}
+            </Typography>
+        );
+    }
 
-const ownerFromResourceId =
-    compose(
-        connect((state: RootState, props: { uuid: string }) => {
-            const childResource = getResource<GroupContentsResource & UserResource>(props.uuid)(state.resources);
-            return { uuid: childResource ? (childResource as Resource).ownerUuid : '' };
-        }),
-        userFromID
+    return (
+        <Typography
+            style={{ color: theme.palette.primary.main }}
+            inline
+            noWrap
+        >
+            {userFullname} ({uuid})
+        </Typography>
     );
+});
 
+const _resourceWithNameLink = withStyles(
+    {},
+    { withTheme: true }
+)((props: { uuid: string; userFullname: string; dispatch: Dispatch; theme: ArvadosTheme }) => {
+    const { uuid, userFullname, dispatch, theme } = props;
+    if (!userFullname) {
+        dispatch<any>(loadResource(uuid, false));
+    }
 
+    return (
+        <Typography
+            style={{ color: theme.palette.primary.main, cursor: 'pointer' }}
+            inline
+            noWrap
+            onClick={() => dispatch<any>(navigateTo(uuid))}
+        >
+            {userFullname ? userFullname : uuid}
+        </Typography>
+    )
+});
 
 
-
-const _resourceWithName =
-    withStyles({}, { withTheme: true })
-        ((props: { uuid: string, userFullname: string, dispatch: Dispatch, theme: ArvadosTheme }) => {
-            const { uuid, userFullname, dispatch, theme } = props;
-            if (userFullname === '') {
-                dispatch<any>(loadResource(uuid, false));
-                return <Typography style={{ color: theme.palette.primary.main }} inline noWrap>
-                    {uuid}
-                </Typography>;
-            }
-
-            return <Typography style={{ color: theme.palette.primary.main }} inline noWrap>
-                {userFullname} ({uuid})
-            </Typography>;
-        });
+export const ResourceOwnerWithNameLink = ownerFromResourceId(_resourceWithNameLink);
 
 export const ResourceOwnerWithName = ownerFromResourceId(_resourceWithName);
 
 export const ResourceWithName = userFromID(_resourceWithName);
 
+export const UserNameFromID = compose(userFromID)((props: { uuid: string; displayAsText?: string; userFullname: string; dispatch: Dispatch }) => {
+    const { uuid, userFullname, dispatch } = props;
 
+    if (userFullname === "") {
+        dispatch<any>(loadResource(uuid, false));
+    }
+    return <span>{userFullname ? userFullname : uuid}</span>;
+});
 
-export const UserNameFromID =
-    compose(userFromID)(
-        (props: { uuid: string, displayAsText?: string, userFullname: string, dispatch: Dispatch }) => {
-            const { uuid, userFullname, dispatch } = props;
+export const ResponsiblePerson = compose(
+    connect((state: RootState, props: { uuid: string; parentRef: HTMLElement | null }) => {
+        let responsiblePersonName: string = "";
+        let responsiblePersonUUID: string = "";
+        let responsiblePersonProperty: string = "";
 
-            if (userFullname === '') {
-                dispatch<any>(loadResource(uuid, false));
-            }
-            return <span>
-                {userFullname ? userFullname : uuid}
-            </span>;
-        });
-
-export const ResponsiblePerson =
-    compose(
-        connect(
-            (state: RootState, props: { uuid: string, parentRef: HTMLElement | null }) => {
-                let responsiblePersonName: string = '';
-                let responsiblePersonUUID: string = '';
-                let responsiblePersonProperty: string = '';
-
-                if (state.auth.config.clusterConfig.Collections.ManagedProperties) {
-                    let index = 0;
-                    const keys = Object.keys(state.auth.config.clusterConfig.Collections.ManagedProperties);
-
-                    while (!responsiblePersonProperty && keys[index]) {
-                        const key = keys[index];
-                        if (state.auth.config.clusterConfig.Collections.ManagedProperties[key].Function === 'original_owner') {
-                            responsiblePersonProperty = key;
-                        }
-                        index++;
-                    }
+        if (state.auth.config.clusterConfig.Collections.ManagedProperties) {
+            let index = 0;
+            const keys = Object.keys(state.auth.config.clusterConfig.Collections.ManagedProperties);
+
+            while (!responsiblePersonProperty && keys[index]) {
+                const key = keys[index];
+                if (state.auth.config.clusterConfig.Collections.ManagedProperties[key].Function === "original_owner") {
+                    responsiblePersonProperty = key;
                 }
+                index++;
+            }
+        }
 
-                let resource: Resource | undefined = getResource<GroupContentsResource & UserResource>(props.uuid)(state.resources);
+        let resource: Resource | undefined = getResource<GroupContentsResource & UserResource>(props.uuid)(state.resources);
 
-                while (resource && resource.kind !== ResourceKind.USER && responsiblePersonProperty) {
-                    responsiblePersonUUID = (resource as CollectionResource).properties[responsiblePersonProperty];
-                    resource = getResource<GroupContentsResource & UserResource>(responsiblePersonUUID)(state.resources);
-                }
+        while (resource && resource.kind !== ResourceKind.USER && responsiblePersonProperty) {
+            responsiblePersonUUID = (resource as CollectionResource).properties[responsiblePersonProperty];
+            resource = getResource<GroupContentsResource & UserResource>(responsiblePersonUUID)(state.resources);
+        }
 
-                if (resource && resource.kind === ResourceKind.USER) {
-                    responsiblePersonName = getUserFullname(resource as UserResource) || (resource as GroupContentsResource).name;
-                }
+        if (resource && resource.kind === ResourceKind.USER) {
+            responsiblePersonName = getUserFullname(resource as UserResource) || (resource as GroupContentsResource).name;
+        }
 
-                return { uuid: responsiblePersonUUID, responsiblePersonName, parentRef: props.parentRef };
-            }),
-        withStyles({}, { withTheme: true }))
-        ((props: { uuid: string | null, responsiblePersonName: string, parentRef: HTMLElement | null, theme: ArvadosTheme }) => {
-            const { uuid, responsiblePersonName, parentRef, theme } = props;
-
-            if (!uuid && parentRef) {
-                parentRef.style.display = 'none';
-                return null;
-            } else if (parentRef) {
-                parentRef.style.display = 'block';
-            }
+        return { uuid: responsiblePersonUUID, responsiblePersonName, parentRef: props.parentRef };
+    }),
+    withStyles({}, { withTheme: true })
+)((props: { uuid: string | null; responsiblePersonName: string; parentRef: HTMLElement | null; theme: ArvadosTheme }) => {
+    const { uuid, responsiblePersonName, parentRef, theme } = props;
 
-            if (!responsiblePersonName) {
-                return <Typography style={{ color: theme.palette.primary.main }} inline noWrap>
-                    {uuid}
-                </Typography>;
-            }
+    if (!uuid && parentRef) {
+        parentRef.style.display = "none";
+        return null;
+    } else if (parentRef) {
+        parentRef.style.display = "block";
+    }
 
-            return <Typography style={{ color: theme.palette.primary.main }} inline noWrap>
-                {responsiblePersonName} ({uuid})
-            </Typography>;
-        });
+    if (!responsiblePersonName) {
+        return (
+            <Typography
+                style={{ color: theme.palette.primary.main }}
+                inline
+                noWrap
+            >
+                {uuid}
+            </Typography>
+        );
+    }
 
-const renderType = (type: string, subtype: string) =>
-    <Typography noWrap>
-        {resourceLabel(type, subtype)}
-    </Typography>;
+    return (
+        <Typography
+            style={{ color: theme.palette.primary.main }}
+            inline
+            noWrap
+        >
+            {responsiblePersonName} ({uuid})
+        </Typography>
+    );
+});
 
-export const ResourceType = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
-        return { type: resource ? resource.kind : '', subtype: resource && resource.kind === ResourceKind.GROUP ? resource.groupClass : '' };
-    })((props: { type: string, subtype: string }) => renderType(props.type, props.subtype));
+const renderType = (type: string, subtype: string) => <Typography noWrap>{resourceLabel(type, subtype)}</Typography>;
+
+export const ResourceType = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
+    return { type: resource ? resource.kind : "", subtype: resource && resource.kind === ResourceKind.GROUP ? resource.groupClass : "" };
+})((props: { type: string; subtype: string }) => renderType(props.type, props.subtype));
 
 export const ResourceStatus = connect((state: RootState, props: { uuid: string }) => {
     return { resource: getResource<GroupContentsResource>(props.uuid)(state.resources) };
 })((props: { resource: GroupContentsResource }) =>
-    (props.resource && props.resource.kind === ResourceKind.COLLECTION)
-        ? <CollectionStatus uuid={props.resource.uuid} />
-        : <ProcessStatus uuid={props.resource.uuid} />
+    props.resource && props.resource.kind === ResourceKind.COLLECTION ? (
+        <CollectionStatus uuid={props.resource.uuid} />
+    ) : (
+        <ProcessStatus uuid={props.resource.uuid} />
+    )
 );
 
 export const CollectionStatus = connect((state: RootState, props: { uuid: string }) => {
     return { collection: getResource<CollectionResource>(props.uuid)(state.resources) };
 })((props: { collection: CollectionResource }) =>
-    (props.collection.uuid !== props.collection.currentVersionUuid)
-        ? <Typography>version {props.collection.version}</Typography>
-        : <Typography>head version</Typography>
+    props.collection.uuid !== props.collection.currentVersionUuid ? (
+        <Typography>version {props.collection.version}</Typography>
+    ) : (
+        <Typography>head version</Typography>
+    )
 );
 
-export const CollectionName = connect((state: RootState, props: { uuid: string, className?: string }) => {
+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>
-);
+        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) };
     }),
-    withStyles({}, { withTheme: true }))
-    ((props: { process?: Process, theme: ArvadosTheme }) =>
-        props.process
-            ? <Chip label={getProcessStatus(props.process)}
-                style={{
-                    height: props.theme.spacing.unit * 3,
-                    width: props.theme.spacing.unit * 12,
-                    ...getProcessStatusStyles(
-                        getProcessStatus(props.process), props.theme),
-                    fontSize: '0.875rem',
-                    borderRadius: props.theme.spacing.unit * 0.625,
-                }}
-            />
-            : <Typography>-</Typography>
-        );
+    withStyles({}, { withTheme: true })
+)((props: { process?: Process; theme: ArvadosTheme }) =>
+    props.process ? (
+        <Chip
+            label={getProcessStatus(props.process)}
+            style={{
+                height: props.theme.spacing.unit * 3,
+                width: props.theme.spacing.unit * 12,
+                ...getProcessStatusStyles(getProcessStatus(props.process), props.theme),
+                fontSize: "0.875rem",
+                borderRadius: props.theme.spacing.unit * 0.625,
+            }}
+        />
+    ) : (
+        <Typography>-</Typography>
+    )
+);
 
-export const ProcessStartDate = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const process = getProcess(props.uuid)(state.resources);
-        return { date: (process && process.container) ? process.container.startedAt : '' };
-    })((props: { date: string }) => renderDate(props.date));
+export const ProcessStartDate = connect((state: RootState, props: { uuid: string }) => {
+    const process = getProcess(props.uuid)(state.resources);
+    return { date: process && process.container ? process.container.startedAt : "" };
+})((props: { date: string }) => renderDate(props.date));
 
-export const renderRunTime = (time: number) =>
-    <Typography noWrap style={{ minWidth: '45px' }}>
+export const renderRunTime = (time: number) => (
+    <Typography
+        noWrap
+        style={{ minWidth: "45px" }}
+    >
         {formatTime(time, true)}
-    </Typography>;
+    </Typography>
+);
 
 interface ContainerRunTimeProps {
     process: Process;
@@ -1017,31 +1103,33 @@ interface ContainerRunTimeState {
 
 export const ContainerRunTime = connect((state: RootState, props: { uuid: string }) => {
     return { process: getProcess(props.uuid)(state.resources) };
-})(class extends React.Component<ContainerRunTimeProps, ContainerRunTimeState> {
-    private timer: any;
+})(
+    class extends React.Component<ContainerRunTimeProps, ContainerRunTimeState> {
+        private timer: any;
 
-    constructor(props: ContainerRunTimeProps) {
-        super(props);
-        this.state = { runtime: this.getRuntime() };
-    }
+        constructor(props: ContainerRunTimeProps) {
+            super(props);
+            this.state = { runtime: this.getRuntime() };
+        }
 
-    getRuntime() {
-        return this.props.process ? getProcessRuntime(this.props.process) : 0;
-    }
+        getRuntime() {
+            return this.props.process ? getProcessRuntime(this.props.process) : 0;
+        }
 
-    updateRuntime() {
-        this.setState({ runtime: this.getRuntime() });
-    }
+        updateRuntime() {
+            this.setState({ runtime: this.getRuntime() });
+        }
 
-    componentDidMount() {
-        this.timer = setInterval(this.updateRuntime.bind(this), 5000);
-    }
+        componentDidMount() {
+            this.timer = setInterval(this.updateRuntime.bind(this), 5000);
+        }
 
-    componentWillUnmount() {
-        clearInterval(this.timer);
-    }
+        componentWillUnmount() {
+            clearInterval(this.timer);
+        }
 
-    render() {
-        return this.props.process ? renderRunTime(this.state.runtime) : <Typography>-</Typography>;
+        render() {
+            return this.props.process ? renderRunTime(this.state.runtime) : <Typography>-</Typography>;
+        }
     }
-});
+);
index e9175f57ba423e5069064db69876ddef15c97e1c..2653a2103345fe40a99cf9deb467e162efb05572 100644 (file)
@@ -83,8 +83,11 @@ const getItem = (res: DetailsResource): DetailsData => {
     }
 };
 
-const mapStateToProps = ({ auth, detailsPanel, resources, collectionPanelFiles }: RootState) => {
-    const resource = getResource(detailsPanel.resourceUuid)(resources) as DetailsResource | undefined;
+const mapStateToProps = ({ auth, detailsPanel, resources, collectionPanelFiles, multiselect, router }: RootState) => {
+    const isDetailsResourceChecked = multiselect.checkedList[detailsPanel.resourceUuid]
+    const currentRoute = router.location ? router.location.pathname : "";
+    const currentItemUuid = isDetailsResourceChecked || currentRoute.includes('collections') ? detailsPanel.resourceUuid : multiselect.selectedUuid ? multiselect.selectedUuid : currentRoute.split('/')[2];
+    const resource = getResource(currentItemUuid)(resources) as DetailsResource | undefined;
     const file = resource
         ? undefined
         : getNode(detailsPanel.resourceUuid)(collectionPanelFiles);
index 98978dd279671eaf23a8ca174440208f4ffa1773..ca224b1d587aec3815c87c46396a54409f09b24d 100644 (file)
@@ -3,8 +3,11 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import React from 'react';
-import { WorkflowIcon } from 'components/icon/icon';
-import { WorkflowResource } from 'models/workflow';
+import { WorkflowIcon, StartIcon } from 'components/icon/icon';
+import {
+    WorkflowResource, parseWorkflowDefinition, getWorkflowInputs,
+    getWorkflowOutputs, getWorkflow
+} from 'models/workflow';
 import { DetailsData } from "./details-data";
 import { DetailsAttribute } from 'components/details-attribute/details-attribute';
 import { ResourceWithName } from 'views-components/data-explorer/renderers';
@@ -15,6 +18,11 @@ import { openRunProcess } from "store/workflow-panel/workflow-panel-actions";
 import { Dispatch } from 'redux';
 import { connect } from 'react-redux';
 import { ArvadosTheme } from 'common/custom-theme';
+import { ProcessIOParameter } from 'views/process-panel/process-io-card';
+import { formatInputData, formatOutputData } from 'store/process-panel/process-panel-actions';
+import { AuthState } from 'store/auth/auth-reducer';
+import { RootState } from 'store/store';
+import { getPropertyChip } from "views-components/resource-properties-form/property-chip";
 
 export interface WorkflowDetailsCardDataProps {
     workflow?: WorkflowResource;
@@ -29,29 +37,101 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({
         () => wf && dispatch<any>(openRunProcess(wf.uuid, wf.ownerUuid, wf.name)),
 });
 
-type CssRules = 'runButton';
+type CssRules = 'runButton' | 'propertyTag';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     runButton: {
+        backgroundColor: theme.customs.colors.green700,
+        '&:hover': {
+            backgroundColor: theme.customs.colors.green800,
+        },
+        marginRight: "5px",
         boxShadow: 'none',
         padding: '2px 10px 2px 5px',
-        fontSize: '0.75rem'
+        marginLeft: 'auto'
+    },
+    propertyTag: {
+        marginRight: theme.spacing.unit / 2,
+        marginBottom: theme.spacing.unit / 2
     },
 });
 
-export const WorkflowDetailsAttributes = connect(null, mapDispatchToProps)(
+interface AuthStateDataProps {
+    auth: AuthState;
+};
+
+export interface RegisteredWorkflowPanelDataProps {
+    item: WorkflowResource;
+    workflowCollection: string;
+    inputParams: ProcessIOParameter[];
+    outputParams: ProcessIOParameter[];
+    gitprops: { [key: string]: string; };
+};
+
+export const getRegisteredWorkflowPanelData = (item: WorkflowResource, auth: AuthState): RegisteredWorkflowPanelDataProps => {
+    let inputParams: ProcessIOParameter[] = [];
+    let outputParams: ProcessIOParameter[] = [];
+    let workflowCollection = "";
+    const gitprops: { [key: string]: string; } = {};
+
+    // parse definition
+    const wfdef = parseWorkflowDefinition(item);
+
+    if (wfdef) {
+        const inputs = getWorkflowInputs(wfdef);
+        if (inputs) {
+            inputs.forEach(elm => {
+                if (elm.default !== undefined && elm.default !== null) {
+                    elm.value = elm.default;
+                }
+            });
+            inputParams = formatInputData(inputs, auth);
+        }
+
+        const outputs = getWorkflowOutputs(wfdef);
+        if (outputs) {
+            outputParams = formatOutputData(outputs, {}, undefined, auth);
+        }
+
+        const wf = getWorkflow(wfdef);
+        if (wf) {
+            const REGEX = /keep:([0-9a-f]{32}\+\d+)\/.*/;
+            if (wf["steps"]) {
+                const pdh = wf["steps"][0].run.match(REGEX);
+                if (pdh) {
+                    workflowCollection = pdh[1];
+                }
+            }
+        }
+
+        for (const elm in wfdef) {
+            if (elm.startsWith("http://arvados.org/cwl#git")) {
+                gitprops[elm.substr(23)] = wfdef[elm]
+            }
+        }
+    }
+
+    return { item, workflowCollection, inputParams, outputParams, gitprops };
+};
+
+const mapStateToProps = (state: RootState): AuthStateDataProps => {
+    return { auth: state.auth };
+};
+
+export const WorkflowDetailsAttributes = connect(mapStateToProps, mapDispatchToProps)(
     withStyles(styles)(
-        ({ workflow, onClick, classes }: WorkflowDetailsCardDataProps & WorkflowDetailsCardActionProps & WithStyles<CssRules>) => {
+        ({ workflow, onClick, auth, classes }: WorkflowDetailsCardDataProps & AuthStateDataProps & WorkflowDetailsCardActionProps & WithStyles<CssRules>) => {
+            if (!workflow) {
+                return <Grid />
+            }
+
+            const data = getRegisteredWorkflowPanelData(workflow, auth);
             return <Grid container>
                 <Button onClick={workflow && onClick(workflow)} className={classes.runButton} variant='contained'
-                    data-cy='details-panel-run-btn' color='primary' size='small'>
-                    Run
+                    data-cy='workflow-details-panel-run-btn' color='primary' size='small'>
+                    <StartIcon />
+                    Run Workflow
                 </Button>
-                {workflow && workflow.description !== "" && <Grid item xs={12} >
-                    <DetailsAttribute
-                        label={"Description"}
-                        value={workflow?.description} />
-                </Grid>}
                 <Grid item xs={12} >
                     <DetailsAttribute
                         label={"Workflow UUID"}
@@ -68,11 +148,16 @@ export const WorkflowDetailsAttributes = connect(null, mapDispatchToProps)(
                 <Grid item xs={12}>
                     <DetailsAttribute label='Last modified' value={formatDate(workflow?.modifiedAt)} />
                 </Grid>
-                <Grid item xs={12} >
+                <Grid item xs={12} data-cy="workflow-details-attributes-modifiedby-user">
                     <DetailsAttribute
                         label='Last modified by user' linkToUuid={workflow?.modifiedByUserUuid}
                         uuidEnhancer={(uuid: string) => <ResourceWithName uuid={uuid} />} />
                 </Grid>
+                <Grid item xs={12} md={12}>
+                    <DetailsAttribute label='Properties' />
+                    {Object.keys(data.gitprops).map(k =>
+                        getPropertyChip(k, data.gitprops[k], undefined, classes.propertyTag))}
+                </Grid>
             </Grid >;
         }));
 
similarity index 63%
rename from src/views-components/dialog-copy/dialog-partial-copy-to-collection.tsx
rename to src/views-components/dialog-copy/dialog-collection-partial-copy-to-existing-collection.tsx
index 4e9dde6a12bc2ef56e1fafa1cf34d73329e660d8..eb95d1f2b6970a7a4b156199acf565e95159ab4d 100644 (file)
@@ -7,23 +7,24 @@ import { memoize } from "lodash/fp";
 import { FormDialog } from 'components/form-dialog/form-dialog';
 import { WithDialogProps } from 'store/dialog/with-dialog';
 import { InjectedFormProps } from 'redux-form';
-import { CollectionPartialCopyToSelectedCollectionFormData } from 'store/collections/collection-partial-copy-actions';
+import { CollectionPartialCopyToExistingCollectionFormData } from 'store/collections/collection-partial-copy-actions';
 import { PickerIdProp } from "store/tree-picker/picker-id";
-import { CollectionPickerField } from 'views-components/form-fields/collection-form-fields';
+import { DirectoryPickerField } from 'views-components/form-fields/collection-form-fields';
 
-type DialogCollectionPartialCopyProps = WithDialogProps<string> & InjectedFormProps<CollectionPartialCopyToSelectedCollectionFormData>;
+type DialogCollectionPartialCopyProps = WithDialogProps<string> & InjectedFormProps<CollectionPartialCopyToExistingCollectionFormData>;
 
-export const DialogCollectionPartialCopyToSelectedCollection = (props: DialogCollectionPartialCopyProps & PickerIdProp) =>
+export const DialogCollectionPartialCopyToExistingCollection = (props: DialogCollectionPartialCopyProps & PickerIdProp) =>
     <FormDialog
-        dialogTitle='Choose collection'
+        dialogTitle='Copy to existing collection'
         formFields={CollectionPartialCopyFields(props.pickerId)}
         submitLabel='Copy files'
+        enableWhenPristine
         {...props}
     />;
 
-export const CollectionPartialCopyFields = memoize(
+const CollectionPartialCopyFields = memoize(
     (pickerId: string) =>
         () =>
             <>
-                <CollectionPickerField {...{ pickerId }}/>
+                <DirectoryPickerField {...{ pickerId }}/>
             </>);
similarity index 68%
rename from src/views-components/dialog-copy/dialog-collection-partial-copy.tsx
rename to src/views-components/dialog-copy/dialog-collection-partial-copy-to-new-collection.tsx
index 3c584e4f51452381d56296e753ad182123af82f2..6b5a7759e5ccfbbfcac7ffc119d4a415ede7472b 100644 (file)
@@ -8,20 +8,20 @@ import { FormDialog } from 'components/form-dialog/form-dialog';
 import { CollectionNameField, CollectionDescriptionField, CollectionProjectPickerField } from 'views-components/form-fields/collection-form-fields';
 import { WithDialogProps } from 'store/dialog/with-dialog';
 import { InjectedFormProps } from 'redux-form';
-import { CollectionPartialCopyFormData } from 'store/collections/collection-partial-copy-actions';
+import { CollectionPartialCopyToNewCollectionFormData } from 'store/collections/collection-partial-copy-actions';
 import { PickerIdProp } from "store/tree-picker/picker-id";
 
-type DialogCollectionPartialCopyProps = WithDialogProps<string> & InjectedFormProps<CollectionPartialCopyFormData>;
+type DialogCollectionPartialCopyProps = WithDialogProps<string> & InjectedFormProps<CollectionPartialCopyToNewCollectionFormData>;
 
-export const DialogCollectionPartialCopy = (props: DialogCollectionPartialCopyProps & PickerIdProp) =>
+export const DialogCollectionPartialCopyToNewCollection = (props: DialogCollectionPartialCopyProps & PickerIdProp) =>
     <FormDialog
-        dialogTitle='Create a collection'
+        dialogTitle='Copy to new collection'
         formFields={CollectionPartialCopyFields(props.pickerId)}
-        submitLabel='Create collection'
+        submitLabel='Create collection'
         {...props}
     />;
 
-export const CollectionPartialCopyFields = memoize(
+const CollectionPartialCopyFields = memoize(
     (pickerId: string) =>
         () =>
             <>
diff --git a/src/views-components/dialog-copy/dialog-collection-partial-copy-to-separate-collections.tsx b/src/views-components/dialog-copy/dialog-collection-partial-copy-to-separate-collections.tsx
new file mode 100644 (file)
index 0000000..32f706a
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { memoize } from "lodash/fp";
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { CollectionProjectPickerField } from 'views-components/form-fields/collection-form-fields';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { InjectedFormProps } from 'redux-form';
+import { CollectionPartialCopyToSeparateCollectionsFormData } from 'store/collections/collection-partial-copy-actions';
+import { PickerIdProp } from "store/tree-picker/picker-id";
+
+type DialogCollectionPartialCopyProps = WithDialogProps<string> & InjectedFormProps<CollectionPartialCopyToSeparateCollectionsFormData>;
+
+export const DialogCollectionPartialCopyToSeparateCollection = (props: DialogCollectionPartialCopyProps & PickerIdProp) =>
+    <FormDialog
+        dialogTitle='Copy to separate collections'
+        formFields={CollectionPartialCopyFields(props.pickerId)}
+        submitLabel='Create collections'
+        {...props}
+    />;
+
+const CollectionPartialCopyFields = memoize(
+    (pickerId: string) =>
+        () =>
+            <>
+                <CollectionProjectPickerField {...{ pickerId }} />
+            </>);
index a3e301195493be96d99b9b0c3c96648b2b6828e8..71d0dab34b14645345d91a8b925311de0363d34d 100644 (file)
@@ -3,37 +3,62 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import React from "react";
-import { memoize } from 'lodash/fp';
-import { InjectedFormProps, Field } from 'redux-form';
-import { WithDialogProps } from 'store/dialog/with-dialog';
-import { FormDialog } from 'components/form-dialog/form-dialog';
-import { ProjectTreePickerField } from 'views-components/projects-tree-picker/tree-picker-field';
-import { COPY_NAME_VALIDATION, COPY_FILE_VALIDATION } from 'validators/validators';
+import { memoize } from "lodash/fp";
+import { InjectedFormProps, Field } from "redux-form";
+import { WithDialogProps } from "store/dialog/with-dialog";
+import { FormDialog } from "components/form-dialog/form-dialog";
+import { ProjectTreePickerField } from "views-components/projects-tree-picker/tree-picker-field";
+import { COPY_NAME_VALIDATION, COPY_FILE_VALIDATION } from "validators/validators";
 import { TextField } from "components/text-field/text-field";
-import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog';
-import { PickerIdProp } from 'store/tree-picker/picker-id';
+import { CopyFormDialogData } from "store/copy-dialog/copy-dialog";
+import { PickerIdProp } from "store/tree-picker/picker-id";
 
 type CopyFormDialogProps = WithDialogProps<string> & InjectedFormProps<CopyFormDialogData>;
 
-export const DialogCopy = (props: CopyFormDialogProps & PickerIdProp) =>
-    <FormDialog
-        dialogTitle='Make a copy'
-        formFields={CopyDialogFields(props.pickerId)}
-        submitLabel='Copy'
-        {...props}
-    />;
+export const DialogCopy = (props: CopyFormDialogProps & PickerIdProp) => {
+    return (
+        <FormDialog
+            dialogTitle="Make a copy"
+            formFields={CopyDialogFields(props.pickerId)}
+            submitLabel="Copy"
+            {...props}
+        />
+    );
+};
 
-const CopyDialogFields = memoize((pickerId: string) =>
-    () =>
-        <>
-            <Field
-                name='name'
-                component={TextField as any}
-                validate={COPY_NAME_VALIDATION}
-                label="Enter a new name for the copy" />
-            <Field
-                name="ownerUuid"
-                component={ProjectTreePickerField}
-                validate={COPY_FILE_VALIDATION}
-                pickerId={pickerId}/>
-        </>);
+const CopyDialogFields = memoize((pickerId: string) => () => (
+    <>
+        <Field
+            name="name"
+            component={TextField as any}
+            validate={COPY_NAME_VALIDATION}
+            label="Enter a new name for the copy"
+        />
+        <Field
+            name="ownerUuid"
+            component={ProjectTreePickerField}
+            validate={COPY_FILE_VALIDATION}
+            pickerId={pickerId}
+        />
+    </>
+));
+
+export const DialogMultiCopy = (props: CopyFormDialogProps & PickerIdProp) => {
+    return (
+        <FormDialog
+            dialogTitle="Make Copies"
+            formFields={CopyMultiDialogFields(props.pickerId)}
+            submitLabel="Copy"
+            {...props}
+        />
+    );
+};
+
+const CopyMultiDialogFields = memoize((pickerId: string) => () => (
+    <Field
+        name="ownerUuid"
+        component={ProjectTreePickerField}
+        validate={COPY_FILE_VALIDATION}
+        pickerId={pickerId}
+    />
+));
index 9f97b1accd8cca463ebc06cb67c90bd518135408..a5d8f3a0f8abde8587f4ff3665c2addd5cb332c7 100644 (file)
@@ -2,38 +2,26 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from "react";
+import React from 'react';
 import { memoize } from 'lodash/fp';
 import { InjectedFormProps, Field } from 'redux-form';
 import { WithDialogProps } from 'store/dialog/with-dialog';
 import { FormDialog } from 'components/form-dialog/form-dialog';
 import { ProjectTreePickerField } from 'views-components/projects-tree-picker/tree-picker-field';
 import { COPY_NAME_VALIDATION, COPY_FILE_VALIDATION } from 'validators/validators';
-import { TextField } from "components/text-field/text-field";
+import { TextField } from 'components/text-field/text-field';
 import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog';
 import { PickerIdProp } from 'store/tree-picker/picker-id';
 
 type ProcessRerunFormDialogProps = WithDialogProps<string> & InjectedFormProps<CopyFormDialogData>;
 
-export const DialogProcessRerun = (props: ProcessRerunFormDialogProps & PickerIdProp) =>
-    <FormDialog
-        dialogTitle='Choose location for re-run'
-        formFields={CopyDialogFields(props.pickerId)}
-        submitLabel='Copy'
-        {...props}
-    />;
+export const DialogProcessRerun = (props: ProcessRerunFormDialogProps & PickerIdProp) => (
+    <FormDialog dialogTitle='Choose location for re-run' formFields={CopyDialogFields(props.pickerId)} submitLabel='Copy' {...props} />
+);
 
-const CopyDialogFields = memoize((pickerId: string) =>
-    () =>
-        <>
-            <Field
-                name='name'
-                component={TextField as any}
-                validate={COPY_NAME_VALIDATION}
-                label="Enter a new name for the copy" />
-            <Field
-                name="ownerUuid"
-                component={ProjectTreePickerField}
-                validate={COPY_FILE_VALIDATION}
-                pickerId={pickerId}/>
-        </>);
+const CopyDialogFields = memoize((pickerId: string) => () => (
+    <>
+        <Field name='name' component={TextField as any} validate={COPY_NAME_VALIDATION} label='Enter a new name for the copy' />
+        <Field name='ownerUuid' component={ProjectTreePickerField} validate={COPY_FILE_VALIDATION} pickerId={pickerId} />
+    </>
+));
index a1c822cf1f6a9d9e741b0788450d0a2f617b3f4a..220b5a2c4d6d97a743c8ffd11fcfe05029f74e6f 100644 (file)
@@ -4,12 +4,12 @@
 
 import { compose } from "redux";
 import { withDialog } from "store/dialog/with-dialog";
-import { reduxForm } from 'redux-form';
-import { COLLECTION_COPY_FORM_NAME } from 'store/collections/collection-copy-actions';
-import { DialogCopy } from "views-components/dialog-copy/dialog-copy";
-import { copyCollection } from 'store/workbench/workbench-actions';
-import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog';
-import { pickerId } from 'store/tree-picker/picker-id';
+import { reduxForm } from "redux-form";
+import { COLLECTION_COPY_FORM_NAME, COLLECTION_MULTI_COPY_FORM_NAME } from "store/collections/collection-copy-actions";
+import { DialogCopy, DialogMultiCopy } from "views-components/dialog-copy/dialog-copy";
+import { copyCollection } from "store/workbench/workbench-actions";
+import { CopyFormDialogData } from "store/copy-dialog/copy-dialog";
+import { pickerId } from "store/tree-picker/picker-id";
 
 export const CopyCollectionDialog = compose(
     withDialog(COLLECTION_COPY_FORM_NAME),
@@ -18,7 +18,19 @@ export const CopyCollectionDialog = compose(
         touchOnChange: true,
         onSubmit: (data, dispatch) => {
             dispatch(copyCollection(data));
-        }
+        },
     }),
-    pickerId(COLLECTION_COPY_FORM_NAME),
-)(DialogCopy);
\ No newline at end of file
+    pickerId(COLLECTION_COPY_FORM_NAME)
+)(DialogCopy);
+
+export const CopyMultiCollectionDialog = compose(
+    withDialog(COLLECTION_MULTI_COPY_FORM_NAME),
+    reduxForm<CopyFormDialogData>({
+        form: COLLECTION_MULTI_COPY_FORM_NAME,
+        touchOnChange: true,
+        onSubmit: (data, dispatch) => {
+            dispatch(copyCollection(data));
+        },
+    }),
+    pickerId(COLLECTION_MULTI_COPY_FORM_NAME)
+)(DialogMultiCopy);
index 6a79b62613d34b435a8c3a04f7bd3ad78e92602d..8afa58dde9583b3cee6f5a1c72651cd13fe952d3 100644 (file)
@@ -2,14 +2,14 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { compose } from "redux";
-import { withDialog } from "store/dialog/with-dialog";
+import { compose } from 'redux';
+import { withDialog } from 'store/dialog/with-dialog';
 import { reduxForm } from 'redux-form';
 import { PROCESS_COPY_FORM_NAME } from 'store/processes/process-copy-actions';
-import { DialogProcessRerun } from "views-components/dialog-copy/dialog-process-rerun";
+import { DialogProcessRerun } from 'views-components/dialog-copy/dialog-process-rerun';
 import { copyProcess } from 'store/workbench/workbench-actions';
 import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog';
-import { pickerId } from "store/tree-picker/picker-id";
+import { pickerId } from 'store/tree-picker/picker-id';
 
 export const CopyProcessDialog = compose(
     withDialog(PROCESS_COPY_FORM_NAME),
@@ -17,7 +17,7 @@ export const CopyProcessDialog = compose(
         form: PROCESS_COPY_FORM_NAME,
         onSubmit: (data, dispatch) => {
             dispatch(copyProcess(data));
-        }
+        },
     }),
-    pickerId(PROCESS_COPY_FORM_NAME),
+    pickerId(PROCESS_COPY_FORM_NAME)
 )(DialogProcessRerun);
index 0729e29c0f4e1c2b31b8146bcf0a92beddeabd2a..345040d5a53b8816957cff6a78a24bd85b861f73 100644 (file)
@@ -4,12 +4,12 @@
 
 import { compose } from "redux";
 import { withDialog } from "store/dialog/with-dialog";
-import { reduxForm } from 'redux-form';
-import { PROJECT_MOVE_FORM_NAME } from 'store/projects/project-move-actions';
-import { MoveToFormDialogData } from 'store/move-to-dialog/move-to-dialog';
-import { DialogMoveTo } from 'views-components/dialog-move/dialog-move-to';
-import { moveProject } from 'store/workbench/workbench-actions';
-import { pickerId } from 'store/tree-picker/picker-id';
+import { reduxForm } from "redux-form";
+import { PROJECT_MOVE_FORM_NAME } from "store/projects/project-move-actions";
+import { MoveToFormDialogData } from "store/move-to-dialog/move-to-dialog";
+import { DialogMoveTo } from "views-components/dialog-move/dialog-move-to";
+import { moveProject } from "store/workbench/workbench-actions";
+import { pickerId } from "store/tree-picker/picker-id";
 
 export const MoveProjectDialog = compose(
     withDialog(PROJECT_MOVE_FORM_NAME),
@@ -17,8 +17,7 @@ export const MoveProjectDialog = compose(
         form: PROJECT_MOVE_FORM_NAME,
         onSubmit: (data, dispatch) => {
             dispatch(moveProject(data));
-        }
+        },
     }),
-    pickerId(PROJECT_MOVE_FORM_NAME),
+    pickerId(PROJECT_MOVE_FORM_NAME)
 )(DialogMoveTo);
-
diff --git a/src/views-components/dialog-forms/partial-copy-collection-dialog.ts b/src/views-components/dialog-forms/partial-copy-collection-dialog.ts
deleted file mode 100644 (file)
index 3630ffb..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { compose } from "redux";
-import { reduxForm } from 'redux-form';
-import { withDialog, } from 'store/dialog/with-dialog';
-import { CollectionPartialCopyFormData, copyCollectionPartial, COLLECTION_PARTIAL_COPY_FORM_NAME } from 'store/collections/collection-partial-copy-actions';
-import { DialogCollectionPartialCopy } from "views-components/dialog-copy/dialog-collection-partial-copy";
-import { pickerId } from "store/tree-picker/picker-id";
-
-
-export const PartialCopyCollectionDialog = compose(
-    withDialog(COLLECTION_PARTIAL_COPY_FORM_NAME),
-    reduxForm<CollectionPartialCopyFormData>({
-        form: COLLECTION_PARTIAL_COPY_FORM_NAME,
-        onSubmit: (data, dispatch) => {
-            dispatch(copyCollectionPartial(data));
-        }
-    }),
-    pickerId(COLLECTION_PARTIAL_COPY_FORM_NAME),
-)(DialogCollectionPartialCopy);
\ No newline at end of file
diff --git a/src/views-components/dialog-forms/partial-copy-to-collection-dialog.ts b/src/views-components/dialog-forms/partial-copy-to-collection-dialog.ts
deleted file mode 100644 (file)
index d7b3392..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { compose } from "redux";
-import { reduxForm } from 'redux-form';
-import { withDialog, } from 'store/dialog/with-dialog';
-import { CollectionPartialCopyToSelectedCollectionFormData, copyCollectionPartialToSelectedCollection, COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION } from 'store/collections/collection-partial-copy-actions';
-import { DialogCollectionPartialCopyToSelectedCollection } from "views-components/dialog-copy/dialog-partial-copy-to-collection";
-import { pickerId } from "store/tree-picker/picker-id";
-
-export const PartialCopyToCollectionDialog = compose(
-    withDialog(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION),
-    reduxForm<CollectionPartialCopyToSelectedCollectionFormData>({
-        form: COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION,
-        onSubmit: (data, dispatch) => {
-            dispatch(copyCollectionPartialToSelectedCollection(data));
-        }
-    }),
-    pickerId(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION),
-)(DialogCollectionPartialCopyToSelectedCollection);
\ No newline at end of file
diff --git a/src/views-components/dialog-forms/partial-copy-to-existing-collection-dialog.ts b/src/views-components/dialog-forms/partial-copy-to-existing-collection-dialog.ts
new file mode 100644 (file)
index 0000000..dd0d0cb
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog, } from 'store/dialog/with-dialog';
+import { CollectionPartialCopyToExistingCollectionFormData, copyCollectionPartialToExistingCollection, COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION } from 'store/collections/collection-partial-copy-actions';
+import { DialogCollectionPartialCopyToExistingCollection } from "views-components/dialog-copy/dialog-collection-partial-copy-to-existing-collection";
+import { pickerId } from "store/tree-picker/picker-id";
+
+export const PartialCopyToExistingCollectionDialog = compose(
+    withDialog(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION),
+    reduxForm<CollectionPartialCopyToExistingCollectionFormData>({
+        form: COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION,
+        onSubmit: (data, dispatch, dialog) => {
+            dispatch(copyCollectionPartialToExistingCollection(dialog.data, data));
+        }
+    }),
+    pickerId(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION),
+)(DialogCollectionPartialCopyToExistingCollection);
diff --git a/src/views-components/dialog-forms/partial-copy-to-new-collection-dialog.ts b/src/views-components/dialog-forms/partial-copy-to-new-collection-dialog.ts
new file mode 100644 (file)
index 0000000..3a321de
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog, } from 'store/dialog/with-dialog';
+import { CollectionPartialCopyToNewCollectionFormData, copyCollectionPartialToNewCollection, COLLECTION_PARTIAL_COPY_FORM_NAME } from 'store/collections/collection-partial-copy-actions';
+import { DialogCollectionPartialCopyToNewCollection } from "views-components/dialog-copy/dialog-collection-partial-copy-to-new-collection";
+import { pickerId } from "store/tree-picker/picker-id";
+
+export const PartialCopyToNewCollectionDialog = compose(
+    withDialog(COLLECTION_PARTIAL_COPY_FORM_NAME),
+    reduxForm<CollectionPartialCopyToNewCollectionFormData>({
+        form: COLLECTION_PARTIAL_COPY_FORM_NAME,
+        onSubmit: (data, dispatch, dialog) => {
+            dispatch(copyCollectionPartialToNewCollection(dialog.data, data));
+        }
+    }),
+    pickerId(COLLECTION_PARTIAL_COPY_FORM_NAME),
+)(DialogCollectionPartialCopyToNewCollection);
diff --git a/src/views-components/dialog-forms/partial-copy-to-separate-collections-dialog.ts b/src/views-components/dialog-forms/partial-copy-to-separate-collections-dialog.ts
new file mode 100644 (file)
index 0000000..78fdd3a
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog, } from 'store/dialog/with-dialog';
+import { COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS, CollectionPartialCopyToSeparateCollectionsFormData, copyCollectionPartialToSeparateCollections } from 'store/collections/collection-partial-copy-actions';
+import { DialogCollectionPartialCopyToSeparateCollection } from "views-components/dialog-copy/dialog-collection-partial-copy-to-separate-collections";
+import { pickerId } from "store/tree-picker/picker-id";
+
+export const PartialCopyToSeparateCollectionsDialog = compose(
+    withDialog(COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS),
+    reduxForm<CollectionPartialCopyToSeparateCollectionsFormData>({
+        form: COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS,
+        onSubmit: (data, dispatch, dialog) => {
+            dispatch(copyCollectionPartialToSeparateCollections(dialog.data, data));
+        }
+    }),
+    pickerId(COLLECTION_PARTIAL_COPY_TO_SEPARATE_COLLECTIONS),
+)(DialogCollectionPartialCopyToSeparateCollection);
diff --git a/src/views-components/dialog-forms/partial-move-to-existing-collection-dialog.ts b/src/views-components/dialog-forms/partial-move-to-existing-collection-dialog.ts
new file mode 100644 (file)
index 0000000..e8d51f1
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog, } from 'store/dialog/with-dialog';
+import { COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION, CollectionPartialMoveToExistingCollectionFormData, moveCollectionPartialToExistingCollection } from "store/collections/collection-partial-move-actions";
+import { DialogCollectionPartialMoveToExistingCollection } from "views-components/dialog-move/dialog-collection-partial-move-to-existing-collection";
+import { pickerId } from "store/tree-picker/picker-id";
+
+export const PartialMoveToExistingCollectionDialog = compose(
+    withDialog(COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION),
+    reduxForm<CollectionPartialMoveToExistingCollectionFormData>({
+        form: COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION,
+        onSubmit: (data, dispatch, dialog) => {
+            dispatch(moveCollectionPartialToExistingCollection(dialog.data, data));
+        }
+    }),
+    pickerId(COLLECTION_PARTIAL_MOVE_TO_SELECTED_COLLECTION),
+)(DialogCollectionPartialMoveToExistingCollection);
diff --git a/src/views-components/dialog-forms/partial-move-to-new-collection-dialog.ts b/src/views-components/dialog-forms/partial-move-to-new-collection-dialog.ts
new file mode 100644 (file)
index 0000000..103e1e1
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog, } from 'store/dialog/with-dialog';
+import { COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION, CollectionPartialMoveToNewCollectionFormData, moveCollectionPartialToNewCollection } from "store/collections/collection-partial-move-actions";
+import { DialogCollectionPartialMoveToNewCollection } from "views-components/dialog-move/dialog-collection-partial-move-to-new-collection";
+import { pickerId } from "store/tree-picker/picker-id";
+
+export const PartialMoveToNewCollectionDialog = compose(
+    withDialog(COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION),
+    reduxForm<CollectionPartialMoveToNewCollectionFormData>({
+        form: COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION,
+        onSubmit: (data, dispatch, dialog) => {
+            dispatch(moveCollectionPartialToNewCollection(dialog.data, data));
+        }
+    }),
+    pickerId(COLLECTION_PARTIAL_MOVE_TO_NEW_COLLECTION),
+)(DialogCollectionPartialMoveToNewCollection);
diff --git a/src/views-components/dialog-forms/partial-move-to-separate-collections-dialog.ts b/src/views-components/dialog-forms/partial-move-to-separate-collections-dialog.ts
new file mode 100644 (file)
index 0000000..8f7ea59
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog, } from 'store/dialog/with-dialog';
+import { COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS, CollectionPartialMoveToSeparateCollectionsFormData, moveCollectionPartialToSeparateCollections } from "store/collections/collection-partial-move-actions";
+import { DialogCollectionPartialMoveToSeparateCollections } from "views-components/dialog-move/dialog-collection-partial-move-to-separate-collections";
+import { pickerId } from "store/tree-picker/picker-id";
+
+export const PartialMoveToSeparateCollectionsDialog = compose(
+    withDialog(COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS),
+    reduxForm<CollectionPartialMoveToSeparateCollectionsFormData>({
+        form: COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS,
+        onSubmit: (data, dispatch, dialog) => {
+            dispatch(moveCollectionPartialToSeparateCollections(dialog.data, data));
+        }
+    }),
+    pickerId(COLLECTION_PARTIAL_MOVE_TO_SEPARATE_COLLECTIONS),
+)(DialogCollectionPartialMoveToSeparateCollections);
diff --git a/src/views-components/dialog-move/dialog-collection-partial-move-to-existing-collection.tsx b/src/views-components/dialog-move/dialog-collection-partial-move-to-existing-collection.tsx
new file mode 100644 (file)
index 0000000..5cd4996
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { memoize } from "lodash/fp";
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { InjectedFormProps } from 'redux-form';
+import { CollectionPartialMoveToExistingCollectionFormData } from "store/collections/collection-partial-move-actions";
+import { PickerIdProp } from "store/tree-picker/picker-id";
+import { DirectoryPickerField } from 'views-components/form-fields/collection-form-fields';
+
+type DialogCollectionPartialMoveProps = WithDialogProps<string> & InjectedFormProps<CollectionPartialMoveToExistingCollectionFormData>;
+
+export const DialogCollectionPartialMoveToExistingCollection = (props: DialogCollectionPartialMoveProps & PickerIdProp) =>
+    <FormDialog
+        dialogTitle='Move to existing collection'
+        formFields={CollectionPartialMoveFields(props.pickerId)}
+        submitLabel='Move files'
+        enableWhenPristine
+        {...props}
+    />;
+
+const CollectionPartialMoveFields = memoize(
+    (pickerId: string) =>
+        () =>
+            <>
+                <DirectoryPickerField {...{ pickerId }}/>
+            </>);
diff --git a/src/views-components/dialog-move/dialog-collection-partial-move-to-new-collection.tsx b/src/views-components/dialog-move/dialog-collection-partial-move-to-new-collection.tsx
new file mode 100644 (file)
index 0000000..a33f377
--- /dev/null
@@ -0,0 +1,31 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { memoize } from "lodash/fp";
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { CollectionNameField, CollectionDescriptionField, CollectionProjectPickerField } from 'views-components/form-fields/collection-form-fields';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { InjectedFormProps } from 'redux-form';
+import { CollectionPartialMoveToNewCollectionFormData } from "store/collections/collection-partial-move-actions";
+import { PickerIdProp } from "store/tree-picker/picker-id";
+
+type DialogCollectionPartialMoveProps = WithDialogProps<string> & InjectedFormProps<CollectionPartialMoveToNewCollectionFormData>;
+
+export const DialogCollectionPartialMoveToNewCollection = (props: DialogCollectionPartialMoveProps & PickerIdProp) =>
+    <FormDialog
+        dialogTitle='Move to new collection'
+        formFields={CollectionPartialMoveFields(props.pickerId)}
+        submitLabel='Create collection'
+        {...props}
+    />;
+
+const CollectionPartialMoveFields = memoize(
+    (pickerId: string) =>
+        () =>
+            <>
+                <CollectionNameField />
+                <CollectionDescriptionField />
+                <CollectionProjectPickerField {...{ pickerId }} />
+            </>);
diff --git a/src/views-components/dialog-move/dialog-collection-partial-move-to-separate-collections.tsx b/src/views-components/dialog-move/dialog-collection-partial-move-to-separate-collections.tsx
new file mode 100644 (file)
index 0000000..1b71662
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { memoize } from "lodash/fp";
+import { FormDialog } from 'components/form-dialog/form-dialog';
+import { CollectionProjectPickerField } from 'views-components/form-fields/collection-form-fields';
+import { WithDialogProps } from 'store/dialog/with-dialog';
+import { InjectedFormProps } from 'redux-form';
+import { CollectionPartialMoveToSeparateCollectionsFormData } from "store/collections/collection-partial-move-actions";
+import { PickerIdProp } from "store/tree-picker/picker-id";
+
+type DialogCollectionPartialMoveProps = WithDialogProps<string> & InjectedFormProps<CollectionPartialMoveToSeparateCollectionsFormData>;
+
+export const DialogCollectionPartialMoveToSeparateCollections = (props: DialogCollectionPartialMoveProps & PickerIdProp) =>
+    <FormDialog
+        dialogTitle='Move to separate collections'
+        formFields={CollectionPartialMoveFields(props.pickerId)}
+        submitLabel='Create collections'
+        {...props}
+    />;
+
+const CollectionPartialMoveFields = memoize(
+    (pickerId: string) =>
+        () =>
+            <>
+                <CollectionProjectPickerField {...{ pickerId }} />
+            </>);
index 7e18111a672f2397b51fd241493d22677aeb6f15..7d5fcf8035ca976270140ea638b0788279b9c439 100644 (file)
@@ -9,12 +9,13 @@ import {
     COLLECTION_NAME_VALIDATION, COLLECTION_NAME_VALIDATION_ALLOW_SLASH,
     COLLECTION_DESCRIPTION_VALIDATION, COLLECTION_PROJECT_VALIDATION
 } from "validators/validators";
-import { ProjectTreePickerField, CollectionTreePickerField } from "views-components/projects-tree-picker/tree-picker-field";
+import { ProjectTreePickerField, CollectionTreePickerField, DirectoryTreePickerField } from "views-components/projects-tree-picker/tree-picker-field";
 import { PickerIdProp } from 'store/tree-picker/picker-id';
 import { connect } from "react-redux";
 import { RootState } from "store/store";
 import { MultiCheckboxField } from "components/checkbox-field/checkbox-field";
 import { getStorageClasses } from "common/config";
+import { ERROR_MESSAGE } from "validators/require";
 
 interface CollectionNameFieldProps {
     validate: Validator[];
@@ -58,6 +59,15 @@ export const CollectionPickerField = (props: PickerIdProp) =>
         component={CollectionTreePickerField}
         validate={COLLECTION_PROJECT_VALIDATION} />;
 
+const validateDirectory = (val) => (val && val.uuid ? undefined : ERROR_MESSAGE);
+
+export const DirectoryPickerField = (props: PickerIdProp) =>
+    <Field
+        name="destination"
+        pickerId={props.pickerId}
+        component={DirectoryTreePickerField as any}
+        validate={validateDirectory} />;
+
 interface StorageClassesProps {
     items: string[];
     defaultClasses?: string[];
@@ -78,4 +88,4 @@ export const CollectionStorageClassesField = connect(
             defaultValues={props.defaultClasses}
             helperText='At least one class should be selected'
             component={MultiCheckboxField}
-            items={props.items} />);
\ No newline at end of file
+            items={props.items} />);
index 3aa9e3f25aeb8ed1c6646765051596cb16ff00dd..6c5902653bb3cdf21b1416430dfcdd4af6881ad3 100644 (file)
@@ -84,27 +84,31 @@ export const LoginForm = withStyles(styles)(
             setHelperText('');
             setSubmitting(true);
             handleSubmit(username, password)
-            .then((response) => {
-                setSubmitting(false);
-                if (response.data.uuid && response.data.api_token) {
-                    const apiToken = `v2/${response.data.uuid}/${response.data.api_token}`;
-                    const rd = new URL(window.location.href);
-                    const rdUrl = rd.pathname + rd.search;
-                    dispatch<any>(saveApiToken(apiToken)).finally(
-                        () => rdUrl === '/' ? dispatch(navigateToRootProject) : dispatch(replace(rdUrl))
-                    );
-                } else {
+                .then((response) => {
+                    setSubmitting(false);
+                    if (response.data.uuid && response.data.api_token) {
+                        const apiToken = `v2/${response.data.uuid}/${response.data.api_token}`;
+                        const rd = new URL(window.location.href);
+                        const rdUrl = rd.pathname + rd.search;
+                        dispatch<any>(saveApiToken(apiToken)).finally(
+                            () => {
+                                if ((new URL(window.location.href).pathname) !== '/my-account') {
+                                    rdUrl === '/' ? dispatch(navigateToRootProject) : dispatch(replace(rdUrl))
+                                }
+                            }
+                        );
+                    } else {
+                        setError(true);
+                        setHelperText(response.data.message || 'Please try again');
+                        setFocus();
+                    }
+                })
+                .catch((err) => {
                     setError(true);
-                    setHelperText(response.data.message || 'Please try again');
+                    setSubmitting(false);
+                    setHelperText(`${(err.response && err.response.data && err.response.data.errors[0]) || 'Error logging in: ' + err}`);
                     setFocus();
-                }
-            })
-            .catch((err) => {
-                setError(true);
-                setSubmitting(false);
-                setHelperText(`${(err.response && err.response.data && err.response.data.errors[0]) || 'Error logging in: '+err}`);
-                setFocus();
-            });
+                });
         };
 
         const handleKeyPress = (e: any) => {
@@ -117,38 +121,38 @@ export const LoginForm = withStyles(styles)(
 
         return (
             <React.Fragment>
-            <form className={classes.root} noValidate autoComplete="off">
-                <Card className={classes.card}>
-                    <div className={classes.wrapper}>
-                    <CardContent>
-                        <TextField
-                            inputRef={userInput}
-                            disabled={isSubmitting}
-                            error={error} fullWidth id="username" type="email"
-                            label="Username" margin="normal"
-                            onChange={(e) => setUsername(e.target.value)}
-                            onKeyPress={(e) => handleKeyPress(e)}
-                        />
-                        <TextField
-                            disabled={isSubmitting}
-                            error={error} fullWidth id="password" type="password"
-                            label="Password" margin="normal"
-                            helperText={helperText}
-                            onChange={(e) => setPassword(e.target.value)}
-                            onKeyPress={(e) => handleKeyPress(e)}
-                        />
-                    </CardContent>
-                    <CardActions>
-                        <Button variant="contained" size="large" color="primary"
-                            className={classes.loginBtn} onClick={() => handleLogin()}
-                            disabled={isSubmitting || isButtonDisabled}>
-                            {loginLabel || 'Log in'}
-                        </Button>
-                    </CardActions>
-                    isSubmitting && <CircularProgress color='secondary' className={classes.progress} />}
-                    </div>
-                </Card>
-            </form>
+                <form className={classes.root} noValidate autoComplete="off">
+                    <Card className={classes.card}>
+                        <div className={classes.wrapper}>
+                            <CardContent>
+                                <TextField
+                                    inputRef={userInput}
+                                    disabled={isSubmitting}
+                                    error={error} fullWidth id="username" type="email"
+                                    label="Username" margin="normal"
+                                    onChange={(e) => setUsername(e.target.value)}
+                                    onKeyPress={(e) => handleKeyPress(e)}
+                                />
+                                <TextField
+                                    disabled={isSubmitting}
+                                    error={error} fullWidth id="password" type="password"
+                                    label="Password" margin="normal"
+                                    helperText={helperText}
+                                    onChange={(e) => setPassword(e.target.value)}
+                                    onKeyPress={(e) => handleKeyPress(e)}
+                                />
+                            </CardContent>
+                            <CardActions>
+                                <Button variant="contained" size="large" color="primary"
+                                    className={classes.loginBtn} onClick={() => handleLogin()}
+                                    disabled={isSubmitting || isButtonDisabled}>
+                                    {loginLabel || 'Log in'}
+                                </Button>
+                            </CardActions>
+                            {isSubmitting && <CircularProgress color='secondary' className={classes.progress} />}
+                        </div>
+                    </Card>
+                </form>
             </React.Fragment>
         );
     });
index 4b62cea2c60f343739d92396ec6cc3dfbeb126fb..c2cc0e2a47c772502d35f46add124bd62273fbf6 100644 (file)
@@ -39,16 +39,6 @@ const mapStateToProps = (state: RootState): AccountMenuProps => ({
     localCluster: state.auth.localCluster
 });
 
-const wb1URL = (route: string) => {
-    const r = route.replace(/^\//, "");
-    if (r.match(/^(projects|collections)\//)) {
-        return r;
-    } else if (r.match(/^processes\//)) {
-        return r.replace(/^processes/, "container_requests");
-    }
-    return "";
-};
-
 type CssRules = 'link';
 
 const styles: StyleRulesCallback<CssRules> = () => ({
@@ -71,10 +61,6 @@ export const AccountMenuComponent =
             <MenuItem onClick={() => dispatch(navigateToSiteManager)}>Site Manager</MenuItem>
             <MenuItem onClick={() => dispatch(navigateToMyAccount)}>My account</MenuItem>
             <MenuItem onClick={() => dispatch(navigateToLinkAccount)}>Link account</MenuItem>
-            <MenuItem>
-                <a href={`${workbenchURL.replace(/\/$/, "")}/${wb1URL(currentRoute)}?api_token=${apiToken}`}
-                    className={classes.link}>
-                    Switch to Workbench v1</a></MenuItem>
         </>;
 
         const reduceItemsFn: (a: React.ReactElement[],
@@ -97,7 +83,7 @@ export const AccountMenuComponent =
                 <MenuItem data-cy="logout-menuitem"
                     onClick={() => dispatch(authActions.LOGOUT({ deleteLinkData: true, preservePath: false }))}>
                     Logout
-                </MenuItem>
+                </MenuItem>
             </DropdownMenu>
             : null;
     };
index 60ce68e99dce95c147267033e443bd0626aa5ac7..c57d5cd85d51fb75e3a0e507c5ae4d692d75e264 100644 (file)
@@ -15,6 +15,7 @@ import { HelpMenu } from 'views-components/main-app-bar/help-menu';
 import { ReactNode } from "react";
 import { AdminMenu } from "views-components/main-app-bar/admin-menu";
 import { pluginConfig } from 'plugins';
+import { sanitizeHTML } from "common/html-sanitize";
 
 type CssRules = 'toolbar' | 'link';
 
@@ -47,7 +48,7 @@ export const MainAppBar = withStyles(styles)(
                     {pluginConfig.appBarLeft || <Grid container item xs={3} direction="column" justify="center">
                         <Typography variant='h6' color="inherit" noWrap>
                             <Link to={Routes.ROOT} className={props.classes.link}>
-                                <span dangerouslySetInnerHTML={{ __html: props.siteBanner }} /> ({props.uuidPrefix})
+                                <span dangerouslySetInnerHTML={{ __html: sanitizeHTML(props.siteBanner) }} /> ({props.uuidPrefix})
                 </Link>
                         </Typography>
                         <Typography variant="caption" color="inherit">
index ca97a612bb11875460c17eeed6487266b9c46cf3..89fd2e9184793bf7cd790d7d052c43ad1917d1d8 100644 (file)
@@ -26,11 +26,11 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({
 type NotificationsMenuProps = {
     isOpen: boolean;
     bannerUUID?: string;
-}
+};
 
 type NotificationsMenuComponentProps = NotificationsMenuProps & {
     openBanner: any;
-}
+};
 
 export const NotificationsMenuComponent = (props: NotificationsMenuComponentProps) => {
     const { isOpen, openBanner } = props;
@@ -39,41 +39,58 @@ export const NotificationsMenuComponent = (props: NotificationsMenuComponentProp
     const menuItems: any[] = [];
 
     if (!isOpen && bannerResult) {
-        menuItems.push(<MenuItem><span onClick={openBanner}>Restore Banner</span></MenuItem>);
+        menuItems.push(
+            <MenuItem onClick={openBanner}>
+                <span>Restore Banner</span>
+            </MenuItem>
+        );
     }
 
     const toggleTooltips = useCallback(() => {
         if (tooltipResult) {
             localStorage.removeItem(TOOLTIP_LOCAL_STORAGE_KEY);
         } else {
-            localStorage.setItem(TOOLTIP_LOCAL_STORAGE_KEY, 'true');
+            localStorage.setItem(TOOLTIP_LOCAL_STORAGE_KEY, "true");
         }
         window.location.reload();
     }, [tooltipResult]);
 
     if (tooltipResult) {
-        menuItems.push(<MenuItem><span onClick={toggleTooltips}>Enable tooltips</span></MenuItem>);
+        menuItems.push(
+            <MenuItem onClick={toggleTooltips}>
+                <span>Enable tooltips</span>
+            </MenuItem>
+        );
     } else {
-        menuItems.push(<MenuItem><span onClick={toggleTooltips}>Disable tooltips</span></MenuItem>);
+        menuItems.push(
+            <MenuItem onClick={toggleTooltips}>
+                <span>Disable tooltips</span>
+            </MenuItem>
+        );
     }
 
     if (menuItems.length === 0) {
         menuItems.push(<MenuItem>You are up to date</MenuItem>);
     }
 
-    return (<DropdownMenu
-        icon={
-            <Badge
-                badgeContent={0}
-                color="primary">
-                <NotificationIcon />
-            </Badge>}
-        id="account-menu"
-        title="Notifications">
-        {
-            menuItems.map((item, i) => <div key={i}>{item}</div>)
-        }
-    </DropdownMenu>);
-}
+    return (
+        <DropdownMenu
+            icon={
+                <Badge
+                    badgeContent={0}
+                    color="primary"
+                >
+                    <NotificationIcon />
+                </Badge>
+            }
+            id="account-menu"
+            title="Notifications"
+        >
+            {menuItems.map((item, i) => (
+                <div key={i}>{item}</div>
+            ))}
+        </DropdownMenu>
+    );
+};
 
 export const NotificationsMenu = connect(mapStateToProps, mapDispatchToProps)(NotificationsMenuComponent);
diff --git a/src/views-components/multiselect-toolbar/ms-collection-action-set.ts b/src/views-components/multiselect-toolbar/ms-collection-action-set.ts
new file mode 100644 (file)
index 0000000..a8a8f45
--- /dev/null
@@ -0,0 +1,94 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { MoveToIcon, CopyIcon, RenameIcon } from "components/icon/icon";
+import { openMoveCollectionDialog } from "store/collections/collection-move-actions";
+import { openCollectionCopyDialog, openMultiCollectionCopyDialog } from "store/collections/collection-copy-actions";
+import { toggleCollectionTrashed } from "store/trash/trash-actions";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
+import { msCommonActionSet, MultiSelectMenuActionSet, MultiSelectMenuAction } from "./ms-menu-actions";
+import { MultiSelectMenuActionNames } from "views-components/multiselect-toolbar/ms-menu-actions";
+import { TrashIcon, Link, FolderSharedIcon } from "components/icon/icon";
+import { openCollectionUpdateDialog } from "store/collections/collection-update-actions";
+import { copyToClipboardAction } from "store/open-in-new-tab/open-in-new-tab.actions";
+import { openWebDavS3InfoDialog } from "store/collections/collection-info-actions";
+
+const { MAKE_A_COPY, MOVE_TO, MOVE_TO_TRASH, EDIT_COLLECTION, OPEN_IN_NEW_TAB, OPEN_W_3RD_PARTY_CLIENT, COPY_TO_CLIPBOARD, VIEW_DETAILS, API_DETAILS, ADD_TO_FAVORITES, SHARE} = MultiSelectMenuActionNames;
+
+const msCopyCollection: MultiSelectMenuAction = {
+    name: MAKE_A_COPY,
+    icon: CopyIcon,
+    hasAlts: false,
+    isForMulti: true,
+    execute: (dispatch, [...resources]) => {
+        if (resources[0].fromContextMenu || resources.length === 1) dispatch<any>(openCollectionCopyDialog(resources[0]));
+        else dispatch<any>(openMultiCollectionCopyDialog(resources[0]));
+    },
+}
+
+const msMoveCollection: MultiSelectMenuAction = {
+    name: MOVE_TO,
+    icon: MoveToIcon,
+    hasAlts: false,
+    isForMulti: true,
+    execute: (dispatch, resources) => dispatch<any>(openMoveCollectionDialog(resources[0])),
+}
+
+const msToggleTrashAction: MultiSelectMenuAction = {
+    name: MOVE_TO_TRASH,
+    icon: TrashIcon,
+    isForMulti: true,
+    hasAlts: false,
+    execute: (dispatch, resources: ContextMenuResource[]) => {
+        for (const resource of [...resources]) {
+            dispatch<any>(toggleCollectionTrashed(resource.uuid, resource.isTrashed!!));
+        }
+    },
+}
+
+const msEditCollection: MultiSelectMenuAction = {
+    name: MultiSelectMenuActionNames.EDIT_COLLECTION,
+    icon: RenameIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openCollectionUpdateDialog(resources[0]));
+    },
+}
+
+const msCopyToClipboardMenuAction: MultiSelectMenuAction  = {
+    name: COPY_TO_CLIPBOARD,
+    icon: Link,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(copyToClipboardAction(resources));
+    },
+};
+
+const msOpenWith3rdPartyClientAction: MultiSelectMenuAction  = {
+    name: OPEN_W_3RD_PARTY_CLIENT,
+    icon: FolderSharedIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openWebDavS3InfoDialog(resources[0].uuid));
+    },
+};
+
+export const msCollectionActionSet: MultiSelectMenuActionSet = [
+    [
+        ...msCommonActionSet,
+        msCopyCollection,
+        msMoveCollection,
+        msToggleTrashAction,
+        msEditCollection,
+        msCopyToClipboardMenuAction,
+        msOpenWith3rdPartyClientAction
+    ],
+];
+
+export const msReadOnlyCollectionActionFilter = new Set([OPEN_IN_NEW_TAB, COPY_TO_CLIPBOARD, MAKE_A_COPY, VIEW_DETAILS, API_DETAILS, ADD_TO_FAVORITES, OPEN_W_3RD_PARTY_CLIENT]);
+export const msCommonCollectionActionFilter = new Set([OPEN_IN_NEW_TAB, COPY_TO_CLIPBOARD, MAKE_A_COPY, VIEW_DETAILS, API_DETAILS, OPEN_W_3RD_PARTY_CLIENT, EDIT_COLLECTION, SHARE, MOVE_TO, ADD_TO_FAVORITES, MOVE_TO_TRASH])
+export const msOldCollectionActionFilter = new Set([OPEN_IN_NEW_TAB, COPY_TO_CLIPBOARD, MAKE_A_COPY, VIEW_DETAILS, API_DETAILS, OPEN_W_3RD_PARTY_CLIENT, EDIT_COLLECTION, SHARE, MOVE_TO, ADD_TO_FAVORITES, MOVE_TO_TRASH])
\ No newline at end of file
diff --git a/src/views-components/multiselect-toolbar/ms-menu-actions.ts b/src/views-components/multiselect-toolbar/ms-menu-actions.ts
new file mode 100644 (file)
index 0000000..91e96d9
--- /dev/null
@@ -0,0 +1,144 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { IconType } from 'components/icon/icon';
+import { ResourcesState } from 'store/resources/resources';
+import { FavoritesState } from 'store/favorites/favorites-reducer';
+import { ContextMenuResource } from 'store/context-menu/context-menu-actions';
+import { AddFavoriteIcon, AdvancedIcon, DetailsIcon, OpenIcon, PublicFavoriteIcon, RemoveFavoriteIcon, ShareIcon } from 'components/icon/icon';
+import { checkFavorite } from 'store/favorites/favorites-reducer';
+import { toggleFavorite } from 'store/favorites/favorites-actions';
+import { favoritePanelActions } from 'store/favorite-panel/favorite-panel-action';
+import { openInNewTabAction } from 'store/open-in-new-tab/open-in-new-tab.actions';
+import { toggleDetailsPanel } from 'store/details-panel/details-panel-action';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
+import { openSharingDialog } from 'store/sharing-dialog/sharing-dialog-actions';
+import { togglePublicFavorite } from "store/public-favorites/public-favorites-actions";
+import { publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action";
+import { PublicFavoritesState } from 'store/public-favorites/public-favorites-reducer';
+
+export enum MultiSelectMenuActionNames {
+    ADD_TO_FAVORITES = 'Add to Favorites',
+    MOVE_TO_TRASH = 'Move to trash',
+    ADD_TO_PUBLIC_FAVORITES = 'Add to public favorites',
+    API_DETAILS = 'API Details',
+    CANCEL = 'CANCEL',
+    COPY_AND_RERUN_PROCESS = 'Copy and re-run process',
+    COPY_TO_CLIPBOARD = 'Copy to clipboard',
+    DELETE_WORKFLOW = 'Delete Workflow',
+    EDIT_COLLECTION = 'Edit collection',
+    EDIT_PROJECT = 'Edit project',
+    EDIT_PROCESS = 'Edit process',
+    FREEZE_PROJECT = 'Freeze Project',
+    MAKE_A_COPY = 'Make a copy',
+    MOVE_TO = 'Move to',
+    NEW_PROJECT = 'New project',
+    OPEN_IN_NEW_TAB = 'Open in new tab',
+    OPEN_W_3RD_PARTY_CLIENT = 'Open with 3rd party client',
+    OUTPUTS = 'Outputs',
+    REMOVE = 'Remove',
+    RUN_WORKFLOW = 'Run Workflow',
+    SHARE = 'Share',
+    VIEW_DETAILS = 'View details',
+};
+
+export type MultiSelectMenuAction = {
+    name: string;
+    icon: IconType;
+    hasAlts: boolean;
+    altName?: string;
+    altIcon?: IconType;
+    isForMulti: boolean;
+    useAlts?: (uuid: string | null, iconProps: {resources: ResourcesState, favorites: FavoritesState, publicFavorites: PublicFavoritesState}) => boolean;
+    execute(dispatch: Dispatch, resources: ContextMenuResource[], state?: any): void;
+    adminOnly?: boolean;
+};
+
+export type MultiSelectMenuActionSet = MultiSelectMenuAction[][];
+
+const { ADD_TO_FAVORITES, ADD_TO_PUBLIC_FAVORITES, OPEN_IN_NEW_TAB, VIEW_DETAILS, API_DETAILS, SHARE } = MultiSelectMenuActionNames;
+
+const msToggleFavoriteAction: MultiSelectMenuAction = {
+    name: ADD_TO_FAVORITES,
+    icon: AddFavoriteIcon,
+    hasAlts: true,
+    altName: 'Remove from Favorites',
+    altIcon: RemoveFavoriteIcon,
+    isForMulti: false,
+    useAlts: (uuid: string, iconProps) => {
+        return checkFavorite(uuid, iconProps.favorites);
+    },
+    execute: (dispatch, resources) => {
+        dispatch<any>(toggleFavorite(resources[0])).then(() => {
+            dispatch(favoritePanelActions.REQUEST_ITEMS());
+        });
+    },
+};
+
+const msOpenInNewTabMenuAction: MultiSelectMenuAction  = {
+    name: OPEN_IN_NEW_TAB,
+    icon: OpenIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openInNewTabAction(resources[0]));
+    },
+};
+
+const msViewDetailsAction: MultiSelectMenuAction  = {
+    name: VIEW_DETAILS,
+    icon: DetailsIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch) => {
+        dispatch<any>(toggleDetailsPanel());
+    },
+};
+
+const msAdvancedAction: MultiSelectMenuAction  = {
+    name: API_DETAILS,
+    icon: AdvancedIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+    },
+};
+
+const msShareAction: MultiSelectMenuAction  = {
+    name: SHARE,
+    icon: ShareIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openSharingDialog(resources[0].uuid));
+    },
+};
+
+const msTogglePublicFavoriteAction: MultiSelectMenuAction = {
+    name: ADD_TO_PUBLIC_FAVORITES,
+    icon: PublicFavoriteIcon,
+    hasAlts: true,
+    altName: 'Remove from public favorites',
+    altIcon: PublicFavoriteIcon,
+    isForMulti: false,
+    useAlts: (uuid: string, iconProps) => {
+        return iconProps.publicFavorites[uuid] === true
+    },
+    execute: (dispatch, resources) => {
+        dispatch<any>(togglePublicFavorite(resources[0])).then(() => {
+            dispatch(publicFavoritePanelActions.REQUEST_ITEMS());
+        });
+    },
+};
+
+export const msCommonActionSet = [
+    msToggleFavoriteAction,
+    msOpenInNewTabMenuAction,
+    msViewDetailsAction,
+    msAdvancedAction,
+    msShareAction,
+    msTogglePublicFavoriteAction
+];
diff --git a/src/views-components/multiselect-toolbar/ms-process-action-set.ts b/src/views-components/multiselect-toolbar/ms-process-action-set.ts
new file mode 100644 (file)
index 0000000..7802ad8
--- /dev/null
@@ -0,0 +1,98 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { MoveToIcon, RemoveIcon, ReRunProcessIcon, OutputIcon, RenameIcon, StopIcon } from "components/icon/icon";
+import { openMoveProcessDialog } from "store/processes/process-move-actions";
+import { openCopyProcessDialog } from "store/processes/process-copy-actions";
+import { openRemoveProcessDialog } from "store/processes/processes-actions";
+import { MultiSelectMenuAction, MultiSelectMenuActionSet, msCommonActionSet } from "./ms-menu-actions";
+import { MultiSelectMenuActionNames } from "views-components/multiselect-toolbar/ms-menu-actions";
+import { openProcessUpdateDialog } from "store/processes/process-update-actions";
+import { msNavigateToOutput } from "store/multiselect/multiselect-actions";
+import { cancelRunningWorkflow } from "store/processes/processes-actions";
+
+const msCopyAndRerunProcess: MultiSelectMenuAction = {
+    name: MultiSelectMenuActionNames.COPY_AND_RERUN_PROCESS,
+    icon: ReRunProcessIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        for (const resource of [...resources]) {
+            dispatch<any>(openCopyProcessDialog(resource));
+        }
+    },
+}
+
+const msRemoveProcess: MultiSelectMenuAction = {
+    name: MultiSelectMenuActionNames.REMOVE,
+    icon: RemoveIcon,
+    hasAlts: false,
+    isForMulti: true,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openRemoveProcessDialog(resources[0], resources.length));
+    },
+}
+
+const msMoveTo: MultiSelectMenuAction = {
+    name: MultiSelectMenuActionNames.MOVE_TO,
+    icon: MoveToIcon,
+    hasAlts: false,
+    isForMulti: true,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openMoveProcessDialog(resources[0]));
+    },
+}
+
+const msViewOutputs: MultiSelectMenuAction = {
+    name: MultiSelectMenuActionNames.OUTPUTS,
+    icon: OutputIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+                if (resources[0]) {
+            dispatch<any>(msNavigateToOutput(resources[0]));
+        }
+    },
+}
+
+const msEditProcess: MultiSelectMenuAction = {
+    name: MultiSelectMenuActionNames.EDIT_PROCESS,
+    icon: RenameIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openProcessUpdateDialog(resources[0]));
+    },
+}
+
+const msCancelProcess: MultiSelectMenuAction = {
+    name: MultiSelectMenuActionNames.CANCEL,
+    icon: StopIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(cancelRunningWorkflow(resources[0].uuid));
+    },
+}
+
+export const msProcessActionSet: MultiSelectMenuActionSet = [
+    [
+        ...msCommonActionSet,
+        msCopyAndRerunProcess,
+        msRemoveProcess,
+        msMoveTo,
+        msViewOutputs,
+        msEditProcess,
+        msCancelProcess
+    ]
+];
+
+const { MOVE_TO, REMOVE, COPY_AND_RERUN_PROCESS, ADD_TO_FAVORITES, OPEN_IN_NEW_TAB, VIEW_DETAILS, API_DETAILS, SHARE, ADD_TO_PUBLIC_FAVORITES, OUTPUTS, EDIT_PROCESS, CANCEL } = MultiSelectMenuActionNames
+
+export const msCommonProcessActionFilter = new Set([MOVE_TO, REMOVE, COPY_AND_RERUN_PROCESS, ADD_TO_FAVORITES, OPEN_IN_NEW_TAB, VIEW_DETAILS, API_DETAILS, SHARE, OUTPUTS, EDIT_PROCESS ]);
+export const msRunningProcessActionFilter = new Set([MOVE_TO, REMOVE, COPY_AND_RERUN_PROCESS, ADD_TO_FAVORITES, OPEN_IN_NEW_TAB, VIEW_DETAILS, API_DETAILS, SHARE, OUTPUTS, EDIT_PROCESS, CANCEL ]);
+
+export const msReadOnlyProcessActionFilter = new Set([COPY_AND_RERUN_PROCESS, ADD_TO_FAVORITES, OPEN_IN_NEW_TAB, VIEW_DETAILS, API_DETAILS, OUTPUTS ]);
+export const msAdminProcessActionFilter = new Set([MOVE_TO, REMOVE, COPY_AND_RERUN_PROCESS, ADD_TO_FAVORITES, OPEN_IN_NEW_TAB, VIEW_DETAILS, API_DETAILS, SHARE, ADD_TO_PUBLIC_FAVORITES, OUTPUTS, EDIT_PROCESS ]);
+
diff --git a/src/views-components/multiselect-toolbar/ms-project-action-set.ts b/src/views-components/multiselect-toolbar/ms-project-action-set.ts
new file mode 100644 (file)
index 0000000..ee1ea1d
--- /dev/null
@@ -0,0 +1,158 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { MultiSelectMenuAction, MultiSelectMenuActionSet, msCommonActionSet } from 'views-components/multiselect-toolbar/ms-menu-actions';
+import { MultiSelectMenuActionNames } from "views-components/multiselect-toolbar/ms-menu-actions";
+import { openMoveProjectDialog } from 'store/projects/project-move-actions';
+import { toggleProjectTrashed } from 'store/trash/trash-actions';
+import {
+    FreezeIcon,
+    MoveToIcon,
+    NewProjectIcon,
+    RenameIcon,
+    UnfreezeIcon,
+} from 'components/icon/icon';
+import { RestoreFromTrashIcon, TrashIcon, FolderSharedIcon, Link } from 'components/icon/icon';
+import { getResource } from 'store/resources/resources';
+import { openProjectCreateDialog } from 'store/projects/project-create-actions';
+import { openProjectUpdateDialog } from 'store/projects/project-update-actions';
+import { freezeProject, unfreezeProject } from 'store/projects/project-lock-actions';
+import { openWebDavS3InfoDialog } from 'store/collections/collection-info-actions';
+import { copyToClipboardAction } from 'store/open-in-new-tab/open-in-new-tab.actions';
+
+const {
+    ADD_TO_FAVORITES,
+    ADD_TO_PUBLIC_FAVORITES,
+    OPEN_IN_NEW_TAB,
+    COPY_TO_CLIPBOARD,
+    VIEW_DETAILS,
+    API_DETAILS,
+    OPEN_W_3RD_PARTY_CLIENT,
+    EDIT_PROJECT,
+    SHARE,
+    MOVE_TO,
+    MOVE_TO_TRASH,
+    FREEZE_PROJECT,
+    NEW_PROJECT,
+} = MultiSelectMenuActionNames;
+
+const msCopyToClipboardMenuAction: MultiSelectMenuAction  = {
+    name: COPY_TO_CLIPBOARD,
+    icon: Link,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(copyToClipboardAction(resources));
+    },
+};
+
+const msEditProjectAction: MultiSelectMenuAction = {
+    name: EDIT_PROJECT,
+    icon: RenameIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openProjectUpdateDialog(resources[0]));
+    },
+};
+
+const msMoveToAction: MultiSelectMenuAction = {
+    name: MOVE_TO,
+    icon: MoveToIcon,
+    hasAlts: false,
+    isForMulti: true,
+    execute: (dispatch, resource) => {
+        dispatch<any>(openMoveProjectDialog(resource[0]));
+    },
+};
+
+const msOpenWith3rdPartyClientAction: MultiSelectMenuAction  = {
+    name: OPEN_W_3RD_PARTY_CLIENT,
+    icon: FolderSharedIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openWebDavS3InfoDialog(resources[0].uuid));
+    },
+};
+
+export const msToggleTrashAction: MultiSelectMenuAction = {
+    name: MOVE_TO_TRASH,
+    icon: TrashIcon,
+    hasAlts: true,
+    altName: 'Restore from Trash',
+    altIcon: RestoreFromTrashIcon,
+    isForMulti: true,
+    useAlts: (uuid, iconProps) => {
+        return uuid ? (getResource(uuid)(iconProps.resources) as any).isTrashed : false;
+    },
+    execute: (dispatch, resources) => {
+        for (const resource of [...resources]) {
+            dispatch<any>(toggleProjectTrashed(resource.uuid, resource.ownerUuid, resource.isTrashed!!, resources.length > 1));
+        }
+    },
+};
+
+const msFreezeProjectAction: MultiSelectMenuAction = {
+    name: FREEZE_PROJECT,
+    icon: FreezeIcon,
+    hasAlts: true,
+    altName: 'Unfreeze Project',
+    altIcon: UnfreezeIcon,
+    isForMulti: false,
+    useAlts: (uuid, iconProps) => {
+        return uuid ? !!(getResource(uuid)(iconProps.resources) as any).frozenByUuid : false;
+    },
+    execute: (dispatch, resources) => {
+        if ((resources[0] as any).frozenByUuid) {
+            dispatch<any>(unfreezeProject(resources[0].uuid));
+        } else {
+            dispatch<any>(freezeProject(resources[0].uuid));
+        }
+    },
+};
+
+const msNewProjectAction: MultiSelectMenuAction = {
+    name: NEW_PROJECT,
+    icon: NewProjectIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources): void => {
+        dispatch<any>(openProjectCreateDialog(resources[0].uuid));
+    },
+};
+
+export const msProjectActionSet: MultiSelectMenuActionSet = [
+    [
+        ...msCommonActionSet,
+        msEditProjectAction,
+        msMoveToAction,
+        msToggleTrashAction,
+        msNewProjectAction,
+        msFreezeProjectAction,
+        msOpenWith3rdPartyClientAction,
+        msCopyToClipboardMenuAction
+    ],
+];
+
+export const msCommonProjectActionFilter = new Set<string>([
+    ADD_TO_FAVORITES,
+    MOVE_TO_TRASH,
+    API_DETAILS,
+    COPY_TO_CLIPBOARD,
+    EDIT_PROJECT,
+    FREEZE_PROJECT,
+    MOVE_TO,
+    NEW_PROJECT,
+    OPEN_IN_NEW_TAB,
+    OPEN_W_3RD_PARTY_CLIENT,
+    SHARE,
+    VIEW_DETAILS,
+]);
+export const msReadOnlyProjectActionFilter = new Set<string>([ADD_TO_FAVORITES, API_DETAILS, COPY_TO_CLIPBOARD, OPEN_IN_NEW_TAB, OPEN_W_3RD_PARTY_CLIENT, VIEW_DETAILS,]);
+export const msFrozenProjectActionFilter = new Set<string>([ADD_TO_FAVORITES, API_DETAILS, COPY_TO_CLIPBOARD, OPEN_IN_NEW_TAB, OPEN_W_3RD_PARTY_CLIENT, VIEW_DETAILS, SHARE, FREEZE_PROJECT])
+export const msAdminFrozenProjectActionFilter = new Set<string>([ADD_TO_FAVORITES, API_DETAILS, COPY_TO_CLIPBOARD, OPEN_IN_NEW_TAB, OPEN_W_3RD_PARTY_CLIENT, VIEW_DETAILS, SHARE, FREEZE_PROJECT, ADD_TO_PUBLIC_FAVORITES])
+
+export const msFilterGroupActionFilter = new Set<string>([ADD_TO_FAVORITES, API_DETAILS, COPY_TO_CLIPBOARD, OPEN_IN_NEW_TAB, OPEN_W_3RD_PARTY_CLIENT, VIEW_DETAILS, SHARE, MOVE_TO_TRASH, EDIT_PROJECT, MOVE_TO])
+export const msAdminFilterGroupActionFilter = new Set<string>([ADD_TO_FAVORITES, API_DETAILS, COPY_TO_CLIPBOARD, OPEN_IN_NEW_TAB, OPEN_W_3RD_PARTY_CLIENT, VIEW_DETAILS, SHARE, MOVE_TO_TRASH, EDIT_PROJECT, MOVE_TO, ADD_TO_PUBLIC_FAVORITES])
\ No newline at end of file
diff --git a/src/views-components/multiselect-toolbar/ms-workflow-action-set.ts b/src/views-components/multiselect-toolbar/ms-workflow-action-set.ts
new file mode 100644 (file)
index 0000000..ab819df
--- /dev/null
@@ -0,0 +1,46 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { openRunProcess, deleteWorkflow } from 'store/workflow-panel/workflow-panel-actions';
+import { StartIcon, TrashIcon, Link } from 'components/icon/icon';
+import { MultiSelectMenuAction, MultiSelectMenuActionSet, msCommonActionSet } from './ms-menu-actions';
+import { MultiSelectMenuActionNames } from "views-components/multiselect-toolbar/ms-menu-actions";
+import { copyToClipboardAction } from 'store/open-in-new-tab/open-in-new-tab.actions';
+
+const { OPEN_IN_NEW_TAB, COPY_TO_CLIPBOARD, VIEW_DETAILS, API_DETAILS, RUN_WORKFLOW, DELETE_WORKFLOW } = MultiSelectMenuActionNames;
+
+const msRunWorkflow: MultiSelectMenuAction = {
+    name: RUN_WORKFLOW,
+    icon: StartIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(openRunProcess(resources[0].uuid, resources[0].ownerUuid, resources[0].name));
+    },
+};
+
+const msDeleteWorkflow: MultiSelectMenuAction = {
+    name: DELETE_WORKFLOW,
+    icon: TrashIcon,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(deleteWorkflow(resources[0].uuid, resources[0].ownerUuid));
+    },
+};
+
+const msCopyToClipboardMenuAction: MultiSelectMenuAction  = {
+    name: COPY_TO_CLIPBOARD,
+    icon: Link,
+    hasAlts: false,
+    isForMulti: false,
+    execute: (dispatch, resources) => {
+        dispatch<any>(copyToClipboardAction(resources));
+    },
+};
+
+export const msWorkflowActionSet: MultiSelectMenuActionSet = [[...msCommonActionSet, msRunWorkflow, msDeleteWorkflow, msCopyToClipboardMenuAction]];
+
+export const msReadOnlyWorkflowActionFilter = new Set([OPEN_IN_NEW_TAB, COPY_TO_CLIPBOARD, VIEW_DETAILS, API_DETAILS, RUN_WORKFLOW ]);
+export const msWorkflowActionFilter = new Set([OPEN_IN_NEW_TAB, COPY_TO_CLIPBOARD, VIEW_DETAILS, API_DETAILS, RUN_WORKFLOW, DELETE_WORKFLOW]);
index 6ab2b42d177032d766c3f5cf396a48e514e89d6c..7e63152be9bd831127e0af1d040b2cb49851b6cf 100644 (file)
@@ -11,7 +11,7 @@ import { loadFavoritesProject } from 'store/tree-picker/tree-picker-actions';
 export const FavoritesTreePicker = connect(() => ({
     rootItemIcon: FavoriteIcon,
 }), (dispatch: Dispatch): Pick<ProjectsTreePickerProps, 'loadRootItem'> => ({
-    loadRootItem: (_, pickerId, includeCollections, includeFiles, options) => {
-        dispatch<any>(loadFavoritesProject({ pickerId, includeCollections, includeFiles }, options));
+    loadRootItem: (_, pickerId, includeCollections, includeDirectories, includeFiles, options) => {
+        dispatch<any>(loadFavoritesProject({ pickerId, includeCollections, includeDirectories, includeFiles }, options));
     },
-}))(ProjectsTreePicker);
\ No newline at end of file
+}))(ProjectsTreePicker);
index 11b51caa884e5b52df00f396f87f4be16d21fa3b..70797f3165eace32a1a6cef81d409f753fc4fe5d 100644 (file)
@@ -21,7 +21,9 @@ import { CollectionFileType } from 'models/collection-file';
 type PickedTreePickerProps = Pick<TreePickerProps<ProjectsTreePickerItem>, 'onContextMenu' | 'toggleItemActive' | 'toggleItemOpen' | 'toggleItemSelection'>;
 
 export interface ProjectsTreePickerDataProps {
+    cascadeSelection: boolean;
     includeCollections?: boolean;
+    includeDirectories?: boolean;
     includeFiles?: boolean;
     rootItemIcon: IconType;
     showSelection?: boolean;
@@ -29,17 +31,17 @@ export interface ProjectsTreePickerDataProps {
     disableActivation?: string[];
     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
     loadRootItem: (item: TreeItem<ProjectsTreePickerRootItem>, pickerId: string,
-        includeCollections?: boolean, includeFiles?: boolean, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) => void;
+        includeCollections?: boolean, includeDirectories?: boolean, includeFiles?: boolean, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) => void;
 }
 
 export type ProjectsTreePickerProps = ProjectsTreePickerDataProps & Partial<PickedTreePickerProps>;
 
-const mapStateToProps = (_: any, { rootItemIcon, showSelection }: ProjectsTreePickerProps) => ({
+const mapStateToProps = (_: any, { rootItemIcon, showSelection, cascadeSelection }: ProjectsTreePickerProps) => ({
     render: renderTreeItem(rootItemIcon),
-    showSelection: isSelectionVisible(showSelection),
+    showSelection: isSelectionVisible(showSelection, cascadeSelection),
 });
 
-const mapDispatchToProps = (dispatch: Dispatch, { loadRootItem, includeCollections, includeFiles, relatedTreePickers, options, ...props }: ProjectsTreePickerProps): PickedTreePickerProps => ({
+const mapDispatchToProps = (dispatch: Dispatch, { loadRootItem, includeCollections, includeDirectories, includeFiles, relatedTreePickers, options, ...props }: ProjectsTreePickerProps): PickedTreePickerProps => ({
     onContextMenu: () => { return; },
     toggleItemActive: (event, item, pickerId) => {
 
@@ -59,18 +61,18 @@ const mapDispatchToProps = (dispatch: Dispatch, { loadRootItem, includeCollectio
             if ('kind' in data) {
                 dispatch<any>(
                     data.kind === ResourceKind.COLLECTION
-                        ? loadCollection(id, pickerId)
-                        : loadProject({ id, pickerId, includeCollections, includeFiles, options })
+                        ? loadCollection(id, pickerId, includeDirectories, includeFiles)
+                        : loadProject({ id, pickerId, includeCollections, includeDirectories, includeFiles, options })
                 );
             } else if (!('type' in data) && loadRootItem) {
-                loadRootItem(item as TreeItem<ProjectsTreePickerRootItem>, pickerId, includeCollections, includeFiles, options);
+                loadRootItem(item as TreeItem<ProjectsTreePickerRootItem>, pickerId, includeCollections, includeDirectories, includeFiles, options);
             }
         } else if (status === TreeItemStatus.LOADED) {
             dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
         }
     },
     toggleItemSelection: (event, item, pickerId) => {
-        dispatch<any>(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECTION({ id: item.id, pickerId }));
+        dispatch<any>(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECTION({ id: item.id, pickerId, cascade: props.cascadeSelection }));
         if (props.toggleItemSelection) {
             props.toggleItemSelection(event, item, pickerId);
         }
@@ -107,11 +109,14 @@ const getProjectPickerIcon = ({ data }: TreeItem<ProjectsTreePickerItem>, rootIc
     }
 };
 
-const isSelectionVisible = (shouldBeVisible?: boolean) =>
-    ({ status, items }: TreeItem<ProjectsTreePickerItem>): boolean => {
+const isSelectionVisible = (shouldBeVisible: boolean | undefined, cascadeSelection: boolean) =>
+    ({ status, items, data }: TreeItem<ProjectsTreePickerItem>): boolean => {
         if (shouldBeVisible) {
-            if (items && items.length > 0) {
-                return items.every(isSelectionVisible(shouldBeVisible));
+            if (!cascadeSelection && 'kind' in data && data.kind === ResourceKind.COLLECTION) {
+                // In non-casecade mode collections are selectable without being loaded
+                return true;
+            } else if (items && items.length > 0) {
+                return items.every(isSelectionVisible(shouldBeVisible, cascadeSelection));
             }
             return status === TreeItemStatus.LOADED;
         }
index 3133c5dbb951ed9632630dc5a1546aee89c62bfb..3f71a58e40a89b94306903862baa22a9230588df 100644 (file)
@@ -11,7 +11,7 @@ import { ProjectsIcon } from 'components/icon/icon';
 export const HomeTreePicker = connect(() => ({
     rootItemIcon: ProjectsIcon,
 }), (dispatch: Dispatch): Pick<ProjectsTreePickerProps, 'loadRootItem'> => ({
-    loadRootItem: (_, pickerId, includeCollections, includeFiles, options) => {
-        dispatch<any>(loadUserProject(pickerId, includeCollections, includeFiles, options));
+    loadRootItem: (_, pickerId, includeCollections, includeDirectories, includeFiles, options) => {
+        dispatch<any>(loadUserProject(pickerId, includeCollections, includeDirectories, includeFiles, options));
     },
 }))(ProjectsTreePicker);
index 9ac0b64fc0e580fcdd4dc64e5db82d5319a3510c..16f6cceb71ce44b711c5214157c48ddaff4d3061 100644 (file)
@@ -23,8 +23,11 @@ import { withStyles, StyleRulesCallback, WithStyles } from '@material-ui/core';
 import { ArvadosTheme } from 'common/custom-theme';
 
 export interface ToplevelPickerProps {
+    currentUuids?: string[];
     pickerId: string;
+    cascadeSelection: boolean;
     includeCollections?: boolean;
+    includeDirectories?: boolean;
     includeFiles?: boolean;
     showSelection?: boolean;
     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
@@ -55,6 +58,7 @@ const mapDispatchToProps = (dispatch: Dispatch, props: ToplevelPickerProps): (Pr
     const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(props.pickerId);
     const params = {
         includeCollections: props.includeCollections,
+        includeDirectories: props.includeDirectories,
         includeFiles: props.includeFiles,
         options: props.options
     };
@@ -104,7 +108,13 @@ export const ProjectsTreePicker = connect(mapStateToProps, mapDispatchToProps)(
             componentDidMount() {
                 const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(this.props.pickerId);
 
-                this.props.dispatch<any>(initProjectsTreePicker(this.props.pickerId));
+                const preloadParams = this.props.currentUuids ? {
+                    selectedItemUuids: this.props.currentUuids,
+                    includeDirectories: !!this.props.includeDirectories,
+                    includeFiles: !!this.props.includeFiles,
+                    multi: !!this.props.showSelection,
+                } : undefined;
+                this.props.dispatch<any>(initProjectsTreePicker(this.props.pickerId, preloadParams));
 
                 this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_PROJECT_SEARCH({ pickerId: search, projectSearchValue: "" }));
                 this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: search, collectionFilterValue: "" }));
@@ -132,7 +142,9 @@ export const ProjectsTreePicker = connect(mapStateToProps, mapDispatchToProps)(
                 const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId);
                 const relatedTreePickers = getRelatedTreePickers(pickerId);
                 const p = {
+                    cascadeSelection: this.props.cascadeSelection,
                     includeCollections: this.props.includeCollections,
+                    includeDirectories: this.props.includeDirectories,
                     includeFiles: this.props.includeFiles,
                     showSelection: this.props.showSelection,
                     options: this.props.options,
index 91551c9abf5cfce0f2604b485f958d0a184633e7..ca03f72836ce6d4e88a0ea74a0459bd397f74792 100644 (file)
@@ -11,7 +11,7 @@ import { loadPublicFavoritesProject } from 'store/tree-picker/tree-picker-action
 export const PublicFavoritesTreePicker = connect(() => ({
     rootItemIcon: PublicFavoriteIcon,
 }), (dispatch: Dispatch): Pick<ProjectsTreePickerProps, 'loadRootItem'> => ({
-    loadRootItem: (_, pickerId, includeCollections, includeFiles, options) => {
-        dispatch<any>(loadPublicFavoritesProject({ pickerId, includeCollections, includeFiles, options }));
+    loadRootItem: (_, pickerId, includeCollections, includeDirectories, includeFiles, options) => {
+        dispatch<any>(loadPublicFavoritesProject({ pickerId, includeCollections, includeDirectories, includeFiles, options }));
     },
-}))(ProjectsTreePicker);
\ No newline at end of file
+}))(ProjectsTreePicker);
index 7bad8ef7e99bc0689ea780b14bbb4c37fbea4ca3..2888050b088cb4ed7bfdf5ce361b25cf67690faf 100644 (file)
@@ -12,7 +12,7 @@ import { SEARCH_PROJECT_ID } from 'store/tree-picker/tree-picker-actions';
 export const SearchProjectsPicker = connect(() => ({
     rootItemIcon: SearchIcon,
 }), (dispatch: Dispatch): Pick<ProjectsTreePickerProps, 'loadRootItem'> => ({
-    loadRootItem: (_, pickerId, includeCollections, includeFiles, options) => {
-        dispatch<any>(loadProject({ id: SEARCH_PROJECT_ID, pickerId, includeCollections, includeFiles, searchProjects: true, options }));
+    loadRootItem: (_, pickerId, includeCollections, includeDirectories, includeFiles, options) => {
+        dispatch<any>(loadProject({ id: SEARCH_PROJECT_ID, pickerId, includeCollections, includeDirectories, includeFiles, searchProjects: true, options }));
     },
 }))(ProjectsTreePicker);
index c15df6ba0c29e9831b8f215c4bf154b1e6896521..1914cd9d3ea16e616f453cb83bf8c965c9cb1739 100644 (file)
@@ -12,7 +12,7 @@ import { SHARED_PROJECT_ID } from 'store/tree-picker/tree-picker-actions';
 export const SharedTreePicker = connect(() => ({
     rootItemIcon: ShareMeIcon,
 }), (dispatch: Dispatch): Pick<ProjectsTreePickerProps, 'loadRootItem'> => ({
-    loadRootItem: (_, pickerId, includeCollections, includeFiles, options) => {
-        dispatch<any>(loadProject({ id: SHARED_PROJECT_ID, pickerId, includeCollections, includeFiles, loadShared: true, options }));
+    loadRootItem: (_, pickerId, includeCollections, includeDirectories, includeFiles, options) => {
+        dispatch<any>(loadProject({ id: SHARED_PROJECT_ID, pickerId, includeCollections, includeDirectories, includeFiles, loadShared: true, options }));
     },
 }))(ProjectsTreePicker);
index 2afa606e363cba8a4adaaf2b118c581af2981719..75cf40c641bbe195e0c8ef02c4c2875d3adbb625 100644 (file)
@@ -9,6 +9,9 @@ import { WrappedFieldProps } from 'redux-form';
 import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker';
 import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware';
 import { PickerIdProp } from 'store/tree-picker/picker-id';
+import { FileOperationLocation, getFileOperationLocation } from "store/tree-picker/tree-picker-actions";
+import { connect } from "react-redux";
+import { Dispatch } from "redux";
 
 export const ProjectTreePickerField = (props: WrappedFieldProps & PickerIdProp) =>
     <div style={{ display: 'flex', minHeight: 0, flexDirection: 'column' }}>
@@ -16,6 +19,7 @@ export const ProjectTreePickerField = (props: WrappedFieldProps & PickerIdProp)
             <ProjectsTreePicker
                 pickerId={props.pickerId}
                 toggleItemActive={handleChange(props)}
+                cascadeSelection={false}
                 options={{ showOnlyOwned: false, showOnlyWritable: true }} />
             {props.meta.dirty && props.meta.error &&
                 <Typography variant='caption' color='error'>
@@ -34,6 +38,7 @@ export const CollectionTreePickerField = (props: WrappedFieldProps & PickerIdPro
             <ProjectsTreePicker
                 pickerId={props.pickerId}
                 toggleItemActive={handleChange(props)}
+                cascadeSelection={false}
                 options={{ showOnlyOwned: false, showOnlyWritable: true }}
                 includeCollections />
             {props.meta.dirty && props.meta.error &&
@@ -42,3 +47,42 @@ export const CollectionTreePickerField = (props: WrappedFieldProps & PickerIdPro
                 </Typography>}
         </div>
     </div>;
+
+type ProjectsTreePickerActionProps = {
+    getFileOperationLocation: (item: ProjectsTreePickerItem) => Promise<FileOperationLocation | undefined>;
+}
+
+const projectsTreePickerMapDispatchToProps = (dispatch: Dispatch): ProjectsTreePickerActionProps => ({
+    getFileOperationLocation: (item: ProjectsTreePickerItem) => dispatch<any>(getFileOperationLocation(item)),
+});
+
+type ProjectsTreePickerCombinedProps = ProjectsTreePickerActionProps & WrappedFieldProps & PickerIdProp;
+
+export const DirectoryTreePickerField = connect(null, projectsTreePickerMapDispatchToProps)(
+    class DirectoryTreePickerFieldComponent extends React.Component<ProjectsTreePickerCombinedProps> {
+
+        handleDirectoryChange = (props: WrappedFieldProps) =>
+            async (_: any, { data }: TreeItem<ProjectsTreePickerItem>) => {
+                const location = await this.props.getFileOperationLocation(data);
+                props.input.onChange(location || '');
+            }
+
+        render() {
+            return <div style={{ display: 'flex', minHeight: 0, flexDirection: 'column' }}>
+                <div style={{ flexBasis: '275px', flexShrink: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
+                    <ProjectsTreePicker
+                        currentUuids={[this.props.input.value.uuid]}
+                        pickerId={this.props.pickerId}
+                        toggleItemActive={this.handleDirectoryChange(this.props)}
+                        cascadeSelection={false}
+                        options={{ showOnlyOwned: false, showOnlyWritable: true }}
+                        includeCollections
+                        includeDirectories />
+                    {this.props.meta.dirty && this.props.meta.error &&
+                        <Typography variant='caption' color='error'>
+                            {this.props.meta.error}
+                        </Typography>}
+                </div>
+            </div>;
+        }
+    });
index 284083477f00e2f4c13f1d13cee483367bbbf630..eba281c9f0854c275454f61ee91142d7d59ab110 100644 (file)
@@ -2,73 +2,61 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-import { compose } from 'redux';
-import {
-    IconButton,
-    Paper,
-    StyleRulesCallback,
-    withStyles,
-    WithStyles,
-    Tooltip,
-    InputAdornment, Input,
-} from '@material-ui/core';
-import SearchIcon from '@material-ui/icons/Search';
-import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown';
-import { ArvadosTheme } from 'common/custom-theme';
-import { SearchView } from 'store/search-bar/search-bar-reducer';
-import {
-    SearchBarBasicView,
-    SearchBarBasicViewDataProps,
-    SearchBarBasicViewActionProps
-} from 'views-components/search-bar/search-bar-basic-view';
+import React from "react";
+import { compose } from "redux";
+import { IconButton, Paper, StyleRulesCallback, withStyles, WithStyles, Tooltip, InputAdornment, Input } from "@material-ui/core";
+import SearchIcon from "@material-ui/icons/Search";
+import ArrowDropDownIcon from "@material-ui/icons/ArrowDropDown";
+import { ArvadosTheme } from "common/custom-theme";
+import { SearchView } from "store/search-bar/search-bar-reducer";
+import { SearchBarBasicView, SearchBarBasicViewDataProps, SearchBarBasicViewActionProps } from "views-components/search-bar/search-bar-basic-view";
 import {
     SearchBarAutocompleteView,
     SearchBarAutocompleteViewDataProps,
-    SearchBarAutocompleteViewActionProps
-} from 'views-components/search-bar/search-bar-autocomplete-view';
+    SearchBarAutocompleteViewActionProps,
+} from "views-components/search-bar/search-bar-autocomplete-view";
 import {
     SearchBarAdvancedView,
     SearchBarAdvancedViewDataProps,
-    SearchBarAdvancedViewActionProps
-} from 'views-components/search-bar/search-bar-advanced-view';
+    SearchBarAdvancedViewActionProps,
+} from "views-components/search-bar/search-bar-advanced-view";
 import { KEY_CODE_DOWN, KEY_CODE_ESC, KEY_CODE_UP, KEY_ENTER } from "common/codes";
-import { debounce } from 'debounce';
-import { Vocabulary } from 'models/vocabulary';
-import { connectVocabulary } from '../resource-properties-form/property-field-common';
+import { debounce } from "debounce";
+import { Vocabulary } from "models/vocabulary";
+import { connectVocabulary } from "../resource-properties-form/property-field-common";
 
-type CssRules = 'container' | 'containerSearchViewOpened' | 'input' | 'view';
+type CssRules = "container" | "containerSearchViewOpened" | "input" | "view";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => {
     return {
         container: {
-            position: 'relative',
-            width: '100%',
+            position: "relative",
+            width: "100%",
             borderRadius: theme.spacing.unit / 2,
             zIndex: theme.zIndex.modal,
         },
         containerSearchViewOpened: {
-            position: 'relative',
-            width: '100%',
+            position: "relative",
+            width: "100%",
             borderRadius: `${theme.spacing.unit / 2}px ${theme.spacing.unit / 2}px 0 0`,
             zIndex: theme.zIndex.modal,
         },
         input: {
-            border: 'none',
-            padding: `0`
+            border: "none",
+            padding: `0`,
         },
         view: {
-            position: 'absolute',
-            width: '100%',
-            zIndex: 1
-        }
+            position: "absolute",
+            width: "100%",
+            zIndex: 1,
+        },
     };
 };
 
-export type SearchBarDataProps = SearchBarViewDataProps
-    & SearchBarAutocompleteViewDataProps
-    & SearchBarAdvancedViewDataProps
-    SearchBarBasicViewDataProps;
+export type SearchBarDataProps = SearchBarViewDataProps &
+    SearchBarAutocompleteViewDataProps &
+    SearchBarAdvancedViewDataProps &
+    SearchBarBasicViewDataProps;
 
 interface SearchBarViewDataProps {
     searchValue: string;
@@ -78,10 +66,10 @@ interface SearchBarViewDataProps {
     vocabulary?: Vocabulary;
 }
 
-export type SearchBarActionProps = SearchBarViewActionProps
-    & SearchBarAutocompleteViewActionProps
-    & SearchBarAdvancedViewActionProps
-    SearchBarBasicViewActionProps;
+export type SearchBarActionProps = SearchBarViewActionProps &
+    SearchBarAutocompleteViewActionProps &
+    SearchBarAdvancedViewActionProps &
+    SearchBarBasicViewActionProps;
 
 interface SearchBarViewActionProps {
     onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
@@ -144,9 +132,11 @@ const handleDropdownClick = (e: React.MouseEvent, props: SearchBarViewProps) =>
     }
 };
 
-export const SearchBarView = compose(connectVocabulary, withStyles(styles))(
+export const SearchBarView = compose(
+    connectVocabulary,
+    withStyles(styles)
+)(
     class extends React.Component<SearchBarViewProps> {
-
         debouncedSearch = debounce(() => {
             this.props.onSearch(this.props.searchValue);
         }, 1000);
@@ -154,12 +144,12 @@ export const SearchBarView = compose(connectVocabulary, withStyles(styles))(
         handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
             this.debouncedSearch();
             this.props.onChange(event);
-        }
+        };
 
         handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
             this.debouncedSearch.clear();
             this.props.onSubmit(event);
-        }
+        };
 
         componentWillUnmount() {
             this.debouncedSearch.clear();
@@ -170,14 +160,14 @@ export const SearchBarView = compose(connectVocabulary, withStyles(styles))(
             const { classes, isPopoverOpen } = this.props;
             return (
                 <>
+                    {isPopoverOpen && <Backdrop onClick={props.closeView} />}
 
-                    {isPopoverOpen &&
-                        <Backdrop onClick={props.closeView} />}
-
-                    <Paper className={isPopoverOpen ? classes.containerSearchViewOpened : classes.container} >
-                        <form onSubmit={this.handleSubmit}>
+                    <Paper className={isPopoverOpen ? classes.containerSearchViewOpened : classes.container}>
+                        <form
+                            data-cy="searchbar-parent-form"
+                            onSubmit={this.handleSubmit}>
                             <Input
-                                data-cy='searchbar-input-field'
+                                data-cy="searchbar-input-field"
                                 className={classes.input}
                                 onChange={this.handleChange}
                                 placeholder="Search"
@@ -188,7 +178,7 @@ export const SearchBarView = compose(connectVocabulary, withStyles(styles))(
                                 onKeyDown={e => handleKeyDown(e, props)}
                                 startAdornment={
                                     <InputAdornment position="start">
-                                        <Tooltip title='Search'>
+                                        <Tooltip title="Search">
                                             <IconButton type="submit">
                                                 <SearchIcon />
                                             </IconButton>
@@ -197,57 +187,69 @@ export const SearchBarView = compose(connectVocabulary, withStyles(styles))(
                                 }
                                 endAdornment={
                                     <InputAdornment position="end">
-                                        <Tooltip title='Advanced search'>
+                                        <Tooltip title="Advanced search">
                                             <IconButton onClick={e => handleDropdownClick(e, props)}>
                                                 <ArrowDropDownIcon />
                                             </IconButton>
                                         </Tooltip>
                                     </InputAdornment>
-                                } />
+                                }
+                            />
                         </form>
-                        <div className={classes.view}>
-                            {isPopoverOpen && getView({ ...props })}
-                        </div>
-                    </Paper >
+                        <div className={classes.view}>{isPopoverOpen && getView({ ...props })}</div>
+                    </Paper>
                 </>
             );
         }
-    });
+    }
+);
 
 const getView = (props: SearchBarViewProps) => {
     switch (props.currentView) {
         case SearchView.AUTOCOMPLETE:
-            return <SearchBarAutocompleteView
-                navigateTo={props.navigateTo}
-                searchResults={props.searchResults}
-                searchValue={props.searchValue}
-                selectedItem={props.selectedItem} />;
+            return (
+                <SearchBarAutocompleteView
+                    navigateTo={props.navigateTo}
+                    searchResults={props.searchResults}
+                    searchValue={props.searchValue}
+                    selectedItem={props.selectedItem}
+                />
+            );
         case SearchView.ADVANCED:
-            return <SearchBarAdvancedView
-                closeAdvanceView={props.closeAdvanceView}
-                tags={props.tags}
-                saveQuery={props.saveQuery} />;
+            return (
+                <SearchBarAdvancedView
+                    closeAdvanceView={props.closeAdvanceView}
+                    tags={props.tags}
+                    saveQuery={props.saveQuery}
+                />
+            );
         default:
-            return <SearchBarBasicView
-                onSetView={props.onSetView}
-                onSearch={props.onSearch}
-                loadRecentQueries={props.loadRecentQueries}
-                savedQueries={props.savedQueries}
-                deleteSavedQuery={props.deleteSavedQuery}
-                editSavedQuery={props.editSavedQuery}
-                selectedItem={props.selectedItem} />;
+            return (
+                <SearchBarBasicView
+                    onSetView={props.onSetView}
+                    onSearch={props.onSearch}
+                    loadRecentQueries={props.loadRecentQueries}
+                    savedQueries={props.savedQueries}
+                    deleteSavedQuery={props.deleteSavedQuery}
+                    editSavedQuery={props.editSavedQuery}
+                    selectedItem={props.selectedItem}
+                />
+            );
     }
 };
 
-const Backdrop = withStyles<'backdrop'>(theme => ({
+const Backdrop = withStyles<"backdrop">(theme => ({
     backdrop: {
-        position: 'fixed',
+        position: "fixed",
         top: 0,
         right: 0,
         bottom: 0,
         left: 0,
-        zIndex: theme.zIndex.modal
-    }
-}))(
-    ({ classes, ...props }: WithStyles<'backdrop'> & React.HTMLProps<HTMLDivElement>) =>
-        <div className={classes.backdrop} {...props} />);
+        zIndex: theme.zIndex.modal,
+    },
+}))(({ classes, ...props }: WithStyles<"backdrop"> & React.HTMLProps<HTMLDivElement>) => (
+    <div
+        className={classes.backdrop}
+        {...props}
+    />
+));
index 02cdeaf2c28e814a3a1d72d24d8cfadfe143eee3..058d7234e47124ae32841462cfccc0bc9cf63523 100644 (file)
@@ -74,7 +74,7 @@ export const ParticipantSelect = connect()(
         };
 
         render() {
-            const { label = 'Share' } = this.props;
+            const { label = 'Add people and groups' } = this.props;
 
             return (
                 <Autocomplete
@@ -88,14 +88,21 @@ export const ParticipantSelect = connect()(
                     onSelect={this.handleSelect}
                     onDelete={this.props.onDelete && !this.props.disabled ? this.handleDelete : undefined}
                     onFocus={this.props.onFocus}
-                    onBlur={this.props.onBlur}
+                    onBlur={this.onBlur}
                     renderChipValue={this.renderChipValue}
                     renderChipTooltip={this.renderChipTooltip}
                     renderSuggestion={this.renderSuggestion}
-                    disabled={this.props.disabled}/>
+                    disabled={this.props.disabled} />
             );
         }
 
+        onBlur = (e) => {
+            if (this.props.onBlur) {
+                this.props.onBlur(e);
+            }
+            setTimeout(() => this.setState({ value: '', suggestions: [] }), 200);
+        }
+
         renderChipValue(chipValue: Participant) {
             const { name, uuid } = chipValue;
             return name || uuid;
index 36447a8dabdc4e20aa15ac4c5906af32b15cd9d7..2fc4d01ad6e27b93819f079a4f0373c8ae6332be 100644 (file)
@@ -27,6 +27,11 @@ describe("<SharingDialogComponent />", () => {
             config: {
                 keepWebServiceUrl: 'http://example.com/',
                 keepWebInlineServiceUrl: 'http://*.collections.example.com/',
+                clusterConfig: {
+                    Users: {
+                        AnonymousUserToken: ""
+                    }
+                }
             }
         }
         store = createStore(combineReducers({
@@ -68,4 +73,4 @@ describe("<SharingDialogComponent />", () => {
         let wrapper = mount(<Provider store={store}><SharingDialogComponent {...props} /></Provider>);
         expect(wrapper.html()).not.toContain('Sharing URLs');
     });
-});
\ No newline at end of file
+});
index b2f313973ea7ef7abb71e0d422877aced717ce47..f83cec60f24ec2662a73b10fdb3764e7f332c324 100644 (file)
@@ -48,6 +48,7 @@ export interface SharingDialogDataProps {
     sharingURLsNr: number;
     privateAccess: boolean;
     sharingURLsDisabled: boolean;
+    permissions: any[];
 }
 export interface SharingDialogActionProps {
     onClose: () => void;
@@ -93,99 +94,90 @@ export default (props: SharingDialogComponentProps) => {
         <DialogTitle>
             Sharing settings
         </DialogTitle>
-        { showTabs &&
-        <Tabs value={tabNr}
-            onChange={(_, tb) => {
-                if (tb === SharingDialogTab.PERMISSIONS) {
-                    refreshPermissions();
+        {showTabs &&
+            <Tabs value={tabNr}
+                onChange={(_, tb) => {
+                    if (tb === SharingDialogTab.PERMISSIONS) {
+                        refreshPermissions();
+                    }
+                    setTabNr(tb)
                 }
-                setTabNr(tb)}
-            }>
-            <Tab label="With users/groups" />
-            <Tab label={`Sharing URLs ${sharingURLsNr > 0 ? '('+sharingURLsNr+')' : ''}`} disabled={saveEnabled} />
-        </Tabs>
+                }>
+                <Tab label="With users/groups" />
+                <Tab label={`Sharing URLs ${sharingURLsNr > 0 ? '(' + sharingURLsNr + ')' : ''}`} disabled={saveEnabled} />
+            </Tabs>
         }
         <DialogContent>
-            { tabNr === SharingDialogTab.PERMISSIONS &&
-            <Grid container direction='column' spacing={24}>
-                <Grid item>
-                    <SharingPublicAccessForm />
-                </Grid>
-                <Grid item>
-                    <SharingManagementForm />
+            {tabNr === SharingDialogTab.PERMISSIONS &&
+                <Grid container direction='column' spacing={24}>
+                    <Grid item>
+                        <SharingInvitationForm onSave={onSave} saveEnabled={saveEnabled} />
+                    </Grid>
+                    <Grid item>
+                        <SharingManagementForm onSave={onSave} />
+                    </Grid>
+                    <Grid item>
+                        <SharingPublicAccessForm onSave={onSave} />
+                    </Grid>
                 </Grid>
-            </Grid>
             }
-            { tabNr === SharingDialogTab.URLS &&
-            <SharingURLsContent uuid={sharedResourceUuid} />
+            {tabNr === SharingDialogTab.URLS &&
+                <SharingURLsContent uuid={sharedResourceUuid} />
             }
         </DialogContent>
         <DialogActions>
             <Grid container spacing={8}>
-                { tabNr === SharingDialogTab.PERMISSIONS &&
-                <Grid item md={12}>
-                    <SharingInvitationForm />
-                </Grid>
-                }
-                { tabNr === SharingDialogTab.URLS && withExpiration && <>
-                <Grid item container direction='row' md={12}>
-                    <MuiPickersUtilsProvider utils={DateFnsUtils}>
-                        <BasePicker autoOk value={expDate} onChange={setExpDate}>
-                        {({ date, handleChange }) => (<>
-                            <Grid item md={6}>
-                                <Calendar date={date} minDate={new Date()} maxDate={undefined}
-                                    onChange={handleChange} />
-                            </Grid>
-                            <Grid item md={6}>
-                                <TimePickerView type="hours" date={date} ampm={false}
-                                    onMinutesChange={() => {}}
-                                    onSecondsChange={() => {}}
-                                    onHourChange={handleChange}
-                                />
-                            </Grid>
-                        </>)}
-                        </BasePicker>
-                    </MuiPickersUtilsProvider>
-                </Grid>
-                <Grid item md={12}>
-                    <Typography variant='caption' align='center'>
-                        Maximum expiration date may be limited by the cluster configuration.
-                    </Typography>
-                </Grid>
+                {tabNr === SharingDialogTab.URLS && withExpiration && <>
+                    <Grid item container direction='row' md={12}>
+                        <MuiPickersUtilsProvider utils={DateFnsUtils}>
+                            <BasePicker autoOk value={expDate} onChange={setExpDate}>
+                                {({ date, handleChange }) => (<>
+                                    <Grid item md={6}>
+                                        <Calendar date={date} minDate={new Date()} maxDate={undefined}
+                                            onChange={handleChange} />
+                                    </Grid>
+                                    <Grid item md={6}>
+                                        <TimePickerView type="hours" date={date} ampm={false}
+                                            onMinutesChange={() => { }}
+                                            onSecondsChange={() => { }}
+                                            onHourChange={handleChange}
+                                        />
+                                    </Grid>
+                                </>)}
+                            </BasePicker>
+                        </MuiPickersUtilsProvider>
+                    </Grid>
+                    <Grid item md={12}>
+                        <Typography variant='caption' align='center'>
+                            Maximum expiration date may be limited by the cluster configuration.
+                        </Typography>
+                    </Grid>
                 </>
                 }
-                { tabNr === SharingDialogTab.PERMISSIONS && !sharingURLsDisabled &&
+                {tabNr === SharingDialogTab.PERMISSIONS && !sharingURLsDisabled &&
                     privateAccess && sharingURLsNr > 0 &&
-                <Grid item md={12}>
-                    <Typography variant='caption' align='center' color='error'>
-                        Although there aren't specific permissions set, this is publicly accessible via Sharing URL(s).
-                    </Typography>
-                </Grid>
+                    <Grid item md={12}>
+                        <Typography variant='caption' align='center' color='error'>
+                            Although there aren't specific permissions set, this is publicly accessible via Sharing URL(s).
+                        </Typography>
+                    </Grid>
                 }
                 <Grid item xs />
-                { tabNr === SharingDialogTab.URLS && <>
-                <Grid item><FormControlLabel
-                    control={<Checkbox color="primary" checked={withExpiration}
-                        onChange={(e) => setWithExpiration(e.target.checked)} />}
-                    label="With expiration" />
-                </Grid>
-                <Grid item>
-                    <Button variant="contained" color="primary"
-                        disabled={expDate !== undefined && expDate <= new Date()}
-                        onClick={onCreateSharingToken(expDate)}>
-                        Create sharing URL
-                    </Button>
-                </Grid>
+                {tabNr === SharingDialogTab.URLS && <>
+                    <Grid item><FormControlLabel
+                        control={<Checkbox color="primary" checked={withExpiration}
+                            onChange={(e) => setWithExpiration(e.target.checked)} />}
+                        label="With expiration" />
+                    </Grid>
+                    <Grid item>
+                        <Button variant="contained" color="primary"
+                            disabled={expDate !== undefined && expDate <= new Date()}
+                            onClick={onCreateSharingToken(expDate)}>
+                            Create sharing URL
+                        </Button>
+                    </Grid>
                 </>
                 }
-                { tabNr === SharingDialogTab.PERMISSIONS &&
-                <Grid item>
-                    <Button onClick={onSave} variant="contained" color="primary"
-                        disabled={!saveEnabled}>
-                        Save changes
-                    </Button>
-                </Grid>
-                }
                 <Grid item>
                     <Button onClick={() => {
                         onClose();
index 01cd390b07f7a2263400954fbba3b9c41f17cb5d..1c9e4d0393fe23d5956542260bf2f4da18d0848a 100644 (file)
@@ -5,6 +5,7 @@
 import { compose, Dispatch } from 'redux';
 import { connect } from 'react-redux';
 import { RootState } from 'store/store';
+import { formValueSelector } from 'redux-form'
 import {
     connectSharingDialog,
     saveSharingDialogChanges,
@@ -22,6 +23,7 @@ import {
     getSharingPublicAccessFormData,
     hasChanges,
     SHARING_DIALOG_NAME,
+    SHARING_MANAGEMENT_FORM_NAME,
     VisibilityLevel
 } from 'store/sharing-dialog/sharing-dialog-types';
 import { WithProgressStateProps } from 'store/progress-indicator/with-progress';
@@ -32,25 +34,28 @@ import { ResourceKind } from 'models/resource';
 
 type Props = WithDialogProps<string> & WithProgressStateProps;
 
+const sharingManagementFormSelector = formValueSelector(SHARING_MANAGEMENT_FORM_NAME);
+
 const mapStateToProps = (state: RootState, { working, ...props }: Props): SharingDialogDataProps => {
     const dialog = getDialog<SharingDialogData>(state.dialog, SHARING_DIALOG_NAME);
     const sharedResourceUuid = dialog?.data.resourceUuid || '';
     const sharingURLsDisabled = state.auth.config.clusterConfig.Workbench.DisableSharingURLsUI;
     return ({
-    ...props,
-    saveEnabled: hasChanges(state),
-    loading: working,
-    sharedResourceUuid,
-    sharingURLsDisabled,
-    sharingURLsNr: !sharingURLsDisabled
-        ? (filterResources( (resource: ApiClientAuthorization) =>
-            resource.kind === ResourceKind.API_CLIENT_AUTHORIZATION  &&
-            resource.scopes.includes(`GET /arvados/v1/collections/${sharedResourceUuid}`) &&
-            resource.scopes.includes(`GET /arvados/v1/collections/${sharedResourceUuid}/`) &&
-            resource.scopes.includes('GET /arvados/v1/keep_services/accessible')
-        )(state.resources) as ApiClientAuthorization[]).length
-        : 0,
-    privateAccess: getSharingPublicAccessFormData(state)?.visibility === VisibilityLevel.PRIVATE,
+        ...props,
+        permissions: sharingManagementFormSelector(state, 'permissions'),
+        saveEnabled: hasChanges(state),
+        loading: working,
+        sharedResourceUuid,
+        sharingURLsDisabled,
+        sharingURLsNr: !sharingURLsDisabled
+            ? (filterResources((resource: ApiClientAuthorization) =>
+                resource.kind === ResourceKind.API_CLIENT_AUTHORIZATION &&
+                resource.scopes.includes(`GET /arvados/v1/collections/${sharedResourceUuid}`) &&
+                resource.scopes.includes(`GET /arvados/v1/collections/${sharedResourceUuid}/`) &&
+                resource.scopes.includes('GET /arvados/v1/keep_services/accessible')
+            )(state.resources) as ApiClientAuthorization[]).length
+            : 0,
+        privateAccess: getSharingPublicAccessFormData(state)?.visibility === VisibilityLevel.PRIVATE,
     })
 };
 
@@ -58,7 +63,7 @@ const mapDispatchToProps = (dispatch: Dispatch, { ...props }: Props): SharingDia
     ...props,
     onClose: props.closeDialog,
     onSave: () => {
-        dispatch<any>(saveSharingDialogChanges);
+        setTimeout(() => dispatch<any>(saveSharingDialogChanges), 0);
     },
     onCreateSharingToken: (d: Date) => () => {
         dispatch<any>(createSharingToken(d));
@@ -73,4 +78,3 @@ export const SharingDialog = compose(
     connectSharingDialogProgress,
     connect(mapStateToProps, mapDispatchToProps)
 )(SharingDialogComponent);
-
index 6c0b8d81a3c94f00c8bc223569666e46f6063812..871ea503ecee45b0281eeb0bdd81d12138130c9d 100644 (file)
@@ -4,19 +4,63 @@
 
 import React from 'react';
 import { Field, WrappedFieldProps, FieldArray, WrappedFieldArrayProps } from 'redux-form';
-import { Grid, FormControl, InputLabel } from '@material-ui/core';
+import { Grid, FormControl, InputLabel, Tooltip, IconButton, StyleRulesCallback } from '@material-ui/core';
 import { PermissionSelect, parsePermissionLevel, formatPermissionLevel } from './permission-select';
 import { ParticipantSelect, Participant } from './participant-select';
+import { AddIcon } from 'components/icon/icon';
+import { WithStyles } from '@material-ui/core/styles';
+import withStyles from '@material-ui/core/styles/withStyles';
+import { ArvadosTheme } from 'common/custom-theme';
 
-export default () =>
-    <Grid container spacing={8}>
-        <Grid data-cy="invite-people-field" item xs={8}>
-            <InvitedPeopleField />
-        </Grid>
-        <Grid data-cy="permission-select-field" item xs={4}>
-            <PermissionSelectField />
-        </Grid>
-    </Grid>;
+type SharingStyles = 'root' | 'addButtonRoot' | 'addButtonPrimary' | 'addButtonDisabled';
+
+const styles: StyleRulesCallback<SharingStyles> = (theme: ArvadosTheme) => ({
+    root: {
+        padding: `${theme.spacing.unit}px 0`,
+    },
+    addButtonRoot: {
+        height: "36px",
+        width: "36px",
+        marginRight: "6px",
+        marginLeft: "6px",
+        marginTop: "12px",
+    },
+    addButtonPrimary: {
+        color: theme.palette.primary.contrastText,
+        background: theme.palette.primary.main,
+        "&:hover": {
+            background: theme.palette.primary.dark,
+        }
+    },
+    addButtonDisabled: {
+        background: 'none',
+    }
+});
+
+const SharingInvitationFormComponent = (props: { onSave: () => void, saveEnabled: boolean }) => <StyledSharingInvitationFormComponent onSave={props.onSave} saveEnabled={props.saveEnabled} />
+
+export default SharingInvitationFormComponent;
+
+const StyledSharingInvitationFormComponent = withStyles(styles)(
+    ({ onSave, saveEnabled, classes }: { onSave: () => void, saveEnabled: boolean } & WithStyles<SharingStyles>) =>
+        <Grid container spacing={8} wrap='nowrap' className={classes.root} >
+            <Grid data-cy="invite-people-field" item xs={8}>
+                <InvitedPeopleField />
+            </Grid>
+            <Grid data-cy="permission-select-field" item xs={4} container wrap='nowrap'>
+                <PermissionSelectField />
+                <IconButton onClick={onSave} disabled={!saveEnabled} color="primary" classes={{
+                    root: classes.addButtonRoot,
+                    colorPrimary: classes.addButtonPrimary,
+                    disabled: classes.addButtonDisabled
+                }}
+                    data-cy='add-invited-people'>
+                    <Tooltip title="Add authorization">
+                        <AddIcon />
+                    </Tooltip>
+                </IconButton>
+            </Grid>
+        </Grid >);
 
 const InvitedPeopleField = () =>
     <FieldArray
index e82edf7c6b85a02fb2ae23054e7bbc6b159188ed..33154732256233fa7d81838879567027894f0bab 100644 (file)
@@ -3,18 +3,25 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { reduxForm } from 'redux-form';
-import { connect } from 'react-redux';
-import { compose } from 'redux';
 import SharingInvitationFormComponent from './sharing-invitation-form-component';
 import { SHARING_INVITATION_FORM_NAME } from 'store/sharing-dialog/sharing-dialog-types';
 import { PermissionLevel } from 'models/permission';
 
-export const SharingInvitationForm = compose(
-    connect(() => ({
+interface InvitationFormData {
+    permissions: PermissionLevel;
+    invitedPeople: string[];
+}
+
+interface SaveProps {
+    onSave: () => void;
+    saveEnabled: boolean;
+}
+
+export const SharingInvitationForm =
+    reduxForm<InvitationFormData, SaveProps>({
+        form: SHARING_INVITATION_FORM_NAME,
         initialValues: {
             permissions: PermissionLevel.CAN_READ,
             invitedPeople: [],
         }
-    })),
-    reduxForm({ form: SHARING_INVITATION_FORM_NAME })
-)(SharingInvitationFormComponent);
\ No newline at end of file
+    })(SharingInvitationFormComponent);
index d4d1095292748a629e502064da862bc12c6bd4d3..b7ac8ced7612c1a234f7c64f5e61c037bb516660 100644 (file)
@@ -9,21 +9,37 @@ import {
     WrappedFieldProps,
     WrappedFieldArrayProps,
     FieldArray,
-    FieldArrayFieldsProps
+    FieldArrayFieldsProps,
+    InjectedFormProps
 } from 'redux-form';
 import { PermissionSelect, formatPermissionLevel, parsePermissionLevel } from './permission-select';
 import { WithStyles } from '@material-ui/core/styles';
 import withStyles from '@material-ui/core/styles/withStyles';
 import { CloseIcon } from 'components/icon/icon';
+import { ArvadosTheme } from 'common/custom-theme';
 
+export interface SaveProps {
+    onSave: () => void;
+}
 
-export default () =>
-    <FieldArray name='permissions' component={SharingManagementFieldArray as any} />;
+const headerStyles: StyleRulesCallback<'heading'> = (theme: ArvadosTheme) => ({
+    heading: {
+        fontSize: '1.25rem',
+    }
+});
+
+export const SharingManagementFormComponent = withStyles(headerStyles)(
+    ({ classes, onSave }: WithStyles<'heading'> & SaveProps & InjectedFormProps<{}, SaveProps>) =>
+        <>
+            <Typography className={classes.heading}>People with access</Typography>
+            <FieldArray<{ onSave: () => void }> name='permissions' component={SharingManagementFieldArray as any} props={{ onSave }} />
+        </>);
 
-const SharingManagementFieldArray = ({ fields }: WrappedFieldArrayProps<{ email: string }>) =>
-    <div>{ fields.map((field, index, fields) =>
-        <PermissionManagementRow key={field} {...{ field, index, fields }} />) }
-        <Divider />
+export default SharingManagementFormComponent;
+
+const SharingManagementFieldArray = ({ fields, onSave }: { onSave: () => void } & WrappedFieldArrayProps<{ email: string }>) =>
+    <div>{fields.map((field, index, fields) =>
+        <PermissionManagementRow key={field} {...{ field, index, fields }} onSave={onSave} />)}
     </div>;
 
 const permissionManagementRowStyles: StyleRulesCallback<'root'> = theme => ({
@@ -31,10 +47,10 @@ const permissionManagementRowStyles: StyleRulesCallback<'root'> = theme => ({
         padding: `${theme.spacing.unit}px 0`,
     }
 });
+
 const PermissionManagementRow = withStyles(permissionManagementRowStyles)(
-    ({ field, index, fields, classes }: { field: string, index: number, fields: FieldArrayFieldsProps<{ email: string }> } & WithStyles<'root'>) =>
+    ({ field, index, fields, classes, onSave }: { field: string, index: number, fields: FieldArrayFieldsProps<{ email: string }>, onSave: () => void; } & WithStyles<'root'>) =>
         <>
-            <Divider />
             <Grid container alignItems='center' spacing={8} wrap='nowrap' className={classes.root}>
                 <Grid item xs={8}>
                     <Typography noWrap variant='subtitle1'>{fields.get(index).email}</Typography>
@@ -44,12 +60,15 @@ const PermissionManagementRow = withStyles(permissionManagementRowStyles)(
                         name={`${field}.permissions` as string}
                         component={PermissionSelectComponent}
                         format={formatPermissionLevel}
-                        parse={parsePermissionLevel} />
-                    <IconButton onClick={() => fields.remove(index)}>
+                        parse={parsePermissionLevel}
+                        onChange={onSave}
+                    />
+                    <IconButton onClick={() => { fields.remove(index); onSave(); }}>
                         <CloseIcon />
                     </IconButton>
                 </Grid>
             </Grid>
+            <Divider />
         </>
 );
 
index 7ecff329b8b6e95eb058ae81ee315e620b0b36df..662199bb0747ea071f58abf3cc505a1acfbb78a6 100644 (file)
@@ -3,9 +3,9 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { reduxForm } from 'redux-form';
-import SharingManagementFormComponent from './sharing-management-form-component';
+import { SharingManagementFormComponent, SaveProps } from './sharing-management-form-component';
 import { SHARING_MANAGEMENT_FORM_NAME } from 'store/sharing-dialog/sharing-dialog-types';
 
-export const SharingManagementForm = reduxForm(
+export const SharingManagementForm = reduxForm<{}, SaveProps>(
     { form: SHARING_MANAGEMENT_FORM_NAME }
 )(SharingManagementFormComponent);
index 7ec71161ab303ed41cc7f4c4e34c43a7ae6b7577..5fc3f4e38ecce5a0ac7adb24568ea16b5e956e2e 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import React from 'react';
-import { Grid, StyleRulesCallback, Divider, Typography } from '@material-ui/core';
+import { Grid, StyleRulesCallback, Typography } from '@material-ui/core';
 import { Field, WrappedFieldProps } from 'redux-form';
 import { WithStyles } from '@material-ui/core/styles';
 import withStyles from '@material-ui/core/styles/withStyles';
@@ -13,13 +13,22 @@ import { VisibilityLevel } from 'store/sharing-dialog/sharing-dialog-types';
 const sharingPublicAccessStyles: StyleRulesCallback<'root'> = theme => ({
     root: {
         padding: `${theme.spacing.unit * 2}px 0`,
+    },
+    heading: {
+        fontSize: '1.25rem',
     }
 });
 
+interface AccessProps {
+    visibility: VisibilityLevel;
+    includePublic: boolean;
+    onSave: () => void;
+}
+
 const SharingPublicAccessForm = withStyles(sharingPublicAccessStyles)(
-    ({ classes, visibility }: WithStyles<'root'> & { visibility: VisibilityLevel }) =>
+    ({ classes, visibility, includePublic, onSave }: WithStyles<'root' | 'heading'> & AccessProps) =>
         <>
-            <Divider />
+            <Typography className={classes.heading}>General access</Typography>
             <Grid container alignItems='center' spacing={8} className={classes.root}>
                 <Grid item xs={8}>
                     <Typography variant='subtitle1'>
@@ -27,7 +36,7 @@ const SharingPublicAccessForm = withStyles(sharingPublicAccessStyles)(
                     </Typography>
                 </Grid>
                 <Grid item xs={4} container wrap='nowrap'>
-                    <Field name='visibility' component={VisibilityLevelSelectComponent} />
+                    <Field<{ includePublic: boolean }> name='visibility' component={VisibilityLevelSelectComponent} includePublic={includePublic} onChange={onSave} />
                 </Grid>
             </Grid>
         </>
@@ -36,19 +45,22 @@ const SharingPublicAccessForm = withStyles(sharingPublicAccessStyles)(
 const renderVisibilityInfo = (visibility: VisibilityLevel) => {
     switch (visibility) {
         case VisibilityLevel.PUBLIC:
-            return 'Anyone can access';
+            return 'Shared with anyone on the Internet';
+        case VisibilityLevel.ALL_USERS:
+            return 'Shared with all users on this cluster';
         case VisibilityLevel.SHARED:
-            return 'Specific people can access';
+            return 'Shared with specific people';
         case VisibilityLevel.PRIVATE:
-            return 'Only you can access';
+            return 'Not shared';
         default:
             return '';
     }
 };
 
-export default ({ visibility }: { visibility: VisibilityLevel }) =>
-    <SharingPublicAccessForm {...{ visibility }} />;
+const SharingPublicAccessFormComponent = ({ visibility, includePublic, onSave }: AccessProps) =>
+    <SharingPublicAccessForm {...{ visibility, includePublic, onSave }} />;
 
-const VisibilityLevelSelectComponent = ({ input }: WrappedFieldProps) =>
-    <VisibilityLevelSelect fullWidth disableUnderline {...input} />;
+export default SharingPublicAccessFormComponent;
 
+const VisibilityLevelSelectComponent = ({ input, includePublic }: { includePublic: boolean } & WrappedFieldProps) =>
+    <VisibilityLevelSelect fullWidth disableUnderline includePublic={includePublic} {...input} />;
index 8ee1d94dbe8edb7bdd609ffcfc81655ce00f3fb3..eb337c38ad19f8aae3c3ed58430b2326ed3940f4 100644 (file)
@@ -10,15 +10,19 @@ import { SHARING_PUBLIC_ACCESS_FORM_NAME, VisibilityLevel } from 'store/sharing-
 import { RootState } from 'store/store';
 import { getSharingPublicAccessFormData } from '../../store/sharing-dialog/sharing-dialog-types';
 
+interface SaveProps {
+    onSave: () => void;
+}
+
 export const SharingPublicAccessForm = compose(
-    reduxForm(
+    reduxForm<{}, SaveProps>(
         { form: SHARING_PUBLIC_ACCESS_FORM_NAME }
     ),
     connect(
         (state: RootState) => {
             const { visibility } = getSharingPublicAccessFormData(state) || { visibility: VisibilityLevel.PRIVATE };
-            return { visibility };
+            const includePublic = state.auth.config.clusterConfig.Users.AnonymousUserToken.length > 0;
+            return { visibility, includePublic };
         }
     )
 )(SharingPublicAccessFormComponent);
-
index c9cbc0df3d5e2f20ad0818a6ee8281e91fb868f8..5facb2e3812e61b43394cb220c299741e64c6451 100644 (file)
@@ -14,7 +14,7 @@ import {
     withStyles
 } from '@material-ui/core';
 import { ApiClientAuthorization } from 'models/api-client-authorization';
-import { CopyIcon, RemoveIcon } from 'components/icon/icon';
+import { CopyIcon, CloseIcon } from 'components/icon/icon';
 import CopyToClipboard from 'react-copy-to-clipboard';
 import { ArvadosTheme } from 'common/custom-theme';
 import moment from 'moment';
@@ -58,38 +58,38 @@ export interface SharingURLsComponentActionProps {
 export type SharingURLsComponentProps = SharingURLsComponentDataProps & SharingURLsComponentActionProps;
 
 export const SharingURLsComponent = withStyles(styles)((props: SharingURLsComponentProps & WithStyles<CssRules>) => <Grid container direction='column' spacing={24} className={props.classes.sharingUrlList}>
-    { props.sharingTokens.length > 0
-    ? props.sharingTokens
-    .sort((a, b) => (new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime()))
-    .map(token => {
-        const url = props.sharingURLsPrefix.includes('*')
-        ? `${props.sharingURLsPrefix.replace('*', props.collectionUuid)}/t=${token.apiToken}/_/`
-        : `${props.sharingURLsPrefix}/c=${props.collectionUuid}/t=${token.apiToken}/_/`;
-        const expDate = new Date(token.expiresAt);
-        const urlLabel = !!token.expiresAt
-        ? `Token ${token.apiToken.slice(0, 8)}... expiring at: ${expDate.toLocaleString()} (${moment(expDate).fromNow()})`
-        : `Token ${token.apiToken.slice(0, 8)}... with no expiration date`;
+    {props.sharingTokens.length > 0
+        ? props.sharingTokens
+            .sort((a, b) => (new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime()))
+            .map(token => {
+                const url = props.sharingURLsPrefix.includes('*')
+                    ? `${props.sharingURLsPrefix.replace('*', props.collectionUuid)}/t=${token.apiToken}/_/`
+                    : `${props.sharingURLsPrefix}/c=${props.collectionUuid}/t=${token.apiToken}/_/`;
+                const expDate = new Date(token.expiresAt);
+                const urlLabel = !!token.expiresAt
+                    ? `Token ${token.apiToken.slice(0, 8)}... expiring at: ${expDate.toLocaleString()} (${moment(expDate).fromNow()})`
+                    : `Token ${token.apiToken.slice(0, 8)}... with no expiration date`;
 
-        return <Grid container alignItems='center' key={token.uuid}  className={props.classes.sharingUrlRow}>
-            <Grid item>
-            <Link className={props.classes.sharingUrlText} href={url} target='_blank'>
-                {urlLabel}
-            </Link>
-            </Grid>
-            <Grid item xs />
-            <Grid item>
-            <span className={props.classes.sharingUrlButton}><Tooltip title='Copy to clipboard'>
-                <CopyToClipboard text={url} onCopy={() => props.onCopy('Sharing URL copied')}>
-                    <CopyIcon />
-                </CopyToClipboard>
-            </Tooltip></span>
-            <span data-cy='remove-url-btn' className={props.classes.sharingUrlButton}><Tooltip title='Remove'>
-                <IconButton onClick={() => props.onDeleteSharingToken(token.uuid)}>
-                    <RemoveIcon />
-                </IconButton>
-            </Tooltip></span>
-            </Grid>
-        </Grid>
-    })
-    : <Grid item><Typography>No sharing URLs</Typography></Grid> }
+                return <Grid container alignItems='center' key={token.uuid} className={props.classes.sharingUrlRow}>
+                    <Grid item>
+                        <Link className={props.classes.sharingUrlText} href={url} target='_blank'>
+                            {urlLabel}
+                        </Link>
+                    </Grid>
+                    <Grid item xs />
+                    <Grid item>
+                        <span className={props.classes.sharingUrlButton}><Tooltip title='Copy to clipboard'>
+                            <CopyToClipboard text={url} onCopy={() => props.onCopy('Sharing URL copied')}>
+                                <CopyIcon />
+                            </CopyToClipboard>
+                        </Tooltip></span>
+                        <span data-cy='remove-url-btn' className={props.classes.sharingUrlButton}><Tooltip title='Remove'>
+                            <IconButton onClick={() => props.onDeleteSharingToken(token.uuid)}>
+                                <CloseIcon />
+                            </IconButton>
+                        </Tooltip></span>
+                    </Grid>
+                </Grid>
+            })
+        : <Grid item><Typography>No sharing URLs</Typography></Grid>}
 </Grid>);
index 434b8f51a3d047f06b1e0ac0254f27e99245e35f..4f12e3eacd203b6ece64a848abcd0d36a8da6746 100644 (file)
@@ -13,21 +13,24 @@ import { SelectItem } from './select-item';
 import { VisibilityLevel } from 'store/sharing-dialog/sharing-dialog-types';
 
 
-type VisibilityLevelSelectClasses = 'value';
+type VisibilityLevelSelectClasses = 'root';
 
 const VisibilityLevelSelectStyles: StyleRulesCallback<VisibilityLevelSelectClasses> = theme => ({
-    value: {
+    root: {
         marginLeft: theme.spacing.unit,
     }
 });
 export const VisibilityLevelSelect = withStyles(VisibilityLevelSelectStyles)(
-    ({ classes, ...props }: SelectProps & WithStyles<VisibilityLevelSelectClasses>) =>
+    ({ classes, includePublic, ...props }: { includePublic: boolean } & SelectProps & WithStyles<VisibilityLevelSelectClasses>) =>
         <Select
             {...props}
             renderValue={renderPermissionItem}
             inputProps={{ classes }}>
-            <MenuItem value={VisibilityLevel.PUBLIC}>
+            {includePublic && <MenuItem value={VisibilityLevel.PUBLIC}>
                 {renderPermissionItem(VisibilityLevel.PUBLIC)}
+            </MenuItem>}
+            <MenuItem value={VisibilityLevel.ALL_USERS}>
+                {renderPermissionItem(VisibilityLevel.ALL_USERS)}
             </MenuItem>
             <MenuItem value={VisibilityLevel.SHARED}>
                 {renderPermissionItem(VisibilityLevel.SHARED)}
@@ -44,6 +47,8 @@ const getIcon = (value: string) => {
     switch (value) {
         case VisibilityLevel.PUBLIC:
             return Public;
+        case VisibilityLevel.ALL_USERS:
+            return Public;
         case VisibilityLevel.SHARED:
             return People;
         case VisibilityLevel.PRIVATE:
@@ -52,4 +57,3 @@ const getIcon = (value: string) => {
             return Lock;
     }
 };
-
index 7874441588d51344247950f50d043de1c81e88fb..6acbb1611883e2323b51d51029539a5b955d613c 100644 (file)
@@ -89,8 +89,7 @@ export const SidePanelButton = withStyles(styles)(
                     enabled = true;
                 } else if (matchProjectRoute(location ? location.pathname : '')) {
                     const currentProject = getResource<ProjectResource>(currentItemId)(resources);
-                    if (currentProject && currentProject.writableBy &&
-                        currentProject.writableBy.indexOf(currentUserUUID || '') >= 0 &&
+                    if (currentProject && currentProject.canWrite &&
                         !currentProject.frozenByUuid &&
                         !isProjectTrashed(currentProject, resources) &&
                         currentProject.groupClass !== GroupClass.FILTER) {
index 9984886b8ac0369d5dbbf296caedc58d2afcbde4..47d34216cc58959678d7e4a7463e98c92348cf69 100644 (file)
@@ -20,22 +20,23 @@ const SidePanelToggle = (props: collapseButtonProps) => {
         root: {
             width: `${COLLAPSE_ICON_SIZE}px`,
             height: `${COLLAPSE_ICON_SIZE}px`,
-            marginTop: '0.4rem'
+            marginTop: '0.4rem',
+            marginLeft: '0.7rem',
+            paddingTop: '1rem',
+            paddingRight: '1rem'
         },
         icon: {
-            height: '1.5rem',
-            width: '3rem',
-            opacity: '0.6',
+            opacity: '0.5',
         },
     }
 
     return <Tooltip disableFocusListener title="Toggle Side Panel">
-        <IconButton style={collapseButtonIconStyles.root} onClick={() => { props.toggleSidePanel(props.isCollapsed) }}>
+        <IconButton data-cy="side-panel-toggle" style={collapseButtonIconStyles.root} onClick={() => { props.toggleSidePanel(props.isCollapsed) }}>
             <div>
                 {props.isCollapsed ?
-                    <img style={{ ...collapseButtonIconStyles.icon, transform: "rotate(180deg)" }} src='/collapseLHS-New.svg#svgView(preserveAspectRatio(none))' alt='expand button' />
+                    <img style={{...collapseButtonIconStyles.icon, marginLeft:'0.25rem'}} src='/mui-start-icon.svg' alt='an arrow pointing right'/>
                     :
-                    <img style={{ ...collapseButtonIconStyles.icon, }} src='/collapseLHS-New.svg#svgView(preserveAspectRatio(none))' alt='collapse button' />}
+                    <img style={{ ...collapseButtonIconStyles.icon, transform: "rotate(180deg)"}} src='/mui-start-icon.svg' alt='an arrow pointing right'/>}
             </div>
         </IconButton>
     </Tooltip>
index 6814a31eb1b8a5b0459ed0609b0dcc9792e4d0d0..19ab3184af88e2f042afb7ac1b02c98bcbfa877e 100644 (file)
@@ -9,7 +9,7 @@ import { TreePicker, TreePickerProps } from "../tree-picker/tree-picker";
 import { TreeItem } from "components/tree/tree";
 import { ProjectResource } from "models/project";
 import { ListItemTextIcon } from "components/list-item-text-icon/list-item-text-icon";
-import { ProcessIcon, ProjectIcon, FilterGroupIcon, FavoriteIcon, ProjectsIcon, ShareMeIcon, TrashIcon, PublicFavoriteIcon, GroupsIcon } from 'components/icon/icon';
+import { ProcessIcon, ProjectIcon, FilterGroupIcon, FavoriteIcon, ProjectsIcon, ShareMeIcon, TrashIcon, PublicFavoriteIcon, GroupsIcon, TerminalIcon } from 'components/icon/icon';
 import { activateSidePanelTreeItem, toggleSidePanelTreeItemCollapse, SIDE_PANEL_TREE, SidePanelTreeCategory } from 'store/side-panel-tree/side-panel-tree-actions';
 import { openSidePanelContextMenu } from 'store/context-menu/context-menu-actions';
 import { noop } from 'lodash';
@@ -64,7 +64,7 @@ const getProjectPickerIcon = (item: TreeItem<ProjectResource | string>) =>
         ? getSidePanelIcon(item.data)
         : (item.data && item.data.groupClass === GroupClass.FILTER)
             ? FilterGroupIcon
-            : ProjectIcon;
+            : ProjectsIcon;
 
 export const getSidePanelIcon = (category: string) => {
     switch (category) {
@@ -82,6 +82,8 @@ export const getSidePanelIcon = (category: string) => {
             return ProcessIcon;
         case SidePanelTreeCategory.GROUPS:
             return GroupsIcon;
+        case SidePanelTreeCategory.SHELL_ACCESS:
+            return TerminalIcon
         default:
             return ProjectIcon;
     }
diff --git a/src/views-components/side-panel/side-panel-collapsed.tsx b/src/views-components/side-panel/side-panel-collapsed.tsx
new file mode 100644 (file)
index 0000000..d2f5cfe
--- /dev/null
@@ -0,0 +1,159 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { ReactElement } from 'react'
+import { connect } from 'react-redux'
+import { ProjectsIcon, ProcessIcon, FavoriteIcon, ShareMeIcon, TrashIcon, PublicFavoriteIcon, GroupsIcon } from 'components/icon/icon'
+import { TerminalIcon } from 'components/icon/icon'
+import { IconButton, List, ListItem, Tooltip } from '@material-ui/core'
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles'
+import { ArvadosTheme } from 'common/custom-theme'
+import { navigateTo } from 'store/navigation/navigation-action'
+import { RootState } from 'store/store'
+import { Dispatch } from 'redux'
+import {
+    navigateToSharedWithMe,
+    navigateToPublicFavorites,
+    navigateToFavorites,
+    navigateToGroups,
+    navigateToAllProcesses,
+    navigateToTrash,
+} from 'store/navigation/navigation-action'
+import { navigateToUserVirtualMachines } from 'store/navigation/navigation-action'
+import { RouterAction } from 'react-router-redux'
+import { User } from 'models/user'
+
+type CssRules = 'button' | 'unselected' | 'selected'
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    button: {
+        width: '40px',
+        height: '40px',
+        paddingLeft: '-2rem',
+        marginLeft: '-0.6rem',
+        marginBottom: '-1rem'
+    },
+    unselected: {
+        color: theme.customs.colors.grey600,
+    },
+    selected: {
+        color: theme.palette.primary.main,
+    },
+})
+
+enum SidePanelCollapsedCategory {
+    PROJECTS = 'Home Projects',
+    FAVORITES = 'My Favorites',
+    PUBLIC_FAVORITES = 'Public Favorites',
+    SHARED_WITH_ME = 'Shared with me',
+    ALL_PROCESSES = 'All Processes',
+    SHELL_ACCESS = 'Shell Access',
+    GROUPS = 'Groups',
+    TRASH = 'Trash',
+}
+
+type TCollapsedCategory = {
+    name: SidePanelCollapsedCategory
+    icon: ReactElement
+    navTarget: RouterAction | ''
+}
+
+const sidePanelCollapsedCategories: TCollapsedCategory[] = [
+    {
+        name: SidePanelCollapsedCategory.PROJECTS,
+        icon: <ProjectsIcon />,
+        navTarget: '',
+    },
+    {
+        name: SidePanelCollapsedCategory.FAVORITES,
+        icon: <FavoriteIcon />,
+        navTarget: navigateToFavorites,
+    },
+    {
+        name: SidePanelCollapsedCategory.PUBLIC_FAVORITES,
+        icon: <PublicFavoriteIcon />,
+        navTarget: navigateToPublicFavorites,
+    },
+    {
+        name: SidePanelCollapsedCategory.SHARED_WITH_ME,
+        icon: <ShareMeIcon />,
+        navTarget: navigateToSharedWithMe,
+    },
+    {
+        name: SidePanelCollapsedCategory.ALL_PROCESSES,
+        icon: <ProcessIcon />,
+        navTarget: navigateToAllProcesses,
+    },
+    {
+        name: SidePanelCollapsedCategory.SHELL_ACCESS,
+        icon: <TerminalIcon />,
+        navTarget: navigateToUserVirtualMachines,
+    },
+    {
+        name: SidePanelCollapsedCategory.GROUPS,
+        icon: <GroupsIcon style={{marginLeft: '2px', scale: '85%'}}/>,
+        navTarget: navigateToGroups,
+    },
+    {
+        name: SidePanelCollapsedCategory.TRASH,
+        icon: <TrashIcon />,
+        navTarget: navigateToTrash,
+    },
+]
+
+type SidePanelCollapsedProps = {
+    user: User;
+    selectedPath: string;
+    navToHome: (uuid: string) => void;
+    navTo: (navTarget: RouterAction | '') => void;
+};
+
+const mapStateToProps = ({auth, properties }: RootState) => {
+        return {
+            user: auth.user,
+            selectedPath: properties.breadcrumbs
+                ? properties.breadcrumbs[0].label !== 'Virtual Machines'
+                ? properties.breadcrumbs[0].label
+                : SidePanelCollapsedCategory.SHELL_ACCESS
+                : SidePanelCollapsedCategory.PROJECTS,
+        }
+}
+
+const mapDispatchToProps = (dispatch: Dispatch) => {
+    return {
+        navToHome: (navTarget) => dispatch<any>(navigateTo(navTarget)),
+        navTo: (navTarget) => dispatch<any>(navTarget),
+    }
+}
+
+export const SidePanelCollapsed = withStyles(styles)(
+    connect(mapStateToProps, mapDispatchToProps)(({ classes, user, selectedPath, navToHome, navTo }: WithStyles & SidePanelCollapsedProps) => {
+
+        const handleClick = (cat: TCollapsedCategory) => {
+            if (cat.name === SidePanelCollapsedCategory.PROJECTS) navToHome(user.uuid)
+            else navTo(cat.navTarget)
+        }
+
+        const { button, unselected, selected } = classes
+        return (
+            <List data-cy='side-panel-collapsed'>
+                {sidePanelCollapsedCategories.map((cat) => (
+                    <ListItem
+                        key={cat.name}
+                        data-cy={`collapsed-${cat.name.toLowerCase().replace(/\s+/g, '-')}`}
+                        onClick={() => handleClick(cat)}
+                        >
+                        <Tooltip
+                            className={selectedPath === cat.name ? selected : unselected}
+                            title={cat.name}
+                            disableFocusListener
+                            >
+                            <IconButton className={button}>{cat.icon}</IconButton>
+                        </Tooltip>
+                    </ListItem>
+                ))}
+            </List>
+        );
+    })
+)
index 4953022d661bd635ff34fa72e667c30b47a6d077..18aed873aa9fc018b36585ea09ea2846122fd28a 100644 (file)
@@ -13,6 +13,7 @@ import { Grid } from '@material-ui/core';
 import { SidePanelButton } from 'views-components/side-panel-button/side-panel-button';
 import { RootState } from 'store/store';
 import SidePanelToggle from 'views-components/side-panel-toggle/side-panel-toggle';
+import { SidePanelCollapsed } from './side-panel-collapsed';
 
 const DRAWER_WIDTH = 240;
 
@@ -47,7 +48,12 @@ export const SidePanel = withStyles(styles)(
     connect(mapStateToProps, mapDispatchToProps)(
         ({ classes, ...props }: WithStyles<CssRules> & SidePanelTreeProps & { currentRoute: string }) =>
             <Grid item xs>
-                {props.isCollapsed ? <SidePanelToggle /> :
+                {props.isCollapsed ? 
+                <>
+                    <SidePanelToggle />
+                    <SidePanelCollapsed />
+                </>
+                :
                 <>
                     <Grid className={classes.topButtonContainer}>
                         <SidePanelButton key={props.currentRoute} />
index 95a86420795b36d22142b4277cd0d4387077ffd3..cba9af636e8eaaf22a1aa6019af47775f68e6fb5 100644 (file)
@@ -46,7 +46,6 @@ const styles = (theme: ArvadosTheme) => ({
 });
 
 export const GroupArrayInput = ({name, input, setPartialGroupInput, hasPartialGroupInput}: GroupArrayInputProps & GroupArrayDataProps) => {
-  console.log(hasPartialGroupInput);
   return <GroupArrayField
       name={name}
       commandInput={input}
index 4914da6233180bc04ac7b32405724d8057e5b4e5..0ccb0502cb28faabe774b4f7b4aba64c2a7b1313 100644 (file)
@@ -2,49 +2,50 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
+import React from "react";
+import { StyleRulesCallback, WithStyles, withStyles } from "@material-ui/core";
 import { DataExplorer } from "views-components/data-explorer/data-explorer";
-import { connect, DispatchProp } from 'react-redux';
-import { DataColumns } from 'components/data-table/data-table';
-import { RouteComponentProps } from 'react-router';
-import { DataTableFilterItem } from 'components/data-table-filters/data-table-filters';
-import { SortDirection } from 'components/data-table/data-column';
-import { ResourceKind } from 'models/resource';
-import { ArvadosTheme } from 'common/custom-theme';
-import { ALL_PROCESSES_PANEL_ID } from 'store/all-processes-panel/all-processes-panel-action';
+import { connect, DispatchProp } from "react-redux";
+import { DataColumns } from "components/data-table/data-table";
+import { RouteComponentProps } from "react-router";
+import { DataTableFilterItem } from "components/data-table-filters/data-table-filters";
+import { SortDirection } from "components/data-table/data-column";
+import { ResourceKind } from "models/resource";
+import { ArvadosTheme } from "common/custom-theme";
+import { ALL_PROCESSES_PANEL_ID } from "store/all-processes-panel/all-processes-panel-action";
 import {
     ProcessStatus,
     ResourceName,
     ResourceOwnerWithName,
     ResourceType,
     ContainerRunTime,
-    ResourceCreatedAtDate
-} from 'views-components/data-explorer/renderers';
-import { ProcessIcon } from 'components/icon/icon';
-import { openProcessContextMenu } from 'store/context-menu/context-menu-actions';
-import { loadDetailsPanel } from 'store/details-panel/details-panel-action';
-import { navigateTo } from 'store/navigation/navigation-action';
+    ResourceCreatedAtDate,
+} from "views-components/data-explorer/renderers";
+import { ProcessIcon } from "components/icon/icon";
+import { openProcessContextMenu } from "store/context-menu/context-menu-actions";
+import { loadDetailsPanel } from "store/details-panel/details-panel-action";
+import { navigateTo } from "store/navigation/navigation-action";
 import { ContainerRequestResource, ContainerRequestState } from "models/container-request";
-import { RootState } from 'store/store';
-import { createTree } from 'models/tree';
-import { getInitialProcessStatusFilters, getInitialProcessTypeFilters } from 'store/resource-type-filters/resource-type-filters';
-import { getProcess } from 'store/processes/process';
-import { ResourcesState } from 'store/resources/resources';
+import { RootState } from "store/store";
+import { createTree } from "models/tree";
+import { getInitialProcessStatusFilters, getInitialProcessTypeFilters } from "store/resource-type-filters/resource-type-filters";
+import { getProcess } from "store/processes/process";
+import { ResourcesState } from "store/resources/resources";
+import { toggleOne } from "store/multiselect/multiselect-actions";
 
 type CssRules = "toolbar" | "button" | "root";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     toolbar: {
         paddingBottom: theme.spacing.unit * 3,
-        textAlign: "right"
+        textAlign: "right",
     },
     button: {
-        marginLeft: theme.spacing.unit
+        marginLeft: theme.spacing.unit,
     },
     root: {
-        width: '100%',
-    }
+        width: "100%",
+    },
 });
 
 export enum AllProcessesPanelColumnNames {
@@ -53,7 +54,7 @@ export enum AllProcessesPanelColumnNames {
     TYPE = "Type",
     OWNER = "Owner",
     CREATED_AT = "Created at",
-    RUNTIME = "Run Time"
+    RUNTIME = "Run Time",
 }
 
 export interface AllProcessesPanelFilter extends DataTableFilterItem {
@@ -65,9 +66,9 @@ export const allProcessesPanelColumns: DataColumns<string, ContainerRequestResou
         name: AllProcessesPanelColumnNames.NAME,
         selected: true,
         configurable: true,
-        sort: {direction: SortDirection.NONE, field: "name"},
+        sort: { direction: SortDirection.NONE, field: "name" },
         filters: createTree(),
-        render: uuid => <ResourceName uuid={uuid} />
+        render: uuid => <ResourceName uuid={uuid} />,
     },
     {
         name: AllProcessesPanelColumnNames.STATUS,
@@ -75,37 +76,37 @@ export const allProcessesPanelColumns: DataColumns<string, ContainerRequestResou
         configurable: true,
         mutuallyExclusiveFilters: true,
         filters: getInitialProcessStatusFilters(),
-        render: uuid => <ProcessStatus uuid={uuid} />
+        render: uuid => <ProcessStatus uuid={uuid} />,
     },
     {
         name: AllProcessesPanelColumnNames.TYPE,
         selected: true,
         configurable: true,
         filters: getInitialProcessTypeFilters(),
-        render: uuid => <ResourceType uuid={uuid} />
+        render: uuid => <ResourceType uuid={uuid} />,
     },
     {
         name: AllProcessesPanelColumnNames.OWNER,
         selected: true,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceOwnerWithName uuid={uuid} />
+        render: uuid => <ResourceOwnerWithName uuid={uuid} />,
     },
     {
         name: AllProcessesPanelColumnNames.CREATED_AT,
         selected: true,
         configurable: true,
-        sort: {direction: SortDirection.DESC, field: "createdAt"},
+        sort: { direction: SortDirection.DESC, field: "createdAt" },
         filters: createTree(),
-        render: uuid => <ResourceCreatedAtDate uuid={uuid} />
+        render: uuid => <ResourceCreatedAtDate uuid={uuid} />,
     },
     {
         name: AllProcessesPanelColumnNames.RUNTIME,
         selected: true,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ContainerRunTime uuid={uuid} />
-    }
+        render: uuid => <ContainerRunTime uuid={uuid} />,
+    },
 ];
 
 interface AllProcessesPanelDataProps {
@@ -117,12 +118,15 @@ interface AllProcessesPanelActionProps {
     onDialogOpen: (ownerUuid: string) => void;
     onItemDoubleClick: (item: string) => void;
 }
-const mapStateToProps = (state : RootState): AllProcessesPanelDataProps => ({
-    resources: state.resources
+const mapStateToProps = (state: RootState): AllProcessesPanelDataProps => ({
+    resources: state.resources,
 });
 
-type AllProcessesPanelProps = AllProcessesPanelDataProps & AllProcessesPanelActionProps & DispatchProp
-    & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
+type AllProcessesPanelProps = AllProcessesPanelDataProps &
+    AllProcessesPanelActionProps &
+    DispatchProp &
+    WithStyles<CssRules> &
+    RouteComponentProps<{ id: string }>;
 
 export const AllProcessesPanel = withStyles(styles)(
     connect(mapStateToProps)(
@@ -133,26 +137,31 @@ export const AllProcessesPanel = withStyles(styles)(
                     this.props.dispatch<any>(openProcessContextMenu(event, process));
                 }
                 this.props.dispatch<any>(loadDetailsPanel(resourceUuid));
-            }
+            };
 
             handleRowDoubleClick = (uuid: string) => {
                 this.props.dispatch<any>(navigateTo(uuid));
-            }
+            };
 
             handleRowClick = (uuid: string) => {
+                this.props.dispatch<any>(toggleOne(uuid))
                 this.props.dispatch<any>(loadDetailsPanel(uuid));
-            }
+            };
 
             render() {
-                return <div className={this.props.classes.root}><DataExplorer
-                    id={ALL_PROCESSES_PANEL_ID}
-                    onRowClick={this.handleRowClick}
-                    onRowDoubleClick={this.handleRowDoubleClick}
-                    onContextMenu={this.handleContextMenu}
-                    contextMenuColumn={true}
-                    defaultViewIcon={ProcessIcon}
-                    defaultViewMessages={['Processes list empty.']} />
-                </div>
+                return (
+                    <div className={this.props.classes.root}>
+                        <DataExplorer
+                            id={ALL_PROCESSES_PANEL_ID}
+                            onRowClick={this.handleRowClick}
+                            onRowDoubleClick={this.handleRowDoubleClick}
+                            onContextMenu={this.handleContextMenu}
+                            contextMenuColumn={true}
+                            defaultViewIcon={ProcessIcon}
+                            defaultViewMessages={["Processes list empty."]}
+                        />
+                    </div>
+                );
             }
         }
     )
index df1b1f1dfe8d4e8626b64ae5244899d982abff1d..28983457e6e7c5a5d2821bad5cc782ecd37ee216 100644 (file)
@@ -11,13 +11,13 @@ import {
     Grid,
     Tooltip,
     Typography,
-    Card
+    Card, CardHeader, CardContent
 } from '@material-ui/core';
 import { connect, DispatchProp } from "react-redux";
 import { RouteComponentProps } from 'react-router';
 import { ArvadosTheme } from 'common/custom-theme';
 import { RootState } from 'store/store';
-import { MoreOptionsIcon, CollectionIcon, ReadOnlyIcon, CollectionOldVersionIcon } from 'components/icon/icon';
+import { MoreVerticalIcon, CollectionIcon, ReadOnlyIcon, CollectionOldVersionIcon } from 'components/icon/icon';
 import { DetailsAttribute } from 'components/details-attribute/details-attribute';
 import { CollectionResource, getCollectionUrl } from 'models/collection';
 import { CollectionPanelFiles } from 'views-components/collection-panel-files/collection-panel-files';
@@ -37,6 +37,7 @@ import { Link as ButtonLink } from '@material-ui/core';
 import { ResourceWithName, ResponsiblePerson } from 'views-components/data-explorer/renderers';
 import { MPVContainer, MPVPanelContent, MPVPanelState } from 'components/multi-panel-view/multi-panel-view';
 import { resourceIsFrozen } from 'common/frozen-resources';
+import { NotFoundView } from 'views/not-found-panel/not-found-panel';
 
 type CssRules = 'root'
     | 'button'
@@ -51,7 +52,11 @@ type CssRules = 'root'
     | 'centeredLabel'
     | 'warningLabel'
     | 'collectionName'
-    | 'readOnlyIcon';
+    | 'readOnlyIcon'
+    | 'header'
+    | 'title'
+    | 'avatar'
+    | 'content';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
@@ -61,9 +66,6 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         cursor: 'pointer'
     },
     infoCard: {
-        paddingLeft: theme.spacing.unit * 2,
-        paddingRight: theme.spacing.unit * 2,
-        paddingBottom: theme.spacing.unit * 2,
     },
     propertiesCard: {
         padding: 0,
@@ -106,6 +108,26 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     readOnlyIcon: {
         marginLeft: theme.spacing.unit,
         fontSize: 'small',
+    },
+    header: {
+        paddingTop: theme.spacing.unit,
+        paddingBottom: theme.spacing.unit,
+    },
+    title: {
+        overflow: 'hidden',
+        paddingTop: theme.spacing.unit * 0.5,
+        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,
+        }
     }
 });
 
@@ -129,8 +151,8 @@ export const CollectionPanel = withStyles(styles)(connect(
                 isWritable = true;
             } else {
                 const itemOwner = getResource<GroupResource | UserResource>(item.ownerUuid)(state.resources);
-                if (itemOwner && itemOwner.writableBy) {
-                    isWritable = itemOwner.writableBy.indexOf(currentUserUUID || '') >= 0;
+                if (itemOwner) {
+                    isWritable = itemOwner.canWrite;
                 }
             }
         }
@@ -152,52 +174,54 @@ export const CollectionPanel = withStyles(styles)(connect(
                     ? <MPVContainer className={classes.root} spacing={8} direction="column" justify-content="flex-start" wrap="nowrap" panelStates={panelsData}>
                         <MPVPanelContent xs="auto" data-cy='collection-info-panel'>
                             <Card className={classes.infoCard}>
-                                <Grid container justify="space-between">
-                                    <Grid item xs={11}><span>
-                                        <IconButton onClick={this.openCollectionDetails}>
-                                            {isOldVersion
-                                                ? <CollectionOldVersionIcon className={classes.iconHeader} />
-                                                : <CollectionIcon className={classes.iconHeader} />}
-                                        </IconButton>
-                                        <IllegalNamingWarning name={item.name} />
+                                <CardHeader
+                                    className={classes.header}
+                                    classes={{
+                                        content: classes.title,
+                                        avatar: classes.avatar,
+                                    }}
+                                    avatar={<IconButton onClick={this.openCollectionDetails}>
+                                        {isOldVersion
+                                            ? <CollectionOldVersionIcon className={classes.iconHeader} />
+                                            : <CollectionIcon className={classes.iconHeader} />}
+                                    </IconButton>}
+                                    title={
                                         <span>
+                                            <IllegalNamingWarning name={item.name} />
                                             {item.name}
                                             {isWritable ||
                                                 <Tooltip title="Read-only">
                                                     <ReadOnlyIcon data-cy="read-only-icon" className={classes.readOnlyIcon} />
-                                                </Tooltip>
-                                            }
+                                                </Tooltip>}
                                         </span>
-                                    </span></Grid>
-                                    <Grid item xs={1} style={{ textAlign: "right" }}>
+                                    }
+                                    action={
                                         <Tooltip title="Actions" disableFocusListener>
                                             <IconButton
                                                 data-cy='collection-panel-options-btn'
                                                 aria-label="Actions"
                                                 onClick={this.handleContextMenu}>
-                                                <MoreOptionsIcon />
+                                                <MoreVerticalIcon />
                                             </IconButton>
                                         </Tooltip>
-                                    </Grid>
-                                </Grid>
-                                <Grid container justify="space-between">
-                                    <Grid item xs={12}>
-                                        <Typography variant="caption">
-                                            {item.description}
+                                    }
+                                />
+                                <CardContent className={classes.content}>
+                                    <Typography variant="caption">
+                                        {item.description}
+                                    </Typography>
+                                    <CollectionDetailsAttributes item={item} classes={classes} twoCol={true} showVersionBrowser={() => dispatch<any>(openDetailsPanel(item.uuid, 1))} />
+                                    {(item.properties.container_request || item.properties.containerRequest) &&
+                                        <span onClick={() => dispatch<any>(navigateToProcess(item.properties.container_request || item.properties.containerRequest))}>
+                                            <DetailsAttribute classLabel={classes.link} label='Link to process' />
+                                        </span>
+                                    }
+                                    {isOldVersion &&
+                                        <Typography className={classes.warningLabel} variant="caption">
+                                            This is an old version. Make a copy to make changes. Go to the <Link to={getCollectionUrl(item.currentVersionUuid)}>head version</Link> for sharing options.
                                         </Typography>
-                                        <CollectionDetailsAttributes item={item} classes={classes} twoCol={true} showVersionBrowser={() => dispatch<any>(openDetailsPanel(item.uuid, 1))} />
-                                        {(item.properties.container_request || item.properties.containerRequest) &&
-                                            <span onClick={() => dispatch<any>(navigateToProcess(item.properties.container_request || item.properties.containerRequest))}>
-                                                <DetailsAttribute classLabel={classes.link} label='Link to process' />
-                                            </span>
-                                        }
-                                        {isOldVersion &&
-                                            <Typography className={classes.warningLabel} variant="caption">
-                                                This is an old version. Make a copy to make changes. Go to the <Link to={getCollectionUrl(item.currentVersionUuid)}>head version</Link> for sharing options.
-                                            </Typography>
-                                        }
-                                    </Grid>
-                                </Grid>
+                                    }
+                                </CardContent>
                             </Card>
                         </MPVPanelContent>
                         <MPVPanelContent xs>
@@ -205,8 +229,12 @@ export const CollectionPanel = withStyles(styles)(connect(
                                 <CollectionPanelFiles isWritable={isWritable} />
                             </Card>
                         </MPVPanelContent>
-                    </MPVContainer>
-                    : null;
+                    </MPVContainer >
+                    : <NotFoundView
+                        icon={CollectionIcon}
+                        messages={["Collection not found"]}
+                    />
+                    ;
             }
 
             handleContextMenu = (event: React.MouseEvent<any>) => {
@@ -322,13 +350,13 @@ export const CollectionDetailsAttributes = (props: CollectionDetailsProps) => {
         </Grid>
         <Grid item xs={12} md={mdSize}>
             <DetailsAttribute classLabel={classes.label} classValue={classes.value}
-                label='Storage classes' value={item.storageClassesDesired.join(', ')} />
+                label='Storage classes' value={item.storageClassesDesired ? item.storageClassesDesired.join(', ') : ["default"]} />
         </Grid>
 
         {/*
             NOTE: The property list should be kept at the bottom, because it spans
             the entire available width, without regards of the twoCol prop.
-        */}
+          */}
         <Grid item xs={12} md={12}>
             <DetailsAttribute classLabel={classes.label} classValue={classes.value}
                 label='Properties' />
index 2392d6fda0380cc2e0108fb8101ae952b18bcf31..aa4f2c1a20a0637d1c3effbf839b6b1219e99974 100644 (file)
@@ -38,6 +38,7 @@ import { GroupClass, GroupResource } from 'models/group';
 import { getProperty } from 'store/properties/properties';
 import { PROJECT_PANEL_CURRENT_UUID } from 'store/project-panel/project-panel-action';
 import { CollectionResource } from 'models/collection';
+import { toggleOne } from 'store/multiselect/multiselect-actions';
 
 type CssRules = "toolbar" | "button" | "root";
 
@@ -171,6 +172,7 @@ export const FavoritePanel = withStyles(styles)(
             }
 
             handleRowClick = (uuid: string) => {
+                this.props.dispatch<any>(toggleOne(uuid))
                 this.props.dispatch<any>(loadDetailsPanel(uuid));
             }
 
index 798a7b67e34906d5cba6aee5255d38b90c670375..fdbc204ee78237dde9e1a4125139c8ac5f5c46c5 100644 (file)
@@ -136,8 +136,8 @@ const mapStateToProps = (state: RootState) => {
     return {
         resources: state.resources,
         groupCanManage: userUuid && !isBuiltinGroup(group?.uuid || '')
-                            ? group?.writableBy?.includes(userUuid)
-                            : false,
+            ? group?.canManage
+            : false,
     };
 };
 
@@ -158,7 +158,7 @@ export const GroupDetailsPanel = withStyles(styles)(connect(
 )(
     class GroupDetailsPanel extends React.Component<GroupDetailsPanelProps & WithStyles<CssRules>> {
         state = {
-          value: 0,
+            value: 0,
         };
 
         componentDidMount() {
@@ -169,56 +169,56 @@ export const GroupDetailsPanel = withStyles(styles)(connect(
             const { value } = this.state;
             return (
                 <Paper className={this.props.classes.root}>
-                  <Tabs value={value} onChange={this.handleChange} variant="fullWidth">
-                      <Tab data-cy="group-details-members-tab" label="MEMBERS" />
-                      <Tab data-cy="group-details-permissions-tab" label="PERMISSIONS" />
-                  </Tabs>
-                  <div className={this.props.classes.content}>
-                    {value === 0 &&
-                        <DataExplorer
-                            id={GROUP_DETAILS_MEMBERS_PANEL_ID}
-                            data-cy="group-members-data-explorer"
-                            onRowClick={noop}
-                            onRowDoubleClick={noop}
-                            onContextMenu={noop}
-                            contextMenuColumn={false}
-                            defaultViewIcon={UserPanelIcon}
-                            defaultViewMessages={[MEMBERS_DEFAULT_MESSAGE]}
-                            hideColumnSelector
-                            hideSearchInput
-                            actions={
+                    <Tabs value={value} onChange={this.handleChange} variant="fullWidth">
+                        <Tab data-cy="group-details-members-tab" label="MEMBERS" />
+                        <Tab data-cy="group-details-permissions-tab" label="PERMISSIONS" />
+                    </Tabs>
+                    <div className={this.props.classes.content}>
+                        {value === 0 &&
+                            <DataExplorer
+                                id={GROUP_DETAILS_MEMBERS_PANEL_ID}
+                                data-cy="group-members-data-explorer"
+                                onRowClick={noop}
+                                onRowDoubleClick={noop}
+                                onContextMenu={noop}
+                                contextMenuColumn={false}
+                                defaultViewIcon={UserPanelIcon}
+                                defaultViewMessages={[MEMBERS_DEFAULT_MESSAGE]}
+                                hideColumnSelector
+                                hideSearchInput
+                                actions={
                                     this.props.groupCanManage &&
                                     <Grid container justify='flex-end'>
                                         <Button
-                                        data-cy="group-member-add"
-                                        variant="contained"
-                                        color="primary"
-                                        onClick={this.props.onAddUser}>
-                                        <AddIcon /> Add user
+                                            data-cy="group-member-add"
+                                            variant="contained"
+                                            color="primary"
+                                            onClick={this.props.onAddUser}>
+                                            <AddIcon /> Add user
                                         </Button>
                                     </Grid>
-                            }
-                            paperProps={{
-                                elevation: 0,
-                            }} />
-                    }
-                    {value === 1 &&
-                        <DataExplorer
-                            id={GROUP_DETAILS_PERMISSIONS_PANEL_ID}
-                            data-cy="group-permissions-data-explorer"
-                            onRowClick={noop}
-                            onRowDoubleClick={noop}
-                            onContextMenu={noop}
-                            contextMenuColumn={false}
-                            defaultViewIcon={KeyIcon}
-                            defaultViewMessages={[PERMISSIONS_DEFAULT_MESSAGE]}
-                            hideColumnSelector
-                            hideSearchInput
-                            paperProps={{
-                                elevation: 0,
-                            }} />
-                    }
-                  </div>
+                                }
+                                paperProps={{
+                                    elevation: 0,
+                                }} />
+                        }
+                        {value === 1 &&
+                            <DataExplorer
+                                id={GROUP_DETAILS_PERMISSIONS_PANEL_ID}
+                                data-cy="group-permissions-data-explorer"
+                                onRowClick={noop}
+                                onRowDoubleClick={noop}
+                                onContextMenu={noop}
+                                contextMenuColumn={false}
+                                defaultViewIcon={KeyIcon}
+                                defaultViewMessages={[PERMISSIONS_DEFAULT_MESSAGE]}
+                                hideColumnSelector
+                                hideSearchInput
+                                paperProps={{
+                                    elevation: 0,
+                                }} />
+                        }
+                    </div>
                 </Paper>
             );
         }
index 064add3a6d940499daf3e600b6a62e9a36df80fb..be765706975356d53535f4c011fb052569da1af3 100644 (file)
@@ -10,6 +10,7 @@ import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/st
 import { ArvadosTheme } from 'common/custom-theme';
 import { navigateToLinkAccount } from 'store/navigation/navigation-action';
 import { RootState } from 'store/store';
+import { sanitizeHTML } from 'common/html-sanitize';
 
 export type CssRules = 'root' | 'ontop' | 'title';
 
@@ -57,7 +58,7 @@ export const InactivePanelRoot = ({ classes, startLinking, inactivePageText, isL
         style={{ marginTop: 56, height: "100%" }}>
         <Grid item>
             <Typography>
-                <span dangerouslySetInnerHTML={{ __html: inactivePageText }} style={{ margin: "1em" }} />
+                <span dangerouslySetInnerHTML={{ __html: sanitizeHTML(inactivePageText) }} style={{ margin: "1em" }} />
             </Typography>
         </Grid>
         { !isLoginClusterFederation
index dee6ee77813008840042ae34ce36101e18c13523..7cc7795648c971fbc7ba9122e0e185ecf03a0b62 100644 (file)
@@ -5,7 +5,7 @@
 import React from 'react';
 import { StyleRulesCallback, WithStyles, withStyles, Card, CardContent, Grid, Table, TableHead, TableRow, TableCell, TableBody, Tooltip, IconButton, Checkbox } from '@material-ui/core';
 import { ArvadosTheme } from 'common/custom-theme';
-import { MoreOptionsIcon } from 'components/icon/icon';
+import { MoreVerticalIcon } from 'components/icon/icon';
 import { KeepServiceResource } from 'models/keep-services';
 
 type CssRules = 'root' | 'tableRow';
@@ -34,7 +34,7 @@ export interface KeepServicePanelRootDataProps {
 type KeepServicePanelRootProps = KeepServicePanelRootActionProps & KeepServicePanelRootDataProps & WithStyles<CssRules>;
 
 export const KeepServicePanelRoot = withStyles(styles)(
-    ({ classes, hasKeepSerices, keepServices, openRowOptions }: KeepServicePanelRootProps) => 
+    ({ classes, hasKeepSerices, keepServices, openRowOptions }: KeepServicePanelRootProps) =>
         <Card className={classes.root}>
             <CardContent>
                 {hasKeepSerices && <Grid container direction="row">
@@ -73,7 +73,7 @@ export const KeepServicePanelRoot = withStyles(styles)(
                                         <TableCell>
                                             <Tooltip title="More options" disableFocusListener>
                                                 <IconButton onClick={event => openRowOptions(event, keepService)}>
-                                                    <MoreOptionsIcon />
+                                                    <MoreVerticalIcon />
                                                 </IconButton>
                                             </Tooltip>
                                         </TableCell>
@@ -84,4 +84,4 @@ export const KeepServicePanelRoot = withStyles(styles)(
                 </Grid>}
             </CardContent>
         </Card>
-);
\ No newline at end of file
+);
index 110097bee59dc7d09a9afbcdcf53f50516114755..f834b3b6dfcaf2346890fd9d38da848a20f60ad4 100644 (file)
@@ -12,6 +12,7 @@ import { RootState } from 'store/store';
 import { LoginForm } from 'views-components/login-form/login-form';
 import Axios from 'axios';
 import { Config } from 'common/config';
+import { sanitizeHTML } from 'common/html-sanitize';
 
 type CssRules = 'root' | 'container' | 'title' | 'content' | 'content__bolder' | 'button';
 
@@ -98,7 +99,7 @@ export const LoginPanel = withStyles(styles)(
             style={{ marginTop: 56, overflowY: "auto", height: "100%" }}>
             <Grid item className={classes.container}>
                 <Typography component="div">
-                    <div dangerouslySetInnerHTML={{ __html: welcomePage }} style={{ margin: "1em" }} />
+                    <div dangerouslySetInnerHTML={{ __html: sanitizeHTML(welcomePage) }} style={{ margin: "1em" }} />
                 </Typography>
                 {Object.keys(remoteHosts).length > 1 && loginCluster === "" &&
 
index 148c331e2971b91ee826ca92a9a1f57b2ba8f312..f54c00c32a2f2856636b263bb19f75b8326d6666 100644 (file)
@@ -3,8 +3,12 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { RootState } from 'store/store';
+import React from 'react';
 import { connect } from 'react-redux';
 import { NotFoundPanelRoot, NotFoundPanelRootDataProps } from 'views/not-found-panel/not-found-panel-root';
+import { Grid } from '@material-ui/core';
+import { DefaultView } from "components/default-view/default-view";
+import { IconType } from 'components/icon/icon';
 
 const mapStateToProps = (state: RootState): NotFoundPanelRootDataProps => {
     return {
@@ -17,3 +21,26 @@ const mapDispatchToProps = null;
 
 export const NotFoundPanel = connect(mapStateToProps, mapDispatchToProps)
     (NotFoundPanelRoot) as any;
+
+export interface NotFoundViewDataProps {
+    messages: string[];
+    icon?: IconType;
+}
+
+// TODO: optionally pass in the UUID and check if the
+// reason the item is not found is because
+// it or a parent project is actually in the trash.
+// If so, offer to untrash the item or the parent project.
+export const NotFoundView =
+    ({ messages, icon: Icon }: NotFoundViewDataProps) =>
+        <Grid
+            container
+            alignItems="center"
+            justify="center"
+            style={{ minHeight: "100%" }}
+            data-cy="not-found-view">
+            <DefaultView
+                icon={Icon}
+                messages={messages}
+            />
+        </Grid>;
index 7bdf889797babf88d816a17935d3848388ddb2d5..1f3a73a510f97c0e7f903a2bbe5c20b2feca44a0 100644 (file)
@@ -13,7 +13,7 @@ import { CollectionName, ContainerRunTime, ResourceWithName } from "views-compon
 import { getProcess, getProcessStatus } from "store/processes/process";
 import { RootState } from "store/store";
 import { connect } from "react-redux";
-import { ProcessResource } from "models/process";
+import { ProcessResource, MOUNT_PATH_CWL_WORKFLOW } from "models/process";
 import { ContainerResource } from "models/container";
 import { navigateToOutput, openWorkflow } from "store/process-panel/process-panel-actions";
 import { ArvadosTheme } from "common/custom-theme";
@@ -21,6 +21,8 @@ import { ProcessRuntimeStatus } from "views-components/process-runtime-status/pr
 import { getPropertyChip } from "views-components/resource-properties-form/property-chip";
 import { ContainerRequestResource } from "models/container-request";
 import { filterResources } from "store/resources/resources";
+import { JSONMount } from 'models/mount-types';
+import { getCollectionUrl } from 'models/collection';
 
 type CssRules = 'link' | 'propertyTag';
 
@@ -40,8 +42,31 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 
 const mapStateToProps = (state: RootState, props: { request: ProcessResource }) => {
     const process = getProcess(props.request.uuid)(state.resources);
+
+    let workflowCollection = "";
+    let workflowPath = "";
+    if (process?.containerRequest?.mounts && process.containerRequest.mounts[MOUNT_PATH_CWL_WORKFLOW]) {
+        const wf = process.containerRequest.mounts[MOUNT_PATH_CWL_WORKFLOW] as JSONMount;
+
+        if (wf.content["$graph"] &&
+            wf.content["$graph"].length > 0 &&
+            wf.content["$graph"][0] &&
+            wf.content["$graph"][0]["steps"] &&
+            wf.content["$graph"][0]["steps"][0]) {
+
+            const REGEX = /keep:([0-9a-f]{32}\+\d+)\/(.*)/;
+            const pdh = wf.content["$graph"][0]["steps"][0].run.match(REGEX);
+            if (pdh) {
+                workflowCollection = pdh[1];
+                workflowPath = pdh[2];
+            }
+        }
+    }
+
     return {
         container: process?.container,
+        workflowCollection,
+        workflowPath,
         subprocesses: filterResources((resource: ContainerRequestResource) =>
             resource.kind === ResourceKind.CONTAINER_REQUEST &&
             resource.requestingContainerUuid === process?.containerRequest.containerUuid
@@ -50,25 +75,33 @@ const mapStateToProps = (state: RootState, props: { request: ProcessResource })
 };
 
 interface ProcessDetailsAttributesActionProps {
-    navigateToOutput: (uuid: string) => void;
+    navigateToOutput: (resource: ContainerRequestResource) => void;
     openWorkflow: (uuid: string) => void;
 }
 
 const mapDispatchToProps = (dispatch: Dispatch): ProcessDetailsAttributesActionProps => ({
-    navigateToOutput: (uuid) => dispatch<any>(navigateToOutput(uuid)),
+    navigateToOutput: (resource) => dispatch<any>(navigateToOutput(resource)),
     openWorkflow: (uuid) => dispatch<any>(openWorkflow(uuid)),
 });
 
 export const ProcessDetailsAttributes = withStyles(styles, { withTheme: true })(
     connect(mapStateToProps, mapDispatchToProps)(
-        (props: { request: ProcessResource, container?: ContainerResource, subprocesses: ContainerRequestResource[], twoCol?: boolean, hideProcessPanelRedundantFields?: boolean, classes: Record<CssRules, string> } & ProcessDetailsAttributesActionProps) => {
+        (props: {
+            request: ProcessResource, container?: ContainerResource, subprocesses: ContainerRequestResource[],
+            workflowCollection, workflowPath,
+            twoCol?: boolean, hideProcessPanelRedundantFields?: boolean, classes: Record<CssRules, string>
+        } & ProcessDetailsAttributesActionProps) => {
             const containerRequest = props.request;
             const container = props.container;
             const subprocesses = props.subprocesses;
             const classes = props.classes;
             const mdSize = props.twoCol ? 6 : 12;
+            const workflowCollection = props.workflowCollection;
+            const workflowPath = props.workflowPath;
             const filteredPropertyKeys = Object.keys(containerRequest.properties)
-                                            .filter(k => (typeof containerRequest.properties[k] !== 'object'));
+                .filter(k => (typeof containerRequest.properties[k] !== 'object'));
+            const hasTotalCost = containerRequest && containerRequest.cumulativeCost > 0;
+            const totalCostNotReady = container && container.cost > 0 && container.state === "Running" && containerRequest && containerRequest.cumulativeCost === 0 && subprocesses.length > 0;
             return <Grid container>
                 <Grid item xs={12}>
                     <ProcessRuntimeStatus runtimeStatus={container?.runtimeStatus} containerCount={containerRequest.containerCount} />
@@ -123,15 +156,18 @@ export const ProcessDetailsAttributes = withStyles(styles, { withTheme: true })(
                 </Grid>
                 <Grid item xs={6}>
                     <DetailsAttribute label='Output collection' />
-                    {containerRequest.outputUuid && <span onClick={() => props.navigateToOutput(containerRequest.outputUuid!)}>
+                    {containerRequest.outputUuid && <span onClick={() => props.navigateToOutput(containerRequest!)}>
                         <CollectionName className={classes.link} uuid={containerRequest.outputUuid} />
                     </span>}
                 </Grid>
-                {container && container.cost > 0 && <Grid item xs={12} md={mdSize}>
-                        <DetailsAttribute label='Cost ' value={formatContainerCost(container.cost)} />
-                </Grid>}
-                {containerRequest && containerRequest.cumulativeCost > 0 && subprocesses.length > 0 && <Grid item xs={12} md={mdSize}>
-                    <DetailsAttribute label='Container &amp; subprocess cost' value={formatContainerCost(containerRequest.cumulativeCost)} />
+                {container && <Grid item xs={12} md={mdSize}>
+                    <DetailsAttribute label='Cost' value={
+                        `${hasTotalCost ? formatContainerCost(containerRequest.cumulativeCost) + ' total, ' : (totalCostNotReady ? 'total pending completion, ' : '')}${container.cost > 0 ? formatContainerCost(container.cost) : 'not available'} for this container`
+                    } />
+
+                    {container && workflowCollection && <Grid item xs={12} md={mdSize}>
+                        <DetailsAttribute label='Workflow code' link={getCollectionUrl(workflowCollection)} value={workflowPath} />
+                    </Grid>}
                 </Grid>}
                 {containerRequest.properties.template_uuid &&
                     <Grid item xs={12} md={mdSize}>
@@ -144,9 +180,9 @@ export const ProcessDetailsAttributes = withStyles(styles, { withTheme: true })(
                     <DetailsAttribute label='Priority' value={containerRequest.priority} />
                 </Grid>
                 {/*
-                    NOTE: The property list should be kept at the bottom, because it spans
-                    the entire available width, without regards of the twoCol prop.
-                */}
+                       NOTE: The property list should be kept at the bottom, because it spans
+                       the entire available width, without regards of the twoCol prop.
+                       */}
                 <Grid item xs={12} md={12}>
                     <DetailsAttribute label='Properties' />
                     {filteredPropertyKeys.length > 0
index 15728eb61f971bc48d484064f121934bec20517e..37f01dd70163c2a51c9a5c08220dada138f853ba 100644 (file)
@@ -16,7 +16,7 @@ import {
     Button,
 } from '@material-ui/core';
 import { ArvadosTheme } from 'common/custom-theme';
-import { CloseIcon, MoreOptionsIcon, ProcessIcon, StartIcon, StopIcon } from 'components/icon/icon';
+import { CloseIcon, MoreVerticalIcon, ProcessIcon, StartIcon, StopIcon } from 'components/icon/icon';
 import { Process, isProcessRunnable, isProcessResumable, isProcessCancelable } from 'store/processes/process';
 import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
 import { ProcessDetailsAttributes } from './process-details-attributes';
@@ -59,10 +59,10 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         fontSize: '0.78rem',
     },
     cancelButton: {
-        color: theme.customs.colors.red900,
-        borderColor: theme.customs.colors.red900,
+        color: theme.palette.common.white,
+        backgroundColor: theme.customs.colors.red900,
         '&:hover': {
-            borderColor: theme.customs.colors.red900,
+            backgroundColor: theme.customs.colors.red900,
         },
         '& svg': {
             fontSize: '22px',
@@ -126,7 +126,7 @@ export const ProcessDetailsCard = withStyles(styles)(
                         {isProcessCancelable(process) &&
                             <Button
                                 data-cy="process-cancel-button"
-                                variant="outlined"
+                                variant="contained"
                                 size="small"
                                 color="primary"
                                 className={classNames(classes.actionButton, classes.cancelButton)}
@@ -139,13 +139,13 @@ export const ProcessDetailsCard = withStyles(styles)(
                             <IconButton
                                 aria-label="More options"
                                 onClick={event => onContextMenu(event)}>
-                                <MoreOptionsIcon />
+                                <MoreVerticalIcon />
                             </IconButton>
                         </Tooltip>
-                        { doHidePanel &&
-                        <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
-                            <IconButton onClick={doHidePanel}><CloseIcon /></IconButton>
-                        </Tooltip> }
+                        {doHidePanel &&
+                            <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
+                                <IconButton onClick={doHidePanel}><CloseIcon /></IconButton>
+                            </Tooltip>}
                     </div>
                 } />
             <CardContent className={classes.content}>
index 045bfca2113cce747cfdb3b099141d70fbb29efa..b5afbf6545ed19f2eb84156f02534c3fa09ab3f8 100644 (file)
@@ -2,8 +2,8 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React, { ReactElement, useState } from 'react';
-import { Dispatch } from 'redux';
+import React, { ReactElement, memo, useState } from "react";
+import { Dispatch } from "redux";
 import {
     StyleRulesCallback,
     WithStyles,
@@ -25,208 +25,201 @@ import {
     Grid,
     Chip,
     CircularProgress,
-} from '@material-ui/core';
-import { ArvadosTheme } from 'common/custom-theme';
+} from "@material-ui/core";
+import { ArvadosTheme } from "common/custom-theme";
+import { CloseIcon, ImageIcon, InputIcon, ImageOffIcon, OutputIcon, MaximizeIcon, UnMaximizeIcon, InfoIcon } from "components/icon/icon";
+import { MPVPanelProps } from "components/multi-panel-view/multi-panel-view";
 import {
-    CloseIcon,
-    ImageIcon,
-    InputIcon,
-    ImageOffIcon,
-    OutputIcon,
-    MaximizeIcon,
-    UnMaximizeIcon,
-    InfoIcon
-} 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,
+    BooleanCommandInputParameter,
+    CommandInputParameter,
+    CWLType,
+    Directory,
+    DirectoryArrayCommandInputParameter,
+    DirectoryCommandInputParameter,
+    EnumCommandInputParameter,
+    FileArrayCommandInputParameter,
+    FileCommandInputParameter,
+    FloatArrayCommandInputParameter,
+    FloatCommandInputParameter,
+    IntArrayCommandInputParameter,
+    IntCommandInputParameter,
+    isArrayOfType,
+    isPrimitiveOfType,
+    StringArrayCommandInputParameter,
+    StringCommandInputParameter,
+    getEnumType,
 } 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';
-import { KEEP_URL_REGEX } from 'models/resource';
+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";
+import { KEEP_URL_REGEX } from "models/resource";
 
 type CssRules =
-  | "card"
-  | "content"
-  | "title"
-  | "header"
-  | "avatar"
-  | "iconHeader"
-  | "tableWrapper"
-  | "tableRoot"
-  | "paramValue"
-  | "keepLink"
-  | "collectionLink"
-  | "imagePreview"
-  | "valArray"
-  | "secondaryVal"
-  | "secondaryRow"
-  | "emptyValue"
-  | "noBorderRow"
-  | "symmetricTabs"
-  | "imagePlaceholder"
-  | "rowWithPreview"
-  | "labelColumn";
+    | "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%'
+        height: "100%",
     },
     header: {
         paddingTop: theme.spacing.unit,
         paddingBottom: 0,
     },
     iconHeader: {
-        fontSize: '1.875rem',
+        fontSize: "1.875rem",
         color: theme.customs.colors.greyL,
     },
     avatar: {
-        alignSelf: 'flex-start',
-        paddingTop: theme.spacing.unit * 0.5
+        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': {
+        "&:last-child": {
             paddingBottom: theme.spacing.unit * 1,
-        }
+        },
     },
     title: {
-        overflow: 'hidden',
+        overflow: "hidden",
         paddingTop: theme.spacing.unit * 0.5,
         color: theme.customs.colors.greyD,
-        fontSize: '1.875rem'
+        fontSize: "1.875rem",
     },
     tableWrapper: {
-        height: 'auto',
+        height: "auto",
         maxHeight: `calc(100% - ${theme.spacing.unit * 4.5}px)`,
-        overflow: 'auto',
+        overflow: "auto",
     },
     tableRoot: {
-        width: '100%',
-        '& thead th': {
-            verticalAlign: 'bottom',
-            paddingBottom: '10px',
+        width: "100%",
+        "& thead th": {
+            verticalAlign: "bottom",
+            paddingBottom: "10px",
+        },
+        "& td, & th": {
+            paddingRight: "25px",
         },
-        '& td, & th': {
-            paddingRight: '25px',
-        }
     },
     paramValue: {
-        display: 'flex',
-        alignItems: 'flex-start',
-        flexDirection: 'column',
+        display: "flex",
+        alignItems: "flex-start",
+        flexDirection: "column",
     },
     keepLink: {
         color: theme.palette.primary.main,
-        textDecoration: 'none',
-        overflowWrap: 'break-word',
-        cursor: 'pointer',
+        textDecoration: "none",
+        overflowWrap: "break-word",
+        cursor: "pointer",
     },
     collectionLink: {
-        margin: '10px',
-        '& a': {
+        margin: "10px",
+        "& a": {
             color: theme.palette.primary.main,
-            textDecoration: 'none',
-            overflowWrap: 'break-word',
-            cursor: 'pointer',
-        }
+            textDecoration: "none",
+            overflowWrap: "break-word",
+            cursor: "pointer",
+        },
     },
     imagePreview: {
-        maxHeight: '15em',
-        maxWidth: '15em',
+        maxHeight: "15em",
+        maxWidth: "15em",
         marginBottom: theme.spacing.unit,
     },
     valArray: {
-        display: 'flex',
-        gap: '10px',
-        flexWrap: 'wrap',
-        '& span': {
-            display: 'inline',
-        }
+        display: "flex",
+        gap: "10px",
+        flexWrap: "wrap",
+        "& span": {
+            display: "inline",
+        },
     },
     secondaryVal: {
-        paddingLeft: '20px',
+        paddingLeft: "20px",
     },
     secondaryRow: {
-        height: '29px',
-        verticalAlign: 'top',
-        position: 'relative',
-        top: '-9px',
+        height: "29px",
+        verticalAlign: "top",
+        position: "relative",
+        top: "-9px",
     },
     emptyValue: {
         color: theme.customs.colors.grey700,
     },
     noBorderRow: {
-        '& td': {
-            borderBottom: 'none',
-        }
+        "& td": {
+            borderBottom: "none",
+        },
     },
     symmetricTabs: {
-        '& button': {
-            flexBasis: '0',
-        }
+        "& button": {
+            flexBasis: "0",
+        },
     },
     imagePlaceholder: {
-        width: '60px',
-        height: '60px',
-        display: 'flex',
-        alignItems: 'center',
-        justifyContent: 'center',
-        backgroundColor: '#cecece',
-        borderRadius: '10px',
+        width: "60px",
+        height: "60px",
+        display: "flex",
+        alignItems: "center",
+        justifyContent: "center",
+        backgroundColor: "#cecece",
+        borderRadius: "10px",
     },
     rowWithPreview: {
-        verticalAlign: 'bottom',
+        verticalAlign: "bottom",
     },
     labelColumn: {
-        minWidth: '120px',
+        minWidth: "120px",
     },
 });
 
 export enum ProcessIOCardType {
-    INPUT = 'Inputs',
-    OUTPUT = 'Outputs',
+    INPUT = "Inputs",
+    OUTPUT = "Outputs",
 }
 export interface ProcessIOCardDataProps {
-    process: Process;
+    process?: Process;
     label: ProcessIOCardType;
     params: ProcessIOParameter[] | null;
     raw: any;
     mounts?: InputCollectionMount[];
     outputUuid?: string;
+    showParams?: boolean;
 }
 
 export interface ProcessIOCardActionProps {
@@ -234,238 +227,390 @@ export interface ProcessIOCardActionProps {
 }
 
 const mapDispatchToProps = (dispatch: Dispatch): ProcessIOCardActionProps => ({
-    navigateTo: (uuid) => dispatch<any>(navigateTo(uuid)),
+    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, doUnMaximizePanel, panelMaximized, panelName, process, navigateTo }: ProcessIOCardProps) => {
-        const [mainProcTabState, setMainProcTabState] = useState(0);
-        const [subProcTabState, setSubProcTabState] = useState(0);
-        const handleMainProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
-            setMainProcTabState(value);
-        }
-        const handleSubProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
-            setSubProcTabState(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);
-
-        // Subprocess
-        const hasInputMounts = !!(label === ProcessIOCardType.INPUT && mounts && mounts.length);
-        const hasOutputCollecton = !!(label === ProcessIOCardType.OUTPUT && outputUuid);
-
-        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> }
-                        { doUnMaximizePanel && panelMaximized &&
-                        <Tooltip title={`Unmaximize ${panelName || 'panel'}`} disableFocusListener>
-                            <IconButton onClick={doUnMaximizePanel}><UnMaximizeIcon /></IconButton>
-                        </Tooltip> }
-                        { doMaximizePanel && !panelMaximized &&
-                        <Tooltip title={`Maximize ${panelName || 'panel'}`} disableFocusListener>
-                            <IconButton onClick={doMaximizePanel}><MaximizeIcon /></IconButton>
-                        </Tooltip> }
-                        { doHidePanel &&
-                        <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
-                            <IconButton disabled={panelMaximized} 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)) &&
+export const ProcessIOCard = withStyles(styles)(
+    connect(
+        null,
+        mapDispatchToProps
+    )(
+        ({
+            classes,
+            label,
+            params,
+            raw,
+            mounts,
+            outputUuid,
+            doHidePanel,
+            doMaximizePanel,
+            doUnMaximizePanel,
+            panelMaximized,
+            panelName,
+            process,
+            navigateTo,
+            showParams,
+        }: ProcessIOCardProps) => {
+            const [mainProcTabState, setMainProcTabState] = useState(0);
+            const [subProcTabState, setSubProcTabState] = useState(0);
+            const handleMainProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
+                setMainProcTabState(value);
+            };
+            const handleSubProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
+                setSubProcTabState(value);
+            };
+
+            const [showImagePreview, setShowImagePreview] = useState(false);
+
+            const PanelIcon = label === ProcessIOCardType.INPUT ? InputIcon : OutputIcon;
+            const mainProcess = !(process && 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);
+
+            // Subprocess
+            const hasInputMounts = !!(label === ProcessIOCardType.INPUT && mounts && mounts.length);
+            const hasOutputCollecton = !!(label === ProcessIOCardType.OUTPUT && outputUuid);
+
+            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>
+                                )}
+                                {doUnMaximizePanel && panelMaximized && (
+                                    <Tooltip
+                                        title={`Unmaximize ${panelName || "panel"}`}
+                                        disableFocusListener
+                                    >
+                                        <IconButton onClick={doUnMaximizePanel}>
+                                            <UnMaximizeIcon />
+                                        </IconButton>
+                                    </Tooltip>
+                                )}
+                                {doMaximizePanel && !panelMaximized && (
+                                    <Tooltip
+                                        title={`Maximize ${panelName || "panel"}`}
+                                        disableFocusListener
+                                    >
+                                        <IconButton onClick={doMaximizePanel}>
+                                            <MaximizeIcon />
+                                        </IconButton>
+                                    </Tooltip>
+                                )}
+                                {doHidePanel && (
+                                    <Tooltip
+                                        title={`Close ${panelName || "panel"}`}
+                                        disableFocusListener
+                                    >
+                                        <IconButton
+                                            disabled={panelMaximized}
+                                            onClick={doHidePanel}
+                                        >
+                                            <CloseIcon />
+                                        </IconButton>
+                                    </Tooltip>
+                                )}
+                            </div>
+                        }
+                    />
+                    <CardContent className={classes.content}>
+                        {mainProcess || showParams ? (
                             <>
-                                <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
-                    (<>
-                        {loading && <Grid container item alignItems='center' justify='center'>
-                            <CircularProgress />
-                        </Grid>}
-                        {!loading && (hasInputMounts || hasOutputCollecton || hasRaw) ?
+                                {/* 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" />}
+                                            {!showParams && <Tab label="JSON" />}
+                                        </Tabs>
+                                        {mainProcTabState === 0 && params && hasParams && (
+                                            <div className={classes.tableWrapper}>
+                                                <ProcessIOPreview
+                                                    data={params}
+                                                    showImagePreview={showImagePreview}
+                                                    valueLabel={showParams ? "Default value" : "Value"}
+                                                />
+                                            </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
                             <>
-                                <Tabs value={subProcTabState} onChange={handleSubProcTabChange} variant="fullWidth" className={classes.symmetricTabs}>
-                                    {hasInputMounts && <Tab label="Collections" />}
-                                    {hasOutputCollecton && <Tab label="Collection" />}
-                                    <Tab label="JSON" />
-                                </Tabs>
-                                <div className={classes.tableWrapper}>
-                                    {subProcTabState === 0 && hasInputMounts && <ProcessInputMounts mounts={mounts || []} />}
-                                    {subProcTabState === 0 && hasOutputCollecton && <>
-                                        {outputUuid && <Typography className={classes.collectionLink}>
-                                            Output Collection: <MuiLink className={classes.keepLink} onClick={() => {navigateTo(outputUuid || "")}}>
-                                            {outputUuid}
-                                        </MuiLink></Typography>}
-                                        <ProcessOutputCollectionFiles isWritable={false} currentItemUuid={outputUuid} />
-                                    </>}
-                                    {(subProcTabState === 1 || (!hasInputMounts && !hasOutputCollecton)) && <div className={classes.tableWrapper}>
-                                        <ProcessIORaw data={raw} />
-                                    </div>}
-                                </div>
-                            </> :
-                            <Grid container item alignItems='center' justify='center'>
-                                <DefaultView messages={["No data to display"]} />
-                            </Grid>
-                        }
-                    </>)
-                }
-            </CardContent>
-        </Card>;
-    }
-));
+                                {loading && (
+                                    <Grid
+                                        container
+                                        item
+                                        alignItems="center"
+                                        justify="center"
+                                    >
+                                        <CircularProgress />
+                                    </Grid>
+                                )}
+                                {!loading && (hasInputMounts || hasOutputCollecton || hasRaw) ? (
+                                    <>
+                                        <Tabs
+                                            value={subProcTabState}
+                                            onChange={handleSubProcTabChange}
+                                            variant="fullWidth"
+                                            className={classes.symmetricTabs}
+                                        >
+                                            {hasInputMounts && <Tab label="Collections" />}
+                                            {hasOutputCollecton && <Tab label="Collection" />}
+                                            <Tab label="JSON" />
+                                        </Tabs>
+                                        <div className={classes.tableWrapper}>
+                                            {subProcTabState === 0 && hasInputMounts && <ProcessInputMounts mounts={mounts || []} />}
+                                            {subProcTabState === 0 && hasOutputCollecton && (
+                                                <>
+                                                    {outputUuid && (
+                                                        <Typography className={classes.collectionLink}>
+                                                            Output Collection:{" "}
+                                                            <MuiLink
+                                                                className={classes.keepLink}
+                                                                onClick={() => {
+                                                                    navigateTo(outputUuid || "");
+                                                                }}
+                                                            >
+                                                                {outputUuid}
+                                                            </MuiLink>
+                                                        </Typography>
+                                                    )}
+                                                    <ProcessOutputCollectionFiles
+                                                        isWritable={false}
+                                                        currentItemUuid={outputUuid}
+                                                    />
+                                                </>
+                                            )}
+                                            {(subProcTabState === 1 || (!hasInputMounts && !hasOutputCollecton)) && (
+                                                <div className={classes.tableWrapper}>
+                                                    <ProcessIORaw data={raw} />
+                                                </div>
+                                            )}
+                                        </div>
+                                    </>
+                                ) : (
+                                    <Grid
+                                        container
+                                        item
+                                        alignItems="center"
+                                        justify="center"
+                                    >
+                                        <DefaultView messages={["No data to display"]} />
+                                    </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;
+    valueLabel: string;
 }
 
 type ProcessIOPreviewProps = ProcessIOPreviewDataProps & WithStyles<CssRules>;
 
-const ProcessIOPreview = withStyles(styles)(
-    ({ classes, data, showImagePreview }: ProcessIOPreviewProps) => {
+const ProcessIOPreview = memo(
+    withStyles(styles)(({ classes, data, showImagePreview, valueLabel }: 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>;
-});
+        return (
+            <Table
+                className={classes.tableRoot}
+                aria-label="Process IO Preview"
+            >
+                <TableHead>
+                    <TableRow>
+                        <TableCell>Name</TableCell>
+                        {showLabel && <TableCell className={classes.labelColumn}>Label</TableCell>}
+                        <TableCell>{valueLabel}</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 (
+                            <React.Fragment key={param.id}>
+                                <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)}
+                                            key={i}
+                                        >
+                                            <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>
+                                    );
+                                })}
+                            </React.Fragment>
+                        );
+                    })}
+                </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>
-)
+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>
-);
+const ProcessIORaw = withStyles(styles)(({ data }: ProcessIORawDataProps) => (
+    <Paper elevation={0}>
+        <DefaultCodeSnippet
+            lines={[JSON.stringify(data, null, 2)]}
+            linked
+        />
+    </Paper>
+));
 
 interface ProcessInputMountsDataProps {
     mounts: InputCollectionMount[];
@@ -473,148 +618,146 @@ interface ProcessInputMountsDataProps {
 
 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>
+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>
-            ))}
-        </TableBody>
-    </Table>
-)));
+            </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: renderPrimitiveValue(boolValue, false) }] :
-                [{display: <EmptyValue />}];
+            return boolValue !== undefined && !(Array.isArray(boolValue) && boolValue.length === 0)
+                ? [{ display: renderPrimitiveValue(boolValue, false) }]
+                : [{ 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: renderPrimitiveValue(intValue, false) }]
-                : [{display: <EmptyValue />}];
+                // Missing values are empty array
+                !(Array.isArray(intValue) && intValue.length === 0)
+                ? [{ display: renderPrimitiveValue(intValue, false) }]
+                : [{ 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: renderPrimitiveValue(floatValue, false) }]:
-                [{display: <EmptyValue />}];
+            return floatValue !== undefined && !(Array.isArray(floatValue) && floatValue.length === 0)
+                ? [{ display: renderPrimitiveValue(floatValue, false) }]
+                : [{ display: <EmptyValue /> }];
 
         case isPrimitiveOfType(input, CWLType.STRING):
             const stringValue = (input as StringCommandInputParameter).value || undefined;
-            return stringValue !== undefined &&
-                    !(Array.isArray(stringValue) && stringValue.length === 0) ?
-                [{display: renderPrimitiveValue(stringValue, false) }] :
-                [{display: <EmptyValue />}];
+            return stringValue !== undefined && !(Array.isArray(stringValue) && stringValue.length === 0)
+                ? [{ display: renderPrimitiveValue(stringValue, false) }]
+                : [{ 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 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 />}];
+            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':
+            return directory !== undefined && !(Array.isArray(directory) && directory.length === 0)
+                ? [directoryToProcessIOValue(directory, auth, pdh)]
+                : [{ display: <EmptyValue /> }];
+
+        case getEnumType(input) !== null:
             const enumValue = (input as EnumCommandInputParameter).value;
-            return enumValue !== undefined && enumValue ?
-                [{ display: <pre>{enumValue}</pre> }] :
-                [{display: <EmptyValue />}];
+            return enumValue !== undefined && enumValue ? [{ display: <pre>{enumValue}</pre> }] : [{ display: <EmptyValue /> }];
 
         case isArrayOfType(input, CWLType.STRING):
             const strArray = (input as StringArrayCommandInputParameter).value || [];
-            return strArray.length ?
-                [{ display: <>{strArray.map((val) => renderPrimitiveValue(val, true))}</> }] :
-                [{display: <EmptyValue />}];
+            return strArray.length ? [{ display: <>{strArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
 
         case isArrayOfType(input, CWLType.INT):
         case isArrayOfType(input, CWLType.LONG):
             const intArray = (input as IntArrayCommandInputParameter).value || [];
-            return intArray.length ?
-                [{ display: <>{intArray.map((val) => renderPrimitiveValue(val, true))}</> }] :
-                [{display: <EmptyValue />}];
+            return intArray.length ? [{ display: <>{intArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ 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) => renderPrimitiveValue(val, true))}</> }] :
-                [{display: <EmptyValue />}];
+            return floatArray.length ? [{ display: <>{floatArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ 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 [
+            const fileArrayMainFiles = (input as FileArrayCommandInputParameter).value || [];
+            const firstMainFilePdh = fileArrayMainFiles.length > 0 && fileArrayMainFiles[0] ? getResourcePdhUrl(fileArrayMainFiles[0], pdh) : "";
+
+            // Convert each main and secondaryFiles into array of ProcessIOValue preserving ordering
+            let fileArrayValues: ProcessIOValue[] = [];
+            for (let i = 0; i < fileArrayMainFiles.length; i++) {
+                const secondaryFiles = (fileArrayMainFiles[i] as unknown as FileWithSecondaryFiles)?.secondaryFiles || [];
+                fileArrayValues.push(
                     // 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)), []);
+                    ...(fileArrayMainFiles[i] ? [fileToProcessIOValue(fileArrayMainFiles[i], false, auth, pdh, i > 0 ? firstMainFilePdh : "")] : []),
+                    ...secondaryFiles.map(file => fileToProcessIOValue(file, true, auth, pdh, firstMainFilePdh))
+                );
+            }
 
-            return fileArrayValues.length ?
-                fileArrayValues :
-                [{display: <EmptyValue />}];
+            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 />}];
+            return directories.length ? directories.map(directory => directoryToProcessIOValue(directory, auth, pdh)) : [{ display: <EmptyValue /> }];
 
         default:
-            return [{display: <UnsupportedValue />}];
+            return [{ display: <UnsupportedValue /> }];
     }
 };
 
 const renderPrimitiveValue = (value: any, asChip: boolean) => {
-    const isObject = typeof value === 'object';
+    const isObject = typeof value === "object";
     if (!isObject) {
-        return asChip ? <Chip label={String(value)} /> : <pre>{String(value)}</pre>;
+        return asChip ? (
+            <Chip
+                key={value}
+                label={String(value)}
+            />
+        ) : (
+            <pre key={value}>{String(value)}</pre>
+        );
     } else {
         return asChip ? <UnsupportedValueChip /> : <UnsupportedValue />;
     }
@@ -624,11 +767,9 @@ const renderPrimitiveValue = (value: any, asChip: boolean) => {
  * @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 || '';
+    const isKeepUrl = file.location?.startsWith("keep:") || false;
+    const keepUrl = isKeepUrl ? file.location?.replace("keep:", "") : pdh ? `${pdh}/${file.location}` : file.location;
+    return keepUrl || "";
 };
 
 interface KeepUrlProps {
@@ -639,47 +780,73 @@ interface KeepUrlProps {
 
 const getResourcePdhUrl = (res: File | Directory, pdh?: string): string => {
     const keepUrl = getKeepUrl(res, pdh);
-    return keepUrl ? keepUrl.split('/').slice(0, 1)[0] : '';
+    return keepUrl ? keepUrl.split("/").slice(0, 1)[0] : "";
 };
 
-const KeepUrlBase = withStyles(styles)(({auth, res, pdh, classes}: KeepUrlProps & WithStyles<CssRules>) => {
+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> :
-        <></>;
+    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 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 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 />;
+    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));
+    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);
+    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;
+    return basename ? (mime.getType(basename) || "").startsWith("image/") : false;
 };
 
-const isFileUrl = (location?: string): boolean => (
-    !!location && !KEEP_URL_REGEX.exec(location) &&
-    (location.startsWith("http://") || location.startsWith("https://"))
-);
+const isFileUrl = (location?: string): boolean =>
+    !!location && !KEEP_URL_REGEX.exec(location) && (location.startsWith("http://") || location.startsWith("https://"));
 
 const normalizeDirectoryLocation = (directory: Directory): Directory => {
     if (!directory.location) {
@@ -687,55 +854,92 @@ const normalizeDirectoryLocation = (directory: Directory): Directory => {
     }
     return {
         ...directory,
-        location: (directory.location || '').endsWith('/') ? directory.location : directory.location + '/',
+        location: (directory.location || "").endsWith("/") ? directory.location : directory.location + "/",
     };
 };
 
 const directoryToProcessIOValue = (directory: Directory, auth: AuthState, pdh?: string): ProcessIOValue => {
-    if (isExternalValue(directory)) {return {display: <UnsupportedValue />}}
+    if (isExternalValue(directory)) {
+        return { display: <UnsupportedValue /> };
+    }
 
     const normalizedDirectory = normalizeDirectoryLocation(directory);
     return {
-        display: <KeepUrlPath auth={auth} res={normalizedDirectory} pdh={pdh}/>,
-        collection: <KeepUrlBase auth={auth} res={normalizedDirectory} pdh={pdh}/>,
+        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 => {
-    if (isExternalValue(file)) {return {display: <UnsupportedValue />}}
+    if (isExternalValue(file)) {
+        return { display: <UnsupportedValue /> };
+    }
 
     if (isFileUrl(file.location)) {
         return {
-            display: <MuiLink href={file.location} target="_blank">{file.location}</MuiLink>,
+            display: (
+                <MuiLink
+                    href={file.location}
+                    target="_blank"
+                >
+                    {file.location}
+                </MuiLink>
+            ),
             secondary,
         };
     }
 
     const resourcePdh = getResourcePdhUrl(file, pdh);
     return {
-        display: <KeepUrlPath auth={auth} res={file} pdh={pdh}/>,
+        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}/> : <></>,
-    }
+        collection:
+            resourcePdh !== mainFilePdh ? (
+                <KeepUrlBase
+                    auth={auth}
+                    res={file}
+                    pdh={pdh}
+                />
+            ) : (
+                <></>
+            ),
+    };
 };
 
-const isExternalValue = (val: any) =>
-    Object.keys(val).includes('$import') ||
-    Object.keys(val).includes('$include')
+const isExternalValue = (val: any) => Object.keys(val).includes("$import") || Object.keys(val).includes("$include");
 
-const EmptyValue = withStyles(styles)(
-    ({classes}: WithStyles<CssRules>) => <span className={classes.emptyValue}>No value</span>
-);
+export const EmptyValue = withStyles(styles)(({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>No value</span>);
 
-const UnsupportedValue = withStyles(styles)(
-    ({classes}: WithStyles<CssRules>) => <span className={classes.emptyValue}>Cannot display value</span>
-);
+const UnsupportedValue = withStyles(styles)(({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>Cannot display value</span>);
 
-const UnsupportedValueChip = withStyles(styles)(
-    ({classes}: WithStyles<CssRules>) => <Chip icon={<InfoIcon />} label={"Cannot display value"} />
-);
+const UnsupportedValueChip = withStyles(styles)(({ classes }: WithStyles<CssRules>) => (
+    <Chip
+        icon={<InfoIcon />}
+        label={"Cannot display value"}
+    />
+));
 
-const ImagePlaceholder = withStyles(styles)(
-    ({classes}: WithStyles<CssRules>) => <span className={classes.imagePlaceholder}><ImageIcon /></span>
-);
+const ImagePlaceholder = withStyles(styles)(({ classes }: WithStyles<CssRules>) => (
+    <span className={classes.imagePlaceholder}>
+        <ImageIcon />
+    </span>
+));
index e14f98f9b2af156d7d16be8e085e6b8a2d0f1286..4fd8f2343d88b5a8b3356c4b0f53987da3f10cb4 100644 (file)
@@ -15,6 +15,7 @@ import {
     Grid,
     Typography,
 } from '@material-ui/core';
+import { useAsyncInterval } from 'common/use-async-interval';
 import { ArvadosTheme } from 'common/custom-theme';
 import {
     CloseIcon,
@@ -28,7 +29,7 @@ import {
     WordWrapOffIcon,
     WordWrapOnIcon,
 } from 'components/icon/icon';
-import { Process } from 'store/processes/process';
+import { Process, isProcessRunning } from 'store/processes/process';
 import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
 import {
     FilterOption,
@@ -84,6 +85,7 @@ export interface ProcessLogsCardActionProps {
     onLogFilterChange: (filter: FilterOption) => void;
     navigateToLog: (uuid: string) => void;
     onCopy: (text: string) => void;
+    pollProcessLogs: (processUuid: string) => Promise<void>;
 }
 
 type ProcessLogsCardProps = ProcessLogsCardDataProps
@@ -94,13 +96,17 @@ type ProcessLogsCardProps = ProcessLogsCardDataProps
 
 export const ProcessLogsCard = withStyles(styles)(
     ({ classes, process, filters, selectedFilter, lines,
-        onLogFilterChange, navigateToLog, onCopy,
+        onLogFilterChange, navigateToLog, onCopy, pollProcessLogs,
         doHidePanel, doMaximizePanel, doUnMaximizePanel, panelMaximized, panelName }: ProcessLogsCardProps) => {
         const [wordWrap, setWordWrap] = useState<boolean>(true);
         const [fontSize, setFontSize] = useState<number>(3);
         const fontBaseSize = 10;
         const fontStepSize = 1;
 
+        useAsyncInterval(() => (
+            pollProcessLogs(process.containerRequest.uuid)
+        ), isProcessRunning(process) ? 2000 : null);
+
         return <Grid item className={classes.root} xs={12}>
             <Card className={classes.card}>
                 <CardHeader className={classes.header}
@@ -184,4 +190,3 @@ export const ProcessLogsCard = withStyles(styles)(
             </Card>
         </Grid >
 });
-
index 2b7391c294ec0ac2ffd120fac25085b00b574e5c..50d343d6223c31f0c4a5998298d415ccaaa688bd 100644 (file)
@@ -33,7 +33,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         },
     },
     logText: {
-        padding: theme.spacing.unit * 0.5,
+        padding: `0 ${theme.spacing.unit*0.5}px`,
     },
     wordWrap: {
         whiteSpace: 'pre-wrap',
@@ -126,4 +126,4 @@ export const ProcessLogCodeSnippet = withStyles(styles)(connect(mapStateToProps)
                 ) }
             </div>
         </MuiThemeProvider>
-    }));
\ No newline at end of file
+    }));
index d99c62ec7493c986e1ea7bda21bf0d30ca9fffc7..c972c0a6cf9ebf130463c72b39ee69b750970945 100644 (file)
@@ -2,34 +2,34 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React 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';
-import { Process } from 'store/processes/process';
-import { SubprocessPanel } from 'views/subprocess-panel/subprocess-panel';
-import { SubprocessFilterDataProps } from 'components/subprocess-filter/subprocess-filter';
-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 { ProcessResourceCard } from './process-resource-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 { WorkflowInputsData } 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, NodeInstanceType } from 'store/process-panel/process-panel';
+import React from "react";
+import { StyleRulesCallback, WithStyles, withStyles } from "@material-ui/core";
+import { ProcessIcon } from "components/icon/icon";
+import { Process } from "store/processes/process";
+import { SubprocessPanel } from "views/subprocess-panel/subprocess-panel";
+import { SubprocessFilterDataProps } from "components/subprocess-filter/subprocess-filter";
+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 { ProcessResourceCard } from "./process-resource-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 { WorkflowInputsData } 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, NodeInstanceType } from "store/process-panel/process-panel";
+import { NotFoundView } from 'views/not-found-panel/not-found-panel';
 
-type CssRules = 'root';
+type CssRules = "root";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
-        width: '100%',
+        width: "100%",
     },
 });
 
@@ -61,6 +61,7 @@ export interface ProcessPanelRootActionProps {
     loadNodeJson: (containerRequest: ContainerRequestResource) => void;
     loadOutputDefinitions: (containerRequest: ContainerRequestResource) => void;
     updateOutputParams: () => void;
+    pollProcessLogs: (processUuid: string) => Promise<void>;
 }
 
 export type ProcessPanelRootProps = ProcessPanelRootDataProps & ProcessPanelRootActionProps & WithStyles<CssRules>;
@@ -93,7 +94,6 @@ export const ProcessPanelRoot = withStyles(styles)(
         updateOutputParams,
         ...props
     }: ProcessPanelRootProps) => {
-
         const outputUuid = process?.containerRequest.outputUuid;
         const containerRequest = process?.containerRequest;
         const inputMounts = getInputCollectionMounts(process?.containerRequest);
@@ -117,9 +117,18 @@ export const ProcessPanelRoot = withStyles(styles)(
             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">
+        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
                         process={process}
                         onContextMenu={event => props.onContextMenu(event, process)}
@@ -128,28 +137,39 @@ export const ProcessPanelRoot = withStyles(styles)(
                         resumeOnHoldWorkflow={props.resumeOnHoldWorkflow}
                     />
                 </MPVPanelContent>
-                <MPVPanelContent forwardProps xs="auto" data-cy="process-cmd">
+                <MPVPanelContent
+                    forwardProps
+                    xs="auto"
+                    data-cy="process-cmd">
                     <ProcessCmdCard
                         onCopy={props.onCopyToClipboard}
-                        process={process} />
+                        process={process}
+                    />
                 </MPVPanelContent>
-                <MPVPanelContent forwardProps xs minHeight='50%' data-cy="process-logs">
+                <MPVPanelContent
+                    forwardProps
+                    xs
+                    minHeight="50%"
+                    data-cy="process-logs">
                     <ProcessLogsCard
                         onCopy={props.onCopyToClipboard}
                         process={process}
                         lines={getProcessPanelLogs(processLogsPanel)}
                         selectedFilter={{
                             label: processLogsPanel.selectedFilter,
-                            value: processLogsPanel.selectedFilter
+                            value: processLogsPanel.selectedFilter,
                         }}
-                        filters={processLogsPanel.filters.map(
-                            filter => ({ label: filter, value: filter })
-                        )}
+                        filters={processLogsPanel.filters.map(filter => ({ label: filter, value: filter }))}
                         onLogFilterChange={props.onLogFilterChange}
                         navigateToLog={props.navigateToLog}
+                        pollProcessLogs={props.pollProcessLogs}
                     />
                 </MPVPanelContent>
-                <MPVPanelContent forwardProps xs maxHeight='50%' data-cy="process-inputs">
+                <MPVPanelContent
+                    forwardProps
+                    xs
+                    maxHeight="50%"
+                    data-cy="process-inputs">
                     <ProcessIOCard
                         label={ProcessIOCardType.INPUT}
                         process={process}
@@ -158,7 +178,11 @@ export const ProcessPanelRoot = withStyles(styles)(
                         mounts={inputMounts}
                     />
                 </MPVPanelContent>
-                <MPVPanelContent forwardProps xs maxHeight='50%' data-cy="process-outputs">
+                <MPVPanelContent
+                    forwardProps
+                    xs
+                    maxHeight="50%"
+                    data-cy="process-outputs">
                     <ProcessIOCard
                         label={ProcessIOCardType.OUTPUT}
                         process={process}
@@ -167,23 +191,28 @@ export const ProcessPanelRoot = withStyles(styles)(
                         outputUuid={outputUuid || ""}
                     />
                 </MPVPanelContent>
-                <MPVPanelContent forwardProps xs data-cy="process-resources">
+                <MPVPanelContent
+                    forwardProps
+                    xs
+                    data-cy="process-resources">
                     <ProcessResourceCard
                         process={process}
                         nodeInfo={nodeInfo}
                     />
                 </MPVPanelContent>
-                <MPVPanelContent forwardProps xs maxHeight='50%' data-cy="process-children">
-                    <SubprocessPanel />
+                <MPVPanelContent
+                    forwardProps
+                    xs
+                    maxHeight="50%"
+                    data-cy="process-children">
+                    <SubprocessPanel process={process} />
                 </MPVPanelContent>
             </MPVContainer>
-            : <Grid container
-                alignItems='center'
-                justify='center'
-                style={{ minHeight: '100%' }}>
-                <DefaultView
-                    icon={ProcessIcon}
-                    messages={['Process not found']} />
-            </Grid>;
+        ) : (
+            <NotFoundView
+                icon={ProcessIcon}
+                messages={["Process not found"]}
+            />
+        );
     }
 );
index 9dcb72cf8810ee59fd841610f157c46f4d69569a..4a6b5fd33344600e1a5e6af1d71e4ecbd09b0a29 100644 (file)
@@ -2,35 +2,28 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { RootState } from 'store/store';
-import { connect } from 'react-redux';
-import { getProcess, getSubprocesses, Process, getProcessStatus } from 'store/processes/process';
-import { Dispatch } from 'redux';
-import { openProcessContextMenu } from 'store/context-menu/context-menu-actions';
-import {
-    ProcessPanelRootDataProps,
-    ProcessPanelRootActionProps,
-    ProcessPanelRoot
-} from './process-panel-root';
-import {
-    getProcessPanelCurrentUuid,
-    ProcessPanel as ProcessPanelState
-} from 'store/process-panel/process-panel';
-import { groupBy } from 'lodash';
+import { RootState } from "store/store";
+import { connect } from "react-redux";
+import { getProcess, getSubprocesses, Process, getProcessStatus } from "store/processes/process";
+import { Dispatch } from "redux";
+import { openProcessContextMenu } from "store/context-menu/context-menu-actions";
+import { ProcessPanelRootDataProps, ProcessPanelRootActionProps, ProcessPanelRoot } from "./process-panel-root";
+import { getProcessPanelCurrentUuid, ProcessPanel as ProcessPanelState } from "store/process-panel/process-panel";
+import { groupBy } from "lodash";
 import {
     loadInputs,
     loadOutputDefinitions,
     loadOutputs,
     toggleProcessPanelFilter,
     updateOutputParams,
-    loadNodeJson
-} from 'store/process-panel/process-panel-actions';
-import { cancelRunningWorkflow, resumeOnHoldWorkflow, startWorkflow } 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';
+    loadNodeJson,
+} from "store/process-panel/process-panel-actions";
+import { cancelRunningWorkflow, resumeOnHoldWorkflow, startWorkflow } from "store/processes/processes-actions";
+import { navigateToLogCollection, pollProcessLogs, setProcessLogsPanelFilter } from "store/process-logs-panel/process-logs-panel-actions";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
 
 const mapStateToProps = ({ router, auth, resources, processPanel, processLogsPanel }: RootState): ProcessPanelRootDataProps => {
-    const uuid = getProcessPanelCurrentUuid(router) || '';
+    const uuid = getProcessPanelCurrentUuid(router) || "";
     const subprocesses = getSubprocesses(uuid)(resources);
     return {
         process: getProcess(uuid)(resources),
@@ -49,40 +42,43 @@ const mapStateToProps = ({ router, auth, resources, processPanel, processLogsPan
 
 const mapDispatchToProps = (dispatch: Dispatch): ProcessPanelRootActionProps => ({
     onCopyToClipboard: (message: string) => {
-        dispatch<any>(snackbarActions.OPEN_SNACKBAR({
-            message,
-            hideDuration: 2000,
-            kind: SnackbarKind.SUCCESS,
-        }));
+        dispatch<any>(
+            snackbarActions.OPEN_SNACKBAR({
+                message,
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
     },
     onContextMenu: (event, process) => {
-        dispatch<any>(openProcessContextMenu(event, process));
+        if (process) {
+            dispatch<any>(openProcessContextMenu(event, process));
+        }
     },
     onToggle: status => {
         dispatch<any>(toggleProcessPanelFilter(status));
     },
-    cancelProcess: (uuid) => dispatch<any>(cancelRunningWorkflow(uuid)),
-    startProcess: (uuid) => dispatch<any>(startWorkflow(uuid)),
-    resumeOnHoldWorkflow: (uuid) => dispatch<any>(resumeOnHoldWorkflow(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)),
+    cancelProcess: uuid => dispatch<any>(cancelRunningWorkflow(uuid)),
+    startProcess: uuid => dispatch<any>(startWorkflow(uuid)),
+    resumeOnHoldWorkflow: uuid => dispatch<any>(resumeOnHoldWorkflow(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()),
-    loadNodeJson: (containerRequest) => dispatch<any>(loadNodeJson(containerRequest)),
+    loadNodeJson: containerRequest => dispatch<any>(loadNodeJson(containerRequest)),
+    pollProcessLogs: processUuid => dispatch<any>(pollProcessLogs(processUuid)),
 });
 
 const getFilters = (processPanel: ProcessPanelState, processes: Process[]) => {
     const grouppedProcesses = groupBy(processes, getProcessStatus);
-    return Object
-        .keys(processPanel.filters)
-        .map(filter => ({
-            label: filter,
-            value: (grouppedProcesses[filter] || []).length,
-            checked: processPanel.filters[filter],
-            key: filter,
-        }));
+    return Object.keys(processPanel.filters).map(filter => ({
+        label: filter,
+        value: (grouppedProcesses[filter] || []).length,
+        checked: processPanel.filters[filter],
+        key: filter,
+    }));
 };
 
 export const ProcessPanel = connect(mapStateToProps, mapDispatchToProps)(ProcessPanelRoot);
index d25d2a21f75602595af43ba36e7fba818feb00a6..b39f48ea12af81634b8e801159c0f9bf41489563 100644 (file)
@@ -119,7 +119,7 @@ export const ProcessResourceCard = withStyles(styles)(connect()(
                                 <DetailsAttribute label="Cores" value={process.container?.runtimeConstraints.vcpus} />
                             </Grid>
                             <Grid item xs={12}>
-                                <DetailsAttribute label="RAM" value={formatFileSize(process.container?.runtimeConstraints.ram)} />
+                                <DetailsAttribute label="RAM*" value={formatFileSize(process.container?.runtimeConstraints.ram)} />
                             </Grid>
                             <Grid item xs={12}>
                                 <DetailsAttribute label="Disk" value={formatFileSize(diskRequest)} />
@@ -214,6 +214,7 @@ export const ProcessResourceCard = withStyles(styles)(connect()(
                             </Grid>}
                     </Grid>
                 </Grid>
+                <Typography>* RAM available to the program is limited to Requested RAM, not Instance RAM</Typography>
             </CardContent>
         </Card >;
     }
index 684fd448443b7102042b3527cebbb5d001ecd3ae..efaf53eb49b21334d87227740cfcaabf9ceaaa33 100644 (file)
@@ -3,12 +3,12 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import React from 'react';
-import withStyles from "@material-ui/core/styles/withStyles";
+import withStyles from '@material-ui/core/styles/withStyles';
 import { DispatchProp, connect } from 'react-redux';
 import { RouteComponentProps } from 'react-router';
-import { StyleRulesCallback, WithStyles } from "@material-ui/core";
+import { StyleRulesCallback, WithStyles } from '@material-ui/core';
 
-import { DataExplorer } from "views-components/data-explorer/data-explorer";
+import { DataExplorer } from 'views-components/data-explorer/data-explorer';
 import { DataColumns } from 'components/data-table/data-table';
 import { RootState } from 'store/store';
 import { DataTableFilterItem } from 'components/data-table-filters/data-table-filters';
@@ -37,61 +37,54 @@ import {
     ResourceDeleteDate,
 } from 'views-components/data-explorer/renderers';
 import { ProjectIcon } from 'components/icon/icon';
-import {
-    ResourcesState,
-    getResource
-} from 'store/resources/resources';
+import { ResourcesState, getResource } from 'store/resources/resources';
 import { loadDetailsPanel } from 'store/details-panel/details-panel-action';
-import {
-    openContextMenu,
-    resourceUuidToContextMenuKind
-} from 'store/context-menu/context-menu-actions';
+import { openContextMenu, resourceUuidToContextMenuKind } from 'store/context-menu/context-menu-actions';
 import { navigateTo } from 'store/navigation/navigation-action';
 import { getProperty } from 'store/properties/properties';
 import { PROJECT_PANEL_CURRENT_UUID } from 'store/project-panel/project-panel-action';
-import { ArvadosTheme } from "common/custom-theme";
+import { ArvadosTheme } from 'common/custom-theme';
 import { createTree } from 'models/tree';
-import {
-    getInitialResourceTypeFilters,
-    getInitialProcessStatusFilters
-} from 'store/resource-type-filters/resource-type-filters';
+import { getInitialResourceTypeFilters, getInitialProcessStatusFilters } from 'store/resource-type-filters/resource-type-filters';
 import { GroupContentsResource } from 'services/groups-service/groups-service';
 import { GroupClass, GroupResource } from 'models/group';
 import { CollectionResource } from 'models/collection';
 import { resourceIsFrozen } from 'common/frozen-resources';
 import { ProjectResource } from 'models/project';
+import { NotFoundView } from 'views/not-found-panel/not-found-panel';
+import { toggleOne } from 'store/multiselect/multiselect-actions';
 
-type CssRules = 'root' | "button";
+type CssRules = 'root' | 'button';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
         width: '100%',
     },
     button: {
-        marginLeft: theme.spacing.unit
+        marginLeft: theme.spacing.unit,
     },
 });
 
 export enum ProjectPanelColumnNames {
-    NAME = "Name",
-    STATUS = "Status",
-    TYPE = "Type",
-    OWNER = "Owner",
-    PORTABLE_DATA_HASH = "Portable Data Hash",
-    FILE_SIZE = "File Size",
-    FILE_COUNT = "File Count",
-    UUID = "UUID",
-    CONTAINER_UUID = "Container UUID",
-    RUNTIME = "Runtime",
-    OUTPUT_UUID = "Output UUID",
-    LOG_UUID = "Log UUID",
+    NAME = 'Name',
+    STATUS = 'Status',
+    TYPE = 'Type',
+    OWNER = 'Owner',
+    PORTABLE_DATA_HASH = 'Portable Data Hash',
+    FILE_SIZE = 'File Size',
+    FILE_COUNT = 'File Count',
+    UUID = 'UUID',
+    CONTAINER_UUID = 'Container UUID',
+    RUNTIME = 'Runtime',
+    OUTPUT_UUID = 'Output UUID',
+    LOG_UUID = 'Log UUID',
     PARENT_PROCESS = 'Parent Process UUID',
     MODIFIED_BY_USER_UUID = 'Modified by User UUID',
-    VERSION = "Version",
-    CREATED_AT = "Date Created",
-    LAST_MODIFIED = "Last Modified",
-    TRASH_AT = "Trash at",
-    DELETE_AT = "Delete at",
+    VERSION = 'Version',
+    CREATED_AT = 'Date Created',
+    LAST_MODIFIED = 'Last Modified',
+    TRASH_AT = 'Trash at',
+    DELETE_AT = 'Delete at',
 }
 
 export interface ProjectPanelFilter extends DataTableFilterItem {
@@ -103,9 +96,9 @@ export const projectPanelColumns: DataColumns<string, ProjectResource> = [
         name: ProjectPanelColumnNames.NAME,
         selected: true,
         configurable: true,
-        sort: {direction: SortDirection.NONE, field: "name"},
+        sort: { direction: SortDirection.NONE, field: 'name' },
         filters: createTree(),
-        render: uuid => <ResourceName uuid={uuid} />
+        render: (uuid) => <ResourceName uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.STATUS,
@@ -113,179 +106,187 @@ export const projectPanelColumns: DataColumns<string, ProjectResource> = [
         configurable: true,
         mutuallyExclusiveFilters: true,
         filters: getInitialProcessStatusFilters(),
-        render: uuid => <ResourceStatus uuid={uuid} />,
+        render: (uuid) => <ResourceStatus uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.TYPE,
         selected: true,
         configurable: true,
         filters: getInitialResourceTypeFilters(),
-        render: uuid => <ResourceType uuid={uuid} />
+        render: (uuid) => <ResourceType uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.OWNER,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceOwnerWithName uuid={uuid} />
+        render: (uuid) => <ResourceOwnerWithName uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.PORTABLE_DATA_HASH,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourcePortableDataHash uuid={uuid} />
+        render: (uuid) => <ResourcePortableDataHash uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.FILE_SIZE,
         selected: true,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceFileSize uuid={uuid} />
+        render: (uuid) => <ResourceFileSize uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.FILE_COUNT,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceFileCount uuid={uuid} />
+        render: (uuid) => <ResourceFileCount uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.UUID,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceUUID uuid={uuid} />
+        render: (uuid) => <ResourceUUID uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.CONTAINER_UUID,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceContainerUuid uuid={uuid} />
+        render: (uuid) => <ResourceContainerUuid uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.RUNTIME,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ContainerRunTime uuid={uuid} />
+        render: (uuid) => <ContainerRunTime uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.OUTPUT_UUID,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceOutputUuid uuid={uuid} />
+        render: (uuid) => <ResourceOutputUuid uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.LOG_UUID,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceLogUuid uuid={uuid} />
+        render: (uuid) => <ResourceLogUuid uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.PARENT_PROCESS,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceParentProcess uuid={uuid} />
+        render: (uuid) => <ResourceParentProcess uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.MODIFIED_BY_USER_UUID,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceModifiedByUserUuid uuid={uuid} />
+        render: (uuid) => <ResourceModifiedByUserUuid uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.VERSION,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceVersion uuid={uuid} />
+        render: (uuid) => <ResourceVersion uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.CREATED_AT,
         selected: false,
         configurable: true,
-        sort: {direction: SortDirection.NONE, field: "createdAt"},
+        sort: { direction: SortDirection.NONE, field: 'createdAt' },
         filters: createTree(),
-        render: uuid => <ResourceCreatedAtDate uuid={uuid} />
+        render: (uuid) => <ResourceCreatedAtDate uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.LAST_MODIFIED,
         selected: true,
         configurable: true,
-        sort: {direction: SortDirection.DESC, field: "modifiedAt"},
+        sort: { direction: SortDirection.DESC, field: 'modifiedAt' },
         filters: createTree(),
-        render: uuid => <ResourceLastModifiedDate uuid={uuid} />
+        render: (uuid) => <ResourceLastModifiedDate uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.TRASH_AT,
         selected: false,
         configurable: true,
-        sort: {direction: SortDirection.NONE, field: "trashAt"},
+        sort: { direction: SortDirection.NONE, field: 'trashAt' },
         filters: createTree(),
-        render: uuid => <ResourceTrashDate uuid={uuid} />
+        render: (uuid) => <ResourceTrashDate uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.DELETE_AT,
         selected: false,
         configurable: true,
-        sort: {direction: SortDirection.NONE, field: "deleteAt"},
+        sort: { direction: SortDirection.NONE, field: 'deleteAt' },
         filters: createTree(),
-        render: uuid => <ResourceDeleteDate uuid={uuid} />
+        render: (uuid) => <ResourceDeleteDate uuid={uuid} />,
     },
-
 ];
 
-export const PROJECT_PANEL_ID = "projectPanel";
+export const PROJECT_PANEL_ID = 'projectPanel';
 
-const DEFAULT_VIEW_MESSAGES = [
-    'Your project is empty.',
-    'Please create a project or create a collection and upload a data.',
-];
+const DEFAULT_VIEW_MESSAGES = ['Your project is empty.', 'Please create a project or create a collection and upload a data.'];
 
 interface ProjectPanelDataProps {
     currentItemId: string;
     resources: ResourcesState;
+    project: GroupResource;
     isAdmin: boolean;
     userUuid: string;
     dataExplorerItems: any;
 }
 
-type ProjectPanelProps = ProjectPanelDataProps & DispatchProp
-    & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
+type ProjectPanelProps = ProjectPanelDataProps & DispatchProp & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
 
+const mapStateToProps = (state: RootState) => {
+    const currentItemId = getProperty<string>(PROJECT_PANEL_CURRENT_UUID)(state.properties);
+    const project = getResource<GroupResource>(currentItemId || "")(state.resources);
+    return {
+        currentItemId,
+        project,
+        resources: state.resources,
+        userUuid: state.auth.user!.uuid,
+    };
+}
 
 export const ProjectPanel = withStyles(styles)(
-    connect((state: RootState) => ({
-        currentItemId: getProperty(PROJECT_PANEL_CURRENT_UUID)(state.properties),
-        resources: state.resources,
-        userUuid: state.auth.user!.uuid
-    }))(
+    connect(mapStateToProps)(
         class extends React.Component<ProjectPanelProps> {
             render() {
                 const { classes } = this.props;
 
-                return <div data-cy='project-panel' className={classes.root}>
-                    <DataExplorer
-                        id={PROJECT_PANEL_ID}
-                        onRowClick={this.handleRowClick}
-                        onRowDoubleClick={this.handleRowDoubleClick}
-                        onContextMenu={this.handleContextMenu}
-                        contextMenuColumn={true}
-                        defaultViewIcon={ProjectIcon}
-                        defaultViewMessages={DEFAULT_VIEW_MESSAGES}
+                return this.props.project ?
+                    <div data-cy='project-panel' className={classes.root}>
+                        <DataExplorer
+                            id={PROJECT_PANEL_ID}
+                            onRowClick={this.handleRowClick}
+                            onRowDoubleClick={this.handleRowDoubleClick}
+                            onContextMenu={this.handleContextMenu}
+                            contextMenuColumn={true}
+                            defaultViewIcon={ProjectIcon}
+                            defaultViewMessages={DEFAULT_VIEW_MESSAGES}
+                        />
+                    </div>
+                    :
+                    <NotFoundView
+                        icon={ProjectIcon}
+                        messages={["Project not found"]}
                     />
-                </div>;
             }
 
             isCurrentItemChild = (resource: Resource) => {
                 return resource.ownerUuid === this.props.currentItemId;
-            }
+            };
 
             handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
                 const { resources, isAdmin } = this.props;
@@ -299,31 +300,33 @@ export const ProjectPanel = withStyles(styles)(
 
                 const menuKind = this.props.dispatch<any>(resourceUuidToContextMenuKind(resourceUuid, readonly));
                 if (menuKind && resource) {
-                    this.props.dispatch<any>(openContextMenu(event, {
-                        name: resource.name,
-                        uuid: resource.uuid,
-                        ownerUuid: resource.ownerUuid,
-                        isTrashed: ('isTrashed' in resource) ? resource.isTrashed : false,
-                        kind: resource.kind,
-                        menuKind,
-                        isAdmin,
-                        isFrozen: resourceIsFrozen(resource, resources),
-                        description: resource.description,
-                        storageClassesDesired: (resource as CollectionResource).storageClassesDesired,
-                        properties: ('properties' in resource) ? resource.properties : {},
-                    }));
+                    this.props.dispatch<any>(
+                        openContextMenu(event, {
+                            name: resource.name,
+                            uuid: resource.uuid,
+                            ownerUuid: resource.ownerUuid,
+                            isTrashed: 'isTrashed' in resource ? resource.isTrashed : false,
+                            kind: resource.kind,
+                            menuKind,
+                            isAdmin,
+                            isFrozen: resourceIsFrozen(resource, resources),
+                            description: resource.description,
+                            storageClassesDesired: (resource as CollectionResource).storageClassesDesired,
+                            properties: 'properties' in resource ? resource.properties : {},
+                        })
+                    );
                 }
                 this.props.dispatch<any>(loadDetailsPanel(resourceUuid));
-            }
+            };
 
             handleRowDoubleClick = (uuid: string) => {
                 this.props.dispatch<any>(navigateTo(uuid));
-            }
+            };
 
             handleRowClick = (uuid: string) => {
+                this.props.dispatch<any>(toggleOne(uuid))
                 this.props.dispatch<any>(loadDetailsPanel(uuid));
-            }
-
+            };
         }
     )
 );
index 47c8aedebfc7645ca4cf451c749ce60418bcd1eb..5cb10c4c66b9af0fdf5b71317c4aad9384b2b0af 100644 (file)
@@ -36,6 +36,7 @@ import { PublicFavoritesState } from 'store/public-favorites/public-favorites-re
 import { getResource, ResourcesState } from 'store/resources/resources';
 import { GroupContentsResource } from 'services/groups-service/groups-service';
 import { CollectionResource } from 'models/collection';
+import { toggleOne } from 'store/multiselect/multiselect-actions';
 
 type CssRules = "toolbar" | "button" | "root";
 
@@ -145,7 +146,8 @@ const mapDispatchToProps = (dispatch: Dispatch): PublicFavoritePanelActionProps
     },
     onDialogOpen: (ownerUuid: string) => { return; },
     onItemClick: (uuid: string) => {
-        dispatch<any>(loadDetailsPanel(uuid));
+                dispatch<any>(toggleOne(uuid))
+                dispatch<any>(loadDetailsPanel(uuid));
     },
     onItemDoubleClick: uuid => {
         dispatch<any>(navigateTo(uuid));
index 996c7bdf49f07dd6e83eb124e39ba50673f3144f..3ec5c56c62b12e5f4ff69e9410c6dc2ad1c21a1f 100644 (file)
@@ -10,7 +10,7 @@ import { ArvadosTheme } from 'common/custom-theme';
 import { Link } from 'react-router-dom';
 import { Dispatch, compose } from 'redux';
 import { RootState } from 'store/store';
-import { HelpIcon, AddIcon, MoreOptionsIcon } from 'components/icon/icon';
+import { HelpIcon, AddIcon, MoreVerticalIcon } from 'components/icon/icon';
 import { loadRepositoriesData, openRepositoriesSampleGitDialog, openRepositoryCreateDialog } from 'store/repositories/repositories-actions';
 import { RepositoryResource } from 'models/repositories';
 import { openRepositoryContextMenu } from 'store/context-menu/context-menu-actions';
@@ -138,7 +138,7 @@ export const RepositoriesPanel = compose(
                                                 <TableCell className={classes.moreOptions}>
                                                     <Tooltip title="More options" disableFocusListener>
                                                         <IconButton onClick={event => onOptionsMenuOpen(event, repository)} className={classes.moreOptionsButton}>
-                                                            <MoreOptionsIcon />
+                                                            <MoreVerticalIcon />
                                                         </IconButton>
                                                     </Tooltip>
                                                 </TableCell>
@@ -151,4 +151,4 @@ export const RepositoriesPanel = compose(
                 );
             }
         }
-    );
\ No newline at end of file
+    );
index 27255bd961e99f9db306ff5e2a637c921352a2b6..dd5bb2f8ea982da4e36b9078fc8e893c092d7bfb 100644 (file)
@@ -15,7 +15,7 @@ import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button, Divid
 import { GenericInputProps, GenericInput } from './generic-input';
 import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker';
 import { connect, DispatchProp } from 'react-redux';
-import { initProjectsTreePicker, getSelectedNodes, treePickerActions, getProjectsTreePickerIds, getAllNodes } from 'store/tree-picker/tree-picker-actions';
+import { initProjectsTreePicker, getSelectedNodes, treePickerActions, getProjectsTreePickerIds, FileOperationLocation, getFileOperationLocation, fileOperationLocationToPickerId } from 'store/tree-picker/tree-picker-actions';
 import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware';
 import { createSelector, createStructuredSelector } from 'reselect';
 import { ChipsInput } from 'components/chips-input/chips-input';
@@ -26,8 +26,11 @@ import { RootState } from 'store/store';
 import { Chips } from 'components/chips/chips';
 import withStyles, { StyleRulesCallback } from '@material-ui/core/styles/withStyles';
 import { CollectionResource } from 'models/collection';
-import { ResourceKind } from 'models/resource';
+import { PORTABLE_DATA_HASH_PATTERN, ResourceKind } from 'models/resource';
+import { Dispatch } from 'redux';
+import { CollectionDirectory, CollectionFileType } from 'models/collection-file';
 
+const LOCATION_REGEX = new RegExp("^(?:keep:)?(" + PORTABLE_DATA_HASH_PATTERN + ")(/.*)?$");
 export interface DirectoryArrayInputProps {
     input: DirectoryArrayCommandInputParameter;
     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
@@ -45,26 +48,35 @@ export const DirectoryArrayInput = ({ input }: DirectoryArrayInputProps) =>
 interface FormattedDirectory {
     name: string;
     portableDataHash: string;
+    subpath: string;
 }
 
-const parseDirectories = (directories: CollectionResource[] | string) =>
+const parseDirectories = (directories: FileOperationLocation[] | string) =>
     typeof directories === 'string'
         ? undefined
         : directories.map(parse);
 
-const parse = (directory: CollectionResource): Directory => ({
+const parse = (directory: FileOperationLocation): Directory => ({
     class: CWLType.DIRECTORY,
     basename: directory.name,
-    location: `keep:${directory.portableDataHash}`,
+    location: `keep:${directory.pdh}${directory.subpath}`,
 });
 
-const formatDirectories = (directories: Directory[] = []) =>
-    directories ? directories.map(format) : [];
+const formatDirectories = (directories: Directory[] = []): FormattedDirectory[] =>
+    directories ? directories.map(format).filter((dir): dir is FormattedDirectory => Boolean(dir)) : [];
 
-const format = ({ location = '', basename = '' }: Directory): FormattedDirectory => ({
-    portableDataHash: location.replace('keep:', ''),
-    name: basename,
-});
+const format = ({ location = '', basename = '' }: Directory): FormattedDirectory | undefined => {
+    const match = LOCATION_REGEX.exec(location);
+
+    if (match) {
+        return {
+            portableDataHash: match[1],
+            subpath: match[2],
+            name: basename,
+        };
+    }
+    return undefined;
+};
 
 const validationSelector = createSelector(
     isRequiredInput,
@@ -79,11 +91,10 @@ const required = (value?: Directory[]) =>
         : ERROR_MESSAGE;
 interface DirectoryArrayInputComponentState {
     open: boolean;
-    directories: CollectionResource[];
-    prevDirectories: CollectionResource[];
+    directories: FileOperationLocation[];
 }
 
-interface DirectoryArrayInputComponentProps {
+interface DirectoryArrayInputDataProps {
     treePickerState: TreePicker;
 }
 
@@ -93,21 +104,39 @@ const mapStateToProps = createStructuredSelector({
     treePickerState: treePickerSelector,
 });
 
-const DirectoryArrayInputComponent = connect(mapStateToProps)(
-    class DirectoryArrayInputComponent extends React.Component<DirectoryArrayInputComponentProps & GenericInputProps & DispatchProp & {
+interface DirectoryArrayInputActionProps {
+    initProjectsTreePicker: (pickerId: string) => void;
+    selectTreePickerNode: (pickerId: string, id: string | string[]) => void;
+    deselectTreePickerNode: (pickerId: string, id: string | string[]) => void;
+    getFileOperationLocation: (item: ProjectsTreePickerItem) => Promise<FileOperationLocation | undefined>;
+}
+
+const mapDispatchToProps = (dispatch: Dispatch): DirectoryArrayInputActionProps => ({
+    initProjectsTreePicker: (pickerId: string) => dispatch<any>(initProjectsTreePicker(pickerId)),
+    selectTreePickerNode: (pickerId: string, id: string | string[]) =>
+        dispatch<any>(treePickerActions.SELECT_TREE_PICKER_NODE({
+            pickerId, id, cascade: false
+        })),
+    deselectTreePickerNode: (pickerId: string, id: string | string[]) =>
+        dispatch<any>(treePickerActions.DESELECT_TREE_PICKER_NODE({
+            pickerId, id, cascade: false
+        })),
+    getFileOperationLocation: (item: ProjectsTreePickerItem) => dispatch<any>(getFileOperationLocation(item)),
+});
+
+const DirectoryArrayInputComponent = connect(mapStateToProps, mapDispatchToProps)(
+    class DirectoryArrayInputComponent extends React.Component<GenericInputProps & DirectoryArrayInputDataProps & DirectoryArrayInputActionProps & DispatchProp & {
         options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
     }, DirectoryArrayInputComponentState> {
         state: DirectoryArrayInputComponentState = {
             open: false,
             directories: [],
-            prevDirectories: [],
         };
 
         directoryRefreshTimeout = -1;
 
         componentDidMount() {
-            this.props.dispatch<any>(
-                initProjectsTreePicker(this.props.commandInput.id));
+            this.props.initProjectsTreePicker(this.props.commandInput.id);
         }
 
         render() {
@@ -118,7 +147,6 @@ const DirectoryArrayInputComponent = connect(mapStateToProps)(
         }
 
         openDialog = () => {
-            this.setDirectoriesFromProps(this.props.input.value);
             this.setState({ open: true });
         }
 
@@ -131,82 +159,52 @@ const DirectoryArrayInputComponent = connect(mapStateToProps)(
             this.props.input.onChange(this.state.directories);
         }
 
-        setDirectories = (directories: CollectionResource[]) => {
+        setDirectoriesFromResources = async (directories: (CollectionResource | CollectionDirectory)[]) => {
+            const locations = (await Promise.all(
+                directories.map(directory => (this.props.getFileOperationLocation(directory)))
+            )).filter((location): location is FileOperationLocation => (
+                location !== undefined
+            ));
 
-            const deletedDirectories = this.state.directories
-                .reduce((deletedDirectories, directory) =>
-                    directories.some(({ uuid }) => uuid === directory.uuid)
-                        ? deletedDirectories
-                        : [...deletedDirectories, directory]
-                    , []);
-
-            this.setState({ directories });
-
-            const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
-            ids.forEach(pickerId => {
-                this.props.dispatch(
-                    treePickerActions.DESELECT_TREE_PICKER_NODE({
-                        pickerId, id: deletedDirectories.map(({ uuid }) => uuid),
-                    })
-                );
-            });
+            this.setDirectories(locations);
+        }
 
+        refreshDirectories = () => {
+            clearTimeout(this.directoryRefreshTimeout);
+            this.directoryRefreshTimeout = window.setTimeout(this.setDirectoriesFromTree);
         }
 
-        setDirectoriesFromProps = (formattedDirectories: FormattedDirectory[]) => {
-            const nodes = getAllNodes<ProjectsTreePickerItem>(this.props.commandInput.id)(this.props.treePickerState);
-            const initialDirectories: CollectionResource[] = [];
+        setDirectoriesFromTree = () => {
+            const nodes = getSelectedNodes<ProjectsTreePickerItem>(this.props.commandInput.id)(this.props.treePickerState);
+            const initialDirectories: (CollectionResource | CollectionDirectory)[] = [];
             const directories = nodes
                 .reduce((directories, { value }) =>
-                    'kind' in value &&
-                        value.kind === ResourceKind.COLLECTION &&
-                        formattedDirectories.find(({ portableDataHash, name }) => value.portableDataHash === portableDataHash && value.name === name)
+                    (('kind' in value && value.kind === ResourceKind.COLLECTION) ||
+                    ('type' in value && value.type === CollectionFileType.DIRECTORY))
                         ? directories.concat(value)
                         : directories, initialDirectories);
+            this.setDirectoriesFromResources(directories);
+        }
+
+        setDirectories = (locations: FileOperationLocation[]) => {
+            const deletedDirectories = this.state.directories
+                .reduce((deletedDirectories, directory) =>
+                    locations.some(({ uuid, subpath }) => uuid === directory.uuid && subpath === directory.subpath)
+                        ? deletedDirectories
+                        : [...deletedDirectories, directory]
+                    , [] as FileOperationLocation[]);
 
-            const addedDirectories = directories
-                .reduce((addedDirectories, directory) =>
-                    this.state.directories.find(({ uuid }) =>
-                        uuid === directory.uuid)
-                        ? addedDirectories
-                        : [...addedDirectories, directory]
-                    , []);
+            this.setState({ directories: locations });
 
             const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
             ids.forEach(pickerId => {
-                this.props.dispatch(
-                    treePickerActions.SELECT_TREE_PICKER_NODE({
-                        pickerId, id: addedDirectories.map(({ uuid }) => uuid),
-                    })
+                this.props.deselectTreePickerNode(
+                    pickerId,
+                    deletedDirectories.map(fileOperationLocationToPickerId)
                 );
             });
+        };
 
-            const orderedDirectories = formattedDirectories.reduce((dirs, formattedDir) => {
-                const dir = directories.find(({ portableDataHash, name }) => portableDataHash === formattedDir.portableDataHash && name === formattedDir.name);
-                return dir
-                    ? [...dirs, dir]
-                    : dirs;
-            }, []);
-
-            this.setDirectories(orderedDirectories);
-
-        }
-
-        refreshDirectories = () => {
-            clearTimeout(this.directoryRefreshTimeout);
-            this.directoryRefreshTimeout = window.setTimeout(this.setSelectedFiles);
-        }
-
-        setSelectedFiles = () => {
-            const nodes = getSelectedNodes<ProjectsTreePickerItem>(this.props.commandInput.id)(this.props.treePickerState);
-            const initialDirectories: CollectionResource[] = [];
-            const directories = nodes
-                .reduce((directories, { value }) =>
-                    'kind' in value && value.kind === ResourceKind.COLLECTION
-                        ? directories.concat(value)
-                        : directories, initialDirectories);
-            this.setDirectories(directories);
-        }
         input = () =>
             <GenericInput
                 component={this.chipsInput}
@@ -265,14 +263,17 @@ const DirectoryArrayInputComponent = connect(mapStateToProps)(
                     onClose={this.closeDialog}
                     fullWidth
                     maxWidth='md' >
-                    <DialogTitle>Choose collections</DialogTitle>
+                    <DialogTitle>Choose directories</DialogTitle>
                     <DialogContent className={classes.root}>
                         <div className={classes.pickerWrapper}>
                             <div className={classes.tree}>
                                 <ProjectsTreePicker
                                     pickerId={this.props.commandInput.id}
+                                    currentUuids={this.state.directories.map(dir => fileOperationLocationToPickerId(dir))}
                                     includeCollections
+                                    includeDirectories
                                     showSelection
+                                    cascadeSelection={false}
                                     options={this.props.options}
                                     toggleItemSelection={this.refreshDirectories} />
                             </div>
index 5348cc2b76ca93a4e24fc1d9474702bc327867e2..63c990fa9f2cb513759bc1d87caa52c1b440b220 100644 (file)
@@ -15,12 +15,11 @@ import {
 } from 'models/workflow';
 import { GenericInputProps, GenericInput } from './generic-input';
 import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker';
-import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
+import { FileOperationLocation, getFileOperationLocation, initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
 import { TreeItem } from 'components/tree/tree';
 import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware';
-import { CollectionResource } from 'models/collection';
-import { ResourceKind } from 'models/resource';
 import { ERROR_MESSAGE } from 'validators/require';
+import { Dispatch } from 'redux';
 
 export interface DirectoryInputProps {
     input: DirectoryCommandInputParameter;
@@ -43,9 +42,9 @@ export const DirectoryInput = ({ input, options }: DirectoryInputProps) =>
 
 const format = (value?: Directory) => value ? value.basename : '';
 
-const parse = (directory: CollectionResource): Directory => ({
+const parse = (directory: FileOperationLocation): Directory => ({
     class: CWLType.DIRECTORY,
-    location: `keep:${directory.portableDataHash}`,
+    location: `keep:${directory.pdh}${directory.subpath}`,
     basename: directory.name,
 });
 
@@ -59,11 +58,21 @@ const getValidation = memoize(
 
 interface DirectoryInputComponentState {
     open: boolean;
-    directory?: CollectionResource;
+    directory?: FileOperationLocation;
 }
 
-const DirectoryInputComponent = connect()(
-    class FileInputComponent extends React.Component<GenericInputProps & DispatchProp & {
+interface DirectoryInputActionProps {
+    initProjectsTreePicker: (pickerId: string) => void;
+    getFileOperationLocation: (item: ProjectsTreePickerItem) => Promise<FileOperationLocation | undefined>;
+}
+
+const mapDispatchToProps = (dispatch: Dispatch): DirectoryInputActionProps => ({
+    initProjectsTreePicker: (pickerId: string) => dispatch<any>(initProjectsTreePicker(pickerId)),
+    getFileOperationLocation: (item: ProjectsTreePickerItem) => dispatch<any>(getFileOperationLocation(item)),
+});
+
+const DirectoryInputComponent = connect(null, mapDispatchToProps)(
+    class FileInputComponent extends React.Component<GenericInputProps & DirectoryInputActionProps & DispatchProp & {
         options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
     }, DirectoryInputComponentState> {
         state: DirectoryInputComponentState = {
@@ -71,8 +80,7 @@ const DirectoryInputComponent = connect()(
         };
 
         componentDidMount() {
-            this.props.dispatch<any>(
-                initProjectsTreePicker(this.props.commandInput.id));
+            this.props.initProjectsTreePicker(this.props.commandInput.id);
         }
 
         render() {
@@ -95,12 +103,9 @@ const DirectoryInputComponent = connect()(
             this.props.input.onChange(this.state.directory);
         }
 
-        setDirectory = (_: {}, { data }: TreeItem<ProjectsTreePickerItem>) => {
-            if ('kind' in data && data.kind === ResourceKind.COLLECTION) {
-                this.setState({ directory: data });
-            } else {
-                this.setState({ directory: undefined });
-            }
+        setDirectory = async (_: {}, { data: item }: TreeItem<ProjectsTreePickerItem>) => {
+            const location = await this.props.getFileOperationLocation(item);
+            this.setState({ directory: location });
         }
 
         renderInput() {
@@ -143,6 +148,8 @@ const DirectoryInputComponent = connect()(
                             <ProjectsTreePicker
                                 pickerId={this.props.commandInput.id}
                                 includeCollections
+                                includeDirectories
+                                cascadeSelection={false}
                                 options={this.props.options}
                                 toggleItemActive={this.setDirectory} />
                         </div>
index f554aff2d3e1ea09d785fa19540565ece372b2fe..207a30acd0f6f0d28691fcf0d0db74e2e075efdc 100644 (file)
@@ -4,18 +4,38 @@
 
 import React from 'react';
 import { Field } from 'redux-form';
+import { memoize } from 'lodash/fp';
+import { require } from 'validators/require';
 import { Select, MenuItem } from '@material-ui/core';
-import { EnumCommandInputParameter, CommandInputEnumSchema } from 'models/workflow';
+import { EnumCommandInputParameter, CommandInputEnumSchema, isRequiredInput, getEnumType } from 'models/workflow';
 import { GenericInputProps, GenericInput } from './generic-input';
 
 export interface EnumInputProps {
     input: EnumCommandInputParameter;
 }
+
+const getValidation = memoize(
+    (input: EnumCommandInputParameter) => ([
+        isRequiredInput(input)
+            ? require
+            : () => undefined,
+    ]));
+
+const emptyToNull = value => {
+    if (value === '') {
+        return null;
+    } else {
+        return value;
+    }
+};
+
 export const EnumInput = ({ input }: EnumInputProps) =>
     <Field
         name={input.id}
         commandInput={input}
         component={EnumInputComponent}
+        validate={getValidation(input)}
+        normalize={emptyToNull}
     />;
 
 const EnumInputComponent = (props: GenericInputProps) =>
@@ -24,26 +44,26 @@ const EnumInputComponent = (props: GenericInputProps) =>
         {...props} />;
 
 const Input = (props: GenericInputProps) => {
-    const type = props.commandInput.type as CommandInputEnumSchema;
+    const type = getEnumType(props.commandInput) as CommandInputEnumSchema;
     return <Select
         value={props.input.value}
         onChange={props.input.onChange}
         disabled={props.commandInput.disabled} >
-        {type.symbols.map(symbol =>
+        {(isRequiredInput(props.commandInput) ? [] : [<MenuItem key={'_empty'} value={''} />]).concat(type.symbols.map(symbol =>
             <MenuItem key={symbol} value={extractValue(symbol)}>
                 {extractValue(symbol)}
-            </MenuItem>)}
+            </MenuItem>))}
     </Select>;
 };
 
 /**
- * Values in workflow definition have an absolute form, for example: 
- * 
+ * Values in workflow definition have an absolute form, for example:
+ *
  * ```#input_collector.cwl/enum_type/Pathway table```
- * 
+ *
  * We want a value that is in form accepted by backend.
  * According to the example above, the correct value is:
- * 
+ *
  * ```Pathway table```
  */
 const extractValue = (symbol: string) => symbol.split('/').pop();
index a2f884e3e636b9c404369fc5b02508887cdbf6de..99338738fa5e03c67b62482c4a25f28f68bd5c6e 100644 (file)
@@ -144,7 +144,9 @@ const FileArrayInputComponent = connect(mapStateToProps)(
             ids.forEach(pickerId => {
                 this.props.dispatch(
                     treePickerActions.DESELECT_TREE_PICKER_NODE({
-                        pickerId, id: deletedFiles.map(({ id }) => id),
+                        pickerId,
+                        id: deletedFiles.map(({ id }) => id),
+                        cascade: true,
                     })
                 );
             });
@@ -164,7 +166,9 @@ const FileArrayInputComponent = connect(mapStateToProps)(
             ids.forEach(pickerId => {
                 this.props.dispatch(
                     treePickerActions.SELECT_TREE_PICKER_NODE({
-                        pickerId, id: addedFiles.map(({ id }) => id),
+                        pickerId,
+                        id: addedFiles.map(({ id }) => id),
+                        cascade: true,
                     })
                 );
             });
@@ -254,8 +258,10 @@ const FileArrayInputComponent = connect(mapStateToProps)(
                                 <ProjectsTreePicker
                                     pickerId={this.props.commandInput.id}
                                     includeCollections
+                                    includeDirectories
                                     includeFiles
                                     showSelection
+                                    cascadeSelection={true}
                                     options={this.props.options}
                                     toggleItemSelection={this.refreshFiles} />
                             </div>
index b0206e1452e8d2845815665640af50b6a8470c5a..6970e2a5b531c9cb1af50a410075845ded643578 100644 (file)
@@ -142,7 +142,9 @@ const FileInputComponent = connect()(
                             <ProjectsTreePicker
                                 pickerId={this.props.commandInput.id}
                                 includeCollections
+                                includeDirectories
                                 includeFiles
+                                cascadeSelection={false}
                                 options={this.props.options}
                                 toggleItemActive={this.setFile} />
                         </div>
index 688af4aafac9f244fe96e07c192efdc6d3bd17da..438bbe8e7e40163b55b8d363a53b82dc5f23ab01 100644 (file)
@@ -99,7 +99,7 @@ export const ProjectInputComponent = connect(mapStateToProps)(
             }
         }
 
-        invalid = () => (!this.state.project || this.state.project.writableBy.indexOf(this.props.userUuid) === -1);
+        invalid = () => (!this.state.project || !this.state.project.canWrite);
 
         renderInput() {
             return <GenericInput
@@ -140,6 +140,7 @@ export const ProjectInputComponent = connect(mapStateToProps)(
                         <div className={classes.pickerWrapper}>
                             <ProjectsTreePicker
                                 pickerId={this.props.commandInput.id}
+                                cascadeSelection={false}
                                 options={this.props.options}
                                 toggleItemActive={this.setProject} />
                         </div>
index 46ab3c526247b770c059caf9343f61b86143610a..ca402ab01334f9f811a28d78060f26b2441d68a5 100644 (file)
@@ -7,7 +7,7 @@ import { reduxForm, InjectedFormProps } from 'redux-form';
 import { CommandInputParameter, CWLType, IntCommandInputParameter, BooleanCommandInputParameter, FileCommandInputParameter, DirectoryCommandInputParameter, DirectoryArrayCommandInputParameter, FloatArrayCommandInputParameter, IntArrayCommandInputParameter } from 'models/workflow';
 import { IntInput } from 'views/run-process-panel/inputs/int-input';
 import { StringInput } from 'views/run-process-panel/inputs/string-input';
-import { StringCommandInputParameter, FloatCommandInputParameter, isPrimitiveOfType, WorkflowInputsData, EnumCommandInputParameter, isArrayOfType, StringArrayCommandInputParameter, FileArrayCommandInputParameter } from '../../models/workflow';
+import { StringCommandInputParameter, FloatCommandInputParameter, isPrimitiveOfType, WorkflowInputsData, EnumCommandInputParameter, isArrayOfType, StringArrayCommandInputParameter, FileArrayCommandInputParameter, getEnumType } from '../../models/workflow';
 import { FloatInput } from 'views/run-process-panel/inputs/float-input';
 import { BooleanInput } from './inputs/boolean-input';
 import { FileInput } from './inputs/file-input';
@@ -94,9 +94,7 @@ const getInputComponent = (input: CommandInputParameter) => {
         case isPrimitiveOfType(input, CWLType.DIRECTORY):
             return <DirectoryInput options={{ showOnlyOwned: false, showOnlyWritable: false }} input={input as DirectoryCommandInputParameter} />;
 
-        case typeof input.type === 'object' &&
-            !(input.type instanceof Array) &&
-            input.type.type === 'enum':
+        case getEnumType(input) !== null:
             return <EnumInput input={input as EnumCommandInputParameter} />;
 
         case isArrayOfType(input, CWLType.STRING):
index 0902f15bdcca963a77369b970ff0e2d959cb31f0..320e85cb997afcdf160895e94c17c43b850b2d8f 100644 (file)
@@ -13,6 +13,7 @@ import { SearchBarAdvancedFormData } from 'models/search-bar';
 import { User } from "models/user";
 import { Config } from 'common/config';
 import { Session } from "models/session";
+import { toggleOne } from "store/multiselect/multiselect-actions";
 
 export interface SearchResultsPanelDataProps {
     data: SearchBarAdvancedFormData;
@@ -46,6 +47,7 @@ const mapDispatchToProps = (dispatch: Dispatch): SearchResultsPanelActionProps =
     },
     onDialogOpen: (ownerUuid: string) => { return; },
     onItemClick: (resourceUuid: string) => {
+        dispatch<any>(toggleOne(resourceUuid))
         dispatch<any>(loadDetailsPanel(resourceUuid));
     },
     onItemDoubleClick: uuid => {
index e6cfccd2694c4765570a76268e57cfa2d22d8c90..f3f827d1469fc27fe8c90d8f543123ba4755698d 100644 (file)
@@ -10,6 +10,7 @@ import { RootState } from 'store/store';
 import { ArvadosTheme } from 'common/custom-theme';
 import { ShareMeIcon } from 'components/icon/icon';
 import { ResourcesState, getResource } from 'store/resources/resources';
+import { ResourceKind } from 'models/resource';
 import { navigateTo } from "store/navigation/navigation-action";
 import { loadDetailsPanel } from "store/details-panel/details-panel-action";
 import { SHARED_WITH_ME_PANEL_ID } from 'store/shared-with-me-panel/shared-with-me-panel-actions';
@@ -17,7 +18,36 @@ import {
     openContextMenu,
     resourceUuidToContextMenuKind
 } from 'store/context-menu/context-menu-actions';
+import {
+    ResourceName,
+    ProcessStatus as ResourceStatus,
+    ResourceType,
+    ResourceOwnerWithNameLink,
+    ResourcePortableDataHash,
+    ResourceFileSize,
+    ResourceFileCount,
+    ResourceUUID,
+    ResourceContainerUuid,
+    ContainerRunTime,
+    ResourceOutputUuid,
+    ResourceLogUuid,
+    ResourceParentProcess,
+    ResourceModifiedByUserUuid,
+    ResourceVersion,
+    ResourceCreatedAtDate,
+    ResourceLastModifiedDate,
+    ResourceTrashDate,
+    ResourceDeleteDate,
+} from 'views-components/data-explorer/renderers';
+import { DataTableFilterItem } from 'components/data-table-filters/data-table-filters';
 import { GroupContentsResource } from 'services/groups-service/groups-service';
+import { toggleOne } from 'store/multiselect/multiselect-actions';
+import { DataColumns } from 'components/data-table/data-table';
+import { ContainerRequestState } from 'models/container-request';
+import { ProjectResource } from 'models/project';
+import { createTree } from 'models/tree';
+import { SortDirection } from 'components/data-table/data-column';
+import { getInitialResourceTypeFilters, getInitialProcessStatusFilters } from 'store/resource-type-filters/resource-type-filters';
 
 type CssRules = "toolbar" | "button" | "root";
 
@@ -34,6 +64,175 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     },
 });
 
+export enum SharedWithMePanelColumnNames {
+    NAME = 'Name',
+    STATUS = 'Status',
+    TYPE = 'Type',
+    OWNER = 'Owner',
+    PORTABLE_DATA_HASH = 'Portable Data Hash',
+    FILE_SIZE = 'File Size',
+    FILE_COUNT = 'File Count',
+    UUID = 'UUID',
+    CONTAINER_UUID = 'Container UUID',
+    RUNTIME = 'Runtime',
+    OUTPUT_UUID = 'Output UUID',
+    LOG_UUID = 'Log UUID',
+    PARENT_PROCESS = 'Parent Process UUID',
+    MODIFIED_BY_USER_UUID = 'Modified by User UUID',
+    VERSION = 'Version',
+    CREATED_AT = 'Date Created',
+    LAST_MODIFIED = 'Last Modified',
+    TRASH_AT = 'Trash at',
+    DELETE_AT = 'Delete at',
+}
+
+export interface ProjectPanelFilter extends DataTableFilterItem {
+    type: ResourceKind | ContainerRequestState;
+}
+
+export const sharedWithMePanelColumns: DataColumns<string, ProjectResource> = [
+    {
+        name: SharedWithMePanelColumnNames.NAME,
+        selected: true,
+        configurable: true,
+        sort: { direction: SortDirection.NONE, field: 'name' },
+        filters: createTree(),
+        render: (uuid) => <ResourceName uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.STATUS,
+        selected: true,
+        configurable: true,
+        mutuallyExclusiveFilters: true,
+        filters: getInitialProcessStatusFilters(),
+        render: (uuid) => <ResourceStatus uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.TYPE,
+        selected: true,
+        configurable: true,
+        filters: getInitialResourceTypeFilters(),
+        render: (uuid) => <ResourceType uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.OWNER,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceOwnerWithNameLink uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.PORTABLE_DATA_HASH,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourcePortableDataHash uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.FILE_SIZE,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceFileSize uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.FILE_COUNT,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceFileCount uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.UUID,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceUUID uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.CONTAINER_UUID,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceContainerUuid uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.RUNTIME,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ContainerRunTime uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.OUTPUT_UUID,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceOutputUuid uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.LOG_UUID,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceLogUuid uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.PARENT_PROCESS,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceParentProcess uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.MODIFIED_BY_USER_UUID,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceModifiedByUserUuid uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.VERSION,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceVersion uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.CREATED_AT,
+        selected: false,
+        configurable: true,
+        sort: { direction: SortDirection.NONE, field: 'createdAt' },
+        filters: createTree(),
+        render: (uuid) => <ResourceCreatedAtDate uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.LAST_MODIFIED,
+        selected: true,
+        configurable: true,
+        sort: { direction: SortDirection.DESC, field: 'modifiedAt' },
+        filters: createTree(),
+        render: (uuid) => <ResourceLastModifiedDate uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.TRASH_AT,
+        selected: false,
+        configurable: true,
+        sort: { direction: SortDirection.NONE, field: 'trashAt' },
+        filters: createTree(),
+        render: (uuid) => <ResourceTrashDate uuid={uuid} />,
+    },
+    {
+        name: SharedWithMePanelColumnNames.DELETE_AT,
+        selected: false,
+        configurable: true,
+        sort: { direction: SortDirection.NONE, field: 'deleteAt' },
+        filters: createTree(),
+        render: (uuid) => <ResourceDeleteDate uuid={uuid} />,
+    },
+];
+
+
 interface SharedWithMePanelDataProps {
     resources: ResourcesState;
     userUuid: string;
@@ -82,6 +281,7 @@ export const SharedWithMePanel = withStyles(styles)(
             }
 
             handleRowClick = (uuid: string) => {
+                this.props.dispatch<any>(toggleOne(uuid))
                 this.props.dispatch<any>(loadDetailsPanel(uuid));
             }
         }
index 99ad1bffd356bf5994f83a58e606ece74143f822..8a266d00c6f146cc7bb0a5853e236224262f8f8f 100644 (file)
@@ -6,7 +6,7 @@ import React from 'react';
 import { StyleRulesCallback, WithStyles, withStyles, Card, CardContent, Button, Typography, Grid, Table, TableHead, TableRow, TableCell, TableBody, Tooltip, IconButton } from '@material-ui/core';
 import { ArvadosTheme } from 'common/custom-theme';
 import { SshKeyResource } from 'models/ssh-key';
-import { AddIcon, MoreOptionsIcon, KeyIcon } from 'components/icon/icon';
+import { AddIcon, MoreVerticalIcon, KeyIcon } from 'components/icon/icon';
 
 type CssRules = 'root' | 'link' | 'buttonContainer' | 'table' | 'tableRow' | 'keyIcon';
 
@@ -103,7 +103,7 @@ export const SshKeyPanelRoot = withStyles(styles)(
                                     <TableCell>
                                         <Tooltip title="More options" disableFocusListener>
                                             <IconButton onClick={event => openRowOptions(event, sshKey)}>
-                                                <MoreOptionsIcon />
+                                                <MoreVerticalIcon />
                                             </IconButton>
                                         </Tooltip>
                                     </TableCell>
@@ -113,4 +113,4 @@ export const SshKeyPanelRoot = withStyles(styles)(
                 </Grid>
             </CardContent>
         </Card>
-    );
\ No newline at end of file
+    );
index 9cf1db7753e6a90c8666d5ac4fcee361dff7ce23..65c723f6d891864b13b3bd14988b66e56c8a14f6 100644 (file)
@@ -20,6 +20,8 @@ import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
 import { StyleRulesCallback, Typography, WithStyles, withStyles } from '@material-ui/core';
 import { ArvadosTheme } from 'common/custom-theme';
 import { ProcessResource } from 'models/process';
+import { SubprocessProgressBar } from 'components/subprocess-progress-bar/subprocess-progress-bar';
+import { Process } from 'store/processes/process';
 
 type CssRules = 'iconHeader' | 'cardHeader';
 
@@ -80,11 +82,12 @@ export const subprocessPanelColumns: DataColumns<string, ProcessResource> = [
 ];
 
 export interface SubprocessPanelDataProps {
+    process: Process;
     resources: ResourcesState;
 }
 
 export interface SubprocessPanelActionProps {
-    onItemClick: (item: string) => void;
+    onRowClick: (item: string) => void;
     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: string, resources: ResourcesState) => void;
     onItemDoubleClick: (item: string) => void;
 }
@@ -111,7 +114,7 @@ const SubProcessesTitle = withStyles(styles)(
 export const SubprocessPanelRoot = (props: SubprocessPanelProps & MPVPanelProps) => {
     return <DataExplorer
         id={SUBPROCESS_PANEL_ID}
-        onRowClick={props.onItemClick}
+        onRowClick={props.onRowClick}
         onRowDoubleClick={props.onItemDoubleClick}
         onContextMenu={(event, item) => props.onContextMenu(event, item, props.resources)}
         contextMenuColumn={true}
@@ -122,5 +125,6 @@ export const SubprocessPanelRoot = (props: SubprocessPanelProps & MPVPanelProps)
         doUnMaximizePanel={props.doUnMaximizePanel}
         panelMaximized={props.panelMaximized}
         panelName={props.panelName}
-        title={<SubProcessesTitle/>} />;
+        title={<SubProcessesTitle/>}
+        progressBar={<SubprocessProgressBar process={props.process} />} />;
 };
index c46a1c52e26125be63a7c8fd50b630477eac01d0..684e1fd2b9c3cd99ef692d104288b4e8d2b1c364 100644 (file)
@@ -4,12 +4,13 @@
 
 import { Dispatch } from "redux";
 import { connect } from "react-redux";
-import { openProcessContextMenu } from 'store/context-menu/context-menu-actions';
-import { SubprocessPanelRoot, SubprocessPanelActionProps, SubprocessPanelDataProps } from 'views/subprocess-panel/subprocess-panel-root';
+import { openProcessContextMenu } from "store/context-menu/context-menu-actions";
+import { SubprocessPanelRoot, SubprocessPanelActionProps, SubprocessPanelDataProps } from "views/subprocess-panel/subprocess-panel-root";
 import { RootState } from "store/store";
 import { navigateTo } from "store/navigation/navigation-action";
 import { loadDetailsPanel } from "store/details-panel/details-panel-action";
 import { getProcess } from "store/processes/process";
+import { toggleOne } from 'store/multiselect/multiselect-actions';
 
 const mapDispatchToProps = (dispatch: Dispatch): SubprocessPanelActionProps => ({
     onContextMenu: (event, resourceUuid, resources) => {
@@ -18,16 +19,17 @@ const mapDispatchToProps = (dispatch: Dispatch): SubprocessPanelActionProps => (
             dispatch<any>(openProcessContextMenu(event, process));
         }
     },
-    onItemClick: (uuid: string) => {
+    onRowClick: (uuid: string) => {
+        dispatch<any>(toggleOne(uuid))
         dispatch<any>(loadDetailsPanel(uuid));
     },
     onItemDoubleClick: uuid => {
         dispatch<any>(navigateTo(uuid));
-    }
+    },
 });
 
-const mapStateToProps = (state: RootState): SubprocessPanelDataProps => ({
-    resources: state.resources
+const mapStateToProps = (state: RootState): Omit<SubprocessPanelDataProps,'process'> => ({
+    resources: state.resources,
 });
 
-export const SubprocessPanel = connect(mapStateToProps, mapDispatchToProps)(SubprocessPanelRoot);
\ No newline at end of file
+export const SubprocessPanel = connect(mapStateToProps, mapDispatchToProps)(SubprocessPanelRoot);
index 350207510555ac30870e21dd19916e9399c27534..2a96ffe0d7cf76f2b34c500dfecde6e6e9f8a071 100644 (file)
@@ -35,6 +35,7 @@ import {
     getTrashPanelTypeFilters
 } from 'store/resource-type-filters/resource-type-filters';
 import { CollectionResource } from 'models/collection';
+import { toggleOne } from 'store/multiselect/multiselect-actions';
 
 type CssRules = "toolbar" | "button" | "root";
 
@@ -178,6 +179,7 @@ export const TrashPanel = withStyles(styles)(
             }
 
             handleRowClick = (uuid: string) => {
+                this.props.dispatch<any>(toggleOne(uuid))
                 this.props.dispatch<any>(loadDetailsPanel(uuid));
             }
         }
index 6a55651678d8964c8be29ffcd18f9c3fa3a83e32..4a2083711efbee0f3d996216609e45441ebc99a1 100644 (file)
@@ -27,7 +27,7 @@ import { ArvadosTheme } from 'common/custom-theme';
 import { PROFILE_EMAIL_VALIDATION, PROFILE_URL_VALIDATION } from "validators/validators";
 import { USER_PROFILE_PANEL_ID } from 'store/user-profile/user-profile-actions';
 import { noop } from 'lodash';
-import { DetailsIcon, GroupsIcon, MoreOptionsIcon } from 'components/icon/icon';
+import { DetailsIcon, GroupsIcon, MoreVerticalIcon } from 'components/icon/icon';
 import { DataColumns } from 'components/data-table/data-table';
 import { ResourceLinkHeadUuid, ResourceLinkHeadPermissionLevel, ResourceLinkHead, ResourceLinkDelete, ResourceLinkTailIsVisible, UserResourceAccountStatus } from 'views-components/data-explorer/renderers';
 import { createTree } from 'models/tree';
@@ -36,7 +36,7 @@ import { DefaultView } from 'components/default-view/default-view';
 import { CopyToClipboardSnackbar } from 'components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar';
 import { PermissionResource } from 'models/permission';
 
-type CssRules = 'root' | 'emptyRoot' | 'gridItem' | 'label' | 'readOnlyValue' | 'title' | 'description' | 'actions' | 'content' | 'copyIcon';
+type CssRules = 'root' | 'emptyRoot' | 'gridItem' | 'label' | 'readOnlyValue' | 'title' | 'description' | 'actions' | 'content' | 'copyIcon' | 'userProfileFormMessage';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
@@ -81,6 +81,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         '& svg': {
             fontSize: '1rem'
         }
+    },
+    userProfileFormMessage: {
+        fontSize: '1.1rem',
     }
 });
 
@@ -97,6 +100,7 @@ export interface UserProfilePanelRootDataProps {
     userUuid: string;
     resources: ResourcesState;
     localCluster: string;
+    userProfileFormMessage: string;
 }
 
 const RoleTypes = [
@@ -165,7 +169,7 @@ export const userProfileGroupsColumns: DataColumns<string, PermissionResource> =
 ];
 
 const ReadOnlyField = withStyles(styles)(
-    (props: ({ label: string, input: {value: string} }) & WithStyles<CssRules> ) => (
+    (props: ({ label: string, input: { value: string } }) & WithStyles<CssRules>) => (
         <Grid item xs={12} data-cy="field">
             <Typography className={props.classes.label}>
                 {props.label}
@@ -184,7 +188,7 @@ export const UserProfilePanelRoot = withStyles(styles)(
         };
 
         componentDidMount() {
-            this.setState({ value: TABS.PROFILE});
+            this.setState({ value: TABS.PROFILE });
         }
 
         render() {
@@ -213,14 +217,14 @@ export const UserProfilePanelRoot = withStyles(styles)(
                                 </Grid>
                                 <Grid item>
                                     <Grid container alignItems="center">
-                                        <Grid item style={{marginRight: '10px'}}><UserResourceAccountStatus uuid={this.props.userUuid} /></Grid>
+                                        <Grid item style={{ marginRight: '10px' }}><UserResourceAccountStatus uuid={this.props.userUuid} /></Grid>
                                         <Grid item>
                                             <Tooltip title="Actions" disableFocusListener>
                                                 <IconButton
                                                     data-cy='user-profile-panel-options-btn'
                                                     aria-label="Actions"
                                                     onClick={(event) => this.handleContextMenu(event, this.props.userUuid)}>
-                                                    <MoreOptionsIcon />
+                                                    <MoreVerticalIcon />
                                                 </IconButton>
                                             </Tooltip>
                                         </Grid>
@@ -261,6 +265,9 @@ export const UserProfilePanelRoot = withStyles(styles)(
                                             disabled
                                         />
                                     </Grid>
+                                    <Grid item className={this.props.classes.gridItem} xs={12}>
+                                        <span className={this.props.classes.userProfileFormMessage}>{this.props.userProfileFormMessage}</span>
+                                    </Grid>
                                     <Grid item className={this.props.classes.gridItem} sm={6} xs={12}>
                                         <Field
                                             label="Organization"
@@ -316,19 +323,19 @@ export const UserProfilePanelRoot = withStyles(styles)(
                     {this.state.value === TABS.GROUPS &&
                         <div className={this.props.classes.content}>
                             <DataExplorer
-                                    id={USER_PROFILE_PANEL_ID}
-                                    data-cy="user-profile-groups-data-explorer"
-                                    onRowClick={noop}
-                                    onRowDoubleClick={noop}
-                                    onContextMenu={noop}
-                                    contextMenuColumn={false}
-                                    hideColumnSelector
-                                    hideSearchInput
-                                    paperProps={{
-                                        elevation: 0,
-                                    }}
-                                    defaultViewIcon={GroupsIcon}
-                                    defaultViewMessages={['Group list is empty.']} />
+                                id={USER_PROFILE_PANEL_ID}
+                                data-cy="user-profile-groups-data-explorer"
+                                onRowClick={noop}
+                                onRowDoubleClick={noop}
+                                onContextMenu={noop}
+                                contextMenuColumn={false}
+                                hideColumnSelector
+                                hideSearchInput
+                                paperProps={{
+                                    elevation: 0,
+                                }}
+                                defaultViewIcon={GroupsIcon}
+                                defaultViewMessages={['Group list is empty.']} />
                         </div>}
                 </Paper >;
             }
index a90d44a9a7a7d207e5ca3d4ea6b425acca21d356..040cbc6f4aa1304e92f121cc8a36f2509873779b 100644 (file)
@@ -14,20 +14,22 @@ import { matchUserProfileRoute } from 'routes/routes';
 import { openUserContextMenu } from 'store/context-menu/context-menu-actions';
 
 const mapStateToProps = (state: RootState): UserProfilePanelRootDataProps => {
-  const pathname = state.router.location ? state.router.location.pathname : '';
-  const match = matchUserProfileRoute(pathname);
-  const uuid = match ? match.params.id : state.auth.user?.uuid || '';
+    const pathname = state.router.location ? state.router.location.pathname : '';
+    const match = matchUserProfileRoute(pathname);
+    const uuid = match ? match.params.id : state.auth.user?.uuid || '';
 
-  return {
-    isAdmin: state.auth.user!.isAdmin,
-    isSelf: state.auth.user!.uuid === uuid,
-    isPristine: isPristine(USER_PROFILE_FORM)(state),
-    isValid: isValid(USER_PROFILE_FORM)(state),
-    isInaccessible: getUserProfileIsInaccessible(state.properties) || false,
-    localCluster: state.auth.localCluster,
-    userUuid: uuid,
-    resources: state.resources,
-}};
+    return {
+        isAdmin: state.auth.user!.isAdmin,
+        isSelf: state.auth.user!.uuid === uuid,
+        isPristine: isPristine(USER_PROFILE_FORM)(state),
+        isValid: isValid(USER_PROFILE_FORM)(state),
+        isInaccessible: getUserProfileIsInaccessible(state.properties) || false,
+        localCluster: state.auth.localCluster,
+        userUuid: uuid,
+        resources: state.resources,
+        userProfileFormMessage: state.auth.config.clusterConfig.Workbench.UserProfileFormMessage,
+    }
+};
 
 const mapDispatchToProps = (dispatch: Dispatch) => ({
     handleContextMenu: (event, resource: UserResource) => dispatch<any>(openUserContextMenu(event, resource)),
index 864218e4a17a17b9d531a8087bacb73757a06d7a..20665f178d1d80dae898357244738790faa6e0ee 100644 (file)
@@ -11,7 +11,7 @@ import { compose, Dispatch } from 'redux';
 import { loadVirtualMachinesAdminData, openAddVirtualMachineLoginDialog, openRemoveVirtualMachineLoginDialog, openEditVirtualMachineLoginDialog } from 'store/virtual-machines/virtual-machines-actions';
 import { RootState } from 'store/store';
 import { ListResults } from 'services/common-service/common-service';
-import { MoreOptionsIcon, AddUserIcon } from 'components/icon/icon';
+import { MoreVerticalIcon, AddUserIcon } from 'components/icon/icon';
 import { VirtualMachineLogins, VirtualMachinesResource } from 'models/virtual-machines';
 import { openVirtualMachinesContextMenu } from 'store/context-menu/context-menu-actions';
 import { ResourceUuid, VirtualMachineHostname, VirtualMachineLogin } from 'views-components/data-explorer/renderers';
@@ -139,7 +139,7 @@ const virtualMachinesTable = (props: VirtualMachineProps) =>
                     <TableCell className={props.classes.moreOptions}>
                         <Tooltip title="More options" disableFocusListener>
                             <IconButton onClick={event => props.onOptionsMenuOpen(event, machine)} className={props.classes.moreOptionsButton}>
-                                <MoreOptionsIcon />
+                                <MoreVerticalIcon />
                             </IconButton>
                         </Tooltip>
                     </TableCell>
index 751ca5f190d1a7162d19a127e443f4f7287cb4f9..56c92805e24946a0499821fd31c7afb77dc48dce 100644 (file)
@@ -18,6 +18,7 @@ import parse from "parse-duration";
 import { CopyIcon } from 'components/icon/icon';
 import CopyToClipboard from 'react-copy-to-clipboard';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+import { sanitizeHTML } from 'common/html-sanitize';
 
 type CssRules = 'button' | 'codeSnippet' | 'link' | 'linkIcon' | 'rightAlign' | 'cardWithoutMachines' | 'icon' | 'chipsRoot' | 'copyIcon' | 'tableWrapper' | 'webshellButton';
 
@@ -269,7 +270,7 @@ const CardSSHSection = (props: VirtualMachineProps) =>
         <Card>
             <CardContent>
                 <Typography>
-                    <div dangerouslySetInnerHTML={{ __html: props.helpText }} style={{ margin: "1em" }} />
+                    <div dangerouslySetInnerHTML={{ __html: sanitizeHTML(props.helpText) }} style={{ margin: "1em" }} />
                 </Typography>
             </CardContent>
         </Card>
index 7103efd132a1b8e1ac149229d1fbcda0e7620a7f..bc2396f7cf8c2930444d6ecb3bb80324a9d5326d 100644 (file)
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import React from "react";
+import { StyleRulesCallback, WithStyles, withStyles } from "@material-ui/core/styles";
 import { Route, Switch } from "react-router";
 import { ProjectPanel } from "views/project-panel/project-panel";
-import { DetailsPanel } from 'views-components/details-panel/details-panel';
-import { ArvadosTheme } from 'common/custom-theme';
+import { DetailsPanel } from "views-components/details-panel/details-panel";
+import { ArvadosTheme } from "common/custom-theme";
 import { ContextMenu } from "views-components/context-menu/context-menu";
 import { FavoritePanel } from "../favorite-panel/favorite-panel";
-import { TokenDialog } from 'views-components/token-dialog/token-dialog';
-import { RichTextEditorDialog } from 'views-components/rich-text-editor-dialog/rich-text-editor-dialog';
-import { Snackbar } from 'views-components/snackbar/snackbar';
-import { CollectionPanel } from '../collection-panel/collection-panel';
-import { RenameFileDialog } from 'views-components/rename-file-dialog/rename-file-dialog';
-import { FileRemoveDialog } from 'views-components/file-remove-dialog/file-remove-dialog';
-import { MultipleFilesRemoveDialog } from 'views-components/file-remove-dialog/multiple-files-remove-dialog';
-import { Routes } from 'routes/routes';
-import { SidePanel } from 'views-components/side-panel/side-panel';
-import { ProcessPanel } from 'views/process-panel/process-panel';
-import { ChangeWorkflowDialog } from 'views-components/run-process-dialog/change-workflow-dialog';
-import { CreateProjectDialog } from 'views-components/dialog-forms/create-project-dialog';
-import { CreateCollectionDialog } from 'views-components/dialog-forms/create-collection-dialog';
-import { CopyCollectionDialog } from 'views-components/dialog-forms/copy-collection-dialog';
-import { CopyProcessDialog } from 'views-components/dialog-forms/copy-process-dialog';
-import { UpdateCollectionDialog } from 'views-components/dialog-forms/update-collection-dialog';
-import { UpdateProcessDialog } from 'views-components/dialog-forms/update-process-dialog';
-import { UpdateProjectDialog } from 'views-components/dialog-forms/update-project-dialog';
-import { MoveProcessDialog } from 'views-components/dialog-forms/move-process-dialog';
-import { MoveProjectDialog } from 'views-components/dialog-forms/move-project-dialog';
-import { MoveCollectionDialog } from 'views-components/dialog-forms/move-collection-dialog';
-import { FilesUploadCollectionDialog } from 'views-components/dialog-forms/files-upload-collection-dialog';
-import { PartialCopyCollectionDialog } from 'views-components/dialog-forms/partial-copy-collection-dialog';
-import { RemoveProcessDialog } from 'views-components/process-remove-dialog/process-remove-dialog';
-import { MainContentBar } from 'views-components/main-content-bar/main-content-bar';
-import { Grid } from '@material-ui/core';
+import { TokenDialog } from "views-components/token-dialog/token-dialog";
+import { RichTextEditorDialog } from "views-components/rich-text-editor-dialog/rich-text-editor-dialog";
+import { Snackbar } from "views-components/snackbar/snackbar";
+import { CollectionPanel } from "../collection-panel/collection-panel";
+import { RenameFileDialog } from "views-components/rename-file-dialog/rename-file-dialog";
+import { FileRemoveDialog } from "views-components/file-remove-dialog/file-remove-dialog";
+import { MultipleFilesRemoveDialog } from "views-components/file-remove-dialog/multiple-files-remove-dialog";
+import { Routes } from "routes/routes";
+import { SidePanel } from "views-components/side-panel/side-panel";
+import { ProcessPanel } from "views/process-panel/process-panel";
+import { ChangeWorkflowDialog } from "views-components/run-process-dialog/change-workflow-dialog";
+import { CreateProjectDialog } from "views-components/dialog-forms/create-project-dialog";
+import { CreateCollectionDialog } from "views-components/dialog-forms/create-collection-dialog";
+import { CopyCollectionDialog, CopyMultiCollectionDialog } from "views-components/dialog-forms/copy-collection-dialog";
+import { CopyProcessDialog } from "views-components/dialog-forms/copy-process-dialog";
+import { UpdateCollectionDialog } from "views-components/dialog-forms/update-collection-dialog";
+import { UpdateProcessDialog } from "views-components/dialog-forms/update-process-dialog";
+import { UpdateProjectDialog } from "views-components/dialog-forms/update-project-dialog";
+import { MoveProcessDialog } from "views-components/dialog-forms/move-process-dialog";
+import { MoveProjectDialog } from "views-components/dialog-forms/move-project-dialog";
+import { MoveCollectionDialog } from "views-components/dialog-forms/move-collection-dialog";
+import { FilesUploadCollectionDialog } from "views-components/dialog-forms/files-upload-collection-dialog";
+import { PartialCopyToNewCollectionDialog } from "views-components/dialog-forms/partial-copy-to-new-collection-dialog";
+import { PartialCopyToExistingCollectionDialog } from "views-components/dialog-forms/partial-copy-to-existing-collection-dialog";
+import { PartialCopyToSeparateCollectionsDialog } from "views-components/dialog-forms/partial-copy-to-separate-collections-dialog";
+import { PartialMoveToNewCollectionDialog } from "views-components/dialog-forms/partial-move-to-new-collection-dialog";
+import { PartialMoveToExistingCollectionDialog } from "views-components/dialog-forms/partial-move-to-existing-collection-dialog";
+import { PartialMoveToSeparateCollectionsDialog } from "views-components/dialog-forms/partial-move-to-separate-collections-dialog";
+import { RemoveProcessDialog } from "views-components/process-remove-dialog/process-remove-dialog";
+import { MainContentBar } from "views-components/main-content-bar/main-content-bar";
+import { Grid } from "@material-ui/core";
 import { TrashPanel } from "views/trash-panel/trash-panel";
-import { SharedWithMePanel } from 'views/shared-with-me-panel/shared-with-me-panel';
-import { RunProcessPanel } from 'views/run-process-panel/run-process-panel';
-import SplitterLayout from 'react-splitter-layout';
-import { WorkflowPanel } from 'views/workflow-panel/workflow-panel';
-import { SearchResultsPanel } from 'views/search-results-panel/search-results-panel';
-import { SshKeyPanel } from 'views/ssh-key-panel/ssh-key-panel';
-import { SshKeyAdminPanel } from 'views/ssh-key-panel/ssh-key-admin-panel';
+import { SharedWithMePanel } from "views/shared-with-me-panel/shared-with-me-panel";
+import { RunProcessPanel } from "views/run-process-panel/run-process-panel";
+import SplitterLayout from "react-splitter-layout";
+import { WorkflowPanel } from "views/workflow-panel/workflow-panel";
+import { RegisteredWorkflowPanel } from "views/workflow-panel/registered-workflow-panel";
+import { SearchResultsPanel } from "views/search-results-panel/search-results-panel";
+import { SshKeyPanel } from "views/ssh-key-panel/ssh-key-panel";
+import { SshKeyAdminPanel } from "views/ssh-key-panel/ssh-key-admin-panel";
 import { SiteManagerPanel } from "views/site-manager-panel/site-manager-panel";
-import { UserProfilePanel } from 'views/user-profile-panel/user-profile-panel';
-import { SharingDialog } from 'views-components/sharing-dialog/sharing-dialog';
-import { NotFoundDialog } from 'views-components/not-found-dialog/not-found-dialog';
-import { AdvancedTabDialog } from 'views-components/advanced-tab-dialog/advanced-tab-dialog';
-import { ProcessInputDialog } from 'views-components/process-input-dialog/process-input-dialog';
-import { VirtualMachineUserPanel } from 'views/virtual-machine-panel/virtual-machine-user-panel';
-import { VirtualMachineAdminPanel } from 'views/virtual-machine-panel/virtual-machine-admin-panel';
-import { RepositoriesPanel } from 'views/repositories-panel/repositories-panel';
-import { KeepServicePanel } from 'views/keep-service-panel/keep-service-panel';
-import { ApiClientAuthorizationPanel } from 'views/api-client-authorization-panel/api-client-authorization-panel';
-import { LinkPanel } from 'views/link-panel/link-panel';
-import { RepositoriesSampleGitDialog } from 'views-components/repositories-sample-git-dialog/repositories-sample-git-dialog';
-import { RepositoryAttributesDialog } from 'views-components/repository-attributes-dialog/repository-attributes-dialog';
-import { CreateRepositoryDialog } from 'views-components/dialog-forms/create-repository-dialog';
-import { RemoveRepositoryDialog } from 'views-components/repository-remove-dialog/repository-remove-dialog';
-import { CreateSshKeyDialog } from 'views-components/dialog-forms/create-ssh-key-dialog';
-import { PublicKeyDialog } from 'views-components/ssh-keys-dialog/public-key-dialog';
-import { RemoveApiClientAuthorizationDialog } from 'views-components/api-client-authorizations-dialog/remove-dialog';
-import { RemoveKeepServiceDialog } from 'views-components/keep-services-dialog/remove-dialog';
-import { RemoveLinkDialog } from 'views-components/links-dialog/remove-dialog';
-import { RemoveSshKeyDialog } from 'views-components/ssh-keys-dialog/remove-dialog';
-import { VirtualMachineAttributesDialog } from 'views-components/virtual-machines-dialog/attributes-dialog';
-import { RemoveVirtualMachineDialog } from 'views-components/virtual-machines-dialog/remove-dialog';
-import { RemoveVirtualMachineLoginDialog } from 'views-components/virtual-machines-dialog/remove-login-dialog';
-import { VirtualMachineAddLoginDialog } from 'views-components/virtual-machines-dialog/add-login-dialog';
-import { AttributesApiClientAuthorizationDialog } from 'views-components/api-client-authorizations-dialog/attributes-dialog';
-import { AttributesKeepServiceDialog } from 'views-components/keep-services-dialog/attributes-dialog';
-import { AttributesLinkDialog } from 'views-components/links-dialog/attributes-dialog';
-import { AttributesSshKeyDialog } from 'views-components/ssh-keys-dialog/attributes-dialog';
-import { UserPanel } from 'views/user-panel/user-panel';
-import { UserAttributesDialog } from 'views-components/user-dialog/attributes-dialog';
-import { CreateUserDialog } from 'views-components/dialog-forms/create-user-dialog';
-import { HelpApiClientAuthorizationDialog } from 'views-components/api-client-authorizations-dialog/help-dialog';
-import { DeactivateDialog } from 'views-components/user-dialog/deactivate-dialog';
-import { ActivateDialog } from 'views-components/user-dialog/activate-dialog';
-import { SetupDialog } from 'views-components/user-dialog/setup-dialog';
-import { GroupsPanel } from 'views/groups-panel/groups-panel';
-import { RemoveGroupDialog } from 'views-components/groups-dialog/remove-dialog';
-import { GroupAttributesDialog } from 'views-components/groups-dialog/attributes-dialog';
-import { GroupDetailsPanel } from 'views/group-details-panel/group-details-panel';
-import { RemoveGroupMemberDialog } from 'views-components/groups-dialog/member-remove-dialog';
-import { GroupMemberAttributesDialog } from 'views-components/groups-dialog/member-attributes-dialog';
-import { PartialCopyToCollectionDialog } from 'views-components/dialog-forms/partial-copy-to-collection-dialog';
-import { PublicFavoritePanel } from 'views/public-favorites-panel/public-favorites-panel';
-import { LinkAccountPanel } from 'views/link-account-panel/link-account-panel';
-import { FedLogin } from './fed-login';
-import { CollectionsContentAddressPanel } from 'views/collection-content-address-panel/collection-content-address-panel';
-import { AllProcessesPanel } from '../all-processes-panel/all-processes-panel';
-import { NotFoundPanel } from '../not-found-panel/not-found-panel';
-import { AutoLogout } from 'views-components/auto-logout/auto-logout';
-import { RestoreCollectionVersionDialog } from 'views-components/collections-dialog/restore-version-dialog';
-import { WebDavS3InfoDialog } from 'views-components/webdav-s3-dialog/webdav-s3-dialog';
-import { pluginConfig } from 'plugins';
-import { ElementListReducer } from 'common/plugintypes';
-import { COLLAPSE_ICON_SIZE } from 'views-components/side-panel-toggle/side-panel-toggle'
-import { Banner } from 'views-components/baner/banner';
+import { UserProfilePanel } from "views/user-profile-panel/user-profile-panel";
+import { SharingDialog } from "views-components/sharing-dialog/sharing-dialog";
+import { NotFoundDialog } from "views-components/not-found-dialog/not-found-dialog";
+import { AdvancedTabDialog } from "views-components/advanced-tab-dialog/advanced-tab-dialog";
+import { ProcessInputDialog } from "views-components/process-input-dialog/process-input-dialog";
+import { VirtualMachineUserPanel } from "views/virtual-machine-panel/virtual-machine-user-panel";
+import { VirtualMachineAdminPanel } from "views/virtual-machine-panel/virtual-machine-admin-panel";
+import { RepositoriesPanel } from "views/repositories-panel/repositories-panel";
+import { KeepServicePanel } from "views/keep-service-panel/keep-service-panel";
+import { ApiClientAuthorizationPanel } from "views/api-client-authorization-panel/api-client-authorization-panel";
+import { LinkPanel } from "views/link-panel/link-panel";
+import { RepositoriesSampleGitDialog } from "views-components/repositories-sample-git-dialog/repositories-sample-git-dialog";
+import { RepositoryAttributesDialog } from "views-components/repository-attributes-dialog/repository-attributes-dialog";
+import { CreateRepositoryDialog } from "views-components/dialog-forms/create-repository-dialog";
+import { RemoveRepositoryDialog } from "views-components/repository-remove-dialog/repository-remove-dialog";
+import { CreateSshKeyDialog } from "views-components/dialog-forms/create-ssh-key-dialog";
+import { PublicKeyDialog } from "views-components/ssh-keys-dialog/public-key-dialog";
+import { RemoveApiClientAuthorizationDialog } from "views-components/api-client-authorizations-dialog/remove-dialog";
+import { RemoveKeepServiceDialog } from "views-components/keep-services-dialog/remove-dialog";
+import { RemoveLinkDialog } from "views-components/links-dialog/remove-dialog";
+import { RemoveSshKeyDialog } from "views-components/ssh-keys-dialog/remove-dialog";
+import { VirtualMachineAttributesDialog } from "views-components/virtual-machines-dialog/attributes-dialog";
+import { RemoveVirtualMachineDialog } from "views-components/virtual-machines-dialog/remove-dialog";
+import { RemoveVirtualMachineLoginDialog } from "views-components/virtual-machines-dialog/remove-login-dialog";
+import { VirtualMachineAddLoginDialog } from "views-components/virtual-machines-dialog/add-login-dialog";
+import { AttributesApiClientAuthorizationDialog } from "views-components/api-client-authorizations-dialog/attributes-dialog";
+import { AttributesKeepServiceDialog } from "views-components/keep-services-dialog/attributes-dialog";
+import { AttributesLinkDialog } from "views-components/links-dialog/attributes-dialog";
+import { AttributesSshKeyDialog } from "views-components/ssh-keys-dialog/attributes-dialog";
+import { UserPanel } from "views/user-panel/user-panel";
+import { UserAttributesDialog } from "views-components/user-dialog/attributes-dialog";
+import { CreateUserDialog } from "views-components/dialog-forms/create-user-dialog";
+import { HelpApiClientAuthorizationDialog } from "views-components/api-client-authorizations-dialog/help-dialog";
+import { DeactivateDialog } from "views-components/user-dialog/deactivate-dialog";
+import { ActivateDialog } from "views-components/user-dialog/activate-dialog";
+import { SetupDialog } from "views-components/user-dialog/setup-dialog";
+import { GroupsPanel } from "views/groups-panel/groups-panel";
+import { RemoveGroupDialog } from "views-components/groups-dialog/remove-dialog";
+import { GroupAttributesDialog } from "views-components/groups-dialog/attributes-dialog";
+import { GroupDetailsPanel } from "views/group-details-panel/group-details-panel";
+import { RemoveGroupMemberDialog } from "views-components/groups-dialog/member-remove-dialog";
+import { GroupMemberAttributesDialog } from "views-components/groups-dialog/member-attributes-dialog";
+import { PublicFavoritePanel } from "views/public-favorites-panel/public-favorites-panel";
+import { LinkAccountPanel } from "views/link-account-panel/link-account-panel";
+import { FedLogin } from "./fed-login";
+import { CollectionsContentAddressPanel } from "views/collection-content-address-panel/collection-content-address-panel";
+import { AllProcessesPanel } from "../all-processes-panel/all-processes-panel";
+import { NotFoundPanel } from "../not-found-panel/not-found-panel";
+import { AutoLogout } from "views-components/auto-logout/auto-logout";
+import { RestoreCollectionVersionDialog } from "views-components/collections-dialog/restore-version-dialog";
+import { WebDavS3InfoDialog } from "views-components/webdav-s3-dialog/webdav-s3-dialog";
+import { pluginConfig } from "plugins";
+import { ElementListReducer } from "common/plugintypes";
+import { COLLAPSE_ICON_SIZE } from "views-components/side-panel-toggle/side-panel-toggle";
+import { Banner } from "views-components/baner/banner";
 
-type CssRules = 'root' | 'container' | 'splitter' | 'asidePanel' | 'contentWrapper' | 'content';
+type CssRules = "root" | "container" | "splitter" | "asidePanel" | "contentWrapper" | "content";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
         paddingTop: theme.spacing.unit * 7,
-        background: theme.palette.background.default
+        background: theme.palette.background.default,
     },
     container: {
-        position: 'relative'
+        position: "relative",
     },
     splitter: {
-        '& > .layout-splitter': {
-            width: '2px',
+        "& > .layout-splitter": {
+            width: "3px",
+        },
+        "& > .layout-splitter-disabled": {
+            pointerEvents: "none",
+            cursor: "pointer",
         },
-        '& > .layout-splitter-disabled': {
-            pointerEvents: 'none',
-            cursor: 'pointer'
-        }
     },
     asidePanel: {
         paddingTop: theme.spacing.unit,
-        height: '100%'
+        height: "100%",
     },
     contentWrapper: {
         paddingTop: theme.spacing.unit,
-        minWidth: 0
+        minWidth: 0,
     },
     content: {
         minWidth: 0,
@@ -135,8 +140,8 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         paddingRight: theme.spacing.unit * 3,
         // Reserve vertical space for app bar + MainContentBar
         minHeight: `calc(100vh - ${theme.spacing.unit * 16}px)`,
-        display: 'flex',
-    }
+        display: "flex",
+    },
 });
 
 interface WorkbenchDataProps {
@@ -151,84 +156,213 @@ type WorkbenchPanelProps = WithStyles<CssRules> & WorkbenchDataProps;
 const defaultSplitterSize = 90;
 
 const getSplitterInitialSize = () => {
-    const splitterSize = localStorage.getItem('splitterSize');
+    const splitterSize = localStorage.getItem("splitterSize");
     return splitterSize ? Number(splitterSize) : defaultSplitterSize;
 };
 
-const saveSplitterSize = (size: number) => localStorage.setItem('splitterSize', size.toString());
+const saveSplitterSize = (size: number) => localStorage.setItem("splitterSize", size.toString());
 
-let routes = <>
-    <Route path={Routes.PROJECTS} component={ProjectPanel} />
-    <Route path={Routes.COLLECTIONS} component={CollectionPanel} />
-    <Route path={Routes.FAVORITES} component={FavoritePanel} />
-    <Route path={Routes.ALL_PROCESSES} component={AllProcessesPanel} />
-    <Route path={Routes.PROCESSES} component={ProcessPanel} />
-    <Route path={Routes.TRASH} component={TrashPanel} />
-    <Route path={Routes.SHARED_WITH_ME} component={SharedWithMePanel} />
-    <Route path={Routes.RUN_PROCESS} component={RunProcessPanel} />
-    <Route path={Routes.WORKFLOWS} component={WorkflowPanel} />
-    <Route path={Routes.SEARCH_RESULTS} component={SearchResultsPanel} />
-    <Route path={Routes.VIRTUAL_MACHINES_USER} component={VirtualMachineUserPanel} />
-    <Route path={Routes.VIRTUAL_MACHINES_ADMIN} component={VirtualMachineAdminPanel} />
-    <Route path={Routes.REPOSITORIES} component={RepositoriesPanel} />
-    <Route path={Routes.SSH_KEYS_USER} component={SshKeyPanel} />
-    <Route path={Routes.SSH_KEYS_ADMIN} component={SshKeyAdminPanel} />
-    <Route path={Routes.SITE_MANAGER} component={SiteManagerPanel} />
-    <Route path={Routes.KEEP_SERVICES} component={KeepServicePanel} />
-    <Route path={Routes.USERS} component={UserPanel} />
-    <Route path={Routes.API_CLIENT_AUTHORIZATIONS} component={ApiClientAuthorizationPanel} />
-    <Route path={Routes.MY_ACCOUNT} component={UserProfilePanel} />
-    <Route path={Routes.USER_PROFILE} component={UserProfilePanel} />
-    <Route path={Routes.GROUPS} component={GroupsPanel} />
-    <Route path={Routes.GROUP_DETAILS} component={GroupDetailsPanel} />
-    <Route path={Routes.LINKS} component={LinkPanel} />
-    <Route path={Routes.PUBLIC_FAVORITES} component={PublicFavoritePanel} />
-    <Route path={Routes.LINK_ACCOUNT} component={LinkAccountPanel} />
-    <Route path={Routes.COLLECTIONS_CONTENT_ADDRESS} component={CollectionsContentAddressPanel} />
-</>;
+let routes = (
+    <>
+        <Route
+            path={Routes.PROJECTS}
+            component={ProjectPanel}
+        />
+        <Route
+            path={Routes.COLLECTIONS}
+            component={CollectionPanel}
+        />
+        <Route
+            path={Routes.FAVORITES}
+            component={FavoritePanel}
+        />
+        <Route
+            path={Routes.ALL_PROCESSES}
+            component={AllProcessesPanel}
+        />
+        <Route
+            path={Routes.PROCESSES}
+            component={ProcessPanel}
+        />
+        <Route
+            path={Routes.TRASH}
+            component={TrashPanel}
+        />
+        <Route
+            path={Routes.SHARED_WITH_ME}
+            component={SharedWithMePanel}
+        />
+        <Route
+            path={Routes.RUN_PROCESS}
+            component={RunProcessPanel}
+        />
+        <Route
+            path={Routes.REGISTEREDWORKFLOW}
+            component={RegisteredWorkflowPanel}
+        />
+        <Route
+            path={Routes.WORKFLOWS}
+            component={WorkflowPanel}
+        />
+        <Route
+            path={Routes.SEARCH_RESULTS}
+            component={SearchResultsPanel}
+        />
+        <Route
+            path={Routes.VIRTUAL_MACHINES_USER}
+            component={VirtualMachineUserPanel}
+        />
+        <Route
+            path={Routes.VIRTUAL_MACHINES_ADMIN}
+            component={VirtualMachineAdminPanel}
+        />
+        <Route
+            path={Routes.REPOSITORIES}
+            component={RepositoriesPanel}
+        />
+        <Route
+            path={Routes.SSH_KEYS_USER}
+            component={SshKeyPanel}
+        />
+        <Route
+            path={Routes.SSH_KEYS_ADMIN}
+            component={SshKeyAdminPanel}
+        />
+        <Route
+            path={Routes.SITE_MANAGER}
+            component={SiteManagerPanel}
+        />
+        <Route
+            path={Routes.KEEP_SERVICES}
+            component={KeepServicePanel}
+        />
+        <Route
+            path={Routes.USERS}
+            component={UserPanel}
+        />
+        <Route
+            path={Routes.API_CLIENT_AUTHORIZATIONS}
+            component={ApiClientAuthorizationPanel}
+        />
+        <Route
+            path={Routes.MY_ACCOUNT}
+            component={UserProfilePanel}
+        />
+        <Route
+            path={Routes.USER_PROFILE}
+            component={UserProfilePanel}
+        />
+        <Route
+            path={Routes.GROUPS}
+            component={GroupsPanel}
+        />
+        <Route
+            path={Routes.GROUP_DETAILS}
+            component={GroupDetailsPanel}
+        />
+        <Route
+            path={Routes.LINKS}
+            component={LinkPanel}
+        />
+        <Route
+            path={Routes.PUBLIC_FAVORITES}
+            component={PublicFavoritePanel}
+        />
+        <Route
+            path={Routes.LINK_ACCOUNT}
+            component={LinkAccountPanel}
+        />
+        <Route
+            path={Routes.COLLECTIONS_CONTENT_ADDRESS}
+            component={CollectionsContentAddressPanel}
+        />
+    </>
+);
 
-const reduceRoutesFn: (a: React.ReactElement[],
-    b: ElementListReducer) => React.ReactElement[] = (a, b) => b(a);
+const reduceRoutesFn: (a: React.ReactElement[], b: ElementListReducer) => React.ReactElement[] = (a, b) => b(a);
 
-routes = React.createElement(React.Fragment, null, pluginConfig.centerPanelList.reduce(reduceRoutesFn, React.Children.toArray(routes.props.children)));
+routes = React.createElement(
+    React.Fragment,
+    null,
+    pluginConfig.centerPanelList.reduce(reduceRoutesFn, React.Children.toArray(routes.props.children))
+);
 
-const applyCollapsedState = (isCollapsed) => {
-    const rightPanel: Element = document.getElementsByClassName('layout-pane')[1]
-    const totalWidth: number = document.getElementsByClassName('splitter-layout')[0]?.clientWidth
-    const rightPanelExpandedWidth = ((totalWidth-COLLAPSE_ICON_SIZE)) / (totalWidth/100) 
-    if(rightPanel) {
-        rightPanel.setAttribute('style', `width: ${isCollapsed ? rightPanelExpandedWidth : getSplitterInitialSize()}%`)
+const applyCollapsedState = isCollapsed => {
+    const rightPanel: Element = document.getElementsByClassName("layout-pane")[1];
+    const totalWidth: number = document.getElementsByClassName("splitter-layout")[0]?.clientWidth;
+    const rightPanelExpandedWidth = (totalWidth - COLLAPSE_ICON_SIZE) / (totalWidth / 100);
+    if (rightPanel) {
+        rightPanel.setAttribute("style", `width: ${isCollapsed ? `calc(${rightPanelExpandedWidth}% - 1rem)` : `${getSplitterInitialSize()}%`}`);
     }
-    const splitter = document.getElementsByClassName('layout-splitter')[0]
-    isCollapsed ? splitter?.classList.add('layout-splitter-disabled') : splitter?.classList.remove('layout-splitter-disabled')
-    
-}
+    const splitter = document.getElementsByClassName("layout-splitter")[0];
+    isCollapsed ? splitter?.classList.add("layout-splitter-disabled") : splitter?.classList.remove("layout-splitter-disabled");
+};
 
-export const WorkbenchPanel =
-    withStyles(styles)((props: WorkbenchPanelProps) =>{
+export const WorkbenchPanel = withStyles(styles)((props: WorkbenchPanelProps) => {
+    //panel size will not scale automatically on window resize, so we do it manually
+    if (props && props.sidePanelIsCollapsed) window.addEventListener("resize", () => applyCollapsedState(props.sidePanelIsCollapsed));
+    applyCollapsedState(props.sidePanelIsCollapsed);
 
-        //panel size will not scale automatically on window resize, so we do it manually
-        window.addEventListener('resize', ()=>applyCollapsedState(props.sidePanelIsCollapsed))
-        applyCollapsedState(props.sidePanelIsCollapsed)
-        
-        return <Grid container item xs className={props.classes.root}>
+    return (
+        <Grid
+            container
+            item
+            xs
+            className={props.classes.root}
+        >
             {props.sessionIdleTimeout > 0 && <AutoLogout />}
-            <Grid container item xs className={props.classes.container}>
-                <SplitterLayout customClassName={props.classes.splitter} percentage={true}
-                    primaryIndex={0} primaryMinSize={10}
-                    secondaryInitialSize={getSplitterInitialSize()} secondaryMinSize={40}
-                    onSecondaryPaneSizeChange={saveSplitterSize}>
-                    {props.isUserActive && props.isNotLinking && <Grid container item xs component='aside' direction='column' className={props.classes.asidePanel}>
-                        <SidePanel />
-                    </Grid>}
-                    <Grid container item xs component="main" direction="column" className={props.classes.contentWrapper}>
-                        <Grid item xs>
+            <Grid
+                container
+                item
+                xs
+                className={props.classes.container}
+            >
+                <SplitterLayout
+                    customClassName={props.classes.splitter}
+                    percentage={true}
+                    primaryIndex={0}
+                    primaryMinSize={10}
+                    secondaryInitialSize={getSplitterInitialSize()}
+                    secondaryMinSize={40}
+                    onSecondaryPaneSizeChange={saveSplitterSize}
+                >
+                    {props.isUserActive && props.isNotLinking && (
+                        <Grid
+                            container
+                            item
+                            xs
+                            component="aside"
+                            direction="column"
+                            className={props.classes.asidePanel}
+                        >
+                            <SidePanel />
+                        </Grid>
+                    )}
+                    <Grid
+                        container
+                        item
+                        xs
+                        component="main"
+                        direction="column"
+                        className={props.classes.contentWrapper}
+                    >
+                        <Grid
+                            item
+                            xs
+                        >
                             {props.isNotLinking && <MainContentBar />}
                         </Grid>
-                        <Grid item xs className={props.classes.content}>
+                        <Grid
+                            item
+                            xs
+                            className={props.classes.content}
+                        >
                             <Switch>
                                 {routes.props.children}
-                                <Route path={Routes.NO_MATCH} component={NotFoundPanel} />
+                                <Route
+                                    path={Routes.NO_MATCH}
+                                    component={NotFoundPanel}
+                                />
                             </Switch>
                         </Grid>
                     </Grid>
@@ -245,6 +379,7 @@ export const WorkbenchPanel =
             <ChangeWorkflowDialog />
             <ContextMenu />
             <CopyCollectionDialog />
+            <CopyMultiCollectionDialog />
             <CopyProcessDialog />
             <CreateCollectionDialog />
             <CreateProjectDialog />
@@ -262,8 +397,12 @@ export const WorkbenchPanel =
             <MoveProjectDialog />
             <MultipleFilesRemoveDialog />
             <PublicKeyDialog />
-            <PartialCopyCollectionDialog />
-            <PartialCopyToCollectionDialog />
+            <PartialCopyToNewCollectionDialog />
+            <PartialCopyToExistingCollectionDialog />
+            <PartialCopyToSeparateCollectionsDialog />
+            <PartialMoveToNewCollectionDialog />
+            <PartialMoveToExistingCollectionDialog />
+            <PartialMoveToSeparateCollectionsDialog />
             <ProcessInputDialog />
             <RestoreCollectionVersionDialog />
             <RemoveApiClientAuthorizationDialog />
@@ -296,5 +435,6 @@ export const WorkbenchPanel =
             <WebDavS3InfoDialog />
             <Banner />
             {React.createElement(React.Fragment, null, pluginConfig.dialogs)}
-        </Grid>}
+        </Grid>
     );
+});
diff --git a/src/views/workflow-panel/registered-workflow-panel.tsx b/src/views/workflow-panel/registered-workflow-panel.tsx
new file mode 100644 (file)
index 0000000..50192e5
--- /dev/null
@@ -0,0 +1,229 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    Tooltip,
+    Typography,
+    Card,
+    CardHeader,
+    CardContent,
+    IconButton
+} from '@material-ui/core';
+import { connect, DispatchProp } from "react-redux";
+import { RouteComponentProps } from 'react-router';
+import { ArvadosTheme } from 'common/custom-theme';
+import { RootState } from 'store/store';
+import { WorkflowIcon, MoreVerticalIcon } from 'components/icon/icon';
+import { WorkflowResource } from 'models/workflow';
+import { ProcessOutputCollectionFiles } from 'views/process-panel/process-output-collection-files';
+import { WorkflowDetailsAttributes, RegisteredWorkflowPanelDataProps, getRegisteredWorkflowPanelData } from 'views-components/details-panel/workflow-details';
+import { getResource } from 'store/resources/resources';
+import { openContextMenu, resourceUuidToContextMenuKind } from 'store/context-menu/context-menu-actions';
+import { MPVContainer, MPVPanelContent, MPVPanelState } from 'components/multi-panel-view/multi-panel-view';
+import { ProcessIOCard, ProcessIOCardType } from 'views/process-panel/process-io-card';
+import { NotFoundView } from 'views/not-found-panel/not-found-panel';
+
+type CssRules = 'root'
+    | 'button'
+    | 'infoCard'
+    | 'propertiesCard'
+    | 'filesCard'
+    | 'iconHeader'
+    | 'tag'
+    | 'label'
+    | 'value'
+    | 'link'
+    | 'centeredLabel'
+    | 'warningLabel'
+    | 'collectionName'
+    | 'readOnlyIcon'
+    | 'header'
+    | 'title'
+    | 'avatar'
+    | 'content';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+    },
+    button: {
+        cursor: 'pointer'
+    },
+    infoCard: {
+    },
+    propertiesCard: {
+        padding: 0,
+    },
+    filesCard: {
+        padding: 0,
+    },
+    iconHeader: {
+        fontSize: '1.875rem',
+        color: theme.customs.colors.greyL
+    },
+    tag: {
+        marginRight: theme.spacing.unit / 2,
+        marginBottom: theme.spacing.unit / 2
+    },
+    label: {
+        fontSize: '0.875rem',
+    },
+    centeredLabel: {
+        fontSize: '0.875rem',
+        textAlign: 'center'
+    },
+    warningLabel: {
+        fontStyle: 'italic'
+    },
+    collectionName: {
+        flexDirection: 'column',
+    },
+    value: {
+        textTransform: 'none',
+        fontSize: '0.875rem'
+    },
+    link: {
+        fontSize: '0.875rem',
+        color: theme.palette.primary.main,
+        '&:hover': {
+            cursor: 'pointer'
+        }
+    },
+    readOnlyIcon: {
+        marginLeft: theme.spacing.unit,
+        fontSize: 'small',
+    },
+    header: {
+        paddingTop: theme.spacing.unit,
+        paddingBottom: theme.spacing.unit,
+    },
+    title: {
+        overflow: 'hidden',
+        paddingTop: theme.spacing.unit * 0.5,
+        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,
+        }
+    }
+});
+
+type RegisteredWorkflowPanelProps = RegisteredWorkflowPanelDataProps & DispatchProp & WithStyles<CssRules>
+
+export const RegisteredWorkflowPanel = withStyles(styles)(connect(
+    (state: RootState, props: RouteComponentProps<{ id: string }>) => {
+        const item = getResource<WorkflowResource>(props.match.params.id)(state.resources);
+        if (item) {
+            return getRegisteredWorkflowPanelData(item, state.auth);
+        }
+        return { item, inputParams: [], outputParams: [], workflowCollection: "", gitprops: {} };
+    })(
+        class extends React.Component<RegisteredWorkflowPanelProps> {
+            render() {
+                const { classes, item, inputParams, outputParams, workflowCollection } = this.props;
+                const panelsData: MPVPanelState[] = [
+                    { name: "Details" },
+                    { name: "Inputs" },
+                    { name: "Outputs" },
+                    { name: "Files" },
+                ];
+                return item
+                    ? <MPVContainer className={classes.root} spacing={8} direction="column" justify-content="flex-start" wrap="nowrap" panelStates={panelsData}>
+                        <MPVPanelContent xs="auto" data-cy='registered-workflow-info-panel'>
+                            <Card className={classes.infoCard}>
+                                <CardHeader
+                                    className={classes.header}
+                                    classes={{
+                                        content: classes.title,
+                                        avatar: classes.avatar,
+                                    }}
+                                    avatar={<WorkflowIcon className={classes.iconHeader} />}
+                                    title={
+                                        <Tooltip title={item.name} placement="bottom-start">
+                                            <Typography noWrap variant='h6'>
+                                                {item.name}
+                                            </Typography>
+                                        </Tooltip>
+                                    }
+                                    subheader={
+                                        <Tooltip title={item.description || '(no-description)'} placement="bottom-start">
+                                            <Typography noWrap variant='body1' color='inherit'>
+                                                {item.description || '(no-description)'}
+                                            </Typography>
+                                        </Tooltip>}
+                                    action={
+                                        <Tooltip title="More options" disableFocusListener>
+                                            <IconButton
+                                                aria-label="More options"
+                                                onClick={event => this.handleContextMenu(event)}>
+                                                <MoreVerticalIcon />
+                                            </IconButton>
+                                        </Tooltip>}
+
+                                />
+
+                                <CardContent className={classes.content}>
+                                    <WorkflowDetailsAttributes workflow={item} />
+                                </CardContent>
+                            </Card>
+                        </MPVPanelContent>
+                        <MPVPanelContent forwardProps xs data-cy="process-inputs">
+                            <ProcessIOCard
+                                label={ProcessIOCardType.INPUT}
+                                params={inputParams}
+                                raw={{}}
+                                showParams={true}
+                            />
+                        </MPVPanelContent>
+                        <MPVPanelContent forwardProps xs data-cy="process-outputs">
+                            <ProcessIOCard
+                                label={ProcessIOCardType.OUTPUT}
+                                params={outputParams}
+                                raw={{}}
+                                showParams={true}
+                            />
+                        </MPVPanelContent>
+                        <MPVPanelContent xs>
+                            <Card className={classes.filesCard}>
+                                <ProcessOutputCollectionFiles isWritable={false} currentItemUuid={workflowCollection} />
+                            </Card>
+                        </MPVPanelContent>
+                    </MPVContainer>
+                    :
+                    <NotFoundView
+                        icon={WorkflowIcon}
+                        messages={["Workflow not found"]}
+                    />
+            }
+
+            handleContextMenu = (event: React.MouseEvent<any>) => {
+                const { uuid, ownerUuid, name, description,
+                    kind } = this.props.item;
+                const menuKind = this.props.dispatch<any>(resourceUuidToContextMenuKind(uuid));
+                const resource = {
+                    uuid,
+                    ownerUuid,
+                    name,
+                    description,
+                    kind,
+                    menuKind,
+                };
+                // Avoid expanding/collapsing the panel
+                event.stopPropagation();
+                this.props.dispatch<any>(openContextMenu(event, resource));
+            }
+        }
+    )
+);
index 7c8e0171e6d38bb48286b3d23a341c5c881986f8..1b74b11f3fa665601fdf75d4aa9fc7b808623242 100644 (file)
@@ -2,20 +2,21 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { RootStore } from 'store/store';
-import { AuthService } from 'services/auth-service/auth-service';
-import { Config } from 'common/config';
-import { WebSocketService } from './websocket-service';
-import { ResourceEventMessage } from './resource-event-message';
-import { ResourceKind } from 'models/resource';
-import { loadProcess } from 'store/processes/processes-actions';
-import { LogEventType } from 'models/log';
-import { addProcessLogsPanelItem } from 'store/process-logs-panel/process-logs-panel-actions';
+import { RootStore } from "store/store";
+import { AuthService } from "services/auth-service/auth-service";
+import { Config } from "common/config";
+import { WebSocketService } from "./websocket-service";
+import { ResourceEventMessage } from "./resource-event-message";
+import { ResourceKind } from "models/resource";
+import { loadProcess } from "store/processes/processes-actions";
+import { getProcess, getSubprocesses } from "store/processes/process";
+import { LogEventType } from "models/log";
 import { subprocessPanelActions } from "store/subprocess-panel/subprocess-panel-actions";
-import { projectPanelActions } from "store/project-panel/project-panel-action";
-import { getProjectPanelCurrentUuid } from 'store/project-panel/project-panel-action';
-import { allProcessesPanelActions } from 'store/all-processes-panel/all-processes-panel-action';
-import { loadCollection } from 'store/workbench/workbench-actions';
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { getProjectPanelCurrentUuid } from "store/project-panel/project-panel-action";
+import { allProcessesPanelActions } from "store/all-processes-panel/all-processes-panel-action";
+import { loadCollection } from "store/workbench/workbench-actions";
+import { matchAllProcessesRoute, matchProjectRoute, matchProcessRoute } from "routes/routes";
 
 export const initWebSocket = (config: Config, authService: AuthService, store: RootStore) => {
     if (config.websocketUrl) {
@@ -29,29 +30,47 @@ export const initWebSocket = (config: Config, authService: AuthService, store: R
 
 const messageListener = (store: RootStore) => (message: ResourceEventMessage) => {
     if (message.eventType === LogEventType.CREATE || message.eventType === LogEventType.UPDATE) {
+        const state = store.getState();
+        const location = state.router.location ? state.router.location.pathname : "";
         switch (message.objectKind) {
             case ResourceKind.COLLECTION:
-                const currentCollection = store.getState().collectionPanel.item;
+                const currentCollection = state.collectionPanel.item;
                 if (currentCollection && currentCollection.uuid === message.objectUuid) {
                     store.dispatch(loadCollection(message.objectUuid));
                 }
                 return;
             case ResourceKind.CONTAINER_REQUEST:
-                if (store.getState().processPanel.containerRequestUuid === message.objectUuid) {
-                    store.dispatch(loadProcess(message.objectUuid));
+                if (matchProcessRoute(location)) {
+                    if (state.processPanel.containerRequestUuid === message.objectUuid) {
+                        store.dispatch(loadProcess(message.objectUuid));
+                    }
+                    const proc = getProcess(state.processPanel.containerRequestUuid)(state.resources);
+                    if (proc && proc.container && proc.container.uuid === message.properties["new_attributes"]["requesting_container_uuid"]) {
+                        store.dispatch(subprocessPanelActions.REQUEST_ITEMS(false, true));
+                        return;
+                    }
                 }
             // fall through, this will happen for container requests as well.
             case ResourceKind.CONTAINER:
-                store.dispatch(subprocessPanelActions.REQUEST_ITEMS());
-                store.dispatch(allProcessesPanelActions.REQUEST_ITEMS());
-                if (message.objectOwnerUuid === getProjectPanelCurrentUuid(store.getState())) {
-                    store.dispatch(projectPanelActions.REQUEST_ITEMS());
+                if (matchProcessRoute(location)) {
+                    // refresh only if this is a subprocess of the currently displayed process.
+                    const subproc = getSubprocesses(state.processPanel.containerRequestUuid)(state.resources);
+                    for (const sb of subproc) {
+                        if (sb.containerRequest.uuid === message.objectUuid || (sb.container && sb.container.uuid === message.objectUuid)) {
+                            store.dispatch(subprocessPanelActions.REQUEST_ITEMS(false, true));
+                            break;
+                        }
+                    }
+                }
+                if (matchAllProcessesRoute(location)) {
+                    store.dispatch(allProcessesPanelActions.REQUEST_ITEMS(false, true));
+                }
+                if (matchProjectRoute(location) && message.objectOwnerUuid === getProjectPanelCurrentUuid(state)) {
+                    store.dispatch(projectPanelActions.REQUEST_ITEMS(false, true));
                 }
                 return;
             default:
                 return;
         }
-    } else {
-        return store.dispatch(addProcessLogsPanelItem(message as ResourceEventMessage<{ text: string }>));
     }
 };
index 367ccecd3512d90f261adc30b85405aac3f6176a..132b0e53266e79d4ff7b42acb451cc6b9ea4261b 100755 (executable)
@@ -96,9 +96,9 @@ fi
 
 echo "Building & installing arvados-server..."
 cd ${ARVADOS_DIR}
-go mod download || exit 1
+GOFLAGS=-buildvcs=false go mod download || exit 1
 cd cmd/arvados-server
-go install
+GOFLAGS=-buildvcs=false go install
 cd -
 
 echo "Installing dev dependencies..."
@@ -139,10 +139,10 @@ exec 8<&"${wb2[0]}"; coproc consume_wb2_stdout (cat <&8 >&2)
 
 # Wait for workbench2 to be up.
 # Using https-get to avoid false positive 'ready' detection.
-yarn run wait-on --timeout 300000 https-get://localhost:${WB2_PORT} || exit 1
+yarn run wait-on --timeout 300000 https-get://127.0.0.1:${WB2_PORT} || exit 1
 
 echo "Running tests..."
 CYPRESS_system_token=systemusertesttoken1234567890aoeuidhtnsqjkxbmwvzpy \
     CYPRESS_controller_url=${controllerURL} \
-    CYPRESS_BASE_URL=https://localhost:${WB2_PORT} \
+    CYPRESS_BASE_URL=https://127.0.0.1:${WB2_PORT} \
     yarn run cypress ${CYPRESS_MODE}
index 7bce40227d5706bcabd95c8448d82a6679f83fdf..08f7108e6230629b7bb787ecfda07f41a51390f6 100644 (file)
@@ -5,6 +5,7 @@
     "target": "es5",
     "lib": [
       "es6",
+      "es2020",
       "dom"
     ],
     "sourceMap": true,
index 580aa8ed9243da6ecf6049fcdb91fb64d35b502a..2e0c4f2a1e2dd0b18d9e71faf93c262cc90f40cd 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
@@ -1646,6 +1646,28 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@coreui/coreui@npm:^4.3.2":
+  version: 4.3.2
+  resolution: "@coreui/coreui@npm:4.3.2"
+  dependencies:
+    postcss-combine-duplicated-selectors: ^10.0.3
+  peerDependencies:
+    "@popperjs/core": ^2.11.6
+  checksum: 88fc70f4f681bb796e1d81ca8472a3d36bfcf92866fc7c6810ead850bc371c99bca123a94abb0fafdf2935972d130005cd62b485406631cfd9abd8f38e14be15
+  languageName: node
+  linkType: hard
+
+"@coreui/react@npm:^4.11.0":
+  version: 4.11.0
+  resolution: "@coreui/react@npm:4.11.0"
+  peerDependencies:
+    "@coreui/coreui": 4.3.0
+    react: ">=17"
+    react-dom: ">=17"
+  checksum: 75c9394125e41e24fb5855b82cba93c9abeea080f9ee5bcc063ff2e581318b85c5bbef6f2c5300f5fd7a3450743488daa29b4baee6feabec38a009a452876a88
+  languageName: node
+  linkType: hard
+
 "@csstools/convert-colors@npm:^1.4.0":
   version: 1.4.0
   resolution: "@csstools/convert-colors@npm:1.4.0"
@@ -1765,7 +1787,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@gar/promisify@npm:^1.0.1":
+"@gar/promisify@npm:^1.0.1, @gar/promisify@npm:^1.1.3":
   version: 1.1.3
   resolution: "@gar/promisify@npm:1.1.3"
   checksum: 4059f790e2d07bf3c3ff3e0fec0daa8144fe35c1f6e0111c9921bd32106adaa97a4ab096ad7dab1e28ee6a9060083c4d1a4ada42a7f5f3f7a96b8812e2b757c1
@@ -2182,6 +2204,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@npmcli/fs@npm:^2.1.0":
+  version: 2.1.2
+  resolution: "@npmcli/fs@npm:2.1.2"
+  dependencies:
+    "@gar/promisify": ^1.1.3
+    semver: ^7.3.5
+  checksum: 405074965e72d4c9d728931b64d2d38e6ea12066d4fad651ac253d175e413c06fe4350970c783db0d749181da8fe49c42d3880bd1cbc12cd68e3a7964d820225
+  languageName: node
+  linkType: hard
+
 "@npmcli/move-file@npm:^1.0.1":
   version: 1.1.2
   resolution: "@npmcli/move-file@npm:1.1.2"
@@ -2192,6 +2224,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@npmcli/move-file@npm:^2.0.0":
+  version: 2.0.1
+  resolution: "@npmcli/move-file@npm:2.0.1"
+  dependencies:
+    mkdirp: ^1.0.4
+    rimraf: ^3.0.2
+  checksum: 52dc02259d98da517fae4cb3a0a3850227bdae4939dda1980b788a7670636ca2b4a01b58df03dd5f65c1e3cb70c50fa8ce5762b582b3f499ec30ee5ce1fd9380
+  languageName: node
+  linkType: hard
+
 "@phenomnomnominal/tsquery@npm:^3.0.0":
   version: 3.0.0
   resolution: "@phenomnomnominal/tsquery@npm:3.0.0"
@@ -2233,6 +2275,24 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@sinonjs/commons@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "@sinonjs/commons@npm:3.0.0"
+  dependencies:
+    type-detect: 4.0.8
+  checksum: b4b5b73d4df4560fb8c0c7b38c7ad4aeabedd362f3373859d804c988c725889cde33550e4bcc7cd316a30f5152a2d1d43db71b6d0c38f5feef71fd8d016763f8
+  languageName: node
+  linkType: hard
+
+"@sinonjs/fake-timers@npm:^10.3.0":
+  version: 10.3.0
+  resolution: "@sinonjs/fake-timers@npm:10.3.0"
+  dependencies:
+    "@sinonjs/commons": ^3.0.0
+  checksum: 614d30cb4d5201550c940945d44c9e0b6d64a888ff2cd5b357f95ad6721070d6b8839cd10e15b76bf5e14af0bcc1d8f9ec00d49a46318f1f669a4bec1d7f3148
+  languageName: node
+  linkType: hard
+
 "@sinonjs/formatio@npm:^3.2.1":
   version: 3.2.2
   resolution: "@sinonjs/formatio@npm:3.2.2"
@@ -2392,6 +2452,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@tootallnate/once@npm:1":
+  version: 1.1.2
+  resolution: "@tootallnate/once@npm:1.1.2"
+  checksum: e1fb1bbbc12089a0cb9433dc290f97bddd062deadb6178ce9bcb93bb7c1aecde5e60184bc7065aec42fe1663622a213493c48bbd4972d931aae48315f18e1be9
+  languageName: node
+  linkType: hard
+
 "@tootallnate/once@npm:2":
   version: 2.0.0
   resolution: "@tootallnate/once@npm:2.0.0"
@@ -2463,6 +2530,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/dompurify@npm:^3.0.3":
+  version: 3.0.3
+  resolution: "@types/dompurify@npm:3.0.3"
+  dependencies:
+    "@types/trusted-types": "*"
+  checksum: ff629277db4d19d836b0d878e93efb27d876d1073db81507c39d44d509b30ee3bcdc9e951dbbf9574b1fc6c52e1eaa95abf4279fa45aca281868717f8a7298da
+  languageName: node
+  linkType: hard
+
 "@types/enzyme-adapter-react-16@npm:1.0.3":
   version: 1.0.3
   resolution: "@types/enzyme-adapter-react-16@npm:1.0.3"
@@ -2624,6 +2700,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/minimist@npm:^1.2.0":
+  version: 1.2.3
+  resolution: "@types/minimist@npm:1.2.3"
+  checksum: 666ea4f8c39dcbdfbc3171fe6b3902157c845cc9cb8cee33c10deb706cda5e0cc80f98ace2d6d29f6774b0dc21180c96cd73c592a1cbefe04777247c7ba0e84b
+  languageName: node
+  linkType: hard
+
 "@types/node@npm:*, @types/node@npm:15.12.4":
   version: 15.12.4
   resolution: "@types/node@npm:15.12.4"
@@ -2631,6 +2714,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/normalize-package-data@npm:^2.4.0":
+  version: 2.4.2
+  resolution: "@types/normalize-package-data@npm:2.4.2"
+  checksum: 2132e4054711e6118de967ae3a34f8c564e58d71fbcab678ec2c34c14659f638a86c35a0fd45237ea35a4a03079cf0a485e3f97736ffba5ed647bfb5da086b03
+  languageName: node
+  linkType: hard
+
 "@types/parse-json@npm:^4.0.0":
   version: 4.0.0
   resolution: "@types/parse-json@npm:4.0.0"
@@ -2859,6 +2949,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/trusted-types@npm:*":
+  version: 2.0.4
+  resolution: "@types/trusted-types@npm:2.0.4"
+  checksum: 5256c4576cd1c90d33ddd9cc9cbd4f202b39c98cbe8b7f74963298f9eb2159c285ea5c25a6181b4c594d8d75641765bff85d72c2d251ad076e6529ce0eeedd1c
+  languageName: node
+  linkType: hard
+
 "@types/uuid@npm:3.4.4":
   version: 3.4.4
   resolution: "@types/uuid@npm:3.4.4"
@@ -3309,6 +3406,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"agentkeepalive@npm:^4.1.3":
+  version: 4.5.0
+  resolution: "agentkeepalive@npm:4.5.0"
+  dependencies:
+    humanize-ms: ^1.2.1
+  checksum: 13278cd5b125e51eddd5079f04d6fe0914ac1b8b91c1f3db2c1822f99ac1a7457869068997784342fe455d59daaff22e14fb7b8c3da4e741896e7e31faf92481
+  languageName: node
+  linkType: hard
+
 "agentkeepalive@npm:^4.2.1":
   version: 4.2.1
   resolution: "agentkeepalive@npm:4.2.1"
@@ -3433,27 +3539,20 @@ __metadata:
   linkType: hard
 
 "ansi-regex@npm:^3.0.0":
-  version: 3.0.0
-  resolution: "ansi-regex@npm:3.0.0"
-  checksum: 2ad11c416f81c39f5c65eafc88cf1d71aa91d76a2f766e75e457c2a3c43e8a003aadbf2966b61c497aa6a6940a36412486c975b3270cdfc3f413b69826189ec3
+  version: 3.0.1
+  resolution: "ansi-regex@npm:3.0.1"
+  checksum: 09daf180c5f59af9850c7ac1bd7fda85ba596cc8cbeb210826e90755f06c818af86d9fa1e6e8322fab2c3b9e9b03f56c537b42241139f824dd75066a1e7257cc
   languageName: node
   linkType: hard
 
 "ansi-regex@npm:^4.0.0, ansi-regex@npm:^4.1.0":
-  version: 4.1.0
-  resolution: "ansi-regex@npm:4.1.0"
-  checksum: 97aa4659538d53e5e441f5ef2949a3cffcb838e57aeaad42c4194e9d7ddb37246a6526c4ca85d3940a9d1e19b11cc2e114530b54c9d700c8baf163c31779baf8
-  languageName: node
-  linkType: hard
-
-"ansi-regex@npm:^5.0.0":
-  version: 5.0.0
-  resolution: "ansi-regex@npm:5.0.0"
-  checksum: b1bb4e992a5d96327bb4f72eaba9f8047f1d808d273ad19d399e266bfcc7fb19a4d1a127a32f7bc61fe46f1a94a4d04ec4c424e3fbe184929aa866323d8ed4ce
+  version: 4.1.1
+  resolution: "ansi-regex@npm:4.1.1"
+  checksum: b1a6ee44cb6ecdabaa770b2ed500542714d4395d71c7e5c25baa631f680fb2ad322eb9ba697548d498a6fd366949fc8b5bfcf48d49a32803611f648005b01888
   languageName: node
   linkType: hard
 
-"ansi-regex@npm:^5.0.1":
+"ansi-regex@npm:^5.0.0, ansi-regex@npm:^5.0.1":
   version: 5.0.1
   resolution: "ansi-regex@npm:5.0.1"
   checksum: 2aa4bb54caf2d622f1afdad09441695af2a83aa3fe8b8afa581d205e57ed4261c183c4d3877cee25794443fde5876417d859c108078ab788d6af7e4fe52eb66b
@@ -3519,7 +3618,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"aproba@npm:^1.0.3, aproba@npm:^1.1.1":
+"aproba@npm:^1.1.1":
   version: 1.2.0
   resolution: "aproba@npm:1.2.0"
   checksum: 0fca141966559d195072ed047658b6e6c4fe92428c385dd38e288eacfc55807e7b4989322f030faff32c0f46bb0bc10f1e0ac32ec22d25315a1e5bbc0ebb76dc
@@ -3533,23 +3632,23 @@ __metadata:
   languageName: node
   linkType: hard
 
-"are-we-there-yet@npm:^3.0.0":
-  version: 3.0.0
-  resolution: "are-we-there-yet@npm:3.0.0"
+"are-we-there-yet@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "are-we-there-yet@npm:2.0.0"
   dependencies:
     delegates: ^1.0.0
     readable-stream: ^3.6.0
-  checksum: 348edfdd931b0b50868b55402c01c3f64df1d4c229ab6f063539a5025fd6c5f5bb8a0cab409bbed8d75d34762d22aa91b7c20b4204eb8177063158d9ba792981
+  checksum: 6c80b4fd04ecee6ba6e737e0b72a4b41bdc64b7d279edfc998678567ff583c8df27e27523bc789f2c99be603ffa9eaa612803da1d886962d2086e7ff6fa90c7c
   languageName: node
   linkType: hard
 
-"are-we-there-yet@npm:~1.1.2":
-  version: 1.1.5
-  resolution: "are-we-there-yet@npm:1.1.5"
+"are-we-there-yet@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "are-we-there-yet@npm:3.0.0"
   dependencies:
     delegates: ^1.0.0
-    readable-stream: ^2.0.6
-  checksum: 9a746b1dbce4122f44002b0c39fbba5b2c6f52c00e88b6ccba6fc68652323f8a1355a20e8ab94846995626d8de3bf67669a3b4a037dff0885db14607168f2b15
+    readable-stream: ^3.6.0
+  checksum: 348edfdd931b0b50868b55402c01c3f64df1d4c229ab6f063539a5025fd6c5f5bb8a0cab409bbed8d75d34762d22aa91b7c20b4204eb8177063158d9ba792981
   languageName: node
   linkType: hard
 
@@ -3723,14 +3822,18 @@ __metadata:
   version: 0.0.0-use.local
   resolution: "arvados-workbench-2@workspace:."
   dependencies:
+    "@coreui/coreui": ^4.3.2
+    "@coreui/react": ^4.11.0
     "@date-io/date-fns": 1
     "@fortawesome/fontawesome-svg-core": 1.2.28
     "@fortawesome/free-solid-svg-icons": 5.13.0
     "@fortawesome/react-fontawesome": 0.1.9
     "@material-ui/core": 3.9.3
     "@material-ui/icons": 3.0.1
+    "@sinonjs/fake-timers": ^10.3.0
     "@types/classnames": 2.2.6
     "@types/debounce": 3.0.0
+    "@types/dompurify": ^3.0.3
     "@types/enzyme": 3.1.14
     "@types/enzyme-adapter-react-16": 1.0.3
     "@types/file-saver": 2.0.0
@@ -3762,12 +3865,14 @@ __metadata:
     axios-mock-adapter: 1.17.0
     babel-core: 6.26.3
     babel-runtime: 6.26.0
+    bootstrap: ^5.3.2
     caniuse-lite: 1.0.30001299
     classnames: 2.2.6
     cwlts: 1.15.29
     cypress: 6.3.0
     date-fns: ^2.28.0
     debounce: 1.2.0
+    dompurify: ^3.0.6
     elliptic: 6.5.4
     enzyme: 3.11.0
     enzyme-adapter-react-16: 1.15.6
@@ -3777,25 +3882,25 @@ __metadata:
     jest-localstorage-mock: 2.2.0
     js-yaml: 3.13.1
     jssha: 2.3.1
-    jszip: 3.1.5
+    jszip: ^3.10.1
     lodash: ^4.17.21
-    lodash-es: 4.17.14
+    lodash-es: ^4.17.21
     lodash.mergewith: 4.6.2
     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
+    moment: ^2.29.4
+    node-sass: ^9.0.0
+    node-sass-chokidar: ^2.0.0
     parse-duration: 0.4.4
     prop-types: 15.7.2
     query-string: 6.9.0
-    react: 16.8.6
+    react: 16.14.0
     react-copy-to-clipboard: 5.0.3
     react-dnd: 5.0.0
     react-dnd-html5-backend: 5.0.1
-    react-dom: 16.8.6
+    react-dom: 16.14.0
     react-dropzone: 5.1.1
     react-highlight-words: 0.14.0
     react-idle-timer: 4.3.6
@@ -3803,7 +3908,7 @@ __metadata:
     react-router: 4.3.1
     react-router-dom: 4.3.1
     react-router-redux: 5.0.0-alpha.9
-    react-rte: 0.16.3
+    react-rte: ^0.16.5
     react-scripts: 3.4.4
     react-splitter-layout: 3.0.1
     react-transition-group: 2.5.0
@@ -3811,6 +3916,7 @@ __metadata:
     react-window: 1.8.5
     redux: 4.0.3
     redux-devtools: 3.4.1
+    redux-devtools-extension: ^2.13.9
     redux-form: 7.4.2
     redux-mock-store: 1.5.4
     redux-thunk: 2.3.0
@@ -3918,18 +4024,18 @@ __metadata:
   linkType: hard
 
 "async@npm:^2.6.2":
-  version: 2.6.3
-  resolution: "async@npm:2.6.3"
+  version: 2.6.4
+  resolution: "async@npm:2.6.4"
   dependencies:
     lodash: ^4.17.14
-  checksum: 5e5561ff8fca807e88738533d620488ac03a5c43fce6c937451f7e35f943d33ad06c24af3f681a48cca3d2b0002b3118faff0a128dc89438a9bf0226f712c499
+  checksum: a52083fb32e1ebe1d63e5c5624038bb30be68ff07a6c8d7dfe35e47c93fc144bd8652cbec869e0ac07d57dde387aa5f1386be3559cdee799cb1f789678d88e19
   languageName: node
   linkType: hard
 
 "async@npm:^3.2.0":
-  version: 3.2.0
-  resolution: "async@npm:3.2.0"
-  checksum: 6739fae769e6c9f76b272558f118ef041d45c979c573a8fe93f8cfbc32eb9c92da032e9effe6bbcc9b1131292cde6c4a9e61a442894aa06a262addd8dd3adda1
+  version: 3.2.4
+  resolution: "async@npm:3.2.4"
+  checksum: 43d07459a4e1d09b84a20772414aa684ff4de085cbcaec6eea3c7a8f8150e8c62aa6cd4e699fe8ee93c3a5b324e777d34642531875a0817a35697522c1b02e89
   languageName: node
   linkType: hard
 
@@ -4015,11 +4121,11 @@ __metadata:
   linkType: hard
 
 "axios@npm:^0.21.1":
-  version: 0.21.1
-  resolution: "axios@npm:0.21.1"
+  version: 0.21.4
+  resolution: "axios@npm:0.21.4"
   dependencies:
-    follow-redirects: ^1.10.0
-  checksum: c87915fa0b18c15c63350112b6b3563a3e2ae524d7707de0a73d2e065e0d30c5d3da8563037bc29d4cc1b7424b5a350cb7274fa52525c6c04a615fe561c6ab11
+    follow-redirects: ^1.14.0
+  checksum: 44245f24ac971e7458f3120c92f9d66d1fc695e8b97019139de5b0cc65d9b8104647db01e5f46917728edfc0cfd88eb30fc4c55e6053eef4ace76768ce95ff3c
   languageName: node
   linkType: hard
 
@@ -4464,15 +4570,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"block-stream@npm:*":
-  version: 0.0.9
-  resolution: "block-stream@npm:0.0.9"
-  dependencies:
-    inherits: ~2.0.0
-  checksum: 72733cbb816181b7c92449e7b650247c02122f743526ce9d948ff68afc27d8709106cd62f2c876c6d8cd3977e0204a014f38d22805974008039bd3bed35f2cbd
-  languageName: node
-  linkType: hard
-
 "bluebird@npm:^3.5.5, bluebird@npm:^3.7.2":
   version: 3.7.2
   resolution: "bluebird@npm:3.7.2"
@@ -4533,6 +4630,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"bootstrap@npm:^5.3.2":
+  version: 5.3.2
+  resolution: "bootstrap@npm:5.3.2"
+  peerDependencies:
+    "@popperjs/core": ^2.11.8
+  checksum: d5580b253d121ffc137388d41da58dce8d15f1ccd574e12f28d4a08e7649ca15e95db645b2b677cb8025bccd446bff04138fc0fe64f8cba0ccc5dc004a8644cf
+  languageName: node
+  linkType: hard
+
 "brace-expansion@npm:^1.1.7":
   version: 1.1.11
   resolution: "brace-expansion@npm:1.1.11"
@@ -4543,6 +4649,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"brace-expansion@npm:^2.0.1":
+  version: 2.0.1
+  resolution: "brace-expansion@npm:2.0.1"
+  dependencies:
+    balanced-match: ^1.0.0
+  checksum: a61e7cd2e8a8505e9f0036b3b6108ba5e926b4b55089eeb5550cd04a471fe216c96d4fe7e4c7f995c728c554ae20ddfc4244cad10aef255e72b62930afd233d1
+  languageName: node
+  linkType: hard
+
 "braces@npm:^2.3.1, braces@npm:^2.3.2":
   version: 2.3.2
   resolution: "braces@npm:2.3.2"
@@ -4561,7 +4676,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"braces@npm:^3.0.1, braces@npm:~3.0.2":
+"braces@npm:^3.0.2, braces@npm:~3.0.2":
   version: 3.0.2
   resolution: "braces@npm:3.0.2"
   dependencies:
@@ -4688,17 +4803,16 @@ __metadata:
   linkType: hard
 
 "browserslist@npm:^4.0.0, browserslist@npm:^4.12.0, browserslist@npm:^4.16.6, browserslist@npm:^4.6.2, browserslist@npm:^4.6.4, browserslist@npm:^4.9.1":
-  version: 4.16.6
-  resolution: "browserslist@npm:4.16.6"
+  version: 4.22.1
+  resolution: "browserslist@npm:4.22.1"
   dependencies:
-    caniuse-lite: ^1.0.30001219
-    colorette: ^1.2.2
-    electron-to-chromium: ^1.3.723
-    escalade: ^3.1.1
-    node-releases: ^1.1.71
+    caniuse-lite: ^1.0.30001541
+    electron-to-chromium: ^1.4.535
+    node-releases: ^2.0.13
+    update-browserslist-db: ^1.0.13
   bin:
     browserslist: cli.js
-  checksum: 3dffc86892d2dcfcfc66b52519b7e5698ae070b4fc92ab047e760efc4cae0474e9e70bbe10d769c8d3491b655ef3a2a885b88e7196c83cc5dc0a46dfdba8b70c
+  checksum: 7e6b10c53f7dd5d83fd2b95b00518889096382539fed6403829d447e05df4744088de46a571071afb447046abc3c66ad06fbc790e70234ec2517452e32ffd862
   languageName: node
   linkType: hard
 
@@ -4827,7 +4941,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"cacache@npm:^15.3.0":
+"cacache@npm:^15.2.0, cacache@npm:^15.3.0":
   version: 15.3.0
   resolution: "cacache@npm:15.3.0"
   dependencies:
@@ -4853,6 +4967,32 @@ __metadata:
   languageName: node
   linkType: hard
 
+"cacache@npm:^16.1.0":
+  version: 16.1.3
+  resolution: "cacache@npm:16.1.3"
+  dependencies:
+    "@npmcli/fs": ^2.1.0
+    "@npmcli/move-file": ^2.0.0
+    chownr: ^2.0.0
+    fs-minipass: ^2.1.0
+    glob: ^8.0.1
+    infer-owner: ^1.0.4
+    lru-cache: ^7.7.1
+    minipass: ^3.1.6
+    minipass-collect: ^1.0.2
+    minipass-flush: ^1.0.5
+    minipass-pipeline: ^1.2.4
+    mkdirp: ^1.0.4
+    p-map: ^4.0.0
+    promise-inflight: ^1.0.1
+    rimraf: ^3.0.2
+    ssri: ^9.0.0
+    tar: ^6.1.11
+    unique-filename: ^2.0.0
+  checksum: d91409e6e57d7d9a3a25e5dcc589c84e75b178ae8ea7de05cbf6b783f77a5fae938f6e8fda6f5257ed70000be27a681e1e44829251bfffe4c10216002f8f14e6
+  languageName: node
+  linkType: hard
+
 "cache-base@npm:^1.0.1":
   version: 1.0.1
   resolution: "cache-base@npm:1.0.1"
@@ -4946,6 +5086,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"camelcase-keys@npm:^6.2.2":
+  version: 6.2.2
+  resolution: "camelcase-keys@npm:6.2.2"
+  dependencies:
+    camelcase: ^5.3.1
+    map-obj: ^4.0.0
+    quick-lru: ^4.0.1
+  checksum: 43c9af1adf840471e54c68ab3e5fe8a62719a6b7dbf4e2e86886b7b0ff96112c945736342b837bd2529ec9d1c7d1934e5653318478d98e0cf22c475c04658e2a
+  languageName: node
+  linkType: hard
+
 "camelcase@npm:5.3.1, camelcase@npm:^5.0.0, camelcase@npm:^5.3.1":
   version: 5.3.1
   resolution: "camelcase@npm:5.3.1"
@@ -4986,10 +5137,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30000981, caniuse-lite@npm:^1.0.30001035, caniuse-lite@npm:^1.0.30001109, caniuse-lite@npm:^1.0.30001219":
-  version: 1.0.30001414
-  resolution: "caniuse-lite@npm:1.0.30001414"
-  checksum: 97210cfd15ded093b20c33d35bef9711a88402c3345411dad420c991a41a3e38ad17fd66721e8334c86e9b2e4aa2c1851d3631f1441afb73b92d93b2b8ca890d
+"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30000981, caniuse-lite@npm:^1.0.30001035, caniuse-lite@npm:^1.0.30001109, caniuse-lite@npm:^1.0.30001541":
+  version: 1.0.30001561
+  resolution: "caniuse-lite@npm:1.0.30001561"
+  checksum: 949829fe037e23346595614e01d362130245920503a12677f2506ce68e1240360113d6383febed41e8aa38cd0f5fd9c69c21b0af65a71c0246d560db489f1373
   languageName: node
   linkType: hard
 
@@ -5027,7 +5178,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"chalk@npm:^1.0.0, chalk@npm:^1.1.1, chalk@npm:^1.1.3":
+"chalk@npm:^1.0.0, chalk@npm:^1.1.3":
   version: 1.1.3
   resolution: "chalk@npm:1.1.3"
   dependencies:
@@ -5040,13 +5191,13 @@ __metadata:
   languageName: node
   linkType: hard
 
-"chalk@npm:^4.0.0, chalk@npm:^4.1.0":
-  version: 4.1.1
-  resolution: "chalk@npm:4.1.1"
+"chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.2":
+  version: 4.1.2
+  resolution: "chalk@npm:4.1.2"
   dependencies:
     ansi-styles: ^4.1.0
     supports-color: ^7.1.0
-  checksum: 036e973e665ba1a32c975e291d5f3d549bceeb7b1b983320d4598fb75d70fe20c5db5d62971ec0fe76cdbce83985a00ee42372416abfc3a5584465005a7855ed
+  checksum: fe75c9d5c76a7a98d45495b91b2172fa3b7a09e0cc9370e5c8feb1c567b85c4288e2b3fded7cfdd7359ac28d6b3844feb8b82b8686842e93d23c827c417e83fc
   languageName: node
   linkType: hard
 
@@ -5123,8 +5274,8 @@ __metadata:
   linkType: hard
 
 "chokidar@npm:^3.3.0, chokidar@npm:^3.4.0, chokidar@npm:^3.4.1":
-  version: 3.5.2
-  resolution: "chokidar@npm:3.5.2"
+  version: 3.5.3
+  resolution: "chokidar@npm:3.5.3"
   dependencies:
     anymatch: ~3.1.2
     braces: ~3.0.2
@@ -5137,7 +5288,7 @@ __metadata:
   dependenciesMeta:
     fsevents:
       optional: true
-  checksum: d1fda32fcd67d9f6170a8468ad2630a3c6194949c9db3f6a91b16478c328b2800f433fb5d2592511b6cb145a47c013ea1cce60b432b1a001ae3ee978a8bffc2d
+  checksum: b49fcde40176ba007ff361b198a2d35df60d9bb2a5aab228279eb810feae9294a6b4649ab15981304447afe1e6ffbf4788ad5db77235dc770ab777c6e771980c
   languageName: node
   linkType: hard
 
@@ -5326,6 +5477,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"cliui@npm:^8.0.1":
+  version: 8.0.1
+  resolution: "cliui@npm:8.0.1"
+  dependencies:
+    string-width: ^4.2.0
+    strip-ansi: ^6.0.1
+    wrap-ansi: ^7.0.0
+  checksum: 79648b3b0045f2e285b76fb2e24e207c6db44323581e421c3acbd0e86454cba1b37aea976ab50195a49e7384b871e6dfb2247ad7dec53c02454ac6497394cb56
+  languageName: node
+  linkType: hard
+
 "clone-deep@npm:^0.2.4":
   version: 0.2.4
   resolution: "clone-deep@npm:0.2.4"
@@ -5434,7 +5596,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"color-support@npm:^1.1.3":
+"color-support@npm:^1.1.2, color-support@npm:^1.1.3":
   version: 1.1.3
   resolution: "color-support@npm:1.1.3"
   bin:
@@ -5453,7 +5615,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"colorette@npm:^1.2.1, colorette@npm:^1.2.2":
+"colorette@npm:^1.2.1":
   version: 1.2.2
   resolution: "colorette@npm:1.2.2"
   checksum: 69fec14ddaedd0f5b00e4bae40dc4bc61f7050ebdc82983a595d6fd64e650b9dc3c033fff378775683138e992e0ddd8717ac7c7cec4d089679dcfbe3cd921b04
@@ -5591,7 +5753,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"console-control-strings@npm:^1.0.0, console-control-strings@npm:^1.1.0, console-control-strings@npm:~1.1.0":
+"console-control-strings@npm:^1.0.0, console-control-strings@npm:^1.1.0":
   version: 1.1.0
   resolution: "console-control-strings@npm:1.1.0"
   checksum: 8755d76787f94e6cf79ce4666f0c5519906d7f5b02d4b884cf41e11dcd759ed69c57da0670afd9236d229a46e0f9cf519db0cd829c6dca820bb5a5c3def584ed
@@ -5735,13 +5897,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"core-js@npm:~2.3.0":
-  version: 2.3.0
-  resolution: "core-js@npm:2.3.0"
-  checksum: eb2e9e82d71e646e91abc9480ee4da8a4c02606418ea83602daae5988b4ba558a233f1a29dc8d660e2e4aaa7f6e4297b6c3089b55b0e7292917eef07a3952972
-  languageName: node
-  linkType: hard
-
 "core-util-is@npm:1.0.2, core-util-is@npm:~1.0.0":
   version: 1.0.2
   resolution: "core-util-is@npm:1.0.2"
@@ -5812,11 +5967,11 @@ __metadata:
   linkType: hard
 
 "cross-fetch@npm:^3.0.4":
-  version: 3.1.4
-  resolution: "cross-fetch@npm:3.1.4"
+  version: 3.1.8
+  resolution: "cross-fetch@npm:3.1.8"
   dependencies:
-    node-fetch: 2.6.1
-  checksum: 2107e5e633aa327bdacab036b1907c7ddd28651ede0c1d4fd14db04510944d56849a8255e2f5b8f9a1da0e061b6cee943f6819fe29ed9a130195e7fadd82a4ff
+    node-fetch: ^2.6.12
+  checksum: 78f993fa099eaaa041122ab037fe9503ecbbcb9daef234d1d2e0b9230a983f64d645d088c464e21a247b825a08dc444a6e7064adfa93536d3a9454b4745b3632
   languageName: node
   linkType: hard
 
@@ -5831,16 +5986,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"cross-spawn@npm:^3.0.0":
-  version: 3.0.1
-  resolution: "cross-spawn@npm:3.0.1"
-  dependencies:
-    lru-cache: ^4.0.1
-    which: ^1.2.9
-  checksum: a029a5028629ce2b7773e341b57415b344b6e46b98b39b308822c3b524e8e92e15f10c4ca3384e90722b882dfce2cc8e10edc8e84ee1394afe9744c4a1082776
-  languageName: node
-  linkType: hard
-
 "cross-spawn@npm:^6.0.0, cross-spawn@npm:^6.0.5":
   version: 6.0.5
   resolution: "cross-spawn@npm:6.0.5"
@@ -5854,7 +5999,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"cross-spawn@npm:^7.0.0":
+"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.3":
   version: 7.0.3
   resolution: "cross-spawn@npm:7.0.3"
   dependencies:
@@ -5977,15 +6122,15 @@ __metadata:
   linkType: hard
 
 "css-select@npm:^4.1.3":
-  version: 4.1.3
-  resolution: "css-select@npm:4.1.3"
+  version: 4.3.0
+  resolution: "css-select@npm:4.3.0"
   dependencies:
     boolbase: ^1.0.0
-    css-what: ^5.0.0
-    domhandler: ^4.2.0
-    domutils: ^2.6.0
-    nth-check: ^2.0.0
-  checksum: 40928f1aa6c71faf36430e7f26bcbb8ab51d07b98b754caacb71906400a195df5e6c7020a94f2982f02e52027b9bd57c99419220cf7020968c3415f14e4be5f8
+    css-what: ^6.0.1
+    domhandler: ^4.3.1
+    domutils: ^2.8.0
+    nth-check: ^2.0.1
+  checksum: d6202736839194dd7f910320032e7cfc40372f025e4bf21ca5bf6eb0a33264f322f50ba9c0adc35dadd342d3d6fae5ca244779a4873afbfa76561e343f2058e0
   languageName: node
   linkType: hard
 
@@ -6018,13 +6163,20 @@ __metadata:
   languageName: node
   linkType: hard
 
-"css-what@npm:^3.2.1, css-what@npm:^5.0.0, css-what@npm:^5.0.1":
+"css-what@npm:^3.2.1, css-what@npm:^5.0.1":
   version: 5.0.1
   resolution: "css-what@npm:5.0.1"
   checksum: 7a3de33a1c130d32d711cce4e0fa747be7a9afe6b5f2c6f3d56bc2765f150f6034f5dd5fe263b9359a1c371c01847399602d74b55322c982742b336d998602cd
   languageName: node
   linkType: hard
 
+"css-what@npm:^6.0.1":
+  version: 6.1.0
+  resolution: "css-what@npm:6.1.0"
+  checksum: b975e547e1e90b79625918f84e67db5d33d896e6de846c9b584094e529f0c63e2ab85ee33b9daffd05bff3a146a1916bec664e18bb76dd5f66cbff9fc13b2bbe
+  languageName: node
+  linkType: hard
+
 "css@npm:^2.0.0":
   version: 2.2.4
   resolution: "css@npm:2.2.4"
@@ -6362,7 +6514,29 @@ __metadata:
   languageName: node
   linkType: hard
 
-"decamelize@npm:^1.1.1, decamelize@npm:^1.1.2, decamelize@npm:^1.2.0":
+"debug@npm:^4.3.3":
+  version: 4.3.4
+  resolution: "debug@npm:4.3.4"
+  dependencies:
+    ms: 2.1.2
+  peerDependenciesMeta:
+    supports-color:
+      optional: true
+  checksum: 3dbad3f94ea64f34431a9cbf0bafb61853eda57bff2880036153438f50fb5a84f27683ba0d8e5426bf41a8c6ff03879488120cf5b3a761e77953169c0600a708
+  languageName: node
+  linkType: hard
+
+"decamelize-keys@npm:^1.1.0":
+  version: 1.1.1
+  resolution: "decamelize-keys@npm:1.1.1"
+  dependencies:
+    decamelize: ^1.1.0
+    map-obj: ^1.0.0
+  checksum: fc645fe20b7bda2680bbf9481a3477257a7f9304b1691036092b97ab04c0ab53e3bf9fcc2d2ae382536568e402ec41fb11e1d4c3836a9abe2d813dd9ef4311e0
+  languageName: node
+  linkType: hard
+
+"decamelize@npm:^1.1.0, decamelize@npm:^1.1.1, decamelize@npm:^1.1.2, decamelize@npm:^1.2.0":
   version: 1.2.0
   resolution: "decamelize@npm:1.2.0"
   checksum: ad8c51a7e7e0720c70ec2eeb1163b66da03e7616d7b98c9ef43cce2416395e84c1e9548dd94f5f6ffecfee9f8b94251fc57121a8b021f2ff2469b2bae247b8aa
@@ -6370,9 +6544,9 @@ __metadata:
   linkType: hard
 
 "decode-uri-component@npm:^0.2.0":
-  version: 0.2.0
-  resolution: "decode-uri-component@npm:0.2.0"
-  checksum: f3749344ab9305ffcfe4bfe300e2dbb61fc6359e2b736812100a3b1b6db0a5668cba31a05e4b45d4d63dbf1a18dfa354cd3ca5bb3ededddabb8cd293f4404f94
+  version: 0.2.2
+  resolution: "decode-uri-component@npm:0.2.2"
+  checksum: 95476a7d28f267292ce745eac3524a9079058bbb35767b76e3ee87d42e34cd0275d2eb19d9d08c3e167f97556e8a2872747f5e65cbebcac8b0c98d83e285f139
   languageName: node
   linkType: hard
 
@@ -6749,6 +6923,22 @@ __metadata:
   languageName: node
   linkType: hard
 
+"domhandler@npm:^4.3.1":
+  version: 4.3.1
+  resolution: "domhandler@npm:4.3.1"
+  dependencies:
+    domelementtype: ^2.2.0
+  checksum: 4c665ceed016e1911bf7d1dadc09dc888090b64dee7851cccd2fcf5442747ec39c647bb1cb8c8919f8bbdd0f0c625a6bafeeed4b2d656bbecdbae893f43ffaaa
+  languageName: node
+  linkType: hard
+
+"dompurify@npm:^3.0.6":
+  version: 3.0.6
+  resolution: "dompurify@npm:3.0.6"
+  checksum: e5c6cdc5fe972a9d0859d939f1d86320de275be00bbef7bd5591c80b1e538935f6ce236624459a1b0c84ecd7c6a1e248684aa4637512659fccc0ce7c353828a6
+  languageName: node
+  linkType: hard
+
 "domutils@npm:^1.7.0":
   version: 1.7.0
   resolution: "domutils@npm:1.7.0"
@@ -6759,7 +6949,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"domutils@npm:^2.5.2, domutils@npm:^2.6.0, domutils@npm:^2.7.0":
+"domutils@npm:^2.5.2, domutils@npm:^2.7.0":
   version: 2.7.0
   resolution: "domutils@npm:2.7.0"
   dependencies:
@@ -6770,6 +6960,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"domutils@npm:^2.8.0":
+  version: 2.8.0
+  resolution: "domutils@npm:2.8.0"
+  dependencies:
+    dom-serializer: ^1.0.1
+    domelementtype: ^2.2.0
+    domhandler: ^4.2.0
+  checksum: abf7434315283e9aadc2a24bac0e00eab07ae4313b40cc239f89d84d7315ebdfd2fb1b5bf750a96bc1b4403d7237c7b2ebf60459be394d625ead4ca89b934391
+  languageName: node
+  linkType: hard
+
 "dot-case@npm:^3.0.4":
   version: 3.0.4
   resolution: "dot-case@npm:3.0.4"
@@ -6925,13 +7126,20 @@ __metadata:
   languageName: node
   linkType: hard
 
-"electron-to-chromium@npm:^1.3.378, electron-to-chromium@npm:^1.3.723":
+"electron-to-chromium@npm:^1.3.378":
   version: 1.3.758
   resolution: "electron-to-chromium@npm:1.3.758"
   checksum: 2fec13dcdd1b24a2314d309566bd08c7f0ce383787e64ea43c14a7fc2a11c8a76fdb9a56ce7a1da6137e1ef46365f999d10c656f2fb6b9ff792ea3ae808ebb86
   languageName: node
   linkType: hard
 
+"electron-to-chromium@npm:^1.4.535":
+  version: 1.4.540
+  resolution: "electron-to-chromium@npm:1.4.540"
+  checksum: 78a48690a5cca3f89544d4e33a11e3101adb0b220da64078f67e167b396cbcd85044853cb88a9453444796599fe157c190ca5ebd00e9daf668ed5a9df3d0bba8
+  languageName: node
+  linkType: hard
+
 "elegant-spinner@npm:^1.0.1":
   version: 1.0.1
   resolution: "elegant-spinner@npm:1.0.1"
@@ -6989,7 +7197,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"encoding@npm:^0.1.11, encoding@npm:^0.1.13":
+"encoding@npm:^0.1.11, encoding@npm:^0.1.12, encoding@npm:^0.1.13":
   version: 0.1.13
   resolution: "encoding@npm:0.1.13"
   dependencies:
@@ -7208,13 +7416,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"es6-promise@npm:~3.0.2":
-  version: 3.0.2
-  resolution: "es6-promise@npm:3.0.2"
-  checksum: f9d6cabf3fa5cff33ddd9791c190b4ae83f372489b62c81d5c19dc10afd2e59736a31e20994f80fc54151c39c00ccc493b11b5b9dfc5e605eff597f239650da5
-  languageName: node
-  linkType: hard
-
 "es6-symbol@npm:^3.1.1, es6-symbol@npm:~3.1.3":
   version: 3.1.3
   resolution: "es6-symbol@npm:3.1.3"
@@ -7600,11 +7801,9 @@ __metadata:
   linkType: hard
 
 "eventsource@npm:^1.0.7":
-  version: 1.1.0
-  resolution: "eventsource@npm:1.1.0"
-  dependencies:
-    original: ^1.0.0
-  checksum: 78338b7e75ec471cb793efb3319e0c4d2bf00fb638a2e3f888ad6d98cd1e3d4492a29f554c0921c7b2ac5130c3a732a1a0056739f6e2f548d714aec685e5da7e
+  version: 1.1.2
+  resolution: "eventsource@npm:1.1.2"
+  checksum: fe8f2ac3c70b1b63ee3cef5c0a28680cb00b5747bfda1d9835695fab3ed602be41c5c799b1fc997b34b02633573fead25b12b036bdf5212f23a6aa9f59212e9b
   languageName: node
   linkType: hard
 
@@ -7859,17 +8058,16 @@ __metadata:
   languageName: node
   linkType: hard
 
-"fast-glob@npm:^3.1.1":
-  version: 3.2.5
-  resolution: "fast-glob@npm:3.2.5"
+"fast-glob@npm:^3.2.9":
+  version: 3.3.1
+  resolution: "fast-glob@npm:3.3.1"
   dependencies:
     "@nodelib/fs.stat": ^2.0.2
     "@nodelib/fs.walk": ^1.2.3
-    glob-parent: ^5.1.0
+    glob-parent: ^5.1.2
     merge2: ^1.3.0
-    micromatch: ^4.0.2
-    picomatch: ^2.2.1
-  checksum: 5d6772c9b63dbb739d60b5630851e1f2cbf9744119e0968eac44c9f8cbc2d3d5cb4f2f0c74715ccb23daa336c87bea42186ed367e6c991afee61cd3d967320eb
+    micromatch: ^4.0.4
+  checksum: b6f3add6403e02cf3a798bfbb1183d0f6da2afd368f27456010c0bc1f9640aea308243d4cb2c0ab142f618276e65ecb8be1661d7c62a7b4e5ba774b9ce5432e5
   languageName: node
   linkType: hard
 
@@ -7931,8 +8129,8 @@ __metadata:
   linkType: hard
 
 "fbjs@npm:^0.8.1":
-  version: 0.8.17
-  resolution: "fbjs@npm:0.8.17"
+  version: 0.8.18
+  resolution: "fbjs@npm:0.8.18"
   dependencies:
     core-js: ^1.0.0
     isomorphic-fetch: ^2.1.1
@@ -7940,8 +8138,8 @@ __metadata:
     object-assign: ^4.1.0
     promise: ^7.1.1
     setimmediate: ^1.0.5
-    ua-parser-js: ^0.7.18
-  checksum: e969aeb175ccf97d8818aab9907a78f253568e0cc1b8762621c5d235bf031419d7e700f16f7711e89dfd1e0fce2b87a05f8a2800f18df0a96258f0780615fd8b
+    ua-parser-js: ^0.7.30
+  checksum: 668731b946a765908c9cbe51d5160f973abb78004b3d122587c3e930e3e1ddcc0ce2b17f2a8637dc9d733e149aa580f8d3035a35cc2d3bc78b78f1b19aab90e2
   languageName: node
   linkType: hard
 
@@ -8116,7 +8314,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"find-up@npm:4.1.0, find-up@npm:^4.0.0":
+"find-up@npm:4.1.0, find-up@npm:^4.0.0, find-up@npm:^4.1.0":
   version: 4.1.0
   resolution: "find-up@npm:4.1.0"
   dependencies:
@@ -8189,13 +8387,13 @@ __metadata:
   languageName: node
   linkType: hard
 
-"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.10.0":
-  version: 1.14.1
-  resolution: "follow-redirects@npm:1.14.1"
+"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.14.0":
+  version: 1.15.3
+  resolution: "follow-redirects@npm:1.15.3"
   peerDependenciesMeta:
     debug:
       optional: true
-  checksum: 7381a55bdc6951c5c1ab73a8da99d9fa4c0496ce72dba92cd2ac2babe0e3ebde9b81c5bca889498ad95984bc773d713284ca2bb17f1b1e1416e5f6531e39a488
+  checksum: 584da22ec5420c837bd096559ebfb8fe69d82512d5585004e36a3b4a6ef6d5905780e0c74508c7b72f907d1fa2b7bd339e613859e9c304d0dc96af2027fd0231
   languageName: node
   linkType: hard
 
@@ -8343,7 +8541,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"fs-minipass@npm:^2.0.0":
+"fs-minipass@npm:^2.0.0, fs-minipass@npm:^2.1.0":
   version: 2.1.0
   resolution: "fs-minipass@npm:2.1.0"
   dependencies:
@@ -8430,7 +8628,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"fstream@npm:1.0.12, fstream@npm:^1.0.0, fstream@npm:^1.0.12":
+"fstream@npm:1.0.12":
   version: 1.0.12
   resolution: "fstream@npm:1.0.12"
   dependencies:
@@ -8475,6 +8673,23 @@ __metadata:
   languageName: node
   linkType: hard
 
+"gauge@npm:^3.0.0":
+  version: 3.0.2
+  resolution: "gauge@npm:3.0.2"
+  dependencies:
+    aproba: ^1.0.3 || ^2.0.0
+    color-support: ^1.1.2
+    console-control-strings: ^1.0.0
+    has-unicode: ^2.0.1
+    object-assign: ^4.1.1
+    signal-exit: ^3.0.0
+    string-width: ^4.2.3
+    strip-ansi: ^6.0.1
+    wide-align: ^1.1.2
+  checksum: 81296c00c7410cdd48f997800155fbead4f32e4f82109be0719c63edc8560e6579946cc8abd04205297640691ec26d21b578837fd13a4e96288ab4b40b1dc3e9
+  languageName: node
+  linkType: hard
+
 "gauge@npm:^4.0.0":
   version: 4.0.2
   resolution: "gauge@npm:4.0.2"
@@ -8492,22 +8707,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"gauge@npm:~2.7.3":
-  version: 2.7.4
-  resolution: "gauge@npm:2.7.4"
-  dependencies:
-    aproba: ^1.0.3
-    console-control-strings: ^1.0.0
-    has-unicode: ^2.0.0
-    object-assign: ^4.1.0
-    signal-exit: ^3.0.0
-    string-width: ^1.0.1
-    strip-ansi: ^3.0.1
-    wide-align: ^1.1.0
-  checksum: a89b53cee65579b46832e050b5f3a79a832cc422c190de79c6b8e2e15296ab92faddde6ddf2d376875cbba2b043efa99b9e1ed8124e7365f61b04e3cee9d40ee
-  languageName: node
-  linkType: hard
-
 "gaze@npm:^1.0.0":
   version: 1.1.3
   resolution: "gaze@npm:1.1.3"
@@ -8616,7 +8815,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"glob-parent@npm:^5.0.0, glob-parent@npm:^5.1.0, glob-parent@npm:~5.1.2":
+"glob-parent@npm:^5.0.0, glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2":
   version: 5.1.2
   resolution: "glob-parent@npm:5.1.2"
   dependencies:
@@ -8646,6 +8845,19 @@ __metadata:
   languageName: node
   linkType: hard
 
+"glob@npm:^8.0.1":
+  version: 8.1.0
+  resolution: "glob@npm:8.1.0"
+  dependencies:
+    fs.realpath: ^1.0.0
+    inflight: ^1.0.4
+    inherits: 2
+    minimatch: ^5.0.1
+    once: ^1.3.0
+  checksum: 92fbea3221a7d12075f26f0227abac435de868dd0736a17170663783296d0dd8d3d532a5672b4488a439bf5d7fb85cdd07c11185d6cd39184f0385cbdfb86a47
+  languageName: node
+  linkType: hard
+
 "global-dirs@npm:^2.0.1":
   version: 2.1.0
   resolution: "global-dirs@npm:2.1.0"
@@ -8714,16 +8926,16 @@ __metadata:
   linkType: hard
 
 "globby@npm:^11.0.3":
-  version: 11.0.4
-  resolution: "globby@npm:11.0.4"
+  version: 11.1.0
+  resolution: "globby@npm:11.1.0"
   dependencies:
     array-union: ^2.1.0
     dir-glob: ^3.0.1
-    fast-glob: ^3.1.1
-    ignore: ^5.1.4
-    merge2: ^1.3.0
+    fast-glob: ^3.2.9
+    ignore: ^5.2.0
+    merge2: ^1.4.1
     slash: ^3.0.0
-  checksum: d3e02d5e459e02ffa578b45f040381c33e3c0538ed99b958f0809230c423337999867d7b0dbf752ce93c46157d3bbf154d3fff988a93ccaeb627df8e1841775b
+  checksum: b4be8885e0cfa018fc783792942d53926c35c50b3aefd3fdcfb9d22c627639dc26bd2327a40a0b74b074100ce95bb7187bfeae2f236856aa3de183af7a02aea6
   languageName: node
   linkType: hard
 
@@ -8806,6 +9018,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"hard-rejection@npm:^2.1.0":
+  version: 2.1.0
+  resolution: "hard-rejection@npm:2.1.0"
+  checksum: 7baaf80a0c7fff4ca79687b4060113f1529589852152fa935e6787a2bc96211e784ad4588fb3048136ff8ffc9dfcf3ae385314a5b24db32de20bea0d1597f9dc
+  languageName: node
+  linkType: hard
+
 "harmony-reflect@npm:^1.4.6":
   version: 1.6.2
   resolution: "harmony-reflect@npm:1.6.2"
@@ -8850,7 +9069,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"has-unicode@npm:^2.0.0, has-unicode@npm:^2.0.1":
+"has-unicode@npm:^2.0.1":
   version: 2.0.1
   resolution: "has-unicode@npm:2.0.1"
   checksum: 1eab07a7436512db0be40a710b29b5dc21fa04880b7f63c9980b706683127e3c1b57cb80ea96d47991bdae2dfe479604f6a1ba410106ee1046a41d1bd0814400
@@ -9007,6 +9226,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"hosted-git-info@npm:^4.0.1":
+  version: 4.1.0
+  resolution: "hosted-git-info@npm:4.1.0"
+  dependencies:
+    lru-cache: ^6.0.0
+  checksum: c3f87b3c2f7eb8c2748c8f49c0c2517c9a95f35d26f4bf54b2a8cba05d2e668f3753548b6ea366b18ec8dadb4e12066e19fa382a01496b0ffa0497eb23cbe461
+  languageName: node
+  linkType: hard
+
 "hpack.js@npm:^2.1.6":
   version: 2.1.6
   resolution: "hpack.js@npm:2.1.6"
@@ -9112,9 +9340,9 @@ __metadata:
   linkType: hard
 
 "http-cache-semantics@npm:^4.1.0":
-  version: 4.1.0
-  resolution: "http-cache-semantics@npm:4.1.0"
-  checksum: 974de94a81c5474be07f269f9fd8383e92ebb5a448208223bfb39e172a9dbc26feff250192ecc23b9593b3f92098e010406b0f24bd4d588d631f80214648ed42
+  version: 4.1.1
+  resolution: "http-cache-semantics@npm:4.1.1"
+  checksum: 83ac0bc60b17a3a36f9953e7be55e5c8f41acc61b22583060e8dedc9dd5e3607c823a88d0926f9150e571f90946835c7fe150732801010845c72cd8bbff1a236
   languageName: node
   linkType: hard
 
@@ -9170,6 +9398,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"http-proxy-agent@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "http-proxy-agent@npm:4.0.1"
+  dependencies:
+    "@tootallnate/once": 1
+    agent-base: 6
+    debug: 4
+  checksum: c6a5da5a1929416b6bbdf77b1aca13888013fe7eb9d59fc292e25d18e041bb154a8dfada58e223fc7b76b9b2d155a87e92e608235201f77d34aa258707963a82
+  languageName: node
+  linkType: hard
+
 "http-proxy-agent@npm:^5.0.0":
   version: 5.0.0
   resolution: "http-proxy-agent@npm:5.0.0"
@@ -9319,10 +9558,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"ignore@npm:^5.1.4":
-  version: 5.1.8
-  resolution: "ignore@npm:5.1.8"
-  checksum: 967abadb61e2cb0e5c5e8c4e1686ab926f91bc1a4680d994b91947d3c65d04c3ae126dcdf67f08e0feeb8ff8407d453e641aeeddcc47a3a3cca359f283cf6121
+"ignore@npm:^5.2.0":
+  version: 5.2.4
+  resolution: "ignore@npm:5.2.4"
+  checksum: 3d4c309c6006e2621659311783eaea7ebcd41fe4ca1d78c91c473157ad6666a57a2df790fe0d07a12300d9aac2888204d7be8d59f9aaf665b1c7fcdb432517ef
   languageName: node
   linkType: hard
 
@@ -9418,18 +9657,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"in-publish@npm:^2.0.0":
-  version: 2.0.1
-  resolution: "in-publish@npm:2.0.1"
-  bin:
-    in-install: in-install.js
-    in-publish: in-publish.js
-    not-in-install: not-in-install.js
-    not-in-publish: not-in-publish.js
-  checksum: 5efde2992a1e76550614a5a2c51f53669d9f3ee3a11d364de22b0c77c41de0b87c52c4c9b04375eaa276761b1944dd2b166323894d2344192328ffe85927ad38
-  languageName: node
-  linkType: hard
-
 "indefinite-observable@npm:^1.0.1":
   version: 1.0.2
   resolution: "indefinite-observable@npm:1.0.2"
@@ -9614,6 +9841,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"ip@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "ip@npm:2.0.0"
+  checksum: cfcfac6b873b701996d71ec82a7dd27ba92450afdb421e356f44044ed688df04567344c36cbacea7d01b1c39a4c732dc012570ebe9bebfb06f27314bca625349
+  languageName: node
+  linkType: hard
+
 "ipaddr.js@npm:1.9.1, ipaddr.js@npm:^1.9.0":
   version: 1.9.1
   resolution: "ipaddr.js@npm:1.9.1"
@@ -9758,8 +9992,17 @@ __metadata:
   languageName: node
   linkType: hard
 
-"is-data-descriptor@npm:^0.1.4":
-  version: 0.1.4
+"is-core-module@npm:^2.5.0":
+  version: 2.13.0
+  resolution: "is-core-module@npm:2.13.0"
+  dependencies:
+    has: ^1.0.3
+  checksum: 053ab101fb390bfeb2333360fd131387bed54e476b26860dc7f5a700bbf34a0ec4454f7c8c4d43e8a0030957e4b3db6e16d35e1890ea6fb654c833095e040355
+  languageName: node
+  linkType: hard
+
+"is-data-descriptor@npm:^0.1.4":
+  version: 0.1.4
   resolution: "is-data-descriptor@npm:0.1.4"
   dependencies:
     kind-of: ^3.0.2
@@ -10017,7 +10260,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"is-plain-obj@npm:^1.0.0":
+"is-plain-obj@npm:^1.0.0, is-plain-obj@npm:^1.1.0":
   version: 1.1.0
   resolution: "is-plain-obj@npm:1.1.0"
   checksum: 0ee04807797aad50859652a7467481816cbb57e5cc97d813a7dcd8915da8195dc68c436010bf39d195226cde6a2d352f4b815f16f26b7bf486a5754290629931
@@ -10749,7 +10992,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"js-base64@npm:^2.1.8":
+"js-base64@npm:^2.1.8, js-base64@npm:^2.4.9":
   version: 2.6.4
   resolution: "js-base64@npm:2.6.4"
   checksum: 5f4084078d6c46f8529741d110df84b14fac3276b903760c21fa8cc8521370d607325dfe1c1a9fbbeaae1ff8e602665aaeef1362427d8fef704f9e3659472ce8
@@ -10917,10 +11160,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"json-schema@npm:0.2.3":
-  version: 0.2.3
-  resolution: "json-schema@npm:0.2.3"
-  checksum: bbc2070988fb5f2a2266a31b956f1b5660e03ea7eaa95b33402901274f625feb586ae0c485e1df854fde40a7f0dc679f3b3ca8e5b8d31f8ea07a0d834de785c7
+"json-schema@npm:0.4.0":
+  version: 0.4.0
+  resolution: "json-schema@npm:0.4.0"
+  checksum: 66389434c3469e698da0df2e7ac5a3281bcff75e797a5c127db7c5b56270e01ae13d9afa3c03344f76e32e81678337a8c912bdbb75101c62e487dc3778461d72
   languageName: node
   linkType: hard
 
@@ -10964,24 +11207,22 @@ __metadata:
   linkType: hard
 
 "json5@npm:^1.0.1":
-  version: 1.0.1
-  resolution: "json5@npm:1.0.1"
+  version: 1.0.2
+  resolution: "json5@npm:1.0.2"
   dependencies:
     minimist: ^1.2.0
   bin:
     json5: lib/cli.js
-  checksum: e76ea23dbb8fc1348c143da628134a98adf4c5a4e8ea2adaa74a80c455fc2cdf0e2e13e6398ef819bfe92306b610ebb2002668ed9fc1af386d593691ef346fc3
+  checksum: 866458a8c58a95a49bef3adba929c625e82532bcff1fe93f01d29cb02cac7c3fe1f4b79951b7792c2da9de0b32871a8401a6e3c5b36778ad852bf5b8a61165d7
   languageName: node
   linkType: hard
 
 "json5@npm:^2.1.2":
-  version: 2.2.0
-  resolution: "json5@npm:2.2.0"
-  dependencies:
-    minimist: ^1.2.5
+  version: 2.2.3
+  resolution: "json5@npm:2.2.3"
   bin:
     json5: lib/cli.js
-  checksum: e88fc5274bb58fc99547baa777886b069d2dd96d9cfc4490b305fd16d711dabd5979e35a4f90873cefbeb552e216b041a304fe56702bedba76e19bc7845f208d
+  checksum: 2a7436a93393830bce797d4626275152e37e877b265e94ca69c99e3d20c2b9dab021279146a39cdb700e71b2dd32a4cebd1514cd57cee102b1af906ce5040349
   languageName: node
   linkType: hard
 
@@ -11018,14 +11259,14 @@ __metadata:
   linkType: hard
 
 "jsprim@npm:^1.2.2":
-  version: 1.4.1
-  resolution: "jsprim@npm:1.4.1"
+  version: 1.4.2
+  resolution: "jsprim@npm:1.4.2"
   dependencies:
     assert-plus: 1.0.0
     extsprintf: 1.3.0
-    json-schema: 0.2.3
+    json-schema: 0.4.0
     verror: 1.10.0
-  checksum: 6bcb20ec265ae18bb48e540a6da2c65f9c844f7522712d6dfcb01039527a49414816f4869000493363f1e1ea96cbad00e46188d5ecc78257a19f152467587373
+  checksum: 2ad1b9fdcccae8b3d580fa6ced25de930eaa1ad154db21bbf8478a4d30bbbec7925b5f5ff29b933fba9412b16a17bd484a8da4fdb3663b5e27af95dd693bab2a
   languageName: node
   linkType: hard
 
@@ -11117,16 +11358,15 @@ __metadata:
   languageName: node
   linkType: hard
 
-"jszip@npm:3.1.5":
-  version: 3.1.5
-  resolution: "jszip@npm:3.1.5"
+"jszip@npm:^3.10.1":
+  version: 3.10.1
+  resolution: "jszip@npm:3.10.1"
   dependencies:
-    core-js: ~2.3.0
-    es6-promise: ~3.0.2
-    lie: ~3.1.0
+    lie: ~3.3.0
     pako: ~1.0.2
-    readable-stream: ~2.0.6
-  checksum: 2d0464089d7a4604c7b7586d089b7aa39fbcfe7cc058f7c066b3c92b43f3b94f69362d1b6dd8252049f5729e1fc452a788703382cbce6d77f607d3ce1227b231
+    readable-stream: ~2.3.6
+    setimmediate: ^1.0.5
+  checksum: abc77bfbe33e691d4d1ac9c74c8851b5761fba6a6986630864f98d876f3fcc2d36817dfc183779f32c00157b5d53a016796677298272a714ae096dfe6b1c8b60
   languageName: node
   linkType: hard
 
@@ -11178,7 +11418,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"kind-of@npm:^6.0.0, kind-of@npm:^6.0.2":
+"kind-of@npm:^6.0.0, kind-of@npm:^6.0.2, kind-of@npm:^6.0.3":
   version: 6.0.3
   resolution: "kind-of@npm:6.0.3"
   checksum: 3ab01e7b1d440b22fe4c31f23d8d38b4d9b91d9f291df683476576493d5dfd2e03848a8b05813dd0c3f0e835bc63f433007ddeceb71f05cb25c45ae1b19c6d3b
@@ -11265,12 +11505,12 @@ __metadata:
   languageName: node
   linkType: hard
 
-"lie@npm:~3.1.0":
-  version: 3.1.1
-  resolution: "lie@npm:3.1.1"
+"lie@npm:~3.3.0":
+  version: 3.3.0
+  resolution: "lie@npm:3.3.0"
   dependencies:
     immediate: ~3.0.5
-  checksum: 6da9f2121d2dbd15f1eca44c0c7e211e66a99c7b326ec8312645f3648935bc3a658cf0e9fa7b5f10144d9e2641500b4f55bd32754607c3de945b5f443e50ddd1
+  checksum: 33102302cf19766f97919a6a98d481e01393288b17a6aa1f030a3542031df42736edde8dab29ffdbf90bebeffc48c761eb1d064dc77592ca3ba3556f9fe6d2a8
   languageName: node
   linkType: hard
 
@@ -11401,24 +11641,24 @@ __metadata:
   linkType: hard
 
 "loader-utils@npm:^1.1.0, loader-utils@npm:^1.2.3, loader-utils@npm:^1.4.0":
-  version: 1.4.0
-  resolution: "loader-utils@npm:1.4.0"
+  version: 1.4.2
+  resolution: "loader-utils@npm:1.4.2"
   dependencies:
     big.js: ^5.2.2
     emojis-list: ^3.0.0
     json5: ^1.0.1
-  checksum: d150b15e7a42ac47d935c8b484b79e44ff6ab4c75df7cc4cb9093350cf014ec0b17bdb60c5d6f91a37b8b218bd63b973e263c65944f58ca2573e402b9a27e717
+  checksum: eb6fb622efc0ffd1abdf68a2022f9eac62bef8ec599cf8adb75e94d1d338381780be6278534170e99edc03380a6d29bc7eb1563c89ce17c5fed3a0b17f1ad804
   languageName: node
   linkType: hard
 
 "loader-utils@npm:^2.0.0":
-  version: 2.0.0
-  resolution: "loader-utils@npm:2.0.0"
+  version: 2.0.4
+  resolution: "loader-utils@npm:2.0.4"
   dependencies:
     big.js: ^5.2.2
     emojis-list: ^3.0.0
     json5: ^2.1.2
-  checksum: 6856423131b50b6f5f259da36f498cfd7fc3c3f8bb17777cf87fdd9159e797d4ba4288d9a96415fd8da62c2906960e88f74711dee72d03a9003bddcd0d364a51
+  checksum: a5281f5fff1eaa310ad5e1164095689443630f3411e927f95031ab4fb83b4a98f388185bb1fe949e8ab8d4247004336a625e9255c22122b815bb9a4c5d8fc3b7
   languageName: node
   linkType: hard
 
@@ -11451,14 +11691,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"lodash-es@npm:4.17.14":
-  version: 4.17.14
-  resolution: "lodash-es@npm:4.17.14"
-  checksum: 56d39dc8e76ac366eae79d4e8d7c19bd2f8981b640a46942bf2d88fa871b2e083e48fe2b895c84ed139e13c0b466cac22ea27d7394be04f2ba62c518392c39be
-  languageName: node
-  linkType: hard
-
-"lodash-es@npm:^4.17.10, lodash-es@npm:^4.17.5, lodash-es@npm:^4.2.1":
+"lodash-es@npm:^4.17.10, lodash-es@npm:^4.17.21, lodash-es@npm:^4.17.5, lodash-es@npm:^4.2.1":
   version: 4.17.21
   resolution: "lodash-es@npm:4.17.21"
   checksum: 05cbffad6e2adbb331a4e16fbd826e7faee403a1a04873b82b42c0f22090f280839f85b95393f487c1303c8a3d2a010048bf06151a6cbe03eee4d388fb0a12d2
@@ -11651,16 +11884,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"lru-cache@npm:^4.0.1":
-  version: 4.1.5
-  resolution: "lru-cache@npm:4.1.5"
-  dependencies:
-    pseudomap: ^1.0.2
-    yallist: ^2.1.2
-  checksum: 4bb4b58a36cd7dc4dcec74cbe6a8f766a38b7426f1ff59d4cf7d82a2aa9b9565cd1cb98f6ff60ce5cd174524868d7bc9b7b1c294371851356066ca9ac4cf135a
-  languageName: node
-  linkType: hard
-
 "lru-cache@npm:^5.1.1":
   version: 5.1.1
   resolution: "lru-cache@npm:5.1.1"
@@ -11686,6 +11909,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"lru-cache@npm:^7.7.1":
+  version: 7.18.3
+  resolution: "lru-cache@npm:7.18.3"
+  checksum: e550d772384709deea3f141af34b6d4fa392e2e418c1498c078de0ee63670f1f46f5eee746e8ef7e69e1c895af0d4224e62ee33e66a543a14763b0f2e74c1356
+  languageName: node
+  linkType: hard
+
 "make-dir@npm:^2.0.0, make-dir@npm:^2.1.0":
   version: 2.1.0
   resolution: "make-dir@npm:2.1.0"
@@ -11729,6 +11959,54 @@ __metadata:
   languageName: node
   linkType: hard
 
+"make-fetch-happen@npm:^10.0.4":
+  version: 10.2.1
+  resolution: "make-fetch-happen@npm:10.2.1"
+  dependencies:
+    agentkeepalive: ^4.2.1
+    cacache: ^16.1.0
+    http-cache-semantics: ^4.1.0
+    http-proxy-agent: ^5.0.0
+    https-proxy-agent: ^5.0.0
+    is-lambda: ^1.0.1
+    lru-cache: ^7.7.1
+    minipass: ^3.1.6
+    minipass-collect: ^1.0.2
+    minipass-fetch: ^2.0.3
+    minipass-flush: ^1.0.5
+    minipass-pipeline: ^1.2.4
+    negotiator: ^0.6.3
+    promise-retry: ^2.0.1
+    socks-proxy-agent: ^7.0.0
+    ssri: ^9.0.0
+  checksum: 2332eb9a8ec96f1ffeeea56ccefabcb4193693597b132cd110734d50f2928842e22b84cfa1508e921b8385cdfd06dda9ad68645fed62b50fff629a580f5fb72c
+  languageName: node
+  linkType: hard
+
+"make-fetch-happen@npm:^9.1.0":
+  version: 9.1.0
+  resolution: "make-fetch-happen@npm:9.1.0"
+  dependencies:
+    agentkeepalive: ^4.1.3
+    cacache: ^15.2.0
+    http-cache-semantics: ^4.1.0
+    http-proxy-agent: ^4.0.1
+    https-proxy-agent: ^5.0.0
+    is-lambda: ^1.0.1
+    lru-cache: ^6.0.0
+    minipass: ^3.1.3
+    minipass-collect: ^1.0.2
+    minipass-fetch: ^1.3.2
+    minipass-flush: ^1.0.5
+    minipass-pipeline: ^1.2.4
+    negotiator: ^0.6.2
+    promise-retry: ^2.0.1
+    socks-proxy-agent: ^6.0.0
+    ssri: ^8.0.0
+  checksum: 0eb371c85fdd0b1584fcfdf3dc3c62395761b3c14658be02620c310305a9a7ecf1617a5e6fb30c1d081c5c8aaf177fa133ee225024313afabb7aa6a10f1e3d04
+  languageName: node
+  linkType: hard
+
 "makeerror@npm:1.0.x":
   version: 1.0.11
   resolution: "makeerror@npm:1.0.11"
@@ -11768,6 +12046,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"map-obj@npm:^4.0.0":
+  version: 4.3.0
+  resolution: "map-obj@npm:4.3.0"
+  checksum: fbc554934d1a27a1910e842bc87b177b1a556609dd803747c85ece420692380827c6ae94a95cce4407c054fa0964be3bf8226f7f2cb2e9eeee432c7c1985684e
+  languageName: node
+  linkType: hard
+
 "map-visit@npm:^1.0.0":
   version: 1.0.0
   resolution: "map-visit@npm:1.0.0"
@@ -11891,6 +12176,26 @@ __metadata:
   languageName: node
   linkType: hard
 
+"meow@npm:^9.0.0":
+  version: 9.0.0
+  resolution: "meow@npm:9.0.0"
+  dependencies:
+    "@types/minimist": ^1.2.0
+    camelcase-keys: ^6.2.2
+    decamelize: ^1.2.0
+    decamelize-keys: ^1.1.0
+    hard-rejection: ^2.1.0
+    minimist-options: 4.1.0
+    normalize-package-data: ^3.0.0
+    read-pkg-up: ^7.0.1
+    redent: ^3.0.0
+    trim-newlines: ^3.0.0
+    type-fest: ^0.18.0
+    yargs-parser: ^20.2.3
+  checksum: 99799c47247f4daeee178e3124f6ef6f84bde2ba3f37652865d5d8f8b8adcf9eedfc551dd043e2455cd8206545fd848e269c0c5ab6b594680a0ad4d3617c9639
+  languageName: node
+  linkType: hard
+
 "merge-deep@npm:^3.0.2":
   version: 3.0.3
   resolution: "merge-deep@npm:3.0.3"
@@ -11916,7 +12221,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"merge2@npm:^1.2.3, merge2@npm:^1.3.0":
+"merge2@npm:^1.2.3, merge2@npm:^1.3.0, merge2@npm:^1.4.1":
   version: 1.4.1
   resolution: "merge2@npm:1.4.1"
   checksum: 7268db63ed5169466540b6fb947aec313200bcf6d40c5ab722c22e242f651994619bcd85601602972d3c85bd2cc45a358a4c61937e9f11a061919a1da569b0c2
@@ -11958,13 +12263,13 @@ __metadata:
   languageName: node
   linkType: hard
 
-"micromatch@npm:^4.0.2":
-  version: 4.0.4
-  resolution: "micromatch@npm:4.0.4"
+"micromatch@npm:^4.0.4":
+  version: 4.0.5
+  resolution: "micromatch@npm:4.0.5"
   dependencies:
-    braces: ^3.0.1
-    picomatch: ^2.2.3
-  checksum: ef3d1c88e79e0a68b0e94a03137676f3324ac18a908c245a9e5936f838079fcc108ac7170a5fadc265a9c2596963462e402841406bda1a4bb7b68805601d631c
+    braces: ^3.0.2
+    picomatch: ^2.3.1
+  checksum: 02a17b671c06e8fefeeb6ef996119c1e597c942e632a21ef589154f23898c9c6a9858526246abb14f8bca6e77734aa9dcf65476fca47cedfb80d9577d52843fc
   languageName: node
   linkType: hard
 
@@ -12037,6 +12342,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"min-indent@npm:^1.0.0":
+  version: 1.0.1
+  resolution: "min-indent@npm:1.0.1"
+  checksum: bfc6dd03c5eaf623a4963ebd94d087f6f4bbbfd8c41329a7f09706b0cb66969c4ddd336abeb587bc44bc6f08e13bf90f0b374f9d71f9f01e04adc2cd6f083ef1
+  languageName: node
+  linkType: hard
+
 "mini-css-extract-plugin@npm:0.9.0":
   version: 0.9.0
   resolution: "mini-css-extract-plugin@npm:0.9.0"
@@ -12065,7 +12377,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"minimatch@npm:3.0.4, minimatch@npm:^3.0.4, minimatch@npm:~3.0.2":
+"minimatch@npm:3.0.4":
   version: 3.0.4
   resolution: "minimatch@npm:3.0.4"
   dependencies:
@@ -12074,10 +12386,48 @@ __metadata:
   languageName: node
   linkType: hard
 
+"minimatch@npm:^3.0.4":
+  version: 3.1.2
+  resolution: "minimatch@npm:3.1.2"
+  dependencies:
+    brace-expansion: ^1.1.7
+  checksum: c154e566406683e7bcb746e000b84d74465b3a832c45d59912b9b55cd50dee66e5c4b1e5566dba26154040e51672f9aa450a9aef0c97cfc7336b78b7afb9540a
+  languageName: node
+  linkType: hard
+
+"minimatch@npm:^5.0.1":
+  version: 5.1.6
+  resolution: "minimatch@npm:5.1.6"
+  dependencies:
+    brace-expansion: ^2.0.1
+  checksum: 7564208ef81d7065a370f788d337cd80a689e981042cb9a1d0e6580b6c6a8c9279eba80010516e258835a988363f99f54a6f711a315089b8b42694f5da9d0d77
+  languageName: node
+  linkType: hard
+
+"minimatch@npm:~3.0.2":
+  version: 3.0.8
+  resolution: "minimatch@npm:3.0.8"
+  dependencies:
+    brace-expansion: ^1.1.7
+  checksum: 850cca179cad715133132693e6963b0db64ab0988c4d211415b087fc23a3e46321e2c5376a01bf5623d8782aba8bdf43c571e2e902e51fdce7175c7215c29f8b
+  languageName: node
+  linkType: hard
+
+"minimist-options@npm:4.1.0":
+  version: 4.1.0
+  resolution: "minimist-options@npm:4.1.0"
+  dependencies:
+    arrify: ^1.0.1
+    is-plain-obj: ^1.1.0
+    kind-of: ^6.0.3
+  checksum: 8c040b3068811e79de1140ca2b708d3e203c8003eb9a414c1ab3cd467fc5f17c9ca02a5aef23bedc51a7f8bfbe77f87e9a7e31ec81fba304cda675b019496f4e
+  languageName: node
+  linkType: hard
+
 "minimist@npm:^1.1.1, minimist@npm:^1.1.3, minimist@npm:^1.2.0, minimist@npm:^1.2.5":
-  version: 1.2.5
-  resolution: "minimist@npm:1.2.5"
-  checksum: 86706ce5b36c16bfc35c5fe3dbb01d5acdc9a22f2b6cc810b6680656a1d2c0e44a0159c9a3ba51fb072bb5c203e49e10b51dcd0eec39c481f4c42086719bae52
+  version: 1.2.8
+  resolution: "minimist@npm:1.2.8"
+  checksum: 75a6d645fb122dad29c06a7597bddea977258957ed88d7a6df59b5cd3fe4a527e253e9bbf2e783e4b73657f9098b96a5fe96ab8a113655d4109108577ecf85b0
   languageName: node
   linkType: hard
 
@@ -12090,6 +12440,21 @@ __metadata:
   languageName: node
   linkType: hard
 
+"minipass-fetch@npm:^1.3.2":
+  version: 1.4.1
+  resolution: "minipass-fetch@npm:1.4.1"
+  dependencies:
+    encoding: ^0.1.12
+    minipass: ^3.1.0
+    minipass-sized: ^1.0.3
+    minizlib: ^2.0.0
+  dependenciesMeta:
+    encoding:
+      optional: true
+  checksum: ec93697bdb62129c4e6c0104138e681e30efef8c15d9429dd172f776f83898471bc76521b539ff913248cc2aa6d2b37b652c993504a51cc53282563640f29216
+  languageName: node
+  linkType: hard
+
 "minipass-fetch@npm:^2.0.2":
   version: 2.0.3
   resolution: "minipass-fetch@npm:2.0.3"
@@ -12105,6 +12470,21 @@ __metadata:
   languageName: node
   linkType: hard
 
+"minipass-fetch@npm:^2.0.3":
+  version: 2.1.2
+  resolution: "minipass-fetch@npm:2.1.2"
+  dependencies:
+    encoding: ^0.1.13
+    minipass: ^3.1.6
+    minipass-sized: ^1.0.3
+    minizlib: ^2.1.2
+  dependenciesMeta:
+    encoding:
+      optional: true
+  checksum: 3f216be79164e915fc91210cea1850e488793c740534985da017a4cbc7a5ff50506956d0f73bb0cb60e4fe91be08b6b61ef35101706d3ef5da2c8709b5f08f91
+  languageName: node
+  linkType: hard
+
 "minipass-flush@npm:^1.0.5":
   version: 1.0.5
   resolution: "minipass-flush@npm:1.0.5"
@@ -12141,6 +12521,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"minipass@npm:^3.1.0, minipass@npm:^3.1.3":
+  version: 3.3.6
+  resolution: "minipass@npm:3.3.6"
+  dependencies:
+    yallist: ^4.0.0
+  checksum: a30d083c8054cee83cdcdc97f97e4641a3f58ae743970457b1489ce38ee1167b3aaf7d815cd39ec7a99b9c40397fd4f686e83750e73e652b21cb516f6d845e48
+  languageName: node
+  linkType: hard
+
 "minipass@npm:^3.1.6":
   version: 3.1.6
   resolution: "minipass@npm:3.1.6"
@@ -12150,7 +12539,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"minizlib@npm:^2.1.1, minizlib@npm:^2.1.2":
+"minipass@npm:^5.0.0":
+  version: 5.0.0
+  resolution: "minipass@npm:5.0.0"
+  checksum: 425dab288738853fded43da3314a0b5c035844d6f3097a8e3b5b29b328da8f3c1af6fc70618b32c29ff906284cf6406b6841376f21caaadd0793c1d5a6a620ea
+  languageName: node
+  linkType: hard
+
+"minizlib@npm:^2.0.0, minizlib@npm:^2.1.1, minizlib@npm:^2.1.2":
   version: 2.1.2
   resolution: "minizlib@npm:2.1.2"
   dependencies:
@@ -12198,7 +12594,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"mkdirp@npm:>=0.5 0, mkdirp@npm:^0.5.0, mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.3, mkdirp@npm:^0.5.4, mkdirp@npm:^0.5.5, mkdirp@npm:~0.5.1":
+"mkdirp@npm:>=0.5 0, mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.3, mkdirp@npm:^0.5.4, mkdirp@npm:^0.5.5, mkdirp@npm:~0.5.1":
   version: 0.5.5
   resolution: "mkdirp@npm:0.5.5"
   dependencies:
@@ -12218,10 +12614,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"moment@npm:2.29.1, moment@npm:^2.27.0":
-  version: 2.29.1
-  resolution: "moment@npm:2.29.1"
-  checksum: 1e14d5f422a2687996be11dd2d50c8de3bd577c4a4ca79ba5d02c397242a933e5b941655de6c8cb90ac18f01cc4127e55b4a12ae3c527a6c0a274e455979345e
+"moment@npm:^2.27.0, moment@npm:^2.29.4":
+  version: 2.29.4
+  resolution: "moment@npm:2.29.4"
+  checksum: 0ec3f9c2bcba38dc2451b1daed5daded747f17610b92427bebe1d08d48d8b7bdd8d9197500b072d14e326dd0ccf3e326b9e3d07c5895d3d49e39b6803b76e80e
   languageName: node
   linkType: hard
 
@@ -12309,6 +12705,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"nan@npm:^2.17.0":
+  version: 2.18.0
+  resolution: "nan@npm:2.18.0"
+  dependencies:
+    node-gyp: latest
+  checksum: 4fe42f58456504eab3105c04a5cffb72066b5f22bd45decf33523cb17e7d6abc33cca2a19829407b9000539c5cb25f410312d4dc5b30220167a3594896ea6a0a
+  languageName: node
+  linkType: hard
+
 "nanomatch@npm:^1.2.9":
   version: 1.2.13
   resolution: "nanomatch@npm:1.2.13"
@@ -12359,7 +12764,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"negotiator@npm:^0.6.3":
+"negotiator@npm:^0.6.2, negotiator@npm:^0.6.3":
   version: 0.6.3
   resolution: "negotiator@npm:0.6.3"
   checksum: b8ffeb1e262eff7968fc90a2b6767b04cfd9842582a9d0ece0af7049537266e7b2506dfb1d107a32f06dd849ab2aea834d5830f7f4d0e5cb7d36e1ae55d021d9
@@ -12410,13 +12815,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"node-fetch@npm:2.6.1":
-  version: 2.6.1
-  resolution: "node-fetch@npm:2.6.1"
-  checksum: 91075bedd57879117e310fbcc36983ad5d699e522edb1ebcdc4ee5294c982843982652925c3532729fdc86b2d64a8a827797a745f332040d91823c8752ee4d7c
-  languageName: node
-  linkType: hard
-
 "node-fetch@npm:^1.0.1":
   version: 1.7.3
   resolution: "node-fetch@npm:1.7.3"
@@ -12427,6 +12825,20 @@ __metadata:
   languageName: node
   linkType: hard
 
+"node-fetch@npm:^2.6.12":
+  version: 2.7.0
+  resolution: "node-fetch@npm:2.7.0"
+  dependencies:
+    whatwg-url: ^5.0.0
+  peerDependencies:
+    encoding: ^0.1.0
+  peerDependenciesMeta:
+    encoding:
+      optional: true
+  checksum: d76d2f5edb451a3f05b15115ec89fc6be39de37c6089f1b6368df03b91e1633fd379a7e01b7ab05089a25034b2023d959b47e59759cb38d88341b2459e89d6e5
+  languageName: node
+  linkType: hard
+
 "node-forge@npm:^0.10.0":
   version: 0.10.0
   resolution: "node-forge@npm:0.10.0"
@@ -12434,25 +12846,23 @@ __metadata:
   languageName: node
   linkType: hard
 
-"node-gyp@npm:^3.8.0":
-  version: 3.8.0
-  resolution: "node-gyp@npm:3.8.0"
+"node-gyp@npm:^8.4.1":
+  version: 8.4.1
+  resolution: "node-gyp@npm:8.4.1"
   dependencies:
-    fstream: ^1.0.0
-    glob: ^7.0.3
-    graceful-fs: ^4.1.2
-    mkdirp: ^0.5.0
-    nopt: 2 || 3
-    npmlog: 0 || 1 || 2 || 3 || 4
-    osenv: 0
-    request: ^2.87.0
-    rimraf: 2
-    semver: ~5.3.0
-    tar: ^2.0.0
-    which: 1
+    env-paths: ^2.2.0
+    glob: ^7.1.4
+    graceful-fs: ^4.2.6
+    make-fetch-happen: ^9.1.0
+    nopt: ^5.0.0
+    npmlog: ^6.0.0
+    rimraf: ^3.0.2
+    semver: ^7.3.5
+    tar: ^6.1.2
+    which: ^2.0.2
   bin:
-    node-gyp: ./bin/node-gyp.js
-  checksum: e99d740db6f5462cfd2f03fdfa89bae7e509e37f158d78a2fec0c858984cceb801723510656110d8f1d0ecf69cc2ceba8b477d22aac3e69ce8094db19dff6b2b
+    node-gyp: bin/node-gyp.js
+  checksum: 341710b5da39d3660e6a886b37e210d33f8282047405c2e62c277bcc744c7552c5b8b972ebc3a7d5c2813794e60cc48c3ebd142c46d6e0321db4db6c92dd0355
   languageName: node
   linkType: hard
 
@@ -12534,66 +12944,84 @@ __metadata:
   languageName: node
   linkType: hard
 
-"node-releases@npm:^1.1.52, node-releases@npm:^1.1.71":
+"node-releases@npm:^1.1.52":
   version: 1.1.73
   resolution: "node-releases@npm:1.1.73"
   checksum: 44a6caec3330538a669c156fa84833725ae92b317585b106e08ab292c14da09f30cb913c10f1a7402180a51b10074832d4e045b6c3512d74c37d86b41a69e63b
   languageName: node
   linkType: hard
 
-"node-sass-chokidar@npm:1.5.0":
-  version: 1.5.0
-  resolution: "node-sass-chokidar@npm:1.5.0"
+"node-releases@npm:^2.0.13":
+  version: 2.0.13
+  resolution: "node-releases@npm:2.0.13"
+  checksum: 17ec8f315dba62710cae71a8dad3cd0288ba943d2ece43504b3b1aa8625bf138637798ab470b1d9035b0545996f63000a8a926e0f6d35d0996424f8b6d36dda3
+  languageName: node
+  linkType: hard
+
+"node-sass-chokidar@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "node-sass-chokidar@npm:2.0.0"
   dependencies:
     async-foreach: ^0.1.3
     chokidar: ^3.4.0
     get-stdin: ^4.0.1
     glob: ^7.0.3
     meow: ^3.7.0
-    node-sass: ^4.14.1
+    node-sass: ^7.0.1
     sass-graph: ^2.2.4
     stdout-stream: ^1.4.0
   bin:
     node-sass-chokidar: bin/node-sass-chokidar
-  checksum: fb3197b1dcc06b7b3c8e7d2e63ab9397745466f2e78871f8ba112f3740f7092f37f6668bc25a0d7bea82fe8a78b4d8dd009151eb0f041dc62029e76a38004e8d
+  checksum: 5aeffc93cddf5cc32d0e86de4999e56e3cdccb1d86b5ed211e2d661f4e579bac19c078ca791662e2aaff9752ba2e18ce87324c07de5b3222064a4c9703856d9c
   languageName: node
   linkType: hard
 
-"node-sass@npm:^4.14.1, node-sass@npm:^4.9.4":
-  version: 4.14.1
-  resolution: "node-sass@npm:4.14.1"
+"node-sass@npm:^7.0.1":
+  version: 7.0.3
+  resolution: "node-sass@npm:7.0.3"
   dependencies:
     async-foreach: ^0.1.3
-    chalk: ^1.1.1
-    cross-spawn: ^3.0.0
+    chalk: ^4.1.2
+    cross-spawn: ^7.0.3
     gaze: ^1.0.0
     get-stdin: ^4.0.1
     glob: ^7.0.3
-    in-publish: ^2.0.0
     lodash: ^4.17.15
-    meow: ^3.7.0
-    mkdirp: ^0.5.1
+    meow: ^9.0.0
     nan: ^2.13.2
-    node-gyp: ^3.8.0
-    npmlog: ^4.0.0
+    node-gyp: ^8.4.1
+    npmlog: ^5.0.0
     request: ^2.88.0
-    sass-graph: 2.2.5
+    sass-graph: ^4.0.1
     stdout-stream: ^1.4.0
     true-case-path: ^1.0.2
   bin:
     node-sass: bin/node-sass
-  checksum: 6894709e7d8c4482fd0d53ce8473fd7c3ddf38ef36a109bbda96aca750e7c28777e89fcf277c9e032ca69328062f10a12be61e01a385ed0d221fbbdfd0ac7448
+  checksum: 7d577d0fb68948959f367341e6cfc2858aa37abc5fadbd9e6b477ed0d192bebf7f8516d0b53c27be30ab05d5cd62d8a9bab08cc4442ef901b02cb51d864b4419
   languageName: node
   linkType: hard
 
-"nopt@npm:2 || 3":
-  version: 3.0.6
-  resolution: "nopt@npm:3.0.6"
+"node-sass@npm:^9.0.0":
+  version: 9.0.0
+  resolution: "node-sass@npm:9.0.0"
   dependencies:
-    abbrev: 1
+    async-foreach: ^0.1.3
+    chalk: ^4.1.2
+    cross-spawn: ^7.0.3
+    gaze: ^1.0.0
+    get-stdin: ^4.0.1
+    glob: ^7.0.3
+    lodash: ^4.17.15
+    make-fetch-happen: ^10.0.4
+    meow: ^9.0.0
+    nan: ^2.17.0
+    node-gyp: ^8.4.1
+    sass-graph: ^4.0.1
+    stdout-stream: ^1.4.0
+    true-case-path: ^2.2.1
   bin:
-    nopt: ./bin/nopt.js
-  checksum: 7f8579029a0d7cb3341c6b1610b31e363f708b7aaaaf3580e3ec5ae8528d1f3a79d350d8bfa331776e6c6703a5a148b72edd9b9b4c1dd55874d8e70e963d1e20
+    node-sass: bin/node-sass
+  checksum: b15fa76b1564c37d65cde7556731e3c09b49c74a6919cd5cff6f71ddbe454bd1ad9e458f5f02f0f81f43919b8755b5f56cf657fa4e32a0a2644a48fbc07147bb
   languageName: node
   linkType: hard
 
@@ -12608,7 +13036,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"normalize-package-data@npm:^2.3.2, normalize-package-data@npm:^2.3.4":
+"normalize-package-data@npm:^2.3.2, normalize-package-data@npm:^2.3.4, normalize-package-data@npm:^2.5.0":
   version: 2.5.0
   resolution: "normalize-package-data@npm:2.5.0"
   dependencies:
@@ -12620,6 +13048,18 @@ __metadata:
   languageName: node
   linkType: hard
 
+"normalize-package-data@npm:^3.0.0":
+  version: 3.0.3
+  resolution: "normalize-package-data@npm:3.0.3"
+  dependencies:
+    hosted-git-info: ^4.0.1
+    is-core-module: ^2.5.0
+    semver: ^7.3.4
+    validate-npm-package-license: ^3.0.1
+  checksum: bbcee00339e7c26fdbc760f9b66d429258e2ceca41a5df41f5df06cc7652de8d82e8679ff188ca095cad8eff2b6118d7d866af2b68400f74602fbcbce39c160a
+  languageName: node
+  linkType: hard
+
 "normalize-path@npm:^2.1.1":
   version: 2.1.1
   resolution: "normalize-path@npm:2.1.1"
@@ -12687,15 +13127,15 @@ __metadata:
   languageName: node
   linkType: hard
 
-"npmlog@npm:0 || 1 || 2 || 3 || 4, npmlog@npm:^4.0.0":
-  version: 4.1.2
-  resolution: "npmlog@npm:4.1.2"
+"npmlog@npm:^5.0.0":
+  version: 5.0.1
+  resolution: "npmlog@npm:5.0.1"
   dependencies:
-    are-we-there-yet: ~1.1.2
-    console-control-strings: ~1.1.0
-    gauge: ~2.7.3
-    set-blocking: ~2.0.0
-  checksum: edbda9f95ec20957a892de1839afc6fb735054c3accf6fbefe767bac9a639fd5cea2baeac6bd2bcd50a85cb54924d57d9886c81c7fbc2332c2ddd19227504192
+    are-we-there-yet: ^2.0.0
+    console-control-strings: ^1.1.0
+    gauge: ^3.0.0
+    set-blocking: ^2.0.0
+  checksum: 516b2663028761f062d13e8beb3f00069c5664925871a9b57989642ebe09f23ab02145bf3ab88da7866c4e112cafff72401f61a672c7c8a20edc585a7016ef5f
   languageName: node
   linkType: hard
 
@@ -12720,12 +13160,12 @@ __metadata:
   languageName: node
   linkType: hard
 
-"nth-check@npm:^2.0.0":
-  version: 2.0.0
-  resolution: "nth-check@npm:2.0.0"
+"nth-check@npm:^2.0.1":
+  version: 2.1.1
+  resolution: "nth-check@npm:2.1.1"
   dependencies:
     boolbase: ^1.0.0
-  checksum: a22eb19616719d46a5b517f76c32e67e4a2b6a229d67ba2f3efb296e24d79687d52b904c2298cd16510215d5d2a419f8ba671f5957a3b4b73905f62ba7aafa3b
+  checksum: 5afc3dafcd1573b08877ca8e6148c52abd565f1d06b1eb08caf982e3fa289a82f2cae697ffb55b5021e146d60443f1590a5d6b944844e944714a5b549675bcd3
   languageName: node
   linkType: hard
 
@@ -12983,15 +13423,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"original@npm:^1.0.0":
-  version: 1.0.2
-  resolution: "original@npm:1.0.2"
-  dependencies:
-    url-parse: ^1.4.3
-  checksum: 8dca9311dab50c8953366127cb86b7c07bf547d6aa6dc6873a75964b7563825351440557e5724d9c652c5e99043b8295624f106af077f84bccf19592e421beb9
-  languageName: node
-  linkType: hard
-
 "os-browserify@npm:^0.3.0":
   version: 0.3.0
   resolution: "os-browserify@npm:0.3.0"
@@ -13015,23 +13446,13 @@ __metadata:
   languageName: node
   linkType: hard
 
-"os-tmpdir@npm:^1.0.0, os-tmpdir@npm:^1.0.1, os-tmpdir@npm:~1.0.2":
+"os-tmpdir@npm:^1.0.1, os-tmpdir@npm:~1.0.2":
   version: 1.0.2
   resolution: "os-tmpdir@npm:1.0.2"
   checksum: 5666560f7b9f10182548bf7013883265be33620b1c1b4a4d405c25be2636f970c5488ff3e6c48de75b55d02bde037249fe5dbfbb4c0fb7714953d56aed062e6d
   languageName: node
   linkType: hard
 
-"osenv@npm:0":
-  version: 0.1.5
-  resolution: "osenv@npm:0.1.5"
-  dependencies:
-    os-homedir: ^1.0.0
-    os-tmpdir: ^1.0.0
-  checksum: 779d261920f2a13e5e18cf02446484f12747d3f2ff82280912f52b213162d43d312647a40c332373cbccd5e3fb8126915d3bfea8dde4827f70f82da76e52d359
-  languageName: node
-  linkType: hard
-
 "ospath@npm:^1.2.2":
   version: 1.2.2
   resolution: "ospath@npm:1.2.2"
@@ -13462,13 +13883,34 @@ __metadata:
   languageName: node
   linkType: hard
 
-"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.3":
+"picocolors@npm:^0.2.1":
+  version: 0.2.1
+  resolution: "picocolors@npm:0.2.1"
+  checksum: 3b0f441f0062def0c0f39e87b898ae7461c3a16ffc9f974f320b44c799418cabff17780ee647fda42b856a1dc45897e2c62047e1b546d94d6d5c6962f45427b2
+  languageName: node
+  linkType: hard
+
+"picocolors@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "picocolors@npm:1.0.0"
+  checksum: a2e8092dd86c8396bdba9f2b5481032848525b3dc295ce9b57896f931e63fc16f79805144321f72976383fc249584672a75cc18d6777c6b757603f372f745981
+  languageName: node
+  linkType: hard
+
+"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1":
   version: 2.3.0
   resolution: "picomatch@npm:2.3.0"
   checksum: 16818720ea7c5872b6af110760dee856c8e4cd79aed1c7a006d076b1cc09eff3ae41ca5019966694c33fbd2e1cc6ea617ab10e4adac6df06556168f13be3fca2
   languageName: node
   linkType: hard
 
+"picomatch@npm:^2.3.1":
+  version: 2.3.1
+  resolution: "picomatch@npm:2.3.1"
+  checksum: 050c865ce81119c4822c45d3c84f1ced46f93a0126febae20737bd05ca20589c564d6e9226977df859ed5e03dc73f02584a2b0faad36e896936238238b0446cf
+  languageName: node
+  linkType: hard
+
 "pify@npm:^2.0.0, pify@npm:^2.2.0":
   version: 2.3.0
   resolution: "pify@npm:2.3.0"
@@ -13698,6 +14140,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"postcss-combine-duplicated-selectors@npm:^10.0.3":
+  version: 10.0.3
+  resolution: "postcss-combine-duplicated-selectors@npm:10.0.3"
+  dependencies:
+    postcss-selector-parser: ^6.0.4
+  peerDependencies:
+    postcss: ^8.1.0
+  checksum: 45c3dff41d0cddb510752ed92fe8c7fc66e5cf88f4988314655419d3ecdf1dc66f484a25ee73f4f292da5da851a0fdba0ec4d59bdedeee935d05b26d31d997ed
+  languageName: node
+  linkType: hard
+
 "postcss-convert-values@npm:^4.0.1":
   version: 4.0.1
   resolution: "postcss-convert-values@npm:4.0.1"
@@ -14342,6 +14795,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"postcss-selector-parser@npm:^6.0.4":
+  version: 6.0.13
+  resolution: "postcss-selector-parser@npm:6.0.13"
+  dependencies:
+    cssesc: ^3.0.0
+    util-deprecate: ^1.0.2
+  checksum: f89163338a1ce3b8ece8e9055cd5a3165e79a15e1c408e18de5ad8f87796b61ec2d48a2902d179ae0c4b5de10fccd3a325a4e660596549b040bc5ad1b465f096
+  languageName: node
+  linkType: hard
+
 "postcss-svgo@npm:^4.0.3":
   version: 4.0.3
   resolution: "postcss-svgo@npm:4.0.3"
@@ -14401,13 +14864,12 @@ __metadata:
   linkType: hard
 
 "postcss@npm:^7, postcss@npm:^7.0.0, postcss@npm:^7.0.1, postcss@npm:^7.0.14, postcss@npm:^7.0.17, postcss@npm:^7.0.2, postcss@npm:^7.0.23, postcss@npm:^7.0.27, postcss@npm:^7.0.32, postcss@npm:^7.0.5, postcss@npm:^7.0.6":
-  version: 7.0.36
-  resolution: "postcss@npm:7.0.36"
+  version: 7.0.39
+  resolution: "postcss@npm:7.0.39"
   dependencies:
-    chalk: ^2.4.2
+    picocolors: ^0.2.1
     source-map: ^0.6.1
-    supports-color: ^6.1.0
-  checksum: 4cfc0989b9ad5d0e8971af80d87f9c5beac5c84cb89ff22ad69852edf73c0a2fa348e7e0a135b5897bf893edad0fe86c428769050431ad9b532f072ff530828d
+  checksum: 4ac793f506c23259189064bdc921260d869a115a82b5e713973c5af8e94fbb5721a5cc3e1e26840500d7e1f1fa42a209747c5b1a151918a9bc11f0d7ed9048e3
   languageName: node
   linkType: hard
 
@@ -14473,13 +14935,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"process-nextick-args@npm:~1.0.6":
-  version: 1.0.7
-  resolution: "process-nextick-args@npm:1.0.7"
-  checksum: 41224fbc803ac6c96907461d4dfc20942efa3ca75f2d521bcf7cf0e89f8dec127fb3fb5d76746b8fb468a232ea02d84824fae08e027aec185fd29049c66d49f8
-  languageName: node
-  linkType: hard
-
 "process-nextick-args@npm:~2.0.0":
   version: 2.0.1
   resolution: "process-nextick-args@npm:2.0.1"
@@ -14596,13 +15051,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"pseudomap@npm:^1.0.2":
-  version: 1.0.2
-  resolution: "pseudomap@npm:1.0.2"
-  checksum: 856c0aae0ff2ad60881168334448e898ad7a0e45fe7386d114b150084254c01e200c957cf378378025df4e052c7890c5bd933939b0e0d2ecfcc1dc2f0b2991f5
-  languageName: node
-  linkType: hard
-
 "psl@npm:^1.1.28":
   version: 1.8.0
   resolution: "psl@npm:1.8.0"
@@ -14691,9 +15139,9 @@ __metadata:
   linkType: hard
 
 "qs@npm:~6.5.2":
-  version: 6.5.2
-  resolution: "qs@npm:6.5.2"
-  checksum: 24af7b9928ba2141233fba2912876ff100403dba1b08b20c3b490da9ea6c636760445ea2211a079e7dfa882a5cf8f738337b3748c8bdd0f93358fa8881d2db8f
+  version: 6.5.3
+  resolution: "qs@npm:6.5.3"
+  checksum: 6f20bf08cabd90c458e50855559539a28d00b2f2e7dddcb66082b16a43188418cb3cb77cbd09268bcef6022935650f0534357b8af9eeb29bf0f27ccb17655692
   languageName: node
   linkType: hard
 
@@ -14746,6 +15194,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"quick-lru@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "quick-lru@npm:4.0.1"
+  checksum: bea46e1abfaa07023e047d3cf1716a06172c4947886c053ede5c50321893711577cb6119360f810cc3ffcd70c4d7db4069c3cee876b358ceff8596e062bd1154
+  languageName: node
+  linkType: hard
+
 "raf@npm:^3.4.1":
   version: 3.4.1
   resolution: "raf@npm:3.4.1"
@@ -14903,17 +15358,17 @@ __metadata:
   languageName: node
   linkType: hard
 
-"react-dom@npm:16.8.6":
-  version: 16.8.6
-  resolution: "react-dom@npm:16.8.6"
+"react-dom@npm:16.14.0":
+  version: 16.14.0
+  resolution: "react-dom@npm:16.14.0"
   dependencies:
     loose-envify: ^1.1.0
     object-assign: ^4.1.1
     prop-types: ^15.6.2
-    scheduler: ^0.13.6
+    scheduler: ^0.19.1
   peerDependencies:
-    react: ^16.0.0
-  checksum: 7f8ebd8523eb4a14a1439efa009d020abc0529da25d0de251a4f3d5b3781061f6b30d72425f5fe944317850997efc6c1d667e99b1fd70172f30a976a00008bf6
+    react: ^16.14.0
+  checksum: 5a5c49da0f106b2655a69f96c622c347febcd10532db391c262b26aec225b235357d9da1834103457683482ab1b229af7a50f6927a6b70e53150275e31785544
   languageName: node
   linkType: hard
 
@@ -15057,9 +15512,9 @@ __metadata:
   languageName: node
   linkType: hard
 
-"react-rte@npm:0.16.3":
-  version: 0.16.3
-  resolution: "react-rte@npm:0.16.3"
+"react-rte@npm:^0.16.5":
+  version: 0.16.5
+  resolution: "react-rte@npm:0.16.5"
   dependencies:
     babel-runtime: ^6.23.0
     class-autobind: ^0.1.4
@@ -15072,9 +15527,9 @@ __metadata:
     draft-js-utils: ">=0.2.0"
     immutable: ^3.8.1
   peerDependencies:
-    react: 0.14.x || 15.x.x || 16.x.x
-    react-dom: 0.14.x || 15.x.x || 16.x.x
-  checksum: 812ed35161bea266cbdf42da0173398834eba0166328a01ae521c86b29b573ed25107985d3a077344ecd30536804376c0d94cb7d534abecdbc1dbf4d7af8bdc4
+    react: 0.14.x || 15.x.x || 16.x.x || 17.x.x
+    react-dom: 0.14.x || 15.x.x || 16.x.x || 17.x.x
+  checksum: 3af94acd7790989c44babc7b1327a0a047a1a7fd03f13d5c1ef2d276e949d7346a8b1b875b8457c2624e5c0cdcb6e3980f967280c52ff2f92d8234debec01c03
   languageName: node
   linkType: hard
 
@@ -15237,15 +15692,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"react@npm:16.8.6":
-  version: 16.8.6
-  resolution: "react@npm:16.8.6"
+"react@npm:16.14.0":
+  version: 16.14.0
+  resolution: "react@npm:16.14.0"
   dependencies:
     loose-envify: ^1.1.0
     object-assign: ^4.1.1
     prop-types: ^15.6.2
-    scheduler: ^0.13.6
-  checksum: 8dfdbec9af6999c2cfb33a9389995c6401daba732e1ee7e0a4920d28fd2e8e6b0fde99dfe4b8e2f81efc4a962c92656e3e79e221323449e55850232163f15ff4
+  checksum: 8484f3ecb13414526f2a7412190575fc134da785c02695eb92bb6028c930bfe1c238d7be2a125088fec663cc7cda0a3623373c46807cf2c281f49c34b79881ac
   languageName: node
   linkType: hard
 
@@ -15279,6 +15733,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"read-pkg-up@npm:^7.0.1":
+  version: 7.0.1
+  resolution: "read-pkg-up@npm:7.0.1"
+  dependencies:
+    find-up: ^4.1.0
+    read-pkg: ^5.2.0
+    type-fest: ^0.8.1
+  checksum: e4e93ce70e5905b490ca8f883eb9e48b5d3cebc6cd4527c25a0d8f3ae2903bd4121c5ab9c5a3e217ada0141098eeb661313c86fa008524b089b8ed0b7f165e44
+  languageName: node
+  linkType: hard
+
 "read-pkg@npm:^1.0.0":
   version: 1.1.0
   resolution: "read-pkg@npm:1.1.0"
@@ -15312,7 +15777,19 @@ __metadata:
   languageName: node
   linkType: hard
 
-"readable-stream@npm:1 || 2, readable-stream@npm:^2.0.0, readable-stream@npm:^2.0.1, readable-stream@npm:^2.0.2, readable-stream@npm:^2.0.6, readable-stream@npm:^2.1.5, readable-stream@npm:^2.2.2, readable-stream@npm:^2.3.3, readable-stream@npm:^2.3.6, readable-stream@npm:~2.3.6":
+"read-pkg@npm:^5.2.0":
+  version: 5.2.0
+  resolution: "read-pkg@npm:5.2.0"
+  dependencies:
+    "@types/normalize-package-data": ^2.4.0
+    normalize-package-data: ^2.5.0
+    parse-json: ^5.0.0
+    type-fest: ^0.6.0
+  checksum: eb696e60528b29aebe10e499ba93f44991908c57d70f2d26f369e46b8b9afc208ef11b4ba64f67630f31df8b6872129e0a8933c8c53b7b4daf0eace536901222
+  languageName: node
+  linkType: hard
+
+"readable-stream@npm:1 || 2, readable-stream@npm:^2.0.0, readable-stream@npm:^2.0.1, readable-stream@npm:^2.0.2, readable-stream@npm:^2.1.5, readable-stream@npm:^2.2.2, readable-stream@npm:^2.3.3, readable-stream@npm:^2.3.6, readable-stream@npm:~2.3.6":
   version: 2.3.7
   resolution: "readable-stream@npm:2.3.7"
   dependencies:
@@ -15338,20 +15815,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"readable-stream@npm:~2.0.6":
-  version: 2.0.6
-  resolution: "readable-stream@npm:2.0.6"
-  dependencies:
-    core-util-is: ~1.0.0
-    inherits: ~2.0.1
-    isarray: ~1.0.0
-    process-nextick-args: ~1.0.6
-    string_decoder: ~0.10.x
-    util-deprecate: ~1.0.1
-  checksum: 5258b248531e58cbd855dab6a67dde3f4939f78a6d7707042ce61a74fe3421a7596405bc9c8970484dc9b2d929136e6cc40985f76759b9264a0a273f6136ed3b
-  languageName: node
-  linkType: hard
-
 "readdirp@npm:^2.2.1":
   version: 2.2.1
   resolution: "readdirp@npm:2.2.1"
@@ -15448,6 +15911,25 @@ __metadata:
   languageName: node
   linkType: hard
 
+"redent@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "redent@npm:3.0.0"
+  dependencies:
+    indent-string: ^4.0.0
+    strip-indent: ^3.0.0
+  checksum: fa1ef20404a2d399235e83cc80bd55a956642e37dd197b4b612ba7327bf87fa32745aeb4a1634b2bab25467164ab4ed9c15be2c307923dd08b0fe7c52431ae6b
+  languageName: node
+  linkType: hard
+
+"redux-devtools-extension@npm:^2.13.9":
+  version: 2.13.9
+  resolution: "redux-devtools-extension@npm:2.13.9"
+  peerDependencies:
+    redux: ^3.1.0 || ^4.0.0
+  checksum: 603d48fd6acf3922ef373b251ab3fdbb990035e90284191047b29d25b06ea18122bc4ef01e0704ccae495acb27ab5e47b560937e98213605dd88299470025db9
+  languageName: node
+  linkType: hard
+
 "redux-devtools-instrument@npm:^1.0.1":
   version: 1.10.0
   resolution: "redux-devtools-instrument@npm:1.10.0"
@@ -16153,31 +16635,31 @@ __metadata:
   languageName: node
   linkType: hard
 
-"sass-graph@npm:2.2.5":
-  version: 2.2.5
-  resolution: "sass-graph@npm:2.2.5"
+"sass-graph@npm:^2.2.4":
+  version: 2.2.6
+  resolution: "sass-graph@npm:2.2.6"
   dependencies:
     glob: ^7.0.0
     lodash: ^4.0.0
     scss-tokenizer: ^0.2.3
-    yargs: ^13.3.2
+    yargs: ^7.0.0
   bin:
     sassgraph: bin/sassgraph
-  checksum: 283b6e5a38c8b4fca77cdc4fc1da9641679120dba80e89361c82b6a3975f90d01cc78129f9f8fd148822e5a648f540c58c9a38b8c2b11ca97abc4f381613c013
+  checksum: 1fb1719c659fdea00a9f55be9722c5902c3d1f1a0919d2e5ceb8a318064f2b214981d98b7d7fecaafc25f522302f919a948351e4ae1d1680b9c045d563550a93
   languageName: node
   linkType: hard
 
-"sass-graph@npm:^2.2.4":
-  version: 2.2.6
-  resolution: "sass-graph@npm:2.2.6"
+"sass-graph@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "sass-graph@npm:4.0.1"
   dependencies:
     glob: ^7.0.0
-    lodash: ^4.0.0
-    scss-tokenizer: ^0.2.3
-    yargs: ^7.0.0
+    lodash: ^4.17.11
+    scss-tokenizer: ^0.4.3
+    yargs: ^17.2.1
   bin:
     sassgraph: bin/sassgraph
-  checksum: 1fb1719c659fdea00a9f55be9722c5902c3d1f1a0919d2e5ceb8a318064f2b214981d98b7d7fecaafc25f522302f919a948351e4ae1d1680b9c045d563550a93
+  checksum: 896f99253bd77a429a95e483ebddee946e195b61d3f84b3e1ccf8ad843265ec0585fa40bf55fbf354c5f57eb9fd0349834a8b190cd2161ab1234cb9af10e3601
   languageName: node
   linkType: hard
 
@@ -16222,16 +16704,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"scheduler@npm:^0.13.6":
-  version: 0.13.6
-  resolution: "scheduler@npm:0.13.6"
-  dependencies:
-    loose-envify: ^1.1.0
-    object-assign: ^4.1.1
-  checksum: c82c705f6d0d6df87b26bf2cca33f427e91889438c0435ade3ee7f41860eda4dd7f3171ca2d93e8fe9431f3bd831ca0e267a401a0296e4b14de05e389f82d320
-  languageName: node
-  linkType: hard
-
 "scheduler@npm:^0.19.1":
   version: 0.19.1
   resolution: "scheduler@npm:0.19.1"
@@ -16274,6 +16746,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"scss-tokenizer@npm:^0.4.3":
+  version: 0.4.3
+  resolution: "scss-tokenizer@npm:0.4.3"
+  dependencies:
+    js-base64: ^2.4.9
+    source-map: ^0.7.3
+  checksum: f3697bb155ae23d88c7cd0275988a73231fe675fbbd250b4e56849ba66319fc249a597f3799a92f9890b12007f00f8f6a7f441283e634679e2acdb2287a341d1
+  languageName: node
+  linkType: hard
+
 "select-hose@npm:^2.0.0":
   version: 2.0.0
   resolution: "select-hose@npm:2.0.0"
@@ -16291,15 +16773,15 @@ __metadata:
   linkType: hard
 
 "semver@npm:2 || 3 || 4 || 5, semver@npm:^5.3.0, semver@npm:^5.4.1, semver@npm:^5.5.0, semver@npm:^5.5.1, semver@npm:^5.6.0, semver@npm:^5.7.0, semver@npm:^5.7.1":
-  version: 5.7.1
-  resolution: "semver@npm:5.7.1"
+  version: 5.7.2
+  resolution: "semver@npm:5.7.2"
   bin:
-    semver: ./bin/semver
-  checksum: 57fd0acfd0bac382ee87cd52cd0aaa5af086a7dc8d60379dfe65fea491fb2489b6016400813930ecd61fd0952dae75c115287a1b16c234b1550887117744dfaf
+    semver: bin/semver
+  checksum: fb4ab5e0dd1c22ce0c937ea390b4a822147a9c53dbd2a9a0132f12fe382902beef4fbf12cf51bb955248d8d15874ce8cd89532569756384f994309825f10b686
   languageName: node
   linkType: hard
 
-"semver@npm:6.3.0, semver@npm:^6.0.0, semver@npm:^6.1.1, semver@npm:^6.1.2, semver@npm:^6.2.0, semver@npm:^6.3.0":
+"semver@npm:6.3.0":
   version: 6.3.0
   resolution: "semver@npm:6.3.0"
   bin:
@@ -16317,23 +16799,23 @@ __metadata:
   languageName: node
   linkType: hard
 
-"semver@npm:^7.3.5":
-  version: 7.3.5
-  resolution: "semver@npm:7.3.5"
-  dependencies:
-    lru-cache: ^6.0.0
+"semver@npm:^6.0.0, semver@npm:^6.1.1, semver@npm:^6.1.2, semver@npm:^6.2.0, semver@npm:^6.3.0":
+  version: 6.3.1
+  resolution: "semver@npm:6.3.1"
   bin:
     semver: bin/semver.js
-  checksum: 5eafe6102bea2a7439897c1856362e31cc348ccf96efd455c8b5bc2c61e6f7e7b8250dc26b8828c1d76a56f818a7ee907a36ae9fb37a599d3d24609207001d60
+  checksum: ae47d06de28836adb9d3e25f22a92943477371292d9b665fb023fae278d345d508ca1958232af086d85e0155aee22e313e100971898bbb8d5d89b8b1d4054ca2
   languageName: node
   linkType: hard
 
-"semver@npm:~5.3.0":
-  version: 5.3.0
-  resolution: "semver@npm:5.3.0"
+"semver@npm:^7.3.4, semver@npm:^7.3.5":
+  version: 7.5.4
+  resolution: "semver@npm:7.5.4"
+  dependencies:
+    lru-cache: ^6.0.0
   bin:
-    semver: ./bin/semver
-  checksum: 2717b14299c76a4b35aec0aafebca22a3644da2942d2a4095f26e36d77a9bbe17a9a3a5199795f83edd26323d5c22024a2d9d373a038dec4e023156fa166d314
+    semver: bin/semver.js
+  checksum: 12d8ad952fa353b0995bf180cdac205a4068b759a140e5d3c608317098b3575ac2f1e09182206bf2eb26120e1c0ed8fb92c48c592f6099680de56bb071423ca3
   languageName: node
   linkType: hard
 
@@ -16394,7 +16876,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"set-blocking@npm:^2.0.0, set-blocking@npm:~2.0.0":
+"set-blocking@npm:^2.0.0":
   version: 2.0.0
   resolution: "set-blocking@npm:2.0.0"
   checksum: 6e65a05f7cf7ebdf8b7c75b101e18c0b7e3dff4940d480efed8aad3a36a4005140b660fa1d804cb8bce911cac290441dc728084a30504d3516ac2ff7ad607b02
@@ -16690,6 +17172,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"socks-proxy-agent@npm:^6.0.0":
+  version: 6.2.1
+  resolution: "socks-proxy-agent@npm:6.2.1"
+  dependencies:
+    agent-base: ^6.0.2
+    debug: ^4.3.3
+    socks: ^2.6.2
+  checksum: 9ca089d489e5ee84af06741135c4b0d2022977dad27ac8d649478a114cdce87849e8d82b7c22b51501a4116e231241592946fc7fae0afc93b65030ee57084f58
+  languageName: node
+  linkType: hard
+
 "socks-proxy-agent@npm:^6.1.1":
   version: 6.1.1
   resolution: "socks-proxy-agent@npm:6.1.1"
@@ -16701,6 +17194,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"socks-proxy-agent@npm:^7.0.0":
+  version: 7.0.0
+  resolution: "socks-proxy-agent@npm:7.0.0"
+  dependencies:
+    agent-base: ^6.0.2
+    debug: ^4.3.3
+    socks: ^2.6.2
+  checksum: 720554370154cbc979e2e9ce6a6ec6ced205d02757d8f5d93fe95adae454fc187a5cbfc6b022afab850a5ce9b4c7d73e0f98e381879cf45f66317a4895953846
+  languageName: node
+  linkType: hard
+
 "socks@npm:^2.6.1":
   version: 2.6.2
   resolution: "socks@npm:2.6.2"
@@ -16711,6 +17215,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"socks@npm:^2.6.2":
+  version: 2.7.1
+  resolution: "socks@npm:2.7.1"
+  dependencies:
+    ip: ^2.0.0
+    smart-buffer: ^4.2.0
+  checksum: 259d9e3e8e1c9809a7f5c32238c3d4d2a36b39b83851d0f573bfde5f21c4b1288417ce1af06af1452569cd1eb0841169afd4998f0e04ba04656f6b7f0e46d748
+  languageName: node
+  linkType: hard
+
 "sort-keys@npm:^1.0.0":
   version: 1.1.2
   resolution: "sort-keys@npm:1.1.2"
@@ -16789,6 +17303,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"source-map@npm:^0.7.3":
+  version: 0.7.4
+  resolution: "source-map@npm:0.7.4"
+  checksum: 01cc5a74b1f0e1d626a58d36ad6898ea820567e87f18dfc9d24a9843a351aaa2ec09b87422589906d6ff1deed29693e176194dc88bcae7c9a852dc74b311dbf5
+  languageName: node
+  linkType: hard
+
 "spdx-correct@npm:^3.0.0":
   version: 3.1.1
   resolution: "spdx-correct@npm:3.1.1"
@@ -16913,7 +17434,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"ssri@npm:^8.0.1":
+"ssri@npm:^8.0.0, ssri@npm:^8.0.1":
   version: 8.0.1
   resolution: "ssri@npm:8.0.1"
   dependencies:
@@ -16922,6 +17443,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"ssri@npm:^9.0.0":
+  version: 9.0.1
+  resolution: "ssri@npm:9.0.1"
+  dependencies:
+    minipass: ^3.1.1
+  checksum: fb58f5e46b6923ae67b87ad5ef1c5ab6d427a17db0bead84570c2df3cd50b4ceb880ebdba2d60726588272890bae842a744e1ecce5bd2a2a582fccd5068309eb
+  languageName: node
+  linkType: hard
+
 "stable@npm:^0.1.8":
   version: 0.1.8
   resolution: "stable@npm:0.1.8"
@@ -17067,7 +17597,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"string-width@npm:^1.0.2 || 2, string-width@npm:^2.1.1":
+"string-width@npm:^2.1.1":
   version: 2.1.1
   resolution: "string-width@npm:2.1.1"
   dependencies:
@@ -17155,13 +17685,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"string_decoder@npm:~0.10.x":
-  version: 0.10.31
-  resolution: "string_decoder@npm:0.10.31"
-  checksum: fe00f8e303647e5db919948ccb5ce0da7dea209ab54702894dd0c664edd98e5d4df4b80d6fabf7b9e92b237359d21136c95bf068b2f7760b772ca974ba970202
-  languageName: node
-  linkType: hard
-
 "string_decoder@npm:~1.1.1":
   version: 1.1.1
   resolution: "string_decoder@npm:1.1.1"
@@ -17182,7 +17705,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"strip-ansi@npm:6.0.0, strip-ansi@npm:^6.0.0":
+"strip-ansi@npm:6.0.0":
   version: 6.0.0
   resolution: "strip-ansi@npm:6.0.0"
   dependencies:
@@ -17218,7 +17741,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"strip-ansi@npm:^6.0.1":
+"strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1":
   version: 6.0.1
   resolution: "strip-ansi@npm:6.0.1"
   dependencies:
@@ -17278,6 +17801,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"strip-indent@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "strip-indent@npm:3.0.0"
+  dependencies:
+    min-indent: ^1.0.0
+  checksum: 18f045d57d9d0d90cd16f72b2313d6364fd2cb4bf85b9f593523ad431c8720011a4d5f08b6591c9d580f446e78855c5334a30fb91aa1560f5d9f95ed1b4a0530
+  languageName: node
+  linkType: hard
+
 "strip-json-comments@npm:^3.0.1":
   version: 3.1.1
   resolution: "strip-json-comments@npm:3.1.1"
@@ -17410,28 +17942,17 @@ __metadata:
   languageName: node
   linkType: hard
 
-"tar@npm:^2.0.0":
-  version: 2.2.2
-  resolution: "tar@npm:2.2.2"
-  dependencies:
-    block-stream: "*"
-    fstream: ^1.0.12
-    inherits: 2
-  checksum: c0c3727d529077423cf771f9f9c06edaaff82034d05d685806d3cee69d334ee8e6f394ee8d02dbd294cdecb95bb22625703279caff24bdb90b17e59de03a4733
-  languageName: node
-  linkType: hard
-
-"tar@npm:^6.0.2, tar@npm:^6.1.2":
-  version: 6.1.11
-  resolution: "tar@npm:6.1.11"
+"tar@npm:^6.0.2, tar@npm:^6.1.11, tar@npm:^6.1.2":
+  version: 6.2.0
+  resolution: "tar@npm:6.2.0"
   dependencies:
     chownr: ^2.0.0
     fs-minipass: ^2.0.0
-    minipass: ^3.0.0
+    minipass: ^5.0.0
     minizlib: ^2.1.1
     mkdirp: ^1.0.3
     yallist: ^4.0.0
-  checksum: a04c07bb9e2d8f46776517d4618f2406fb977a74d914ad98b264fc3db0fe8224da5bec11e5f8902c5b9bcb8ace22d95fbe3c7b36b8593b7dfc8391a25898f32f
+  checksum: db4d9fe74a2082c3a5016630092c54c8375ff3b280186938cfd104f2e089c4fd9bad58688ef6be9cf186a889671bf355c7cda38f09bbf60604b281715ca57f5c
   languageName: node
   linkType: hard
 
@@ -17474,15 +17995,15 @@ __metadata:
   linkType: hard
 
 "terser@npm:^4.1.2, terser@npm:^4.6.12, terser@npm:^4.6.3":
-  version: 4.8.0
-  resolution: "terser@npm:4.8.0"
+  version: 4.8.1
+  resolution: "terser@npm:4.8.1"
   dependencies:
     commander: ^2.20.0
     source-map: ~0.6.1
     source-map-support: ~0.5.12
   bin:
     terser: bin/terser
-  checksum: f980789097d4f856c1ef4b9a7ada37beb0bb022fb8aa3057968862b5864ad7c244253b3e269c9eb0ab7d0caf97b9521273f2d1cf1e0e942ff0016e0583859c71
+  checksum: b342819bf7e82283059aaa3f22bb74deb1862d07573ba5a8947882190ad525fd9b44a15074986be083fd379c58b9a879457a330b66dcdb77b485c44267f9a55a
   languageName: node
   linkType: hard
 
@@ -17601,9 +18122,9 @@ __metadata:
   linkType: hard
 
 "tmpl@npm:1.0.x":
-  version: 1.0.4
-  resolution: "tmpl@npm:1.0.4"
-  checksum: 72c93335044b5b8771207d2e9cf71e8c26b110d0f0f924f6d6c06b509d89552c7c0e4086a574ce4f05110ac40c1faf6277ecba7221afeb57ebbab70d8de39cc4
+  version: 1.0.5
+  resolution: "tmpl@npm:1.0.5"
+  checksum: cd922d9b853c00fe414c5a774817be65b058d54a2d01ebb415840960406c669a0fc632f66df885e24cb022ec812739199ccbdb8d1164c3e513f85bfca5ab2873
   languageName: node
   linkType: hard
 
@@ -17701,7 +18222,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"trim-newlines@npm:^1.0.0":
+"tr46@npm:~0.0.3":
+  version: 0.0.3
+  resolution: "tr46@npm:0.0.3"
+  checksum: 726321c5eaf41b5002e17ffbd1fb7245999a073e8979085dacd47c4b4e8068ff5777142fc6726d6ca1fd2ff16921b48788b87225cbc57c72636f6efa8efbffe3
+  languageName: node
+  linkType: hard
+
+"trim-newlines@npm:^1.0.0, trim-newlines@npm:^3.0.0":
   version: 3.0.1
   resolution: "trim-newlines@npm:3.0.1"
   checksum: b530f3fadf78e570cf3c761fb74fef655beff6b0f84b29209bac6c9622db75ad1417f4a7b5d54c96605dcd72734ad44526fef9f396807b90839449eb543c6206
@@ -17724,6 +18252,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"true-case-path@npm:^2.2.1":
+  version: 2.2.1
+  resolution: "true-case-path@npm:2.2.1"
+  checksum: fd5f1c2a87a122a65ffb1f84b580366be08dac7f552ea0fa4b5a6ab0a013af950b0e752beddb1c6c1652e6d6a2b293b7b3fd86a5a1706242ad365b68f1b5c6f1
+  languageName: node
+  linkType: hard
+
 "ts-mock-imports@npm:1.3.7":
   version: 1.3.7
   resolution: "ts-mock-imports@npm:1.3.7"
@@ -17884,6 +18419,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"type-fest@npm:^0.18.0":
+  version: 0.18.1
+  resolution: "type-fest@npm:0.18.1"
+  checksum: e96dcee18abe50ec82dab6cbc4751b3a82046da54c52e3b2d035b3c519732c0b3dd7a2fa9df24efd1a38d953d8d4813c50985f215f1957ee5e4f26b0fe0da395
+  languageName: node
+  linkType: hard
+
 "type-fest@npm:^0.21.3":
   version: 0.21.3
   resolution: "type-fest@npm:0.21.3"
@@ -17891,6 +18433,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"type-fest@npm:^0.6.0":
+  version: 0.6.0
+  resolution: "type-fest@npm:0.6.0"
+  checksum: b2188e6e4b21557f6e92960ec496d28a51d68658018cba8b597bd3ef757721d1db309f120ae987abeeda874511d14b776157ff809f23c6d1ce8f83b9b2b7d60f
+  languageName: node
+  linkType: hard
+
 "type-fest@npm:^0.8.1":
   version: 0.8.1
   resolution: "type-fest@npm:0.8.1"
@@ -17949,10 +18498,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"ua-parser-js@npm:^0.7.18":
-  version: 0.7.24
-  resolution: "ua-parser-js@npm:0.7.24"
-  checksum: 722e0291fe6ad0d439cd29c4cd919f4e1b7262fe78e4c2149756180f8ad723ae04713839115eeb8738aca6d6258a743668090fb1e1417bc1fba27acc815a84e2
+"ua-parser-js@npm:^0.7.18, ua-parser-js@npm:^0.7.30":
+  version: 0.7.36
+  resolution: "ua-parser-js@npm:0.7.36"
+  checksum: 04e18e7f6bf4964a10d74131ea9784c7f01d0c2d3b96f73340ac0a1f8e83d010b99fd7d425e7a2100fa40c58b72f6201408cbf4baa2df1103637f96fb59f2a30
   languageName: node
   linkType: hard
 
@@ -18041,6 +18590,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"unique-filename@npm:^2.0.0":
+  version: 2.0.1
+  resolution: "unique-filename@npm:2.0.1"
+  dependencies:
+    unique-slug: ^3.0.0
+  checksum: 807acf3381aff319086b64dc7125a9a37c09c44af7620bd4f7f3247fcd5565660ac12d8b80534dcbfd067e6fe88a67e621386dd796a8af828d1337a8420a255f
+  languageName: node
+  linkType: hard
+
 "unique-slug@npm:^2.0.0":
   version: 2.0.2
   resolution: "unique-slug@npm:2.0.2"
@@ -18050,6 +18608,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"unique-slug@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "unique-slug@npm:3.0.0"
+  dependencies:
+    imurmurhash: ^0.1.4
+  checksum: 49f8d915ba7f0101801b922062ee46b7953256c93ceca74303bd8e6413ae10aa7e8216556b54dc5382895e8221d04f1efaf75f945c2e4a515b4139f77aa6640c
+  languageName: node
+  linkType: hard
+
 "universalify@npm:^0.1.0":
   version: 0.1.2
   resolution: "universalify@npm:0.1.2"
@@ -18102,6 +18669,20 @@ __metadata:
   languageName: node
   linkType: hard
 
+"update-browserslist-db@npm:^1.0.13":
+  version: 1.0.13
+  resolution: "update-browserslist-db@npm:1.0.13"
+  dependencies:
+    escalade: ^3.1.1
+    picocolors: ^1.0.0
+  peerDependencies:
+    browserslist: ">= 4.21.0"
+  bin:
+    update-browserslist-db: cli.js
+  checksum: 1e47d80182ab6e4ad35396ad8b61008ae2a1330221175d0abd37689658bdb61af9b705bfc41057fd16682474d79944fb2d86767c5ed5ae34b6276b9bed353322
+  languageName: node
+  linkType: hard
+
 "uri-js@npm:^4.2.2":
   version: 4.4.1
   resolution: "uri-js@npm:4.4.1"
@@ -18136,12 +18717,12 @@ __metadata:
   linkType: hard
 
 "url-parse@npm:^1.4.3":
-  version: 1.5.1
-  resolution: "url-parse@npm:1.5.1"
+  version: 1.5.10
+  resolution: "url-parse@npm:1.5.10"
   dependencies:
     querystringify: ^2.1.1
     requires-port: ^1.0.0
-  checksum: ce5c400db52d83b941944502000081e2338e46834cf16f2888961dc034ea5d49dbeb85ac8fdbe28c3fe738c09320a71a2f6d9286b748895cd464b1e208b6b991
+  checksum: fbdba6b1d83336aca2216bbdc38ba658d9cfb8fc7f665eb8b17852de638ff7d1a162c198a8e4ed66001ddbf6c9888d41e4798912c62b4fd777a31657989f7bdf
   languageName: node
   linkType: hard
 
@@ -18408,6 +18989,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"webidl-conversions@npm:^3.0.0":
+  version: 3.0.1
+  resolution: "webidl-conversions@npm:3.0.1"
+  checksum: c92a0a6ab95314bde9c32e1d0a6dfac83b578f8fa5f21e675bc2706ed6981bc26b7eb7e6a1fab158e5ce4adf9caa4a0aee49a52505d4d13c7be545f15021b17c
+  languageName: node
+  linkType: hard
+
 "webidl-conversions@npm:^4.0.2":
   version: 4.0.2
   resolution: "webidl-conversions@npm:4.0.2"
@@ -18595,6 +19183,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"whatwg-url@npm:^5.0.0":
+  version: 5.0.0
+  resolution: "whatwg-url@npm:5.0.0"
+  dependencies:
+    tr46: ~0.0.3
+    webidl-conversions: ^3.0.0
+  checksum: b8daed4ad3356cc4899048a15b2c143a9aed0dfae1f611ebd55073310c7b910f522ad75d727346ad64203d7e6c79ef25eafd465f4d12775ca44b90fa82ed9e2c
+  languageName: node
+  linkType: hard
+
 "whatwg-url@npm:^6.4.1":
   version: 6.5.0
   resolution: "whatwg-url@npm:6.5.0"
@@ -18644,7 +19242,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"which@npm:1, which@npm:^1.2.9, which@npm:^1.3.0, which@npm:^1.3.1":
+"which@npm:^1.2.9, which@npm:^1.3.0, which@npm:^1.3.1":
   version: 1.3.1
   resolution: "which@npm:1.3.1"
   dependencies:
@@ -18666,16 +19264,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"wide-align@npm:^1.1.0":
-  version: 1.1.3
-  resolution: "wide-align@npm:1.1.3"
-  dependencies:
-    string-width: ^1.0.2 || 2
-  checksum: d09c8012652a9e6cab3e82338d1874a4d7db2ad1bd19ab43eb744acf0b9b5632ec406bdbbbb970a8f4771a7d5ef49824d038ba70aa884e7723f5b090ab87134d
-  languageName: node
-  linkType: hard
-
-"wide-align@npm:^1.1.5":
+"wide-align@npm:^1.1.2, wide-align@npm:^1.1.5":
   version: 1.1.5
   resolution: "wide-align@npm:1.1.5"
   dependencies:
@@ -18685,9 +19274,9 @@ __metadata:
   linkType: hard
 
 "word-wrap@npm:~1.2.3":
-  version: 1.2.3
-  resolution: "word-wrap@npm:1.2.3"
-  checksum: 30b48f91fcf12106ed3186ae4fa86a6a1842416df425be7b60485de14bec665a54a68e4b5156647dec3a70f25e84d270ca8bc8cd23182ed095f5c7206a938c1f
+  version: 1.2.5
+  resolution: "word-wrap@npm:1.2.5"
+  checksum: f93ba3586fc181f94afdaff3a6fef27920b4b6d9eaefed0f428f8e07adea2a7f54a5f2830ce59406c8416f033f86902b91eb824072354645eea687dff3691ccb
   languageName: node
   linkType: hard
 
@@ -19016,13 +19605,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"yallist@npm:^2.1.2":
-  version: 2.1.2
-  resolution: "yallist@npm:2.1.2"
-  checksum: 9ba99409209f485b6fcb970330908a6d41fa1c933f75e08250316cce19383179a6b70a7e0721b89672ebb6199cc377bf3e432f55100da6a7d6e11902b0a642cb
-  languageName: node
-  linkType: hard
-
 "yallist@npm:^3.0.2":
   version: 3.1.1
   resolution: "yallist@npm:3.1.1"
@@ -19067,13 +19649,20 @@ __metadata:
   languageName: node
   linkType: hard
 
-"yargs-parser@npm:^20.2.2":
+"yargs-parser@npm:^20.2.2, yargs-parser@npm:^20.2.3":
   version: 20.2.9
   resolution: "yargs-parser@npm:20.2.9"
   checksum: 8bb69015f2b0ff9e17b2c8e6bfe224ab463dd00ca211eece72a4cd8a906224d2703fb8a326d36fdd0e68701e201b2a60ed7cf81ce0fd9b3799f9fe7745977ae3
   languageName: node
   linkType: hard
 
+"yargs-parser@npm:^21.1.1":
+  version: 21.1.1
+  resolution: "yargs-parser@npm:21.1.1"
+  checksum: ed2d96a616a9e3e1cc7d204c62ecc61f7aaab633dcbfab2c6df50f7f87b393993fe6640d017759fe112d0cb1e0119f2b4150a87305cc873fd90831c6a58ccf1c
+  languageName: node
+  linkType: hard
+
 "yargs-parser@npm:^5.0.1":
   version: 5.0.1
   resolution: "yargs-parser@npm:5.0.1"
@@ -19117,6 +19706,21 @@ __metadata:
   languageName: node
   linkType: hard
 
+"yargs@npm:^17.2.1":
+  version: 17.7.2
+  resolution: "yargs@npm:17.7.2"
+  dependencies:
+    cliui: ^8.0.1
+    escalade: ^3.1.1
+    get-caller-file: ^2.0.5
+    require-directory: ^2.1.1
+    string-width: ^4.2.3
+    y18n: ^5.0.5
+    yargs-parser: ^21.1.1
+  checksum: 73b572e863aa4a8cbef323dd911d79d193b772defd5a51aab0aca2d446655216f5002c42c5306033968193bdbf892a7a4c110b0d77954a7fdf563e653967b56a
+  languageName: node
+  linkType: hard
+
 "yargs@npm:^7.0.0":
   version: 7.1.2
   resolution: "yargs@npm:7.1.2"