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>

383 files changed:
.gitignore
.licenseignore
.yarn/releases/yarn-3.2.0.cjs
Makefile
cypress/fixtures/files/banner.html [new file with mode: 0644]
cypress/fixtures/files/cat.png [new file with mode: 0644]
cypress/fixtures/files/tooltips.txt [new file with mode: 0644]
cypress/fixtures/webdav-propfind-outputs.xml [new file with mode: 0644]
cypress/fixtures/workflow_directory_array.yaml [new file with mode: 0644]
cypress/integration/banner-tooltip.spec.js [new file with mode: 0644]
cypress/integration/collection.spec.js
cypress/integration/create-workflow.spec.js
cypress/integration/favorites.spec.js
cypress/integration/group-manage.spec.js
cypress/integration/login.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
docker/Dockerfile
package.json
public/mui-start-icon.svg [new file with mode: 0644]
src/common/config.ts
src/common/custom-theme.ts
src/common/formatters.test.ts
src/common/formatters.ts
src/common/frozen-resources.ts [new file with mode: 0644]
src/common/html-sanitize.ts [new file with mode: 0644]
src/common/redirect-to.test.ts
src/common/redirect-to.ts
src/common/service-provider.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/common/webdav.test.ts
src/common/webdav.ts
src/components/autocomplete/autocomplete.tsx
src/components/breadcrumbs/breadcrumbs.test.tsx
src/components/breadcrumbs/breadcrumbs.tsx
src/components/chips-input/chips-input.tsx
src/components/collection-panel-files/collection-panel-files.tsx
src/components/column-selector/column-selector.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-column.ts
src/components/data-table/data-table.test.tsx
src/components/data-table/data-table.tsx
src/components/default-view/default-view.tsx
src/components/details-attribute/details-attribute.tsx
src/components/dropdown-menu/dropdown-menu.tsx
src/components/file-upload/file-upload.tsx
src/components/form-dialog/form-dialog.tsx
src/components/icon/icon.tsx
src/components/multi-panel-view/multi-panel-view.test.tsx
src/components/multi-panel-view/multi-panel-view.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/search-input/search-input.test.tsx
src/components/search-input/search-input.tsx
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/text-field/text-field.tsx
src/components/tree/tree.tsx
src/index.css
src/index.tsx
src/models/collection-file.ts
src/models/container-request.ts
src/models/container.ts
src/models/group.ts
src/models/project.ts
src/models/resource.ts
src/models/runtime-constraints.ts
src/models/search-bar.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/api/filter-builder.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.test.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/project-service/project-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-action.ts
src/store/all-processes-panel/all-processes-panel-middleware-service.ts
src/store/api-client-authorizations/api-client-authorizations-middleware-service.ts
src/store/auth/auth-action.test.ts
src/store/auth/auth-action.ts
src/store/auth/auth-middleware.test.ts
src/store/auth/auth-middleware.ts
src/store/banner/banner-action.ts [new file with mode: 0644]
src/store/banner/banner-reducer.ts [new file with mode: 0644]
src/store/breadcrumbs/breadcrumbs-actions.ts
src/store/collection-panel/collection-panel-action.ts
src/store/collection-panel/collection-panel-files/collection-panel-files-actions.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-create-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/collections/collection-update-actions.ts
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.test.ts
src/store/data-explorer/data-explorer-middleware.ts
src/store/data-explorer/data-explorer-reducer.test.tsx
src/store/data-explorer/data-explorer-reducer.ts
src/store/dialog/dialog-reducer.ts
src/store/dialog/with-dialog.ts
src/store/favorite-panel/favorite-panel-action.ts
src/store/favorite-panel/favorite-panel-middleware-service.ts
src/store/favorites/favorites-actions.ts
src/store/group-details-panel/group-details-panel-members-middleware-service.ts
src/store/groups-panel/groups-panel-actions.ts
src/store/groups-panel/groups-panel-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/process-panel/process-panel.ts
src/store/processes/process-copy-actions.test.ts [new file with mode: 0644]
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-create-actions.ts
src/store/projects/project-lock-actions.ts [new file with mode: 0644]
src/store/projects/project-move-actions.ts
src/store/projects/project-update-actions.ts
src/store/public-favorites-panel/public-favorites-action.ts
src/store/public-favorites-panel/public-favorites-middleware-service.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-bar/search-bar-actions.ts
src/store/search-bar/search-bar-tree-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/shared-with-me-panel/shared-with-me-panel-actions.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/side-panel/side-panel-action.ts
src/store/side-panel/side-panel-reducer.tsx [new file with mode: 0644]
src/store/snackbar/snackbar-actions.ts
src/store/snackbar/snackbar-reducer.ts
src/store/store.ts
src/store/subprocess-panel/subprocess-panel-actions.ts
src/store/subprocess-panel/subprocess-panel-middleware-service.ts
src/store/tooltips/tooltips-middleware.ts [new file with mode: 0644]
src/store/trash-panel/trash-panel-action.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 [new file with mode: 0644]
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/users/users-actions.ts
src/store/virtual-machines/virtual-machines-actions.ts
src/store/workbench/workbench-actions.ts
src/store/workflow-panel/workflow-middleware-service.ts
src/store/workflow-panel/workflow-panel-actions.ts
src/validators/validators.tsx
src/views-components/advanced-tab-dialog/advanced-tab-dialog.tsx
src/views-components/auto-logout/auto-logout.tsx
src/views-components/baner/banner.test.tsx [new file with mode: 0644]
src/views-components/baner/banner.tsx [new file with mode: 0644]
src/views-components/breadcrumbs/breadcrumbs.ts
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/actions/lock-action.tsx [new file with mode: 0644]
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.test.tsx
src/views-components/data-explorer/renderers.tsx
src/views-components/details-panel/collection-details.tsx
src/views-components/details-panel/details-panel.tsx
src/views-components/details-panel/project-details.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 60% 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 66% 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 [new file with mode: 0644]
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/file-uploader/file-uploader.tsx
src/views-components/form-fields/collection-form-fields.tsx
src/views-components/form-fields/search-bar-form-fields.tsx
src/views-components/login-form/login-form.tsx
src/views-components/main-app-bar/account-menu.test.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/main-content-bar/main-content-bar.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/process-runtime-status/process-runtime-status.tsx
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 [new file with mode: 0644]
src/views-components/projects-tree-picker/shared-tree-picker.tsx
src/views-components/projects-tree-picker/tree-picker-field.tsx
src/views-components/resource-properties-form/property-value-field.tsx
src/views-components/resource-properties-form/resource-properties-form.tsx
src/views-components/search-bar/search-bar-view.tsx
src/views-components/search-bar/search-bar.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 [new file with mode: 0644]
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/snackbar/snackbar.tsx
src/views-components/tree-picker/tree-picker.ts
src/views-components/virtual-machines-dialog/add-login-dialog.tsx
src/views-components/virtual-machines-dialog/group-array-input.tsx
src/views-components/webdav-s3-dialog/webdav-s3-dialog.test.tsx
src/views-components/webdav-s3-dialog/webdav-s3-dialog.tsx
src/views/all-processes-panel/all-processes-panel.tsx
src/views/api-client-authorization-panel/api-client-authorization-panel-root.tsx
src/views/collection-content-address-panel/collection-content-address-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/groups-panel/groups-panel.tsx
src/views/inactive-panel/inactive-panel.tsx
src/views/keep-service-panel/keep-service-panel-root.tsx
src/views/link-panel/link-panel-root.tsx
src/views/login-panel/login-panel.tsx
src/views/main-panel/main-panel-root.tsx
src/views/main-panel/main-panel.tsx
src/views/not-found-panel/not-found-panel-root.tsx
src/views/not-found-panel/not-found-panel.tsx
src/views/process-panel/process-cmd-card.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 [new file with mode: 0644]
src/views/process-panel/process-log-card.tsx
src/views/process-panel/process-log-code-snippet.tsx
src/views/process-panel/process-output-collection-files.ts [new file with mode: 0644]
src/views/process-panel/process-panel-root.tsx
src/views/process-panel/process-panel.tsx
src/views/process-panel/process-resource-card.tsx [new file with mode: 0644]
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/generic-input.tsx
src/views/run-process-panel/inputs/project-input.tsx
src/views/run-process-panel/run-process-basic-form.tsx
src/views/run-process-panel/run-process-inputs-form.tsx
src/views/search-results-panel/search-results-panel-view.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-panel/user-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.test.tsx
src/views/workbench/workbench.tsx
src/views/workflow-panel/registered-workflow-panel.tsx [new file with mode: 0644]
src/views/workflow-panel/workflow-panel-view.tsx
src/websocket/websocket.ts
tools/arvados_config.yml
tools/run-integration-tests.sh
tsconfig.json
yarn.lock

index 6a564a2bc75617053c888e76a6dce6ba769cb44b..7358d62706c7ff5723dcea2e551bcec1b781b0b4 100644 (file)
@@ -33,6 +33,8 @@ yarn-error.log*
 
 .idea
 .vscode
+/public/config.json
+/public/_health/
 
 # see https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored
 .pnp.*
index 7622a59435b0c54d3ed22a3a57691dc4ebd73dea..2d7deb739d1d54b1a9168b05c35c7cbee81f3254 100644 (file)
@@ -15,6 +15,10 @@ public/*
 src/lib/cwl-svg/*
 tools/arvados_config.yml
 cypress/fixtures/files/5mb.bin
+cypress/fixtures/files/cat.png
+cypress/fixtures/files/banner.html
+cypress/fixtures/files/tooltips.txt
+cypress/fixtures/webdav-propfind-outputs.xml
 .yarn/releases/*
 package.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 2236f9de4f78df396f3f0ba7be58d1cce726d44c..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,13 +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 -
-       ~/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
@@ -76,12 +79,18 @@ 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
-       docker run -ti -v$(PWD):$(PWD) -w$(PWD) workbench2-build make integration-tests
+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
 
@@ -121,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
@@ -143,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/files/banner.html b/cypress/fixtures/files/banner.html
new file mode 100644 (file)
index 0000000..34966bd
--- /dev/null
@@ -0,0 +1,5 @@
+<div>
+    <h1>Hi there</h1>
+    <h3>This is my amazing</h3>
+    <h5 style="color: red">Banner</h5>
+</div>
\ No newline at end of file
diff --git a/cypress/fixtures/files/cat.png b/cypress/fixtures/files/cat.png
new file mode 100644 (file)
index 0000000..6ebc4ba
Binary files /dev/null and b/cypress/fixtures/files/cat.png differ
diff --git a/cypress/fixtures/files/tooltips.txt b/cypress/fixtures/files/tooltips.txt
new file mode 100644 (file)
index 0000000..c3c2162
--- /dev/null
@@ -0,0 +1,3 @@
+{
+    "[data-cy=side-panel-tree]": "This allows you to navigate through the app"
+}
\ No newline at end of file
diff --git a/cypress/fixtures/webdav-propfind-outputs.xml b/cypress/fixtures/webdav-propfind-outputs.xml
new file mode 100644 (file)
index 0000000..4bd1659
--- /dev/null
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<D:multistatus xmlns:D="DAV:">
+  <D:response>
+    <D:href>/c=zzzzz-4zz18-zzzzzzzzzzzzzzz/</D:href>
+    <D:propstat>
+      <D:prop>
+        <D:resourcetype>
+          <D:collection xmlns:D="DAV:" />
+        </D:resourcetype>
+        <D:getlastmodified>Mon, 11 Jul 2022 21:54:20 GMT</D:getlastmodified>
+        <D:supportedlock>
+          <D:lockentry xmlns:D="DAV:">
+            <D:lockscope>
+              <D:exclusive />
+            </D:lockscope>
+            <D:locktype>
+              <D:write />
+            </D:locktype>
+          </D:lockentry>
+        </D:supportedlock>
+        <D:displayname></D:displayname>
+      </D:prop>
+      <D:status>HTTP/1.1 200 OK</D:status>
+    </D:propstat>
+  </D:response>
+  <D:response>
+    <D:href>/c=zzzzz-4zz18-zzzzzzzzzzzzzzz/cwl.output.json</D:href>
+    <D:propstat>
+      <D:prop>
+        <D:displayname>cwl.output.json</D:displayname>
+        <D:getcontentlength>141</D:getcontentlength>
+        <D:getlastmodified>Mon, 11 Jul 2022 21:54:20 GMT</D:getlastmodified>
+        <D:supportedlock>
+          <D:lockentry xmlns:D="DAV:">
+            <D:lockscope>
+              <D:exclusive />
+            </D:lockscope>
+            <D:locktype>
+              <D:write />
+            </D:locktype>
+          </D:lockentry>
+        </D:supportedlock>
+        <D:resourcetype></D:resourcetype>
+        <D:getcontenttype>application/json</D:getcontenttype>
+        <D:getetag>"000000000000000000"</D:getetag>
+      </D:prop>
+      <D:status>HTTP/1.1 200 OK</D:status>
+    </D:propstat>
+  </D:response>
+</D:multistatus>
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
diff --git a/cypress/integration/banner-tooltip.spec.js b/cypress/integration/banner-tooltip.spec.js
new file mode 100644 (file)
index 0000000..295bc38
--- /dev/null
@@ -0,0 +1,115 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Banner / tooltip tests', function () {
+    let activeUser;
+    let adminUser;
+    let collectionUUID;
+
+    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('collectionuser1', 'Collection', 'User', false, true)
+            .as('activeUser').then(function () {
+                activeUser = this.activeUser;
+            });
+            cy.on('uncaught:exception', (err, runnable) => {console.error(err)});
+    });
+
+    beforeEach(function () {
+        cy.clearCookies();
+        cy.clearLocalStorage();
+    });
+
+    it('should re-show the banner', () => {
+        setupTheEnvironment();
+
+        cy.loginAs(adminUser);
+
+        cy.wait(2000);
+
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+
+        cy.get('[title=Notifications]').click();
+        cy.get('li').contains('Restore Banner').click();
+
+        cy.wait(2000);
+
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').should('be.visible');
+    });
+
+
+    it('should show tooltips and remove tooltips as localStorage key is present', () => {
+        setupTheEnvironment();
+
+        cy.loginAs(adminUser);
+
+        cy.wait(2000);
+
+        cy.get('[data-cy=side-panel-tree]').then(($el) => {
+            const el = $el.get(0) //native DOM element
+            expect(el._tippy).to.exist;
+        });
+
+        cy.wait(2000);
+
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+
+        cy.get('[title=Notifications]').click();
+        cy.get('li').contains('Disable tooltips').click();
+
+        cy.get('[data-cy=side-panel-tree]').then(($el) => {
+            const el = $el.get(0) //native DOM element
+            expect(el._tippy).to.be.undefined;
+        });
+    });
+
+    const setupTheEnvironment = () => {
+            cy.createCollection(adminUser.token, {
+                name: `BannerTooltipTest${Math.floor(Math.random() * 999999)}`,
+                owner_uuid: adminUser.user.uuid,
+            }).as('bannerCollection');
+
+            cy.getAll('@bannerCollection')
+                .then(function ([bannerCollection]) {
+
+                    collectionUUID=bannerCollection.uuid;
+
+                    cy.loginAs(adminUser);
+
+                    cy.goToPath(`/collections/${bannerCollection.uuid}`);
+
+                    cy.get('[data-cy=upload-button]').click();
+
+                    cy.fixture('files/banner.html').as('banner');
+                    cy.fixture('files/tooltips.txt').as('tooltips');
+
+                    cy.getAll('@banner', '@tooltips')
+                        .then(([banner, 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);
+                        });
+
+                    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('banner.html').should('exist');
+                    cy.get('[data-cy=collection-files-right-panel]')
+                        .contains('tooltips.json').should('exist');
+
+                        cy.intercept({ method: 'GET', url: '**/arvados/v1/config?nocache=*' }, (req) => {
+                            req.reply((res) => {
+                                res.body.Workbench.BannerUUID = collectionUUID;
+                            });
+                        });
+                });
+    }
+});
index 28454a9093b3e87499b8daf652ecafb01df4fdaa..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,279 +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;
+            .as("testCollection")
+            .then(function (testCollection) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${testCollection.uuid}`);
 
-                    for (i=0; i < childrenCollection.length; i += j) {
-                      map[childrenCollection[i].outerText] = childrenCollection[i + 1].outerText;
-                    }
+                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 }));
+            });
+    });
 
-                    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 () {
+        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",
         });
+        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");
+        // 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")
+            .within(() => {
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(name);
+                });
+                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")
+            .within(() => {
+                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-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")
+            .within(() => {
+                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-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", false);
-                      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("*");
                         })
-                        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');
-                            }
+                        .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,
+                            });
+                            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('not.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('not.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
@@ -312,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;
             });
@@ -500,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) {
@@ -524,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)}`;
@@ -608,73 +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(() => {
-                        cy.get('[data-cy=projects-tree-home-tree-picker]')
-                            .find('i')
-                            .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)}`;
-        const fileName = 'foobar'
 
         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"];
+
             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', fileName);
-            cy.get('[data-cy=collection-info-panel]').should('contain', collName);
-
-            cy.updateCollection(adminUser.token, testCollection.uuid, {
-                name: `${collName + ' updated'}`,
-                manifest_text: `. 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);
-                cy.get('[data-cy=collection-files-panel]').should('contain', fileName);
+            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"}`,
+                    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);
+                    fileName
+                        ? 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}`;
 
@@ -682,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
@@ -716,382 +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-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 8df8389ffd1a6fbc4683a1fc2405b1d146713ce6..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;
 
@@ -82,7 +82,7 @@ describe('Multi-file deletion tests', function () {
                 });
 
             cy.get('[data-cy=choose-a-file-dialog]').as('chooseFileDialog');
-            cy.get('@chooseFileDialog').contains('Projects').closest('ul').find('i').click();
+            cy.get('@chooseFileDialog').contains('Home Projects').closest('ul').find('i').click();
 
             cy.get('@project1').then((project1) => {
                 cy.get('@chooseFileDialog').find(`[data-id=${project1.uuid}]`).find('i').click();
@@ -158,17 +158,16 @@ describe('Multi-file deletion tests', function () {
                         cy.get('label').contains('foo').parent('div').find('input').click();
                         cy.get('div[role=dialog]')
                             .within(() => {
-                                cy.get('p').contains('Projects').closest('div[role=button]')
-                                    .within(() => {
-                                        cy.get('svg[role=presentation]')
-                                            .click({ multiple: true });
-                                    });
+                                // 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.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();
@@ -177,11 +176,10 @@ describe('Multi-file deletion tests', function () {
                         cy.get('label').contains('bar').parent('div').find('input').click();
                         cy.get('div[role=dialog]')
                             .within(() => {
-                                cy.get('p').contains('Projects').closest('div[role=button]')
-                                    .within(() => {
-                                        cy.get('svg[role=presentation]')
-                                            .click({ multiple: true });
-                                    });
+                                // 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('input[type=checkbox]').click();
@@ -206,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 7fd091245f770015a7c86c12ae938d0ace54db86..db9a0d5f394072736dbff8c1af730182eacc6ee4 100644 (file)
@@ -119,7 +119,10 @@ describe('Favorites tests', function () {
                 });
 
                 cy.get('[data-cy=form-dialog]').within(function () {
-                    cy.get('[data-cy=projects-tree-favourites-tree-picker]').find('i').click();
+                    // must use .then to avoid selecting instead of expanding https://github.com/cypress-io/cypress/issues/5529
+                    cy.get('[data-cy=projects-tree-favourites-tree-picker]')
+                        .find('i')
+                        .then(el => el.click());
                     cy.contains(myProject1.name);
                     cy.contains(mySharedWritableProject.name);
                     cy.get('[data-cy=projects-tree-favourites-tree-picker]')
index ffe2c8c4dfd27ac892007213c2209b0afff4686c..c4731bb3c6bf01bdde33ccdb62cc57579c4531bc 100644 (file)
@@ -70,7 +70,14 @@ describe('Group manage tests', function() {
                 cy.get('[data-cy=invite-people-field] input').type("other");
             });
         cy.get('[role=tooltip]').click();
-        cy.get('.sharing-dialog').contains('Save').click();
+        // Add admin to the group
+        cy.get('.sharing-dialog')
+            .should('contain', 'Sharing settings')
+            .within(() => {
+                cy.get('[data-cy=invite-people-field] input').type("admin");
+            });
+        cy.get('[role=tooltip]').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
@@ -109,6 +116,27 @@ describe('Group manage tests', function() {
             .within(() => {
                 cy.contains('Write');
             });
+
+        // Change admin to manage
+        cy.get('[data-cy=group-members-data-explorer]')
+            .contains(adminUser.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.contains('Read')
+                    .parents('td')
+                    .within(() => {
+                        cy.get('button').click();
+                    });
+            });
+        cy.get('[data-cy=context-menu]')
+            .contains('Manage')
+            .click();
+        cy.get('[data-cy=group-members-data-explorer]')
+            .contains(adminUser.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.contains('Manage');
+            });
     });
 
     it('can unhide and re-hide users', function() {
@@ -212,6 +240,7 @@ describe('Group manage tests', function() {
     });
 
     it('renames the group', function() {
+        cy.loginAs(adminUser);
         // Navigate to Groups
         cy.get('[data-cy=side-panel-tree]').contains('Groups').click();
 
index aeea01cdcfcb8d99fa5f6bac415ffec69dbc0175..2c539e4902aa36f2c9687adcc380af44d0b35dde 100644 (file)
@@ -79,11 +79,18 @@ describe('Login tests', function() {
     })
 
     it('logs out when token no longer valid', function() {
+        cy.createProject({
+            owningUser: activeUser,
+            projectName: `Test Project ${Math.floor(Math.random() * 999999)}`,
+            addToFavorites: false
+        }).as('testProject1');
         // Log in
         cy.visit(`/token/?api_token=${activeUser.token}`);
         cy.url().should('contain', '/projects/');
         cy.get('div#root').should('contain', 'Arvados Workbench (zzzzz)');
         cy.get('div#root').should('not.contain', 'Your account is inactive');
+        cy.waitForDom();
+
         // Invalidate own token.
         const tokenUuid = activeUser.token.split('/')[1];
         cy.doRequest('PUT', `/arvados/v1/api_client_authorizations/${tokenUuid}`, {
@@ -93,8 +100,13 @@ describe('Login tests', function() {
             })
         }, null, activeUser.token, true);
         // Should log the user out.
-        cy.visit('/');
-        cy.get('div#root').should('contain', 'Please log in');
+
+        cy.getAll('@testProject1').then(([testProject1]) => {
+            cy.get('main').contains(testProject1.name).click();
+            cy.get('div#root').should('contain', 'Please log in');
+            // Should retain last visited url when auth is invalidated
+            cy.url().should('contain', `/projects/${testProject1.uuid}`);
+        })
     })
 
     it('logs in successfully with valid admin token', function() {
@@ -131,4 +143,4 @@ describe('Login tests', function() {
         cy.get('button[title="Account Management"]').click();
         cy.get('ul[role=menu] > li[role=menuitem]').contains(randomUser.username);
     })
-})
\ No newline at end of file
+})
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 55290fa36bb4d8a97ecd47b3056f185a4ed83396..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,
+            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('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;
+                })
+                    .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,202 +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]')
-                    .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('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("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("");
 
-        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.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}`);
-                // Should show main logs by default
-                cy.get('[data-cy=process-logs-filter]').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.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");
             });
         });
-    });
 
-    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');
+        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.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',
+
+            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";
+                    });
+                });
+
+                // Fake container
+                const container = getFakeContainer(fakeCrUuid);
+                cy.intercept(
+                    { method: "GET", url: `**/arvados/v1/container/${fakeCrUuid}` },
+                    {
+                        statusCode: 200,
+                        body: { ...container, state: "Queued", priority: 500 },
                     }
-                })
-                .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',
+                );
+
+                // 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");
+            });
+
+            // 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");
+            });
         });
-        // 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');
+    });
+
+    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;
+                });
+            });
+
+            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");
+                });
+            });
         });
 
+        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.",
+            ];
 
-        // 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;
+            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");
+
+                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");
+                });
+            });
+        });
+
+        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]) {
-            cy.goToPath(`/processes/${containerRequest.uuid}`);
-            cy.get('[data-cy=process-runtime-status-retry-warning]')
-                .should('contain', 'Process retried 1 time');
+        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");
+                });
+            });
         });
+    });
 
-        cy.getAll('@containerRequest').then(function([containerRequest]) {
-            containerCount = 3;
-            cy.goToPath(`/processes/${containerRequest.uuid}`);
-            cy.get('[data-cy=process-runtime-status-retry-warning]')
-                .should('contain', 'Process retried 2 times');
+    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,
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_int",
+                    type: "int",
+                },
+                input: {
+                    input_int: 1,
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_long",
+                    type: "long",
+                },
+                input: {
+                    input_long: 1,
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_float",
+                    type: "float",
+                },
+                input: {
+                    input_float: 1.5,
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_double",
+                    type: "double",
+                },
+                input: {
+                    input_double: 1.3,
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_string",
+                    type: "string",
+                },
+                input: {
+                    input_string: "Hello World",
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_file_array",
+                    type: {
+                        items: "File",
+                        type: "array",
+                    },
+                },
+                input: {
+                    input_file_array: [
+                        {
+                            basename: "input2.tar",
+                            class: "File",
+                            location: "keep:00000000000000000000000000000000+02/input2.tar",
+                        },
+                        {
+                            basename: "input3.tar",
+                            class: "File",
+                            location: "keep:00000000000000000000000000000000+03/input3.tar",
+                            secondaryFiles: [
+                                {
+                                    basename: "input3-2.txt",
+                                    class: "File",
+                                    location: "keep:00000000000000000000000000000000+03/input3-2.txt",
+                                },
+                            ],
+                        },
+                        {
+                            $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",
+                        },
+                        {
+                            basename: "11111111111111111111111111111111+03",
+                            class: "Directory",
+                            location: "keep:11111111111111111111111111111111+03",
+                        },
+                        {
+                            $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",
+                    },
+                },
+                input: {
+                    input_long_array: [
+                        10,
+                        20,
+                        {
+                            $import: "import_path",
+                        },
+                    ],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_float_array",
+                    type: {
+                        items: "float",
+                        type: "array",
+                    },
+                },
+                input: {
+                    input_float_array: [
+                        10.2,
+                        10.4,
+                        10.6,
+                        {
+                            $import: "import_path",
+                        },
+                    ],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_double_array",
+                    type: {
+                        items: "double",
+                        type: "array",
+                    },
+                },
+                input: {
+                    input_double_array: [
+                        20.1,
+                        20.2,
+                        20.3,
+                        {
+                            $import: "import_path",
+                        },
+                    ],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_string_array",
+                    type: {
+                        items: "string",
+                        type: "array",
+                    },
+                },
+                input: {
+                    input_string_array: [
+                        "Hello",
+                        "World",
+                        "!",
+                        {
+                            $import: "import_path",
+                        },
+                    ],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_bool_include",
+                    type: "boolean",
+                },
+                input: {
+                    input_bool_include: {
+                        $include: "include_path",
+                    },
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_int_include",
+                    type: "int",
+                },
+                input: {
+                    input_int_include: {
+                        $include: "include_path",
+                    },
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_float_include",
+                    type: "float",
+                },
+                input: {
+                    input_float_include: {
+                        $include: "include_path",
+                    },
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_string_include",
+                    type: "string",
+                },
+                input: {
+                    input_string_include: {
+                        $include: "include_path",
+                    },
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_file_include",
+                    type: "File",
+                },
+                input: {
+                    input_file_include: {
+                        $include: "include_path",
+                    },
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_directory_include",
+                    type: "Directory",
+                },
+                input: {
+                    input_directory_include: {
+                        $include: "include_path",
+                    },
+                },
+            },
+            {
+                definition: {
+                    id: "#main/input_file_url",
+                    type: "File",
+                },
+                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",
+                },
+                output: {
+                    output_file: {
+                        basename: "cat.png",
+                        class: "File",
+                        location: "cat.png",
+                    },
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_file_with_secondary",
+                    doc: "Doc Description",
+                    type: "File",
+                },
+                output: {
+                    output_file_with_secondary: {
+                        basename: "main.dat",
+                        class: "File",
+                        location: "main.dat",
+                        secondaryFiles: [
+                            {
+                                basename: "secondary.dat",
+                                class: "File",
+                                location: "secondary.dat",
+                            },
+                            {
+                                basename: "secondary2.dat",
+                                class: "File",
+                                location: "secondary2.dat",
+                            },
+                        ],
+                    },
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_dir",
+                    doc: ["Doc desc 1", "Doc desc 2"],
+                    type: "Directory",
+                },
+                output: {
+                    output_dir: {
+                        basename: "outdir1",
+                        class: "Directory",
+                        location: "outdir1",
+                    },
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_bool",
+                    type: "boolean",
+                },
+                output: {
+                    output_bool: true,
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_int",
+                    type: "int",
+                },
+                output: {
+                    output_int: 1,
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_long",
+                    type: "long",
+                },
+                output: {
+                    output_long: 1,
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_float",
+                    type: "float",
+                },
+                output: {
+                    output_float: 100.5,
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_double",
+                    type: "double",
+                },
+                output: {
+                    output_double: 100.3,
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_string",
+                    type: "string",
+                },
+                output: {
+                    output_string: "Hello output",
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_file_array",
+                    type: {
+                        items: "File",
+                        type: "array",
+                    },
+                },
+                output: {
+                    output_file_array: [
+                        {
+                            basename: "output2.tar",
+                            class: "File",
+                            location: "output2.tar",
+                        },
+                        {
+                            basename: "output3.tar",
+                            class: "File",
+                            location: "output3.tar",
+                        },
+                    ],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_dir_array",
+                    type: {
+                        items: "Directory",
+                        type: "array",
+                    },
+                },
+                output: {
+                    output_dir_array: [
+                        {
+                            basename: "outdir2",
+                            class: "Directory",
+                            location: "outdir2",
+                        },
+                        {
+                            basename: "outdir3",
+                            class: "Directory",
+                            location: "outdir3",
+                        },
+                    ],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_int_array",
+                    type: {
+                        items: "int",
+                        type: "array",
+                    },
+                },
+                output: {
+                    output_int_array: [10, 11, 12],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_long_array",
+                    type: {
+                        items: "long",
+                        type: "array",
+                    },
+                },
+                output: {
+                    output_long_array: [51, 52],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_float_array",
+                    type: {
+                        items: "float",
+                        type: "array",
+                    },
+                },
+                output: {
+                    output_float_array: [100.2, 100.4, 100.6],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_double_array",
+                    type: {
+                        items: "double",
+                        type: "array",
+                    },
+                },
+                output: {
+                    output_double_array: [100.1, 100.2, 100.3],
+                },
+            },
+            {
+                definition: {
+                    id: "#main/output_string_array",
+                    type: {
+                        items: "string",
+                        type: "array",
+                    },
+                },
+                output: {
+                    output_string_array: ["Hello", "Output", "!"],
+                },
+            },
+        ];
+
+        const verifyIOParameter = (name, label, doc, val, collection, multipleRows) => {
+            cy.get("table tr")
+                .contains(name)
+                .parents("tr")
+                .within($mainRow => {
+                    label && cy.contains(label);
+
+                    if (multipleRows) {
+                        cy.get($mainRow).nextUntil('[data-cy="process-io-param"]').as("secondaryRows");
+                        if (val) {
+                            if (Array.isArray(val)) {
+                                val.forEach(v => cy.get("@secondaryRows").contains(v));
+                            } else {
+                                cy.get("@secondaryRows").contains(val);
+                            }
+                        }
+                        if (collection) {
+                            cy.get("@secondaryRows").contains(collection);
+                        }
+                    } else {
+                        if (val) {
+                            if (Array.isArray(val)) {
+                                val.forEach(v => cy.contains(v));
+                            } else {
+                                cy.contains(val);
+                            }
+                        }
+                        if (collection) {
+                            cy.contains(collection);
+                        }
+                    }
+                });
+        };
+
+        const verifyIOParameterImage = (name, url) => {
+            cy.get("table tr")
+                .contains(name)
+                .parents("tr")
+                .within(() => {
+                    cy.get('[alt="Inline Preview"]')
+                        .should("be.visible")
+                        .and($img => {
+                            expect($img[0].naturalWidth).to.be.greaterThan(0);
+                            expect($img[0].src).contains(url);
+                        });
+                });
+        };
+
+        it("displays IO parameters with keep links and previews", function () {
+            // Create output collection for real files
+            cy.createCollection(adminUser.token, {
+                name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+                owner_uuid: activeUser.user.uuid,
+            }).then(testOutputCollection => {
+                cy.loginAs(activeUser);
+
+                cy.goToPath(`/collections/${testOutputCollection.uuid}`);
+
+                cy.get("[data-cy=upload-button]").click();
+
+                cy.fixture("files/cat.png", "base64").then(content => {
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "cat.png");
+                    cy.get("[data-cy=form-submit-btn]").click();
+                    cy.waitForDom().get("[data-cy=form-submit-btn]").should("not.exist");
+                    // Confirm final collection state.
+                    cy.get("[data-cy=collection-files-panel]").contains("cat.png").should("exist");
+                });
+
+                cy.getCollection(activeUser.token, testOutputCollection.uuid).as("testOutputCollection");
+            });
+
+            // Get updated collection pdh
+            cy.getAll("@testOutputCollection").then(([testOutputCollection]) => {
+                // Add output uuid and inputs to container request
+                cy.intercept({ method: "GET", url: "**/arvados/v1/container_requests/*" }, req => {
+                    req.reply(res => {
+                        res.body.output_uuid = testOutputCollection.uuid;
+                        res.body.mounts["/var/lib/cwl/cwl.input.json"] = {
+                            content: testInputs.map(param => param.input).reduce((acc, val) => Object.assign(acc, val), {}),
+                        };
+                        res.body.mounts["/var/lib/cwl/workflow.json"] = {
+                            content: {
+                                $graph: [
+                                    {
+                                        id: "#main",
+                                        inputs: testInputs.map(input => input.definition),
+                                        outputs: testOutputs.map(output => output.definition),
+                                    },
+                                ],
+                            },
+                        };
+                    });
+                });
+
+                // Stub fake output collection
+                cy.intercept(
+                    { method: "GET", url: `**/arvados/v1/collections/${testOutputCollection.uuid}*` },
+                    {
+                        statusCode: 200,
+                        body: {
+                            uuid: testOutputCollection.uuid,
+                            portable_data_hash: testOutputCollection.portable_data_hash,
+                        },
+                    }
+                );
+
+                // Stub fake output json
+                cy.intercept(
+                    { method: "GET", url: "**/c%3Dzzzzz-4zz18-zzzzzzzzzzzzzzz/cwl.output.json" },
+                    {
+                        statusCode: 200,
+                        body: testOutputs.map(param => param.output).reduce((acc, val) => Object.assign(acc, val), {}),
+                    }
+                );
+
+                // Stub webdav response, points to output json
+                cy.intercept(
+                    { method: "PROPFIND", url: "*" },
+                    {
+                        fixture: "webdav-propfind-outputs.xml",
+                    }
+                );
+            });
+
+            createContainerRequest(activeUser, "test_container_request", "arvados/jobs", ["echo", "hello world"], false, "Committed").as(
+                "containerRequest"
+            );
+
+            cy.getAll("@containerRequest", "@testOutputCollection").then(function ([containerRequest, testOutputCollection]) {
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-io-card] h6")
+                    .contains("Inputs")
+                    .parents("[data-cy=process-io-card]")
+                    .within(() => {
+                        verifyIOParameter("input_file", null, "Label Description", "input1.tar", "00000000000000000000000000000000+01");
+                        verifyIOParameter("input_file", null, "Label Description", "input1-2.txt", undefined, true);
+                        verifyIOParameter("input_file", null, "Label Description", "input1-3.txt", undefined, true);
+                        verifyIOParameter("input_file", null, "Label Description", "input1-4.txt", undefined, true);
+                        verifyIOParameter("input_dir", null, "Doc Description", "/", "11111111111111111111111111111111+01");
+                        verifyIOParameter("input_bool", null, "Doc desc 1, Doc desc 2", "true");
+                        verifyIOParameter("input_int", null, null, "1");
+                        verifyIOParameter("input_long", null, null, "1");
+                        verifyIOParameter("input_float", null, null, "1.5");
+                        verifyIOParameter("input_double", null, null, "1.3");
+                        verifyIOParameter("input_string", null, null, "Hello World");
+                        verifyIOParameter("input_file_array", null, null, "input2.tar", "00000000000000000000000000000000+02");
+                        verifyIOParameter("input_file_array", null, null, "input3.tar", undefined, true);
+                        verifyIOParameter("input_file_array", null, null, "input3-2.txt", undefined, true);
+                        verifyIOParameter("input_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({ 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/";
+
+            cy.loginAs(activeUser);
+
+            // Add output uuid and inputs to container request
+            cy.intercept({ method: "GET", url: "**/arvados/v1/container_requests/*" }, req => {
+                req.reply(res => {
+                    res.body.output_uuid = fakeOutputUUID;
+                    res.body.mounts["/var/lib/cwl/cwl.input.json"] = {
+                        content: {},
+                    };
+                    res.body.mounts["/var/lib/cwl/workflow.json"] = {
+                        content: {
+                            $graph: [
+                                {
+                                    id: "#main",
+                                    inputs: testInputs.map(input => input.definition),
+                                    outputs: testOutputs.map(output => output.definition),
+                                },
+                            ],
+                        },
+                    };
+                });
+            });
+
+            // Stub fake output collection
+            cy.intercept(
+                { method: "GET", url: `**/arvados/v1/collections/${fakeOutputUUID}*` },
+                {
+                    statusCode: 200,
+                    body: {
+                        uuid: fakeOutputUUID,
+                        portable_data_hash: fakeOutputPDH,
+                    },
+                }
+            );
+
+            // Stub fake output json
+            cy.intercept(
+                { method: "GET", url: `**/c%3D${fakeOutputUUID}/cwl.output.json` },
+                {
+                    statusCode: 200,
+                    body: {},
+                }
+            );
+
+            cy.readFile("cypress/fixtures/webdav-propfind-outputs.xml").then(data => {
+                // Stub webdav response, points to output json
+                cy.intercept(
+                    { method: "PROPFIND", url: "*" },
+                    {
+                        statusCode: 200,
+                        body: data.replace(/zzzzz-4zz18-zzzzzzzzzzzzzzz/g, fakeOutputUUID),
+                    }
+                );
+            });
+
+            createContainerRequest(activeUser, "test_container_request", "arvados/jobs", ["echo", "hello world"], false, "Committed").as(
+                "containerRequest"
+            );
+
+            cy.getAll("@containerRequest").then(function ([containerRequest]) {
+                cy.goToPath(`/processes/${containerRequest.uuid}`);
+                cy.get("[data-cy=process-io-card] h6")
+                    .contains("Inputs")
+                    .parents("[data-cy=process-io-card]")
+                    .within(() => {
+                        cy.wait(2000);
+                        cy.waitForDom();
+                        cy.get("tbody tr").each(item => {
+                            cy.wrap(item).contains("No value");
+                        });
+                    });
+                cy.get("[data-cy=process-io-card] h6")
+                    .contains("Outputs")
+                    .parents("[data-cy=process-io-card]")
+                    .within(() => {
+                        cy.get("tbody tr").each(item => {
+                            cy.wrap(item).contains("No value");
+                        });
+                    });
+            });
         });
     });
 });
index 9c5e791cda64c849fb6ca15c4cc1d1ec39141982..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 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("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")
+            .within(() => {
+                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-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({ 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, {
+                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);
+                });
+        };
+
+        // Edit description
+        editProjectDescription(projName, "Test description");
+
+        // Check description is set
+        verifyProjectDescription(projName, "<p>Test description</p>");
+
+        // Clear description
+        editProjectDescription(projName, "{selectall}{backspace}");
+
+        // Check description is null
+        verifyProjectDescription(projName, null);
+
+        // Set description to contain whitespace
+        editProjectDescription(projName, "{selectall}{backspace}    x");
+        editProjectDescription(projName, "{backspace}");
+
+        // Check description is null
+        verifyProjectDescription(projName, null);
+    });
+
+    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('navigates to the parent project after trashing the one being displayed', 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: `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');
+            name: name,
+            group_class: "project",
         });
-        cy.getAll('@testRootProject', '@testSubProject').then(function([testRootProject, testSubProject]) {
+        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")
+            .within(() => {
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(name);
+                });
+                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")
+            .within(() => {
+                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-dialog]").should("not.exist");
+    });
+
+    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]) {
             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('navigates to the root project after trashing the parent of the one being displayed', 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)}`;
+
+        [fooProjectNameA, fooProjectNameB, barProjectNameA].forEach(projName => {
+            cy.createGroup(activeUser.token, {
+                name: projName,
+                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=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");
+
+        // 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");
+    });
+
+    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("[data-cy=context-menu]").contains("API Details").click();
+
+                    cy.get("[role=tablist]").contains("METADATA").click();
+
+                    cy.get("td").contains(uuid).should("exist");
+
+                    cy.get("td").contains(activeUser.user.uuid).should("exist");
+                });
+        });
+    });
+
+    describe("Frozen projects", () => {
+        beforeEach(() => {
+            cy.createGroup(activeUser.token, {
+                name: `Main project ${Math.floor(Math.random() * 999999)}`,
+                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");
+                });
+
+            cy.get("@mainProject").then(mainProject => {
+                cy.createGroup(adminUser.token, {
+                    name: `Sub project ${Math.floor(Math.random() * 999999)}`,
+                    group_class: "project",
+                    owner_uuid: mainProject.uuid,
+                }).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");
+            });
+        });
+
+        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=context-menu]").contains("Freeze").click();
+
+                cy.get("[data-cy=project-panel]").contains(mainProject.name).rightclick();
+
+                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]) => {
+                cy.loginAs(activeUser);
+
+                cy.get("[data-cy=project-panel]").contains(mainProject.name).rightclick();
+
+                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(mainCollection.name).rightclick();
+
+                cy.get("[data-cy=context-menu]").contains("Move to trash").should("not.exist");
+            });
+        });
+
+        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("main").contains(adminProject.name).rightclick();
+
+                cy.get("[data-cy=context-menu]").contains("Freeze").should("not.exist");
+            });
+        });
+
+        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("[data-cy=context-menu]").contains("Freeze").click();
 
-                    cy.get('main').contains(projectName).rightclick();
+                cy.wait(1000);
 
-                    cy.get('[data-cy=context-menu]').contains('API Details').click();
+                cy.get("main").contains(adminProject.name).rightclick();
 
-                    cy.get('[role=tablist]').contains('METADATA').click();
+                cy.get("[data-cy=context-menu]").contains("Unfreeze").click();
 
-                    cy.get('td').contains(uuid).should('exist');
+                cy.get("main").contains(adminProject.name).rightclick();
 
-                    cy.get('td').contains(activeUser.user.uuid).should('exist');
+                cy.get("[data-cy=context-menu]").contains("Freeze").should("exist");
+            });
+        });
+    });
+
+    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")
+            .within(() => {
+                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-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", () => {
+        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.intercept({ method: "GET", url: "**/arvados/v1/groups/*/contents*" }).as("filteredQuery");
+        [
+            {
+                name: "Name",
+                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,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,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,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,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);
+            });
+            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 f01a8911060ffd22e6545f8a3f0ffca137a4e190..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]')
-            .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 sudo{enter}');
-                  })
+                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('[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=snackbar]').contains('Permission updated');
-        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=snackbar]').contains('Permission updated');
-        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=snackbar]').contains('Permission updated');
-        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();
         });
-        cy.get('[data-cy=snackbar]').contains('Permission updated');
 
         // 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 e98000fc71403462a2f67ffbb0008c804da5c4b0..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,361 +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) => {
+    (subject, file, fileName, binaryMode = true) => {
         cy.window().then(window => {
-            const blob = b64toBlob(file, '', 512);
+            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);
@@ -417,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 b93ebd50ebca225056b779796acdbd921218cfde..f529b796d104f408ec99ed31b74a39eb2069685e 100644 (file)
@@ -32,3 +32,6 @@ RUN cd /usr/src/arvados && \
     go run ./cmd/arvados-server install -type test && cd .. && \
     rm -rf arvados && \
     apt-get clean
+
+RUN git config --global --add safe.directory /usr/src/arvados && \
+    git config --global --add safe.directory /usr/src/workbench2
\ No newline at end of file
index 9e663ca6ac5a440946e91f1f3a50ce030db355e0..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",
-    "moment": "2.29.1",
+    "mime": "^3.0.0",
+    "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",
     "set-value": "2.0.1",
     "shell-escape": "^0.2.0",
     "sinon": "7.3",
-    "tslint": "5.20.0",
-    "tslint-etc": "1.6.0",
+    "tippy.js": "^6.3.7",
     "unionize": "2.1.2",
     "uuid": "3.3.2"
   },
@@ -90,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/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 2954d70493b31676663cd00586583f4966f06d0a..eff998ae5ea45cff369d753d249acb8c6a510684 100644 (file)
@@ -2,9 +2,10 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import Axios from "axios";
+import Axios from 'axios';
 
-export const WORKBENCH_CONFIG_URL = process.env.REACT_APP_ARVADOS_CONFIG_URL || "/config.json";
+export const WORKBENCH_CONFIG_URL =
+    process.env.REACT_APP_ARVADOS_CONFIG_URL || '/config.json';
 
 interface WorkbenchConfig {
     API_HOST: string;
@@ -13,6 +14,10 @@ interface WorkbenchConfig {
 }
 
 export interface ClusterConfigJSON {
+    API: {
+        UnfreezeProjectRequiresAdmin: boolean
+        MaxItemsPerResponse: number
+    },
     ClusterID: string;
     RemoteClusters: {
         [key: string]: {
@@ -28,26 +33,37 @@ export interface ClusterConfigJSON {
     };
     Services: {
         Controller: {
-            ExternalURL: string
-        }
+            ExternalURL: string;
+        };
         Workbench1: {
-            ExternalURL: string
-        }
+            ExternalURL: string;
+        };
         Workbench2: {
-            ExternalURL: string
-        }
+            ExternalURL: string;
+        };
+        Workbench: {
+            DisableSharingURLsUI: boolean;
+            ArvadosDocsite: string;
+            FileViewersConfigURL: string;
+            WelcomePageHTML: string;
+            InactivePageHTML: string;
+            SSHHelpPageHTML: string;
+            SSHHelpHostSuffix: string;
+            SiteName: string;
+            IdleTimeout: string;
+        };
         Websocket: {
-            ExternalURL: string
-        }
+            ExternalURL: string;
+        };
         WebDAV: {
-            ExternalURL: string
-        },
+            ExternalURL: string;
+        };
         WebDAVDownload: {
-            ExternalURL: string
-        },
+            ExternalURL: string;
+        };
         WebShell: {
-            ExternalURL: string
-        }
+            ExternalURL: string;
+        };
     };
     Workbench: {
         DisableSharingURLsUI: boolean;
@@ -59,45 +75,51 @@ export interface ClusterConfigJSON {
         SSHHelpHostSuffix: string;
         SiteName: string;
         IdleTimeout: string;
+        BannerUUID: string;
+        UserProfileFormFields: {};
+        UserProfileFormMessage: string;
     };
     Login: {
         LoginCluster: string;
         Google: {
             Enable: boolean;
-        }
+        };
         LDAP: {
             Enable: boolean;
-        }
+        };
         OpenIDConnect: {
             Enable: boolean;
-        }
+        };
         PAM: {
             Enable: boolean;
-        }
+        };
         SSO: {
             Enable: boolean;
-        }
+        };
         Test: {
             Enable: boolean;
-        }
+        };
     };
     Collections: {
         ForwardSlashNameSubstitution: string;
         ManagedProperties?: {
             [key: string]: {
-                Function: string,
-                Value: string,
-                Protected?: boolean,
-            }
-        },
-        TrustAllContent: boolean
+                Function: string;
+                Value: string;
+                Protected?: boolean;
+            };
+        };
+        TrustAllContent: boolean;
     };
     Volumes: {
         [key: string]: {
             StorageClasses: {
                 [key: string]: boolean;
-            }
-        }
+            };
+        };
+    };
+    Users: {
+        AnonymousUserToken: string;
     };
 }
 
@@ -106,7 +128,7 @@ export class Config {
     keepWebServiceUrl!: string;
     keepWebInlineServiceUrl!: string;
     remoteHosts!: {
-        [key: string]: string
+        [key: string]: string;
     };
     rootUrl!: string;
     uuidPrefix!: string;
@@ -129,8 +151,10 @@ export const buildConfig = (clusterConfig: ClusterConfigJSON): Config => {
     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.keepWebServiceUrl =
+        clusterConfigJSON.Services.WebDAVDownload.ExternalURL;
+    config.keepWebInlineServiceUrl =
+        clusterConfigJSON.Services.WebDAV.ExternalURL;
     config.loginCluster = clusterConfigJSON.Login.LoginCluster;
     config.clusterConfig = clusterConfigJSON;
     config.apiRevision = 0;
@@ -141,8 +165,8 @@ export const buildConfig = (clusterConfig: ClusterConfigJSON): 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 => {
+    Object.keys(volumes).forEach((v) => {
+        Object.keys(volumes[v].StorageClasses || {}).forEach((sc) => {
             if (volumes[v].StorageClasses[sc]) {
                 classes.add(sc);
             }
@@ -156,12 +180,16 @@ const getApiRevision = async (apiUrl: string) => {
         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.");
+        console.warn(
+            'Unable to get API Revision number, defaulting to zero. Some features may not work properly.'
+        );
         return 0;
     }
 };
 
-const removeTrailingSlashes = (config: ClusterConfigJSON): ClusterConfigJSON => {
+const removeTrailingSlashes = (
+    config: ClusterConfigJSON
+): ClusterConfigJSON => {
     const svcs: any = {};
     Object.keys(config.Services).forEach((s) => {
         svcs[s] = config.Services[s];
@@ -173,39 +201,53 @@ const removeTrailingSlashes = (config: ClusterConfigJSON): ClusterConfigJSON =>
 };
 
 export const fetchConfig = () => {
-    return Axios
-        .get<WorkbenchConfig>(WORKBENCH_CONFIG_URL + "?nocache=" + (new Date()).getTime())
-        .then(response => response.data)
+    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.`);
+            console.warn(
+                `There was an exception getting the Workbench config file at ${WORKBENCH_CONFIG_URL}. Using defaults instead.`
+            );
             return Promise.resolve(getDefaultConfig());
         })
-        .then(workbenchConfig => {
+        .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.`);
+                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(/\/+$/, ''));
+            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}`);
+                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");
+                    warnLocalConfig('FILE_VIEWERS_CONFIG_URL');
                     fileViewerConfigUrl = workbenchConfig.FILE_VIEWERS_CONFIG_URL;
-                }
-                else {
-                    fileViewerConfigUrl = config.clusterConfig.Workbench.FileViewersConfigURL || "/file-viewers-example.json";
+                } 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.`)
+                    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);
 
@@ -215,37 +257,62 @@ remove the entire ${varName} entry from ${WORKBENCH_CONFIG_URL}`);
 };
 
 // Maps remote cluster hosts and removes the default RemoteCluster entry
-export const mapRemoteHosts = (clusterConfigJSON: ClusterConfigJSON, config: Config) => {
+export const mapRemoteHosts = (
+    clusterConfigJSON: ClusterConfigJSON,
+    config: Config
+) => {
     config.remoteHosts = {};
-    Object.keys(clusterConfigJSON.RemoteClusters).forEach(k => { config.remoteHosts[k] = clusterConfigJSON.RemoteClusters[k].Host; });
-    delete config.remoteHosts["*"];
+    Object.keys(clusterConfigJSON.RemoteClusters).forEach((k) => {
+        config.remoteHosts[k] = clusterConfigJSON.RemoteClusters[k].Host;
+    });
+    delete config.remoteHosts['*'];
 };
 
-export const mockClusterConfigJSON = (config: Partial<ClusterConfigJSON>): ClusterConfigJSON => ({
-    ClusterID: "",
+export const mockClusterConfigJSON = (
+    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: "" },
+        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",
+        ArvadosDocsite: '',
+        FileViewersConfigURL: '',
+        WelcomePageHTML: '',
+        InactivePageHTML: '',
+        SSHHelpPageHTML: '',
+        SSHHelpHostSuffix: '',
+        SiteName: '',
+        IdleTimeout: '0s',
+        BannerUUID: "",
+        UserProfileFormFields: {},
+        UserProfileFormMessage: '',
     },
     Login: {
-        LoginCluster: "",
+        LoginCluster: '',
         Google: {
             Enable: false,
         },
@@ -266,40 +333,44 @@ export const mockClusterConfigJSON = (config: Partial<ClusterConfigJSON>): Clust
         },
     },
     Collections: {
-        ForwardSlashNameSubstitution: "",
+        ForwardSlashNameSubstitution: '',
         TrustAllContent: false,
     },
     Volumes: {},
-    ...config
+    Users: {
+        AnonymousUserToken: ""
+    },
+    ...config,
 });
 
 export const mockConfig = (config: Partial<Config>): Config => ({
-    baseUrl: "",
-    keepWebServiceUrl: "",
-    keepWebInlineServiceUrl: "",
+    baseUrl: '',
+    keepWebServiceUrl: '',
+    keepWebInlineServiceUrl: '',
     remoteHosts: {},
-    rootUrl: "",
-    uuidPrefix: "",
-    websocketUrl: "",
-    workbenchUrl: "",
-    workbench2Url: "",
-    vocabularyUrl: "",
-    fileViewersConfigUrl: "",
-    loginCluster: "",
+    rootUrl: '',
+    uuidPrefix: '',
+    websocketUrl: '',
+    workbenchUrl: '',
+    workbench2Url: '',
+    vocabularyUrl: '',
+    fileViewersConfigUrl: '',
+    loginCluster: '',
     clusterConfig: mockClusterConfigJSON({}),
     apiRevision: 0,
-    ...config
+    ...config,
 });
 
 const getDefaultConfig = (): WorkbenchConfig => {
-    let apiHost = "";
+    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.`);
+    } 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,
@@ -308,9 +379,11 @@ const getDefaultConfig = (): WorkbenchConfig => {
     };
 };
 
-export const ARVADOS_API_PATH = "arvados/v1";
-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()}`;
-export const getVocabularyURL = (apiHost: string) => `https://${apiHost}/${VOCABULARY_PATH}?nocache=${(new Date()).getTime()}`;
+export const ARVADOS_API_PATH = 'arvados/v1';
+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()}`;
+export const getVocabularyURL = (apiHost: string) =>
+    `https://${apiHost}/${VOCABULARY_PATH}?nocache=${new Date().getTime()}`;
index fc89a4ae216b2790385570f293b74c9ebd3145ba..135204a066da6ba0734139061bc0378fdbc94e4e 100644 (file)
@@ -9,7 +9,6 @@ import grey from '@material-ui/core/colors/grey';
 import green from '@material-ui/core/colors/green';
 import yellow from '@material-ui/core/colors/yellow';
 import red from '@material-ui/core/colors/red';
-import teal from '@material-ui/core/colors/teal';
 
 export interface ArvadosThemeOptions extends ThemeOptions {
     customs: any;
@@ -23,21 +22,36 @@ export interface ArvadosTheme extends Theme {
 
 interface Colors {
     green700: string;
+    green800: string;
     yellow100: string;
     yellow700: string;
     yellow900: string;
     red100: string;
     red900: string;
     blue500: string;
+    blue700: string;
     grey500: string;
+    grey600: string;
+    grey700: string;
+    grey900: string;
     purple: string;
-    orange: string;
+    orange: string; 
+    greyL: string;
+    greyD: string;
+    darkblue: string;
 }
 
-const arvadosPurple = '#361336';
+/**
+* arvadosGreyLight is the hex equivalent of rgba(0,0,0,0.87) on #fafafa background and arvadosGreyDark is the hex equivalent of rgab(0,0,0,0.54) on #fafafa background  
+*/
+
+const arvadosDarkBlue = '#052a3c';
+const arvadosGreyLight = '#737373'; 
+const arvadosGreyDark = '#212121'; 
 const grey500 = grey["500"];
 const grey600 = grey["600"];
 const grey700 = grey["700"];
+const grey800 = grey["800"];
 const grey900 = grey["900"];
 
 export const themeOptions: ArvadosThemeOptions = {
@@ -47,15 +61,23 @@ export const themeOptions: ArvadosThemeOptions = {
     customs: {
         colors: {
             green700: green["700"],
+            green800: green["800"],
             yellow100: yellow["100"],
             yellow700: yellow["700"],
             yellow900: yellow["900"],
             red100: red["100"],
             red900: red['900'],
             blue500: blue['500'],
+            blue700: blue['700'],
             grey500: grey500,
-            purple: arvadosPurple,
+            grey600: grey600,
+            grey700: grey700,
+            grey800: grey800,
+            grey900: grey900,
+            darkblue: arvadosDarkBlue,
             orange: '#f0ad4e',
+            greyL: arvadosGreyLight,
+            greyD: arvadosGreyDark,
         }
     },
     overrides: {
@@ -66,7 +88,7 @@ export const themeOptions: ArvadosThemeOptions = {
         },
         MuiAppBar: {
             colorPrimary: {
-                backgroundColor: arvadosPurple
+                backgroundColor: arvadosDarkBlue
             }
         },
         MuiTabs: {
@@ -74,14 +96,13 @@ export const themeOptions: ArvadosThemeOptions = {
                 color: grey600
             },
             indicator: {
-                backgroundColor: arvadosPurple
+                backgroundColor: arvadosDarkBlue
             }
         },
         MuiTab: {
             root: {
                 '&$selected': {
                     fontWeight: 700,
-                    color: arvadosPurple
                 }
             }
         },
@@ -97,7 +118,7 @@ export const themeOptions: ArvadosThemeOptions = {
         },
         MuiListItemIcon: {
             root: {
-                fontSize: '1.25rem'
+                fontSize: '1.25rem',
             }
         },
         MuiCardHeader: {
@@ -106,7 +127,7 @@ export const themeOptions: ArvadosThemeOptions = {
                 alignItems: 'center'
             },
             title: {
-                color: grey700,
+                color: arvadosGreyDark, 
                 fontSize: '1.25rem'
             }
         },
@@ -143,7 +164,7 @@ export const themeOptions: ArvadosThemeOptions = {
             },
             underline: {
                 '&:after': {
-                    borderBottomColor: arvadosPurple
+                    borderBottomColor: arvadosDarkBlue
                 },
                 '&:hover:not($disabled):not($focused):not($error):before': {
                     borderBottom: '1px solid inherit'
@@ -155,7 +176,7 @@ export const themeOptions: ArvadosThemeOptions = {
                 fontSize: '0.875rem',
                 "&$focused": {
                     "&$focused:not($error)": {
-                        color: arvadosPurple
+                        color: arvadosDarkBlue
                     }
                 }
             }
@@ -163,7 +184,7 @@ export const themeOptions: ArvadosThemeOptions = {
         MuiStepIcon: {
             root: {
                 '&$active': {
-                    color: arvadosPurple
+                    color: arvadosDarkBlue
                 },
                 '&$completed': {
                     color: 'inherited'
@@ -178,11 +199,12 @@ export const themeOptions: ArvadosThemeOptions = {
     },
     palette: {
         primary: {
-            main: teal.A700,
-            dark: teal.A400,
+            main: '#017ead',
+            dark: '#015272',
+            light: '#82cffd',
             contrastText: '#fff'
         }
     },
 };
 
-export const CustomTheme = createMuiTheme(themeOptions);
\ No newline at end of file
+export const CustomTheme = createMuiTheme(themeOptions);
index 83177e2207da3bde728dba28b1c6f48700ff20e5..048779727e4865724e1bdcd67e862d3012e6a361 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { formatUploadSpeed } from "./formatters";
+import { formatUploadSpeed, formatContainerCost } from "./formatters";
 
 describe('formatUploadSpeed', () => {
     it('should show speed less than 1MB/s', () => {
@@ -25,5 +25,21 @@ describe('formatUploadSpeed', () => {
 
         // then
         expect(result).toBe('5.23 MB/s');
-    }); 
-});
\ No newline at end of file
+    });
+});
+
+describe('formatContainerCost', () => {
+    it('should correctly round to tenth of a cent', () => {
+        expect(formatContainerCost(0.0)).toBe('$0');
+        expect(formatContainerCost(0.125)).toBe('$0.125');
+        expect(formatContainerCost(0.1254)).toBe('$0.125');
+        expect(formatContainerCost(0.1255)).toBe('$0.126');
+    });
+
+    it('should round up any smaller value to 0.001', () => {
+        expect(formatContainerCost(0.0)).toBe('$0');
+        expect(formatContainerCost(0.001)).toBe('$0.001');
+        expect(formatContainerCost(0.0001)).toBe('$0.001');
+        expect(formatContainerCost(0.00001)).toBe('$0.001');
+    });
+});
index 6d0a7e491e4e508384ccbad42f66cc2eb3f8c195..a38609a678661c420d5b88a23197540feb859dca 100644 (file)
@@ -2,8 +2,12 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { PropertyValue } from "models/search-bar";
-import { Vocabulary, getTagKeyLabel, getTagValueLabel } from "models/vocabulary";
+import { PropertyValue } from 'models/search-bar';
+import {
+    Vocabulary,
+    getTagKeyLabel,
+    getTagValueLabel,
+} from 'models/vocabulary';
 
 export const formatDate = (isoDate?: string | null, utc: boolean = false) => {
     if (isoDate) {
@@ -11,41 +15,42 @@ export const formatDate = (isoDate?: string | null, utc: boolean = false) => {
         let text: string;
         if (utc) {
             text = date.toUTCString();
-        }
-        else {
+        } else {
             text = date.toLocaleString();
         }
-        return text === 'Invalid Date' ? "(none)" : text;
+        return text === 'Invalid Date' ? '(none)' : text;
     }
-    return "(none)";
+    return '-';
 };
 
 export const formatFileSize = (size?: number | string) => {
-    if (typeof size === "number") {
-        if (size === 0) { return "0 B"; }
+    if (typeof size === 'number') {
+        if (size === 0) {
+            return '0 B';
+        }
 
         for (const { base, unit } of FILE_SIZES) {
             if (size >= base) {
-                return `${(size / base).toFixed()} ${unit}`;
+                return `${(size / base).toFixed(base === 1 ? 0 : 1)} ${unit}`;
             }
         }
     }
-    if ((typeof size === "string" && size === '') || size === undefined) {
-        return '';
+    if ((typeof size === 'string' && size === '') || size === undefined) {
+        return '-';
     }
-    return "0 B";
+    return '0 B';
 };
 
 export const formatTime = (time: number, seconds?: boolean) => {
-    const minutes = Math.floor(time / (1000 * 60) % 60).toFixed(0);
+    const minutes = Math.floor((time / (1000 * 60)) % 60).toFixed(0);
     const hours = Math.floor(time / (1000 * 60 * 60)).toFixed(0);
 
     if (seconds) {
-        const seconds = Math.floor(time / (1000) % 60).toFixed(0);
-        return hours + "h " + minutes + "m " + seconds + "s";
+        const seconds = Math.floor((time / 1000) % 60).toFixed(0);
+        return hours + 'h ' + minutes + 'm ' + seconds + 's';
     }
 
-    return hours + "h " + minutes + "m";
+    return hours + 'h ' + minutes + 'm';
 };
 
 export const getTimeDiff = (endTime: string, startTime: string) => {
@@ -53,14 +58,20 @@ export const getTimeDiff = (endTime: string, startTime: string) => {
 };
 
 export const formatProgress = (loaded: number, total: number) => {
-    const progress = loaded >= 0 && total > 0 ? loaded * 100 / total : 0;
+    const progress = loaded >= 0 && total > 0 ? (loaded * 100) / total : 0;
     return `${progress.toFixed(2)}%`;
 };
 
-export function formatUploadSpeed(prevLoaded: number, loaded: number, prevTime: number, currentTime: number) {
-    const speed = loaded > prevLoaded && currentTime > prevTime
-        ? (loaded - prevLoaded) / (currentTime - prevTime)
-        : 0;
+export function formatUploadSpeed(
+    prevLoaded: number,
+    loaded: number,
+    prevTime: number,
+    currentTime: number
+) {
+    const speed =
+        loaded > prevLoaded && currentTime > prevTime
+            ? (loaded - prevLoaded) / (currentTime - prevTime)
+            : 0;
 
     return `${(speed / 1000).toFixed(2)} MB/s`;
 }
@@ -68,34 +79,53 @@ export function formatUploadSpeed(prevLoaded: number, loaded: number, prevTime:
 const FILE_SIZES = [
     {
         base: 1099511627776,
-        unit: "TB"
+        unit: 'TiB',
     },
     {
         base: 1073741824,
-        unit: "GB"
+        unit: 'GiB',
     },
     {
         base: 1048576,
-        unit: "MB"
+        unit: 'MiB',
     },
     {
         base: 1024,
-        unit: "KB"
+        unit: 'KiB',
     },
     {
         base: 1,
-        unit: "B"
-    }
+        unit: 'B',
+    },
 ];
 
-export const formatPropertyValue = (pv: PropertyValue, vocabulary?: Vocabulary) => {
+export const formatPropertyValue = (
+    pv: PropertyValue,
+    vocabulary?: Vocabulary
+) => {
     if (vocabulary && pv.keyID && pv.valueID) {
-        return `${getTagKeyLabel(pv.keyID, vocabulary)}: ${getTagValueLabel(pv.keyID, pv.valueID!, vocabulary)}`;
+        return `${getTagKeyLabel(pv.keyID, vocabulary)}: ${getTagValueLabel(
+            pv.keyID,
+            pv.valueID!,
+            vocabulary
+        )}`;
     }
     if (pv.key) {
-        return pv.value
-            ? `${pv.key}: ${pv.value}`
-            : pv.key;
+        return pv.value ? `${pv.key}: ${pv.value}` : pv.key;
+    }
+    return '';
+};
+
+export const formatContainerCost = (cost: number): string => {
+    const decimalPlaces = 3;
+
+    const factor = Math.pow(10, decimalPlaces);
+    const rounded = Math.round(cost * factor) / factor;
+    if (cost > 0 && rounded === 0) {
+        // Display min value of 0.001
+        return `$${1 / factor}`;
+    } else {
+        // Otherwise use rounded value to proper decimal places
+        return `$${rounded}`;
     }
-    return "";
 };
diff --git a/src/common/frozen-resources.ts b/src/common/frozen-resources.ts
new file mode 100644 (file)
index 0000000..8d22791
--- /dev/null
@@ -0,0 +1,19 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ProjectResource } from "models/project";
+import { getResource } from "store/resources/resources";
+
+export const resourceIsFrozen = (resource: any, resources): boolean => {
+    let isFrozen: boolean = !!resource.frozenByUuid;
+    let ownerUuid: string | undefined = resource?.ownerUuid;
+
+    while(!isFrozen && !!ownerUuid && ownerUuid.indexOf('000000000000000') === -1) {
+        const parentResource: ProjectResource | undefined = getResource<ProjectResource>(ownerUuid)(resources);
+        isFrozen = !!parentResource?.frozenByUuid;
+        ownerUuid = parentResource?.ownerUuid;
+    }
+
+    return isFrozen;
+}
\ No newline at end of file
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]));
     }
 };
 
index 080916c55c3798d1e7e60507edff42503a9b95b0..e0504ebf8cc628898da3de5965d25ff26d93801e 100644 (file)
@@ -6,6 +6,7 @@ class ServicesProvider {
 
     private static instance: ServicesProvider;
 
+    private store;
     private services;
 
     private constructor() {}
@@ -30,6 +31,20 @@ class ServicesProvider {
         }
         return this.services;
     }
+
+    public setStore(newStore): void {
+        if (!this.store) {
+            this.store = newStore;
+        }
+    }
+
+    public getStore() {
+        if (!this.store) {
+            throw "Please check if store has been set in the index.ts before the app is initiated"; // eslint-disable-line no-throw-literal
+        }
+
+        return this.store;
+    }
 }
 
 export default ServicesProvider.getInstance();
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 2ab106fcd3cb90a8710de2c0c5662cb8b53b78c5..1149c451e3991fc9bc07eb463989b00ed4d17495 100644 (file)
@@ -2,7 +2,6 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { customEncodeURI } from "./url";
 import { WebDAV } from "./webdav";
 
 describe('WebDAV', () => {
@@ -14,34 +13,36 @@ describe('WebDAV', () => {
         const request = await promise;
         expect(open).toHaveBeenCalledWith('PROPFIND', 'http://foo.com/foo');
         expect(setRequestHeader).toHaveBeenCalledWith('Authorization', 'Basic');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
         expect(request).toBeInstanceOf(XMLHttpRequest);
     });
 
     it('allows to modify defaults after instantiation', async () => {
         const { open, load, setRequestHeader, createRequest } = mockCreateRequest();
-        const webdav = new WebDAV(undefined, createRequest);
-        webdav.defaults.baseURL = 'http://foo.com/';
-        webdav.defaults.headers = { Authorization: 'Basic' };
+        const webdav = new WebDAV({ baseURL: 'http://foo.com/' }, createRequest);
+        webdav.setAuthorization('Basic');
         const promise = webdav.propfind('foo');
         load();
         const request = await promise;
         expect(open).toHaveBeenCalledWith('PROPFIND', 'http://foo.com/foo');
         expect(setRequestHeader).toHaveBeenCalledWith('Authorization', 'Basic');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
         expect(request).toBeInstanceOf(XMLHttpRequest);
     });
 
     it('PROPFIND', async () => {
-        const { open, load, createRequest } = mockCreateRequest();
+        const { open, load, setRequestHeader, createRequest } = mockCreateRequest();
         const webdav = new WebDAV(undefined, createRequest);
         const promise = webdav.propfind('foo');
         load();
         const request = await promise;
         expect(open).toHaveBeenCalledWith('PROPFIND', 'foo');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
         expect(request).toBeInstanceOf(XMLHttpRequest);
     });
 
     it('PUT', async () => {
-        const { open, send, load, progress, createRequest } = mockCreateRequest();
+        const { open, send, load, progress, setRequestHeader, createRequest } = mockCreateRequest();
         const webdav = new WebDAV(undefined, createRequest);
         const promise = webdav.put('foo', 'Test data');
         progress();
@@ -49,88 +50,90 @@ describe('WebDAV', () => {
         const request = await promise;
         expect(open).toHaveBeenCalledWith('PUT', 'foo');
         expect(send).toHaveBeenCalledWith('Test data');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
         expect(request).toBeInstanceOf(XMLHttpRequest);
     });
 
     it('COPY', async () => {
         const { open, setRequestHeader, load, createRequest } = mockCreateRequest();
-        const webdav = new WebDAV(undefined, createRequest);
-        webdav.defaults.baseURL = 'http://base';
+        const webdav = new WebDAV({ baseURL: 'http://base' }, createRequest);
         const promise = webdav.copy('foo', 'foo-copy');
         load();
         const request = await promise;
         expect(open).toHaveBeenCalledWith('COPY', 'http://base/foo');
         expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-copy');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
         expect(request).toBeInstanceOf(XMLHttpRequest);
     });
 
     it('COPY - adds baseURL with trailing slash to Destination header', async () => {
         const { open, setRequestHeader, load, createRequest } = mockCreateRequest();
-        const webdav = new WebDAV(undefined, createRequest);
-        webdav.defaults.baseURL = 'http://base';
+        const webdav = new WebDAV({ baseURL: 'http://base' }, createRequest);
         const promise = webdav.copy('foo', 'foo-copy');
         load();
         const request = await promise;
         expect(open).toHaveBeenCalledWith('COPY', 'http://base/foo');
         expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-copy');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
         expect(request).toBeInstanceOf(XMLHttpRequest);
     });
 
     it('COPY - adds baseURL without trailing slash to Destination header', async () => {
         const { open, setRequestHeader, load, createRequest } = mockCreateRequest();
-        const webdav = new WebDAV(undefined, createRequest);
-        webdav.defaults.baseURL = 'http://base';
+        const webdav = new WebDAV({ baseURL: 'http://base' }, createRequest);
         const promise = webdav.copy('foo', 'foo-copy');
         load();
         const request = await promise;
         expect(open).toHaveBeenCalledWith('COPY', 'http://base/foo');
         expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-copy');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
         expect(request).toBeInstanceOf(XMLHttpRequest);
     });
 
     it('MOVE', async () => {
         const { open, setRequestHeader, load, createRequest } = mockCreateRequest();
-        const webdav = new WebDAV(undefined, createRequest);
-        webdav.defaults.baseURL = 'http://base';
+        const webdav = new WebDAV({ baseURL: 'http://base' }, createRequest);
         const promise = webdav.move('foo', 'foo-moved');
         load();
         const request = await promise;
         expect(open).toHaveBeenCalledWith('MOVE', 'http://base/foo');
         expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-moved');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
         expect(request).toBeInstanceOf(XMLHttpRequest);
     });
 
     it('MOVE - adds baseURL with trailing slash to Destination header', async () => {
         const { open, setRequestHeader, load, createRequest } = mockCreateRequest();
-        const webdav = new WebDAV(undefined, createRequest);
-        webdav.defaults.baseURL = 'http://base';
+        const webdav = new WebDAV({ baseURL: 'http://base' }, createRequest);
         const promise = webdav.move('foo', 'foo-moved');
         load();
         const request = await promise;
         expect(open).toHaveBeenCalledWith('MOVE', 'http://base/foo');
         expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-moved');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
         expect(request).toBeInstanceOf(XMLHttpRequest);
     });
 
     it('MOVE - adds baseURL without trailing slash to Destination header', async () => {
         const { open, setRequestHeader, load, createRequest } = mockCreateRequest();
-        const webdav = new WebDAV(undefined, createRequest);
-        webdav.defaults.baseURL = 'http://base';
+        const webdav = new WebDAV({ baseURL: 'http://base' }, createRequest);
         const promise = webdav.move('foo', 'foo-moved');
         load();
         const request = await promise;
         expect(open).toHaveBeenCalledWith('MOVE', 'http://base/foo');
         expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-moved');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
         expect(request).toBeInstanceOf(XMLHttpRequest);
     });
 
     it('DELETE', async () => {
-        const { open, load, createRequest } = mockCreateRequest();
+        const { open, load, setRequestHeader, createRequest } = mockCreateRequest();
         const webdav = new WebDAV(undefined, createRequest);
         const promise = webdav.delete('foo');
         load();
         const request = await promise;
         expect(open).toHaveBeenCalledWith('DELETE', 'foo');
+        expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
         expect(request).toBeInstanceOf(XMLHttpRequest);
     });
 });
index 93ec21cb724f26d2ede4cf9be4b211defaf85b9c..1f3da0d6148f4bb45c2406c8e544f35f125aa990 100644 (file)
@@ -6,17 +6,29 @@ import { customEncodeURI } from "./url";
 
 export class WebDAV {
 
-    defaults: WebDAVDefaults = {
+    private defaults: WebDAVDefaults = {
         baseURL: '',
-        headers: {},
+        headers: {
+            'Cache-Control': 'no-cache'
+        },
     };
 
     constructor(config?: Partial<WebDAVDefaults>, private createRequest = () => new XMLHttpRequest()) {
         if (config) {
-            this.defaults = { ...this.defaults, ...config };
+            this.defaults = {
+                ...this.defaults,
+                ...config,
+                headers: {
+                    ...this.defaults.headers,
+                    ...config.headers
+                },
+            };
         }
     }
 
+    getBaseUrl = (): string => this.defaults.baseURL;
+    setAuthorization = (token?) => this.defaults.headers.Authorization = token;
+
     propfind = (url: string, config: WebDAVRequestConfig = {}) =>
         this.request({
             ...config, url,
@@ -30,6 +42,12 @@ export class WebDAV {
             data
         })
 
+    get = (url: string, config: WebDAVRequestConfig = {}) =>
+        this.request({
+            ...config, url,
+            method: 'GET'
+        })
+
     upload = (url: string, files: File[], config: WebDAVRequestConfig = {}) => {
         return Promise.all(
             files.map(file => this.request({
@@ -76,7 +94,7 @@ export class WebDAV {
             this.defaults.baseURL = this.defaults.baseURL.replace(/\/+$/, '');
             r.open(config.method,
                 `${this.defaults.baseURL
-                    ? this.defaults.baseURL+'/'
+                    ? this.defaults.baseURL + '/'
                     : ''}${customEncodeURI(config.url)}`);
 
             const headers = { ...this.defaults.headers, ...config.headers };
@@ -88,7 +106,7 @@ export class WebDAV {
                 Object.assign(window, { cancelTokens: {} });
             }
 
-            (window as any).cancelTokens[config.url] = () => { 
+            (window as any).cancelTokens[config.url] = () => {
                 resolve(r);
                 r.abort();
             }
@@ -138,4 +156,4 @@ interface RequestConfig {
     headers?: { [key: string]: string };
     data?: any;
     onUploadProgress?: (event: ProgressEvent) => void;
-}
\ No newline at end of file
+}
index 0044807b8a9ddb3e2abeb43a546dad5a2ada4d83..17d85e856c3cb53901f468b31ccd5bf4e93c6d40 100644 (file)
@@ -8,7 +8,7 @@ import {
     Chip as MuiChip,
     Popper as MuiPopper,
     Paper as MuiPaper,
-    FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List, FormHelperText
+    FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List, FormHelperText, Tooltip
 } from '@material-ui/core';
 import { PopperProps } from '@material-ui/core/Popper';
 import { WithStyles } from '@material-ui/core/styles';
@@ -30,6 +30,7 @@ export interface AutocompleteProps<Item, Suggestion> {
     onDelete?: (item: Item, index: number) => void;
     onSelect?: (suggestion: Suggestion) => void;
     renderChipValue?: (item: Item) => string;
+    renderChipTooltip?: (item: Item) => string;
     renderSuggestion?: (suggestion: Suggestion) => React.ReactNode;
 }
 
@@ -171,11 +172,22 @@ export class Autocomplete<Value, Suggestion> extends React.Component<Autocomplet
         }
 
         return items.map(
-            (item, index) =>
-                <Chip
-                    label={this.renderChipValue(item)}
-                    key={index}
-                    onDelete={onDelete && !this.props.disabled ? (() =>  onDelete(item, index)) : undefined} />
+            (item, index) => {
+                const tooltip = this.props.renderChipTooltip ? this.props.renderChipTooltip(item) : '';
+                if (tooltip && tooltip.length) {
+                    return <span key={index}>
+                        <Tooltip title={tooltip}>
+                        <Chip
+                            label={this.renderChipValue(item)}
+                            key={index}
+                            onDelete={onDelete && !this.props.disabled ? (() =>  onDelete(item, index)) : undefined} />
+                    </Tooltip></span>
+                } else {
+                    return <span key={index}><Chip
+                        label={this.renderChipValue(item)}
+                        onDelete={onDelete && !this.props.disabled ? (() =>  onDelete(item, index)) : undefined} /></span>
+                }
+            }
         );
     }
 
index fe3d2ab09983151d06cd2bfee697f2caa42667d6..f17ce3936dc7d0c5604047322df99daf107423ba 100644 (file)
@@ -3,48 +3,79 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import React from "react";
-import { configure, shallow } from "enzyme";
+import { configure, mount } from "enzyme";
 
 import Adapter from "enzyme-adapter-react-16";
 import { Breadcrumbs } from "./breadcrumbs";
-import { Button } from "@material-ui/core";
+import { Button, MuiThemeProvider } from "@material-ui/core";
 import ChevronRightIcon from '@material-ui/icons/ChevronRight';
+import { CustomTheme } from 'common/custom-theme';
+import { Provider } from "react-redux";
+import { combineReducers, createStore } from "redux";
 
 configure({ adapter: new Adapter() });
 
 describe("<Breadcrumbs />", () => {
 
     let onClick: () => void;
-
+    let resources = {};
+    let store;
     beforeEach(() => {
         onClick = jest.fn();
+        const initialAuthState = {
+            config: {
+                clusterConfig: {
+                    Collections: {
+                        ForwardSlashNameSubstitution: "/"
+                    }
+                }
+            }
+        }
+        store = createStore(combineReducers({
+            auth: (state: any = initialAuthState, action: any) => state,
+        }));
     });
 
     it("renders one item", () => {
         const items = [
-            { label: 'breadcrumb 1' }
+            { label: 'breadcrumb 1', uuid: '1' }
         ];
-        const breadcrumbs = shallow(<Breadcrumbs items={items} onClick={onClick} onContextMenu={jest.fn()} />).dive();
+        const breadcrumbs = mount(
+            <Provider store={store}>
+                <MuiThemeProvider theme={CustomTheme}>
+                    <Breadcrumbs items={items} resources={resources} onClick={onClick} onContextMenu={jest.fn()} />
+                </MuiThemeProvider>
+            </Provider>);
         expect(breadcrumbs.find(Button)).toHaveLength(1);
         expect(breadcrumbs.find(ChevronRightIcon)).toHaveLength(0);
     });
 
     it("renders multiple items", () => {
         const items = [
-            { label: 'breadcrumb 1' },
-            { label: 'breadcrumb 2' }
+            { label: 'breadcrumb 1', uuid: '1' },
+            { label: 'breadcrumb 2', uuid: '2' }
         ];
-        const breadcrumbs = shallow(<Breadcrumbs items={items} onClick={onClick} onContextMenu={jest.fn()} />).dive();
+        const breadcrumbs = mount(
+            <Provider store={store}>
+                <MuiThemeProvider theme={CustomTheme}>
+                    <Breadcrumbs items={items} resources={resources} onClick={onClick} onContextMenu={jest.fn()} />
+                </MuiThemeProvider>
+            </Provider>);
         expect(breadcrumbs.find(Button)).toHaveLength(2);
         expect(breadcrumbs.find(ChevronRightIcon)).toHaveLength(1);
     });
 
     it("calls onClick with clicked item", () => {
         const items = [
-            { label: 'breadcrumb 1' },
-            { label: 'breadcrumb 2' }
+            { label: 'breadcrumb 1', uuid: '1' },
+            { label: 'breadcrumb 2', uuid: '2' }
         ];
-        const breadcrumbs = shallow(<Breadcrumbs items={items} onClick={onClick} onContextMenu={jest.fn()} />).dive();
+        const breadcrumbs = mount(
+            <Provider store={store}>
+                <MuiThemeProvider theme={CustomTheme}>
+                    <Breadcrumbs items={items} resources={resources} onClick={onClick} onContextMenu={jest.fn()} />
+                </MuiThemeProvider>
+            </Provider>);
         breadcrumbs.find(Button).at(1).simulate('click');
         expect(onClick).toBeCalledWith(items[1]);
     });
index 3d668856ecd5d1c20e4faebc3b51d54466045277..baf84d1da253fd2ac266eac6ba7b2215d004a1a2 100644 (file)
@@ -7,40 +7,64 @@ import { Button, Grid, StyleRulesCallback, WithStyles, Typography, Tooltip } fro
 import ChevronRightIcon from '@material-ui/icons/ChevronRight';
 import { withStyles } from '@material-ui/core';
 import { IllegalNamingWarning } from '../warning/warning';
-import { IconType } from 'components/icon/icon';
+import { IconType, FreezeIcon } from 'components/icon/icon';
 import grey from '@material-ui/core/colors/grey';
+import { ResourcesState } from 'store/resources/resources';
+import classNames from 'classnames';
+import { ArvadosTheme } from 'common/custom-theme';
 
 export interface Breadcrumb {
     label: string;
     icon?: IconType;
+    uuid: string;
 }
 
-type CssRules = "item" | "currentItem" | "label" | "icon";
+type CssRules = "item" | "chevron" | "label" | "buttonLabel" | "icon" | "frozenIcon";
 
-const styles: StyleRulesCallback<CssRules> = theme => ({
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     item: {
-        opacity: 0.6
+        borderRadius: '16px',
+        height: '32px',
+        minWidth: '36px',
+        color: theme.customs.colors.grey700,
+        '&.parentItem': {
+            color: `${theme.palette.primary.main}`,
+        },
     },
-    currentItem: {
-        opacity: 1
+    chevron: {
+        color: grey["600"],
     },
     label: {
-        textTransform: "none"
+        textTransform: "none",
+        paddingRight: '3px',
+        paddingLeft: '3px',
+        lineHeight: '1.4',
+    },
+    buttonLabel: {
+        overflow: 'hidden',
+        justifyContent: 'flex-start',
     },
     icon: {
         fontSize: 20,
-        color: grey["600"]
+        color: grey["600"],
+        marginRight: '5px',
+    },
+    frozenIcon: {
+        fontSize: 20,
+        color: grey["600"],
+        marginLeft: '3px',
     },
 });
 
 export interface BreadcrumbsProps {
     items: Breadcrumb[];
+    resources: ResourcesState;
     onClick: (breadcrumb: Breadcrumb) => void;
     onContextMenu: (event: React.MouseEvent<HTMLElement>, breadcrumb: Breadcrumb) => void;
 }
 
 export const Breadcrumbs = withStyles(styles)(
-    ({ classes, onClick, onContextMenu, items }: BreadcrumbsProps & WithStyles<CssRules>) =>
+    ({ classes, onClick, onContextMenu, items, resources }: BreadcrumbsProps & WithStyles<CssRules>) =>
     <Grid container data-cy='breadcrumbs' alignItems="center" wrap="nowrap">
     {
         items.map((item, index) => {
@@ -58,8 +82,14 @@ export const Breadcrumbs = withStyles(styles)(
                                 : isLastItem
                                     ? 'breadcrumb-last'
                                     : false}
+                            className={classNames(
+                                isLastItem ? null : 'parentItem',
+                                classes.item
+                            )}
+                            classes={{
+                                label: classes.buttonLabel
+                            }}
                             color="inherit"
-                            className={isLastItem ? classes.currentItem : classes.item}
                             onClick={() => onClick(item)}
                             onContextMenu={event => onContextMenu(event, item)}>
                             <Icon className={classes.icon} />
@@ -69,9 +99,12 @@ export const Breadcrumbs = withStyles(styles)(
                                 className={classes.label}>
                                 {item.label}
                             </Typography>
+                            {
+                                (resources[item.uuid] as any)?.frozenByUuid ? <FreezeIcon className={classes.frozenIcon} /> : null
+                            }
                         </Button>
                     </Tooltip>
-                    {!isLastItem && <ChevronRightIcon color="inherit" className={classes.item} />}
+                    {!isLastItem && <ChevronRightIcon color="inherit" className={classNames('parentItem', classes.chevron)} />}
                 </React.Fragment>
             );
         })
index cbb1fb1283b31246152f2c32b6a961cb5f256248..7b9ff4a6a81e4fe199301004d1905e6b0f664826 100644 (file)
@@ -12,6 +12,7 @@ interface ChipsInputProps<Value> {
     values: Value[];
     getLabel?: (value: Value) => string;
     onChange: (value: Value[]) => void;
+    onPartialInput?: (value: boolean) => void;
     handleFocus?: (e: any) => void;
     handleBlur?: (e: any) => void;
     chipsClassName?: string;
@@ -54,6 +55,9 @@ export const ChipsInput = withStyles(styles)(
 
         setText = (event: React.ChangeEvent<HTMLInputElement>) => {
             this.setState({ text: event.target.value }, () => {
+                // Update partial input status
+                this.props.onPartialInput && this.props.onPartialInput(this.state.text !== '');
+
                 // If pattern is provided, check for delimiter
                 if (this.props.pattern) {
                     const matches = this.state.text.match(this.props.pattern);
@@ -92,6 +96,7 @@ export const ChipsInput = withStyles(styles)(
                     this.setState({ text: '' });
                     this.props.onChange([...this.props.values, newValue]);
                 }
+                this.props.onPartialInput && this.props.onPartialInput(false);
             }
         }
 
index 06b3c507dba2ba3a49ad998dc59e8277756121b2..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,518 +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: '1rem',
-        marginBottom: '1rem',
-        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: '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: '2rem',
-        marginTop: '-1rem',
-        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();
-    webdavClient.defaults.baseURL = config.keepWebServiceUrl;
-    webdavClient.defaults.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) {
-            // Avoid fetching the same content level twice
-            if (leftKey !== rightKey) {
-                fetchData([leftKey, rightKey], true);
-            } else {
-                fetchData(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 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 5fbef6b62c1e37d2c882c5968f3983d3f5f2fcf6..0eb1323a07d44d2fd58a3ea3a5f912f01d083a18 100644 (file)
@@ -12,17 +12,23 @@ import { DataColumns } from '../data-table/data-table';
 import { ArvadosTheme } from "common/custom-theme";
 
 interface ColumnSelectorDataProps {
-    columns: DataColumns<any>;
-    onColumnToggle: (column: DataColumn<any>) => void;
+    columns: DataColumns<any, any>;
+    onColumnToggle: (column: DataColumn<any, any>) => void;
     className?: string;
 }
 
-type CssRules = "checkbox";
+type CssRules = "checkbox" | "listItem" | "listItemText";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     checkbox: {
         width: 24,
         height: 24
+    },
+    listItem: {
+        padding: 0
+    },
+    listItemText: {
+        paddingTop: '0.2rem'
     }
 });
 
@@ -39,13 +45,15 @@ export const ColumnSelector = withStyles(styles)(
                             <ListItem
                                 button
                                 key={index}
+                                className={classes.listItem}
                                 onClick={() => onColumnToggle(column)}>
                                 <Checkbox
                                     disableRipple
                                     color="primary"
                                     checked={column.selected}
                                     className={classes.checkbox} />
-                                <ListItemText>
+                                <ListItemText
+                                    className={classes.listItemText}>
                                     {column.name}
                                 </ListItemText>
                             </ListItem>
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 40617f73336b197149790264b2d379c4616d1e3f..27e46d584962c8d3e1cb1ca536b21ab1b4577ecf 100644 (file)
@@ -2,62 +2,79 @@
 //
 // 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, 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: theme.spacing.unit * 2
+        paddingBottom: 0,
     },
     toolbar: {
-        paddingTop: theme.spacing.unit,
-        paddingRight: theme.spacing.unit * 2,
+        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',
-        paddingLeft: theme.spacing.unit * 3,
-        paddingTop: theme.spacing.unit * 3,
-        fontSize: '18px'
+        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",
+        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> {
     fetchMode: DataTableFetchMode;
     items: T[];
     itemsAvailable: number;
-    columns: DataColumns<T>;
+    columns: DataColumns<T, any>;
     searchLabel?: string;
     searchValue: string;
     rowsPerPage: number;
@@ -74,40 +91,46 @@ 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> {
-    onSetColumns: (columns: DataColumns<T>) => void;
+    onSetColumns: (columns: DataColumns<T, any>) => void;
     onSearch: (value: string) => void;
     onRowClick: (item: T) => void;
     onRowDoubleClick: (item: T) => void;
-    onColumnToggle: (column: DataColumn<T>) => void;
+    onColumnToggle: (column: DataColumn<T, any>) => void;
     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: T) => void;
-    onSortToggle: (column: DataColumn<T>) => void;
-    onFiltersChange: (filters: DataTableFilters, column: DataColumn<T>) => void;
+    onSortToggle: (column: DataColumn<T, any>) => void;
+    onFiltersChange: (filters: DataTableFilters, column: DataColumn<T, any>) => void;
     onChangePage: (page: number) => void;
     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,
@@ -140,126 +163,251 @@ 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, 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}>
-                                    <Grid container justify="space-between" wrap="nowrap" alignItems="center">
-                                        {!hideSearchInput && <div className={classes.searchBox}>
-                                            {!hideSearchInput && <SearchInput
-                                                label={searchLabel}
-                                                value={searchValue}
-                                                selfClearProp={currentItemUuid}
-                                                onSearch={onSearch} />}
-                                        </div>}
-                                        {actions}
-                                        {!hideColumnSelector && <ColumnSelector
-                                            columns={columns}
-                                            onColumnToggle={onColumnToggle} />}
+            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>
-                                    { doMaximizePanel && !panelMaximized &&
-                                        <Tooltip title={`Maximize ${panelName || 'panel'}`} disableFocusListener>
-                                            <IconButton onClick={doMaximizePanel}><MaximizeIcon /></IconButton>
-                                        </Tooltip> }
-                                    { doHidePanel &&
-                                        <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
-                                            <IconButton onClick={doHidePanel}><CloseIcon /></IconButton>
-                                        </Tooltip> }
-                                </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>
+                                )}
+                                <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> = {
+        contextMenuColumn: DataColumn<any, any> = {
             name: "Actions",
             selected: true,
             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 f32fea2b5237c85fbdad07048258f4d94fb6794c..35655fb7dfea02aa9170cbfb2952f9a1eb2ad4c9 100644 (file)
@@ -6,7 +6,12 @@ import React from "react";
 import { DataTableFilters } from "../data-table-filters/data-table-filters-tree";
 import { createTree } from 'models/tree';
 
-export interface DataColumn<T> {
+/**
+ *
+ * @template I Type of dataexplorer item reference
+ * @template R Type of resource to use to restrict values of column sort.field
+ */
+export interface DataColumn<I, R> {
     key?: React.Key;
     name: string;
     selected: boolean;
@@ -17,9 +22,9 @@ export interface DataColumn<T> {
      * radio group and only one filter can be selected at a time.
      */
     mutuallyExclusiveFilters?: boolean;
-    sortDirection?: SortDirection;
+    sort?: {direction: SortDirection, field: keyof R};
     filters: DataTableFilters;
-    render: (item: T) => React.ReactElement<any>;
+    render: (item: I) => React.ReactElement<any>;
     renderHeader?: () => React.ReactElement<any>;
 }
 
@@ -29,24 +34,23 @@ export enum SortDirection {
     NONE = "none"
 }
 
-export const toggleSortDirection = <T>(column: DataColumn<T>): DataColumn<T> => {
-    return column.sortDirection
-        ? column.sortDirection === SortDirection.ASC
-            ? { ...column, sortDirection: SortDirection.DESC }
-            : { ...column, sortDirection: SortDirection.ASC }
+export const toggleSortDirection = <I, R>(column: DataColumn<I, R>): DataColumn<I, R> => {
+    return column.sort
+        ? column.sort.direction === SortDirection.ASC
+            ? { ...column, sort: {...column.sort, direction: SortDirection.DESC} }
+            : { ...column, sort: {...column.sort, direction: SortDirection.ASC} }
         : column;
 };
 
-export const resetSortDirection = <T>(column: DataColumn<T>): DataColumn<T> => {
-    return column.sortDirection ? { ...column, sortDirection: SortDirection.NONE } : column;
+export const resetSortDirection = <I, R>(column: DataColumn<I, R>): DataColumn<I, R> => {
+    return column.sort ? { ...column, sort: {...column.sort, direction: SortDirection.NONE} } : column;
 };
 
-export const createDataColumn = <T>(dataColumn: Partial<DataColumn<T>>): DataColumn<T> => ({
+export const createDataColumn = <I, R>(dataColumn: Partial<DataColumn<I, R>>): DataColumn<I, R> => ({
     key: '',
     name: '',
     selected: true,
     configurable: true,
-    sortDirection: SortDirection.NONE,
     filters: createTree(),
     render: () => React.createElement('span'),
     ...dataColumn,
index 866564ac4f25140cbf84f9c4dd8a6dca648cbcd9..880868bdf8d54c4d0b24c198b07bcea7a66f3a0a 100644 (file)
 
 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() });
 
 describe("<DataTable />", () => {
     it("shows only selected columns", () => {
-        const columns: DataColumns<string> = [
+        const columns: DataColumns<string, string> = [
             createDataColumn({
                 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", () => {
-        const columns: DataColumns<string> = [
+        const columns: DataColumns<string, string> = [
             createDataColumn({
                 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", () => {
-        const columns: DataColumns<string> = [
+        const columns: DataColumns<string, string> = [
             createDataColumn({
                 name: "Column 1",
                 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", () => {
-        const columns: DataColumns<string> = [
+        const columns: DataColumns<string, string> = [
             createDataColumn({
                 name: "Column 1",
                 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> = [
+        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> = [
+        const columns: DataColumns<string, string> = [
             createDataColumn({
                 name: "Column 1",
-                sortDirection: SortDirection.ASC,
+                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> = [{
-            name: "Column 1",
-            sortDirection: SortDirection.ASC,
-            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> = [{
-            name: "Column 1",
-            sortDirection: SortDirection.ASC,
-            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 d942234d0fcb4841d609850de408ed3c3cdfe3e1..de3e272d1ed88f8ec2d622222bc390b61e720dad 100644 (file)
 //
 // 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';
-
-export type DataColumns<T> = Array<DataColumn<T>>;
+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<T> {
-    items: T[];
-    columns: DataColumns<T>;
-    onRowClick: (event: React.MouseEvent<HTMLTableRowElement>, item: T) => void;
-    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: T) => void;
-    onRowDoubleClick: (event: React.MouseEvent<HTMLTableRowElement>, item: T) => void;
-    onSortToggle: (column: DataColumn<T>) => void;
-    onFiltersChange: (filters: DataTableFilters, column: DataColumn<T>) => void;
-    extractKey?: (item: T) => React.Key;
+export interface DataTableDataProps<I> {
+    items: I[];
+    columns: DataColumns<I, any>;
+    onRowClick: (event: React.MouseEvent<HTMLTableRowElement>, item: I) => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: I) => void;
+    onRowDoubleClick: (event: React.MouseEvent<HTMLTableRowElement>, item: I) => void;
+    onSortToggle: (column: DataColumn<I, any>) => void;
+    onFiltersChange: (filters: DataTableFilters, column: DataColumn<I, any>) => void;
+    extractKey?: (item: I) => React.Key;
     working?: boolean;
     defaultViewIcon?: IconType;
     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'
+        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>) => {
-            const dirty = columns.some((column) => getTreeDirty('')(column.filters));
-            return <DataTableDefaultView
-                icon={this.props.defaultViewIcon}
-                messages={this.props.defaultViewMessages}
-                filtersApplied={dirty} />;
-        }
+        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}
+                />
+            );
+        };
 
-        renderHeadCell = (column: DataColumn<T>, index: number) => {
-            const { name, key, renderHeader, filters, sortDirection } = column;
-            const { onSortToggle, onFiltersChange, classes } = this.props;
-            return <TableCell className={classes.tableCell} key={key || index}>
-                {renderHeader ?
-                    renderHeader() :
-                    countNodes(filters) > 0
-                        ? <DataTableFiltersPopover
+        renderHeadCell = (column: DataColumn<T, any>, index: number) => {
+            const { name, key, renderHeader, filters, sort } = column;
+            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>
-                        : sortDirection
-                            ? <TableSortLabel
-                                active={sortDirection !== SortDirection.NONE}
-                                direction={sortDirection !== SortDirection.NONE ? sortDirection : 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>, index: number) => React.ReactElement<any>) => {
+        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 014b8cc48a7022fbf04f07e9895521c171175f9d..5acea6193be906ddb9ec3c865f1d9e80ea068b2f 100644 (file)
@@ -29,7 +29,7 @@ export interface DefaultViewDataProps {
     messages: string[];
     filtersApplied?: boolean;
     classMessage?: string;
-    icon: IconType;
+    icon?: IconType;
     classIcon?: string;
 }
 
@@ -38,7 +38,7 @@ type DefaultViewProps = DefaultViewDataProps & WithStyles<CssRules>;
 export const DefaultView = withStyles(styles)(
     ({ classes, classRoot, messages, classMessage, icon: Icon, classIcon }: DefaultViewProps) =>
         <Typography className={classnames([classes.root, classRoot])} component="div">
-            <Icon className={classnames([classes.icon, classIcon])} />
+            {Icon && <Icon className={classnames([classes.icon, classIcon])} />}
             {messages.map((msg: string, index: number) => {
                 return <Typography key={index}
                     className={classnames([classes.message, classMessage])}>{msg}</Typography>;
index e52c487d6bbe33967e4786c54eb0655e947173b2..92d31b0b8e278b924bfc7fb2d25a61f5a497de1d 100644 (file)
@@ -24,7 +24,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     },
     label: {
         boxSizing: 'border-box',
-        color: theme.palette.grey["500"],
+        color: theme.palette.grey["600"],
         width: '100%'
     },
     value: {
@@ -42,7 +42,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     },
     copyIcon: {
         marginLeft: theme.spacing.unit,
-        color: theme.palette.grey["500"],
+        color: theme.palette.grey["600"],
         cursor: 'pointer',
         display: 'inline',
         '& svg': {
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 54d5b5db2b9513a34579d1d5465a89587c3fb8eb..e6c15144522fa8467ed43296d312a507cf4eba00 100644 (file)
@@ -34,7 +34,8 @@ const styles: StyleRulesCallback<CssRules> = theme => ({
         width: "100%",
         height: "200px",
         position: "relative",
-        border: "1px solid rgba(0, 0, 0, 0.42)"
+        border: "1px solid rgba(0, 0, 0, 0.42)",
+        boxSizing: 'border-box',
     },
     dropzoneBorder: {
         content: "",
index 0fc799dee92b62eb0ab58a6ce100403dc7477193..b50504a6c0b7a7ef4bd8da5bb330a73a18e0406c 100644 (file)
@@ -8,7 +8,7 @@ import { Dialog, DialogActions, DialogContent, DialogTitle } from '@material-ui/
 import { Button, StyleRulesCallback, WithStyles, withStyles, CircularProgress } from '@material-ui/core';
 import { WithDialogProps } from 'store/dialog/with-dialog';
 
-type CssRules = "button" | "lastButton" | "formContainer" | "dialogTitle" | "progressIndicator" | "dialogActions";
+type CssRules = "button" | "lastButton" | "form" | "formContainer" | "dialogTitle" | "progressIndicator" | "dialogActions";
 
 const styles: StyleRulesCallback<CssRules> = theme => ({
     button: {
@@ -18,6 +18,12 @@ const styles: StyleRulesCallback<CssRules> = theme => ({
         marginLeft: theme.spacing.unit,
         marginRight: "0",
     },
+    form: {
+        display: 'flex',
+        overflowY: 'auto',
+        flexDirection: 'column',
+        flex: '0 1 auto',
+    },
     formContainer: {
         display: "flex",
         flexDirection: "column",
@@ -57,7 +63,7 @@ export const FormDialog = withStyles(styles)((props: DialogProjectProps) =>
         disableEscapeKeyDown={props.submitting}
         fullWidth
         maxWidth='md'>
-        <form data-cy='form-dialog'>
+        <form data-cy='form-dialog' className={props.classes.form}>
             <DialogTitle className={props.classes.dialogTitle}>
                 {props.dialogTitle}
             </DialogTitle>
index a64ed0a8a00f12531b1283922b352abfcdd4392a..2dd97c1663777cc5d64a7540351d8d8dfeef5b52 100644 (file)
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-import { Badge, 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 BubbleChart from '@material-ui/icons/BubbleChart';
-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 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 MoveToInbox from '@material-ui/icons/MoveToInbox';
-import Notifications from '@material-ui/icons/Notifications';
-import OpenInNew from '@material-ui/icons/OpenInNew';
-import People from '@material-ui/icons/People';
-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 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 CropFreeSharp from '@material-ui/icons/CropFreeSharp';
-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 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: 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: 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>;
-
-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 InputIcon: 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) => <CropFreeSharp {...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 OutputIcon: 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) => <BubbleChart {...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} />;
+    </Tooltip>
+);
+
+// https://materialdesignicons.com/icon/image-off
+export const ImageOffIcon = (props: any) => (
+    <SvgIcon {...props}>
+        <path d="M21 17.2L6.8 3H19C20.1 3 21 3.9 21 5V17.2M20.7 22L19.7 21H5C3.9 21 3 20.1 3 19V4.3L2 3.3L3.3 2L22 20.7L20.7 22M16.8 18L12.9 14.1L11 16.5L8.5 13.5L5 18H16.8Z" />
+    </SvgIcon>
+);
+
+// https://materialdesignicons.com/icon/inbox-arrow-up
+export const OutputIcon: IconType = (props: any) => (
+    <SvgIcon {...props}>
+        <path d="M14,14H10V11H8L12,7L16,11H14V14M16,11M5,15V5H19V15H15A3,3 0 0,1 12,18A3,3 0 0,1 9,15H5M19,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3" />
+    </SvgIcon>
+);
+
+// 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} />;
index d690e82fa7ecfbd33c92cb98c2b2229e3d6a6dc2..3f4911c2258780963b32dea7c01645078b3e1057 100644 (file)
@@ -10,7 +10,7 @@ import { Button } from "@material-ui/core";
 
 configure({ adapter: new Adapter() });
 
-const PanelMock = ({panelName, panelMaximized, doHidePanel, doMaximizePanel, panelIlluminated, panelRef, children, ...rest}) =>
+const PanelMock = ({panelName, panelMaximized, doHidePanel, doMaximizePanel, doUnMaximizePanel, panelIlluminated, panelRef, children, ...rest}) =>
     <div {...rest}>{children}</div>;
 
 describe('<MPVContainer />', () => {
index f4c3f3ba44f5624b83a0ebaf299239957d84263e..203748d5e0b2c73ff6241b100718f2c01f5e68b2 100644 (file)
@@ -19,9 +19,12 @@ import { InfoIcon } from 'components/icon/icon';
 import { ReactNodeArray } from 'prop-types';
 import classNames from 'classnames';
 
-type CssRules = 'button' | 'buttonIcon' | 'content';
+type CssRules = 'root' | 'button' | 'buttonIcon' | 'content';
 
 const styles: StyleRulesCallback<CssRules> = theme => ({
+    root: {
+        marginTop: '10px',
+    },
     button: {
         padding: '2px 5px',
         marginRight: '5px',
@@ -48,14 +51,15 @@ interface MPVHideablePanelDataProps {
 interface MPVHideablePanelActionProps {
     doHidePanel: () => void;
     doMaximizePanel: () => void;
+    doUnMaximizePanel: () => void;
 }
 
 type MPVHideablePanelProps = MPVHideablePanelDataProps & MPVHideablePanelActionProps;
 
-const MPVHideablePanel = ({doHidePanel, doMaximizePanel, name, visible, maximized, illuminated, ...props}: MPVHideablePanelProps) =>
+const MPVHideablePanel = ({doHidePanel, doMaximizePanel, doUnMaximizePanel, name, visible, maximized, illuminated, ...props}: MPVHideablePanelProps) =>
     visible
     ? <>
-        {React.cloneElement((props.children as ReactElement), { doHidePanel, doMaximizePanel, panelName: name, panelMaximized: maximized, panelIlluminated: illuminated, panelRef: props.panelRef })}
+        {React.cloneElement((props.children as ReactElement), { doHidePanel, doMaximizePanel, doUnMaximizePanel, panelName: name, panelMaximized: maximized, panelIlluminated: illuminated, panelRef: props.panelRef })}
     </>
     : null;
 
@@ -66,11 +70,13 @@ interface MPVPanelDataProps {
     panelRef?: MutableRefObject<any>;
     forwardProps?: boolean;
     maxHeight?: string;
+    minHeight?: string;
 }
 
 interface MPVPanelActionProps {
     doHidePanel?: () => void;
     doMaximizePanel?: () => void;
+    doUnMaximizePanel?: () => void;
 }
 
 // Props received by panel implementors
@@ -79,24 +85,24 @@ export type MPVPanelProps = MPVPanelDataProps & MPVPanelActionProps;
 type MPVPanelContentProps = {children: ReactElement} & MPVPanelProps & GridProps;
 
 // Grid item compatible component for layout and MPV props passing
-export const MPVPanelContent = ({doHidePanel, doMaximizePanel, panelName,
-    panelMaximized, panelIlluminated, panelRef, forwardProps, maxHeight,
+export const MPVPanelContent = ({doHidePanel, doMaximizePanel, doUnMaximizePanel, panelName,
+    panelMaximized, panelIlluminated, panelRef, forwardProps, maxHeight, minHeight,
     ...props}: MPVPanelContentProps) => {
     useEffect(() => {
         if (panelRef && panelRef.current) {
-            panelRef.current.scrollIntoView({behavior: 'smooth'});
+            panelRef.current.scrollIntoView({alignToTop: true});
         }
     }, [panelRef]);
 
-    const mh = panelMaximized
+    const maxH = panelMaximized
         ? '100%'
         : maxHeight;
 
-    return <Grid item style={{maxHeight: mh}} {...props}>
+    return <Grid item style={{maxHeight: maxH, minHeight}} {...props}>
         <span ref={panelRef} /> {/* Element to scroll to when the panel is selected */}
         <Paper style={{height: '100%'}} elevation={panelIlluminated ? 8 : 0}>
             { forwardProps
-                ? React.cloneElement(props.children, { doHidePanel, doMaximizePanel, panelName, panelMaximized })
+                ? React.cloneElement(props.children, { doHidePanel, doMaximizePanel, doUnMaximizePanel, panelName, panelMaximized })
                 : props.children }
         </Paper>
     </Grid>;
@@ -118,11 +124,12 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
     } else if (!isArray(children)) {
         children = [children];
     }
-    const visibility = (children as ReactNodeArray).map((_, idx) =>
+    const initialVisibility = (children as ReactNodeArray).map((_, idx) =>
         !panelStates || // if panelStates wasn't passed, default to all visible panels
             (panelStates[idx] &&
                 (panelStates[idx].visible || panelStates[idx].visible === undefined)));
-    const [panelVisibility, setPanelVisibility] = useState<boolean[]>(visibility);
+    const [panelVisibility, setPanelVisibility] = useState<boolean[]>(initialVisibility);
+    const [previousPanelVisibility, setPreviousPanelVisibility] = useState<boolean[]>(initialVisibility);
     const [highlightedPanel, setHighlightedPanel] = useState<number>(-1);
     const [selectedPanel, setSelectedPanel] = useState<number>(-1);
     const panelRef = useRef<any>(null);
@@ -133,6 +140,7 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
     if (isArray(children)) {
         for (let idx = 0; idx < children.length; idx++) {
             const showFn = (idx: number) => () => {
+                setPreviousPanelVisibility(initialVisibility);
                 setPanelVisibility([
                     ...panelVisibility.slice(0, idx),
                     true,
@@ -141,6 +149,7 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
                 setSelectedPanel(idx);
             };
             const hideFn = (idx: number) => () => {
+                setPreviousPanelVisibility(initialVisibility);
                 setPanelVisibility([
                     ...panelVisibility.slice(0, idx),
                     false,
@@ -148,13 +157,18 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
                 ])
             };
             const maximizeFn = (idx: number) => () => {
+                setPreviousPanelVisibility(panelVisibility);
                 // Maximize X == hide all but X
                 setPanelVisibility([
                     ...panelVisibility.slice(0, idx).map(() => false),
                     true,
                     ...panelVisibility.slice(idx+1).map(() => false),
-                ])
+                ]);
             };
+            const unMaximizeFn = (idx: number) => () => {
+                setPanelVisibility(previousPanelVisibility);
+                setSelectedPanel(idx);
+            }
             const panelName = panelStates === undefined
                 ? `Panel ${idx+1}`
                 : (panelStates[idx] && panelStates[idx].name) || `Panel ${idx+1}`;
@@ -188,14 +202,14 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
                 <MPVHideablePanel key={idx} visible={panelVisibility[idx]} name={panelName}
                     panelRef={(idx === selectedPanel) ? panelRef : undefined}
                     maximized={panelIsMaximized} illuminated={idx === highlightedPanel}
-                    doHidePanel={hideFn(idx)} doMaximizePanel={maximizeFn(idx)}>
+                    doHidePanel={hideFn(idx)} doMaximizePanel={maximizeFn(idx)} doUnMaximizePanel={panelIsMaximized ? unMaximizeFn(idx) : () => null}>
                     {children[idx]}
                 </MPVHideablePanel>;
             panels = [...panels, aPanel];
         };
     };
 
-    return <Grid container {...props}>
+    return <Grid container {...props} className={classes.root}>
         <Grid container item direction="row">
             { buttons.map((tgl, idx) => <Grid item key={idx}>{tgl}</Grid>) }
         </Grid>
@@ -210,4 +224,4 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
     </Grid>;
 };
 
-export const MPVContainer = withStyles(styles)(MPVContainerComponent);
\ No newline at end of file
+export const MPVContainer = withStyles(styles)(MPVContainerComponent);
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 c57d3608b06b470e260ed2ceabf76875290f3019..ba70f752b9d22fe131dd1b40fdbefe6b7dfa5731 100644 (file)
@@ -98,11 +98,22 @@ describe("<SearchInput />", () => {
     describe("on input target change", () => {
         it("clears the input value on selfClearProp change", () => {
             const searchInput = mount(<SearchInput selfClearProp="abc" value="123" onSearch={onSearch} debounce={1000}/>);
-            searchInput.setProps({ selfClearProp: 'aaa' });
+
+            // component should clear value upon creation
             jest.runTimersToTime(1000);
             expect(onSearch).toBeCalledWith("");
             expect(onSearch).toHaveBeenCalledTimes(1);
+
+            // component should not clear on same selfClearProp
+            searchInput.setProps({ selfClearProp: 'abc' });
+            jest.runTimersToTime(1000);
+            expect(onSearch).toHaveBeenCalledTimes(1);
+
+            // component should clear on selfClearProp change
+            searchInput.setProps({ selfClearProp: '111' });
+            jest.runTimersToTime(1000);
+            expect(onSearch).toBeCalledWith("");
+            expect(onSearch).toHaveBeenCalledTimes(2);
         });
     });
-
 });
index 50338f401c9387b680ef417a715e2130d932b265..fbb4f599b60690a5d5e7723f2a1301f25a513487 100644 (file)
@@ -2,36 +2,17 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-import { IconButton, StyleRulesCallback, withStyles, WithStyles, FormControl, InputLabel, Input, InputAdornment, Tooltip } from '@material-ui/core';
+import React, {useState, useEffect} from 'react';
+import {
+    IconButton,
+    FormControl,
+    InputLabel,
+    Input,
+    InputAdornment,
+    Tooltip,
+} from '@material-ui/core';
 import SearchIcon from '@material-ui/icons/Search';
 
-type CssRules = 'container' | 'input' | 'button';
-
-const styles: StyleRulesCallback<CssRules> = theme => {
-    return {
-        container: {
-            position: 'relative',
-            width: '100%'
-        },
-        input: {
-            border: 'none',
-            borderRadius: theme.spacing.unit / 4,
-            boxSizing: 'border-box',
-            padding: theme.spacing.unit,
-            paddingRight: theme.spacing.unit * 4,
-            width: '100%',
-        },
-        button: {
-            position: 'absolute',
-            top: theme.spacing.unit / 2,
-            right: theme.spacing.unit / 2,
-            width: theme.spacing.unit * 3,
-            height: theme.spacing.unit * 3
-        }
-    };
-};
-
 interface SearchInputDataProps {
     value: string;
     label?: string;
@@ -43,84 +24,75 @@ interface SearchInputActionProps {
     debounce?: number;
 }
 
-type SearchInputProps = SearchInputDataProps & SearchInputActionProps & WithStyles<CssRules>;
-
-interface SearchInputState {
-    value: string;
-    label: string;
-    selfClearProp: string;
-}
+type SearchInputProps = SearchInputDataProps & SearchInputActionProps;
 
 export const DEFAULT_SEARCH_DEBOUNCE = 1000;
 
-export const SearchInput = withStyles(styles)(
-    class extends React.Component<SearchInputProps> {
-        state: SearchInputState = {
-            value: "",
-            label: "",
-            selfClearProp: ""
-        };
+export const SearchInput = (props: SearchInputProps) => {
+    const [timeout, setTimeout] = useState(0);
+    const [value, setValue] = useState("");
+    const [label, setLabel] = useState("Search");
+    const [selfClearProp, setSelfClearProp] = useState("");
 
-        timeout: number;
-
-        render() {
-            return <form onSubmit={this.handleSubmit}>
-                <FormControl>
-                    <InputLabel>{this.state.label}</InputLabel>
-                    <Input
-                        type="text"
-                        data-cy="search-input"
-                        value={this.state.value}
-                        onChange={this.handleChange}
-                        endAdornment={
-                            <InputAdornment position="end">
-                                <Tooltip title='Search'>
-                                    <IconButton
-                                        onClick={this.handleSubmit}>
-                                        <SearchIcon />
-                                    </IconButton>
-                                </Tooltip>
-                            </InputAdornment>
-                        } />
-                </FormControl>
-            </form>;
+    useEffect(() => {
+        if (props.value) {
+            setValue(props.value);
         }
-
-        componentDidMount() {
-            this.setState({
-                value: this.props.value,
-                label: this.props.label || 'Search'
-            });
+        if (props.label) {
+            setLabel(props.label);
         }
 
-        componentWillReceiveProps(nextProps: SearchInputProps) {
-            if (nextProps.value !== this.props.value) {
-                this.setState({ value: nextProps.value });
-            }
-            if (this.state.value !== '' && nextProps.selfClearProp && nextProps.selfClearProp !== this.state.selfClearProp) {
-                this.props.onSearch('');
-                this.setState({ selfClearProp: nextProps.selfClearProp });
-            }
-        }
+        return () => {
+            setValue("");
+            clearTimeout(timeout);
+        };
+    }, [props.value, props.label]); // eslint-disable-line react-hooks/exhaustive-deps
 
-        componentWillUnmount() {
-            clearTimeout(this.timeout);
+    useEffect(() => {
+        if (selfClearProp !== props.selfClearProp) {
+            setValue("");
+            setSelfClearProp(props.selfClearProp);
+            handleChange({ target: { value: "" } } as any);
         }
+    }, [props.selfClearProp]); // eslint-disable-line react-hooks/exhaustive-deps
 
-        handleSubmit = (event: React.FormEvent<HTMLElement>) => {
-            event.preventDefault();
-            clearTimeout(this.timeout);
-            this.props.onSearch(this.state.value);
-        }
+    const handleSubmit = (event: React.FormEvent<HTMLElement>) => {
+        event.preventDefault();
+        clearTimeout(timeout);
+        props.onSearch(value);
+    };
 
-        handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
-            clearTimeout(this.timeout);
-            this.setState({ value: event.target.value });
-            this.timeout = window.setTimeout(
-                () => this.props.onSearch(this.state.value),
-                this.props.debounce || DEFAULT_SEARCH_DEBOUNCE
-            );
+    const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+        const { target: { value: eventValue } } = event;
+        clearTimeout(timeout);
+        setValue(eventValue);
+
+        setTimeout(window.setTimeout(
+            () => {
+                props.onSearch(eventValue);
+            },
+            props.debounce || DEFAULT_SEARCH_DEBOUNCE
+        ));
+    };
 
-        }
-    }
-);
+    return <form onSubmit={handleSubmit}>
+        <FormControl>
+            <InputLabel>{label}</InputLabel>
+            <Input
+                type="text"
+                data-cy="search-input"
+                value={value}
+                onChange={handleChange}
+                endAdornment={
+                    <InputAdornment position="end">
+                        <Tooltip title='Search'>
+                            <IconButton
+                                onClick={handleSubmit}>
+                                <SearchIcon />
+                            </IconButton>
+                        </Tooltip>
+                    </InputAdornment>
+                } />
+        </FormControl>
+    </form>;
+};
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 78e2c7fbedc3f5ac64ecd5aab9328e7532a77e16..b2a8dd4848602a24eb66fba407f545d0cf992c35 100644 (file)
@@ -72,7 +72,11 @@ export const RichEditorTextField = withStyles(styles)(
 
         onChange = (value: any) => {
             this.setState({ value });
-            this.props.input.onChange(value.toString('html'));
+            this.props.input.onChange(
+                !!value.getEditorState().getCurrentContent().getPlainText().trim()
+                ? value.toString('html')
+                : null
+            );
         }
 
         render() {
index fc9dbc743ae19a15d59172bd2c30734eb223cd11..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 } 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'
@@ -26,7 +27,9 @@ type CssRules = 'list'
     | 'toggableIcon'
     | 'checkbox'
     | 'childItem'
-    | 'childItemIcon';
+    | 'childItemIcon'
+    | 'frozenIcon'
+    | 'indentSpacer';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     list: {
@@ -44,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
@@ -83,6 +87,14 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     active: {
         color: theme.palette.primary.main,
     },
+    frozenIcon: {
+        fontSize: 20,
+        color: theme.palette.grey["600"],
+        marginLeft: '10px',
+    },
+    indentSpacer: {
+        width: '0.25rem'
+    }
 });
 
 export enum TreeItemStatus {
@@ -93,6 +105,7 @@ export enum TreeItemStatus {
 
 export interface TreeItem<T> {
     data: T;
+    depth?: number;
     id: string;
     open: boolean;
     active: boolean;
@@ -102,6 +115,7 @@ export interface TreeItem<T> {
     flatTree?: boolean;
     status: TreeItemStatus;
     items?: Array<TreeItem<T>>;
+    isFrozen?: boolean;
 }
 
 export interface TreeProps<T> {
@@ -118,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.
@@ -147,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;
@@ -160,6 +179,7 @@ interface FlatTreeProps {
     showSelection: any;
     useRadioButtons?: boolean;
     handleCheckboxChange: Function;
+    selectedRef?: (node: HTMLDivElement | null) => void;
 }
 
 const FLAT_TREE_ACTIONS = {
@@ -168,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) {
@@ -189,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;
         }
@@ -231,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}
@@ -247,12 +274,15 @@ 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>
+                            {
+                                !!item.data.frozenByUuid ? <FreezeIcon className={props.classes.frozenIcon} /> : null
+                            }
                         </span>
                     </div>
                 </div>)
@@ -260,99 +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,
@@ -360,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();
@@ -370,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 0172d68bcc5c9385ebdbf29d7a06a7022ef08c96..51f07761d0d9b4ab4e990886138eec7d98ba08ab 100644 (file)
@@ -5,3 +5,25 @@ body {
     width: 100vw;
     height: 100vh;
 }
+
+.app-banner {
+    width: calc(100% - 2rem);
+    height: 150px;
+    z-index: 11111;
+    position: fixed;
+    top: 0px;
+    background-color: #00bfa5;
+    border: 1px solid #01685a;
+    color: #ffffff;
+    margin: 1rem;
+    box-sizing: border-box;
+    cursor: pointer;
+}
+
+.app-banner span {
+    font-size: 2rem;
+    text-align: center;
+    display: block;
+    margin: auto;
+    padding: 2rem;
+}
\ No newline at end of file
index 5d939d36d5f33eec2caa0741e604ced7e2ffa662..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, 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, 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,89 +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();
+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;
-        }
+    // 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) {
-                        store.dispatch(logout());
-                    } else {
-                        store.dispatch(snackbarActions.OPEN_SNACKBAR({
-                            message: `${error.errors
-                                ? error.errors[0]
-                                : error.message}`,
-                            kind: SnackbarKind.ERROR,
-                            hideDuration: 8000
-                        })
-                        );
-                    }
+    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);
-
-        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 99ec4cf086ad6acfd3102f5e3dfd77b6c7f14867..d3adb03a92a435019f3a368cd36f0a822773f999 100644 (file)
@@ -2,40 +2,84 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Resource, ResourceKind, ResourceWithProperties } from "./resource";
-import { MountType } from "models/mount-types";
+import { Resource, ResourceKind, ResourceWithProperties } from './resource';
+import { MountType } from 'models/mount-types';
 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, ResourceWithProperties {
-    kind: ResourceKind.CONTAINER_REQUEST;
-    name: string;
-    description: string;
-    state: ContainerRequestState;
-    requestingContainerUuid: string | null;
-    containerUuid: string | null;
+export interface ContainerRequestResource
+    extends Resource,
+    ResourceWithProperties {
+    command: string[];
     containerCountMax: number;
-    mounts: {[path: string]: MountType};
-    runtimeConstraints: RuntimeConstraints;
-    schedulingParameters: SchedulingParameters;
+    containerCount: number;
     containerImage: string;
-    environment: any;
+    containerUuid: string | null;
+    cumulativeCost: number;
     cwd: string;
-    command: string[];
-    outputPath: 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;
-    expiresAt: string;
+    requestingContainerUuid: string | null;
+    runtimeConstraints: RuntimeConstraints;
+    schedulingParameters: SchedulingParameters;
+    state: ContainerRequestState;
     useExisting: boolean;
-    logUuid: string | null;
-    outputUuid: string | null;
-    filters: string;
-    containerCount: number;
 }
+
+// Until the api supports unselecting fields, we need a list of all other fields to omit mounts
+export const containerRequestFieldsNoMounts = [
+    "command",
+    "container_count_max",
+    "container_count",
+    "container_image",
+    "container_uuid",
+    "created_at",
+    "cumulative_cost",
+    "cwd",
+    "description",
+    "environment",
+    "etag",
+    "expires_at",
+    "filters",
+    "href",
+    "kind",
+    "log_uuid",
+    "modified_at",
+    "modified_by_client_uuid",
+    "modified_by_user_uuid",
+    "name",
+    "output_name",
+    "output_path",
+    "output_properties",
+    "output_storage_classes",
+    "output_ttl",
+    "output_uuid",
+    "owner_uuid",
+    "priority",
+    "properties",
+    "requesting_container_uuid",
+    "runtime_constraints",
+    "scheduling_parameters",
+    "state",
+    "use_existing",
+    "uuid",
+];
index 127c250886f1b1c5086080bb006ece4f0a7e7308..c86f11cee1b4ebeadd33f2dbd01ee042ff2bb693 100644 (file)
@@ -25,10 +25,12 @@ export interface ContainerResource extends Resource {
     environment: {};
     cwd: string;
     command: string[];
+    cost: number;
     outputPath: string;
     mounts: MountType[];
     runtimeConstraints: RuntimeConstraints;
     runtimeStatus: RuntimeStatus;
+    runtimeUserUuid: string;
     schedulingParameters: SchedulingParameters;
     output: string | null;
     containerImage: string;
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 b47b426f274989db009552aab3ecbd9e8c1bfa1e..8dd2e716e2400cfe6c15f855f310e8a0dadb97f3 100644 (file)
@@ -5,6 +5,7 @@
 import { GroupClass, GroupResource } from "./group";
 
 export interface ProjectResource extends GroupResource {
+    frozenByUuid: null | string;
     groupClass: GroupClass.PROJECT | GroupClass.FILTER | GroupClass.ROLE;
 }
 
index fd86727782c2960b15ee78d4237da4dcc53363b4..2d2b9f210141e27c20d4ae284bbec9a79899dee8 100644 (file)
@@ -67,6 +67,7 @@ export const RESOURCE_UUID_PATTERN = '[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}';
 export const PORTABLE_DATA_HASH_PATTERN = '[a-f0-9]{32}\\+\\d+';
 export const RESOURCE_UUID_REGEX = new RegExp("^" + RESOURCE_UUID_PATTERN + "$");
 export const COLLECTION_PDH_REGEX = new RegExp("^" + PORTABLE_DATA_HASH_PATTERN + "$");
+export const KEEP_URL_REGEX = new RegExp("^(keep:)?" + PORTABLE_DATA_HASH_PATTERN);
 
 export const isResourceUuid = (uuid: string) =>
     RESOURCE_UUID_REGEX.test(uuid);
index 89101c6ea3dd52e35e00ff0ab8f8f576a8d4140e..63982529bd2df52844653b7d17b0445f92c1ebdd 100644 (file)
@@ -2,9 +2,17 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+export interface CUDAParameters {
+    device_count: number;
+    driver_version: string;
+    hardware_capability: string;
+}
+
 export interface RuntimeConstraints {
     ram: number;
     vcpus: number;
     keep_cache_ram?: number;
+    keep_cache_disk?: number;
     API: boolean;
+    cuda?: CUDAParameters;
 }
index b404496f9381c0d76ac0ea2c16f6d09bf378795c..f9320a26d700f2582afea62cf512a0fb0ba2b085 100644 (file)
@@ -3,11 +3,13 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { ResourceKind } from 'models/resource';
+import { GroupResource } from './group';
 
 export type SearchBarAdvancedFormData = {
     type?: ResourceKind;
     cluster?: string;
     projectUuid?: string;
+    projectObject?: GroupResource;
     inTrash: boolean;
     pastVersions: boolean;
     dateFrom: string;
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 9b3d97d8486337befae509d35761d93cf1edf6be..0df6eac24158809ce426560359bab96b0985fe1c 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Resource, ResourceKind } from 'models/resource';
+import { Resource, ResourceKind, RESOURCE_UUID_REGEX } from 'models/resource';
 
 export type UserPrefs = {
     profile?: {
@@ -24,6 +24,8 @@ export interface User {
     prefs: UserPrefs;
     isAdmin: boolean;
     isActive: boolean;
+    canWrite: boolean;
+    canManage: boolean;
 }
 
 export const getUserFullname = (user: User) => {
@@ -44,8 +46,22 @@ export const getUserDisplayName = (user: User, withEmail = false, withUuid = fal
     return parts.join(' ');
 };
 
+export const getUserDetailsString = (user: User) => {
+    let parts: string[] = [];
+    const userCluster = getUserClusterID(user);
+    user.username.length && parts.push(user.username);
+    user.email.length && parts.push(`<${user.email}>`);
+    userCluster && userCluster.length && parts.push(`(${userCluster})`);
+    return parts.join(' ');
+};
+
+export const getUserClusterID = (user: User): string | undefined => {
+    const match = RESOURCE_UUID_REGEX.exec(user.uuid);
+    const parts = match ? match[0].split('-') : [];
+    return parts.length === 3 ? parts[0] : undefined;
+};
+
 export interface UserResource extends Resource, User {
     kind: ResourceKind.USER;
     defaultOwnerUuid: string;
-    writableBy: string[];
 }
index 6d21dbc766381831a1e048529913e77f51784a38..369db4c7c77a875dfea99ef5fe7e0a3a703d2526 100644 (file)
@@ -4,6 +4,7 @@
 
 import { Resource, ResourceKind } from "./resource";
 import { safeLoad } from 'js-yaml';
+import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter";
 
 export interface WorkflowResource extends Resource {
     kind: ResourceKind.WORKFLOW;
@@ -152,10 +153,21 @@ export const getWorkflowInputs = (workflowDefinition: WorkflowResourceDefinition
         : undefined;
 };
 
+export const getWorkflowOutputs = (workflowDefinition: WorkflowResourceDefinition) => {
+    if (!workflowDefinition) { return undefined; }
+    return getWorkflow(workflowDefinition)
+        ? getWorkflow(workflowDefinition)!.outputs
+        : undefined;
+};
+
 export const getInputLabel = (input: CommandInputParameter) => {
     return `${input.label || input.id.split('/').pop()}`;
 };
 
+export const getIOParamId = (input: CommandInputParameter | CommandOutputParameter) => {
+    return `${input.id.split('/').pop()}`;
+};
+
 export const isRequiredInput = ({ type }: CommandInputParameter) => {
     if (type instanceof Array) {
         for (const t of type) {
@@ -173,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 da67935a1e5d8a44c5bf2c601c3fc639204ca8ab..bb97665a8c6652a7b89324872a416e3b39c343a5 100644 (file)
@@ -64,7 +64,7 @@ export class FilterBuilder {
         return this.addCondition("properties." + field, "exists", false, "", "", resourcePrefix);
     }
 
-    public addFullTextSearch(value: string) {
+    public addFullTextSearch(value: string, table?: string) {
         const regex = /"[^"]*"/;
         const matches: any[] = [];
 
@@ -76,10 +76,15 @@ export class FilterBuilder {
             match = value.match(regex);
         }
 
+        let searchIn = 'any';
+        if (table) {
+            searchIn = table + ".any";
+        }
+
         const terms = value.trim().split(/(\s+)/).concat(matches);
         terms.forEach(term => {
             if (term !== " ") {
-                this.addCondition("any", "ilike", term, "%", "%");
+                this.addCondition(searchIn, "ilike", term, "%", "%");
             }
         });
         return this;
index 52bfa29ecae21bb2a86967d0dbd63cd9b072f9fd..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 {
@@ -120,9 +122,13 @@ export class AuthService {
         window.location.assign(`https://${homeClusterHost}/login?${(uuidPrefix !== homeCluster && homeCluster !== loginCluster) ? "remote=" + uuidPrefix + "&" : ""}return_to=${currentUrl}`);
     }
 
-    public logout(expireToken: string) {
-        const currentUrl = `${window.location.protocol}//${window.location.host}`;
-        window.location.assign(`${this.baseUrl || ""}/logout?api_token=${expireToken}&return_to=${currentUrl}`);
+    public logout(expireToken: string, preservePath: boolean) {
+        const fullUrl = new URL(window.location.href);
+        const wbBase = `${fullUrl.protocol}//${fullUrl.host}`;
+        const wbPath = fullUrl.pathname + fullUrl.search;
+        const returnTo = `${wbBase}${preservePath ? wbPath : ''}`
+
+        window.location.assign(`${this.baseUrl || ""}/logout?api_token=${expireToken}&return_to=${returnTo}`);
     }
 
     public getUserDetails = (showErrors?: boolean): Promise<User> => {
@@ -142,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 817fdd5453bac324948d1d8d8170c8bec2d7168f..3b4f423a0f54d72c003442ddbb025fb7ba109f00 100644 (file)
@@ -7,28 +7,30 @@ import MockAdapter from 'axios-mock-adapter';
 import { snakeCase } from 'lodash';
 import { CollectionResource, defaultCollectionSelectedFields } from 'models/collection';
 import { AuthService } from '../auth-service/auth-service';
-import { CollectionService } from './collection-service';
+import { CollectionService, emptyCollectionPdh } from './collection-service';
 
 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(),
         } as any;
         authService = {} as AuthService;
         actions = {
             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();
     });
 
@@ -77,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);
         });
@@ -93,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 () => {
@@ -105,11 +107,11 @@ 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.only('should upload files with custom uplaod target', async () => {
+        it('should upload files with custom uplaod target', async () => {
             // given
             const files: File[] = [{name: 'test-file1'} as File];
             const collectionUUID = 'zzzzz-4zz18-0123456789abcde';
@@ -119,50 +121,335 @@ 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");
         });
     });
 
     describe('deleteFiles', () => {
         it('should remove no files', async () => {
             // given
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
             const filePaths: string[] = [];
-            const collectionUUID = '';
+            const collectionUUID = 'zzzzz-tpzed-5o5tg0l9a57gxxx';
 
             // when
             await collectionService.deleteFiles(collectionUUID, filePaths);
 
             // then
-            expect(webdavClient.delete).not.toHaveBeenCalled();
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${collectionUUID}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {},
+                }
+            );
         });
 
         it('should remove only root files', async () => {
             // given
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
             const filePaths: string[] = ['/root/1', '/root/1/100', '/root/1/100/test.txt', '/root/2', '/root/2/200', '/root/3/300/test.txt'];
-            const collectionUUID = '';
+            const collectionUUID = 'zzzzz-tpzed-5o5tg0l9a57gxxx';
 
             // when
             await collectionService.deleteFiles(collectionUUID, filePaths);
 
             // then
-            expect(webdavClient.delete).toHaveBeenCalledTimes(3);
-            expect(webdavClient.delete).toHaveBeenCalledWith("c=/root/3/300/test.txt");
-            expect(webdavClient.delete).toHaveBeenCalledWith("c=/root/2");
-            expect(webdavClient.delete).toHaveBeenCalledWith("c=/root/1");
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${collectionUUID}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        '/root/3/300/test.txt': '',
+                        '/root/2': '',
+                        '/root/1': '',
+                    },
+                }
+            );
         });
 
-        it('should remove files with uuid prefix', async () => {
+        it('should batch remove files', async () => {
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
             // given
-            const filePaths: string[] = ['/root/1'];
-            const collectionUUID = 'zzzzz-tpzed-5o5tg0l9a57gxxx';
+            const filePaths: string[] = ['/root/1', '/secondFile', 'barefile.txt'];
+            const collectionUUID = 'zzzzz-4zz18-5o5tg0l9a57gxxx';
 
             // when
             await collectionService.deleteFiles(collectionUUID, filePaths);
 
             // then
-            expect(webdavClient.delete).toHaveBeenCalledTimes(1);
-            expect(webdavClient.delete).toHaveBeenCalledWith("c=zzzzz-tpzed-5o5tg0l9a57gxxx/root/1");
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${collectionUUID}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        '/root/1': '',
+                        '/secondFile': '',
+                        '/barefile.txt': '',
+                    },
+                }
+            );
+        });
+    });
+
+    describe('renameFile', () => {
+        it('should rename file', async () => {
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+            const collectionUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+            const collectionPdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+            const oldPath = '/old/path';
+            const newPath = '/new/filename';
+
+            await collectionService.renameFile(collectionUuid, collectionPdh, oldPath, newPath);
+
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${collectionUuid}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        [newPath]: `${collectionPdh}${oldPath}`,
+                        [oldPath]: '',
+                    },
+                }
+            );
         });
     });
-});
\ No newline at end of file
+
+    describe('copyFiles', () => {
+        it('should batch copy files', async () => {
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+            const filePaths: string[] = ['/root/1', '/secondFile', 'barefile.txt'];
+            const sourcePdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+            const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+            const destinationPath = '/destinationPath';
+
+            // when
+            await collectionService.copyFiles(sourcePdh, filePaths, {uuid: destinationUuid}, destinationPath);
+
+            // then
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${destinationUuid}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        [`${destinationPath}/1`]: `${sourcePdh}/root/1`,
+                        [`${destinationPath}/secondFile`]: `${sourcePdh}/secondFile`,
+                        [`${destinationPath}/barefile.txt`]: `${sourcePdh}/barefile.txt`,
+                    },
+                }
+            );
+        });
+
+        it('should copy files from rooth', async () => {
+            // Test copying from root paths
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+            const filePaths: string[] = ['/'];
+            const sourcePdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+            const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+            const destinationPath = '/destinationPath';
+
+            await collectionService.copyFiles(sourcePdh, filePaths, {uuid: destinationUuid}, destinationPath);
+
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${destinationUuid}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        [`${destinationPath}`]: `${sourcePdh}/`,
+                    },
+                }
+            );
+        });
+
+        it('should copy files to root path', async () => {
+            // Test copying to root paths
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+            const filePaths: string[] = ['/'];
+            const sourcePdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+            const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+            const destinationPath = '/';
+
+            await collectionService.copyFiles(sourcePdh, filePaths, {uuid: destinationUuid}, destinationPath);
+
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${destinationUuid}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        "/": `${sourcePdh}/`,
+                    },
+                }
+            );
+        });
+    });
+
+    describe('moveFiles', () => {
+        it('should batch move files', async () => {
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+            // given
+            const filePaths: string[] = ['/rootFile', '/secondFile', '/subpath/subfile', 'barefile.txt'];
+            const srcCollectionUUID = 'zzzzz-4zz18-5o5tg0l9a57gxxx';
+            const srcCollectionPdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+            const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+            const destinationPath = '/destinationPath';
+
+            // when
+            await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, {uuid: destinationUuid}, destinationPath);
+
+            // then
+            expect(serverApi.put).toHaveBeenCalledTimes(2);
+            // Verify copy
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${destinationUuid}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        [`${destinationPath}/rootFile`]: `${srcCollectionPdh}/rootFile`,
+                        [`${destinationPath}/secondFile`]: `${srcCollectionPdh}/secondFile`,
+                        [`${destinationPath}/subfile`]: `${srcCollectionPdh}/subpath/subfile`,
+                        [`${destinationPath}/barefile.txt`]: `${srcCollectionPdh}/barefile.txt`,
+                    },
+                }
+            );
+            // Verify delete
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${srcCollectionUUID}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        "/rootFile": "",
+                        "/secondFile": "",
+                        "/subpath/subfile": "",
+                        "/barefile.txt": "",
+                    },
+                }
+            );
+        });
+
+        it('should batch move files within collection', async () => {
+            serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+            // given
+            const filePaths: string[] = ['/one', '/two', '/subpath/subfile', 'barefile.txt'];
+            const srcCollectionUUID = 'zzzzz-4zz18-5o5tg0l9a57gxxx';
+            const srcCollectionPdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+            const destinationPath = '/destinationPath';
+
+            // when
+            await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, {uuid: srcCollectionUUID}, destinationPath);
+
+            // then
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            // Verify copy
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${srcCollectionUUID}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        [`${destinationPath}/one`]: `${srcCollectionPdh}/one`,
+                        ['/one']: '',
+                        [`${destinationPath}/two`]: `${srcCollectionPdh}/two`,
+                        ['/two']: '',
+                        [`${destinationPath}/subfile`]: `${srcCollectionPdh}/subpath/subfile`,
+                        ['/subpath/subfile']: '',
+                        [`${destinationPath}/barefile.txt`]: `${srcCollectionPdh}/barefile.txt`,
+                        ['/barefile.txt']: '',
+                    },
+                }
+            );
+        });
+
+        it('should abort batch move when copy fails', async () => {
+            // Simulate failure to copy
+            serverApi.put = jest.fn(() => Promise.reject({
+                data: {},
+                response: {
+                    "errors": ["error getting snapshot of \"rootFile\" from \"8cd9ce1dfa21c635b620b1bfee7aaa08+180\": file does not exist"]
+                }
+            }));
+            // given
+            const filePaths: string[] = ['/rootFile', '/secondFile', '/subpath/subfile', 'barefile.txt'];
+            const srcCollectionUUID = 'zzzzz-4zz18-5o5tg0l9a57gxxx';
+            const srcCollectionPdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180';
+
+            const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq';
+            const destinationPath = '/destinationPath';
+
+            // when
+            try {
+                await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, {uuid: destinationUuid}, destinationPath);
+            } catch {}
+
+            // then
+            expect(serverApi.put).toHaveBeenCalledTimes(1);
+            // Verify copy
+            expect(serverApi.put).toHaveBeenCalledWith(
+                `/collections/${destinationUuid}`, {
+                    collection: {
+                        preserve_version: true
+                    },
+                    replace_files: {
+                        [`${destinationPath}/rootFile`]: `${srcCollectionPdh}/rootFile`,
+                        [`${destinationPath}/secondFile`]: `${srcCollectionPdh}/secondFile`,
+                        [`${destinationPath}/subfile`]: `${srcCollectionPdh}/subpath/subfile`,
+                        [`${destinationPath}/barefile.txt`]: `${srcCollectionPdh}/barefile.txt`,
+                    },
+                }
+            );
+        });
+    });
+
+    describe('createDirectory', () => {
+        it('creates empty directory', async () => {
+            // given
+            const directoryNames = [
+                {in: 'newDir', out: 'newDir'},
+                {in: '/fooDir', out: 'fooDir'},
+                {in: '/anotherPath/', out: 'anotherPath'},
+                {in: 'trailingSlash/', out: 'trailingSlash'},
+            ];
+            const collectionUuid = 'zzzzz-tpzed-5o5tg0l9a57gxxx';
+
+            for (var i = 0; i < directoryNames.length; i++) {
+                serverApi.put = jest.fn(() => Promise.resolve({ data: {} }));
+                // when
+                await collectionService.createDirectory(collectionUuid, directoryNames[i].in);
+                // then
+                expect(serverApi.put).toHaveBeenCalledTimes(1);
+                expect(serverApi.put).toHaveBeenCalledWith(
+                    `/collections/${collectionUuid}`, {
+                        collection: {
+                            preserve_version: true
+                        },
+                        replace_files: {
+                            ["/" + directoryNames[i].out]: emptyCollectionPdh,
+                        },
+                    }
+                );
+            }
+        });
+    });
+
+});
index 92e4dfbae3e6d43f28f73b03dfce992da3f3fcfc..e50e5ed35026403c6332865d6b897c32a01f5605 100644 (file)
@@ -10,22 +10,30 @@ import { AuthService } from "../auth-service/auth-service";
 import { extractFilesData } from "./collection-service-files-response";
 import { TrashableResourceService } from "services/common-service/trashable-resource-service";
 import { ApiActions } from "services/api/api-actions";
-import { customEncodeURI } from "common/url";
 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 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",
         ]);
     }
 
@@ -35,48 +43,71 @@ export class CollectionService extends TrashableResourceService<CollectionResour
         return super.get(uuid, showErrors, selectParam, session);
     }
 
-    create(data?: Partial<CollectionResource>) {
-        return super.create({ ...data, preserveVersion: true });
+    create(data?: Partial<CollectionResource>, showErrors?: boolean) {
+        return super.create({ ...data, preserveVersion: true }, showErrors);
     }
 
-    update(uuid: string, data: Partial<CollectionResource>) {
-        const select = [...Object.keys(data), 'version', 'modifiedAt'];
-        return super.update(uuid, { ...data, preserveVersion: true }, select);
+    update(uuid: string, data: Partial<CollectionResource>, showErrors?: boolean) {
+        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();
     }
 
-    async deleteFiles(collectionUuid: string, filePaths: string[]) {
-        const sortedUniquePaths = Array.from(new Set(filePaths))
-            .sort((a, b) => a.length - b.length)
-            .reduce((acc, currentPath) => {
-                const parentPathFound = acc.find((parentPath) => currentPath.indexOf(`${parentPath}/`) > -1);
-
-                if (!parentPathFound) {
-                    return [...acc, currentPath];
-                }
-
-                return acc;
-            }, []);
-
-        for (const path of sortedUniquePaths) {
-            if (path.indexOf(collectionUuid) === -1) {
-                await this.webdavClient.delete(`c=${collectionUuid}${path}`);
+    private combineFilePath(parts: string[]) {
+        return parts.reduce((path, part) => {
+            // Trim leading and trailing slashes
+            const trimmedPart = part.split("/").filter(Boolean).join("/");
+            if (trimmedPart.length) {
+                const separator = path.endsWith("/") ? "" : "/";
+                return `${path}${separator}${trimmedPart}`;
             } else {
-                await this.webdavClient.delete(`c=${path}`);
+                return path;
             }
+        }, "/");
+    }
+
+    private replaceFiles(data: CollectionPartialUpdateOrCreate, fileMap: {}, showErrors?: boolean) {
+        const payload = {
+            collection: {
+                preserve_version: true,
+                ...CommonService.mapKeys(snakeCase)(data),
+                // Don't send uuid in payload when creating
+                uuid: undefined,
+            },
+            replace_files: fileMap,
+        };
+        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
+            );
         }
-        await this.update(collectionUuid, { preserveVersion: true });
     }
 
-    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);
@@ -84,39 +115,140 @@ export class CollectionService extends TrashableResourceService<CollectionResour
         await this.update(collectionUuid, { preserveVersion: true });
     }
 
-    async moveFile(collectionUuid: string, oldPath: string, newPath: string) {
-        await this.webdavClient.move(
-            `c=${collectionUuid}${oldPath}`,
-            `c=${collectionUuid}/${customEncodeURI(newPath)}`
+    async renameFile(collectionUuid: string, collectionPdh: string, oldPath: string, newPath: string) {
+        return this.replaceFiles(
+            { uuid: collectionUuid },
+            {
+                [this.combineFilePath([newPath])]: `${collectionPdh}${this.combineFilePath([oldPath])}`,
+                [this.combineFilePath([oldPath])]: "",
+            }
         );
-        await this.update(collectionUuid, { preserveVersion: true });
     }
 
     extendFileURL = (file: CollectionDirectory | CollectionFile) => {
-        const baseUrl = this.webdavClient.defaults.baseURL.endsWith('/')
-            ? this.webdavClient.defaults.baseURL.slice(0, -1)
-            : this.webdavClient.defaults.baseURL;
+        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.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('//', '/');
+    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);
+
+                if (!parentPathFound) {
+                    return [...acc, currentPath];
+                }
+
+                return acc;
+            }, []);
+
+        const fileMap = optimizedFiles.reduce((obj, filePath) => {
+            return {
+                ...obj,
+                [this.combineFilePath([filePath])]: "",
+            };
+        }, {});
+
+        return this.replaceFiles({ uuid: collectionUuid }, fileMap, showErrors);
+    }
+
+    copyFiles(
+        sourcePdh: string,
+        files: string[],
+        destinationCollection: CollectionPartialUpdateOrCreate,
+        destinationPath: string,
+        showErrors?: boolean
+    ) {
+        const fileMap = files.reduce((obj, sourceFile) => {
+            const fileBasename = sourceFile.split("/").filter(Boolean).slice(-1).join("");
+            return {
+                ...obj,
+                [this.combineFilePath([destinationPath, fileBasename])]: `${sourcePdh}${this.combineFilePath([sourceFile])}`,
+            };
+        }, {});
+
+        return this.replaceFiles(destinationCollection, fileMap, showErrors);
+    }
+
+    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 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;
+                }
+            }, {});
+
+            if (errors.length === 0) {
+                return this.replaceFiles({ uuid: sourceUuid }, fileMap, showErrors);
+            } else {
+                return Promise.reject({ errors });
+            }
+        } else {
+            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 };
+
+        return this.replaceFiles({ uuid: collectionUuid }, fileMap, showErrors);
     }
 }
index b94756ae02f18f0cf7f8ebaa85ec5dc950b17fbc..7f47f20ef77f296795b9003b1ef1df00ff679401 100644 (file)
@@ -136,8 +136,9 @@ describe("CommonResourceService", () => {
         await commonResourceService.list({ filters: tooBig });
         expect(axiosMock.history.get.length).toBe(0);
         expect(axiosMock.history.post.length).toBe(1);
-        expect(axiosMock.history.post[0].data.get('filters')).toBe(`[${tooBig}]`);
-        expect(axiosMock.history.post[0].params._method).toBe('GET');
+        const postParams = new URLSearchParams(axiosMock.history.post[0].data);
+        expect(postParams.get('filters')).toBe(`[${tooBig}]`);
+        expect(postParams.get('_method')).toBe('GET');
     });
 
     it("#list using GET when query string is not too big", async () => {
index c6306779a9ee8cef4bb6eff287c485462f5e898a..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,25 +24,31 @@ 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>) {
+    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),
             };
         }
-        return super.create(payload);
+        return super.create(payload, showErrors);
     }
 
-    update(uuid: string, data: Partial<T>, select?: string[]) {
+    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),
             };
@@ -48,12 +56,12 @@ export class CommonResourceService<T extends Resource> extends CommonService<T>
                 payload.select = ['uuid', ...select.map(field => snakeCase(field))];
             };
         }
-        return super.update(uuid, payload);
+        return super.update(uuid, payload, showErrors);
     }
 }
 
 export const getCommonResourceServiceError = (errorResponse: any) => {
-    if ('errors' in errorResponse && 'errorToken' 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 f16a2024ad2c3a1775d4a5fb7e83f784e08554e4..8e9fe631701bd0877075774f6d3666dd9fa73ec1 100644 (file)
@@ -87,11 +87,13 @@ export class CommonService<T> {
                 return mapKeys ? CommonService.mapResponseKeys(response) : response.data;
             })
             .catch(({ response }) => {
-                actions.progressFn(reqId, false);
-                const errors = CommonService.mapResponseKeys(response) as Errors;
-                errors.status = response.status;
-                actions.errorFn(reqId, errors, showErrors);
-                throw errors;
+                if (response) {
+                    actions.progressFn(reqId, false);
+                    const errors = CommonService.mapResponseKeys(response) as Errors;
+                    errors.status = response.status;
+                    actions.errorFn(reqId, errors, showErrors);
+                    throw errors;
+                }
             });
     }
 
@@ -105,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
         );
     }
 
@@ -152,11 +156,14 @@ export class CommonService<T> {
             return CommonService.defaultResponse(
                 this.serverApi.get(`/${this.resourceType}`, { params }),
                 this.actions,
+                true,
                 showErrors
             );
         } else {
             // Using the POST special case to avoid URI length 414 errors.
-            const formData = new FormData();
+            // We must use urlencoded post body since api doesn't support form data
+            // const formData = new FormData();
+            const formData = new URLSearchParams();
             formData.append("_method", "GET");
             Object.keys(params).forEach(key => {
                 if (params[key] !== undefined) {
@@ -164,23 +171,22 @@ export class CommonService<T> {
                 }
             });
             return CommonService.defaultResponse(
-                this.serverApi.post(`/${this.resourceType}`, formData, {
-                    params: {
-                        _method: 'GET'
-                    }
-                }),
+                this.serverApi.post(`/${this.resourceType}`, formData, {}),
                 this.actions,
+                true,
                 showErrors
             );
         }
     }
 
-    update(uuid: string, data: Partial<T>) {
+    update(uuid: string, data: Partial<T>, showErrors?: boolean) {
         this.validateUuid(uuid);
         return CommonService.defaultResponse(
             this.serverApi
                 .put<T>(`/${this.resourceType}/${uuid}`, data && CommonService.mapKeys(snakeCase)(data)),
-            this.actions
+            this.actions,
+            undefined, // 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 dc6a798cf1c48c0ae4fa168c707a5266cc13495a..b9f47df0dbb97e01e7f020c84ebf2eb1139c4339 100644 (file)
@@ -2,18 +2,22 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+import { CancelToken } from 'axios';
 import { snakeCase, camelCase } from "lodash";
 import { CommonResourceService } from 'services/common-service/common-resource-service';
-import { ListResults, ListArguments } from 'services/common-service/common-service';
-import { AxiosInstance, AxiosRequestConfig } from "axios";
-import { CollectionResource } from "models/collection";
-import { ProjectResource } from "models/project";
-import { ProcessResource } from "models/process";
-import { WorkflowResource } from "models/workflow";
-import { TrashableResourceService } from "services/common-service/trashable-resource-service";
-import { ApiActions } from "services/api/api-actions";
-import { GroupResource } from "models/group";
-import { Session } from "models/session";
+import {
+    ListResults,
+    ListArguments,
+} from 'services/common-service/common-service';
+import { AxiosInstance, AxiosRequestConfig } from 'axios';
+import { CollectionResource } from 'models/collection';
+import { ProjectResource } from 'models/project';
+import { ProcessResource } from 'models/process';
+import { WorkflowResource } from 'models/workflow';
+import { TrashableResourceService } from 'services/common-service/trashable-resource-service';
+import { ApiActions } from 'services/api/api-actions';
+import { GroupResource } from 'models/group';
+import { Session } from 'models/session';
 
 export interface ContentsArguments {
     limit?: number;
@@ -23,6 +27,7 @@ export interface ContentsArguments {
     recursive?: boolean;
     includeTrash?: boolean;
     excludeHomeProject?: boolean;
+    select?: string[];
 }
 
 export interface SharedArguments extends ListArguments {
@@ -30,51 +35,70 @@ export interface SharedArguments extends ListArguments {
 }
 
 export type GroupContentsResource =
-    CollectionResource |
-    ProjectResource |
-    ProcessResource |
-    WorkflowResource;
-
-export class GroupsService<T extends GroupResource = GroupResource> extends TrashableResourceService<T> {
+    | CollectionResource
+    | ProjectResource
+    | ProcessResource
+    | WorkflowResource;
 
+export class GroupsService<
+    T extends GroupResource = GroupResource
+    > extends TrashableResourceService<T> {
     constructor(serverApi: AxiosInstance, actions: ApiActions) {
-        super(serverApi, "groups", actions);
+        super(serverApi, 'groups', actions);
     }
 
-    async contents(uuid: string, args: ContentsArguments = {}, session?: Session): Promise<ListResults<GroupContentsResource>> {
-        const { filters, order, ...other } = args;
+    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
+            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),
         };
-        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 };
+            cfg.headers = { Authorization: 'Bearer ' + session.token };
+        }
+
+        if (cancelToken) {
+            cfg.cancelToken = cancelToken;
         }
 
         const response = await CommonResourceService.defaultResponse(
-            this.serverApi.get(this.resourceType + pathUrl, cfg), this.actions, false
+            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>> {
+    shared(
+        params: SharedArguments = {}
+    ): Promise<ListResults<GroupContentsResource>> {
         return CommonResourceService.defaultResponse(
-            this.serverApi
-                .get(this.resourceType + '/shared', { params }),
+            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 07b083fdab34a06ed7890fa001172886ab960451..442a6ab94fc78dae1974459e6fc20c8013af405f 100644 (file)
@@ -9,9 +9,9 @@ import { ListArguments } from "services/common-service/common-service";
 import { FilterBuilder, joinFilters } from "services/api/filter-builder";
 export class ProjectService extends GroupsService<ProjectResource> {
 
-    create(data: Partial<ProjectResource>) {
+    create(data: Partial<ProjectResource>, showErrors?: boolean) {
         const projectData = { ...data, groupClass: GroupClass.PROJECT };
-        return super.create(projectData);
+        return super.create(projectData, showErrors);
     }
 
     list(args: ListArguments = {}) {
index 2afb843f6c7760303512fd240e763ccaaa588d5e..cd04a65feff5286be41bf030fdcf241da8bf0a14 100644 (file)
@@ -39,14 +39,14 @@ export function setAuthorizationHeader(services: ServiceRepository, token: strin
     services.apiClient.defaults.headers.common = {
         Authorization: `Bearer ${token}`
     };
-    services.webdavClient.defaults.headers = {
-        Authorization: `Bearer ${token}`
-    };
+    services.keepWebdavClient.setAuthorization(`Bearer ${token}`);
+    services.apiWebdavClient.setAuthorization(`Bearer ${token}`);
 }
 
 export function removeAuthorizationHeader(services: ServiceRepository) {
     delete services.apiClient.defaults.headers.common;
-    delete services.webdavClient.defaults.headers.common;
+    services.keepWebdavClient.setAuthorization(undefined);
+    services.apiWebdavClient.setAuthorization(undefined);
 }
 
 export const createServices = (config: Config, actions: ApiActions, useApiClient?: AxiosInstance) => {
@@ -57,8 +57,13 @@ export const createServices = (config: Config, actions: ApiActions, useApiClient
     const apiClient = useApiClient || Axios.create({ headers: {} });
     apiClient.defaults.baseURL = config.baseUrl;
 
-    const webdavClient = new WebDAV();
-    webdavClient.defaults.baseURL = config.keepWebServiceUrl;
+    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);
@@ -67,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);
@@ -76,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();
@@ -111,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 3d30eaec46dcc58ebdf9aeb485bbdd968314c302..c33cd823cff8bda05787bcd0c78e4af8c02ac58e 100644 (file)
@@ -2,9 +2,13 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+import { Dispatch } from "redux";
 import { bindDataExplorerActions } from "../data-explorer/data-explorer-action";
 
 export const ALL_PROCESSES_PANEL_ID = "allProcessesPanel";
 export const allProcessesPanelActions = bindDataExplorerActions(ALL_PROCESSES_PANEL_ID);
 
-export const loadAllProcessesPanel = () => allProcessesPanelActions.REQUEST_ITEMS();
+export const loadAllProcessesPanel = () => (dispatch: Dispatch) => {
+    dispatch(allProcessesPanelActions.RESET_EXPLORER_SEARCH_VALUE());
+    dispatch(allProcessesPanelActions.REQUEST_ITEMS());
+}
index 5d5e77d6c64396af8628fb9d1ccb9e3aef3cea83..955d9689afc7f02eb471d0e965ad376cc9af16a4 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { DataExplorerMiddlewareService, dataExplorerToListParams, getDataExplorerColumnFilters } from "store/data-explorer/data-explorer-middleware-service";
+import { DataExplorerMiddlewareService, dataExplorerToListParams, getDataExplorerColumnFilters, getOrder } from "store/data-explorer/data-explorer-middleware-service";
 import { RootState } from "../store";
 import { ServiceRepository } from "services/services";
 import { FilterBuilder, joinFilters } from "services/api/filter-builder";
@@ -11,7 +11,7 @@ import { Dispatch, MiddlewareAPI } from "redux";
 import { resourcesActions } from "store/resources/resources-actions";
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
-import { getDataExplorer, DataExplorer, getSortColumn } from "store/data-explorer/data-explorer-reducer";
+import { getDataExplorer, DataExplorer } from "store/data-explorer/data-explorer-reducer";
 import { loadMissingProcessesInformation } from "store/project-panel/project-panel-middleware-service";
 import { DataColumns } from "components/data-table/data-table";
 import {
@@ -20,22 +20,20 @@ import {
     serializeOnlyProcessTypeFilters
 } from "../resource-type-filters/resource-type-filters";
 import { AllProcessesPanelColumnNames } from "views/all-processes-panel/all-processes-panel";
-import { OrderBuilder, OrderDirection } from "services/api/order-builder";
-import { ProcessResource } from "models/process";
-import { SortDirection } from "components/data-table/data-column";
+import { containerRequestFieldsNoMounts, ContainerRequestResource } from "models/container-request";
 
 export class AllProcessesPanelMiddlewareService 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 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),
@@ -43,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({
@@ -53,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,
@@ -66,52 +64,14 @@ export class AllProcessesPanelMiddlewareService extends DataExplorerMiddlewareSe
     }
 }
 
-// Until the api supports unselecting fields, we need a list of all other fields to omit mounts
-export const containerRequestFieldsNoMounts = [
-    "command",
-    "container_count_max",
-    "container_count",
-    "container_image",
-    "container_uuid",
-    "created_at",
-    "cwd",
-    "description",
-    "environment",
-    "etag",
-    "expires_at",
-    "filters",
-    "href",
-    "kind",
-    "log_uuid",
-    "modified_at",
-    "modified_by_client_uuid",
-    "modified_by_user_uuid",
-    "name",
-    "output_name",
-    "output_path",
-    "output_properties",
-    "output_storage_classes",
-    "output_ttl",
-    "output_uuid",
-    "owner_uuid",
-    "priority",
-    "properties",
-    "requesting_container_uuid",
-    "runtime_constraints",
-    "scheduling_parameters",
-    "state",
-    "use_existing",
-    "uuid",
-];
-
-const getParams = ( dataExplorer: DataExplorer ) => ({
+const getParams = (dataExplorer: DataExplorer) => ({
     ...dataExplorerToListParams(dataExplorer),
-    order: getOrder(dataExplorer),
+    order: getOrder<ContainerRequestResource>(dataExplorer),
     filters: getFilters(dataExplorer)
 });
 
-const getFilters = ( dataExplorer: DataExplorer ) => {
-    const columns = dataExplorer.columns as DataColumns<string>;
+const getFilters = (dataExplorer: DataExplorer) => {
+    const columns = dataExplorer.columns as DataColumns<string, ContainerRequestResource>;
     const statusColumnFilters = getDataExplorerColumnFilters(columns, 'Status');
     const activeStatusFilter = Object.keys(statusColumnFilters).find(
         filterName => statusColumnFilters[filterName].selected
@@ -128,23 +88,6 @@ const getFilters = ( dataExplorer: DataExplorer ) => {
     );
 };
 
-const getOrder = (dataExplorer: DataExplorer) => {
-    const sortColumn = getSortColumn(dataExplorer);
-    const order = new OrderBuilder<ProcessResource>();
-    if (sortColumn) {
-        const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC
-            ? OrderDirection.ASC
-            : OrderDirection.DESC;
-
-        const columnName = sortColumn && sortColumn.name === AllProcessesPanelColumnNames.NAME ? "name" : "createdAt";
-        return order
-            .addOrder(sortDirection, columnName)
-            .getOrder();
-    } else {
-        return order.getOrder();
-    }
-};
-
 const allProcessesPanelDataExplorerIsNotSet = () =>
     snackbarActions.OPEN_SNACKBAR({
         message: 'All Processes panel is not ready.',
index d67dcbad7a905401794dca6096b265ed8c55e927..9ab02549904c10770590c88c107803db53f0d027 100644 (file)
@@ -4,18 +4,14 @@
 
 import { ServiceRepository } from 'services/services';
 import { MiddlewareAPI, Dispatch } from 'redux';
-import { DataExplorerMiddlewareService, dataExplorerToListParams, listResultsToDataExplorerItemsMeta } from 'store/data-explorer/data-explorer-middleware-service';
+import { DataExplorerMiddlewareService, dataExplorerToListParams, getOrder, listResultsToDataExplorerItemsMeta } from 'store/data-explorer/data-explorer-middleware-service';
 import { RootState } from 'store/store';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 import { DataExplorer, getDataExplorer } from 'store/data-explorer/data-explorer-reducer';
 import { updateResources } from 'store/resources/resources-actions';
-import { getSortColumn } from "store/data-explorer/data-explorer-reducer";
 import { apiClientAuthorizationsActions } from 'store/api-client-authorizations/api-client-authorizations-actions';
-import { OrderDirection, OrderBuilder } from 'services/api/order-builder';
 import { ListResults } from 'services/common-service/common-service';
 import { ApiClientAuthorization } from 'models/api-client-authorization';
-import { ApiClientAuthorizationPanelColumnNames } from 'views/api-client-authorization-panel/api-client-authorization-panel-root';
-import { SortDirection } from 'components/data-table/data-column';
 
 export class ApiClientAuthorizationMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
@@ -37,26 +33,9 @@ export class ApiClientAuthorizationMiddlewareService extends DataExplorerMiddlew
 
 export const getParams = (dataExplorer: DataExplorer) => ({
     ...dataExplorerToListParams(dataExplorer),
-    order: getOrder(dataExplorer)
+    order: getOrder<ApiClientAuthorization>(dataExplorer)
 });
 
-const getOrder = (dataExplorer: DataExplorer) => {
-    const sortColumn = getSortColumn(dataExplorer);
-    const order = new OrderBuilder<ApiClientAuthorization>();
-    if (sortColumn) {
-        const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC
-            ? OrderDirection.ASC
-            : OrderDirection.DESC;
-
-        const columnName = sortColumn && sortColumn.name === ApiClientAuthorizationPanelColumnNames.UUID ? "uuid" : "updatedAt";
-        return order
-            .addOrder(sortDirection, columnName)
-            .getOrder();
-    } else {
-        return order.getOrder();
-    }
-};
-
 export const setItems = (listResults: ListResults<ApiClientAuthorization>) =>
     apiClientAuthorizationsActions.SET_ITEMS({
         ...listResultsToDataExplorerItemsMeta(listResults),
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 7fc9df774358a73d3d9448e604f83bf8693242c0..145a461c8fac717dcfb66c9f3a43779b765c83c6 100644 (file)
@@ -20,7 +20,7 @@ import { getTokenV2 } from 'models/api-client-authorization';
 
 export const authActions = unionize({
     LOGIN: {},
-    LOGOUT: ofType<{ deleteLinkData: boolean }>(),
+    LOGOUT: ofType<{ deleteLinkData: boolean, preservePath: boolean }>(),
     SET_CONFIG: ofType<{ config: Config }>(),
     SET_EXTRA_TOKEN: ofType<{ extraApiToken: string, extraApiTokenExpiration?: Date }>(),
     RESET_EXTRA_TOKEN: {},
@@ -92,6 +92,9 @@ export const saveApiToken = (token: string) => async (dispatch: Dispatch, getSta
 
     // If the token is from a LoginCluster federation, get user & token data
     // from the token issuing cluster.
+    if (!config) {
+        return;
+    }
     const lc = (config as Config).loginCluster
     const tokenCluster = tokenParts.length === 3
         ? tokenParts[1].substring(0, 5)
@@ -110,7 +113,7 @@ export const saveApiToken = (token: string) => async (dispatch: Dispatch, getSta
         const tokenLocation = await svc.authService.getStorageType();
         dispatch(authActions.INIT_USER({ user, token, tokenExpiration, tokenLocation }));
     } catch (e) {
-        dispatch(authActions.LOGOUT({ deleteLinkData: false }));
+        dispatch(authActions.LOGOUT({ deleteLinkData: false, preservePath: false }));
     }
 };
 
@@ -127,7 +130,7 @@ export const getNewExtraToken = (reuseStored: boolean = false) =>
                 const client = await svc.apiClientAuthorizationService.get('current');
                 dispatch(authActions.SET_EXTRA_TOKEN({
                     extraApiToken: extraToken,
-                    extraApiTokenExpiration: client.expiresAt ? new Date(client.expiresAt): undefined,
+                    extraApiTokenExpiration: client.expiresAt ? new Date(client.expiresAt) : undefined,
                 }));
                 return extraToken;
             } catch (e) {
@@ -145,7 +148,7 @@ export const getNewExtraToken = (reuseStored: boolean = false) =>
             const newExtraToken = getTokenV2(client);
             dispatch(authActions.SET_EXTRA_TOKEN({
                 extraApiToken: newExtraToken,
-                extraApiTokenExpiration: client.expiresAt ? new Date(client.expiresAt): undefined,
+                extraApiTokenExpiration: client.expiresAt ? new Date(client.expiresAt) : undefined,
             }));
             return newExtraToken;
         } catch {
@@ -160,8 +163,8 @@ export const login = (uuidPrefix: string, homeCluster: string, loginCluster: str
         dispatch(authActions.LOGIN());
     };
 
-export const logout = (deleteLinkData: boolean = false) =>
+export const logout = (deleteLinkData: boolean = false, preservePath: boolean = false) =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) =>
-        dispatch(authActions.LOGOUT({ deleteLinkData }));
+        dispatch(authActions.LOGOUT({ deleteLinkData, preservePath }))
 
 export type AuthAction = UnionOf<typeof authActions>;
index 9ded9e674ec02ddb8a0202e64d84276e4a6980da..5a0364ebf9000162ef7544dccd0607420ba2b290 100644 (file)
@@ -36,10 +36,10 @@ describe("AuthMiddleware", () => {
         window.location.assign = jest.fn();
         const next = jest.fn();
         const middleware = authMiddleware(services)(store)(next);
-        middleware(authActions.LOGOUT({deleteLinkData: false}));
+        middleware(authActions.LOGOUT({deleteLinkData: false, preservePath: false}));
         expect(window.location.assign).toBeCalledWith(
             `/logout?api_token=someToken&return_to=${location.protocol}//${location.host}`
         );
         expect(localStorage.getItem(API_TOKEN_KEY)).toBeFalsy();
     });
-});
\ No newline at end of file
+});
index 87a1253b8052840393a5abef4b3bcf66b668d0a7..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,10 +66,10 @@ 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 }) => {
+        LOGOUT: ({ deleteLinkData, preservePath }) => {
             next(action);
             if (deleteLinkData) {
                 services.linkAccountService.removeAccountToLink();
@@ -69,7 +79,7 @@ export const authMiddleware = (services: ServiceRepository): Middleware => store
             services.authService.removeSessions();
             services.authService.removeUser();
             removeAuthorizationHeader(services);
-            services.authService.logout(token || '');
+            services.authService.logout(token || '', preservePath);
         },
         default: () => next(action)
     });
diff --git a/src/store/banner/banner-action.ts b/src/store/banner/banner-action.ts
new file mode 100644 (file)
index 0000000..808ca82
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { RootState } from "store/store";
+import { unionize, UnionOf } from 'common/unionize';
+
+export const bannerReducerActions = unionize({
+    OPEN_BANNER: {},
+    CLOSE_BANNER: {},
+});
+
+export type BannerAction = UnionOf<typeof bannerReducerActions>;
+
+export const openBanner = () =>
+    async (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch(bannerReducerActions.OPEN_BANNER());
+    };
+
+export const closeBanner = () =>
+    async (dispatch: Dispatch<any>, getState: () => RootState) => {
+        dispatch(bannerReducerActions.CLOSE_BANNER());
+    };
+
+export default {
+    openBanner,
+    closeBanner
+};
diff --git a/src/store/banner/banner-reducer.ts b/src/store/banner/banner-reducer.ts
new file mode 100644 (file)
index 0000000..8009f4b
--- /dev/null
@@ -0,0 +1,26 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { BannerAction, bannerReducerActions } from "./banner-action";
+
+export interface BannerState {
+    isOpen: boolean;
+}
+
+const initialState = {
+    isOpen: false,
+};
+
+export const bannerReducer = (state: BannerState = initialState, action: BannerAction) =>
+    bannerReducerActions.match(action, {
+        default: () => state,
+        OPEN_BANNER: () => ({
+             ...state,
+             isOpen: true,
+        }),
+        CLOSE_BANNER: () => ({
+            ...state,
+            isOpen: false,
+       }),
+    });
index 08e1a132fd12e23f722cb2bc4e88f6c0374caab0..9aebeb904c64115e574624163718d2fea43bcb82 100644 (file)
@@ -5,10 +5,7 @@
 import { Dispatch } from 'redux';
 import { RootState } from 'store/store';
 import { getUserUuid } from "common/getuser";
-import { Breadcrumb } from 'components/breadcrumbs/breadcrumbs';
 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';
@@ -18,46 +15,108 @@ import { ResourceKind } from 'models/resource';
 import { GroupResource } from 'models/group';
 import { extractUuidKind } from 'models/resource';
 import { UserResource } from 'models/user';
+import { FilterBuilder } from 'services/api/filter-builder';
+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, 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 interface ResourceBreadcrumb extends Breadcrumb {
-    uuid: string;
-}
-
-export const setBreadcrumbs = (breadcrumbs: any, currentItem?: any) => {
+export const setBreadcrumbs = (breadcrumbs: any, currentItem?: CollectionResource | ContainerRequestResource | GroupResource | WorkflowResource) => {
     if (currentItem) {
-        const addLastItem = { label: currentItem.name, uuid: currentItem.uuid };
-        breadcrumbs.push(addLastItem);
+        breadcrumbs.push(resourceToBreadcrumb(currentItem));
     }
     return propertiesActions.SET_PROPERTY({ key: BREADCRUMBS, value: breadcrumbs });
 };
 
+const resourceToBreadcrumbIcon = (resource: CollectionResource | ContainerRequestResource | GroupResource | WorkflowResource): IconType | undefined => {
+    switch (resource.kind) {
+        case ResourceKind.PROJECT:
+            return ProjectIcon;
+        case ResourceKind.PROCESS:
+            return ProcessIcon;
+        case ResourceKind.COLLECTION:
+            return CollectionIcon;
+        case ResourceKind.WORKFLOW:
+            return WorkflowIcon;
+        default:
+            return undefined;
+    }
+}
 
-const getSidePanelTreeBreadcrumbs = (uuid: string) => (treePicker: TreePicker): ResourceBreadcrumb[] => {
-    const nodes = getSidePanelTreeBranch(uuid)(treePicker);
-    return nodes.map(node =>
-        typeof node.value === 'string'
-            ? { label: node.value, uuid: node.id }
-            : { label: node.value.name, uuid: node.value.uuid });
-};
+const resourceToBreadcrumb = (resource: CollectionResource | ContainerRequestResource | GroupResource | WorkflowResource): Breadcrumb => ({
+    label: resource.name,
+    uuid: resource.uuid,
+    icon: resourceToBreadcrumbIcon(resource),
+})
 
 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);
-
-        if (uuidKind === ResourceKind.COLLECTION) {
-            const collectionItem = item ? item : await services.collectionService.get(currentUuid);
-            dispatch(setBreadcrumbs(breadcrumbs, collectionItem));
-        } else if (uuidKind === ResourceKind.PROCESS) {
-            const processItem = await services.containerRequestService.get(currentUuid);
-            dispatch(setBreadcrumbs(breadcrumbs, processItem));
+        try {
+            dispatch(progressIndicatorActions.START_WORKING(uuid + "-breadcrumbs"));
+            const ancestors = await services.ancestorsService.ancestors(uuid, '');
+            dispatch(updateResources(ancestors));
+
+            let breadcrumbs: Breadcrumb[] = [];
+            const { collectionPanel: { item } } = getState();
+
+            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)
+                });
+            }
+
+            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));
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(uuid + "-breadcrumbs"));
         }
-        dispatch(setBreadcrumbs(breadcrumbs));
     };
 
 export const setSharedWithMeBreadcrumbs = (uuid: string) =>
@@ -68,35 +127,96 @@ 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: ResourceBreadcrumb[] = [
-            { label: category, uuid: category }
-        ];
-        const { collectionPanel: { item } } = getState();
-        const path = getState().router.location!.pathname;
-        const currentUuid = path.split('/')[2];
-        const uuidKind = extractUuidKind(currentUuid);
-        const breadcrumbs = ancestors.reduce((breadcrumbs, ancestor) =>
-            ancestor.kind === ResourceKind.GROUP
-                ? [...breadcrumbs, { label: ancestor.name, uuid: ancestor.uuid }]
-                : breadcrumbs,
-            initialBreadcrumbs);
-        if (uuidKind === ResourceKind.COLLECTION) {
-            const collectionItem = item ? item : await services.collectionService.get(currentUuid);
-            dispatch(setBreadcrumbs(breadcrumbs, collectionItem));
-        } else if (uuidKind === ResourceKind.PROCESS) {
-            const processItem = await services.containerRequestService.get(currentUuid);
-            dispatch(setBreadcrumbs(breadcrumbs, processItem));
+        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));
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(uuid + "-breadcrumbs"));
         }
-        dispatch(setBreadcrumbs(breadcrumbs));
     };
 
+const getProcessParent = (childProcess: ContainerRequestResource) =>
+    async (services: ServiceRepository): Promise<ContainerRequestResource | undefined> => {
+        if (childProcess.requestingContainerUuid) {
+            const parentProcesses = await services.containerRequestService.list({
+                order: new OrderBuilder<ProcessResource>().addAsc('createdAt').getOrder(),
+                filters: new FilterBuilder().addEqual('container_uuid', childProcess.requestingContainerUuid).getFilters(),
+                select: containerRequestFieldsNoMounts,
+            });
+            if (parentProcesses.items.length > 0) {
+                return parentProcesses.items[0];
+            } else {
+                return undefined;
+            }
+        } else {
+            return undefined;
+        }
+    }
+
+const getCollectionParent = (collection: CollectionResource) =>
+    async (services: ServiceRepository): Promise<ContainerRequestResource | undefined> => {
+        const parentOutputPromise = services.containerRequestService.list({
+            order: new OrderBuilder<ProcessResource>().addAsc('createdAt').getOrder(),
+            filters: new FilterBuilder().addEqual('output_uuid', collection.uuid).getFilters(),
+            select: containerRequestFieldsNoMounts,
+        });
+        const parentLogPromise = services.containerRequestService.list({
+            order: new OrderBuilder<ProcessResource>().addAsc('createdAt').getOrder(),
+            filters: new FilterBuilder().addEqual('log_uuid', collection.uuid).getFilters(),
+            select: containerRequestFieldsNoMounts,
+        });
+        const [parentOutput, parentLog] = await Promise.all([parentOutputPromise, parentLogPromise]);
+        return parentOutput.items.length > 0 ?
+            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));
@@ -114,15 +234,23 @@ export const setProcessBreadcrumbs = (processUuid: string) =>
     };
 
 export const setGroupsBreadcrumbs = () =>
-    setBreadcrumbs([{ label: SidePanelTreeCategory.GROUPS }]);
+    setBreadcrumbs([{
+        label: SidePanelTreeCategory.GROUPS,
+        uuid: SidePanelTreeCategory.GROUPS,
+        icon: getSidePanelIcon(SidePanelTreeCategory.GROUPS)
+    }]);
 
 export const setGroupDetailsBreadcrumbs = (groupUuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
 
         const group = getResource<GroupResource>(groupUuid)(getState().resources);
 
-        const breadcrumbs: ResourceBreadcrumb[] = [
-            { label: SidePanelTreeCategory.GROUPS, uuid: SidePanelTreeCategory.GROUPS },
+        const breadcrumbs: Breadcrumb[] = [
+            {
+                label: SidePanelTreeCategory.GROUPS,
+                uuid: SidePanelTreeCategory.GROUPS,
+                icon: getSidePanelIcon(SidePanelTreeCategory.GROUPS)
+            },
             { label: group ? group.name : (await services.groupsService.get(groupUuid)).name, uuid: groupUuid },
         ];
 
@@ -139,14 +267,14 @@ 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);
-            const breadcrumbs: ResourceBreadcrumb[] = [
+                || await services.userService.get(userUuid, false);
+            const breadcrumbs: Breadcrumb[] = [
                 { label: USERS_PANEL_LABEL, uuid: USERS_PANEL_LABEL },
                 { label: user ? user.username : userUuid, uuid: userUuid },
             ];
             dispatch(setBreadcrumbs(breadcrumbs));
         } catch (e) {
-            const breadcrumbs: ResourceBreadcrumb[] = [
+            const breadcrumbs: Breadcrumb[] = [
                 { label: USERS_PANEL_LABEL, uuid: USERS_PANEL_LABEL },
                 { label: 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 8c5e5b5a67df1f345ccd0ef645b7ec2e25e858c0..547f1534d111d6bc7d99b716b8891b7dff5d8359 100644 (file)
@@ -65,10 +65,11 @@ export const removeCollectionsSelectedFiles = () =>
 
 export const FILE_REMOVE_DIALOG = 'fileRemoveDialog';
 
-export const openFileRemoveDialog = (filePath: string) =>
+export const openFileRemoveDialog = (fileUuid: string) =>
     (dispatch: Dispatch, getState: () => RootState) => {
-        const file = getNodeValue(filePath)(getState().collectionPanelFiles);
+        const file = getNodeValue(fileUuid)(getState().collectionPanelFiles);
         if (file) {
+            const filePath = getFileFullPath(file);
             const isDirectory = file.type === CollectionFileType.DIRECTORY;
             const title = isDirectory
                 ? 'Removing directory'
@@ -129,7 +130,7 @@ export const renameFile = (newFullPath: string) =>
                 dispatch(startSubmit(RENAME_FILE_DIALOG));
                 const oldPath = getFileFullPath(file);
                 const newPath = newFullPath;
-                services.collectionService.moveFile(currentCollection.uuid, oldPath, newPath).then(() => {
+                services.collectionService.renameFile(currentCollection.uuid, currentCollection.portableDataHash, oldPath, newPath).then(() => {
                     dispatch(dialogActions.CLOSE_DIALOG({ id: RENAME_FILE_DIALOG }));
                     dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'File name changed.', hideDuration: 2000 }));
                 }).catch(e => {
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 983b309aa4543f1edf0c8d5a903ffc48b552411b..2d89cccd020072b11545691231107ed5a510c7a2 100644 (file)
@@ -4,27 +4,24 @@
 
 import { ServiceRepository } from 'services/services';
 import { MiddlewareAPI, Dispatch } from 'redux';
-import { DataExplorerMiddlewareService } from 'store/data-explorer/data-explorer-middleware-service';
+import { DataExplorerMiddlewareService, getOrder } from 'store/data-explorer/data-explorer-middleware-service';
 import { RootState } from 'store/store';
 import { getUserUuid } from "common/getuser";
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 import { getDataExplorer } from 'store/data-explorer/data-explorer-reducer';
 import { resourcesActions } from 'store/resources/resources-actions';
 import { FilterBuilder } from 'services/api/filter-builder';
-import { SortDirection } from 'components/data-table/data-column';
-import { OrderDirection, OrderBuilder } from 'services/api/order-builder';
-import { getSortColumn } from "store/data-explorer/data-explorer-reducer";
-import { FavoritePanelColumnNames } from 'views/favorite-panel/favorite-panel';
-import { GroupContentsResource, GroupContentsResourcePrefix } from 'services/groups-service/groups-service';
 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';
 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) {
@@ -36,18 +33,6 @@ export class CollectionsWithSameContentAddressMiddlewareService extends DataExpl
         if (!dataExplorer) {
             api.dispatch(collectionPanelDataExplorerIsNotSet());
         } else {
-            const sortColumn = getSortColumn(dataExplorer);
-
-            const contentOrder = new OrderBuilder<GroupContentsResource>();
-
-            if (sortColumn && sortColumn.name === FavoritePanelColumnNames.NAME) {
-                const direction = sortColumn.sortDirection === SortDirection.ASC
-                    ? OrderDirection.ASC
-                    : OrderDirection.DESC;
-
-                contentOrder
-                    .addOrder(direction, "name", GroupContentsResourcePrefix.COLLECTION);
-            }
             try {
                 api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
                 const userUuid = getUserUuid(api.getState());
@@ -60,7 +45,8 @@ export class CollectionsWithSameContentAddressMiddlewareService extends DataExpl
                         .addEqual('portable_data_hash', contentAddress)
                         .addILike("name", dataExplorer.searchValue)
                         .getFilters(),
-                    includeOldVersions: true
+                    includeOldVersions: true,
+                    order: getOrder<CollectionResource>(dataExplorer)
                 });
                 const userUuids = response.items.map(it => {
                     if (extractUuidKind(it.ownerUuid) === ResourceKind.USER) {
@@ -104,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 17fecc1e15b740681b8759d84dd7e01dc5bb0a0f..f3d1fd3b77ebcc71df07e169f8dfdc4c79b1d36a 100644 (file)
@@ -59,7 +59,7 @@ export const createCollection = (data: CollectionCreateFormDialogData) =>
         let newCollection: CollectionResource | undefined;
         try {
             dispatch(progressIndicatorActions.START_WORKING(COLLECTION_CREATE_FORM_NAME));
-            newCollection = await services.collectionService.create(data);
+            newCollection = await services.collectionService.create(data, false);
             await dispatch<any>(uploadCollectionFiles(newCollection.uuid));
             dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_CREATE_FORM_NAME }));
             dispatch(reset(COLLECTION_CREATE_FORM_NAME));
@@ -68,11 +68,14 @@ export const createCollection = (data: CollectionCreateFormDialogData) =>
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
                 dispatch(stopSubmit(COLLECTION_CREATE_FORM_NAME, { name: 'Collection with the same name already exists.' } as FormErrors));
-            } else if (error === CommonResourceServiceError.NONE) {
+            } else {
                 dispatch(stopSubmit(COLLECTION_CREATE_FORM_NAME));
                 dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_CREATE_FORM_NAME }));
+                const errMsg = e.errors
+                    ? e.errors.join('')
+                    : 'There was an error while creating the collection';
                 dispatch(snackbarActions.OPEN_SNACKBAR({
-                    message: 'Collection has not been created.',
+                    message: errMsg,
                     hideDuration: 2000,
                     kind: SnackbarKind.ERROR
                 }));
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 bf9c64492d79cef6a5f6a75708436866a9700e0b..d955c9478d3d6d3198a2a249e94a142cd8c5a77a 100644 (file)
@@ -52,7 +52,7 @@ export const updateCollection = (collection: CollectionUpdateFormDialogData) =>
             name: collection.name,
             storageClassesDesired: collection.storageClassesDesired,
             description: collection.description,
-            properties: collection.properties }
+            properties: collection.properties }, false
         ).then(updatedCollection => {
             updatedCollection = {...cachedCollection, ...updatedCollection};
             dispatch(collectionPanelActions.SET_COLLECTION(updatedCollection));
@@ -72,8 +72,11 @@ export const updateCollection = (collection: CollectionUpdateFormDialogData) =>
                 dispatch(stopSubmit(COLLECTION_UPDATE_FORM_NAME, { name: 'Collection with the same name already exists.' } as FormErrors));
             } else {
                 dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_UPDATE_FORM_NAME }));
+                const errMsg = e.errors
+                    ? e.errors.join('')
+                    : 'There was an error while updating the collection';
                 dispatch(snackbarActions.OPEN_SNACKBAR({
-                    message: e.errors.join(''),
+                    message: errMsg,
                     hideDuration: 2000,
                     kind: SnackbarKind.ERROR }));
                 }
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 e00b65b3699e12285e68501e8e3eaae35b87ac70..464314877ff645328d838f2ddbbb1e4cd2a99ec7 100644 (file)
@@ -2,29 +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 { 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>;
@@ -34,250 +38,289 @@ export type ContextMenuResource = {
     uuid: string;
     ownerUuid: string;
     description?: string;
-    kind: ResourceKind,
+    kind: ResourceKind;
     menuKind: ContextMenuKind | string;
     isTrashed?: boolean;
     isEditable?: boolean;
     outputUuid?: string;
     workflowUuid?: string;
+    isAdmin?: boolean;
+    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,
-            }));
+            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;
         switch (kind) {
             case ResourceKind.PROJECT:
-                return (isAdminUser && !readonly)
-                    ? (resource && resource.groupClass !== GroupClass.FILTER)
+                if (isFrozen) {
+                    return isAdminUser ? ContextMenuKind.FROZEN_PROJECT_ADMIN : ContextMenuKind.FROZEN_PROJECT;
+                }
+
+                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 && !readonly)
-                            ? 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 && !readonly)
-                    ? 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 7ba8225d5b6a7c3754f1f45c5f2657f9644516ea..ea050e609f558a91decb73ac7badd65fe18f7d3f 100644 (file)
@@ -4,61 +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> }>(),
-    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 }>(),
-    SET_REQUEST_STATE: ofType<{ id: string, requestState: DataTableRequestState }>(),
+    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 }>(),
 });
 
 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> }) =>
-        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 }),
-    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 71a6ee6a9577341a639e5df492fc78cf7441459e..6bb95a9a6c05b1e6d14bb3607b3dbfdded1b0e90 100644 (file)
@@ -2,13 +2,16 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Dispatch, MiddlewareAPI } from "redux";
-import { RootState } from "../store";
-import { DataColumns } from "components/data-table/data-table";
-import { DataExplorer } from './data-explorer-reducer';
+import { Dispatch, MiddlewareAPI } from 'redux';
+import { RootState } from '../store';
+import { DataColumns } from 'components/data-table/data-table';
+import { DataExplorer, getSortColumn } from './data-explorer-reducer';
 import { ListResults } from 'services/common-service/common-service';
-import { createTree } from "models/tree";
-import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree";
+import { createTree } from 'models/tree';
+import { DataTableFilters } from 'components/data-table-filters/data-table-filters-tree';
+import { OrderBuilder, OrderDirection } from 'services/api/order-builder';
+import { SortDirection } from 'components/data-table/data-column';
+import { Resource } from 'models/resource';
 
 export abstract class DataExplorerMiddlewareService {
     protected readonly id: string;
@@ -21,25 +24,57 @@ export abstract class DataExplorerMiddlewareService {
         return this.id;
     }
 
-    public getColumnFilters<T>(columns: DataColumns<T>, columnName: string): DataTableFilters {
+    public getColumnFilters<T>(
+        columns: DataColumns<T, any>,
+        columnName: string
+    ): DataTableFilters {
         return getDataExplorerColumnFilters(columns, columnName);
     }
 
-    abstract requestItems(api: MiddlewareAPI<Dispatch, RootState>, criteriaChanged?: boolean): Promise<void>;
+    abstract requestItems(
+        api: MiddlewareAPI<Dispatch, RootState>,
+        criteriaChanged?: boolean,
+        background?: boolean
+    ): Promise<void>;
 }
 
-export const getDataExplorerColumnFilters = <T>(columns: DataColumns<T>, columnName: string): DataTableFilters => {
-    const column = columns.find(c => c.name === columnName);
+export const getDataExplorerColumnFilters = <T>(
+    columns: DataColumns<T, any>,
+    columnName: string
+): DataTableFilters => {
+    const column = columns.find((c) => c.name === columnName);
     return column ? column.filters : createTree();
 };
 
 export const dataExplorerToListParams = (dataExplorer: DataExplorer) => ({
     limit: dataExplorer.rowsPerPage,
-    offset: dataExplorer.page * dataExplorer.rowsPerPage
+    offset: dataExplorer.page * dataExplorer.rowsPerPage,
 });
 
-export const listResultsToDataExplorerItemsMeta = <R>({ itemsAvailable, offset, limit }: ListResults<R>) => ({
+export const getOrder = <T extends Resource = Resource>(dataExplorer: DataExplorer) => {
+    const sortColumn = getSortColumn<T>(dataExplorer);
+    const order = new OrderBuilder<T>();
+    if (sortColumn && sortColumn.sort) {
+        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)
+            .addOrder(OrderDirection.DESC, "createdAt")
+            .getOrder();
+    } else {
+        return order.getOrder();
+    }
+};
+
+export const listResultsToDataExplorerItemsMeta = <R>({
+    itemsAvailable,
+    offset,
+    limit,
+}: ListResults<R>) => ({
     itemsAvailable,
     page: Math.floor(offset / limit),
-    rowsPerPage: limit
+    rowsPerPage: limit,
 });
index ef6cfe42e0f494c90aead7f3a83a83956d4946af..8bb10f0c3bc0bc70a9f399127351c0887ddac7be 100644 (file)
@@ -201,7 +201,7 @@ describe("DataExplorerMiddleware", () => {
 class ServiceMock extends DataExplorerMiddlewareService {
     constructor(private config: {
         id: string,
-        columns: DataColumns<any>,
+        columns: DataColumns<any, any>,
         requestItems: (api: MiddlewareAPI) => Promise<void>
     }) {
         super(config.id);
index efe51fe3740e73c368e077ee2d0ca0db707b6433..3404b375a86b3e6a48c779a441a3c89043443754 100644 (file)
@@ -1,4 +1,3 @@
-
 // Copyright (C) The Arvados Authors. All rights reserved.
 //
 // SPDX-License-Identifier: AGPL-3.0
 import { Dispatch } from 'redux';
 import { RootState } from 'store/store';
 import { ServiceRepository } from 'services/services';
-import { Middleware } from "redux";
-import { dataExplorerActions, bindDataExplorerActions, DataTableRequestState } from "./data-explorer-action";
-import { getDataExplorer } from "./data-explorer-reducer";
-import { DataExplorerMiddlewareService } from "./data-explorer-middleware-service";
+import { Middleware } from 'redux';
+import {
+    dataExplorerActions,
+    bindDataExplorerActions,
+    DataTableRequestState,
+} from './data-explorer-action';
+import { getDataExplorer } from './data-explorer-reducer';
+import { DataExplorerMiddlewareService } from './data-explorer-middleware-service';
 
-export const dataExplorerMiddleware = (service: DataExplorerMiddlewareService): Middleware => api => next => {
-    const actions = bindDataExplorerActions(service.getId());
+export const dataExplorerMiddleware =
+    (service: DataExplorerMiddlewareService): Middleware =>
+        (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 }));
+                return (action) => {
+                    const handleAction =
+                        <T extends { id: string }>(handler: (data: T) => void) =>
+                            (data: T) => {
+                                next(action);
+                                if (data.id === service.getId()) {
+                                    handler(data);
                                 }
-                                // 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;
+                            };
+                    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()
+                                    );
+                                    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 d26d768a0ecd089447d587a08190ef4ebe24a45f..01aa7296334050a361472bd345916932203c1ee7 100644 (file)
@@ -10,13 +10,13 @@ import { SortDirection } from "../../components/data-table/data-column";
 
 describe('data-explorer-reducer', () => {
     it('should set columns', () => {
-        const columns: DataColumns<any> = [{
+        const columns: DataColumns<any, any> = [{
             name: "Column 1",
             filters: [],
             render: jest.fn(),
             selected: true,
             configurable: true,
-            sortDirection: SortDirection.NONE
+            sort: {direction: SortDirection.NONE, field: "name"}
         }];
         const state = dataExplorerReducer(undefined,
             dataExplorerActions.SET_COLUMNS({ id: "Data explorer", columns }));
@@ -24,12 +24,12 @@ describe('data-explorer-reducer', () => {
     });
 
     it('should toggle sorting', () => {
-        const columns: DataColumns<any> = [{
+        const columns: DataColumns<any, any> = [{
             name: "Column 1",
             filters: [],
             render: jest.fn(),
             selected: true,
-            sortDirection: SortDirection.ASC,
+            sort: {direction: SortDirection.ASC, field: "name"},
             configurable: true
         }, {
             name: "Column 2",
@@ -37,22 +37,22 @@ describe('data-explorer-reducer', () => {
             render: jest.fn(),
             selected: true,
             configurable: true,
-            sortDirection: SortDirection.NONE,
+            sort: {direction: SortDirection.NONE, field: "name"},
         }];
         const state = dataExplorerReducer({ "Data explorer": { ...initialDataExplorer, columns } },
             dataExplorerActions.TOGGLE_SORT({ id: "Data explorer", columnName: "Column 2" }));
-        expect(state["Data explorer"].columns[0].sortDirection).toEqual("none");
-        expect(state["Data explorer"].columns[1].sortDirection).toEqual("asc");
+        expect(state["Data explorer"].columns[0].sort.direction).toEqual("none");
+        expect(state["Data explorer"].columns[1].sort.direction).toEqual("asc");
     });
 
     it('should set filters', () => {
-        const columns: DataColumns<any> = [{
+        const columns: DataColumns<any, any> = [{
             name: "Column 1",
             filters: [],
             render: jest.fn(),
             selected: true,
             configurable: true,
-            sortDirection: SortDirection.NONE
+            sort: {direction: SortDirection.NONE, field: "name"}
         }];
 
         const filters: DataTableFilterItem[] = [{
index 1e5cd88fa1299c2eea4f42fed52ad46a4c8445e3..a0a7eb6400b1160f0702d2e4243b94912c85bfa1 100644 (file)
@@ -6,15 +6,22 @@ import {
     DataColumn,
     resetSortDirection,
     SortDirection,
-    toggleSortDirection
-} from "components/data-table/data-column";
-import { DataExplorerAction, dataExplorerActions, DataTableRequestState } from "./data-explorer-action";
-import { DataColumns, DataTableFetchMode } from "components/data-table/data-table";
-import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree";
+    toggleSortDirection,
+} from 'components/data-table/data-column';
+import {
+    DataExplorerAction,
+    dataExplorerActions,
+    DataTableRequestState,
+} from './data-explorer-action';
+import {
+    DataColumns,
+    DataTableFetchMode,
+} from 'components/data-table/data-table';
+import { DataTableFilters } from 'components/data-table-filters/data-table-filters-tree';
 
 export interface DataExplorer {
     fetchMode: DataTableFetchMode;
-    columns: DataColumns<any>;
+    columns: DataColumns<any, any>;
     items: any[];
     itemsAvailable: number;
     page: number;
@@ -33,52 +40,78 @@ export const initialDataExplorer: DataExplorer = {
     page: 0,
     rowsPerPage: 50,
     rowsPerPageOptions: [10, 20, 50, 100, 200, 500],
-    searchValue: "",
-    requestState: DataTableRequestState.IDLE
+    searchValue: '',
+    requestState: DataTableRequestState.IDLE,
 };
 
 export type DataExplorerState = Record<string, DataExplorer>;
 
-export const dataExplorerReducer = (state: DataExplorerState = {}, action: DataExplorerAction) =>
-    dataExplorerActions.match(action, {
+export const dataExplorerReducer = (
+    state: DataExplorerState = {},
+    action: DataExplorerAction
+) => {
+    return dataExplorerActions.match(action, {
         CLEAR: ({ id }) =>
-            update(state, id, explorer => ({ ...explorer, page: 0, itemsAvailable: 0, items: [] })),
+            update(state, id, (explorer) => ({
+                ...explorer,
+                page: 0,
+                itemsAvailable: 0,
+                items: [],
+            })),
 
         RESET_PAGINATION: ({ id }) =>
-            update(state, id, explorer => ({ ...explorer, page: 0 })),
+            update(state, id, (explorer) => ({ ...explorer, page: 0 })),
 
         SET_FETCH_MODE: ({ id, fetchMode }) =>
-            update(state, id, explorer => ({ ...explorer, fetchMode })),
+            update(state, id, (explorer) => ({ ...explorer, fetchMode })),
 
-        SET_COLUMNS: ({ id, columns }) =>
-            update(state, id, setColumns(columns)),
+        SET_COLUMNS: ({ id, columns }) => update(state, id, setColumns(columns)),
 
         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 => ({
+            update(state, id, (explorer) => ({
                 ...explorer,
                 items: state[id].items.concat(items),
                 itemsAvailable: state[id].itemsAvailable + itemsAvailable,
                 page,
-                rowsPerPage
+                rowsPerPage,
             })),
 
         SET_PAGE: ({ id, page }) =>
-            update(state, id, explorer => ({ ...explorer, page })),
+            update(state, id, (explorer) => ({ ...explorer, page })),
 
         SET_ROWS_PER_PAGE: ({ id, rowsPerPage }) =>
-            update(state, id, explorer => ({ ...explorer, rowsPerPage })),
+            update(state, id, (explorer) => ({ ...explorer, rowsPerPage })),
 
         SET_EXPLORER_SEARCH_VALUE: ({ id, searchValue }) =>
-            update(state, id, explorer => ({ ...explorer, searchValue })),
+            update(state, id, (explorer) => ({ ...explorer, searchValue })),
+
+        RESET_EXPLORER_SEARCH_VALUE: ({ id }) =>
+            update(state, id, (explorer) => ({ ...explorer, searchValue: '' })),
 
         SET_REQUEST_STATE: ({ id, requestState }) =>
-            update(state, id, explorer => ({ ...explorer, requestState })),
+            update(state, id, (explorer) => ({ ...explorer, requestState })),
 
         TOGGLE_SORT: ({ id, columnName }) =>
             update(state, id, mapColumns(toggleSort(columnName))),
@@ -86,19 +119,29 @@ export const dataExplorerReducer = (state: DataExplorerState = {}, action: DataE
         TOGGLE_COLUMN: ({ id, columnName }) =>
             update(state, id, mapColumns(toggleColumn(columnName))),
 
-        default: () => state
+        default: () => state,
     });
+};
+export const getDataExplorer = (state: DataExplorerState, id: string) => {
+    const returnValue = state[id] || initialDataExplorer;
+    return returnValue;
+};
 
-export const getDataExplorer = (state: DataExplorerState, id: string) =>
-    state[id] || initialDataExplorer;
-
-export const getSortColumn = (dataExplorer: DataExplorer) => dataExplorer.columns.find((c: any) =>
-    !!c.sortDirection && c.sortDirection !== SortDirection.NONE);
-
-const update = (state: DataExplorerState, id: string, updateFn: (dataExplorer: DataExplorer) => DataExplorer) =>
-    ({ ...state, [id]: updateFn(getDataExplorer(state, id)) });
-
-const canUpdateColumns = (prevColumns: DataColumns<any>, nextColumns: DataColumns<any>) => {
+export const getSortColumn = <R>(dataExplorer: DataExplorer): DataColumn<any, R> | undefined =>
+    dataExplorer.columns.find(
+        (c: DataColumn<any, R>) => !!c.sort && c.sort.direction !== SortDirection.NONE
+    );
+
+const update = (
+    state: DataExplorerState,
+    id: string,
+    updateFn: (dataExplorer: DataExplorer) => DataExplorer
+) => ({ ...state, [id]: updateFn(getDataExplorer(state, id)) });
+
+const canUpdateColumns = (
+    prevColumns: DataColumns<any, any>,
+    nextColumns: DataColumns<any, any>
+) => {
     if (prevColumns.length !== nextColumns.length) {
         return true;
     }
@@ -112,25 +155,32 @@ const canUpdateColumns = (prevColumns: DataColumns<any>, nextColumns: DataColumn
     return false;
 };
 
-const setColumns = (columns: DataColumns<any>) =>
-    (dataExplorer: DataExplorer) =>
-        ({ ...dataExplorer, columns: canUpdateColumns(dataExplorer.columns, columns) ? columns : dataExplorer.columns });
+const setColumns =
+    (columns: DataColumns<any, any>) => (dataExplorer: DataExplorer) => ({
+        ...dataExplorer,
+        columns: canUpdateColumns(dataExplorer.columns, columns)
+            ? columns
+            : dataExplorer.columns,
+    });
 
-const mapColumns = (mapFn: (column: DataColumn<any>) => DataColumn<any>) =>
-    (dataExplorer: DataExplorer) =>
-        ({ ...dataExplorer, columns: dataExplorer.columns.map(mapFn) });
+const mapColumns =
+    (mapFn: (column: DataColumn<any, any>) => DataColumn<any, any>) =>
+        (dataExplorer: DataExplorer) => ({
+            ...dataExplorer,
+            columns: dataExplorer.columns.map(mapFn),
+        });
 
-const toggleSort = (columnName: string) =>
-    (column: DataColumn<any>) => column.name === columnName
+const toggleSort = (columnName: string) => (column: DataColumn<any, any>) =>
+    column.name === columnName
         ? toggleSortDirection(column)
         : resetSortDirection(column);
 
-const toggleColumn = (columnName: string) =>
-    (column: DataColumn<any>) => column.name === columnName
+const toggleColumn = (columnName: string) => (column: DataColumn<any, any>) =>
+    column.name === columnName
         ? { ...column, selected: !column.selected }
         : column;
 
-const setFilters = (columnName: string, filters: DataTableFilters) =>
-    (column: DataColumn<any>) => column.name === columnName
-        ? { ...column, filters }
-        : column;
+const setFilters =
+    (columnName: string, filters: DataTableFilters) =>
+        (column: DataColumn<any, any>) =>
+            column.name === columnName ? { ...column, filters } : column;
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 067d5ceedb90bbbdc6f947d5b6b2afe5fe2fbf00..85ede867044d2d4bccd1340283f0a735b7391139 100644 (file)
@@ -2,9 +2,13 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+import { Dispatch } from "redux";
 import { bindDataExplorerActions } from "../data-explorer/data-explorer-action";
 
 export const FAVORITE_PANEL_ID = "favoritePanel";
 export const favoritePanelActions = bindDataExplorerActions(FAVORITE_PANEL_ID);
 
-export const loadFavoritePanel = () => favoritePanelActions.REQUEST_ITEMS();
+export const loadFavoritePanel = () => (dispatch: Dispatch) => {
+    dispatch(favoritePanelActions.RESET_EXPLORER_SEARCH_VALUE());
+    dispatch(favoritePanelActions.REQUEST_ITEMS());
+};
\ No newline at end of file
index f88f7b914d3ace2a5bd1e7fe75b77ebbe8fd89ba..0229834c3bc148ee6c48e64fdca945e11716b80c 100644 (file)
@@ -8,24 +8,20 @@ import { RootState } from "../store";
 import { getUserUuid } from "common/getuser";
 import { DataColumns } from "components/data-table/data-table";
 import { ServiceRepository } from "services/services";
-import { SortDirection } from "components/data-table/data-column";
 import { FilterBuilder } from "services/api/filter-builder";
 import { updateFavorites } from "../favorites/favorites-actions";
 import { favoritePanelActions } from "./favorite-panel-action";
 import { Dispatch, MiddlewareAPI } from "redux";
-import { OrderBuilder, OrderDirection } from "services/api/order-builder";
-import { LinkResource } from "models/link";
-import { GroupContentsResource, GroupContentsResourcePrefix } from "services/groups-service/groups-service";
 import { resourcesActions } from "store/resources/resources-actions";
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
 import { getDataExplorer } from "store/data-explorer/data-explorer-reducer";
 import { loadMissingProcessesInformation } from "store/project-panel/project-panel-middleware-service";
-import { getSortColumn } from "store/data-explorer/data-explorer-reducer";
 import { getDataExplorerColumnFilters } from 'store/data-explorer/data-explorer-middleware-service';
 import { serializeSimpleObjectTypeFilters } from '../resource-type-filters/resource-type-filters';
 import { ResourceKind } from "models/resource";
 import { LinkClass } from "models/link";
+import { GroupContentsResource } from "services/groups-service/groups-service";
 
 export class FavoritePanelMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
@@ -37,25 +33,9 @@ export class FavoritePanelMiddlewareService extends DataExplorerMiddlewareServic
         if (!dataExplorer) {
             api.dispatch(favoritesPanelDataExplorerIsNotSet());
         } else {
-            const columns = dataExplorer.columns as DataColumns<string>;
-            const sortColumn = getSortColumn(dataExplorer);
+            const columns = dataExplorer.columns as DataColumns<string, GroupContentsResource>;
             const typeFilters = serializeSimpleObjectTypeFilters(getDataExplorerColumnFilters(columns, FavoritePanelColumnNames.TYPE));
 
-
-            const linkOrder = new OrderBuilder<LinkResource>();
-            const contentOrder = new OrderBuilder<GroupContentsResource>();
-
-            if (sortColumn && sortColumn.name === FavoritePanelColumnNames.NAME) {
-                const direction = sortColumn.sortDirection === SortDirection.ASC
-                    ? OrderDirection.ASC
-                    : OrderDirection.DESC;
-
-                linkOrder.addOrder(direction, "name");
-                contentOrder
-                    .addOrder(direction, "name", GroupContentsResourcePrefix.COLLECTION)
-                    .addOrder(direction, "name", GroupContentsResourcePrefix.PROCESS)
-                    .addOrder(direction, "name", GroupContentsResourcePrefix.PROJECT);
-            }
             try {
                 api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
                 const responseLinks = await this.services.linkService.list({
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 6e63702e57ec676a009d21ceb691da519f1e480b..203bf44658f77e1bb600edccbbb114ff4980d392 100644 (file)
@@ -25,7 +25,10 @@ export const GROUP_REMOVE_DIALOG = 'groupRemoveDialog';
 
 export const GroupsPanelActions = bindDataExplorerActions(GROUPS_PANEL_ID);
 
-export const loadGroupsPanel = () => GroupsPanelActions.REQUEST_ITEMS();
+export const loadGroupsPanel = () => (dispatch: Dispatch) => {
+    dispatch(GroupsPanelActions.RESET_EXPLORER_SEARCH_VALUE());
+    dispatch(GroupsPanelActions.REQUEST_ITEMS());
+};
 
 export const openCreateGroupDialog = () =>
     (dispatch: Dispatch, getState: () => RootState) => {
@@ -113,7 +116,7 @@ export const createGroup = ({ name, users = [], description }: ProjectUpdateForm
             }
             dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_CREATE_FORM_NAME }));
             dispatch(reset(PROJECT_CREATE_FORM_NAME));
-            dispatch(loadGroupsPanel());
+            dispatch<any>(loadGroupsPanel());
             dispatch(snackbarActions.OPEN_SNACKBAR({
                 message: `${newGroup.name} group has been created`,
                 kind: SnackbarKind.SUCCESS
index 3997e33cef015547bd27ac74435833198248414a..7d7803f59e6943efd042a0da166c38ccc473bdda 100644 (file)
@@ -14,7 +14,6 @@ import { updateResources } from 'store/resources/resources-actions';
 import { OrderBuilder, OrderDirection } from 'services/api/order-builder';
 import { GroupResource, GroupClass } from 'models/group';
 import { SortDirection } from 'components/data-table/data-column';
-import { GroupsPanelColumnNames } from 'views/groups-panel/groups-panel';
 import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
 
 export class GroupsPanelMiddlewareService extends DataExplorerMiddlewareService {
@@ -28,14 +27,14 @@ export class GroupsPanelMiddlewareService extends DataExplorerMiddlewareService
         } else {
             try {
                 api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
+                const sortColumn = getSortColumn<GroupResource>(dataExplorer);
                 const order = new OrderBuilder<GroupResource>();
-                const sortColumn = getSortColumn(dataExplorer);
-                if (sortColumn) {
+                if (sortColumn && sortColumn.sort) {
                     const direction =
-                        sortColumn.sortDirection === SortDirection.ASC && sortColumn.name === GroupsPanelColumnNames.GROUP
+                        sortColumn.sort.direction === SortDirection.ASC
                             ? OrderDirection.ASC
                             : OrderDirection.DESC;
-                    order.addOrder(direction, 'name');
+                    order.addOrder(direction, sortColumn.sort.field);
                 }
                 const filters = new FilterBuilder()
                     .addEqual('group_class', GroupClass.ROLE)
index da849a594151cf303509313ac69a405f7f6e28a6..cc6ea8cf389ebdd09573630f419477920792997a 100644 (file)
@@ -4,18 +4,15 @@
 
 import { ServiceRepository } from 'services/services';
 import { MiddlewareAPI, Dispatch } from 'redux';
-import { DataExplorerMiddlewareService, dataExplorerToListParams, listResultsToDataExplorerItemsMeta } from 'store/data-explorer/data-explorer-middleware-service';
+import { DataExplorerMiddlewareService, dataExplorerToListParams, getOrder, listResultsToDataExplorerItemsMeta } from 'store/data-explorer/data-explorer-middleware-service';
 import { RootState } from 'store/store';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 import { DataExplorer, getDataExplorer } from 'store/data-explorer/data-explorer-reducer';
 import { updateResources } from 'store/resources/resources-actions';
-import { SortDirection } from 'components/data-table/data-column';
-import { OrderDirection, OrderBuilder } from 'services/api/order-builder';
 import { ListResults } from 'services/common-service/common-service';
-import { getSortColumn } from "store/data-explorer/data-explorer-reducer";
 import { LinkResource } from 'models/link';
 import { linkPanelActions } from 'store/link-panel/link-panel-actions';
-import { LinkPanelColumnNames } from 'views/link-panel/link-panel-root';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
 
 export class LinkMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
@@ -26,37 +23,23 @@ 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()));
         }
     }
 }
 
 export const getParams = (dataExplorer: DataExplorer) => ({
     ...dataExplorerToListParams(dataExplorer),
-    order: getOrder(dataExplorer)
+    order: getOrder<LinkResource>(dataExplorer)
 });
 
-const getOrder = (dataExplorer: DataExplorer) => {
-    const sortColumn = getSortColumn(dataExplorer);
-    const order = new OrderBuilder<LinkResource>();
-    if (sortColumn) {
-        const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC
-            ? OrderDirection.ASC
-            : OrderDirection.DESC;
-
-        const columnName = sortColumn && sortColumn.name === LinkPanelColumnNames.NAME ? "name" : "modifiedAt";
-        return order
-            .addOrder(sortDirection, columnName)
-            .getOrder();
-    } else {
-        return order.getOrder();
-    }
-};
-
 export const setItems = (listResults: ListResults<LinkResource>) =>
     linkPanelActions.SET_ITEMS({
         ...listResultsToDataExplorerItemsMeta(listResults),
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 c465aae8695a51291918aa0fd630e57fe8b327c6..83055e32fcbd3750e50b648c4166f6d618b469de 100644 (file)
@@ -2,26 +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) {
-        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 d4f5ab59244cabccea950b3bbcaf119feb63e83b..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 } 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,83 +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);
-        if (process && process.container) {
-            const logResources = await loadContainerLogs(process.container.uuid, logService);
-            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) => {
-    const requestFilters = new FilterBuilder()
-        .addEqual('object_uuid', containerUuid)
-        .addIn('event_type', PROCESS_PANEL_LOG_EVENT_TYPES)
-        .getFilters();
-    const requestOrder = new OrderBuilder<LogResource>()
-        .addAsc('eventAt')
-        .getOrder();
-    const requestParams = {
-        limit: MAX_AMOUNT_OF_LOGS,
-        filters: requestFilters,
-        order: requestOrder,
-    };
-    const { items } = await logService.list(requestParams);
-    return items;
-};
+const loadContainerLogFileList = async (containerRequest: ContainerRequestResource, logService: LogService) => {
+    const logCollectionContents = await logService.listLogFiles(containerRequest);
 
-const createInitialLogPanelState = (logResources: LogResource[]) => {
-    const allLogs = logsToLines(logResources);
-    const mainLogs = logsToLines(logResources.filter(
-        e => MAIN_EVENT_TYPES.indexOf(e.eventType) > -1
+    // 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
     ));
-    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)];
-    const logs = {
-        [MAIN_FILTER_TYPE]: mainLogs,
-        [ALL_FILTER_TYPE]: allLogs,
-        ...groupedLogs
-    };
+};
+
+/**
+ * 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)]);
+        }
+    })).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,
+    }
 };
 
-const logsToLines = (logs: LogResource[]) =>
-    logs.map(({ properties }) => properties.text);
+/**
+ * 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 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) => {
@@ -116,12 +336,10 @@ 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 }));
         }
     };
 
-const MAX_AMOUNT_OF_LOGS = 10000;
-
 const ALL_FILTER_TYPE = 'All logs';
 
 const MAIN_FILTER_TYPE = 'Main logs';
@@ -143,3 +361,8 @@ const PROCESS_PANEL_LOG_EVENT_TYPES = [
     LogEventType.CONTAINER,
     LogEventType.KEEPSTORE,
 ];
+
+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 e77c300d8c07cc50a73da44734a59b69b1737211..2111afdb2fc89d05eaba56ad46add0c43beccf19 100644 (file)
 // SPDX-License-Identifier: AGPL-3.0
 
 import { unionize, ofType, UnionOf } from "common/unionize";
-import { 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 { 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<{}>(),
     SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID: ofType<string>(),
     SET_PROCESS_PANEL_FILTERS: ofType<string[]>(),
     TOGGLE_PROCESS_PANEL_FILTER: ofType<string>(),
+    SET_INPUT_RAW: ofType<WorkflowInputsData | null>(),
+    SET_INPUT_PARAMS: ofType<ProcessIOParameter[] | null>(),
+    SET_OUTPUT_RAW: ofType<OutputDetails | null>(),
+    SET_OUTPUT_DEFINITIONS: ofType<CommandOutputParameter[]>(),
+    SET_OUTPUT_PARAMS: ofType<ProcessIOParameter[] | null>(),
+    SET_NODE_INFO: ofType<NodeInfo>(),
 });
 
 export type ProcessPanelAction = UnionOf<typeof processPanelActions>;
 
 export const toggleProcessPanelFilter = processPanelActions.TOGGLE_PROCESS_PANEL_FILTER;
 
-export const loadProcessPanel = (uuid: string) =>
-    async (dispatch: Dispatch) => {
-        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 = (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) => {
+        dispatch<ProcessPanelAction>(processPanelActions.SET_INPUT_RAW(getRawInputs(containerRequest)));
+        dispatch<ProcessPanelAction>(processPanelActions.SET_INPUT_PARAMS(formatInputData(getInputs(containerRequest), getState().auth)));
+    };
+
+export const loadOutputs =
+    (containerRequest: ContainerRequestResource) => async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const noOutputs = { rawOutputs: {} };
+
+        if (!containerRequest.outputUuid) {
+            dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW({ uuid: containerRequest.uuid, outputRaw: noOutputs }));
+            return;
+        }
+        try {
+            const propsOutputs = getRawOutputs(containerRequest);
+            const filesPromise = services.collectionService.files(containerRequest.outputUuid);
+            const collectionPromise = services.collectionService.get(containerRequest.outputUuid);
+            const [files, collection] = await Promise.all([filesPromise, collectionPromise]);
+
+            // If has propsOutput, skip fetching cwl.output.json
+            if (propsOutputs !== undefined) {
+                dispatch<ProcessPanelAction>(
+                    processPanelActions.SET_OUTPUT_RAW({
+                        rawOutputs: propsOutputs,
+                        pdh: collection.portableDataHash,
+                    })
+                );
+            } else {
+                // Fetch outputs from keep
+                const outputFile = files.find(file => file.name === "cwl.output.json") as CollectionFile | undefined;
+                let outputData = outputFile ? await services.collectionService.getFileContents(outputFile) : undefined;
+                if (outputData && (outputData = JSON.parse(outputData)) && collection.portableDataHash) {
+                    dispatch<ProcessPanelAction>(
+                        processPanelActions.SET_OUTPUT_RAW({
+                            uuid: containerRequest.uuid,
+                            outputRaw: { rawOutputs: outputData, pdh: collection.portableDataHash },
+                        })
+                    );
+                } else {
+                    dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW({ uuid: containerRequest.uuid, outputRaw: noOutputs }));
+                }
+            }
+        } catch {
+            dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW({ uuid: containerRequest.uuid, outputRaw: noOutputs }));
+        }
     };
 
-export const navigateToOutput = (uuid: string) =>
-    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 {
-            await services.collectionService.get(uuid);
-            dispatch<any>(navigateTo(uuid));
+            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;
+            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,
+                    })
+                );
+            } else {
+                dispatch<ProcessPanelAction>(processPanelActions.SET_NODE_INFO(noLog));
+            }
         } catch {
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'This collection does not exists!', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            dispatch<ProcessPanelAction>(processPanelActions.SET_NODE_INFO(noLog));
         }
     };
 
-export const openWorkflow = (uuid: string) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch<any>(navigateToWorkflows);
-        dispatch<any>(showWorkflowDetails(uuid));
+export const loadOutputDefinitions =
+    (containerRequest: ContainerRequestResource) => async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        if (containerRequest && containerRequest.mounts) {
+            dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_DEFINITIONS(getOutputParameters(containerRequest)));
+        }
     };
 
+export const updateOutputParams = () => async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+    const outputDefinitions = getState().processPanel.outputDefinitions;
+    const outputRaw = getState().processPanel.outputRaw;
+
+    if (outputRaw && 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>(navigateTo(uuid));
+};
+
 export const initProcessPanelFilters = processPanelActions.SET_PROCESS_PANEL_FILTERS([
     ProcessStatus.QUEUED,
     ProcessStatus.COMPLETED,
@@ -59,5 +170,30 @@ export const initProcessPanelFilters = processPanelActions.SET_PROCESS_PANEL_FIL
     ProcessStatus.ONHOLD,
     ProcessStatus.FAILING,
     ProcessStatus.WARNING,
-    ProcessStatus.CANCELLED
+    ProcessStatus.CANCELLED,
 ]);
+
+export const formatInputData = (inputs: CommandInputParameter[], auth: AuthState): ProcessIOParameter[] => {
+    return inputs.map(input => {
+        return {
+            id: getIOParamId(input),
+            label: input.label || "",
+            value: getIOParamDisplayValue(auth, input),
+        };
+    });
+};
+
+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),
+        };
+    });
+};
index d26e76932038bf500e33074a68548645f6a1064f..ea6de66db415294d183fb208af0b4ee7f68acfc9 100644 (file)
@@ -2,18 +2,26 @@
 //
 // 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: "",
-    filters: {}
+    filters: {},
+    inputRaw: null,
+    inputParams: null,
+    outputRaw: null,
+    nodeInfo: null,
+    outputDefinitions: [],
+    outputParams: null,
 };
 
 export const processPanelReducer = (state = initialState, action: ProcessPanelAction): ProcessPanel =>
     processPanelActions.match(action, {
+        RESET_PROCESS_PANEL: () => initialState,
         SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID: containerRequestUuid => ({
-            ...state, containerRequestUuid
+            ...state,
+            containerRequestUuid,
         }),
         SET_PROCESS_PANEL_FILTERS: statuses => {
             const filters = statuses.reduce((filters, status) => ({ ...filters, [status]: true }), {});
@@ -23,5 +31,44 @@ export const processPanelReducer = (state = initialState, action: ProcessPanelAc
             const filters = { ...state.filters, [status]: !state.filters[status] };
             return { ...state, filters };
         },
+        SET_INPUT_RAW: inputRaw => {
+            // Since mounts can disappear and reappear, only set inputs
+            //   if current state is null or new inputs has content
+            if (state.inputRaw === null || (inputRaw && Object.keys(inputRaw).length)) {
+                return { ...state, inputRaw };
+            } else {
+                return state;
+            }
+        },
+        SET_INPUT_PARAMS: inputParams => {
+            // Since mounts can disappear and reappear, only set inputs
+            //   if current state is null or new inputs has content
+            if (state.inputParams === null || (inputParams && inputParams.length)) {
+                return { ...state, inputParams };
+            } else {
+                return state;
+            }
+        },
+        SET_OUTPUT_RAW: (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 };
+        },
+        SET_OUTPUT_DEFINITIONS: outputDefinitions => {
+            // Set output definitions is only additive to avoid clearing when mounts go temporarily missing
+            if (outputDefinitions.length) {
+                return { ...state, outputDefinitions };
+            } else {
+                return state;
+            }
+        },
+        SET_OUTPUT_PARAMS: outputParams => {
+            return { ...state, outputParams };
+        },
         default: () => state,
     });
index 49c2691d9d4c21c3fdd2ee48eea60a74f2db31c6..1ec60ff54c27f69b3fad5ee0494aa53ad3a693d2 100644 (file)
@@ -2,16 +2,53 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+import { WorkflowInputsData } from 'models/workflow';
 import { RouterState } from "react-router-redux";
 import { matchProcessRoute } from "routes/routes";
+import { ProcessIOParameter } from "views/process-panel/process-io-card";
+import { CommandOutputParameter } from 'cwlts/mappings/v1.0/CommandOutputParameter';
+
+export type OutputDetails = {
+    rawOutputs?: any;
+    pdh?: string;
+}
+
+export interface CUDAFeatures {
+    DriverVersion: string;
+    HardwareCapability: string;
+    DeviceCount: number;
+}
+
+export interface NodeInstanceType {
+    Name: string;
+    ProviderType: string;
+    VCPUs: number;
+    RAM: number;
+    Scratch: number;
+    IncludedScratch: number;
+    AddedScratch: number;
+    Price: number;
+    Preemptible: boolean;
+    CUDA: CUDAFeatures;
+};
+
+export interface NodeInfo {
+    nodeInfo: NodeInstanceType | null;
+};
 
 export interface ProcessPanel {
     containerRequestUuid: string;
     filters: { [status: string]: boolean };
+    inputRaw: WorkflowInputsData | null;
+    inputParams: ProcessIOParameter[] | null;
+    outputRaw: OutputDetails | null;
+    outputDefinitions: CommandOutputParameter[];
+    outputParams: ProcessIOParameter[] | null;
+    nodeInfo: NodeInstanceType | null;
 }
 
 export const getProcessPanelCurrentUuid = (router: RouterState) => {
     const pathname = router.location ? router.location.pathname : '';
     const match = matchProcessRoute(pathname);
     return match ? match.params.id : undefined;
-};
\ No newline at end of file
+};
diff --git a/src/store/processes/process-copy-actions.test.ts b/src/store/processes/process-copy-actions.test.ts
new file mode 100644 (file)
index 0000000..cb064ed
--- /dev/null
@@ -0,0 +1,483 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { configure } from 'enzyme';
+import Adapter from 'enzyme-adapter-react-16';
+import { copyProcess } from './process-copy-actions';
+import { CommonService } from 'services/common-service/common-service';
+import { snakeCase } from 'lodash';
+
+configure({ adapter: new Adapter() });
+
+describe('ProcessCopyAction', () => {
+    // let props;
+    let dispatch: any, getState: any, services: any;
+
+    let sampleFailedProcess = {
+        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_count: 1,
+        container_count_max: 10,
+        container_image: "arvados/jobs",
+        container_uuid: "zzzzz-dz642-b9j9dtk1yikp9h0",
+        created_at: "2023-01-23T22:50:50.788284000Z",
+        cumulative_cost: 0.00120553009559028,
+        cwd: "/var/spool/cwl",
+        description: "test decsription",
+        environment: {},
+        etag: "2es6px6q7uo0yqi2i291x8gd6",
+        expires_at: null,
+        filters: null,
+        href: "/container_requests/zzzzz-xvhdp-111111111111111",
+        kind: "arvados#containerRequest",
+        log_uuid: "zzzzz-4zz18-a1gxqy9o6zyrdy8",
+        modified_at: "2023-01-24T21:13:54.772612000Z",
+        modified_by_client_uuid: "zzzzz-ozdt8-q6dzdi1lcc03155",
+        modified_by_user_uuid: "jutro-tpzed-vllbpebicy84rd5",
+        mounts: {
+        "/var/lib/cwl/cwl.input.json": {
+            capacity: 0,
+            commit: "",
+            content: {
+            input: {
+                basename: "logo.ai.no.whitespace.png",
+                class: "File",
+                location:
+                "keep:5d3238c4db721a92c98b0305a47b0485+75/logo.ai.no.whitespace.png",
+            },
+            reverse_sort: true,
+            },
+            device_type: "",
+            exclude_from_output: false,
+            git_url: "",
+            kind: "json",
+            path: "",
+            portable_data_hash: "",
+            repository_name: "",
+            uuid: "",
+            writable: false,
+        },
+        "/var/lib/cwl/workflow.json": {
+            capacity: 0,
+            commit: "",
+            content: {
+            $graph: [
+                {
+                class: "Workflow",
+                doc: "Reverse the lines in a document, then sort those lines.",
+                id: "#main",
+                inputs: [
+                    {
+                    default: null,
+                    doc: "The input file to be processed.",
+                    id: "#main/input",
+                    type: "File",
+                    },
+                    {
+                    default: true,
+                    doc: "If true, reverse (decending) sort",
+                    id: "#main/reverse_sort",
+                    type: "boolean",
+                    },
+                ],
+                outputs: [
+                    {
+                    doc: "The output with the lines reversed and sorted.",
+                    id: "#main/output",
+                    outputSource: "#main/sorted/output",
+                    type: "File",
+                    },
+                ],
+                steps: [
+                    {
+                    id: "#main/rev",
+                    in: [{ id: "#main/rev/input", source: "#main/input" }],
+                    out: ["#main/rev/output"],
+                    run: "#revtool.cwl",
+                    },
+                    {
+                    id: "#main/sorted",
+                    in: [
+                        { id: "#main/sorted/input", source: "#main/rev/output" },
+                        {
+                        id: "#main/sorted/reverse",
+                        source: "#main/reverse_sort",
+                        },
+                    ],
+                    out: ["#main/sorted/output"],
+                    run: "#sorttool.cwl",
+                    },
+                ],
+                },
+                {
+                baseCommand: "rev",
+                class: "CommandLineTool",
+                doc: "Reverse each line using the `rev` command",
+                hints: [{ class: "ResourceRequirement", ramMin: 8 }],
+                id: "#revtool.cwl",
+                inputs: [
+                    { id: "#revtool.cwl/input", inputBinding: {}, type: "File" },
+                ],
+                outputs: [
+                    {
+                    id: "#revtool.cwl/output",
+                    outputBinding: { glob: "output.txt" },
+                    type: "File",
+                    },
+                ],
+                stdout: "output.txt",
+                },
+                {
+                baseCommand: "sort",
+                class: "CommandLineTool",
+                doc: "Sort lines using the `sort` command",
+                hints: [{ class: "ResourceRequirement", ramMin: 8 }],
+                id: "#sorttool.cwl",
+                inputs: [
+                    {
+                    id: "#sorttool.cwl/reverse",
+                    inputBinding: { position: 1, prefix: "-r" },
+                    type: "boolean",
+                    },
+                    {
+                    id: "#sorttool.cwl/input",
+                    inputBinding: { position: 2 },
+                    type: "File",
+                    },
+                ],
+                outputs: [
+                    {
+                    id: "#sorttool.cwl/output",
+                    outputBinding: { glob: "output.txt" },
+                    type: "File",
+                    },
+                ],
+                stdout: "output.txt",
+                },
+            ],
+            cwlVersion: "v1.0",
+            },
+            device_type: "",
+            exclude_from_output: false,
+            git_url: "",
+            kind: "json",
+            path: "",
+            portable_data_hash: "",
+            repository_name: "",
+            uuid: "",
+            writable: false,
+        },
+        "/var/spool/cwl": {
+            capacity: 0,
+            commit: "",
+            content: null,
+            device_type: "",
+            exclude_from_output: false,
+            git_url: "",
+            kind: "collection",
+            path: "",
+            portable_data_hash: "",
+            repository_name: "",
+            uuid: "",
+            writable: true,
+        },
+        stdout: {
+            capacity: 0,
+            commit: "",
+            content: null,
+            device_type: "",
+            exclude_from_output: false,
+            git_url: "",
+            kind: "file",
+            path: "/var/spool/cwl/cwl.output.json",
+            portable_data_hash: "",
+            repository_name: "",
+            uuid: "",
+            writable: false,
+        },
+        },
+        name: "Copy of: Copy of: Copy of: revsort.cwl",
+        output_name: "Output from revsort.cwl",
+        output_path: "/var/spool/cwl",
+        output_properties: { key: "val" },
+        output_storage_classes: ["default"],
+        output_ttl: 999999,
+        output_uuid: "zzzzz-4zz18-wolwlyfxmlhmgd4",
+        owner_uuid: "zzzzz-j7d0g-yr18k784zplfeza",
+        priority: 500,
+        properties: {
+        template_uuid: "zzzzz-7fd4e-7xsza0vgfe785cy",
+        workflowName: "revsort.cwl",
+        },
+        requesting_container_uuid: null,
+        runtime_constraints: {
+        API: true,
+        cuda: { device_count: 0, driver_version: "", hardware_capability: "" },
+        keep_cache_disk: 0,
+        keep_cache_ram: 0,
+        ram: 1342177280,
+        vcpus: 1,
+        },
+        runtime_token: "",
+        scheduling_parameters: {
+        max_run_time: 0,
+        partitions: [],
+        preemptible: false,
+        },
+        state: "Final",
+        use_existing: false,
+        uuid: "zzzzz-xvhdp-111111111111111",
+    };
+
+    let expectedContainerRequest = {
+        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_count_max: 10,
+        container_image: "arvados/jobs",
+        cwd: "/var/spool/cwl",
+        description: "test decsription",
+        environment: {},
+        kind: "arvados#containerRequest",
+        mounts: {
+        "/var/lib/cwl/cwl.input.json": {
+            capacity: 0,
+            commit: "",
+            content: {
+            input: {
+                basename: "logo.ai.no.whitespace.png",
+                class: "File",
+                location:
+                "keep:5d3238c4db721a92c98b0305a47b0485+75/logo.ai.no.whitespace.png",
+            },
+            reverse_sort: true,
+            },
+            device_type: "",
+            exclude_from_output: false,
+            git_url: "",
+            kind: "json",
+            path: "",
+            portable_data_hash: "",
+            repository_name: "",
+            uuid: "",
+            writable: false,
+        },
+        "/var/lib/cwl/workflow.json": {
+            capacity: 0,
+            commit: "",
+            content: {
+            $graph: [
+                {
+                class: "Workflow",
+                doc: "Reverse the lines in a document, then sort those lines.",
+                id: "#main",
+                inputs: [
+                    {
+                    default: null,
+                    doc: "The input file to be processed.",
+                    id: "#main/input",
+                    type: "File",
+                    },
+                    {
+                    default: true,
+                    doc: "If true, reverse (decending) sort",
+                    id: "#main/reverse_sort",
+                    type: "boolean",
+                    },
+                ],
+                outputs: [
+                    {
+                    doc: "The output with the lines reversed and sorted.",
+                    id: "#main/output",
+                    outputSource: "#main/sorted/output",
+                    type: "File",
+                    },
+                ],
+                steps: [
+                    {
+                    id: "#main/rev",
+                    in: [{ id: "#main/rev/input", source: "#main/input" }],
+                    out: ["#main/rev/output"],
+                    run: "#revtool.cwl",
+                    },
+                    {
+                    id: "#main/sorted",
+                    in: [
+                        {
+                        id: "#main/sorted/input",
+                        source: "#main/rev/output",
+                        },
+                        {
+                        id: "#main/sorted/reverse",
+                        source: "#main/reverse_sort",
+                        },
+                    ],
+                    out: ["#main/sorted/output"],
+                    run: "#sorttool.cwl",
+                    },
+                ],
+                },
+                {
+                baseCommand: "rev",
+                class: "CommandLineTool",
+                doc: "Reverse each line using the `rev` command",
+                hints: [{ class: "ResourceRequirement", ramMin: 8 }],
+                id: "#revtool.cwl",
+                inputs: [
+                    {
+                    id: "#revtool.cwl/input",
+                    inputBinding: {},
+                    type: "File",
+                    },
+                ],
+                outputs: [
+                    {
+                    id: "#revtool.cwl/output",
+                    outputBinding: { glob: "output.txt" },
+                    type: "File",
+                    },
+                ],
+                stdout: "output.txt",
+                },
+                {
+                baseCommand: "sort",
+                class: "CommandLineTool",
+                doc: "Sort lines using the `sort` command",
+                hints: [{ class: "ResourceRequirement", ramMin: 8 }],
+                id: "#sorttool.cwl",
+                inputs: [
+                    {
+                    id: "#sorttool.cwl/reverse",
+                    inputBinding: { position: 1, prefix: "-r" },
+                    type: "boolean",
+                    },
+                    {
+                    id: "#sorttool.cwl/input",
+                    inputBinding: { position: 2 },
+                    type: "File",
+                    },
+                ],
+                outputs: [
+                    {
+                    id: "#sorttool.cwl/output",
+                    outputBinding: { glob: "output.txt" },
+                    type: "File",
+                    },
+                ],
+                stdout: "output.txt",
+                },
+            ],
+            cwlVersion: "v1.0",
+            },
+            device_type: "",
+            exclude_from_output: false,
+            git_url: "",
+            kind: "json",
+            path: "",
+            portable_data_hash: "",
+            repository_name: "",
+            uuid: "",
+            writable: false,
+        },
+        "/var/spool/cwl": {
+            capacity: 0,
+            commit: "",
+            content: null,
+            device_type: "",
+            exclude_from_output: false,
+            git_url: "",
+            kind: "collection",
+            path: "",
+            portable_data_hash: "",
+            repository_name: "",
+            uuid: "",
+            writable: true,
+        },
+        stdout: {
+            capacity: 0,
+            commit: "",
+            content: null,
+            device_type: "",
+            exclude_from_output: false,
+            git_url: "",
+            kind: "file",
+            path: "/var/spool/cwl/cwl.output.json",
+            portable_data_hash: "",
+            repository_name: "",
+            uuid: "",
+            writable: false,
+        },
+        },
+        name: "newname.cwl",
+        output_name: "Output from revsort.cwl",
+        output_path: "/var/spool/cwl",
+        output_properties: { key: "val" },
+        output_storage_classes: ["default"],
+        output_ttl: 999999,
+        owner_uuid: "zzzzz-j7d0g-000000000000000",
+        priority: 500,
+        properties: {
+        template_uuid: "zzzzz-7fd4e-7xsza0vgfe785cy",
+        workflowName: "revsort.cwl",
+        },
+        runtime_constraints: {
+        API: true,
+        cuda: {
+            device_count: 0,
+            driver_version: "",
+            hardware_capability: "",
+        },
+        keep_cache_disk: 0,
+        keep_cache_ram: 0,
+        ram: 1342177280,
+        vcpus: 1,
+        },
+        scheduling_parameters: {
+        max_run_time: 0,
+        partitions: [],
+        preemptible: false,
+        },
+        state: "Uncommitted",
+        use_existing: false,
+    };
+
+    beforeEach(() => {
+        dispatch = jest.fn();
+        services = {
+            containerRequestService: {
+                get: jest.fn().mockImplementation(async () => (CommonService.mapResponseKeys({data: sampleFailedProcess}))),
+                create: jest.fn().mockImplementation(async (data) => (CommonService.mapKeys(snakeCase)(data))),
+            },
+        };
+        getState = () => ({
+            auth: {},
+        });
+    });
+
+    it("should request the failed process and return a copy with the proper fields", async () => {
+        // when
+        const newprocess = await copyProcess({
+            name: "newname.cwl",
+            uuid: "zzzzz-xvhdp-111111111111111",
+            ownerUuid: "zzzzz-j7d0g-000000000000000",
+        })(dispatch, getState, services);
+
+        // then
+        expect(services.containerRequestService.get).toHaveBeenCalledWith("zzzzz-xvhdp-111111111111111");
+        expect(newprocess).toEqual(expectedContainerRequest);
+
+    });
+});
index 57e8539778bd8d6dbbbfe6651a65eb5fe6e55bb6..36d73940b13d0a873fac46d5f3df6db1f0d8948d 100644 (file)
@@ -2,21 +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';
 
 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());
@@ -29,17 +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 { kind, containerImage, outputPath, outputName, containerCountMax, command, properties, requestingContainerUuid, mounts, runtimeConstraints, schedulingParameters, environment, cwd, outputTtl, priority, expiresAt, useExisting, filters } = process;
-            await services.containerRequestService.create({ command, containerImage, outputPath, ownerUuid: resource.ownerUuid, name: resource.name, kind, outputName, containerCountMax, properties, requestingContainerUuid, mounts, runtimeConstraints, schedulingParameters, environment, cwd, outputTtl, priority, expiresAt, useExisting, filters });
-            dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME }));
-            return process;
-        } catch (e) {
-            dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME }));
-            throw new Error('Could not copy the process.');
-        }
-    };
\ No newline at end of file
+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 19f30dd2b333d005096e7916faccb8f19e32f837..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 => {
@@ -73,37 +75,93 @@ export const getProcessRuntime = ({ container }: Process) => {
     }
 };
 
-export const getProcessStatusColor = (status: string, { customs }: ArvadosTheme) => {
+
+export const getProcessStatusStyles = (status: string, theme: ArvadosTheme): React.CSSProperties => {
+    let color = theme.customs.colors.grey500;
+    let running = false;
     switch (status) {
         case ProcessStatus.RUNNING:
-            return customs.colors.blue500;
+            color = theme.customs.colors.green800;
+            running = true;
+            break;
         case ProcessStatus.COMPLETED:
-            return customs.colors.green700;
+        case ProcessStatus.REUSED:
+            color = theme.customs.colors.green800;
+            break;
         case ProcessStatus.WARNING:
-            return customs.colors.yellow700;
+            color = theme.customs.colors.green800;
+            running = true;
+            break;
         case ProcessStatus.FAILING:
-            return customs.colors.orange;
+            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:
-            return customs.colors.red900;
+            color = theme.customs.colors.red900;
+            break;
+        case ProcessStatus.QUEUED:
+            color = theme.customs.colors.grey600;
+            running = true;
+            break;
         default:
-            return customs.colors.grey500;
+            color = theme.customs.colors.grey600;
+            break;
     }
+
+    // Using color and running we build the text, border, and background style properties
+    return {
+        // Set background color when not running, otherwise use white
+        backgroundColor: running ? theme.palette.common.white : color,
+        // 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}` } : {}),
+    };
 };
 
 export const getProcessStatus = ({ containerRequest, container }: Process): ProcessStatus => {
     switch (true) {
+        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;
@@ -119,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;
             }
@@ -132,6 +193,32 @@ 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
+);
+
+export const isProcessResumable = ({ containerRequest, container }: Process): boolean => (
+    containerRequest.state === ContainerRequestState.COMMITTED &&
+    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))
+);
+
+export const isProcessCancelable = ({ containerRequest, container }: Process): boolean => (
+    containerRequest.priority !== null &&
+    containerRequest.priority > 0 &&
+    container !== undefined &&
+    (container.state === ContainerState.QUEUED ||
+        container.state === ContainerState.LOCKED ||
+        container.state === ContainerState.RUNNING)
+);
+
 const isSubprocess = (containerUuid: string) => (resource: Resource) =>
     resource.kind === ResourceKind.CONTAINER_REQUEST
     && (resource as ContainerRequestResource).requestingContainerUuid === containerUuid;
index c4d421ac09d9b5719a9f8d1b8f9a00833b7cf662..eadb05e5e1460ff47db6b12d687b36394a9c624e 100644 (file)
@@ -3,40 +3,76 @@
 // 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 { getWorkflow, getWorkflowInputs } from "models/workflow";
+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) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<Process> => {
-        const containerRequest = await services.containerRequestService.get(containerRequestUuid);
-        dispatch<any>(updateResources([containerRequest]));
+export const loadProcess =
+    (containerRequestUuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<Process | undefined> => {
+        let containerRequest: ContainerRequestResource | undefined = undefined;
+        try {
+            containerRequest = await services.containerRequestService.get(containerRequestUuid);
+            dispatch<any>(updateResources([containerRequest]));
+        } catch {
+            return undefined;
+        }
+
+        if (containerRequest.outputUuid) {
+            try {
+                const collection = await services.collectionService.get(containerRequest.outputUuid, false);
+                dispatch<any>(updateResources([collection]));
+            } catch {}
+        }
 
         if (containerRequest.containerUuid) {
-            const container = await services.containerService.get(containerRequest.containerUuid);
-            dispatch<any>(updateResources([container]));
+            let container: ContainerResource | undefined = undefined;
+            try {
+                container = await services.containerService.get(containerRequest.containerUuid, false);
+                dispatch<any>(updateResources([container]));
+            } catch {}
+
+            try {
+                if (container && container.runtimeUserUuid) {
+                    const runtimeUser = await services.userService.get(container.runtimeUserUuid, false);
+                    dispatch<any>(updateResources([runtimeUser]));
+                }
+            } catch {}
+
             return { containerRequest, container };
         }
         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;
         }
@@ -50,6 +86,7 @@ const containerFieldsNoMounts = [
     "auth_uuid",
     "command",
     "container_image",
+    "cost",
     "created_at",
     "cwd",
     "environment",
@@ -80,27 +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 });
-            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 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 };
 
@@ -114,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));
 
@@ -127,41 +199,145 @@ export const reRunProcess = (processUuid: string, workflowUuid: string) =>
         }
     };
 
-const getInputs = (data: any) => {
-    if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) { return []; }
+/*
+ * Fetches raw inputs from containerRequest mounts with fallback to properties
+ * Returns undefined if containerRequest not loaded
+ * Returns {} if inputs not found in mounts or props
+ */
+export const getRawInputs = (data: any): WorkflowInputsData | undefined => {
+    if (!data) {
+        return undefined;
+    }
+    const mountInput = data.mounts?.[MOUNT_PATH_CWL_INPUT]?.content;
+    const propsInput = data.properties?.cwl_input;
+    if (!mountInput && !propsInput) {
+        return {};
+    }
+    return mountInput || propsInput;
+};
+
+export const getInputs = (data: any): CommandInputParameter[] => {
+    // Definitions from mounts are needed so we return early if missing
+    if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) {
+        return [];
+    }
+    const content = getRawInputs(data) as any;
+    // Only escape if content is falsy to allow displaying definitions if no inputs are present
+    // (Don't check raw content length)
+    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: data.mounts[MOUNT_PATH_CWL_INPUT].content[it.id],
-                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,
+          }))
+        : [];
 };
 
-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
-            }
+/*
+ * Fetches raw outputs from containerRequest properties
+ * Assumes containerRequest is loaded
+ */
+export const getRawOutputs = (data: any): CommandInputParameter[] | undefined => {
+    if (!data || !data.properties || !data.properties.cwl_output) {
+        return undefined;
+    }
+    return data.properties.cwl_output;
+};
+
+export type InputCollectionMount = {
+    path: string;
+    pdh: string;
+};
+
+export const getInputCollectionMounts = (data: any): InputCollectionMount[] => {
+    if (!data || !data.mounts) {
+        return [];
+    }
+    return Object.keys(data.mounts)
+        .map(key => ({
+            ...data.mounts[key],
+            path: key,
+        }))
+        .filter(mount => mount.kind === "collection" && mount.portable_data_hash && mount.path)
+        .map(mount => ({
+            path: mount.path,
+            pdh: mount.portable_data_hash,
         }));
-    };
+};
 
-export const REMOVE_PROCESS_DIALOG = 'removeProcessDialog';
+export const getOutputParameters = (data: any): CommandOutputParameter[] => {
+    if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) {
+        return [];
+    }
+    const outputs = getWorkflowOutputs(data.mounts[MOUNT_PATH_CWL_WORKFLOW].content);
+    return outputs
+        ? outputs.map((it: any) => ({
+              type: it.type,
+              id: it.id,
+              label: it.label,
+              doc: it.doc,
+          }))
+        : [];
+};
 
-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 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 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 3079e0ec220e7bdc380735b00ff6672030b8146a..305799e820f6c070a1797d457989ae022ff591c5 100644 (file)
@@ -2,24 +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.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 d8a5d82dc22b5444cbd4196abb18e8402b2b929c..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>;
+    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,31 +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);
 };
 
-export const getOrder = (dataExplorer: DataExplorer) => {
-    const sortColumn = getSortColumn(dataExplorer);
+const getOrder = (dataExplorer: DataExplorer) => {
+    const sortColumn = getSortColumn<ProjectResource>(dataExplorer);
     const order = new OrderBuilder<ProjectResource>();
-    if (sortColumn) {
-        const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC
-            ? OrderDirection.ASC
-            : OrderDirection.DESC;
+    if (sortColumn && sortColumn.sort) {
+        const sortDirection = sortColumn.sort.direction === SortDirection.ASC ? OrderDirection.ASC : OrderDirection.DESC;
 
-        const columnName = sortColumn && sortColumn.name === ProjectPanelColumnNames.NAME ? "name" : "createdAt";
+        // Use createdAt as a secondary sort column so we break ties consistently.
         return order
-            .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.COLLECTION)
-            .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.PROCESS)
-            .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.PROJECT)
+            .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();
@@ -157,18 +151,18 @@ export 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 23eaf7a4a56aaa077083f0f738c37ad5f81ebb6e..c15c37483ed88e9444618ae4ef13cebf5cb91b67 100644 (file)
@@ -20,6 +20,8 @@ import { ServiceRepository } from 'services/services';
 import { matchProjectRoute, matchRunProcessRoute } from 'routes/routes';
 import { RouterState } from "react-router-redux";
 import { GroupClass } from "models/group";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
 
 export interface ProjectCreateFormDialogData {
     ownerUuid: string;
@@ -65,7 +67,8 @@ export const createProject = (project: Partial<ProjectResource>) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(startSubmit(PROJECT_CREATE_FORM_NAME));
         try {
-            const newProject = await services.projectService.create(project);
+            dispatch(progressIndicatorActions.START_WORKING(PROJECT_CREATE_FORM_NAME));
+            const newProject = await services.projectService.create(project, false);
             dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_CREATE_FORM_NAME }));
             dispatch(reset(PROJECT_CREATE_FORM_NAME));
             return newProject;
@@ -73,7 +76,20 @@ export const createProject = (project: Partial<ProjectResource>) =>
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
                 dispatch(stopSubmit(PROJECT_CREATE_FORM_NAME, { name: 'Project with the same name already exists.' } as FormErrors));
+            } else {
+                dispatch(stopSubmit(PROJECT_CREATE_FORM_NAME));
+                dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_CREATE_FORM_NAME }));
+                const errMsg = e.errors
+                    ? e.errors.join('')
+                    : 'There was an error while creating the collection';
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: errMsg,
+                    hideDuration: 2000,
+                    kind: SnackbarKind.ERROR
+                }));
             }
             return undefined;
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(PROJECT_CREATE_FORM_NAME));
         }
     };
diff --git a/src/store/projects/project-lock-actions.ts b/src/store/projects/project-lock-actions.ts
new file mode 100644 (file)
index 0000000..28e934d
--- /dev/null
@@ -0,0 +1,37 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { ServiceRepository } from "services/services";
+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) => {
+    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;
+};
+
+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));
+    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 a6e6748535596e26d09c9b3f948ebf1cabe186dd..812490319aadd47620a142cc345cbe808d15c9ec 100644 (file)
@@ -3,27 +3,18 @@
 // 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";
 import { getResource } from "store/resources/resources";
 import { ProjectResource } from "models/project";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
 
 export interface ProjectUpdateFormDialogData {
     uuid: string;
@@ -33,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(
@@ -61,7 +53,9 @@ export const updateProject = (project: ProjectUpdateFormDialogData) =>
                     name: project.name,
                     description: project.description,
                     properties: project.properties,
-                });
+                },
+                false
+            );
             dispatch(projectPanelActions.REQUEST_ITEMS());
             dispatch(reset(PROJECT_UPDATE_FORM_NAME));
             dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_UPDATE_FORM_NAME }));
@@ -69,8 +63,18 @@ 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,
+                    })
+                );
             }
-            return ;
+            return;
         }
     };
index bc0fc3297d574630db28f515ff7cf32673fcad8b..6e36e1f8b92e2200536bf646f4e3a827a52ac234 100644 (file)
@@ -2,9 +2,13 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+import { Dispatch } from "redux";
 import { bindDataExplorerActions } from "store/data-explorer/data-explorer-action";
 
 export const PUBLIC_FAVORITE_PANEL_ID = "publicFavoritePanel";
 export const publicFavoritePanelActions = bindDataExplorerActions(PUBLIC_FAVORITE_PANEL_ID);
 
-export const loadPublicFavoritePanel = () => publicFavoritePanelActions.REQUEST_ITEMS();
\ No newline at end of file
+export const loadPublicFavoritePanel = () => (dispatch: Dispatch) => {
+    dispatch(publicFavoritePanelActions.RESET_EXPLORER_SEARCH_VALUE());
+    dispatch(publicFavoritePanelActions.REQUEST_ITEMS());
+};
\ No newline at end of file
index dd21a38026642c3cf576c957d6b98559d2a9a302..48d27be5413214c5a64f91fdec10f200261496b9 100644 (file)
@@ -10,17 +10,14 @@ import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 import { getDataExplorer } from 'store/data-explorer/data-explorer-reducer';
 import { resourcesActions } from 'store/resources/resources-actions';
 import { FilterBuilder } from 'services/api/filter-builder';
-import { SortDirection } from 'components/data-table/data-column';
-import { OrderDirection, OrderBuilder } from 'services/api/order-builder';
-import { getSortColumn } from "store/data-explorer/data-explorer-reducer";
 import { FavoritePanelColumnNames } from 'views/favorite-panel/favorite-panel';
 import { publicFavoritePanelActions } from 'store/public-favorites-panel/public-favorites-action';
 import { DataColumns } from 'components/data-table/data-table';
 import { serializeSimpleObjectTypeFilters } from '../resource-type-filters/resource-type-filters';
-import { LinkResource, LinkClass } from 'models/link';
-import { GroupContentsResource, GroupContentsResourcePrefix } from 'services/groups-service/groups-service';
+import { LinkClass } from 'models/link';
 import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
 import { updatePublicFavorites } from 'store/public-favorites/public-favorites-actions';
+import { GroupContentsResource } from 'services/groups-service/groups-service';
 
 export class PublicFavoritesMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
@@ -32,25 +29,9 @@ export class PublicFavoritesMiddlewareService extends DataExplorerMiddlewareServ
         if (!dataExplorer) {
             api.dispatch(favoritesPanelDataExplorerIsNotSet());
         } else {
-            const columns = dataExplorer.columns as DataColumns<string>;
-            const sortColumn = getSortColumn(dataExplorer);
+            const columns = dataExplorer.columns as DataColumns<string, GroupContentsResource>;
             const typeFilters = serializeSimpleObjectTypeFilters(getDataExplorerColumnFilters(columns, FavoritePanelColumnNames.TYPE));
 
-
-            const linkOrder = new OrderBuilder<LinkResource>();
-            const contentOrder = new OrderBuilder<GroupContentsResource>();
-
-            if (sortColumn && sortColumn.name === FavoritePanelColumnNames.NAME) {
-                const direction = sortColumn.sortDirection === SortDirection.ASC
-                    ? OrderDirection.ASC
-                    : OrderDirection.DESC;
-
-                linkOrder.addOrder(direction, "name");
-                contentOrder
-                    .addOrder(direction, "name", GroupContentsResourcePrefix.COLLECTION)
-                    .addOrder(direction, "name", GroupContentsResourcePrefix.PROCESS)
-                    .addOrder(direction, "name", GroupContentsResourcePrefix.PROJECT);
-            }
             try {
                 api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
                 const uuidPrefix = api.getState().auth.config.uuidPrefix;
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 7d76ec69a4af957672f27a72aed08b68b8d1c48b..af40e86ade09bfc30698eded0bb1b8802dfac577 100644 (file)
@@ -2,6 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+import axios from "axios";
 import { ofType, unionize, UnionOf } from "common/unionize";
 import { GroupContentsResource, GroupContentsResourcePrefix } from 'services/groups-service/groups-service';
 import { Dispatch } from 'redux';
@@ -62,13 +63,13 @@ export const loadRecentQueries = () =>
         return recentQueries;
     };
 
-export const searchData = (searchValue: string) =>
+export const searchData = (searchValue: string, useCancel = false) =>
     async (dispatch: Dispatch, getState: () => RootState) => {
         const currentView = getState().searchBar.currentView;
         dispatch(searchResultsPanelActions.CLEAR());
         dispatch(searchBarActions.SET_SEARCH_VALUE(searchValue));
         if (searchValue.length > 0) {
-            dispatch<any>(searchGroups(searchValue, 5));
+            dispatch<any>(searchGroups(searchValue, 5, useCancel));
             if (currentView === SearchView.BASIC) {
                 dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
                 dispatch(navigateToSearchResults(searchValue));
@@ -88,6 +89,9 @@ export const searchAdvancedData = (data: SearchBarAdvancedFormData) =>
 
 export const setSearchValueFromAdvancedData = (data: SearchBarAdvancedFormData, prevData?: SearchBarAdvancedFormData) =>
     (dispatch: Dispatch, getState: () => RootState) => {
+        if (data.projectObject) {
+            data.projectUuid = data.projectObject.uuid;
+        }
         const searchValue = getState().searchBar.searchValue;
         const value = getQueryFromAdvancedData({
             ...data,
@@ -97,8 +101,11 @@ export const setSearchValueFromAdvancedData = (data: SearchBarAdvancedFormData,
     };
 
 export const setAdvancedDataFromSearchValue = (search: string, vocabulary: Vocabulary) =>
-    async (dispatch: Dispatch) => {
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const data = getAdvancedDataFromQuery(search, vocabulary);
+        if (data.projectUuid) {
+            data.projectObject = await services.projectService.get(data.projectUuid);
+        }
         dispatch<any>(initialize(SEARCH_BAR_ADVANCED_FORM_NAME, data));
         if (data.projectUuid) {
             await dispatch<any>(activateSearchBarProject(data.projectUuid));
@@ -203,26 +210,41 @@ export const submitData = (event: React.FormEvent<HTMLFormElement>) =>
         }
     };
 
-
-const searchGroups = (searchValue: string, limit: number) =>
+let cancelTokens: any[] = [];
+const searchGroups = (searchValue: string, limit: number, useCancel = false) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const currentView = getState().searchBar.currentView;
 
-        if (searchValue || currentView === SearchView.ADVANCED) {
-            const { cluster: clusterId } = getAdvancedDataFromQuery(searchValue);
-            const sessions = getSearchSessions(clusterId, getState().auth.sessions);
-            const lists: ListResults<GroupContentsResource>[] = await Promise.all(sessions.map(session => {
-                const filters = queryToFilters(searchValue, session.apiRevision);
-                return services.groupsService.contents('', {
-                    filters,
-                    limit,
-                    recursive: true
-                }, session);
-            }));
-
-            const items = lists.reduce((items, list) => items.concat(list.items), [] as GroupContentsResource[]);
-            dispatch(searchBarActions.SET_SEARCH_RESULTS(items));
+        if (cancelTokens.length > 0 && useCancel) {
+            cancelTokens.forEach(cancelToken => (cancelToken as any).cancel('New search request triggered.'));
+            cancelTokens = [];
         }
+
+        setTimeout(async () => {
+            if (searchValue || currentView === SearchView.ADVANCED) {
+                const { cluster: clusterId } = getAdvancedDataFromQuery(searchValue);
+                const sessions = getSearchSessions(clusterId, getState().auth.sessions);
+                const lists: ListResults<GroupContentsResource>[] = await Promise.all(sessions.map((session, index) => {
+                    cancelTokens.push(axios.CancelToken.source());
+                    const filters = queryToFilters(searchValue, session.apiRevision);
+                    return services.groupsService.contents('', {
+                        filters,
+                        limit,
+                        recursive: true
+                    }, session, cancelTokens[index].token);
+                }));
+
+                cancelTokens = [];
+
+                const items = lists.reduce((items, list) => items.concat(list.items), [] as GroupContentsResource[]);
+
+                if (lists.filter(list => !!(list as any).items).length !== lists.length) {
+                    dispatch(searchBarActions.SET_SEARCH_RESULTS([]));
+                } else {
+                    dispatch(searchBarActions.SET_SEARCH_RESULTS(items));
+                }
+            }
+        }, 10);
     };
 
 const buildQueryFromKeyMap = (data: any, keyMap: string[][]) => {
@@ -274,7 +296,7 @@ export const getQueryFromAdvancedData = (data: SearchBarAdvancedFormData, prevDa
         };
         (data.properties || []).forEach(p =>
             fo[`prop-"${p.keyID || p.key}":"${p.valueID || p.value}"`] = `"${p.valueID || p.value}"`
-            );
+        );
         return fo;
     };
 
@@ -292,9 +314,9 @@ export const getQueryFromAdvancedData = (data: SearchBarAdvancedFormData, prevDa
             [`has:"${p.keyID || p.key}"`, `prop-"${p.keyID || p.key}":"${p.valueID || p.value}"`]
         ));
 
-    const modified = getModifiedKeysValues(flatData(data), prevData ? flatData(prevData):{});
+    const modified = getModifiedKeysValues(flatData(data), prevData ? flatData(prevData) : {});
     value = buildQueryFromKeyMap(
-        {searchValue: data.searchValue, ...modified} as SearchBarAdvancedFormData, keyMap);
+        { searchValue: data.searchValue, ...modified } as SearchBarAdvancedFormData, keyMap);
 
     value = value.trim();
     return value;
index 6ab25d6ea5cc3f83c6be151669e57a51bbba6d56..b0bad2f3c294285e82e9d18030b79421bef41376 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { getTreePicker, TreePicker } from "store/tree-picker/tree-picker";
-import { getNode, getNodeAncestorsIds, initTreeNode, TreeNodeStatus } from "models/tree";
+import { getNode, getNodeAncestorsIds, initTreeNode } from "models/tree";
 import { Dispatch } from "redux";
 import { RootState } from "store/store";
 import { getUserUuid } from "common/getuser";
@@ -66,8 +66,10 @@ export const expandSearchBarTreeItem = (id: string) =>
     };
 
 export const activateSearchBarProject = (id: string) =>
-    async (dispatch: Dispatch, getState: () => RootState) => {
-        const { treePicker } = getState();
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+
+
+        /*const { treePicker } = getState();
         const node = getSearchBarTreeNode(id)(treePicker);
         if (node && node.status !== TreeNodeStatus.LOADED) {
             await dispatch<any>(loadSearchBarTreeProjects(id));
@@ -78,7 +80,7 @@ export const activateSearchBarProject = (id: string) =>
             ids: getSearchBarTreeNodeAncestorsIds(id)(treePicker),
             pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID
         }));
-        dispatch<any>(expandSearchBarTreeItem(id));
+        dispatch<any>(expandSearchBarTreeItem(id));*/
     };
 
 
index 78ba6c38bf130b2b34ab067d80e893f6edbb4135..00a69cd2e308f733b617ec5a5b35dadebe42c01c 100644 (file)
@@ -20,11 +20,12 @@ import {
     getAdvancedDataFromQuery
 } from 'store/search-bar/search-bar-actions';
 import { getSortColumn } from "store/data-explorer/data-explorer-reducer";
-import { joinFilters } from 'services/api/filter-builder';
+import { FilterBuilder, joinFilters } from 'services/api/filter-builder';
 import { DataColumns } from 'components/data-table/data-table';
 import { serializeResourceTypeFilters } from 'store//resource-type-filters/resource-type-filters';
 import { ProjectPanelColumnNames } from 'views/project-panel/project-panel';
-import { Resource } from 'models/resource';
+import { ResourceKind } from 'models/resource';
+import { ContainerRequestResource } from 'models/container-request';
 
 export class SearchResultsMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
@@ -60,15 +61,27 @@ export class SearchResultsMiddlewareService extends DataExplorerMiddlewareServic
                 .then((response) => {
                     api.dispatch(updateResources(response.items));
                     api.dispatch(appendItems(response));
+                    // Request all containers for process status to be available
+                    const containerRequests = response.items.filter((item) => item.kind === ResourceKind.CONTAINER_REQUEST) as ContainerRequestResource[];
+                    const containerUuids = containerRequests.map(container => container.containerUuid).filter(uuid => uuid !== null) as string[];
+                    containerUuids.length && this.services.containerService
+                        .list({
+                            filters: new FilterBuilder()
+                                .addIn('uuid', containerUuids)
+                                .getFilters()
+                        }, false)
+                        .then((containers) => {
+                            api.dispatch(updateResources(containers.items));
+                        });
                 }).catch(() => {
                     api.dispatch(couldNotFetchSearchResults(session.clusterId));
                 });
-            }
+        }
         );
     }
 }
 
-const typeFilters = (columns: DataColumns<string>) => serializeResourceTypeFilters(getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.TYPE));
+const typeFilters = (columns: DataColumns<string, GroupContentsResource>) => serializeResourceTypeFilters(getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.TYPE));
 
 export const getParams = (dataExplorer: DataExplorer, query: string, apiRevision: number) => ({
     ...dataExplorerToListParams(dataExplorer),
@@ -82,17 +95,19 @@ export const getParams = (dataExplorer: DataExplorer, query: string, apiRevision
 });
 
 const getOrder = (dataExplorer: DataExplorer) => {
-    const sortColumn = getSortColumn(dataExplorer);
+    const sortColumn = getSortColumn<GroupContentsResource>(dataExplorer);
     const order = new OrderBuilder<GroupContentsResource>();
-    if (sortColumn) {
-        const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC
+    if (sortColumn && sortColumn.sort) {
+        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.name as keyof Resource, GroupContentsResourcePrefix.COLLECTION)
-            .addOrder(sortDirection, sortColumn.name as keyof Resource, GroupContentsResourcePrefix.PROCESS)
-            .addOrder(sortDirection, sortColumn.name as keyof Resource, GroupContentsResourcePrefix.PROJECT)
+            .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 5f92637cbb3b7951874fe06218dac34acb7f9b2b..1a2bdabab3d7f0325579525dc41e66fa2028377a 100644 (file)
@@ -17,11 +17,11 @@ import { GroupContentsResource, GroupContentsResourcePrefix } from 'services/gro
 import { SortDirection } from 'components/data-table/data-column';
 import { OrderBuilder, OrderDirection } from 'services/api/order-builder';
 import { ProjectResource } from 'models/project';
-import { ProjectPanelColumnNames } from 'views/project-panel/project-panel';
 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) {
@@ -34,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));
@@ -52,31 +48,31 @@ 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,
 });
 
-export const getOrder = (dataExplorer: DataExplorer) => {
-    const sortColumn = getSortColumn(dataExplorer);
+const getOrder = (dataExplorer: DataExplorer) => {
+    const sortColumn = getSortColumn<ProjectResource>(dataExplorer);
     const order = new OrderBuilder<ProjectResource>();
-    if (sortColumn) {
-        const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC
+    if (sortColumn && sortColumn.sort) {
+        const sortDirection = sortColumn.sort.direction === SortDirection.ASC
             ? OrderDirection.ASC
             : OrderDirection.DESC;
-        const columnName = sortColumn && sortColumn.name === ProjectPanelColumnNames.NAME ? "name" : "createdAt";
-        if (columnName === 'name') {
-            return order
-                .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.COLLECTION)
-                .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.PROCESS)
-                .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.PROJECT)
-                .getOrder();
-        } else {
-            return order
-                .addOrder(sortDirection, columnName)
-                .getOrder();
-        }
+
+        // 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 c8731ae68e55e9455eb00cbbd13915d464022eb3..616bd005de301c801fddd8c32b6f8f9c1666c3ff 100644 (file)
@@ -2,8 +2,12 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+import { Dispatch } from "redux";
 import { bindDataExplorerActions } from "../data-explorer/data-explorer-action";
 
 export const SHARED_WITH_ME_PANEL_ID = "sharedWithMePanel";
 export const sharedWithMePanelActions = bindDataExplorerActions(SHARED_WITH_ME_PANEL_ID);
-export const loadSharedWithMePanel = () => sharedWithMePanelActions.REQUEST_ITEMS();
+export const loadSharedWithMePanel = () => (dispatch: Dispatch) => {
+    dispatch(sharedWithMePanelActions.RESET_EXPLORER_SEARCH_VALUE());
+    dispatch(sharedWithMePanelActions.REQUEST_ITEMS());
+};
\ No newline at end of file
index cdc6c0c7267d4bab58434999b260d8905de7bc82..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', permissionLinks.map(({ tailUuid }) => tailUuid))
-            .getFilters();
-
-        const { items: users } = await userService.list({ filters, count: "none" });
-        const { items: groups } = await groupsService.list({ filters, count: "none" });
+    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 dd56b42870d2486a5d887caee3f54a3b134eb447..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 = 'Projects',
-    SHARED_WITH_ME = 'Shared with me',
-    PUBLIC_FAVORITES = 'Public Favorites',
+    PROJECTS = 'Home Projects',
     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 9e8da28307fc410467c594ba75d702e96f9d5db8..e4f53ceaa6be6df93c35c0831fb4d8c93d986d59 100644 (file)
@@ -5,7 +5,17 @@
 import { Dispatch } from 'redux';
 import { navigateTo } from 'store/navigation/navigation-action';
 
+export const sidePanelActions = {
+    TOGGLE_COLLAPSE: 'TOGGLE_COLLAPSE'
+}
+
 export const navigateFromSidePanel = (id: string) =>
     (dispatch: Dispatch) => {
         dispatch<any>(navigateTo(id));
     };
+
+export const toggleSidePanel = (collapsedState: boolean) => {
+    return (dispatch) => {
+        dispatch({type: sidePanelActions.TOGGLE_COLLAPSE, payload: !collapsedState})
+    }
+}
\ No newline at end of file
diff --git a/src/store/side-panel/side-panel-reducer.tsx b/src/store/side-panel/side-panel-reducer.tsx
new file mode 100644 (file)
index 0000000..a6ed03b
--- /dev/null
@@ -0,0 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { sidePanelActions } from "./side-panel-action"
+
+interface SidePanelState {
+  collapsedState: boolean
+}
+
+const sidePanelInitialState = {
+  collapsedState: false
+}
+
+export const sidePanelReducer = (state: SidePanelState = sidePanelInitialState, action)=>{
+  if(action.type === sidePanelActions.TOGGLE_COLLAPSE) return {...state, collapsedState: action.payload}
+  return state
+}
\ No newline at end of file
index c04371543f34058ca39ee54f091889baf6367675..7b6f2efd150e1d8e3170fb0e8017a3b4370b2f03 100644 (file)
@@ -20,7 +20,7 @@ export enum SnackbarKind {
 
 export const snackbarActions = unionize({
     OPEN_SNACKBAR: ofType<{message: string; hideDuration?: number, kind?: SnackbarKind, link?: string}>(),
-    CLOSE_SNACKBAR: ofType<{}>(),
+    CLOSE_SNACKBAR: ofType<{}|null>(),
     SHIFT_MESSAGES: ofType<{}>()
 });
 
index fa1717c7a4aba0cd51b0e633d486150fde87b64a..c3fcfb0795e280fe36a844a4208e45c581b44838 100644 (file)
@@ -29,10 +29,21 @@ export const snackbarReducer = (state = initialState, action: SnackbarAction) =>
                 })
             };
         },
-        CLOSE_SNACKBAR: () => ({
-            ...state,
-            open: false
-        }),
+        CLOSE_SNACKBAR: (payload) => {
+            let newMessages: any = [...state.messages];// state.messages.filter(({ message }) => message !== payload);
+
+            if (payload === undefined || JSON.stringify(payload) === '{}') {
+                newMessages.pop();
+            } else {
+                newMessages = state.messages.filter((message, index) => index !== payload);
+            }
+
+            return {
+                ...state,
+                messages: newMessages,
+                open: newMessages.length > 0
+            }
+        },
         SHIFT_MESSAGES: () => {
             const messages = state.messages.filter((m, idx) => idx > 0);
             return {
index 94f110a09563ab17537b44445b964f650fef2ce5..daa9812e729900fd23fcb2bd04966f6997e764ae 100644 (file)
@@ -2,89 +2,90 @@
 //
 // 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 } from './tree-picker/tree-picker-reducer';
-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 { 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 {
-      __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: typeof compose;
+        __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: typeof compose;
     }
 }
 
-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> };
@@ -92,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();
 
@@ -157,6 +133,7 @@ export function configureStore(history: History, services: ServiceRepository, co
         routerMiddleware(history),
         thunkMiddleware.withExtraArgument(services),
         authMiddleware(services),
+        tooltipsMiddleware(services),
         projectPanelMiddleware,
         favoritePanelMiddleware,
         allProcessessPanelMiddleware,
@@ -174,43 +151,50 @@ export function configureStore(history: History, services: ServiceRepository, co
         publicFavoritesMiddleware,
         collectionsContentAddress,
         subprocessMiddleware,
+        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),
-    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,
-    fileUploader: fileUploaderReducer,
-    processPanel: processPanelReducer,
-    progressIndicator: progressIndicatorReducer,
-    runProcessPanel: runProcessPanelReducer,
-    appInfo: appInfoReducer,
-    searchBar: searchBarReducer,
-    virtualMachines: virtualMachinesReducer,
-    repositories: repositoriesReducer,
-    keepServices: keepServicesReducer,
-    linkAccountPanel: linkAccountPanelReducer
-});
+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 dd303233117af7a74e77f8bfa34b381ac183ddfa..5124c8346a6951fe656cf0ae255038a7cfc344bd 100644 (file)
@@ -5,54 +5,50 @@
 import { ServiceRepository } from 'services/services';
 import { MiddlewareAPI, Dispatch } from 'redux';
 import {
-    DataExplorerMiddlewareService, dataExplorerToListParams, listResultsToDataExplorerItemsMeta, getDataExplorerColumnFilters
+    DataExplorerMiddlewareService, dataExplorerToListParams, listResultsToDataExplorerItemsMeta, getDataExplorerColumnFilters, getOrder
 } from 'store/data-explorer/data-explorer-middleware-service';
 import { RootState } from 'store/store';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 import { DataExplorer, getDataExplorer } from 'store/data-explorer/data-explorer-reducer';
 import { updateResources } from 'store/resources/resources-actions';
-import { SortDirection } from 'components/data-table/data-column';
-import { OrderDirection, OrderBuilder } from 'services/api/order-builder';
 import { ListResults } from 'services/common-service/common-service';
-import { getSortColumn } from "store/data-explorer/data-explorer-reducer";
 import { ProcessResource } from 'models/process';
-import { SubprocessPanelColumnNames } from 'views/subprocess-panel/subprocess-panel-root';
 import { FilterBuilder, joinFilters } from 'services/api/filter-builder';
 import { subprocessPanelActions } from './subprocess-panel-actions';
 import { DataColumns } from 'components/data-table/data-table';
 import { ProcessStatusFilter, buildProcessStatusFilters } from '../resource-type-filters/resource-type-filters';
-import { ContainerRequestResource } from 'models/container-request';
+import { ContainerRequestResource, containerRequestFieldsNoMounts } from 'models/container-request';
 import { progressIndicatorActions } from '../progress-indicator/progress-indicator-actions';
 import { loadMissingProcessesInformation } from '../project-panel/project-panel-middleware-service';
-import { containerRequestFieldsNoMounts } from 'store/all-processes-panel/all-processes-panel-middleware-service';
 
 export class SubprocessMiddlewareService 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 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);
-            const containerRequests = await this.services.containerRequestService.list(
-                {
-                    ...getParams(dataExplorer, parentContainerRequest) ,
-                    select: containerRequestFieldsNoMounts
-                });
-
-            api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
-            api.dispatch(updateResources(containerRequests.items));
-            await api.dispatch<any>(loadMissingProcessesInformation(containerRequests.items));
-            // Populate the actual user view
-            api.dispatch(setItems(containerRequests));
+            if (parentContainerRequest.containerUuid) {
+                const containerRequests = await this.services.containerRequestService.list(
+                    {
+                        ...getParams(dataExplorer, parentContainerRequest),
+                        select: containerRequestFieldsNoMounts
+                    });
+                api.dispatch(updateResources(containerRequests.items));
+                await api.dispatch<any>(loadMissingProcessesInformation(containerRequests.items));
+                // Populate the actual user view
+                api.dispatch(setItems(containerRequests));
+            }
+            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());
         }
     }
@@ -62,51 +58,34 @@ export const getParams = (
     dataExplorer: DataExplorer,
     parentContainerRequest: ContainerRequestResource) => ({
         ...dataExplorerToListParams(dataExplorer),
-        order: getOrder(dataExplorer),
+        order: getOrder<ProcessResource>(dataExplorer),
         filters: getFilters(dataExplorer, parentContainerRequest)
     });
 
-const getOrder = (dataExplorer: DataExplorer) => {
-    const sortColumn = getSortColumn(dataExplorer);
-    const order = new OrderBuilder<ProcessResource>();
-    if (sortColumn) {
-        const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC
-            ? OrderDirection.ASC
-            : OrderDirection.DESC;
-
-        const columnName = sortColumn && sortColumn.name === SubprocessPanelColumnNames.NAME ? "name" : "modifiedAt";
-        return order
-            .addOrder(sortDirection, columnName)
-            .getOrder();
-    } else {
-        return order.getOrder();
-    }
-};
-
 export const getFilters = (
     dataExplorer: DataExplorer,
     parentContainerRequest: ContainerRequestResource) => {
-        const columns = dataExplorer.columns as DataColumns<string>;
-        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({
diff --git a/src/store/tooltips/tooltips-middleware.ts b/src/store/tooltips/tooltips-middleware.ts
new file mode 100644 (file)
index 0000000..d4ea41e
--- /dev/null
@@ -0,0 +1,84 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CollectionDirectory, CollectionFile } from "models/collection-file";
+import { Middleware, Store } from "redux";
+import { ServiceRepository } from "services/services";
+import { RootState } from "store/store";
+import tippy, { createSingleton } from 'tippy.js';
+import 'tippy.js/dist/tippy.css';
+
+let running = false;
+let tooltipsContents = null;
+let tooltipsFetchFailed = false;
+export const TOOLTIP_LOCAL_STORAGE_KEY = "TOOLTIP_LOCAL_STORAGE_KEY";
+
+const tippySingleton = createSingleton([], {delay: 10});
+
+export const tooltipsMiddleware = (services: ServiceRepository): Middleware => (store: Store) => next => action => {
+    const state: RootState = store.getState();
+
+    if (state && state.auth && state.auth.config && state.auth.config.clusterConfig && state.auth.config.clusterConfig.Workbench) {
+        const hideTooltip = localStorage.getItem(TOOLTIP_LOCAL_STORAGE_KEY);
+        const { BannerUUID: bannerUUID } = state.auth.config.clusterConfig.Workbench;
+    
+        if (bannerUUID && !tooltipsContents && !hideTooltip && !tooltipsFetchFailed && !running) {
+            running = true;
+            fetchTooltips(services, bannerUUID);
+        } else if (tooltipsContents && !hideTooltip && !tooltipsFetchFailed) {
+            applyTooltips();
+        }
+    }
+
+    return next(action);
+};
+
+const fetchTooltips = (services, bannerUUID) => {
+    services.collectionService.files(bannerUUID)
+        .then(results => {
+            const tooltipsFile: CollectionDirectory | CollectionFile | undefined = results.find(({ name }) => name === 'tooltips.json');
+
+            if (tooltipsFile) {
+                running = true;
+                services.collectionService.getFileContents(tooltipsFile as CollectionFile)
+                    .then(data => {
+                        tooltipsContents = JSON.parse(data);
+                        applyTooltips();
+                    })
+                    .catch(() => {})
+                    .finally(() => {
+                        running = false;
+                    });
+            }  else {
+                tooltipsFetchFailed = true;
+            }
+        })
+        .catch(() => {})
+        .finally(() => {
+            running = false;
+        });
+};
+
+const applyTooltips = () => {
+    const tippyInstances: any[] = Object.keys(tooltipsContents as any)
+        .map((key) => {
+            const content = (tooltipsContents as any)[key]
+            const element = document.querySelector(key);
+
+            if (element) {
+                const hasTippyAttatched = !!(element as any)._tippy;
+
+                if (!hasTippyAttatched && tooltipsContents) {
+                    return tippy(element as any, { content });
+                }
+            }
+
+            return null;
+        })
+        .filter(data => !!data);
+
+    if (tippyInstances.length > 0) {
+        tippySingleton.setInstances(tippyInstances);
+    }
+};
\ No newline at end of file
index 80321b040402c567f276c323468a7be3d09e3894..78b1a9729dc441c3bc94ef9bb10fbd68599b621d 100644 (file)
@@ -2,9 +2,13 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+import { Dispatch } from "redux";
 import { bindDataExplorerActions } from "store/data-explorer/data-explorer-action";
 
 export const TRASH_PANEL_ID = "trashPanel";
 export const trashPanelActions = bindDataExplorerActions(TRASH_PANEL_ID);
 
-export const loadTrashPanel = () => trashPanelActions.REQUEST_ITEMS();
+export const loadTrashPanel = () => (dispatch: Dispatch) => {
+    dispatch(trashPanelActions.RESET_EXPLORER_SEARCH_VALUE());
+    dispatch(trashPanelActions.REQUEST_ITEMS());
+};
\ No newline at end of file
index 0319f729861269f7a643ec90c9694d44360a5df4..c822cece8742856330a7d7734cd655cae923aec7 100644 (file)
@@ -15,19 +15,20 @@ import { FilterBuilder } from "services/api/filter-builder";
 import { trashPanelActions } from "./trash-panel-action";
 import { Dispatch, MiddlewareAPI } from "redux";
 import { OrderBuilder, OrderDirection } from "services/api/order-builder";
-import { GroupContentsResourcePrefix } from "services/groups-service/groups-service";
-import { ProjectResource } from "models/project";
+import { GroupContentsResource, GroupContentsResourcePrefix } from "services/groups-service/groups-service";
 import { ProjectPanelColumnNames } from "views/project-panel/project-panel";
 import { updateFavorites } from "store/favorites/favorites-actions";
 import { updatePublicFavorites } from 'store/public-favorites/public-favorites-actions';
 import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
 import { updateResources } from "store/resources/resources-actions";
 import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
-import { getSortColumn } from "store/data-explorer/data-explorer-reducer";
+import { DataExplorer, getSortColumn } from "store/data-explorer/data-explorer-reducer";
 import { serializeResourceTypeFilters } from 'store//resource-type-filters/resource-type-filters';
 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);
@@ -35,8 +36,7 @@ export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService {
 
     async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
         const dataExplorer = api.getState().dataExplorer[this.getId()];
-        const columns = dataExplorer.columns as DataColumns<string>;
-        const sortColumn = getSortColumn(dataExplorer);
+        const columns = dataExplorer.columns as DataColumns<string, CollectionResource>;
 
         const typeFilters = serializeResourceTypeFilters(getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.TYPE));
 
@@ -52,27 +52,14 @@ export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService {
             otherFilters,
         );
 
-        const order = new OrderBuilder<ProjectResource>();
-
-        if (sortColumn) {
-            const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC
-                ? OrderDirection.ASC
-                : OrderDirection.DESC;
-
-            const columnName = sortColumn && sortColumn.name === ProjectPanelColumnNames.NAME ? "name" : "createdAt";
-            order
-                .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.COLLECTION)
-                .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.PROJECT);
-        }
-
         const userUuid = getUserUuid(api.getState());
         if (!userUuid) { return; }
         try {
             api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
             const listResults = await this.services.groupsService
-                .contents(userUuid, {
+                .contents('', {
                     ...dataExplorerToListParams(dataExplorer),
-                    order: order.getOrder(),
+                    order: getOrder(dataExplorer),
                     filters,
                     recursive: true,
                     includeTrash: true
@@ -98,9 +85,29 @@ export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService {
             }));
             api.dispatch(couldNotFetchTrashContents());
         }
+        api.dispatch<any>(removeDisabledButton(MultiSelectMenuActionNames.MOVE_TO_TRASH))
     }
 }
 
+const getOrder = (dataExplorer: DataExplorer) => {
+    const sortColumn = getSortColumn<GroupContentsResource>(dataExplorer);
+    const order = new OrderBuilder<GroupContentsResource>();
+    if (sortColumn && sortColumn.sort) {
+        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.PROJECT)
+            .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS)
+            .getOrder();
+    } else {
+        return order.getOrder();
+    }
+};
+
 const couldNotFetchTrashContents = () =>
     snackbarActions.OPEN_SNACKBAR({
         message: 'Could not fetch trash contents.',
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 06abe39f7b3a3adead00a18d846eb52577f6e515..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";
@@ -14,7 +14,7 @@ import { pipe, values } from 'lodash/fp';
 import { ResourceKind } from 'models/resource';
 import { GroupContentsResource } from 'services/groups-service/groups-service';
 import { getTreePicker, TreePicker } from './tree-picker';
-import { ProjectsTreePickerItem } from 'views-components/projects-tree-picker/generic-projects-tree-picker';
+import { ProjectsTreePickerItem } from './tree-picker-middleware';
 import { OrderBuilder } from 'services/api/order-builder';
 import { ProjectResource } from 'models/project';
 import { mapTree } from '../../models/tree';
@@ -22,28 +22,52 @@ 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 }>(),
     LOAD_TREE_PICKER_NODE_SUCCESS: ofType<{ id: string, nodes: Array<TreeNode<any>>, pickerId: string }>(),
     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 }>()
 });
 
 export type TreePickerAction = UnionOf<typeof treePickerActions>;
 
+export interface LoadProjectParams {
+    includeCollections?: boolean;
+    includeDirectories?: boolean;
+    includeFiles?: boolean;
+    includeFilterGroups?: boolean;
+    options?: { showOnlyOwned: boolean; showOnlyWritable: boolean; };
+}
+
+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>;
+
 export const getProjectsTreePickerIds = (pickerId: string) => ({
     home: `${pickerId}_home`,
     shared: `${pickerId}_shared`,
     favorites: `${pickerId}_favorites`,
-    publicFavorites: `${pickerId}_publicFavorites`
+    publicFavorites: `${pickerId}_publicFavorites`,
+    search: `${pickerId}_search`,
 });
 
 export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Value>) => true) => (state: TreePicker) =>
@@ -69,13 +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) => {
-        const { home, shared, favorites, publicFavorites } = getProjectsTreePickerIds(pickerId);
+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> {
@@ -93,54 +135,117 @@ export const receiveTreePickerData = <T>(params: ReceiveTreePickerDataParams<T>)
             nodes: data.map(item => initTreeNode(extractNodeData(item))),
             pickerId,
         }));
-        dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
+        dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId }));
     };
 
-interface LoadProjectParams {
+interface LoadProjectParamsWithId extends LoadProjectParams {
     id: string;
     pickerId: string;
-    includeCollections?: boolean;
-    includeFiles?: boolean;
-    includeFilterGroups?: boolean;
     loadShared?: boolean;
+    searchProjects?: boolean;
 }
-export const loadProject = (params: LoadProjectParams) =>
-    async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => {
-        const { id, pickerId, includeCollections = false, includeFiles = false, includeFilterGroups = false, loadShared = false } = params;
+
+/**
+ * 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,
+            includeDirectories = false,
+            includeFiles = false,
+            includeFilterGroups = false,
+            loadShared = false,
+            options,
+            searchProjects = false
+        } = params;
 
         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
 
-        const filters = pipe(
-            (fb: FilterBuilder) => includeCollections
-                ? fb.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
-                : fb.addIsA('uuid', [ResourceKind.PROJECT]),
-            fb => fb.getFilters(),
-        )(new FilterBuilder());
+        let filterB = new FilterBuilder();
 
-        const { items } = await services.groupsService.contents(loadShared ? '' : id, { filters, excludeHomeProject: loadShared || undefined });
+        filterB = (includeCollections && !searchProjects)
+            ? filterB.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION])
+            : filterB.addIsA('uuid', [ResourceKind.PROJECT]);
 
-        dispatch<any>(receiveTreePickerData<GroupContentsResource>({
-            id,
-            pickerId,
-            data: items.filter((item) => {
+        const state = getState();
+
+        if (state.treePickerSearch.collectionFilterValues[pickerId]) {
+            filterB = filterB.addFullTextSearch(state.treePickerSearch.collectionFilterValues[pickerId], 'collections');
+        } else {
+            filterB = filterB.addNotIn("collections.properties.type", ["intermediate", "log"]);
+        }
+
+        if (searchProjects && state.treePickerSearch.projectSearchValues[pickerId]) {
+            filterB = filterB.addFullTextSearch(state.treePickerSearch.projectSearchValues[pickerId], 'groups');
+        }
+
+        const filters = filterB.getFilters();
+
+        const itemLimit = 200;
+
+        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: ""
+                });
+            }
+
+            dispatch<any>(receiveTreePickerData<GroupContentsResource>({
+                id,
+                pickerId,
+                data: items.filter((item) => {
                     if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
                         return false;
                     }
+
+                    if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
+                        return false;
+                    }
+
                     return true;
                 }),
-            extractNodeData: item => ({
-                id: item.uuid,
-                value: item,
-                status: item.kind === ResourceKind.PROJECT
-                    ? TreeNodeStatus.INITIAL
-                    : includeFiles
-                        ? TreeNodeStatus.INITIAL
-                        : TreeNodeStatus.LOADED
-            }),
-        }));
+                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
+                                : 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 }));
 
@@ -149,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.portableDataHash);
+                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());
@@ -174,7 +285,7 @@ export const initUserProject = (pickerId: string) =>
             dispatch(receiveTreePickerData({
                 id: '',
                 pickerId,
-                data: [{ uuid, name: 'Projects' }],
+                data: [{ uuid, name: HOME_PROJECT_ID }],
                 extractNodeData: value => ({
                     id: value.uuid,
                     status: TreeNodeStatus.INITIAL,
@@ -183,11 +294,11 @@ export const initUserProject = (pickerId: string) =>
             }));
         }
     };
-export const loadUserProject = (pickerId: string, includeCollections = false, includeFiles = false) =>
+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 }));
+            dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeDirectories, includeFiles, options }));
         }
     };
 
@@ -206,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) => {
@@ -236,16 +475,34 @@ export const initPublicFavoritesProject = (pickerId: string) =>
         }));
     };
 
+export const SEARCH_PROJECT_ID = 'Search all Projects';
+export const initSearchProject = (pickerId: string) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(receiveTreePickerData({
+            id: '',
+            pickerId,
+            data: [{ uuid: SEARCH_PROJECT_ID, name: SEARCH_PROJECT_ID }],
+            extractNodeData: value => ({
+                id: value.uuid,
+                status: TreeNodeStatus.INITIAL,
+                value,
+            }),
+        }));
+    };
+
+
 interface LoadFavoritesProjectParams {
     pickerId: string;
     includeCollections?: boolean;
+    includeDirectories?: boolean;
     includeFiles?: boolean;
+    options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
 }
 
 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(
@@ -261,7 +518,11 @@ 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;
+                    }
+
+                    if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
                         return false;
                     }
 
@@ -272,7 +533,7 @@ export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
                     value: item,
                     status: item.kind === ResourceKind.PROJECT
                         ? TreeNodeStatus.INITIAL
-                        : includeFiles
+                        : includeDirectories || includeFiles
                             ? TreeNodeStatus.INITIAL
                             : TreeNodeStatus.LOADED
                 }),
@@ -282,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`;
 
@@ -301,13 +562,19 @@ export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) =
         dispatch<any>(receiveTreePickerData<LinkResource>({
             id: 'Public Favorites',
             pickerId,
-            data: items,
+            data: items.filter(item => {
+                if (params.options && params.options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) {
+                    return false;
+                }
+
+                return true;
+            }),
             extractNodeData: item => ({
                 id: item.headUuid,
                 value: item,
                 status: item.headKind === ResourceKind.PROJECT
                     ? TreeNodeStatus.INITIAL
-                    : includeFiles
+                    : includeDirectories || includeFiles
                         ? TreeNodeStatus.INITIAL
                         : TreeNodeStatus.LOADED
             }),
@@ -378,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;
+}
diff --git a/src/store/tree-picker/tree-picker-middleware.ts b/src/store/tree-picker/tree-picker-middleware.ts
new file mode 100644 (file)
index 0000000..6f748a9
--- /dev/null
@@ -0,0 +1,122 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, MiddlewareAPI } from 'redux';
+import { RootState } from 'store/store';
+import { ServiceRepository } from 'services/services';
+import { Middleware } from "redux";
+import { getNode, getNodeDescendantsIds, TreeNodeStatus } from 'models/tree';
+import { getTreePicker } from './tree-picker';
+import {
+    treePickerSearchActions, loadProject, loadFavoritesProject, loadPublicFavoritesProject,
+    SHARED_PROJECT_ID, FAVORITES_PROJECT_ID, PUBLIC_FAVORITES_PROJECT_ID, SEARCH_PROJECT_ID
+} from "./tree-picker-actions";
+import { LinkResource } from "models/link";
+import { GroupContentsResource } from 'services/groups-service/groups-service';
+import { CollectionDirectory, CollectionFile } from 'models/collection-file';
+
+export interface ProjectsTreePickerRootItem {
+    id: string;
+    name: string;
+}
+
+export type ProjectsTreePickerItem = ProjectsTreePickerRootItem | GroupContentsResource | CollectionDirectory | CollectionFile | LinkResource;
+
+export const treePickerSearchMiddleware: Middleware = store => next => action => {
+    let isSearchAction = false;
+    let searchChanged = false;
+
+    treePickerSearchActions.match(action, {
+        SET_TREE_PICKER_PROJECT_SEARCH: ({ pickerId, projectSearchValue }) => {
+            isSearchAction = true;
+            searchChanged = store.getState().treePickerSearch.projectSearchValues[pickerId] !== projectSearchValue;
+        },
+
+        SET_TREE_PICKER_COLLECTION_FILTER: ({ pickerId, collectionFilterValue }) => {
+            isSearchAction = true;
+            searchChanged = store.getState().treePickerSearch.collectionFilterValues[pickerId] !== collectionFilterValue;
+        },
+
+        REFRESH_TREE_PICKER: refreshPickers(store),
+        default: () => { }
+    });
+
+    if (isSearchAction && !searchChanged) {
+        return;
+    }
+
+    // pass it on to the reducer
+    const r = next(action);
+
+    treePickerSearchActions.match(action, {
+        SET_TREE_PICKER_PROJECT_SEARCH: ({ 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];
+                    dispatch<any>(loadProject({
+                        ...loadParams,
+                        id: SEARCH_PROJECT_ID,
+                        pickerId: pickerId,
+                        searchProjects: true
+                    }));
+                }
+            }),
+
+        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 349240eaf490ed0a309f437dff44064906bbc865..84d5ed0ca729013f9d9215d1c7dcffd21f1730ed 100644 (file)
@@ -2,13 +2,15 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { createTree, TreeNode, setNode, Tree, TreeNodeStatus, setNodeStatus, expandNode, deactivateNode, selectNodes, deselectNodes } from 'models/tree';
+import {
+    createTree, TreeNode, setNode, Tree, TreeNodeStatus, setNodeStatus,
+    expandNode, deactivateNode, selectNodes, deselectNodes,
+    activateNode, getNode, toggleNodeCollapse, toggleNodeSelection, appendSubtree, expandNodeAncestors
+} from 'models/tree';
 import { TreePicker } from "./tree-picker";
-import { treePickerActions, TreePickerAction } from "./tree-picker-actions";
+import { treePickerActions, treePickerSearchActions, TreePickerAction, TreePickerSearchAction, LoadProjectParams } from "./tree-picker-actions";
 import { compose } from "redux";
-import { activateNode, getNode, toggleNodeCollapse, toggleNodeSelection } from 'models/tree';
 import { pipe } from 'lodash/fp';
-import { appendSubtree } from 'models/tree';
 
 export const treePickerReducer = (state: TreePicker = {}, action: TreePickerAction) =>
     treePickerActions.match(action, {
@@ -18,12 +20,18 @@ export const treePickerReducer = (state: TreePicker = {}, action: TreePickerActi
         LOAD_TREE_PICKER_NODE_SUCCESS: ({ id, nodes, pickerId }) =>
             updateOrCreatePicker(state, pickerId, compose(receiveNodes(nodes)(id), setNodeStatus(id)(TreeNodeStatus.LOADED))),
 
-        APPEND_TREE_PICKER_NODE_SUBTREE: ({ id, subtree, pickerId}) =>
+        APPEND_TREE_PICKER_NODE_SUBTREE: ({ id, subtree, pickerId }) =>
             updateOrCreatePicker(state, pickerId, compose(appendSubtree(id, subtree), setNodeStatus(id)(TreeNodeStatus.LOADED))),
 
         TOGGLE_TREE_PICKER_NODE_COLLAPSE: ({ id, pickerId }) =>
             updateOrCreatePicker(state, pickerId, toggleNodeCollapse(id)),
 
+        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(
@@ -36,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),
@@ -67,6 +75,33 @@ const receiveNodes = <V>(nodes: Array<TreeNode<V>>) => (parent: string) => (stat
         newState = setNode({ ...parentNode, children: [] })(state);
     }
     return nodes.reduce((tree, node) => {
+        const preexistingNode = getNode(node.id)(state);
+        if (preexistingNode) {
+            node = { ...preexistingNode, value: node.value };
+        }
         return setNode({ ...node, parent })(tree);
     }, newState);
 };
+
+interface TreePickerSearch {
+    projectSearchValues: { [pickerId: string]: string };
+    collectionFilterValues: { [pickerId: string]: string };
+    loadProjectParams: { [pickerId: string]: LoadProjectParams };
+}
+
+export const treePickerSearchReducer = (state: TreePickerSearch = { projectSearchValues: {}, collectionFilterValues: {}, loadProjectParams: {} }, action: TreePickerSearchAction) =>
+    treePickerSearchActions.match(action, {
+        SET_TREE_PICKER_PROJECT_SEARCH: ({ pickerId, projectSearchValue }) => ({
+            ...state, projectSearchValues: { ...state.projectSearchValues, [pickerId]: projectSearchValue }
+        }),
+
+        SET_TREE_PICKER_COLLECTION_FILTER: ({ pickerId, collectionFilterValue }) => ({
+            ...state, collectionFilterValues: { ...state.collectionFilterValues, [pickerId]: collectionFilterValue }
+        }),
+
+        SET_TREE_PICKER_LOAD_PARAMS: ({ pickerId, params }) => ({
+            ...state, loadProjectParams: { ...state.loadProjectParams, [pickerId]: params }
+        }),
+
+        default: () => state
+    });
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 c0589a6056e0393745800e0559d2af5193a0065e..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()));
         }
     }
 }
@@ -56,28 +60,23 @@ const getParams = (dataExplorer: DataExplorer) => ({
         .getFilters()
 });
 
-export const getOrder = (dataExplorer: DataExplorer) => {
-    const sortColumn = getSortColumn(dataExplorer);
+const getOrder = (dataExplorer: DataExplorer) => {
+    const sortColumn = getSortColumn<UserResource>(dataExplorer);
     const order = new OrderBuilder<UserResource>();
-    if (sortColumn) {
-        const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC
+    if (sortColumn && sortColumn.sort) {
+        const sortDirection = sortColumn.sort.direction === SortDirection.ASC
             ? OrderDirection.ASC
             : OrderDirection.DESC;
-        switch (sortColumn.name) {
-            case UserPanelColumnNames.NAME:
-                order.addOrder(sortDirection, "firstName")
-                    .addOrder(sortDirection, "lastName");
-                break;
-            case UserPanelColumnNames.UUID:
-                order.addOrder(sortDirection, "uuid");
-                break;
-            case UserPanelColumnNames.EMAIL:
-                order.addOrder(sortDirection, "email");
-                break;
-            case UserPanelColumnNames.USERNAME:
-                order.addOrder(sortDirection, "username");
-                break;
+
+        if (sortColumn.name === UserPanelColumnNames.NAME) {
+            order.addOrder(sortDirection, "firstName")
+                .addOrder(sortDirection, "lastName");
+        } 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 b553b324e5aea7298f26845485ab75f3fe1f0f32..4c789dbeede4fda3f97f61b6ed32286f120a5d2c 100644 (file)
@@ -148,6 +148,7 @@ export const toggleIsAdmin = (uuid: string) =>
 
 export const loadUsersPanel = () =>
     (dispatch: Dispatch) => {
+        dispatch(userBindedActions.RESET_EXPLORER_SEARCH_VALUE());
         dispatch(userBindedActions.REQUEST_ITEMS());
     };
 
index e4b17ea0b807d4bfbeb544e9f1a811aedbec290b..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,46 +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()
-        });
-        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"
-        });
-        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 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)
-                .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) =>
@@ -121,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) =>
@@ -139,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 {
@@ -154,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 0a3484310ee74a5d6e5182f3e47fb27290520ef3..188dba05689edf9be63c7da67ed6c35c2db1df55 100644 (file)
@@ -2,28 +2,24 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Dispatch } from 'redux';
+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 { 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';
+    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";
 import {
     setBreadcrumbs,
     setGroupDetailsBreadcrumbs,
@@ -35,177 +31,222 @@ 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';
+} 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 { 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 {
-    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 { 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';
+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";
+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);
     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 }));
-
-            if (services.linkAccountService.getAccountToLink()) {
-                dispatch(linkAccountPanelActions.HAS_SESSION_DATA());
-            }
+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());
+        }
 
-            dispatch<any>(initSidePanelTree());
-            if (router.location) {
-                const match = matchRootRoute(router.location.pathname);
-                if (match) {
-                    dispatch<any>(navigateToRootProject);
-                }
+        dispatch<any>(initSidePanelTree());
+        if (router.location) {
+            const match = matchRootRoute(router.location.pathname);
+            if (match) {
+                dispatch<any>(navigateToRootProject);
             }
-        } else {
-            dispatch(userIsNotAuthenticated);
         }
-    };
+    } else {
+        dispatch(userIsNotAuthenticated);
+    }
+};
 
 export const loadFavorites = () =>
-    handleFirstTimeLoad(
-        (dispatch: Dispatch) => {
-            dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.FAVORITES));
-            dispatch<any>(loadFavoritePanel());
-            dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.FAVORITES));
-        });
-
-export const loadCollectionContentAddress = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadCollectionsContentAddressPanel());
+    handleFirstTimeLoad((dispatch: Dispatch) => {
+        dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.FAVORITES));
+        dispatch<any>(loadFavoritePanel());
+        dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.FAVORITES));
     });
 
+export const loadCollectionContentAddress = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadCollectionsContentAddressPanel());
+});
+
 export const loadTrash = () =>
-    handleFirstTimeLoad(
-        (dispatch: Dispatch) => {
-            dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.TRASH));
-            dispatch<any>(loadTrashPanel());
-            dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.TRASH));
-        });
+    handleFirstTimeLoad((dispatch: Dispatch) => {
+        dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.TRASH));
+        dispatch<any>(loadTrashPanel());
+        dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.TRASH));
+    });
 
 export const loadAllProcesses = () =>
-    handleFirstTimeLoad(
-        (dispatch: Dispatch) => {
-            dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.ALL_PROCESSES));
-            dispatch<any>(loadAllProcessesPanel());
-            dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.ALL_PROCESSES));
-        }
-    );
+    handleFirstTimeLoad((dispatch: Dispatch) => {
+        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));
             } else if (userUuid !== uuid) {
                 await dispatch(finishLoadingProject(uuid));
-                const match = await loadGroupContentsResource({ uuid, userUuid, services });
+                const match = await loadGroupContentsResource({
+                    uuid,
+                    userUuid,
+                    services,
+                });
                 match({
                     OWNED: async () => {
                         await dispatch(activateSidePanelTreeItem(uuid));
@@ -219,222 +260,455 @@ export const loadProject = (uuid: string) =>
                         await dispatch(activateSidePanelTreeItem(SidePanelTreeCategory.TRASH));
                         dispatch<any>(setTrashBreadcrumbs(uuid));
                         dispatch(setIsProjectPanelTrashed(true));
-                    }
+                    },
                 });
             } else {
                 await dispatch(finishLoadingProject(userUuid));
                 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({
+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));
-        }
-    };
+                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) {
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Project has been moved', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
-                if (oldProject) {
-                    await dispatch<any>(loadSidePanelTreeProjects(oldProject.ownerUuid));
-                }
-                dispatch<any>(reloadProjectMatchingUuid([oldOwnerUuid, movedProject.ownerUuid, movedProject.uuid]));
+export const moveProject =
+    (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);
             }
-        } catch (e) {
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR }));
-        }
-    };
 
-export const updateProject = (data: projectUpdateActions.ProjectUpdateFormDialogData) =>
-    async (dispatch: Dispatch) => {
-        const updatedProject = await dispatch<any>(projectUpdateActions.updateProject(data));
-        if (updatedProject) {
-            dispatch(snackbarActions.OPEN_SNACKBAR({
+            //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 as any).frozenByUuid ? 'Could not move frozen project.' : 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]));
-        }
-    };
+                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({
+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]));
-        }
-    };
+                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 });
+                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({
+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));
-        }
-    };
+                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);
 
-export const copyCollection = (data: CopyFormDialogData) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    //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);
+    }
+
+    async function copySingleCollection(copyToProject: CollectionCopyResource) {
+        const newName = data.fromContextMenu || collectionsToCopy.length === 1 ? data.name : `Copy of: ${copyToProject.name}`;
         try {
-            const copyToProject = getResource(data.ownerUuid)(getState().resources);
-            const collection = await dispatch<any>(collectionCopyActions.copyCollection(data));
+            const collection = await dispatch<any>(
+                collectionCopyActions.copyCollection({
+                    ...copyToProject,
+                    name: newName,
+                    fromContextMenu: collectionsToCopy.length === 1 ? true : data.fromContextMenu,
+                })
+            );
             if (copyToProject && collection) {
-                dispatch<any>(reloadProjectMatchingUuid([copyToProject.uuid]));
-                dispatch(snackbarActions.OPEN_SNACKBAR({
-                    message: 'Collection has been copied.',
-                    hideDuration: 3000,
-                    kind: SnackbarKind.SUCCESS,
-                    link: collection.ownerUuid
-                }));
+                await dispatch<any>(reloadProjectMatchingUuid([copyToProject.uuid]));
+                dispatch(
+                    snackbarActions.OPEN_SNACKBAR({
+                        message: "Collection has been copied.",
+                        hideDuration: 3000,
+                        kind: SnackbarKind.SUCCESS,
+                        link: collection.ownerUuid,
+                    })
+                );
+                dispatch<any>(multiselectActions.deselectOne(copyToProject.uuid));
             }
         } catch (e) {
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            dispatch(
+                snackbarActions.OPEN_SNACKBAR({
+                    message: e.message,
+                    hideDuration: 2000,
+                    kind: SnackbarKind.ERROR,
+                })
+            );
         }
-    };
+    }
+    dispatch(projectPanelActions.REQUEST_ITEMS());
+};
 
-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 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 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) => {
+    handleFirstTimeLoad(async (dispatch: Dispatch, getState: () => RootState) => {
+        try {
+            dispatch(progressIndicatorActions.START_WORKING(uuid));
             dispatch<any>(loadProcessPanel(uuid));
             const process = await dispatch<any>(processesActions.loadProcess(uuid));
-            await dispatch<any>(activateSidePanelTreeItem(process.containerRequest.ownerUuid));
-            dispatch<any>(setProcessBreadcrumbs(uuid));
-            dispatch<any>(loadDetailsPanel(uuid));
-        });
-
-export const updateProcess = (data: processUpdateActions.ProcessUpdateFormDialogData) =>
-    async (dispatch: Dispatch) => {
-        try {
-            const process = await dispatch<any>(processUpdateActions.updateProcess(data));
             if (process) {
-                dispatch(snackbarActions.OPEN_SNACKBAR({
-                    message: "Process has been successfully updated.",
-                    hideDuration: 2000,
-                    kind: SnackbarKind.SUCCESS
-                }));
-                dispatch<any>(updateResources([process]));
-                dispatch<any>(reloadProjectMatchingUuid([process.ownerUuid]));
+                await dispatch<any>(finishLoadingProject(process.containerRequest.ownerUuid));
+                await dispatch<any>(activateSidePanelTreeItem(process.containerRequest.ownerUuid));
+                dispatch<any>(setProcessBreadcrumbs(uuid));
+                dispatch<any>(loadDetailsPanel(uuid));
             }
-        } catch (e) {
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(uuid));
         }
-    };
+    });
 
-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 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 copyProcess = (data: CopyFormDialogData) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        try {
-            const process = await dispatch<any>(processCopyActions.copyProcess(data));
+export const updateProcess = (data: processUpdateActions.ProcessUpdateFormDialogData) => async (dispatch: Dispatch) => {
+    try {
+        const process = await dispatch<any>(processUpdateActions.updateProcess(data));
+        if (process) {
+            dispatch(
+                snackbarActions.OPEN_SNACKBAR({
+                    message: "Process has been successfully updated.",
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS,
+                })
+            );
             dispatch<any>(updateResources([process]));
             dispatch<any>(reloadProjectMatchingUuid([process.ownerUuid]));
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Process has been copied.', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
-        } 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, 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 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 resourceIsNotLoaded = (uuid: string) =>
     snackbarActions.OPEN_SNACKBAR({
         message: `Resource identified by ${uuid} is not loaded.`,
-        kind: SnackbarKind.ERROR
+        kind: SnackbarKind.ERROR,
     });
 
 export const userIsNotAuthenticated = snackbarActions.OPEN_SNACKBAR({
-    message: 'User is not authenticated',
-    kind: SnackbarKind.ERROR
+    message: "User is not authenticated",
+    kind: SnackbarKind.ERROR,
 });
 
 export const couldNotLoadUser = snackbarActions.OPEN_SNACKBAR({
-    message: 'Could not load user',
-    kind: SnackbarKind.ERROR
+    message: "Could not load user",
+    kind: SnackbarKind.ERROR,
 });
 
-export const reloadProjectMatchingUuid = (matchingUuids: string[]) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+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));
@@ -447,124 +721,97 @@ export const loadSharedWithMe = handleFirstTimeLoad(async (dispatch: Dispatch) =
     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>(loadPublicFavoritePanel());
-            dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.PUBLIC_FAVORITES));
-        });
-
-export const loadSearchResults = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadSearchResultsPanel());
+    handleFirstTimeLoad((dispatch: Dispatch) => {
+        dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.PUBLIC_FAVORITES));
+        dispatch<any>(loadPublicFavoritePanel());
+        dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.PUBLIC_FAVORITES));
     });
 
-export const loadLinks = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadLinkPanel());
-    });
+export const loadSearchResults = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadSearchResultsPanel());
+});
 
-export const loadVirtualMachines = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadVirtualMachinesPanel());
-        dispatch(setBreadcrumbs([{ label: 'Virtual Machines' }]));
-    });
+export const loadLinks = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadLinkPanel());
+});
 
-export const loadVirtualMachinesAdmin = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadVirtualMachinesPanel());
-        dispatch(setBreadcrumbs([{ label: 'Virtual Machines Admin', icon: AdminMenuIcon }]));
-    });
+export const loadVirtualMachines = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadVirtualMachinesPanel());
+    dispatch(setBreadcrumbs([{ label: "Virtual Machines" }]));
+});
 
-export const loadRepositories = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadRepositoriesPanel());
-        dispatch(setBreadcrumbs([{ label: 'Repositories' }]));
-    });
+export const loadVirtualMachinesAdmin = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadVirtualMachinesPanel());
+    dispatch(setBreadcrumbs([{ label: "Virtual Machines Admin", icon: AdminMenuIcon }]));
+});
 
-export const loadSshKeys = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadSshKeysPanel());
-    });
+export const loadRepositories = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadRepositoriesPanel());
+    dispatch(setBreadcrumbs([{ label: "Repositories" }]));
+});
 
-export const loadSiteManager = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadSiteManagerPanel());
-    });
+export const loadSshKeys = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadSshKeysPanel());
+});
+
+export const loadSiteManager = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadSiteManagerPanel());
+});
 
 export const loadUserProfile = (userUuid?: string) =>
-    handleFirstTimeLoad(
-        (dispatch: Dispatch<any>) => {
-            if (userUuid) {
-                dispatch(setUserProfileBreadcrumbs(userUuid));
-                dispatch(userProfilePanelActions.loadUserProfilePanel(userUuid));
-            } else {
-                dispatch(setMyAccountBreadcrumbs());
-                dispatch(userProfilePanelActions.loadUserProfilePanel());
-            }
+    handleFirstTimeLoad((dispatch: Dispatch<any>) => {
+        if (userUuid) {
+            dispatch(setUserProfileBreadcrumbs(userUuid));
+            dispatch(userProfilePanelActions.loadUserProfilePanel(userUuid));
+        } else {
+            dispatch(setMyAccountBreadcrumbs());
+            dispatch(userProfilePanelActions.loadUserProfilePanel());
         }
-    );
-
-export const loadLinkAccount = handleFirstTimeLoad(
-    (dispatch: Dispatch<any>) => {
-        dispatch(loadLinkAccountPanel());
     });
 
-export const loadKeepServices = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadKeepServicesPanel());
-    });
+export const loadLinkAccount = handleFirstTimeLoad((dispatch: Dispatch<any>) => {
+    dispatch(loadLinkAccountPanel());
+});
 
-export const loadUsers = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadUsersPanel());
-        dispatch(setUsersBreadcrumbs());
-    });
+export const loadKeepServices = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadKeepServicesPanel());
+});
 
-export const loadApiClientAuthorizations = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadApiClientAuthorizationsPanel());
-    });
+export const loadUsers = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadUsersPanel());
+    dispatch(setUsersBreadcrumbs());
+});
 
-export const loadGroupsPanel = handleFirstTimeLoad(
-    (dispatch: Dispatch<any>) => {
-        dispatch(setGroupsBreadcrumbs());
-        dispatch(groupPanelActions.loadGroupsPanel());
-    });
+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 loadGroupDetailsPanel = (groupUuid: string) =>
-    handleFirstTimeLoad(
-        (dispatch: Dispatch<any>) => {
-            dispatch(setGroupDetailsBreadcrumbs(groupUuid));
-            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]));
-        }
-    };
+    handleFirstTimeLoad((dispatch: Dispatch<any>) => {
+        dispatch(setGroupDetailsBreadcrumbs(groupUuid));
+        dispatch(groupDetailsPanelActions.loadGroupDetailsPanel(groupUuid));
+    });
 
-const loadGroupContentsResource = async (params: {
-    uuid: string,
-    userUuid: string,
-    services: ServiceRepository
-}) => {
-    const filters = new FilterBuilder()
-        .addEqual('uuid', params.uuid)
-        .getFilters();
+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,
@@ -573,9 +820,10 @@ const loadGroupContentsResource = async (params: {
     const resource = items.shift();
     let handler: GroupContentsHandler;
     if (resource) {
-        handler = (resource.kind === ResourceKind.COLLECTION || resource.kind === ResourceKind.PROJECT) && resource.isTrashed
-            ? groupContentsHandlers.TRASHED(resource)
-            : groupContentsHandlers.OWNED(resource);
+        handler =
+            (resource.kind === ResourceKind.COLLECTION || resource.kind === ResourceKind.PROJECT) && resource.isTrashed
+                ? groupContentsHandlers.TRASHED(resource)
+                : groupContentsHandlers.OWNED(resource);
     } else {
         const kind = extractUuidKind(params.uuid);
         let resource: GroupContentsResource;
@@ -583,14 +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 = {
@@ -602,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 d3a1d055f47fa98f442384109b9caa9373bba7e1..587f02246cb62979e48c2939ffc2f0996d04aada 100644 (file)
@@ -4,19 +4,15 @@
 
 import { ServiceRepository } from 'services/services';
 import { MiddlewareAPI, Dispatch } from 'redux';
-import { DataExplorerMiddlewareService, dataExplorerToListParams, listResultsToDataExplorerItemsMeta } from 'store/data-explorer/data-explorer-middleware-service';
+import { DataExplorerMiddlewareService, dataExplorerToListParams, getOrder, listResultsToDataExplorerItemsMeta } from 'store/data-explorer/data-explorer-middleware-service';
 import { RootState } from 'store/store';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 import { DataExplorer, getDataExplorer } from 'store/data-explorer/data-explorer-reducer';
 import { updateResources } from 'store/resources/resources-actions';
 import { FilterBuilder } from 'services/api/filter-builder';
-import { SortDirection } from 'components/data-table/data-column';
-import { WorkflowPanelColumnNames } from 'views/workflow-panel/workflow-panel-view';
-import { OrderDirection, OrderBuilder } from 'services/api/order-builder';
 import { WorkflowResource } from 'models/workflow';
 import { ListResults } from 'services/common-service/common-service';
 import { workflowPanelActions } from 'store/workflow-panel/workflow-panel-actions';
-import { getSortColumn } from "store/data-explorer/data-explorer-reducer";
 
 export class WorkflowMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
@@ -38,7 +34,7 @@ export class WorkflowMiddlewareService extends DataExplorerMiddlewareService {
 
 export const getParams = (dataExplorer: DataExplorer) => ({
     ...dataExplorerToListParams(dataExplorer),
-    order: getOrder(dataExplorer),
+    order: getOrder<WorkflowResource>(dataExplorer),
     filters: getFilters(dataExplorer)
 });
 
@@ -49,22 +45,6 @@ export const getFilters = (dataExplorer: DataExplorer) => {
     return filters;
 };
 
-export const getOrder = (dataExplorer: DataExplorer) => {
-    const sortColumn = getSortColumn(dataExplorer);
-    const order = new OrderBuilder<WorkflowResource>();
-    if (sortColumn) {
-        const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC
-            ? OrderDirection.ASC
-            : OrderDirection.DESC;
-        const columnName = sortColumn && sortColumn.name === WorkflowPanelColumnNames.NAME ? "name" : "modifiedAt";
-        return order
-            .addOrder(sortDirection, columnName)
-            .getOrder();
-    } else {
-        return order.getOrder();
-    }
-};
-
 export const setItems = (listResults: ListResults<WorkflowResource>) =>
     workflowPanelActions.SET_ITEMS({
         ...listResultsToDataExplorerItemsMeta(listResults),
@@ -75,4 +55,4 @@ const couldNotFetchWorkflows = () =>
     snackbarActions.OPEN_SNACKBAR({
         message: 'Could not fetch workflows.',
         kind: SnackbarKind.ERROR
-    });
\ No newline at end of file
+    });
index 912f76308ceac33cb6865efdc3aeccb2322695e1..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,7 @@ 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";
 const UUID_PREFIX_PROPERTY_NAME = 'uuidPrefix';
@@ -62,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;
                 }
             }
@@ -74,6 +73,18 @@ export const openRunProcess = (workflowUuid: string, ownerUuid?: string, name?:
 
             dispatch(initialize(RUN_PROCESS_BASIC_FORM, { name, owner }));
 
+            const definition = parseWorkflowDefinition(workflow);
+            if (definition) {
+                const inputs = getWorkflowInputs(definition);
+                if (inputs) {
+                    const values = inputs.reduce((values, input) => ({
+                        ...values,
+                        [input.id]: input.default,
+                    }), {});
+                    dispatch(initialize(RUN_PROCESS_INPUTS_FORM, values));
+                }
+            }
+
             if (inputObj) {
                 dispatch(initialize(RUN_PROCESS_INPUTS_FORM, inputObj));
             }
@@ -90,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 });
@@ -100,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 6e72ef689829ef3aad0b6cc1c265d0467491ee21..87a4c1f57e2523fee923b41619b288c929fc99f1 100644 (file)
@@ -8,8 +8,8 @@ import { isRsaKey } from './is-rsa-key';
 import { isRemoteHost } from "./is-remote-host";
 import { validFilePath, validName, validNameAllowSlash } from "./valid-name";
 
-export const TAG_KEY_VALIDATION = [require, maxLength(255)];
-export const TAG_VALUE_VALIDATION = [require, maxLength(255)];
+export const TAG_KEY_VALIDATION = [maxLength(255)];
+export const TAG_VALUE_VALIDATION = [maxLength(255)];
 
 export const PROJECT_NAME_VALIDATION = [require, validName, maxLength(255)];
 export const PROJECT_NAME_VALIDATION_ALLOW_SLASH = [require, validNameAllowSlash, maxLength(255)];
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 a2d71d08dc406bf2d1baf96d13ce2929f5291c28..b4bef2b5efdb51b40d73542f51aad87b86969a16 100644 (file)
@@ -30,7 +30,7 @@ const mapStateToProps = (state: RootState, ownProps: any): AutoLogoutDataProps =
 });
 
 const mapDispatchToProps = (dispatch: Dispatch): AutoLogoutActionProps => ({
-    doLogout: () => dispatch<any>(logout(true)),
+    doLogout: () => dispatch<any>(logout(true, true)),
     doWarn: (message: string, duration: number) =>
         dispatch(snackbarActions.OPEN_SNACKBAR({
             message, hideDuration: duration, kind: SnackbarKind.WARNING })),
diff --git a/src/views-components/baner/banner.test.tsx b/src/views-components/baner/banner.test.tsx
new file mode 100644 (file)
index 0000000..1e82008
--- /dev/null
@@ -0,0 +1,63 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { configure, shallow, mount } from "enzyme";
+import { BannerComponent } from './banner';
+import { Button } from "@material-ui/core";
+import Adapter from "enzyme-adapter-react-16";
+import servicesProvider from '../../common/service-provider';
+
+configure({ adapter: new Adapter() });
+
+jest.mock('../../common/service-provider', () => ({
+    getServices: jest.fn(),
+}));
+
+describe('<BannerComponent />', () => {
+
+    let props;
+
+    beforeEach(() => {
+        props = {
+            isOpen: false,
+            bannerUUID: undefined,
+            keepWebInlineServiceUrl: '',
+            openBanner: jest.fn(),
+            closeBanner: jest.fn(),
+            classes: {} as any,
+        }
+    });
+
+    it('renders without crashing', () => {
+        // when
+        const banner = shallow(<BannerComponent {...props} />);
+        
+        // then
+        expect(banner.find(Button)).toHaveLength(1);
+    });
+
+    it('calls collectionService', () => {
+        // given
+        props.isOpen = true;
+        props.bannerUUID = '123';
+        const mocks = {
+            collectionService: {
+                files: jest.fn(() => ({ then: (callback) => callback([{ name: 'banner.html' }]) })),
+                getFileContents: jest.fn(() => ({ then: (callback) => callback('<h1>Test</h1>') }))
+            }
+        };
+        (servicesProvider.getServices as any).mockImplementation(() => mocks);
+
+        // when
+        const banner = mount(<BannerComponent {...props} />);
+
+        // then
+        expect(servicesProvider.getServices).toHaveBeenCalled();
+        expect(mocks.collectionService.files).toHaveBeenCalled();
+        expect(mocks.collectionService.getFileContents).toHaveBeenCalled();
+        expect(banner.html()).toContain('<h1>Test</h1>');
+    });
+});
+
diff --git a/src/views-components/baner/banner.tsx b/src/views-components/baner/banner.tsx
new file mode 100644 (file)
index 0000000..ac5b894
--- /dev/null
@@ -0,0 +1,114 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+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 { sanitizeHTML } from "common/html-sanitize";
+
+type CssRules = "dialogContent" | "dialogContentIframe";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    dialogContent: {
+        minWidth: "550px",
+        minHeight: "500px",
+        display: "block",
+    },
+    dialogContentIframe: {
+        minWidth: "550px",
+        minHeight: "500px",
+    },
+});
+
+interface BannerProps {
+    isOpen: boolean;
+    bannerUUID?: string;
+    keepWebInlineServiceUrl: string;
+}
+
+type BannerComponentProps = BannerProps &
+    WithStyles<CssRules> & {
+        openBanner: Function;
+        closeBanner: Function;
+    };
+
+const mapStateToProps = (state: RootState): BannerProps => ({
+    isOpen: state.banner.isOpen,
+    bannerUUID: state.auth.config.clusterConfig.Workbench.BannerUUID,
+    keepWebInlineServiceUrl: state.auth.config.keepWebInlineServiceUrl,
+});
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    openBanner: () => dispatch<any>(bannerActions.openBanner()),
+    closeBanner: () => dispatch<any>(bannerActions.closeBanner()),
+});
+
+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 onConfirm = useCallback(() => {
+        closeBanner();
+    }, [closeBanner]);
+
+    useEffect(() => {
+        if (!!bannerUUID && bannerUUID !== "") {
+            servicesProvider
+                .getServices()
+                .collectionService.files(bannerUUID)
+                .then(results => {
+                    const bannerFileData = results.find(({ name }) => name === "banner.html");
+                    const result = localStorage.getItem(BANNER_LOCAL_STORAGE_KEY);
+
+                    if (result && result === JSON.stringify(bannerFileData) && !isOpen) {
+                        return;
+                    }
+
+                    if (bannerFileData) {
+                        servicesProvider
+                            .getServices()
+                            .collectionService.getFileContents(bannerFileData)
+                            .then(data => {
+                                setBannerContents(data);
+                                openBanner();
+                                localStorage.setItem(BANNER_LOCAL_STORAGE_KEY, JSON.stringify(bannerFileData));
+                            });
+                    }
+                });
+        }
+    }, [bannerUUID, keepWebInlineServiceUrl, openBanner, isOpen]);
+
+    return (
+        <Dialog
+            open={isOpen}
+            maxWidth="md"
+        >
+            <div data-cy="confirmation-dialog">
+                <DialogContent className={props.classes.dialogContent}>
+                    <div dangerouslySetInnerHTML={{ __html: sanitizeHTML(bannerContents) }}></div>
+                </DialogContent>
+                <DialogActions style={{ margin: "0px 24px 24px" }}>
+                    <Button
+                        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 cb48b38fb62bfd95c5bc83f5b1a8dd0f5f835f4d..0334097d2eee07ce079b2f7c9863e1bb59a2756d 100644 (file)
@@ -3,28 +3,29 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { connect } from "react-redux";
-import { Breadcrumbs as BreadcrumbsComponent, BreadcrumbsProps } from 'components/breadcrumbs/breadcrumbs';
+import { Breadcrumb, Breadcrumbs as BreadcrumbsComponent, BreadcrumbsProps } from 'components/breadcrumbs/breadcrumbs';
 import { RootState } from 'store/store';
 import { Dispatch } from 'redux';
 import { navigateTo } from 'store/navigation/navigation-action';
 import { getProperty } from '../../store/properties/properties';
-import { ResourceBreadcrumb, BREADCRUMBS } from '../../store/breadcrumbs/breadcrumbs-actions';
+import { BREADCRUMBS } from '../../store/breadcrumbs/breadcrumbs-actions';
 import { openSidePanelContextMenu } from 'store/context-menu/context-menu-actions';
 
-type BreadcrumbsDataProps = Pick<BreadcrumbsProps, 'items'>;
+type BreadcrumbsDataProps = Pick<BreadcrumbsProps, 'items' | 'resources'>;
 type BreadcrumbsActionProps = Pick<BreadcrumbsProps, 'onClick' | 'onContextMenu'>;
 
-const mapStateToProps = () => ({ properties }: RootState): BreadcrumbsDataProps => ({
-    items: getProperty<ResourceBreadcrumb[]>(BREADCRUMBS)(properties) || []
+const mapStateToProps = () => ({ properties, resources }: RootState): BreadcrumbsDataProps => ({
+    items: (getProperty<Breadcrumb[]>(BREADCRUMBS)(properties) || []),
+    resources,
 });
 
 const mapDispatchToProps = (dispatch: Dispatch): BreadcrumbsActionProps => ({
-    onClick: ({ uuid }: ResourceBreadcrumb) => {
+    onClick: ({ uuid }: Breadcrumb) => {
         dispatch<any>(navigateTo(uuid));
     },
-    onContextMenu: (event, breadcrumb: ResourceBreadcrumb) => {
+    onContextMenu: (event, breadcrumb: Breadcrumb) => {
         dispatch<any>(openSidePanelContextMenu(event, breadcrumb.uuid));
     }
 });
 
-export const Breadcrumbs = connect(mapStateToProps(), mapDispatchToProps)(BreadcrumbsComponent);
\ No newline at end of file
+export const Breadcrumbs = connect(mapStateToProps(), mapDispatchToProps)(BreadcrumbsComponent);
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 56cfee85d1a4388fe261e1b1ffe2ba5b413ff902..2aa7faa1242369be4ea985bad80805b94529b72f 100644 (file)
@@ -6,117 +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, CopyIcon, DetailsIcon,
-    RemoveIcon, ReRunProcessIcon, OutputIcon,
-    AdvancedIcon
+    RenameIcon,
+    ShareIcon,
+    MoveToIcon,
+    DetailsIcon,
+    RemoveIcon,
+    ReRunProcessIcon,
+    OutputIcon,
+    AdvancedIcon,
+    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, reRunProcess } from "store/processes/processes-actions";
-import { toggleDetailsPanel } from 'store/details-panel/details-panel-action';
-import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { openRemoveProcessDialog } from "store/processes/processes-actions";
+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: CopyIcon,
-        name: "Copy to project",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openCopyProcessDialog(resource));
-        }
-    },
-    {
-        icon: ReRunProcessIcon,
-        name: "Re-run process",
-        execute: (dispatch, resource) => {
-            if(resource.workflowUuid) {
-                dispatch<any>(reRunProcess(resource.uuid, resource.workflowUuid));
-            } else {
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: `You can't re-run this process`, hideDuration: 2000, kind: SnackbarKind.ERROR }));
-            }
-        }
-    },
-    {
-        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 e352d0c4ced3e17f99c200f007ab09bad2fef740..2706315179b718124d61bb0eaf3b1bb708c13607 100644 (file)
 // 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";
+import { freezeProject, unfreezeProject } from "store/projects/project-lock-actions";
 
-export const readOnlyProjectActionSet: ContextMenuActionSet = [[
-    {
-        component: ToggleFavoriteAction,
-        name: 'ToggleFavoriteAction',
-        execute: (dispatch, resource) => {
-            dispatch<any>(toggleFavorite(resource)).then(() => {
-                dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
-            });
-        }
+export const toggleFavoriteAction = {
+    component: ToggleFavoriteAction,
+    name: "ToggleFavoriteAction",
+    execute: (dispatch, resources) => {
+        dispatch(toggleFavorite(resources[0])).then(() => {
+            dispatch(favoritePanelActions.REQUEST_ITEMS());
+        });
     },
-    {
-        icon: OpenIcon,
-        name: "Open in new tab",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openInNewTabAction(resource));
-        }
+};
+
+export const openInNewTabMenuAction = {
+    icon: OpenIcon,
+    name: "Open in new tab",
+    execute: (dispatch, resources) => {
+        dispatch(openInNewTabAction(resources[0]));
     },
-    {
-        icon: Link,
-        name: "Copy to clipboard",
-        execute: (dispatch, resource) => {
-            dispatch<any>(copyToClipboardAction(resource));
-        }
+};
+
+export const copyToClipboardMenuAction = {
+    icon: Link,
+    name: "Copy to clipboard",
+    execute: (dispatch, resources) => {
+        dispatch(copyToClipboardAction(resources));
     },
-    {
-        icon: DetailsIcon,
-        name: "View details",
-        execute: dispatch => {
-            dispatch<any>(toggleDetailsPanel());
-        }
+};
+
+export const viewDetailsAction = {
+    icon: DetailsIcon,
+    name: "View details",
+    execute: dispatch => {
+        dispatch(toggleDetailsPanel());
     },
-    {
-        icon: AdvancedIcon,
-        name: "API Details",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openAdvancedTabDialog(resource.uuid));
-        }
+};
+
+export const advancedAction = {
+    icon: AdvancedIcon,
+    name: "API Details",
+    execute: (dispatch, resources) => {
+        dispatch(openAdvancedTabDialog(resources[0].uuid));
+    },
+};
+
+export const openWith3rdPartyClientAction = {
+    icon: FolderSharedIcon,
+    name: "Open with 3rd party client",
+    execute: (dispatch, resources) => {
+        dispatch(openWebDavS3InfoDialog(resources[0].uuid));
     },
-    {
-        icon: FolderSharedIcon,
-        name: "Open with 3rd party client",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openWebDavS3InfoDialog(resource.uuid));
+};
+
+export const editProjectAction = {
+    icon: RenameIcon,
+    name: "Edit project",
+    execute: (dispatch, resources) => {
+        dispatch(openProjectUpdateDialog(resources[0]));
+    },
+};
+
+export const shareAction = {
+    icon: ShareIcon,
+    name: "Share",
+    execute: (dispatch, resources) => {
+        dispatch(openSharingDialog(resources[0].uuid));
+    },
+};
+
+export const moveToAction = {
+    icon: MoveToIcon,
+    name: "Move to",
+    execute: (dispatch, resource) => {
+        dispatch(openMoveProjectDialog(resource[0]));
+    },
+};
+
+export const toggleTrashAction = {
+    component: ToggleTrashAction,
+    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, resources) => {
+        if (resources[0].isFrozen) {
+            dispatch(unfreezeProject(resources[0].uuid));
+        } else {
+            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 = [
     [
-        ...readOnlyProjectActionSet.reduce((prev, next) => prev.concat(next), []),
-        {
-            icon: RenameIcon,
-            name: "Edit project",
-            execute: (dispatch, resource) => {
-                dispatch<any>(openProjectUpdateDialog(resource));
-            }
-        },
-        {
-            icon: ShareIcon,
-            name: "Share",
-            execute: (dispatch, { uuid }) => {
-                dispatch<any>(openSharingDialog(uuid));
-            }
-        },
-        {
-            icon: MoveToIcon,
-            name: "Move to",
-            execute: (dispatch, resource) => {
-                dispatch<any>(openMoveProjectDialog(resource));
-            }
-        },
-        {
-            component: ToggleTrashAction,
-            name: 'ToggleTrashAction',
-            execute: (dispatch, resource) => {
-                dispatch<any>(toggleProjectTrashed(resource.uuid, resource.ownerUuid, resource.isTrashed!!));
-            }
-        },
-    ]
+        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 = [
     [
-        ...filterGroupActionSet.reduce((prev, next) => prev.concat(next), []),
-        {
-            icon: NewProjectIcon,
-            name: "New project",
-            execute: (dispatch, resource) => {
-                dispatch<any>(openProjectCreateDialog(resource.uuid));
-            }
-        },
-    ]
+        toggleFavoriteAction,
+        openInNewTabMenuAction,
+        copyToClipboardMenuAction,
+        viewDetailsAction,
+        advancedAction,
+        openWith3rdPartyClientAction,
+        editProjectAction,
+        shareAction,
+        moveToAction,
+        toggleTrashAction,
+        newProjectAction,
+        freezeProjectAction,
+    ],
 ];
index ebf827aa6609fc7fcb1a8ce547a374bc3a3750bd..490bf3e30a9e649f85165a988751aff4357be40f 100644 (file)
@@ -7,30 +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 { projectActionSet, filterGroupActionSet } from "views-components/context-menu/action-sets/project-action-set";
+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 projectAdminActionSet: ContextMenuActionSet = [[
-    ...projectActionSet.reduce((prev, next) => prev.concat(next), []),
-    {
-        component: TogglePublicFavoriteAction,
-        name: 'TogglePublicFavoriteAction',
-        execute: (dispatch, resource) => {
-            dispatch<any>(togglePublicFavorite(resource)).then(() => {
-                dispatch<any>(publicFavoritePanelActions.REQUEST_ITEMS());
-            });
-        }
-    }
-]];
+export const togglePublicFavoriteAction = {
+    component: TogglePublicFavoriteAction,
+    name: "TogglePublicFavoriteAction",
+    execute: (dispatch, resources) => {
+        dispatch(togglePublicFavorite(resources[0])).then(() => {
+            dispatch(publicFavoritePanelActions.REQUEST_ITEMS());
+        });
+    },
+};
 
-export const filterGroupAdminActionSet: ContextMenuActionSet = [[
-    ...filterGroupActionSet.reduce((prev, next) => prev.concat(next), []),
-    {
-        component: TogglePublicFavoriteAction,
-        name: 'TogglePublicFavoriteAction',
-        execute: (dispatch, resource) => {
-            dispatch<any>(togglePublicFavorite(resource)).then(() => {
-                dispatch<any>(publicFavoritePanelActions.REQUEST_ITEMS());
-            });
-        }
-    }
-]];
+export const projectAdminActionSet: ContextMenuActionSet = [
+    [
+        toggleFavoriteAction,
+        openInNewTabMenuAction,
+        copyToClipboardMenuAction,
+        viewDetailsAction,
+        advancedAction,
+        openWith3rdPartyClientAction,
+        editProjectAction,
+        shareAction,
+        moveToAction,
+        toggleTrashAction,
+        newProjectAction,
+        freezeProjectAction,
+        togglePublicFavoriteAction,
+    ],
+];
+
+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));
+            },
+        },
+    ],
+];
diff --git a/src/views-components/context-menu/actions/lock-action.tsx b/src/views-components/context-menu/actions/lock-action.tsx
new file mode 100644 (file)
index 0000000..99eb756
--- /dev/null
@@ -0,0 +1,45 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { ListItemIcon, ListItemText, ListItem } from "@material-ui/core";
+import { FreezeIcon, UnfreezeIcon } from "components/icon/icon";
+import { connect } from "react-redux";
+import { RootState } from "store/store";
+import { ProjectResource } from "models/project";
+import { withRouter, RouteComponentProps } from "react-router";
+import { resourceIsFrozen } from "common/frozen-resources";
+
+const mapStateToProps = (state: RootState, props: { onClick: () => {} }) => ({
+    isAdmin: !!state.auth.user?.isAdmin,
+    isLocked: !!(state.resources[state.contextMenu.resource!.uuid] as ProjectResource).frozenByUuid,
+    canManage: (state.resources[state.contextMenu.resource!.uuid] as ProjectResource).canManage,
+    canUnfreeze: !state.auth.remoteHostsConfig[state.auth.homeCluster]?.clusterConfig?.API?.UnfreezeProjectRequiresAdmin,
+    resource: state.contextMenu.resource,
+    resources: state.resources,
+    onClick: props.onClick
+});
+
+export const ToggleLockAction = withRouter(connect(mapStateToProps)((props: {
+    resource: any,
+    resources: any,
+    onClick: () => void,
+    state: RootState, isAdmin: boolean, isLocked: boolean, canManage: boolean, canUnfreeze: boolean,
+} & RouteComponentProps) =>
+    (props.canManage && !props.isLocked) || (props.isLocked && props.canManage && (props.canUnfreeze || props.isAdmin))  ? 
+        resourceIsFrozen(props.resource, props.resources) ? null :
+            <ListItem
+                button
+                onClick={props.onClick} >
+                <ListItemIcon>
+                    {props.isLocked
+                        ? <UnfreezeIcon />
+                        : <FreezeIcon />}
+                </ListItemIcon>
+                <ListItemText style={{ textDecoration: 'none' }}>
+                    {props.isLocked
+                        ? <>Unfreeze project</>
+                        : <>Freeze project</>}
+                </ListItemText>
+            </ListItem > : null));
index 1c9eb99b42a48b5a453f311054e862bb68e3b26e..a953500b3ae7a49f9216bce9544b24b3771a9982 100644 (file)
@@ -7,7 +7,7 @@ import { ContextMenuItem } from "components/context-menu/context-menu";
 import { ContextMenuResource } from "store/context-menu/context-menu-actions";
 
 export interface ContextMenuAction extends ContextMenuItem {
-    execute(dispatch: Dispatch, resource: ContextMenuResource): void;
+    execute(dispatch: Dispatch, resources: ContextMenuResource[], state?: any): void;
 }
 
 export type ContextMenuActionSet = Array<Array<ContextMenuAction>>;
index a8e7fd028aa81cf477eeaa7b99d028da514f42bd..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,66 +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',
+    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",
@@ -111,5 +118,6 @@ export enum ContextMenuKind {
     PERMISSION_EDIT = "PermissionEdit",
     LINK = "Link",
     WORKFLOW = "Workflow",
-    SEARCH_RESULTS = "SearchResults"
+    READONLY_WORKFLOW = "ReadOnlyWorkflow",
+    SEARCH_RESULTS = "SearchResults",
 }
index 06d97038e759c96712502ab52f6e9c80ba2af3c1..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,26 +22,29 @@ 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,
         currentRefresh: currentRefresh,
         currentRoute: currentRoute,
         paperKey: currentRoute,
-        currentItemUuid
+        currentItemUuid,
+        isMSToolbarVisible,
+        checkedList: multiselect.checkedList,
     };
 };
 
 const mapDispatchToProps = () => {
     return (dispatch: Dispatch, { id, onRowClick, onRowDoubleClick, onContextMenu }: Props) => ({
-        onSetColumns: (columns: DataColumns<any>) => {
+        onSetColumns: (columns: DataColumns<any, any>) => {
             dispatch(dataExplorerActions.SET_COLUMNS({ id, columns }));
         },
 
@@ -48,15 +52,15 @@ const mapDispatchToProps = () => {
             dispatch(dataExplorerActions.SET_EXPLORER_SEARCH_VALUE({ id, searchValue }));
         },
 
-        onColumnToggle: (column: DataColumn<any>) => {
+        onColumnToggle: (column: DataColumn<any, any>) => {
             dispatch(dataExplorerActions.TOGGLE_COLUMN({ id, columnName: column.name }));
         },
 
-        onSortToggle: (column: DataColumn<any>) => {
+        onSortToggle: (column: DataColumn<any, any>) => {
             dispatch(dataExplorerActions.TOGGLE_SORT({ id, columnName: column.name }));
         },
 
-        onFiltersChange: (filters: DataTableFilters, column: DataColumn<any>) => {
+        onFiltersChange: (filters: DataTableFilters, column: DataColumn<any, any>) => {
             dispatch(dataExplorerActions.SET_FILTERS({ id, columnName: column.name, filters }));
         },
 
@@ -72,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,
@@ -81,4 +93,3 @@ const mapDispatchToProps = () => {
 };
 
 export const DataExplorer = connect(mapStateToProps, mapDispatchToProps)(DataExplorerComponent);
-
index 5bc123df60016f041aef06a78a597557ea100757..ac8729aa3d32b7ff4dfe0fde5ff05171344dccd2 100644 (file)
@@ -29,12 +29,10 @@ describe('renderers', () => {
                     colors: {
                         // Color values are arbitrary, but they should be
                         // representative of the colors used in the UI.
-                        blue500: 'rgb(0, 0, 255)',
-                        green700: 'rgb(0, 255, 0)',
-                        yellow700: 'rgb(255, 255, 0)',
+                        green800: 'rgb(0, 255, 0)',
                         red900: 'rgb(255, 0, 0)',
                         orange: 'rgb(240, 173, 78)',
-                        grey500: 'rgb(128, 128, 128)',
+                        grey600: 'rgb(128, 128, 128)',
                     }
                 },
                 spacing: {
@@ -49,42 +47,44 @@ describe('renderers', () => {
         };
 
         [
-            // CR Status ; Priority ; C Status ; Exit Code ; C RuntimeStatus ; Expected label ; Expected Color
-            [CR.COMMITTED, 1, C.RUNNING, null, {}, PS.RUNNING, props.theme.customs.colors.blue500],
-            [CR.COMMITTED, 1, C.RUNNING, null, {error: 'whoops'}, PS.FAILING, props.theme.customs.colors.orange],
-            [CR.COMMITTED, 1, C.RUNNING, null, {warning: 'watch out!'}, PS.WARNING, props.theme.customs.colors.yellow700],
-            [CR.FINAL, 1, C.CANCELLED, null, {}, PS.CANCELLED, props.theme.customs.colors.red900],
-            [CR.FINAL, 1, C.COMPLETE, 137, {}, PS.FAILED, props.theme.customs.colors.red900],
-            [CR.FINAL, 1, C.COMPLETE, 0, {}, PS.COMPLETED, props.theme.customs.colors.green700],
-            [CR.COMMITTED, 0, C.LOCKED, null, {}, PS.ONHOLD, props.theme.customs.colors.grey500],
-            [CR.COMMITTED, 0, C.QUEUED, null, {}, PS.ONHOLD, props.theme.customs.colors.grey500],
-            [CR.COMMITTED, 1, C.LOCKED, null, {}, PS.QUEUED, props.theme.customs.colors.grey500],
-            [CR.COMMITTED, 1, C.QUEUED, null, {}, PS.QUEUED, props.theme.customs.colors.grey500],
-        ].forEach(([crState, crPrio, cState, exitCode, rs, eLabel, eColor]) => {
+            // CR Status ; Priority ; C Status ; Exit Code ; C RuntimeStatus ; Expected label ; Expected bg color ; Expected fg color
+            [CR.COMMITTED, 1, C.RUNNING, null, {}, PS.RUNNING, props.theme.palette.common.white, props.theme.customs.colors.green800],
+            [CR.COMMITTED, 1, C.RUNNING, null, { error: 'whoops' }, PS.FAILING, props.theme.palette.common.white, props.theme.customs.colors.red900],
+            [CR.COMMITTED, 1, C.RUNNING, null, { warning: 'watch out!' }, PS.WARNING, props.theme.palette.common.white, props.theme.customs.colors.green800],
+            [CR.FINAL, 1, C.CANCELLED, null, {}, PS.CANCELLED, props.theme.customs.colors.red900, props.theme.palette.common.white],
+            [CR.FINAL, 1, C.COMPLETE, 137, {}, PS.FAILED, props.theme.customs.colors.red900, props.theme.palette.common.white],
+            [CR.FINAL, 1, C.COMPLETE, 0, {}, PS.COMPLETED, props.theme.customs.colors.green800, props.theme.palette.common.white],
+            [CR.COMMITTED, 0, C.LOCKED, null, {}, PS.ONHOLD, props.theme.customs.colors.grey600, props.theme.palette.common.white],
+            [CR.COMMITTED, 0, C.QUEUED, null, {}, PS.ONHOLD, props.theme.customs.colors.grey600, props.theme.palette.common.white],
+            [CR.COMMITTED, 1, C.LOCKED, null, {}, PS.QUEUED, props.theme.palette.common.white, props.theme.customs.colors.grey600],
+            [CR.COMMITTED, 1, C.QUEUED, null, {}, PS.QUEUED, props.theme.palette.common.white, props.theme.customs.colors.grey600],
+        ].forEach(([crState, crPrio, cState, exitCode, rs, eLabel, eColor, tColor]) => {
             it(`should render the state label '${eLabel}' and color '${eColor}' for CR state=${crState}, priority=${crPrio}, C state=${cState}, exitCode=${exitCode} and RuntimeStatus=${JSON.stringify(rs)}`, () => {
                 const containerUuid = 'zzzzz-dz642-zzzzzzzzzzzzzzz';
-                const store = mockStore({ resources: {
-                    [props.uuid]: {
-                        kind: ResourceKind.CONTAINER_REQUEST,
-                        state: crState,
-                        containerUuid: containerUuid,
-                        priority: crPrio,
-                    },
-                    [containerUuid]: {
-                        kind: ResourceKind.CONTAINER,
-                        state: cState,
-                        runtimeStatus: rs,
-                        exitCode: exitCode,
-                    },
-                }});
+                const store = mockStore({
+                    resources: {
+                        [props.uuid]: {
+                            kind: ResourceKind.CONTAINER_REQUEST,
+                            state: crState,
+                            containerUuid: containerUuid,
+                            priority: crPrio,
+                        },
+                        [containerUuid]: {
+                            kind: ResourceKind.CONTAINER,
+                            state: cState,
+                            runtimeStatus: rs,
+                            exitCode: exitCode,
+                        },
+                    }
+                });
 
                 const wrapper = mount(<Provider store={store}>
-                        <ProcessStatus {...props} />
-                    </Provider>);
+                    <ProcessStatus {...props} />
+                </Provider>);
 
                 expect(wrapper.text()).toEqual(eLabel);
                 expect(getComputedStyle(wrapper.getDOMNode())
-                    .getPropertyValue('color')).toEqual(props.theme.palette.common.white);
+                    .getPropertyValue('color')).toEqual(tColor);
                 expect(getComputedStyle(wrapper.getDOMNode())
                     .getPropertyValue('background-color')).toEqual(eColor);
             });
@@ -100,12 +100,14 @@ describe('renderers', () => {
 
         it('should render collection fileSizeTotal', () => {
             // given
-            const store = mockStore({ resources: {
-                [props.uuid]: {
-                    kind: ResourceKind.COLLECTION,
-                    fileSizeTotal: 100,
+            const store = mockStore({
+                resources: {
+                    [props.uuid]: {
+                        kind: ResourceKind.COLLECTION,
+                        fileSizeTotal: 100,
+                    }
                 }
-            }});
+            });
 
             // when
             const wrapper = mount(<Provider store={store}>
@@ -131,16 +133,20 @@ describe('renderers', () => {
 
         it('should render empty string for non collection resource', () => {
             // given
-            const store1 = mockStore({ resources: {
-                [props.uuid]: {
-                    kind: ResourceKind.PROJECT,
+            const store1 = mockStore({
+                resources: {
+                    [props.uuid]: {
+                        kind: ResourceKind.PROJECT,
+                    }
                 }
-            }});
-            const store2 = mockStore({ resources: {
-                [props.uuid]: {
-                    kind: ResourceKind.PROJECT,
+            });
+            const store2 = mockStore({
+                resources: {
+                    [props.uuid]: {
+                        kind: ResourceKind.PROJECT,
+                    }
                 }
-            }});
+            });
 
             // when
             const wrapper1 = mount(<Provider store={store1}>
@@ -155,4 +161,4 @@ describe('renderers', () => {
             expect(wrapper2.text()).toContain('');
         });
     });
-});
\ No newline at end of file
+});
index 7822bdc6b4cd2411aaa37fc78a6a626200db35cf..059aad4344fad0f49d55349f79e076a3f92d92e9 100644 (file)
@@ -2,19 +2,12 @@
 //
 // 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,
     FilterGroupIcon,
     CollectionIcon,
@@ -28,67 +21,100 @@ 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, getProcessStatusColor, 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';
+} 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} />
-            </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>;
+    );
 };
 
-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 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]);
+
+    if (props.item.frozenByUuid) {
+        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));
 
 const renderIcon = (item: GroupContentsResource) => {
     switch (item.kind) {
@@ -112,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}>
-        <Grid item>
-            {renderIcon(item)}
-        </Grid>
+const renderWorkflowName = (item: WorkflowResource) => (
+    <Grid
+        container
+        alignItems="center"
+        wrap="nowrap"
+        spacing={16}
+    >
+        <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`;
@@ -141,484 +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}
-        <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>
+);
 
-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 || '(none)'}</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)) {
@@ -628,248 +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 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 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",
+}
 
-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));
+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 })}</Grid>
+        </Grid>
+    );
+};
 
-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 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 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 ResourceFileSize = connect((state: RootState, props: { uuid: string }) => {
+    const resource = getResource<CollectionResource>(props.uuid)(state.resources);
 
-export const renderFileSize = (fileSize?: number) =>
-    <Typography noWrap style={{ minWidth: '45px' }}>
-        {formatFileSize(fileSize)}
-    </Typography>;
+    if (resource && resource.kind !== ResourceKind.COLLECTION) {
+        return { fileSize: "" };
+    }
 
-export const ResourceFileSize = connect(
-    (state: RootState, props: { uuid: string }) => {
-        const resource = getResource<CollectionResource>(props.uuid)(state.resources);
+    return { fileSize: resource ? resource.fileSizeTotal : 0 };
+})((props: { fileSize?: number }) => renderFileSize(props.fileSize));
 
-        if (resource && resource.kind !== ResourceKind.COLLECTION) {
-            return { fileSize: '' };
-        }
+const renderOwner = (owner: string) => <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));
+
+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 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 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>;
+        {portableDataHash ? (
+            <>
+                {portableDataHash}
+                <CopyToClipboardSnackbar value={portableDataHash} />
+            </>
+        ) : (
+            "-"
+        )}
+    </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));
+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 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));
-
-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>;
+};
+
+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;
+    }
+
+    return { uuid: props.uuid, userFullname };
+});
 
-            return { uuid: props.uuid, userFullname };
-        });
+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
+);
+
+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, userFullname: string, dispatch: Dispatch }) => {
-            const { uuid, userFullname, dispatch } = props;
+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 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 (userFullname === "") {
+        dispatch<any>(loadResource(uuid, false));
+    }
+    return <span>{userFullname ? userFullname : uuid}</span>;
+});
 
-                let resource: Resource | undefined = getResource<GroupContentsResource & UserResource>(props.uuid)(state.resources);
+export const ResponsiblePerson = compose(
+    connect((state: RootState, props: { uuid: string; parentRef: HTMLElement | null }) => {
+        let responsiblePersonName: string = "";
+        let responsiblePersonUUID: string = "";
+        let responsiblePersonProperty: string = "";
 
-                while (resource && resource.kind !== ResourceKind.USER && responsiblePersonProperty) {
-                    responsiblePersonUUID = (resource as CollectionResource).properties[responsiblePersonProperty];
-                    resource = getResource<GroupContentsResource & UserResource>(responsiblePersonUUID)(state.resources);
-                }
+        if (state.auth.config.clusterConfig.Collections.ManagedProperties) {
+            let index = 0;
+            const keys = Object.keys(state.auth.config.clusterConfig.Collections.ManagedProperties);
 
-                if (resource && resource.kind === ResourceKind.USER) {
-                    responsiblePersonName = getUserFullname(resource as UserResource) || (resource as GroupContentsResource).name;
+            while (!responsiblePersonProperty && keys[index]) {
+                const key = keys[index];
+                if (state.auth.config.clusterConfig.Collections.ManagedProperties[key].Function === "original_owner") {
+                    responsiblePersonProperty = key;
                 }
-
-                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';
+                index++;
             }
+        }
 
-            if (!responsiblePersonName) {
-                return <Typography style={{ color: theme.palette.primary.main }} inline noWrap>
-                    {uuid}
-                </Typography>;
-            }
+        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);
+        }
 
-            return <Typography style={{ color: theme.palette.primary.main }} inline noWrap>
-                {responsiblePersonName} ({uuid})
-            </Typography>;
-        });
+        if (resource && resource.kind === ResourceKind.USER) {
+            responsiblePersonName = getUserFullname(resource as UserResource) || (resource as GroupContentsResource).name;
+        }
 
-const renderType = (type: string, subtype: string) =>
-    <Typography noWrap>
-        {resourceLabel(type, subtype)}
-    </Typography>;
+        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";
+    }
 
-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));
+    if (!responsiblePersonName) {
+        return (
+            <Typography
+                style={{ color: theme.palette.primary.main }}
+                inline
+                noWrap
+            >
+                {uuid}
+            </Typography>
+        );
+    }
+
+    return (
+        <Typography
+            style={{ color: theme.palette.primary.main }}
+            inline
+            noWrap
+        >
+            {responsiblePersonName} ({uuid})
+        </Typography>
+    );
+});
+
+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 }) => {
+    return {
+        collection: getResource<CollectionResource>(props.uuid)(state.resources),
+        uuid: props.uuid,
+        className: props.className,
+    };
+})((props: { collection: CollectionResource; uuid: string; className?: string }) => (
+    <Typography className={props.className}>{props.collection?.name || props.uuid}</Typography>
+));
+
 export const ProcessStatus = compose(
     connect((state: RootState, props: { uuid: string }) => {
         return { process: getProcess(props.uuid)(state.resources) };
     }),
-    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,
-                    backgroundColor: getProcessStatusColor(
-                        getProcessStatus(props.process), props.theme),
-                    color: props.theme.palette.common.white,
-                    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;
@@ -881,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 renderRunTime(this.state.runtime);
+        render() {
+            return this.props.process ? renderRunTime(this.state.runtime) : <Typography>-</Typography>;
+        }
     }
-});
+);
index 5edfbc37e1b36354df7fb5c848763f3613e7ec72..4431465b87688ec985a11307d6fd23b8b9f7edae 100644 (file)
@@ -8,7 +8,7 @@ import { CollectionResource } from 'models/collection';
 import { DetailsData } from "./details-data";
 import { CollectionDetailsAttributes } from 'views/collection-panel/collection-panel';
 import { RootState } from 'store/store';
-import { filterResources, getResource } from 'store/resources/resources';
+import { filterResources, getResource, ResourcesState } from 'store/resources/resources';
 import { connect } from 'react-redux';
 import { Button, Grid, ListItem, StyleRulesCallback, Typography, withStyles, WithStyles } from '@material-ui/core';
 import { formatDate, formatFileSize } from 'common/formatters';
@@ -17,6 +17,7 @@ import { Dispatch } from 'redux';
 import { navigateTo } from 'store/navigation/navigation-action';
 import { openContextMenu, resourceUuidToContextMenuKind } from 'store/context-menu/context-menu-actions';
 import { openCollectionUpdateDialog } from 'store/collections/collection-update-actions';
+import { resourceIsFrozen } from 'common/frozen-resources';
 
 export type CssRules = 'versionBrowserHeader'
     | 'versionBrowserItem'
@@ -82,6 +83,7 @@ export class CollectionDetails extends DetailsData<CollectionResource> {
 }
 
 interface CollectionInfoDataProps {
+    resources: ResourcesState;
     currentCollection: CollectionResource | undefined;
 }
 
@@ -91,6 +93,7 @@ interface CollectionInfoDispatchProps {
 
 const ciMapStateToProps = (state: RootState): CollectionInfoDataProps => {
     return {
+        resources: state.resources,
         currentCollection: getResource<CollectionResource>(state.detailsPanel.resourceUuid)(state.resources),
     };
 };
@@ -110,10 +113,11 @@ type CollectionInfoProps = CollectionInfoDataProps & CollectionInfoDispatchProps
 
 const CollectionInfo = withStyles(styles)(
     connect(ciMapStateToProps, ciMapDispatchToProps)(
-        ({ currentCollection, editCollection, classes }: CollectionInfoProps) =>
+        ({ currentCollection, resources, editCollection, classes }: CollectionInfoProps) =>
             currentCollection !== undefined
                 ? <div>
                     <Button
+                        disabled={resourceIsFrozen(currentCollection, resources)}
                         className={classes.editButton} variant='contained'
                         data-cy='details-panel-edit-btn' color='primary' size='small'
                         onClick={() => editCollection(currentCollection)}>
index adbbab79333b385eec7b028c98c75aa7c4a041cb..2653a2103345fe40a99cf9deb467e162efb05572 100644 (file)
@@ -27,6 +27,7 @@ import { getResource } from 'store/resources/resources';
 import { toggleDetailsPanel, SLIDE_TIMEOUT, openDetailsPanel } from 'store/details-panel/details-panel-action';
 import { FileDetails } from 'views-components/details-panel/file-details';
 import { getNode } from 'models/tree';
+import { resourceIsFrozen } from 'common/frozen-resources';
 
 type CssRules = 'root' | 'container' | 'opened' | 'headerContainer' | 'headerIcon' | 'tabContainer';
 
@@ -82,12 +83,22 @@ 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);
+
+    let isFrozen = false;
+    if (resource) {
+        isFrozen = resourceIsFrozen(resource, resources);
+    }
+
     return {
+        isFrozen,
         authConfig: auth.config,
         isOpened: detailsPanel.isOpened,
         tabNr: detailsPanel.tabNr,
@@ -111,6 +122,7 @@ export interface DetailsPanelDataProps {
     isOpened: boolean;
     tabNr: number;
     res: DetailsResource;
+    isFrozen: boolean;
 }
 
 type DetailsPanelProps = DetailsPanelDataProps & WithStyles<CssRules>;
index 6d48e984de0f5ec7c84178c82b16c4e3a924918f..7dc6709da591a84a7ecd813582aa509733008c0a 100644 (file)
@@ -19,6 +19,9 @@ import { getPropertyChip } from '../resource-properties-form/property-chip';
 import { ResourceWithName } from '../data-explorer/renderers';
 import { GroupClass } from "models/group";
 import { openProjectUpdateDialog, ProjectUpdateFormDialogData } from 'store/projects/project-update-actions';
+import { RootState } from 'store/store';
+import { ResourcesState } from 'store/resources/resources';
+import { resourceIsFrozen } from 'common/frozen-resources';
 
 export class ProjectDetails extends DetailsData<ProjectResource> {
     getIcon(className?: string) {
@@ -59,6 +62,12 @@ interface ProjectDetailsComponentActionProps {
     onClick: (prj: ProjectUpdateFormDialogData) => () => void;
 }
 
+const mapStateToProps = (state: RootState): { resources: ResourcesState } => {
+    return {
+        resources: state.resources
+    };
+};
+
 const mapDispatchToProps = (dispatch: Dispatch) => ({
     onClick: (prj: ProjectUpdateFormDialogData) =>
         () => dispatch<any>(openProjectUpdateDialog(prj)),
@@ -66,9 +75,9 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({
 
 type ProjectDetailsComponentProps = ProjectDetailsComponentDataProps & ProjectDetailsComponentActionProps & WithStyles<CssRules>;
 
-const ProjectDetailsComponent = connect(null, mapDispatchToProps)(
+const ProjectDetailsComponent = connect(mapStateToProps, mapDispatchToProps)(
     withStyles(styles)(
-        ({ classes, project, onClick }: ProjectDetailsComponentProps) => <div>
+        ({ classes, project, resources, onClick }: ProjectDetailsComponentProps & { resources: ResourcesState }) => <div>
             {project.groupClass !== GroupClass.FILTER ?
                 <Button onClick={onClick({
                     uuid: project.uuid,
@@ -76,6 +85,7 @@ const ProjectDetailsComponent = connect(null, mapDispatchToProps)(
                     description: project.description,
                     properties: project.properties,
                 })}
+                    disabled={resourceIsFrozen(project, resources)}
                     className={classes.editButton} variant='contained'
                     data-cy='details-panel-edit-btn' color='primary' size='small'>
                     <RenameIcon className={classes.editIcon} /> Edit
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 60%
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 a79ed0bcce76250490232ec0f94bb327655d6669..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) =>
         () =>
-            <div>
-                <CollectionPickerField {...{ pickerId }}/>
-            </div>);
+            <>
+                <DirectoryPickerField {...{ pickerId }}/>
+            </>);
similarity index 66%
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 7a3c5fddd1255c1c3f92c30d5ce137fdf2460f9e..6b5a7759e5ccfbbfcac7ffc119d4a415ede7472b 100644 (file)
@@ -8,24 +8,24 @@ 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) =>
         () =>
-            <div>
+            <>
                 <CollectionNameField />
                 <CollectionDescriptionField />
                 <CollectionProjectPickerField {...{ pickerId }} />
-            </div>);
+            </>);
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 5605e6caa866c266990caa9722ac94be96004aef..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) =>
-    () =>
-        <span>
-            <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}/>
-        </span>);
+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}
+    />
+));
diff --git a/src/views-components/dialog-copy/dialog-process-rerun.tsx b/src/views-components/dialog-copy/dialog-process-rerun.tsx
new file mode 100644 (file)
index 0000000..a5d8f3a
--- /dev/null
@@ -0,0 +1,27 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// 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 { 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} />
+);
+
+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 c8f33642abe9bdb45d8b49818afc5c0294d1b1d6..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 { DialogCopy } from "views-components/dialog-copy/dialog-copy";
+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),
-)(DialogCopy);
\ No newline at end of file
+    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 cde286c450c4764be139b288c65ab3ab55f9d4e2..be59261722377a7001b59c85bbac3d72dbf1536a 100644 (file)
@@ -36,7 +36,7 @@ const mapDispatchToProps = (dispatch: Dispatch, { onDrop }: FileUploaderProps):
 export const FileUploader = connect(mapStateToProps, mapDispatchToProps)(FileUpload);
 
 export const FileUploaderField = (props: WrappedFieldProps & { label?: string }) =>
-    <div>
+    <>
         <Typography variant='caption'>{props.label}</Typography>
         <FileUploader disabled={false} onDrop={props.input.onChange} />
-    </div>;
+    </>;
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 777fa824dfbcb7df0233d0058a921e40d54bf9bf..47633a0b12061d649f0491c0328d56ca7c5ed1af 100644 (file)
@@ -3,20 +3,17 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import React from "react";
-import { Field, WrappedFieldProps, FieldArray } from 'redux-form';
+import { Field, FieldArray } from 'redux-form';
 import { TextField, DateTextField } from "components/text-field/text-field";
 import { CheckboxField } from 'components/checkbox-field/checkbox-field';
 import { NativeSelectField } from 'components/select-field/select-field';
 import { ResourceKind } from 'models/resource';
-import { HomeTreePicker } from 'views-components/projects-tree-picker/home-tree-picker';
-import { SEARCH_BAR_ADVANCED_FORM_PICKER_ID } from 'store/search-bar/search-bar-actions';
 import { SearchBarAdvancedPropertiesView } from 'views-components/search-bar/search-bar-advanced-properties-view';
-import { TreeItem } from "components/tree/tree";
-import { ProjectsTreePickerItem } from "views-components/projects-tree-picker/generic-projects-tree-picker";
 import { PropertyKeyField, } from 'views-components/resource-properties-form/property-key-field';
 import { PropertyValueField } from 'views-components/resource-properties-form/property-value-field';
 import { connect } from "react-redux";
 import { RootState } from "store/store";
+import { ProjectInput, ProjectCommandInputParameter } from 'views/run-process-panel/inputs/project-input';
 
 export const SearchBarTypeField = () =>
     <Field
@@ -36,7 +33,7 @@ interface SearchBarClusterFieldProps {
 
 export const SearchBarClusterField = connect(
     (state: RootState) => ({
-        clusters: [{key: '', value: 'Any'}].concat(
+        clusters: [{ key: '', value: 'Any' }].concat(
             state.auth.sessions
                 .filter(s => s.loggedIn)
                 .map(s => ({
@@ -46,24 +43,15 @@ export const SearchBarClusterField = connect(
     }))((props: SearchBarClusterFieldProps) => <Field
         name='cluster'
         component={NativeSelectField as any}
-        items={props.clusters}/>
+        items={props.clusters} />
     );
 
 export const SearchBarProjectField = () =>
-    <Field
-        name='projectUuid'
-        component={ProjectsPicker} />;
-
-const ProjectsPicker = (props: WrappedFieldProps) =>
-    <div style={{ height: '100px', display: 'flex', flexDirection: 'column', overflow: 'overlay' }}>
-        <HomeTreePicker
-            pickerId={SEARCH_BAR_ADVANCED_FORM_PICKER_ID}
-            toggleItemActive={
-                (_: any, { id }: TreeItem<ProjectsTreePickerItem>) => {
-                    props.input.onChange(id);
-                }
-            } />
-    </div>;
+    <ProjectInput required={false} input={{
+        id: "projectObject",
+        label: "Limit search to Project"
+    } as ProjectCommandInputParameter}
+        options={{ showOnlyOwned: false, showOnlyWritable: false }} />
 
 export const SearchBarTrashField = () =>
     <Field
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 f0316e34c0181a95e477bea2b711781b1b5c96b8..1d7b77ac17ffb2e71318bfa4d99dff8f703bf786 100644 (file)
@@ -43,7 +43,7 @@ describe('<AccountMenu />', () => {
         it('should dispatch a logout action when clicked', () => {
             wrapper.find('[data-cy="logout-menuitem"]').simulate('click');
             expect(props.dispatch).toHaveBeenCalledWith({
-                payload: {deleteLinkData: true},
+                payload: {deleteLinkData: true, preservePath: false},
                 type: 'LOGOUT',
             });
         });
index 7faf27c2eef507a68a776de5c692b50b8461ee34..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[],
@@ -95,9 +81,9 @@ export const AccountMenuComponent =
                 {user.isActive && accountMenuItems}
                 <Divider />
                 <MenuItem data-cy="logout-menuitem"
-                    onClick={() => dispatch(authActions.LOGOUT({ deleteLinkData: true }))}>
+                    onClick={() => dispatch(authActions.LOGOUT({ deleteLinkData: true, preservePath: false }))}>
                     Logout
-                </MenuItem>
+                </MenuItem>
             </DropdownMenu>
             : null;
     };
index 442b90345dadad7687266a46749757a9ac8c6527..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';
 
@@ -24,7 +25,7 @@ const styles: StyleRulesCallback<CssRules> = () => ({
         color: 'inherit'
     },
     toolbar: {
-        height: '56px'
+        height: '56px',
     }
 });
 
@@ -34,6 +35,7 @@ interface MainAppBarDataProps {
     children?: ReactNode;
     uuidPrefix: string;
     siteBanner: string;
+    sidePanelIsCollapsed: boolean;
 }
 
 export type MainAppBarProps = MainAppBarDataProps & WithStyles<CssRules>;
@@ -46,10 +48,12 @@ 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">{props.buildInfo}</Typography>
+                        <Typography variant="caption" color="inherit">
+                            
+                            {props.buildInfo}</Typography>
                     </Grid>}
                     <Grid
                         item
index e27bdad552f7c51c34610c3e3bba38d2ed87a279..89fd2e9184793bf7cd790d7d052c43ad1917d1d8 100644 (file)
@@ -3,21 +3,94 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import React from "react";
-import { Badge, MenuItem } from '@material-ui/core';
+import { Dispatch } from "redux";
+import { connect } from "react-redux";
+import { Badge, MenuItem } from "@material-ui/core";
 import { DropdownMenu } from "components/dropdown-menu/dropdown-menu";
-import { NotificationIcon } from 'components/icon/icon';
+import { NotificationIcon } from "components/icon/icon";
+import bannerActions from "store/banner/banner-action";
+import { BANNER_LOCAL_STORAGE_KEY } from "views-components/baner/banner";
+import { RootState } from "store/store";
+import { TOOLTIP_LOCAL_STORAGE_KEY } from "store/tooltips/tooltips-middleware";
+import { useCallback } from "react";
 
-export const NotificationsMenu = 
-    () =>
+const mapStateToProps = (state: RootState): NotificationsMenuProps => ({
+    isOpen: state.banner.isOpen,
+    bannerUUID: state.auth.config.clusterConfig.Workbench.BannerUUID,
+});
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    openBanner: () => dispatch<any>(bannerActions.openBanner()),
+});
+
+type NotificationsMenuProps = {
+    isOpen: boolean;
+    bannerUUID?: string;
+};
+
+type NotificationsMenuComponentProps = NotificationsMenuProps & {
+    openBanner: any;
+};
+
+export const NotificationsMenuComponent = (props: NotificationsMenuComponentProps) => {
+    const { isOpen, openBanner } = props;
+    const bannerResult = localStorage.getItem(BANNER_LOCAL_STORAGE_KEY);
+    const tooltipResult = localStorage.getItem(TOOLTIP_LOCAL_STORAGE_KEY);
+    const menuItems: any[] = [];
+
+    if (!isOpen && bannerResult) {
+        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");
+        }
+        window.location.reload();
+    }, [tooltipResult]);
+
+    if (tooltipResult) {
+        menuItems.push(
+            <MenuItem onClick={toggleTooltips}>
+                <span>Enable tooltips</span>
+            </MenuItem>
+        );
+    } else {
+        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">
+                    color="primary"
+                >
                     <NotificationIcon />
-                </Badge>}
+                </Badge>
+            }
             id="account-menu"
-            title="Notifications">
-            <MenuItem>You are up to date</MenuItem>
-        </DropdownMenu>;
+            title="Notifications"
+        >
+            {menuItems.map((item, i) => (
+                <div key={i}>{item}</div>
+            ))}
+        </DropdownMenu>
+    );
+};
 
+export const NotificationsMenu = connect(mapStateToProps, mapDispatchToProps)(NotificationsMenuComponent);
index 271d77c1085319854c8edec9d887ff0968bc233c..3f4de301f2b465e59ea94e01c25116a23bba9257 100644 (file)
@@ -15,9 +15,15 @@ import RefreshButton from "components/refresh-button/refresh-button";
 import { loadSidePanelTreeProjects } from "store/side-panel-tree/side-panel-tree-actions";
 import { Dispatch } from "redux";
 
-type CssRules = "infoTooltip";
+type CssRules = 'mainBar' | 'breadcrumbContainer' | 'infoTooltip';
 
 const styles: StyleRulesCallback<CssRules> = theme => ({
+    mainBar: {
+        flexWrap: 'nowrap',
+    },
+    breadcrumbContainer: {
+        overflow: 'hidden',
+    },
     infoTooltip: {
         marginTop: '-10px',
         marginLeft: '10px',
@@ -61,8 +67,8 @@ const mapDispatchToProps = () => (dispatch: Dispatch) => ({
 
 export const MainContentBar = connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(
     (props: MainContentBarProps & WithStyles<CssRules> & any) =>
-        <Toolbar><Grid container>
-            <Grid container item xs alignItems="center">
+        <Toolbar><Grid container className={props.classes.mainBar}>
+            <Grid container item xs alignItems="center" className={props.classes.breadcrumbContainer}>
                 <Breadcrumbs />
             </Grid>
             <Grid item>
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 3b5fae3639591a224a6860e24ab57921afa3fbf0..4761e12f4c6a4b8f47aa95dad5afa59a618182ec 100644 (file)
@@ -55,7 +55,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         whiteSpace: 'pre-line',
     },
     errorColor: {
-        color: theme.customs.colors.red900,
+        color: theme.customs.colors.grey700,
     },
     error: {
         backgroundColor: theme.customs.colors.red100,
@@ -65,7 +65,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         backgroundColor: theme.customs.colors.yellow100,
     },
     warningColor: {
-        color: theme.customs.colors.yellow900,
+        color: theme.customs.colors.grey700,
     },
     paperRoot: {
         minHeight: theme.spacing.unit * 6,
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 aa9fb60b1db7a4237be254fd5405528d6f90b2d5..70797f3165eace32a1a6cef81d409f753fc4fe5d 100644 (file)
@@ -10,24 +10,20 @@ import { TreeItem, TreeItemStatus } from 'components/tree/tree';
 import { ProjectResource } from "models/project";
 import { treePickerActions } from "store/tree-picker/tree-picker-actions";
 import { ListItemTextIcon } from "components/list-item-text-icon/list-item-text-icon";
-import { ProjectIcon, InputIcon, IconType, CollectionIcon } from 'components/icon/icon';
+import { ProjectIcon, FileInputIcon, IconType, CollectionIcon } from 'components/icon/icon';
 import { loadProject, loadCollection } from 'store/tree-picker/tree-picker-actions';
-import { GroupContentsResource } from 'services/groups-service/groups-service';
-import { CollectionDirectory, CollectionFile, CollectionFileType } from 'models/collection-file';
+import { ProjectsTreePickerItem, ProjectsTreePickerRootItem } from 'store/tree-picker/tree-picker-middleware';
 import { ResourceKind } from 'models/resource';
 import { TreePickerProps, TreePicker } from "views-components/tree-picker/tree-picker";
-import { LinkResource } from "models/link";
+import { CollectionFileType } from 'models/collection-file';
 
-export interface ProjectsTreePickerRootItem {
-    id: string;
-    name: string;
-}
 
-export type ProjectsTreePickerItem = ProjectsTreePickerRootItem | GroupContentsResource | CollectionDirectory | CollectionFile | LinkResource;
 type PickedTreePickerProps = Pick<TreePickerProps<ProjectsTreePickerItem>, 'onContextMenu' | 'toggleItemActive' | 'toggleItemOpen' | 'toggleItemSelection'>;
 
 export interface ProjectsTreePickerDataProps {
+    cascadeSelection: boolean;
     includeCollections?: boolean;
+    includeDirectories?: boolean;
     includeFiles?: boolean;
     rootItemIcon: IconType;
     showSelection?: boolean;
@@ -35,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) => {
 
@@ -65,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 })
+                        ? 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);
         }
@@ -104,7 +100,7 @@ const getProjectPickerIcon = ({ data }: TreeItem<ProjectsTreePickerItem>, rootIc
     } else if ('type' in data) {
         switch (data.type) {
             case CollectionFileType.FILE:
-                return InputIcon;
+                return FileInputIcon;
             default:
                 return ProjectIcon;
         }
@@ -113,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 df5fa9c28341bfbfdf74ced449645d568e64684f..3f71a58e40a89b94306903862baa22a9230588df 100644 (file)
@@ -6,12 +6,12 @@ import { connect } from 'react-redux';
 import { ProjectsTreePicker, ProjectsTreePickerProps } from 'views-components/projects-tree-picker/generic-projects-tree-picker';
 import { Dispatch } from 'redux';
 import { loadUserProject } from 'store/tree-picker/tree-picker-actions';
-import { ProjectIcon } from 'components/icon/icon';
+import { ProjectsIcon } from 'components/icon/icon';
 
 export const HomeTreePicker = connect(() => ({
-    rootItemIcon: ProjectIcon,
+    rootItemIcon: ProjectsIcon,
 }), (dispatch: Dispatch): Pick<ProjectsTreePickerProps, 'loadRootItem'> => ({
-    loadRootItem: (_, pickerId, includeCollections, includeFiles) => {
-        dispatch<any>(loadUserProject(pickerId, includeCollections, includeFiles));
+    loadRootItem: (_, pickerId, includeCollections, includeDirectories, includeFiles, options) => {
+        dispatch<any>(loadUserProject(pickerId, includeCollections, includeDirectories, includeFiles, options));
     },
-}))(ProjectsTreePicker);
\ No newline at end of file
+}))(ProjectsTreePicker);
index ee8ce1d5ea5ffc365fa40b2334b9281bc2b53128..16f6cceb71ce44b711c5214157c48ddaff4d3061 100644 (file)
@@ -3,18 +3,31 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import React from 'react';
-import { values, memoize, pipe } from 'lodash/fp';
+import { Dispatch } from 'redux';
+import { connect, DispatchProp } from 'react-redux';
+import { RootState } from 'store/store';
+import { values, pipe } from 'lodash/fp';
 import { HomeTreePicker } from 'views-components/projects-tree-picker/home-tree-picker';
 import { SharedTreePicker } from 'views-components/projects-tree-picker/shared-tree-picker';
 import { FavoritesTreePicker } from 'views-components/projects-tree-picker/favorites-tree-picker';
-import { getProjectsTreePickerIds, SHARED_PROJECT_ID, FAVORITES_PROJECT_ID } from 'store/tree-picker/tree-picker-actions';
+import { SearchProjectsPicker } from 'views-components/projects-tree-picker/search-projects-picker';
+import {
+    getProjectsTreePickerIds, treePickerActions, treePickerSearchActions, initProjectsTreePicker,
+    SHARED_PROJECT_ID, FAVORITES_PROJECT_ID
+} from 'store/tree-picker/tree-picker-actions';
 import { TreeItem } from 'components/tree/tree';
-import { ProjectsTreePickerItem } from './generic-projects-tree-picker';
+import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware';
 import { PublicFavoritesTreePicker } from './public-favorites-tree-picker';
+import { SearchInput } from 'components/search-input/search-input';
+import { withStyles, StyleRulesCallback, WithStyles } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
 
-export interface ProjectsTreePickerProps {
+export interface ToplevelPickerProps {
+    currentUuids?: string[];
     pickerId: string;
+    cascadeSelection: boolean;
     includeCollections?: boolean;
+    includeDirectories?: boolean;
     includeFiles?: boolean;
     showSelection?: boolean;
     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
@@ -22,29 +35,155 @@ export interface ProjectsTreePickerProps {
     toggleItemSelection?: (event: React.MouseEvent<HTMLElement>, item: TreeItem<ProjectsTreePickerItem>, pickerId: string) => void;
 }
 
-export const ProjectsTreePicker = ({ pickerId, ...props }: ProjectsTreePickerProps) => {
-    const { home, shared, favorites, publicFavorites } = getProjectsTreePickerIds(pickerId);
-    const relatedTreePickers = getRelatedTreePickers(pickerId);
-    const p = {
+interface ProjectsTreePickerSearchProps {
+    projectSearch: string;
+    collectionFilter: string;
+}
+
+interface ProjectsTreePickerActionProps {
+    onProjectSearch: (value: string) => void;
+    onCollectionFilter: (value: string) => void;
+}
+
+const mapStateToProps = (state: RootState, props: ToplevelPickerProps): ProjectsTreePickerSearchProps => {
+    const { search } = getProjectsTreePickerIds(props.pickerId);
+    return {
         ...props,
-        relatedTreePickers,
-        disableActivation
+        projectSearch: state.treePickerSearch.projectSearchValues[search],
+        collectionFilter: state.treePickerSearch.collectionFilterValues[search],
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch, props: ToplevelPickerProps): (ProjectsTreePickerActionProps & DispatchProp) => {
+    const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(props.pickerId);
+    const params = {
+        includeCollections: props.includeCollections,
+        includeDirectories: props.includeDirectories,
+        includeFiles: props.includeFiles,
+        options: props.options
     };
-    return <div>
-        <div data-cy="projects-tree-home-tree-picker">
-            <HomeTreePicker pickerId={home} {...p} />
-        </div>
-        <div data-cy="projects-tree-shared-tree-picker">
-            <SharedTreePicker pickerId={shared} {...p} />
-        </div>
-        <div data-cy="projects-tree-public-favourites-tree-picker">
-            <PublicFavoritesTreePicker pickerId={publicFavorites} {...p} />
-        </div>
-        <div data-cy="projects-tree-favourites-tree-picker">
-            <FavoritesTreePicker pickerId={favorites} {...p} />
-        </div>
-    </div>;
+    dispatch(treePickerSearchActions.SET_TREE_PICKER_LOAD_PARAMS({ pickerId: home, params }));
+    dispatch(treePickerSearchActions.SET_TREE_PICKER_LOAD_PARAMS({ pickerId: shared, params }));
+    dispatch(treePickerSearchActions.SET_TREE_PICKER_LOAD_PARAMS({ pickerId: favorites, params }));
+    dispatch(treePickerSearchActions.SET_TREE_PICKER_LOAD_PARAMS({ pickerId: publicFavorites, params }));
+    dispatch(treePickerSearchActions.SET_TREE_PICKER_LOAD_PARAMS({ pickerId: search, params }));
+
+    return {
+        onProjectSearch: (projectSearchValue: string) => dispatch(treePickerSearchActions.SET_TREE_PICKER_PROJECT_SEARCH({ pickerId: search, projectSearchValue })),
+        onCollectionFilter: (collectionFilterValue: string) => {
+            dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: home, collectionFilterValue }));
+            dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: shared, collectionFilterValue }));
+            dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: favorites, collectionFilterValue }));
+            dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: publicFavorites, collectionFilterValue }));
+            dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: search, collectionFilterValue }));
+        },
+        dispatch
+    }
 };
 
-const getRelatedTreePickers = memoize(pipe(getProjectsTreePickerIds, values));
+type CssRules = 'pickerHeight' | 'searchFlex' | 'scrolledBox';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    pickerHeight: {
+        height: "100%",
+        display: "flex",
+        flexDirection: "column",
+    },
+    searchFlex: {
+        display: "flex",
+        justifyContent: "space-around",
+        paddingBottom: "1em"
+    },
+    scrolledBox: {
+        overflow: "scroll"
+    }
+});
+
+type ProjectsTreePickerCombinedProps = ToplevelPickerProps & ProjectsTreePickerSearchProps & ProjectsTreePickerActionProps & DispatchProp & WithStyles<CssRules>;
+
+export const ProjectsTreePicker = connect(mapStateToProps, mapDispatchToProps)(
+    withStyles(styles)(
+        class FileInputComponent extends React.Component<ProjectsTreePickerCombinedProps> {
+
+            componentDidMount() {
+                const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(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: "" }));
+                this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: home, collectionFilterValue: "" }));
+                this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: shared, collectionFilterValue: "" }));
+                this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: favorites, collectionFilterValue: "" }));
+                this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: publicFavorites, collectionFilterValue: "" }));
+            }
+
+            componentWillUnmount() {
+                const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(this.props.pickerId);
+                // Release all the state, we don't need it to hang around forever.
+                this.props.dispatch(treePickerActions.RESET_TREE_PICKER({ pickerId: search }));
+                this.props.dispatch(treePickerActions.RESET_TREE_PICKER({ pickerId: home }));
+                this.props.dispatch(treePickerActions.RESET_TREE_PICKER({ pickerId: shared }));
+                this.props.dispatch(treePickerActions.RESET_TREE_PICKER({ pickerId: favorites }));
+                this.props.dispatch(treePickerActions.RESET_TREE_PICKER({ pickerId: publicFavorites }));
+            }
+
+            render() {
+                const pickerId = this.props.pickerId;
+                const onProjectSearch = this.props.onProjectSearch;
+                const onCollectionFilter = this.props.onCollectionFilter;
+
+                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,
+                    toggleItemActive: this.props.toggleItemActive,
+                    toggleItemSelection: this.props.toggleItemSelection,
+                    relatedTreePickers,
+                    disableActivation,
+                };
+                return <div className={this.props.classes.pickerHeight} >
+                    <span className={this.props.classes.searchFlex}>
+                        <SearchInput value="" label="Search for a Project" selfClearProp='' onSearch={onProjectSearch} debounce={500} />
+                        {this.props.includeCollections &&
+                            <SearchInput value="" label="Filter Collections list in Projects" selfClearProp='' onSearch={onCollectionFilter} debounce={500} />}
+                    </span>
+
+                    <div className={this.props.classes.scrolledBox}>
+                        {this.props.projectSearch ?
+                            <div data-cy="projects-tree-search-picker">
+                                <SearchProjectsPicker {...p} pickerId={search} />
+                            </div>
+                            :
+                            <>
+                                <div data-cy="projects-tree-home-tree-picker">
+                                    <HomeTreePicker {...p} pickerId={home} />
+                                </div>
+                                <div data-cy="projects-tree-shared-tree-picker">
+                                    <SharedTreePicker {...p} pickerId={shared} />
+                                </div>
+                                <div data-cy="projects-tree-public-favourites-tree-picker">
+                                    <PublicFavoritesTreePicker {...p} pickerId={publicFavorites} />
+                                </div>
+                                <div data-cy="projects-tree-favourites-tree-picker">
+                                    <FavoritesTreePicker {...p} pickerId={favorites} />
+                                </div>
+                            </>}
+                    </div>
+                </div >;
+            }
+        }));
+
+const getRelatedTreePickers = pipe(getProjectsTreePickerIds, values);
 const disableActivation = [SHARED_PROJECT_ID, FAVORITES_PROJECT_ID];
index d2037af438972684c716139d58995bfa29487524..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) => {
-        dispatch<any>(loadPublicFavoritesProject({ pickerId, includeCollections, includeFiles }));
+    loadRootItem: (_, pickerId, includeCollections, includeDirectories, includeFiles, options) => {
+        dispatch<any>(loadPublicFavoritesProject({ pickerId, includeCollections, includeDirectories, includeFiles, options }));
     },
-}))(ProjectsTreePicker);
\ No newline at end of file
+}))(ProjectsTreePicker);
diff --git a/src/views-components/projects-tree-picker/search-projects-picker.tsx b/src/views-components/projects-tree-picker/search-projects-picker.tsx
new file mode 100644 (file)
index 0000000..2888050
--- /dev/null
@@ -0,0 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from 'react-redux';
+import { ProjectsTreePicker, ProjectsTreePickerProps } from 'views-components/projects-tree-picker/generic-projects-tree-picker';
+import { Dispatch } from 'redux';
+import { SearchIcon } from 'components/icon/icon';
+import { loadProject } from 'store/tree-picker/tree-picker-actions';
+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, includeDirectories, includeFiles, options) => {
+        dispatch<any>(loadProject({ id: SEARCH_PROJECT_ID, pickerId, includeCollections, includeDirectories, includeFiles, searchProjects: true, options }));
+    },
+}))(ProjectsTreePicker);
index d6a59bea2d6ec6802679dee7fc7bb1741bb2f120..1914cd9d3ea16e616f453cb83bf8c965c9cb1739 100644 (file)
@@ -7,11 +7,12 @@ import { ProjectsTreePicker, ProjectsTreePickerProps } from 'views-components/pr
 import { Dispatch } from 'redux';
 import { ShareMeIcon } from 'components/icon/icon';
 import { loadProject } from 'store/tree-picker/tree-picker-actions';
+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) => {
-        dispatch<any>(loadProject({ id: 'Shared with me', pickerId, includeCollections, includeFiles, loadShared: true }));
+    loadRootItem: (_, pickerId, includeCollections, includeDirectories, includeFiles, options) => {
+        dispatch<any>(loadProject({ id: SHARED_PROJECT_ID, pickerId, includeCollections, includeDirectories, includeFiles, loadShared: true, options }));
     },
-}))(ProjectsTreePicker);
\ No newline at end of file
+}))(ProjectsTreePicker);
index e5fecf9799fe03e898d6c641138a3b5e27ef22ab..75cf40c641bbe195e0c8ef02c4c2875d3adbb625 100644 (file)
@@ -7,19 +7,25 @@ import { Typography } from "@material-ui/core";
 import { TreeItem } from "components/tree/tree";
 import { WrappedFieldProps } from 'redux-form';
 import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker';
-import { ProjectsTreePickerItem } from 'views-components/projects-tree-picker/generic-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={{ height: '200px', display: 'flex', flexDirection: 'column' }}>
-        <ProjectsTreePicker
-            pickerId={props.pickerId}
-            toggleItemActive={handleChange(props)}
-            options={{ showOnlyOwned: false, showOnlyWritable: true }} />
-        {props.meta.dirty && props.meta.error &&
-            <Typography variant='caption' color='error'>
-                {props.meta.error}
-            </Typography>}
+    <div style={{ display: 'flex', minHeight: 0, flexDirection: 'column' }}>
+        <div style={{ flexBasis: '275px', flexShrink: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
+            <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'>
+                    {props.meta.error}
+                </Typography>}
+        </div>
     </div>;
 
 const handleChange = (props: WrappedFieldProps) =>
@@ -27,14 +33,56 @@ const handleChange = (props: WrappedFieldProps) =>
         props.input.onChange(id);
 
 export const CollectionTreePickerField = (props: WrappedFieldProps & PickerIdProp) =>
-    <div style={{ height: '200px', display: 'flex', flexDirection: 'column' }}>
-        <ProjectsTreePicker
-            pickerId={props.pickerId}
-            toggleItemActive={handleChange(props)}
-            options={{ showOnlyOwned: false, showOnlyWritable: true }}
-            includeCollections />
-        {props.meta.dirty && props.meta.error &&
-            <Typography variant='caption' color='error'>
-                {props.meta.error}
-            </Typography>}
-    </div>;
\ No newline at end of file
+    <div style={{ display: 'flex', minHeight: 0, flexDirection: 'column' }}>
+        <div style={{ flexBasis: '275px', flexShrink: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
+            <ProjectsTreePicker
+                pickerId={props.pickerId}
+                toggleItemActive={handleChange(props)}
+                cascadeSelection={false}
+                options={{ showOnlyOwned: false, showOnlyWritable: true }}
+                includeCollections />
+            {props.meta.dirty && props.meta.error &&
+                <Typography variant='caption' color='error'>
+                    {props.meta.error}
+                </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 b8e525bf675ad5ebe6e7171e2798a393d2ea8855..8941d441a821fd9cbbfca21fc70544e87d19304e 100644 (file)
@@ -89,7 +89,7 @@ const getValidation = (props: PropertyValueFieldProps) =>
 
 const matchTagValues = ({ vocabulary, propertyKeyId }: PropertyValueFieldProps) =>
     (value: string) =>
-        getTagValues(propertyKeyId, vocabulary).find(v => v.label === value)
+        getTagValues(propertyKeyId, vocabulary).find(v => !value || v.label === value)
             ? undefined
             : 'Incorrect value';
 
index 25d0f2bb377e8a9bcb567838c212558a542ff4e8..0147312912730849da6fceba14be2fa799f53ec6 100644 (file)
@@ -3,13 +3,29 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import React from 'react';
-import { InjectedFormProps } from 'redux-form';
+import { RootState } from 'store/store';
+import { connect } from 'react-redux';
+import { formValueSelector, InjectedFormProps } from 'redux-form';
 import { Grid, withStyles, WithStyles } from '@material-ui/core';
 import { PropertyKeyField, PROPERTY_KEY_FIELD_NAME, PROPERTY_KEY_FIELD_ID } from './property-key-field';
 import { PropertyValueField, PROPERTY_VALUE_FIELD_NAME, PROPERTY_VALUE_FIELD_ID } from './property-value-field';
 import { ProgressButton } from 'components/progress-button/progress-button';
 import { GridClassKey } from '@material-ui/core/Grid';
 
+const AddButton = withStyles(theme => ({
+    root: { marginTop: theme.spacing.unit }
+}))(ProgressButton);
+
+const mapStateToProps = (state: RootState) => {
+    return {
+        applySelector: (selector) => selector(state, 'key', 'value', 'keyID', 'valueID')
+    }
+}
+
+interface ApplySelector {
+    applySelector: (selector) => any;
+}
+
 export interface ResourcePropertiesFormData {
     uuid: string;
     [PROPERTY_KEY_FIELD_NAME]: string;
@@ -19,10 +35,11 @@ export interface ResourcePropertiesFormData {
     clearPropertyKeyOnSelect?: boolean;
 }
 
-export type ResourcePropertiesFormProps = {uuid: string; clearPropertyKeyOnSelect?: boolean } & InjectedFormProps<ResourcePropertiesFormData, {uuid: string; }> & WithStyles<GridClassKey>;
+type ResourcePropertiesFormProps = {uuid: string; clearPropertyKeyOnSelect?: boolean } & InjectedFormProps<ResourcePropertiesFormData, {uuid: string;}> & WithStyles<GridClassKey> & ApplySelector;
 
-export const ResourcePropertiesForm = ({ handleSubmit, change, submitting, invalid, classes, uuid, clearPropertyKeyOnSelect }: ResourcePropertiesFormProps ) => {
+export const ResourcePropertiesForm = connect(mapStateToProps)(({ handleSubmit, change, submitting, invalid, classes, uuid, clearPropertyKeyOnSelect, applySelector,  ...props }: ResourcePropertiesFormProps ) => {
     change('uuid', uuid); // Sets the uuid field to the uuid of the resource.
+    const propertyValue = applySelector(formValueSelector(props.form));
     return <form data-cy='resource-properties-form' onSubmit={handleSubmit}>
         <Grid container spacing={16} classes={classes}>
             <Grid item xs>
@@ -32,19 +49,16 @@ export const ResourcePropertiesForm = ({ handleSubmit, change, submitting, inval
                 <PropertyValueField />
             </Grid>
             <Grid item>
-                <Button
+                <AddButton
                     data-cy='property-add-btn'
-                    disabled={invalid}
+                    disabled={invalid || !(propertyValue.key && propertyValue.value)}
                     loading={submitting}
                     color='primary'
                     variant='contained'
                     type='submit'>
                     Add
-                </Button>
+                </AddButton>
             </Grid>
         </Grid>
-    </form>};
-
-export const Button = withStyles(theme => ({
-    root: { marginTop: theme.spacing.unit }
-}))(ProgressButton);
+    </form>}
+);
\ No newline at end of file
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 327644ed81fe6d3ebcafca5bbee5665cac9b1643..6a4d2a620e1fbf8ccd2cb3d4a209130cf75ce07e 100644 (file)
@@ -38,7 +38,7 @@ const mapStateToProps = ({ searchBar, form }: RootState): SearchBarDataProps =>
 };
 
 const mapDispatchToProps = (dispatch: Dispatch): SearchBarActionProps => ({
-    onSearch: (valueSearch: string) => dispatch<any>(searchData(valueSearch)),
+    onSearch: (valueSearch: string) => dispatch<any>(searchData(valueSearch, true)),
     onChange: (event: React.ChangeEvent<HTMLInputElement>) => dispatch<any>(changeData(event.target.value)),
     onSetView: (currentView: string) => dispatch(goToView(currentView)),
     onSubmit: (event: React.FormEvent<HTMLFormElement>) => dispatch<any>(submitData(event)),
index a826fcd59aaa9f6be62f0e5979861c679031474d..058d7234e47124ae32841462cfccc0bc9cf63523 100644 (file)
@@ -11,12 +11,13 @@ import { debounce } from 'debounce';
 import { ListItemText, Typography } from '@material-ui/core';
 import { noop } from 'lodash/fp';
 import { GroupClass, GroupResource } from 'models/group';
-import { getUserDisplayName, UserResource } from 'models/user';
+import { getUserDetailsString, getUserDisplayName, UserResource } from 'models/user';
 import { Resource, ResourceKind } from 'models/resource';
 import { ListResults } from 'services/common-service/common-service';
 
 export interface Participant {
     name: string;
+    tooltip: string;
     uuid: string;
 }
 
@@ -43,10 +44,21 @@ interface ParticipantSelectState {
     suggestions: ParticipantResource[];
 }
 
-const getDisplayName = (item: GroupResource | UserResource) => {
+const getDisplayName = (item: GroupResource | UserResource, detailed: boolean) => {
     switch (item.kind) {
         case ResourceKind.USER:
-            return getUserDisplayName(item, true, true);
+            return getUserDisplayName(item, detailed, detailed);
+        case ResourceKind.GROUP:
+            return item.name + `(${`(${(item as Resource).uuid})`})`;
+        default:
+            return (item as Resource).uuid;
+    }
+};
+
+const getDisplayTooltip = (item: GroupResource | UserResource) => {
+    switch (item.kind) {
+        case ResourceKind.USER:
+            return getUserDetailsString(item);
         case ResourceKind.GROUP:
             return item.name + `(${`(${(item as Resource).uuid})`})`;
         default:
@@ -62,7 +74,7 @@ export const ParticipantSelect = connect()(
         };
 
         render() {
-            const { label = 'Share' } = this.props;
+            const { label = 'Add people and groups' } = this.props;
 
             return (
                 <Autocomplete
@@ -76,22 +88,34 @@ 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;
         }
 
+        renderChipTooltip(item: Participant) {
+            return item.tooltip;
+        }
+
         renderSuggestion(item: ParticipantResource) {
             return (
                 <ListItemText>
-                    <Typography noWrap>{getDisplayName(item)}</Typography>
+                    <Typography noWrap>{getDisplayName(item, true)}</Typography>
                 </ListItemText>
             );
         }
@@ -107,6 +131,7 @@ export const ParticipantSelect = connect()(
                 this.setState({ value: '', suggestions: [] });
                 onCreate({
                     name: '',
+                    tooltip: '',
                     uuid: this.state.value,
                 });
             }
@@ -117,7 +142,8 @@ export const ParticipantSelect = connect()(
             const { onSelect = noop } = this.props;
             this.setState({ value: '', suggestions: [] });
             onSelect({
-                name: getDisplayName(selection),
+                name: getDisplayName(selection, false),
+                tooltip: getDisplayTooltip(selection),
                 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 c813efb0a373f4080fe177b1234ce0222f5f5a1d..6acbb1611883e2323b51d51029539a5b955d613c 100644 (file)
@@ -21,6 +21,7 @@ import { extractUuidKind, ResourceKind } from 'models/resource';
 import { pluginConfig } from 'plugins';
 import { ElementListReducer } from 'common/plugintypes';
 import { Location } from 'history';
+import { ProjectResource } from 'models/project';
 
 type CssRules = 'button' | 'menuItem' | 'icon';
 
@@ -87,9 +88,9 @@ export const SidePanelButton = withStyles(styles)(
                 if (currentItemId === currentUserUUID) {
                     enabled = true;
                 } else if (matchProjectRoute(location ? location.pathname : '')) {
-                    const currentProject = getResource<GroupResource>(currentItemId)(resources);
-                    if (currentProject &&
-                        currentProject.writableBy.indexOf(currentUserUUID || '') >= 0 &&
+                    const currentProject = getResource<ProjectResource>(currentItemId)(resources);
+                    if (currentProject && currentProject.canWrite &&
+                        !currentProject.frozenByUuid &&
                         !isProjectTrashed(currentProject, resources) &&
                         currentProject.groupClass !== GroupClass.FILTER) {
                         enabled = true;
diff --git a/src/views-components/side-panel-toggle/side-panel-toggle.tsx b/src/views-components/side-panel-toggle/side-panel-toggle.tsx
new file mode 100644 (file)
index 0000000..47d3421
--- /dev/null
@@ -0,0 +1,59 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { Tooltip, IconButton } from '@material-ui/core';
+import { connect } from 'react-redux';
+import { toggleSidePanel } from "store/side-panel/side-panel-action";
+import { RootState } from 'store/store';
+
+type collapseButtonProps = {
+    isCollapsed: boolean;
+    toggleSidePanel: (collapsedState: boolean) => void
+}
+
+export const COLLAPSE_ICON_SIZE = 35
+
+const SidePanelToggle = (props: collapseButtonProps) => {
+    const collapseButtonIconStyles = {
+        root: {
+            width: `${COLLAPSE_ICON_SIZE}px`,
+            height: `${COLLAPSE_ICON_SIZE}px`,
+            marginTop: '0.4rem',
+            marginLeft: '0.7rem',
+            paddingTop: '1rem',
+            paddingRight: '1rem'
+        },
+        icon: {
+            opacity: '0.5',
+        },
+    }
+
+    return <Tooltip disableFocusListener title="Toggle Side Panel">
+        <IconButton data-cy="side-panel-toggle" style={collapseButtonIconStyles.root} onClick={() => { props.toggleSidePanel(props.isCollapsed) }}>
+            <div>
+                {props.isCollapsed ?
+                    <img style={{...collapseButtonIconStyles.icon, marginLeft:'0.25rem'}} src='/mui-start-icon.svg' alt='an arrow pointing right'/>
+                    :
+                    <img style={{ ...collapseButtonIconStyles.icon, transform: "rotate(180deg)"}} src='/mui-start-icon.svg' alt='an arrow pointing right'/>}
+            </div>
+        </IconButton>
+    </Tooltip>
+};
+
+const mapStateToProps = (state: RootState) => {
+    return {
+        isCollapsed: state.sidePanel.collapsedState
+    }
+}
+
+const mapDispatchToProps = (dispatch) => {
+    return {
+        toggleSidePanel: (collapsedState) => {
+            return dispatch(toggleSidePanel(collapsedState))
+        }
+    }
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(SidePanelToggle)
index 7f5b8d738797ed9a4247ccf523510ce0b648eab2..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';
@@ -20,6 +20,7 @@ import { GroupClass } from "models/group";
 export interface SidePanelTreeProps {
     onItemActivation: (id: string) => void;
     sidePanelProgress?: boolean;
+    isCollapsed?: boolean
 }
 
 type SidePanelTreeActionProps = Pick<TreePickerProps<ProjectResource | string>, 'onContextMenu' | 'toggleItemActive' | 'toggleItemOpen' | 'toggleItemSelection'>;
@@ -40,9 +41,9 @@ const mapDispatchToProps = (dispatch: Dispatch, props: SidePanelTreeProps): Side
 
 export const SidePanelTree = connect(undefined, mapDispatchToProps)(
     (props: SidePanelTreeActionProps) =>
-        <span data-cy="side-panel-tree">
+        <div data-cy="side-panel-tree">
             <TreePicker {...props} render={renderSidePanelItem} pickerId={SIDE_PANEL_TREE} />
-        </span>);
+        </div>);
 
 const renderSidePanelItem = (item: TreeItem<ProjectResource>) => {
     const name = typeof item.data === 'string' ? item.data : item.data.name;
@@ -63,9 +64,9 @@ const getProjectPickerIcon = (item: TreeItem<ProjectResource | string>) =>
         ? getSidePanelIcon(item.data)
         : (item.data && item.data.groupClass === GroupClass.FILTER)
             ? FilterGroupIcon
-            : ProjectIcon;
+            : ProjectsIcon;
 
-const getSidePanelIcon = (category: string) => {
+export const getSidePanelIcon = (category: string) => {
     switch (category) {
         case SidePanelTreeCategory.FAVORITES:
             return FavoriteIcon;
@@ -81,6 +82,8 @@ 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 218b624cd5675585120cf1b8d34b1322a0ff62c0..18aed873aa9fc018b36585ea09ea2846122fd28a 100644 (file)
@@ -12,10 +12,12 @@ import { navigateFromSidePanel } from 'store/side-panel/side-panel-action';
 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_WITDH = 240;
+const DRAWER_WIDTH = 240;
 
-type CssRules = 'root';
+type CssRules = 'root' | 'topButtonContainer';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
@@ -23,7 +25,11 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         borderRight: `1px solid ${theme.palette.divider}`,
         height: '100%',
         overflowX: 'auto',
-        width: DRAWER_WITDH,
+        width: DRAWER_WIDTH,
+    },
+    topButtonContainer: {
+        display: 'flex',
+        justifyContent: 'space-between'
     }
 });
 
@@ -33,15 +39,27 @@ const mapDispatchToProps = (dispatch: Dispatch): SidePanelTreeProps => ({
     }
 });
 
-const mapStateToProps = ({ router }: RootState) => ({
+const mapStateToProps = ({ router, sidePanel }: RootState) => ({
     currentRoute: router.location ? router.location.pathname : '',
+    isCollapsed: sidePanel.collapsedState
 });
 
 export const SidePanel = withStyles(styles)(
     connect(mapStateToProps, mapDispatchToProps)(
         ({ classes, ...props }: WithStyles<CssRules> & SidePanelTreeProps & { currentRoute: string }) =>
             <Grid item xs>
-                <SidePanelButton key={props.currentRoute} />
-                <SidePanelTree {...props} />
+                {props.isCollapsed ? 
+                <>
+                    <SidePanelToggle />
+                    <SidePanelCollapsed />
+                </>
+                :
+                <>
+                    <Grid className={classes.topButtonContainer}>
+                        <SidePanelButton key={props.currentRoute} />
+                        <SidePanelToggle/>
+                    </Grid>
+                    <SidePanelTree {...props} />
+                </>}
             </Grid>
     ));
index a33b6968255abd841b9eb59c7cc624453ea9344e..1887f0bde042c0e95fa5b78326dc71247ec75d10 100644 (file)
@@ -8,7 +8,7 @@ import { connect } from "react-redux";
 import { RootState } from "store/store";
 import { Button, IconButton, StyleRulesCallback, WithStyles, withStyles, SnackbarContent } from '@material-ui/core';
 import MaterialSnackbar, { SnackbarOrigin } from "@material-ui/core/Snackbar";
-import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { snackbarActions, SnackbarKind, SnackbarMessage } from "store/snackbar/snackbar-actions";
 import { navigateTo } from 'store/navigation/navigation-action';
 import WarningIcon from '@material-ui/icons/Warning';
 import CheckCircleIcon from '@material-ui/icons/CheckCircle';
@@ -23,13 +23,11 @@ interface SnackbarDataProps {
     anchorOrigin?: SnackbarOrigin;
     autoHideDuration?: number;
     open: boolean;
-    message?: React.ReactElement<any>;
-    kind: SnackbarKind;
-    link?: string;
+    messages: SnackbarMessage[];
 }
 
 interface SnackbarEventProps {
-    onClose?: (event: React.SyntheticEvent<any>, reason: string) => void;
+    onClose?: (event: React.SyntheticEvent<any>, reason: string, message?: string) => void;
     onExited: () => void;
     onClick: (uuid: string) => void;
 }
@@ -39,17 +37,15 @@ const mapStateToProps = (state: RootState): SnackbarDataProps => {
     return {
         anchorOrigin: { vertical: "bottom", horizontal: "right" },
         open: state.snackbar.open,
-        message: <span>{messages.length > 0 ? messages[0].message : ""}</span>,
-        autoHideDuration: messages.length > 0 ? messages[0].hideDuration : 0,
-        kind: messages.length > 0 ? messages[0].kind : SnackbarKind.INFO,
-        link: messages.length > 0 ? messages[0].link : ''
+        messages,
+        autoHideDuration: messages.length > 0 ? messages[0].hideDuration : 0
     };
 };
 
 const mapDispatchToProps = (dispatch: Dispatch): SnackbarEventProps => ({
-    onClose: (event: any, reason: string) => {
+    onClose: (event: any, reason: string, id: undefined) => {
         if (reason !== "clickaway") {
-            dispatch(snackbarActions.CLOSE_SNACKBAR());
+            dispatch(snackbarActions.CLOSE_SNACKBAR(id));
         }
     },
     onExited: () => {
@@ -60,7 +56,7 @@ const mapDispatchToProps = (dispatch: Dispatch): SnackbarEventProps => ({
     }
 });
 
-type CssRules = "success" | "error" | "info" | "warning" | "icon" | "iconVariant" | "message" | "linkButton";
+type CssRules = "success" | "error" | "info" | "warning" | "icon" | "iconVariant" | "message" | "linkButton" | "snackbarContent";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     success: {
@@ -88,6 +84,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     },
     linkButton: {
         fontWeight: 'bolder'
+    },
+    snackbarContent: {
+        marginBottom: '1rem'
     }
 });
 
@@ -104,53 +103,60 @@ export const Snackbar = withStyles(styles)(connect(mapStateToProps, mapDispatchT
             [SnackbarKind.ERROR]: [ErrorIcon, classes.error]
         };
 
-        const [Icon, cssClass] = variants[props.kind];
-
-
-
         return (
             <MaterialSnackbar
                 open={props.open}
-                message={props.message}
                 onClose={props.onClose}
                 onExited={props.onExited}
                 anchorOrigin={props.anchorOrigin}
                 autoHideDuration={props.autoHideDuration}>
-                <div data-cy="snackbar"><SnackbarContent
-                    className={classNames(cssClass)}
-                    aria-describedby="client-snackbar"
-                    message={
-                        <span id="client-snackbar" className={classes.message}>
-                            <Icon className={classNames(classes.icon, classes.iconVariant)} />
-                            {props.message}
-                        </span>
+                <div data-cy="snackbar">
+                    {
+                         props.messages.map((message, index) => {
+                            const [Icon, cssClass] = variants[message.kind];
+
+                            return <SnackbarContent
+                                key={`${index}-${message.message}`}
+                                className={classNames(cssClass, classes.snackbarContent)}
+                                aria-describedby="client-snackbar"
+                                message={
+                                    <span id="client-snackbar" className={classes.message}>
+                                        <Icon className={classNames(classes.icon, classes.iconVariant)} />
+                                        {message.message}
+                                    </span>
+                                }
+                                action={actions(message, props.onClick, props.onClose, classes, index, props.autoHideDuration)}
+                            />
+                         })
                     }
-                    action={actions(props)}
-                /></div>
+                </div>
             </MaterialSnackbar>
         );
     }
 ));
 
-const actions = (props: SnackbarProps) => {
-    const { link, onClose, onClick, classes } = props;
+const actions = (props: SnackbarMessage, onClick, onClose, classes, index, autoHideDuration) => {
+    if (onClose && autoHideDuration) {
+        setTimeout(onClose, autoHideDuration);
+    }
+
     const actions = [
         <IconButton
             key="close"
             aria-label="Close"
             color="inherit"
-            onClick={e => onClose && onClose(e, '')}>
+            onClick={e => onClose && onClose(e, '', index)}>
             <CloseIcon className={classes.icon} />
         </IconButton>
     ];
-    if (link) {
+    if (props.link) {
         actions.splice(0, 0,
             <Button key="goTo"
                 aria-label="goTo"
                 size="small"
                 color="inherit"
                 className={classes.linkButton}
-                onClick={() => onClick(link)}>
+                onClick={() => onClick(props.link)}>
                 <span data-cy='snackbar-goto-action'>Go To</span>
             </Button>
         );
index 86c76e0824ce6c78ffb66ea6c487a28ce9db313f..a6fdfefec9d21d013f72a6e87ffe3ecbc6ca83fe 100644 (file)
@@ -8,6 +8,7 @@ import { RootState } from "store/store";
 import { getNodeChildrenIds, Tree as Ttree, createTree, getNode, TreeNodeStatus } from 'models/tree';
 import { Dispatch } from "redux";
 import { initTreeNode } from '../../models/tree';
+import { ResourcesState } from "store/resources/resources";
 
 type Callback<T> = (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>, pickerId: string) => void;
 export interface TreePickerProps<T> {
@@ -34,30 +35,23 @@ const addToItemsIdMap = <T>(item: TreeItem<T>, itemsIdMap: Map<string, TreeItem<
     return item;
 };
 
-const memoizedMapStateToProps = () => {
-    let prevTree: Ttree<any>;
-    let mappedProps: Pick<TreeProps<any>, 'items' | 'disableRipple' | 'itemsMap'>;
-    return <T>(state: RootState, props: TreePickerProps<T>): Pick<TreeProps<T>, 'items' | 'disableRipple' | 'itemsMap'> => {
+const mapStateToProps =
+    <T>(state: RootState, props: TreePickerProps<T>): Pick<TreeProps<T>, 'items' | 'disableRipple' | 'itemsMap'> => {
         const itemsIdMap: Map<string, TreeItem<T>> = new Map();
         const tree = state.treePicker[props.pickerId] || createTree();
-        if (tree !== prevTree) {
-            prevTree = tree;
-            mappedProps = {
-                disableRipple: true,
-                items: getNodeChildrenIds('')(tree)
-                    .map(treePickerToTreeItems(tree))
-                    .map(item => addToItemsIdMap(item, itemsIdMap))
-                    .map(parentItem => ({
-                        ...parentItem,
-                        flatTree: true,
-                        items: flatTree(itemsIdMap, 2, parentItem.items || []),
-                    })),
-                itemsMap: itemsIdMap,
-            };
-        }
-        return mappedProps;
+        return {
+            disableRipple: true,
+            items: getNodeChildrenIds('')(tree)
+                .map(treePickerToTreeItems(tree, state.resources))
+                .map(item => addToItemsIdMap(item, itemsIdMap))
+                .map(parentItem => ({
+                    ...parentItem,
+                    flatTree: true,
+                    items: flatTree(itemsIdMap, 2, parentItem.items || []),
+                })),
+            itemsMap: itemsIdMap,
+        };
     };
-};
 
 const mapDispatchToProps = (_: Dispatch, props: TreePickerProps<any>): Pick<TreeProps<any>, 'onContextMenu' | 'toggleItemOpen' | 'toggleItemActive' | 'toggleItemSelection'> => ({
     onContextMenu: (event, item) => props.onContextMenu(event, item, props.pickerId),
@@ -66,16 +60,17 @@ const mapDispatchToProps = (_: Dispatch, props: TreePickerProps<any>): Pick<Tree
     toggleItemSelection: (event, item) => props.toggleItemSelection(event, item, props.pickerId),
 });
 
-export const TreePicker = connect(memoizedMapStateToProps(), mapDispatchToProps)(Tree);
+export const TreePicker = connect(mapStateToProps, mapDispatchToProps)(Tree);
 
-const treePickerToTreeItems = (tree: Ttree<any>) =>
+const treePickerToTreeItems = (tree: Ttree<any>, resources: ResourcesState) =>
     (id: string): TreeItem<any> => {
         const node = getNode(id)(tree) || initTreeNode({ id: '', value: 'InvalidNode' });
         const items = getNodeChildrenIds(node.id)(tree)
-            .map(treePickerToTreeItems(tree));
+            .map(treePickerToTreeItems(tree, resources));
+        const resource = resources[node.id];
         return {
             active: node.active,
-            data: node.value,
+            data: resource ? { ...resource, name: node.value.name || node.value } : undefined || node.value,
             id: node.id,
             items: items.length > 0 ? items : undefined,
             open: node.expanded,
index 1654452bf493b656b031c648fa1faf96a1275e1a..b591bb8fcebbeb24b832db6dff40756a1ebe36b3 100644 (file)
@@ -9,7 +9,7 @@ import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
 import { FormDialog } from 'components/form-dialog/form-dialog';
 import { VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, VIRTUAL_MACHINE_ADD_LOGIN_FORM, addUpdateVirtualMachineLogin, AddLoginFormData, VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD, VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD } from 'store/virtual-machines/virtual-machines-actions';
 import { ParticipantSelect } from 'views-components/sharing-dialog/participant-select';
-import { GroupArrayInput } from 'views-components/virtual-machines-dialog/group-array-input';
+import { GroupArrayInput, GroupArrayDataProps } from 'views-components/virtual-machines-dialog/group-array-input';
 
 export const VirtualMachineAddLoginDialog = compose(
     withDialog(VIRTUAL_MACHINE_ADD_LOGIN_DIALOG),
@@ -20,16 +20,25 @@ export const VirtualMachineAddLoginDialog = compose(
         }
     })
 )(
-    (props: CreateGroupDialogComponentProps) =>
-        <FormDialog
+    (props: CreateGroupDialogComponentProps) => {
+        const [hasPartialGroupInput, setPartialGroupInput] = React.useState<boolean>(false);
+
+        return <FormDialog
             dialogTitle={props.data.updating ? "Update login permission" : "Add login permission"}
             formFields={AddLoginFormFields}
             submitLabel={props.data.updating ? "Update" : "Add"}
             {...props}
-        />
+            data={{
+                ...props.data,
+                setPartialGroupInput,
+                hasPartialGroupInput,
+            }}
+            invalid={props.invalid || hasPartialGroupInput}
+        />;
+    }
 );
 
-type CreateGroupDialogComponentProps = WithDialogProps<{updating: boolean}> & InjectedFormProps<AddLoginFormData>;
+type CreateGroupDialogComponentProps = WithDialogProps<{updating: boolean}> & GroupArrayDataProps & InjectedFormProps<AddLoginFormData>;
 
 const AddLoginFormFields = (props) => {
     return <>
@@ -42,6 +51,8 @@ const AddLoginFormFields = (props) => {
             name={VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD}
             input={{id:"Add groups to VM login (eg: docker, sudo)", disabled:false}}
             required={false}
+            setPartialGroupInput={props.data.setPartialGroupInput}
+            hasPartialGroupInput={props.data.hasPartialGroupInput}
         />
     </>;
 }
index 3ea5e77c883e2316f2d2fc7dd16a40dfd7953558..cba9af636e8eaaf22a1aa6019af47775f68e6fb5 100644 (file)
@@ -4,34 +4,58 @@
 
 import React from 'react';
 import { StringArrayCommandInputParameter } from 'models/workflow';
-import { Field } from 'redux-form';
+import { Field, GenericField } from 'redux-form';
 import { GenericInputProps } from 'views/run-process-panel/inputs/generic-input';
 import { ChipsInput } from 'components/chips-input/chips-input';
 import { identity } from 'lodash';
-import { withStyles, WithStyles, FormGroup, Input, InputLabel, FormControl } from '@material-ui/core';
+import { withStyles, WithStyles, FormGroup, Input, InputLabel, FormControl, FormHelperText } from '@material-ui/core';
+import classnames from "classnames";
+import { ArvadosTheme } from 'common/custom-theme';
 
-export interface StringArrayInputProps {
+export interface GroupArrayDataProps {
+  hasPartialGroupInput?: boolean;
+  setPartialGroupInput?: (value: boolean) => void;
+}
+
+interface GroupArrayFieldProps {
+  commandInput: StringArrayCommandInputParameter;
+}
+
+const GroupArrayField = Field as new () => GenericField<GroupArrayDataProps & GroupArrayFieldProps>;
+
+export interface GroupArrayInputProps {
   name: string;
   input: StringArrayCommandInputParameter;
   required: boolean;
 }
 
-type CssRules = 'chips';
+type CssRules = 'chips' | 'partialInputHelper' | 'partialInputHelperVisible';
 
-const styles = {
+const styles = (theme: ArvadosTheme) => ({
     chips: {
         marginTop: "16px",
     },
-};
+    partialInputHelper: {
+        textAlign: 'right' as 'right',
+        visibility: 'hidden' as 'hidden',
+        color: theme.palette.error.dark,
+    },
+    partialInputHelperVisible: {
+        visibility: 'visible' as 'visible',
+    }
+});
 
-export const GroupArrayInput = ({name, input}: StringArrayInputProps) =>
-    <Field
-        name={name}
-        commandInput={input}
-        component={StringArrayInputComponent as any}
-        />;
+export const GroupArrayInput = ({name, input, setPartialGroupInput, hasPartialGroupInput}: GroupArrayInputProps & GroupArrayDataProps) => {
+  return <GroupArrayField
+      name={name}
+      commandInput={input}
+      component={GroupArrayInputComponent as any}
+      setPartialGroupInput={setPartialGroupInput}
+      hasPartialGroupInput={hasPartialGroupInput}
+      />;
+}
 
-const StringArrayInputComponent = (props: GenericInputProps) => {
+const GroupArrayInputComponent = (props: GenericInputProps & GroupArrayDataProps) => {
   return <FormGroup>
         <FormControl fullWidth error={props.meta.error}>
           <InputLabel shrink={props.meta.active || props.input.value.length > 0}>{props.commandInput.id}</InputLabel>
@@ -41,24 +65,30 @@ const StringArrayInputComponent = (props: GenericInputProps) => {
     };
 
 const StyledInputComponent = withStyles(styles)(
-  class InputComponent extends React.PureComponent<GenericInputProps & WithStyles<CssRules>>{
+  class InputComponent extends React.PureComponent<GenericInputProps & WithStyles<CssRules> & GroupArrayDataProps>{
       render() {
           const { classes } = this.props;
-          const { commandInput, input, meta } = this.props;
-          return <ChipsInput
-              deletable={!commandInput.disabled}
-              orderable={!commandInput.disabled}
-              disabled={commandInput.disabled}
-              values={input.value}
-              onChange={this.handleChange}
-              handleFocus={input.onFocus}
-              createNewValue={identity}
-              inputComponent={Input}
-              chipsClassName={classes.chips}
-              pattern={/[_a-z][-0-9_a-z]*/ig}
-              inputProps={{
-                  error: meta.error,
-              }} />;
+          const { commandInput, input, meta, hasPartialGroupInput } = this.props;
+          return <>
+            <ChipsInput
+                deletable={!commandInput.disabled}
+                orderable={!commandInput.disabled}
+                disabled={commandInput.disabled}
+                values={input.value}
+                onChange={this.handleChange}
+                handleFocus={input.onFocus}
+                createNewValue={identity}
+                inputComponent={Input}
+                chipsClassName={classes.chips}
+                pattern={/[_a-z][-0-9_a-z]*/ig}
+                onPartialInput={this.props.setPartialGroupInput}
+                inputProps={{
+                    error: meta.error || hasPartialGroupInput,
+                }} />
+                <FormHelperText className={classnames([classes.partialInputHelper, ...(hasPartialGroupInput ? [classes.partialInputHelperVisible] : [])])}>
+                  Press enter to complete group name
+                </FormHelperText>
+          </>;
       }
 
       handleChange = (values: {}[]) => {
index ec0a9c8a588d9ab971bc2a3b00564c85b647f4b0..d654f6edab41f524a2020079f6e6b7d8841b98e5 100644 (file)
@@ -64,7 +64,7 @@ describe('WebDavS3InfoDialog', () => {
         );
 
         // then
-        expect(wrapper.text()).toContain("davs://bobby@download.example.com/by_id/zzzzz-4zz18-b1f8tbldjrm8885");
+        expect(wrapper.text()).toContain("davs://bobby@download.example.com/c=zzzzz-4zz18-b1f8tbldjrm8885");
     });
 
     it('render win/mac tab', () => {
@@ -79,7 +79,7 @@ describe('WebDavS3InfoDialog', () => {
         );
 
         // then
-        expect(wrapper.text()).toContain("https://download.example.com/by_id/zzzzz-4zz18-b1f8tbldjrm8885");
+        expect(wrapper.text()).toContain("https://download.example.com/c=zzzzz-4zz18-b1f8tbldjrm8885");
     });
 
     it('render s3 tab with federated token', () => {
index 8e9edac11accccfe710877b44c30ab4400c87e45..a32044a711ef36a70820596ceb509d5a976665e9 100644 (file)
@@ -79,7 +79,7 @@ const mountainduckTemplate = ({
             <key>Port</key>
             <string>${(cyberDavStr.split(':')[2] || '443').split('/')[0]}</string>
             <key>Username</key>
-            <string>${username}</string>${isValidIpAddress(collectionsUrl.replace('https://', ``).split(':')[0])?
+            <string>${username}</string>${isValidIpAddress(collectionsUrl.replace('https://', ``).split(':')[0]) ?
             `
             <key>Path</key>
             <string>/c=${uuid}</string>` : ''}
@@ -120,8 +120,8 @@ export const WebDavS3InfoDialog = compose(
         } else {
             winDav = new URL(props.data.downloadUrl);
             cyberDav = new URL(props.data.downloadUrl);
-            winDav.pathname = `/by_id/${props.data.uuid}`;
-            cyberDav.pathname = `/by_id/${props.data.uuid}`;
+            winDav.pathname = `/c=${props.data.uuid}`;
+            cyberDav.pathname = `/c=${props.data.uuid}`;
         }
 
         cyberDav.username = props.data.username;
@@ -279,8 +279,8 @@ export const WebDavS3InfoDialog = compose(
                     </DetailsAttribute>
 
                     <p>
-                      Note: This curl command downloads single files.
-                      Append the desired filename to the end of the URL.
+                        Note: This curl command downloads single files.
+                        Append the desired filename to the end of the URL.
                     </p>
 
                 </TabPanel>
@@ -292,7 +292,7 @@ export const WebDavS3InfoDialog = compose(
                     color='primary'
                     onClick={props.closeDialog}>
                     Close
-               </Button>
+                </Button>
             </DialogActions>
 
         </Dialog >;
index 0e08a87912b8e4cca0bfce4c61d0d8eb261e2a95..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';
-import { 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';
+    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 { 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,21 +54,21 @@ export enum AllProcessesPanelColumnNames {
     TYPE = "Type",
     OWNER = "Owner",
     CREATED_AT = "Created at",
-    RUNTIME = "Run Time"
+    RUNTIME = "Run Time",
 }
 
 export interface AllProcessesPanelFilter extends DataTableFilterItem {
     type: ResourceKind | ContainerRequestState;
 }
 
-export const allProcessesPanelColumns: DataColumns<string> = [
+export const allProcessesPanelColumns: DataColumns<string, ContainerRequestResource> = [
     {
         name: AllProcessesPanelColumnNames.NAME,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.NONE,
+        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> = [
         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,
-        sortDirection: SortDirection.DESC,
+        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 ddca138c6263024be7f737f4a5e6a784a787c915..3d415744bfe7afeea95f2b9af903d97a6f2f038c 100644 (file)
@@ -18,6 +18,7 @@ import {
     CommonUuid, TokenApiClientId, TokenApiToken, TokenCreatedByIpAddress, TokenDefaultOwnerUuid, TokenExpiresAt,
     TokenLastUsedAt, TokenLastUsedByIpAddress, TokenScopes, TokenUserId
 } from 'views-components/data-explorer/renderers';
+import { ApiClientAuthorization } from 'models/api-client-authorization';
 
 type CssRules = 'root';
 
@@ -41,12 +42,12 @@ export enum ApiClientAuthorizationPanelColumnNames {
     USER_ID = 'User ID'
 }
 
-export const apiClientAuthorizationPanelColumns: DataColumns<string> = [
+export const apiClientAuthorizationPanelColumns: DataColumns<string, ApiClientAuthorization> = [
     {
         name: ApiClientAuthorizationPanelColumnNames.UUID,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.NONE,
+        sort: {direction: SortDirection.NONE, field: "uuid"},
         filters: createTree(),
         render: uuid => <CommonUuid uuid={uuid} />
     },
index 8e8266cc22a732b05be7ce48f3308a4f6db33aa2..ea23ce51239bc29a67199e45ddc5b36e90ccf105 100644 (file)
@@ -40,13 +40,13 @@ type CssRules = 'backLink' | 'backIcon' | 'root' | 'content';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     backLink: {
-        fontSize: '14px',
+        fontSize: '12px',
         fontWeight: 600,
         display: 'flex',
         alignItems: 'center',
         padding: theme.spacing.unit,
         marginBottom: theme.spacing.unit,
-        color: theme.palette.grey["700"],
+        color: theme.palette.grey["500"],
     },
     backIcon: {
         marginRight: theme.spacing.unit
@@ -67,12 +67,12 @@ enum CollectionContentAddressPanelColumnNames {
     LAST_MODIFIED = "Last modified"
 }
 
-export const collectionContentAddressPanelColumns: DataColumns<string> = [
+export const collectionContentAddressPanelColumns: DataColumns<string, CollectionResource> = [
     {
         name: CollectionContentAddressPanelColumnNames.COLLECTION_WITH_THIS_ADDRESS,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.NONE,
+        sort: {direction: SortDirection.NONE, field: "uuid"},
         filters: createTree(),
         render: uuid => <ResourceName uuid={uuid} />
     },
@@ -94,7 +94,7 @@ export const collectionContentAddressPanelColumns: DataColumns<string> = [
         name: CollectionContentAddressPanelColumnNames.LAST_MODIFIED,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.DESC,
+        sort: {direction: SortDirection.DESC, field: "modifiedAt"},
         filters: createTree(),
         render: uuid => <ResourceLastModifiedDate uuid={uuid} />
     }
index 9d127a605cc617cd2c7367aa460a039c185e34e9..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';
@@ -36,6 +36,8 @@ import { Link } from 'react-router-dom';
 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'
@@ -50,7 +52,11 @@ type CssRules = 'root'
     | 'centeredLabel'
     | 'warningLabel'
     | 'collectionName'
-    | 'readOnlyIcon';
+    | 'readOnlyIcon'
+    | 'header'
+    | 'title'
+    | 'avatar'
+    | 'content';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
@@ -60,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,
@@ -72,14 +75,14 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     },
     iconHeader: {
         fontSize: '1.875rem',
-        color: theme.customs.colors.yellow700
+        color: theme.customs.colors.greyL
     },
     tag: {
         marginRight: theme.spacing.unit / 2,
         marginBottom: theme.spacing.unit / 2
     },
     label: {
-        fontSize: '0.875rem'
+        fontSize: '0.875rem',
     },
     centeredLabel: {
         fontSize: '0.875rem',
@@ -105,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,
+        }
     }
 });
 
@@ -128,11 +151,16 @@ 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;
                 }
             }
         }
+
+        if (item && isWritable) {
+            isWritable = !resourceIsFrozen(item, state.resources);
+        }
+
         return { item, isWritable, isOldVersion };
     })(
         class extends React.Component<CollectionPanelProps> {
@@ -146,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>
@@ -199,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>) => {
@@ -316,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 cb02f1ad0785a91728c163d234fd930887fe54dd..aa4f2c1a20a0637d1c3effbf839b6b1219e99974 100644 (file)
@@ -9,7 +9,6 @@ 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 { FAVORITE_PANEL_ID } from "store/favorite-panel/favorite-panel-action";
@@ -39,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";
 
@@ -68,12 +68,11 @@ export interface FavoritePanelFilter extends DataTableFilterItem {
     type: ResourceKind | ContainerRequestState;
 }
 
-export const favoritePanelColumns: DataColumns<string> = [
+export const favoritePanelColumns: DataColumns<string, GroupContentsResource> = [
     {
         name: FavoritePanelColumnNames.NAME,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.NONE,
         filters: createTree(),
         render: uuid => <ResourceName uuid={uuid} />
     },
@@ -109,7 +108,6 @@ export const favoritePanelColumns: DataColumns<string> = [
         name: FavoritePanelColumnNames.LAST_MODIFIED,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.DESC,
         filters: createTree(),
         render: uuid => <ResourceLastModifiedDate uuid={uuid} />
     }
@@ -174,6 +172,7 @@ export const FavoritePanel = withStyles(styles)(
             }
 
             handleRowClick = (uuid: string) => {
+                this.props.dispatch<any>(toggleOne(uuid))
                 this.props.dispatch<any>(loadDetailsPanel(uuid));
             }
 
index 311bc86e7124f6bf511372e5178177c73e79599c..fdbc204ee78237dde9e1a4125139c8ac5f5c46c5 100644 (file)
@@ -19,6 +19,7 @@ import { AddIcon, UserPanelIcon, KeyIcon } from 'components/icon/icon';
 import { getUserUuid } from 'common/getuser';
 import { GroupResource, isBuiltinGroup } from 'models/group';
 import { ArvadosTheme } from 'common/custom-theme';
+import { PermissionResource } from 'models/permission';
 
 type CssRules = "root" | "content";
 
@@ -51,7 +52,7 @@ export enum GroupDetailsPanelPermissionsColumnNames {
 const MEMBERS_DEFAULT_MESSAGE = 'Members list is empty.';
 const PERMISSIONS_DEFAULT_MESSAGE = 'Permissions list is empty.';
 
-export const groupDetailsMembersPanelColumns: DataColumns<string> = [
+export const groupDetailsMembersPanelColumns: DataColumns<string, PermissionResource> = [
     {
         name: GroupDetailsPanelMembersColumnNames.FULL_NAME,
         selected: true,
@@ -96,7 +97,7 @@ export const groupDetailsMembersPanelColumns: DataColumns<string> = [
     },
 ];
 
-export const groupDetailsPermissionsPanelColumns: DataColumns<string> = [
+export const groupDetailsPermissionsPanelColumns: DataColumns<string, PermissionResource> = [
     {
         name: GroupDetailsPanelPermissionsColumnNames.NAME,
         selected: true,
@@ -135,8 +136,8 @@ const mapStateToProps = (state: RootState) => {
     return {
         resources: state.resources,
         groupCanManage: userUuid && !isBuiltinGroup(group?.uuid || '')
-                            ? group?.writableBy?.includes(userUuid)
-                            : false,
+            ? group?.canManage
+            : false,
     };
 };
 
@@ -157,7 +158,7 @@ export const GroupDetailsPanel = withStyles(styles)(connect(
 )(
     class GroupDetailsPanel extends React.Component<GroupDetailsPanelProps & WithStyles<CssRules>> {
         state = {
-          value: 0,
+            value: 0,
         };
 
         componentDidMount() {
@@ -168,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 3251c729eee32d6df8d75a4c298d38d9bb0e8c4b..33acad50c6cab50c457c699cd38806618e264253 100644 (file)
@@ -37,12 +37,12 @@ export enum GroupsPanelColumnNames {
     MEMBERS = "Members",
 }
 
-export const groupsPanelColumns: DataColumns<string> = [
+export const groupsPanelColumns: DataColumns<string, GroupResource> = [
     {
         name: GroupsPanelColumnNames.GROUP,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.ASC,
+        sort: {direction: SortDirection.ASC, field: "name"},
         filters: createTree(),
         render: uuid => <ResourceName uuid={uuid} />
     },
index 8a7c7928933925e77462d7d8b5e4e8d0875bbbdd..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';
 
@@ -17,16 +18,8 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
         position: 'relative',
         backgroundColor: theme.palette.grey["200"],
-        '&::after': {
-            content: `''`,
-            position: 'absolute',
-            top: 0,
-            left: 0,
-            bottom: 0,
-            right: 0,
-            background: 'url("arvados-logo-big.png") no-repeat center center',
-            opacity: 0.2,
-        }
+        background: 'url("arvados-logo-big.png") no-repeat center center',
+        backgroundBlendMode: 'soft-light',
     },
     ontop: {
         zIndex: 10
@@ -59,14 +52,13 @@ export interface InactivePanelStateProps {
 
 type InactivePanelProps = WithStyles<CssRules> & InactivePanelActionProps & InactivePanelStateProps;
 
-
 export const InactivePanelRoot = ({ classes, startLinking, inactivePageText, isLoginClusterFederation }: InactivePanelProps) =>
     <Grid container justify="center" alignItems="center" direction="column" spacing={24}
         className={classes.root}
         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 c24d463700517bf1606e50e548dd56822c1344dc..f75275af2a937cad84f6371fe060bbe7950c22b4 100644 (file)
@@ -16,6 +16,7 @@ import {
 from 'views-components/data-explorer/renderers';
 import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
 import { ArvadosTheme } from 'common/custom-theme';
+import { LinkResource } from 'models/link';
 
 type CssRules = "root";
 
@@ -33,12 +34,12 @@ export enum LinkPanelColumnNames {
     UUID = "UUID"
 }
 
-export const linkPanelColumns: DataColumns<string> = [
+export const linkPanelColumns: DataColumns<string, LinkResource> = [
     {
         name: LinkPanelColumnNames.NAME,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.NONE,
+        sort: {direction: SortDirection.NONE, field: "name"},
         filters: createTree(),
         render: uuid => <ResourceLinkName uuid={uuid} />
     },
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 5853acb065b97acf18ea9061b5dabad77fc1bb50..e5514d8ef687dcbc41e4321d86fdd068a5f21a90 100644 (file)
@@ -32,31 +32,40 @@ export interface MainPanelRootDataProps {
     isLinkingPath: boolean;
     siteBanner: string;
     sessionIdleTimeout: number;
+    sidePanelIsCollapsed: boolean;
 }
 
-type MainPanelRootProps = MainPanelRootDataProps & WithStyles<CssRules>;
+interface MainPanelRootDispatchProps {
+    toggleSidePanel: () => void
+}
+
+type MainPanelRootProps = MainPanelRootDataProps & MainPanelRootDispatchProps & WithStyles<CssRules>;
 
 export const MainPanelRoot = withStyles(styles)(
     ({ classes, loading, working, user, buildInfo, uuidPrefix,
-        isNotLinking, isLinkingPath, siteBanner, sessionIdleTimeout }: MainPanelRootProps) =>
-        loading
+        isNotLinking, isLinkingPath, siteBanner, sessionIdleTimeout, 
+        sidePanelIsCollapsed, toggleSidePanel }: MainPanelRootProps) =>{
+        return loading
             ? <WorkbenchLoadingScreen />
             : <>
-                {isNotLinking && <MainAppBar
-                    user={user}
-                    buildInfo={buildInfo}
-                    uuidPrefix={uuidPrefix}
-                    siteBanner={siteBanner}>
-                    {working
-                        ? <LinearProgress color="secondary" data-cy="linear-progress" />
-                        : null}
-                </MainAppBar>}
-                <Grid container direction="column" className={classes.root}>
-                    {user
-                        ? (user.isActive || (!user.isActive && isLinkingPath)
-                            ? <WorkbenchPanel isNotLinking={isNotLinking} isUserActive={user.isActive} sessionIdleTimeout={sessionIdleTimeout} />
-                            : <InactivePanel />)
-                        : <LoginPanel />}
-                </Grid>
-            </>
+            {isNotLinking && <MainAppBar
+                user={user}
+                buildInfo={buildInfo}
+                uuidPrefix={uuidPrefix}
+                siteBanner={siteBanner}
+                sidePanelIsCollapsed={sidePanelIsCollapsed}
+                >
+                {working
+                    ? <LinearProgress color="secondary" data-cy="linear-progress" />
+                    : null}
+            </MainAppBar>}
+            <Grid container direction="column" className={classes.root}>
+                {user
+                    ? (user.isActive || (!user.isActive && isLinkingPath)
+                    ? <WorkbenchPanel isNotLinking={isNotLinking} isUserActive={user.isActive} sessionIdleTimeout={sessionIdleTimeout} sidePanelIsCollapsed={sidePanelIsCollapsed}/>
+                    : <InactivePanel />)
+                    : <LoginPanel />}
+            </Grid>
+        </>
+    }
 );
index 2968499df1cef23364329e8fbb1badf4a1ec780a..fac3da67150f37cacfaf7a6a711c94361dea326e 100644 (file)
@@ -10,6 +10,7 @@ import { isSystemWorking } from 'store/progress-indicator/progress-indicator-red
 import { isWorkbenchLoading } from 'store/workbench/workbench-actions';
 import { LinkAccountPanelStatus } from 'store/link-account-panel/link-account-panel-reducer';
 import { matchLinkAccountRoute } from 'routes/routes';
+import { toggleSidePanel } from "store/side-panel/side-panel-action";
 
 const mapStateToProps = (state: RootState): MainPanelRootDataProps => {
     return {
@@ -21,10 +22,17 @@ const mapStateToProps = (state: RootState): MainPanelRootDataProps => {
         isNotLinking: state.linkAccountPanel.status === LinkAccountPanelStatus.NONE || state.linkAccountPanel.status === LinkAccountPanelStatus.INITIAL,
         isLinkingPath: state.router.location ? matchLinkAccountRoute(state.router.location.pathname) !== null : false,
         siteBanner: state.auth.config.clusterConfig.Workbench.SiteName,
-        sessionIdleTimeout: parse(state.auth.config.clusterConfig.Workbench.IdleTimeout, 's') || 0
+        sessionIdleTimeout: parse(state.auth.config.clusterConfig.Workbench.IdleTimeout, 's') || 0,
+        sidePanelIsCollapsed: state.sidePanel.collapsedState,
     };
 };
 
-const mapDispatchToProps = null;
+const mapDispatchToProps = (dispatch) => {
+    return {
+        toggleSidePanel: (collapsedState)=>{
+            return dispatch(toggleSidePanel(collapsedState))
+        }
+    }
+};
 
 export const MainPanel = connect(mapStateToProps, mapDispatchToProps)(MainPanelRoot);
index 06a74a6882d5f7f390d058652b7647cfbcd6230b..e5d8507b5cfce976b734442a29c3667ff10d5794 100644 (file)
@@ -23,7 +23,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         fontSize: '18px'
     },
     active: {
-        color: theme.customs.colors.green700,
+        color: theme.customs.colors.grey700,
         textDecoration: 'none',
     }
 });
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 4143501e23f495e99d97f1557f0ae48de5e4a689..d8368449cbad9902f818d379c0270d781b19070d 100644 (file)
@@ -35,7 +35,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     },
     iconHeader: {
         fontSize: '1.875rem',
-        color: theme.customs.colors.green700,
+        color: theme.customs.colors.greyL,
     },
     avatar: {
         alignSelf: 'flex-start',
@@ -50,7 +50,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     },
     title: {
         overflow: 'hidden',
-        paddingTop: theme.spacing.unit * 0.5
+        paddingTop: theme.spacing.unit * 0.5,
+        color: theme.customs.colors.greyD,
+        fontSize: '1.875rem'  
     },
 });
 
index 4af2c9cda75392cfd45f8707206913a5d56e3d95..1f3a73a510f97c0e7f903a2bbe5c20b2feca44a0 100644 (file)
@@ -5,21 +5,24 @@
 import React from "react";
 import { Grid, StyleRulesCallback, withStyles } from "@material-ui/core";
 import { Dispatch } from 'redux';
-import { formatDate } from "common/formatters";
+import { formatContainerCost, formatDate } from "common/formatters";
 import { resourceLabel } from "common/labels";
 import { DetailsAttribute } from "components/details-attribute/details-attribute";
 import { ResourceKind } from "models/resource";
-import { ContainerRunTime, ResourceWithName } from "views-components/data-explorer/renderers";
+import { CollectionName, ContainerRunTime, ResourceWithName } from "views-components/data-explorer/renderers";
 import { getProcess, getProcessStatus } from "store/processes/process";
 import { RootState } from "store/store";
 import { connect } from "react-redux";
-import { ProcessResource } from "models/process";
+import { ProcessResource, MOUNT_PATH_CWL_WORKFLOW } from "models/process";
 import { ContainerResource } from "models/container";
-import { openProcessInputDialog } from "store/processes/process-input-actions";
 import { navigateToOutput, openWorkflow } from "store/process-panel/process-panel-actions";
 import { ArvadosTheme } from "common/custom-theme";
 import { ProcessRuntimeStatus } from "views-components/process-runtime-status/process-runtime-status";
 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';
 
@@ -38,30 +41,67 @@ 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: getProcess(props.request.uuid)(state.resources)?.container,
+        container: process?.container,
+        workflowCollection,
+        workflowPath,
+        subprocesses: filterResources((resource: ContainerRequestResource) =>
+            resource.kind === ResourceKind.CONTAINER_REQUEST &&
+            resource.requestingContainerUuid === process?.containerRequest.containerUuid
+        )(state.resources),
     };
 };
 
 interface ProcessDetailsAttributesActionProps {
-    openProcessInputDialog: (uuid: string) => void;
-    navigateToOutput: (uuid: string) => void;
+    navigateToOutput: (resource: ContainerRequestResource) => void;
     openWorkflow: (uuid: string) => void;
 }
 
 const mapDispatchToProps = (dispatch: Dispatch): ProcessDetailsAttributesActionProps => ({
-    openProcessInputDialog: (uuid) => dispatch<any>(openProcessInputDialog(uuid)),
-    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, 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'));
+            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} />
@@ -70,10 +110,10 @@ export const ProcessDetailsAttributes = withStyles(styles, { withTheme: true })(
                     <DetailsAttribute label='Type' value={resourceLabel(ResourceKind.PROCESS)} />
                 </Grid>}
                 <Grid item xs={12} md={mdSize}>
-                    <DetailsAttribute label='Container Request UUID' linkToUuid={containerRequest.uuid} value={containerRequest.uuid} />
+                    <DetailsAttribute label='Container request UUID' linkToUuid={containerRequest.uuid} value={containerRequest.uuid} />
                 </Grid>
                 <Grid item xs={12} md={mdSize}>
-                    <DetailsAttribute label='Docker Image locator'
+                    <DetailsAttribute label='Docker image locator'
                         linkToUuid={containerRequest.containerImage} value={containerRequest.containerImage} />
                 </Grid>
                 <Grid item xs={12} md={mdSize}>
@@ -101,17 +141,34 @@ export const ProcessDetailsAttributes = withStyles(styles, { withTheme: true })(
                         <ContainerRunTime uuid={containerRequest.uuid} />
                     </DetailsAttribute>
                 </Grid>
+                {(containerRequest && containerRequest.modifiedByUserUuid) && <Grid item xs={12} md={mdSize} data-cy="process-details-attributes-modifiedby-user">
+                    <DetailsAttribute
+                        label='Submitted by' linkToUuid={containerRequest.modifiedByUserUuid}
+                        uuidEnhancer={(uuid: string) => <ResourceWithName uuid={uuid} />} />
+                </Grid>}
+                {(container && container.runtimeUserUuid && container.runtimeUserUuid !== containerRequest.modifiedByUserUuid) && <Grid item xs={12} md={mdSize} data-cy="process-details-attributes-runtime-user">
+                    <DetailsAttribute
+                        label='Run as' linkToUuid={container.runtimeUserUuid}
+                        uuidEnhancer={(uuid: string) => <ResourceWithName uuid={uuid} />} />
+                </Grid>}
                 <Grid item xs={12} md={mdSize}>
-                    <DetailsAttribute label='Requesting Container UUID' value={containerRequest.requestingContainerUuid || "(none)"} />
+                    <DetailsAttribute label='Requesting container UUID' value={containerRequest.requestingContainerUuid || "(none)"} />
                 </Grid>
                 <Grid item xs={6}>
-                    <span onClick={() => props.navigateToOutput(containerRequest.outputUuid!)}>
-                        <DetailsAttribute classLabel={classes.link} label='Outputs' />
-                    </span>
-                    <span onClick={() => props.openProcessInputDialog(containerRequest.uuid)}>
-                        <DetailsAttribute classLabel={classes.link} label='Inputs' />
-                    </span>
+                    <DetailsAttribute label='Output collection' />
+                    {containerRequest.outputUuid && <span onClick={() => props.navigateToOutput(containerRequest!)}>
+                        <CollectionName className={classes.link} uuid={containerRequest.outputUuid} />
+                    </span>}
                 </Grid>
+                {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}>
                         <span onClick={() => props.openWorkflow(containerRequest.properties.template_uuid)}>
@@ -123,13 +180,13 @@ 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' />
-                    {Object.keys(containerRequest.properties).length > 0
-                        ? Object.keys(containerRequest.properties).map(k =>
+                    {filteredPropertyKeys.length > 0
+                        ? filteredPropertyKeys.map(k =>
                             Array.isArray(containerRequest.properties[k])
                                 ? containerRequest.properties[k].map((v: string) =>
                                     getPropertyChip(k, v, undefined, classes.propertyTag))
index da6438a1b83fab34d76e096b8a18a93ddfc0d274..37f01dd70163c2a51c9a5c08220dada138f853ba 100644 (file)
@@ -13,16 +13,17 @@ import {
     CardContent,
     Tooltip,
     Typography,
+    Button,
 } from '@material-ui/core';
 import { ArvadosTheme } from 'common/custom-theme';
-import { CloseIcon, MoreOptionsIcon, ProcessIcon } from 'components/icon/icon';
-import { Process } from 'store/processes/process';
+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';
 import { ProcessStatus } from 'views-components/data-explorer/renderers';
-import { ContainerState } from 'models/container';
+import classNames from 'classnames';
 
-type CssRules = 'card' | 'content' | 'title' | 'header' | 'cancelButton' | 'avatar' | 'iconHeader';
+type CssRules = 'card' | 'content' | 'title' | 'header' | 'cancelButton' | 'avatar' | 'iconHeader' | 'actionButton';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     card: {
@@ -34,7 +35,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     },
     iconHeader: {
         fontSize: '1.875rem',
-        color: theme.customs.colors.green700,
+        color: theme.customs.colors.greyL,
     },
     avatar: {
         alignSelf: 'flex-start',
@@ -49,28 +50,45 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     },
     title: {
         overflow: 'hidden',
-        paddingTop: theme.spacing.unit * 0.5
+        paddingTop: theme.spacing.unit * 0.5,
+        color: theme.customs.colors.green700,
+    },
+    actionButton: {
+        padding: "0px 5px 0 0",
+        marginRight: "5px",
+        fontSize: '0.78rem',
     },
     cancelButton: {
-        paddingRight: theme.spacing.unit * 2,
-        fontSize: '14px',
-        color: theme.customs.colors.red900,
-        "&:hover": {
-            cursor: 'pointer'
-        }
+        color: theme.palette.common.white,
+        backgroundColor: theme.customs.colors.red900,
+        '&:hover': {
+            backgroundColor: theme.customs.colors.red900,
+        },
+        '& svg': {
+            fontSize: '22px',
+        },
     },
 });
 
 export interface ProcessDetailsCardDataProps {
     process: Process;
     cancelProcess: (uuid: string) => void;
+    startProcess: (uuid: string) => void;
+    resumeOnHoldWorkflow: (uuid: string) => void;
     onContextMenu: (event: React.MouseEvent<HTMLElement>) => void;
 }
 
 type ProcessDetailsCardProps = ProcessDetailsCardDataProps & WithStyles<CssRules> & MPVPanelProps;
 
 export const ProcessDetailsCard = withStyles(styles)(
-    ({ cancelProcess, onContextMenu, classes, process, doHidePanel, panelName }: ProcessDetailsCardProps) => {
+    ({ cancelProcess, startProcess, resumeOnHoldWorkflow, onContextMenu, classes, process, doHidePanel, panelName }: ProcessDetailsCardProps) => {
+        let runAction: ((uuid: string) => void) | undefined = undefined;
+        if (isProcessRunnable(process)) {
+            runAction = startProcess;
+        } else if (isProcessResumable(process)) {
+            runAction = resumeOnHoldWorkflow;
+        }
+
         return <Card className={classes.card}>
             <CardHeader
                 className={classes.header}
@@ -81,7 +99,7 @@ export const ProcessDetailsCard = withStyles(styles)(
                 avatar={<ProcessIcon className={classes.iconHeader} />}
                 title={
                     <Tooltip title={process.containerRequest.name} placement="bottom-start">
-                        <Typography noWrap variant='h6' color='inherit'>
+                        <Typography noWrap variant='h6'>
                             {process.containerRequest.name}
                         </Typography>
                     </Tooltip>
@@ -94,20 +112,40 @@ export const ProcessDetailsCard = withStyles(styles)(
                     </Tooltip>}
                 action={
                     <div>
-                        {process.container && process.container.state === ContainerState.RUNNING &&
-                            <span className={classes.cancelButton} onClick={() => cancelProcess(process.containerRequest.uuid)}>Cancel</span>}
+                        {runAction !== undefined &&
+                            <Button
+                                data-cy="process-run-button"
+                                variant="contained"
+                                size="small"
+                                color="primary"
+                                className={classes.actionButton}
+                                onClick={() => runAction && runAction(process.containerRequest.uuid)}>
+                                <StartIcon />
+                                Run
+                            </Button>}
+                        {isProcessCancelable(process) &&
+                            <Button
+                                data-cy="process-cancel-button"
+                                variant="contained"
+                                size="small"
+                                color="primary"
+                                className={classNames(classes.actionButton, classes.cancelButton)}
+                                onClick={() => cancelProcess(process.containerRequest.uuid)}>
+                                <StopIcon />
+                                Cancel
+                            </Button>}
                         <ProcessStatus uuid={process.containerRequest.uuid} />
                         <Tooltip title="More options" disableFocusListener>
                             <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}>
diff --git a/src/views/process-panel/process-io-card.tsx b/src/views/process-panel/process-io-card.tsx
new file mode 100644 (file)
index 0000000..b5afbf6
--- /dev/null
@@ -0,0 +1,945 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { ReactElement, memo, useState } from "react";
+import { Dispatch } from "redux";
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    Card,
+    CardHeader,
+    IconButton,
+    CardContent,
+    Tooltip,
+    Typography,
+    Tabs,
+    Tab,
+    Table,
+    TableHead,
+    TableBody,
+    TableRow,
+    TableCell,
+    Paper,
+    Grid,
+    Chip,
+    CircularProgress,
+} from "@material-ui/core";
+import { ArvadosTheme } from "common/custom-theme";
+import { CloseIcon, ImageIcon, InputIcon, ImageOffIcon, OutputIcon, MaximizeIcon, 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,
+    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";
+
+type CssRules =
+    | "card"
+    | "content"
+    | "title"
+    | "header"
+    | "avatar"
+    | "iconHeader"
+    | "tableWrapper"
+    | "tableRoot"
+    | "paramValue"
+    | "keepLink"
+    | "collectionLink"
+    | "imagePreview"
+    | "valArray"
+    | "secondaryVal"
+    | "secondaryRow"
+    | "emptyValue"
+    | "noBorderRow"
+    | "symmetricTabs"
+    | "imagePlaceholder"
+    | "rowWithPreview"
+    | "labelColumn";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    card: {
+        height: "100%",
+    },
+    header: {
+        paddingTop: theme.spacing.unit,
+        paddingBottom: 0,
+    },
+    iconHeader: {
+        fontSize: "1.875rem",
+        color: theme.customs.colors.greyL,
+    },
+    avatar: {
+        alignSelf: "flex-start",
+        paddingTop: theme.spacing.unit * 0.5,
+    },
+    content: {
+        height: `calc(100% - ${theme.spacing.unit * 7}px - ${theme.spacing.unit * 1.5}px)`,
+        padding: theme.spacing.unit * 1.0,
+        paddingTop: 0,
+        "&:last-child": {
+            paddingBottom: theme.spacing.unit * 1,
+        },
+    },
+    title: {
+        overflow: "hidden",
+        paddingTop: theme.spacing.unit * 0.5,
+        color: theme.customs.colors.greyD,
+        fontSize: "1.875rem",
+    },
+    tableWrapper: {
+        height: "auto",
+        maxHeight: `calc(100% - ${theme.spacing.unit * 4.5}px)`,
+        overflow: "auto",
+    },
+    tableRoot: {
+        width: "100%",
+        "& thead th": {
+            verticalAlign: "bottom",
+            paddingBottom: "10px",
+        },
+        "& td, & th": {
+            paddingRight: "25px",
+        },
+    },
+    paramValue: {
+        display: "flex",
+        alignItems: "flex-start",
+        flexDirection: "column",
+    },
+    keepLink: {
+        color: theme.palette.primary.main,
+        textDecoration: "none",
+        overflowWrap: "break-word",
+        cursor: "pointer",
+    },
+    collectionLink: {
+        margin: "10px",
+        "& a": {
+            color: theme.palette.primary.main,
+            textDecoration: "none",
+            overflowWrap: "break-word",
+            cursor: "pointer",
+        },
+    },
+    imagePreview: {
+        maxHeight: "15em",
+        maxWidth: "15em",
+        marginBottom: theme.spacing.unit,
+    },
+    valArray: {
+        display: "flex",
+        gap: "10px",
+        flexWrap: "wrap",
+        "& span": {
+            display: "inline",
+        },
+    },
+    secondaryVal: {
+        paddingLeft: "20px",
+    },
+    secondaryRow: {
+        height: "29px",
+        verticalAlign: "top",
+        position: "relative",
+        top: "-9px",
+    },
+    emptyValue: {
+        color: theme.customs.colors.grey700,
+    },
+    noBorderRow: {
+        "& td": {
+            borderBottom: "none",
+        },
+    },
+    symmetricTabs: {
+        "& button": {
+            flexBasis: "0",
+        },
+    },
+    imagePlaceholder: {
+        width: "60px",
+        height: "60px",
+        display: "flex",
+        alignItems: "center",
+        justifyContent: "center",
+        backgroundColor: "#cecece",
+        borderRadius: "10px",
+    },
+    rowWithPreview: {
+        verticalAlign: "bottom",
+    },
+    labelColumn: {
+        minWidth: "120px",
+    },
+});
+
+export enum ProcessIOCardType {
+    INPUT = "Inputs",
+    OUTPUT = "Outputs",
+}
+export interface ProcessIOCardDataProps {
+    process?: Process;
+    label: ProcessIOCardType;
+    params: ProcessIOParameter[] | null;
+    raw: any;
+    mounts?: InputCollectionMount[];
+    outputUuid?: string;
+    showParams?: boolean;
+}
+
+export interface ProcessIOCardActionProps {
+    navigateTo: (uuid: string) => void;
+}
+
+const mapDispatchToProps = (dispatch: Dispatch): ProcessIOCardActionProps => ({
+    navigateTo: uuid => dispatch<any>(navigateTo(uuid)),
+});
+
+type ProcessIOCardProps = ProcessIOCardDataProps & ProcessIOCardActionProps & WithStyles<CssRules> & MPVPanelProps;
+
+export const ProcessIOCard = withStyles(styles)(
+    connect(
+        null,
+        mapDispatchToProps
+    )(
+        ({
+            classes,
+            label,
+            params,
+            raw,
+            mounts,
+            outputUuid,
+            doHidePanel,
+            doMaximizePanel,
+            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 ? (
+                            <>
+                                {/* 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
+                            <>
+                                {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 = 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>{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>
+));
+
+interface ProcessIORawDataProps {
+    data: ProcessIOParameter[];
+}
+
+const ProcessIORaw = withStyles(styles)(({ data }: ProcessIORawDataProps) => (
+    <Paper elevation={0}>
+        <DefaultCodeSnippet
+            lines={[JSON.stringify(data, null, 2)]}
+            linked
+        />
+    </Paper>
+));
+
+interface ProcessInputMountsDataProps {
+    mounts: InputCollectionMount[];
+}
+
+type ProcessInputMountsProps = ProcessInputMountsDataProps & WithStyles<CssRules>;
+
+const ProcessInputMounts = withStyles(styles)(
+    connect((state: RootState) => ({
+        auth: state.auth,
+    }))(({ mounts, classes, auth }: ProcessInputMountsProps & { auth: AuthState }) => (
+        <Table
+            className={classes.tableRoot}
+            aria-label="Process Input Mounts"
+        >
+            <TableHead>
+                <TableRow>
+                    <TableCell>Path</TableCell>
+                    <TableCell>Portable Data Hash</TableCell>
+                </TableRow>
+            </TableHead>
+            <TableBody>
+                {mounts.map(mount => (
+                    <TableRow key={mount.path}>
+                        <TableCell>
+                            <pre>{mount.path}</pre>
+                        </TableCell>
+                        <TableCell>
+                            <RouterLink
+                                to={getNavUrl(mount.pdh, auth)}
+                                className={classes.keepLink}
+                            >
+                                {mount.pdh}
+                            </RouterLink>
+                        </TableCell>
+                    </TableRow>
+                ))}
+            </TableBody>
+        </Table>
+    ))
+);
+
+type FileWithSecondaryFiles = {
+    secondaryFiles: File[];
+};
+
+export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParameter | CommandOutputParameter, pdh?: string): ProcessIOValue[] => {
+    switch (true) {
+        case isPrimitiveOfType(input, CWLType.BOOLEAN):
+            const boolValue = (input as BooleanCommandInputParameter).value;
+            return boolValue !== undefined && !(Array.isArray(boolValue) && boolValue.length === 0)
+                ? [{ display: 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 /> }];
+
+        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 /> }];
+
+        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 /> }];
+
+        case isPrimitiveOfType(input, CWLType.FILE):
+            const mainFile = (input as FileCommandInputParameter).value;
+            // secondaryFiles: File[] is not part of CommandOutputParameter so we cast to access secondaryFiles
+            const secondaryFiles = (mainFile as unknown as FileWithSecondaryFiles)?.secondaryFiles || [];
+            const files = [...(mainFile && !(Array.isArray(mainFile) && mainFile.length === 0) ? [mainFile] : []), ...secondaryFiles];
+            const mainFilePdhUrl = mainFile ? getResourcePdhUrl(mainFile, pdh) : "";
+            return files.length
+                ? files.map((file, i) => fileToProcessIOValue(file, i > 0, auth, pdh, i > 0 ? mainFilePdhUrl : ""))
+                : [{ display: <EmptyValue /> }];
+
+        case isPrimitiveOfType(input, CWLType.DIRECTORY):
+            const directory = (input as DirectoryCommandInputParameter).value;
+            return directory !== undefined && !(Array.isArray(directory) && directory.length === 0)
+                ? [directoryToProcessIOValue(directory, auth, pdh)]
+                : [{ display: <EmptyValue /> }];
+
+        case getEnumType(input) !== null:
+            const enumValue = (input as EnumCommandInputParameter).value;
+            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 /> }];
+
+        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 /> }];
+
+        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 /> }];
+
+        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 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
+                    ...(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 /> }];
+
+        case isArrayOfType(input, CWLType.DIRECTORY):
+            const directories = (input as DirectoryArrayCommandInputParameter).value || [];
+            return directories.length ? directories.map(directory => directoryToProcessIOValue(directory, auth, pdh)) : [{ display: <EmptyValue /> }];
+
+        default:
+            return [{ display: <UnsupportedValue /> }];
+    }
+};
+
+const renderPrimitiveValue = (value: any, asChip: boolean) => {
+    const isObject = typeof value === "object";
+    if (!isObject) {
+        return asChip ? (
+            <Chip
+                key={value}
+                label={String(value)}
+            />
+        ) : (
+            <pre key={value}>{String(value)}</pre>
+        );
+    } else {
+        return asChip ? <UnsupportedValueChip /> : <UnsupportedValue />;
+    }
+};
+
+/*
+ * @returns keep url without keep: prefix
+ */
+const getKeepUrl = (file: File | Directory, pdh?: string): string => {
+    const isKeepUrl = file.location?.startsWith("keep:") || false;
+    const keepUrl = isKeepUrl ? file.location?.replace("keep:", "") : pdh ? `${pdh}/${file.location}` : file.location;
+    return keepUrl || "";
+};
+
+interface KeepUrlProps {
+    auth: AuthState;
+    res: File | Directory;
+    pdh?: string;
+}
+
+const getResourcePdhUrl = (res: File | Directory, pdh?: string): string => {
+    const keepUrl = getKeepUrl(res, pdh);
+    return keepUrl ? keepUrl.split("/").slice(0, 1)[0] : "";
+};
+
+const KeepUrlBase = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles<CssRules>) => {
+    const pdhUrl = getResourcePdhUrl(res, pdh);
+    // Passing a pdh always returns a relative wb2 collection url
+    const pdhWbPath = getNavUrl(pdhUrl, auth);
+    return pdhUrl && pdhWbPath ? (
+        <Tooltip title={"View collection in Workbench"}>
+            <RouterLink
+                to={pdhWbPath}
+                className={classes.keepLink}
+            >
+                {pdhUrl}
+            </RouterLink>
+        </Tooltip>
+    ) : (
+        <></>
+    );
+});
+
+const KeepUrlPath = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles<CssRules>) => {
+    const keepUrl = getKeepUrl(res, pdh);
+    const keepUrlParts = keepUrl ? keepUrl.split("/") : [];
+    const keepUrlPath = keepUrlParts.length > 1 ? keepUrlParts.slice(1).join("/") : "";
+
+    const keepUrlPathNav = getKeepNavUrl(auth, res, pdh);
+    return keepUrlPathNav ? (
+        <Tooltip title={"View in keep-web"}>
+            <a
+                className={classes.keepLink}
+                href={keepUrlPathNav}
+                target="_blank"
+                rel="noopener noreferrer"
+            >
+                {keepUrlPath || "/"}
+            </a>
+        </Tooltip>
+    ) : (
+        <EmptyValue />
+    );
+});
+
+const getKeepNavUrl = (auth: AuthState, file: File | Directory, pdh?: string): string => {
+    let keepUrl = getKeepUrl(file, pdh);
+    return getInlineFileUrl(
+        `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`,
+        auth.config.keepWebServiceUrl,
+        auth.config.keepWebInlineServiceUrl
+    );
+};
+
+const getImageUrl = (auth: AuthState, file: File, pdh?: string): string => {
+    const keepUrl = getKeepUrl(file, pdh);
+    return getInlineFileUrl(
+        `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`,
+        auth.config.keepWebServiceUrl,
+        auth.config.keepWebInlineServiceUrl
+    );
+};
+
+const isFileImage = (basename?: string): boolean => {
+    return basename ? (mime.getType(basename) || "").startsWith("image/") : false;
+};
+
+const isFileUrl = (location?: string): boolean =>
+    !!location && !KEEP_URL_REGEX.exec(location) && (location.startsWith("http://") || location.startsWith("https://"));
+
+const normalizeDirectoryLocation = (directory: Directory): Directory => {
+    if (!directory.location) {
+        return directory;
+    }
+    return {
+        ...directory,
+        location: (directory.location || "").endsWith("/") ? directory.location : directory.location + "/",
+    };
+};
+
+const directoryToProcessIOValue = (directory: Directory, auth: AuthState, pdh?: string): ProcessIOValue => {
+    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}
+            />
+        ),
+    };
+};
+
+const fileToProcessIOValue = (file: File, secondary: boolean, auth: AuthState, pdh: string | undefined, mainFilePdh: string): ProcessIOValue => {
+    if (isExternalValue(file)) {
+        return { display: <UnsupportedValue /> };
+    }
+
+    if (isFileUrl(file.location)) {
+        return {
+            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}
+            />
+        ),
+        secondary,
+        imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
+        collection:
+            resourcePdh !== mainFilePdh ? (
+                <KeepUrlBase
+                    auth={auth}
+                    res={file}
+                    pdh={pdh}
+                />
+            ) : (
+                <></>
+            ),
+    };
+};
+
+const isExternalValue = (val: any) => Object.keys(val).includes("$import") || Object.keys(val).includes("$include");
+
+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 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>
+));
index 936b31a5497612999aab5a340ab33cf8bd7b04f4..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,
@@ -22,12 +23,13 @@ import {
     CopyIcon,
     LogIcon,
     MaximizeIcon,
+    UnMaximizeIcon,
     TextDecreaseIcon,
     TextIncreaseIcon,
     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,
@@ -54,17 +56,19 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     },
     logViewer: {
         height: '100%',
+        overflowY: 'scroll', // Required for MacOS's Safari -- See #19687
     },
     logViewerContainer: {
         height: '100%',
     },
     title: {
         overflow: 'hidden',
-        paddingTop: theme.spacing.unit * 0.5
+        paddingTop: theme.spacing.unit * 0.5,
+        color: theme.customs.colors.greyD
     },
     iconHeader: {
         fontSize: '1.875rem',
-        color: theme.customs.colors.green700
+        color: theme.customs.colors.greyL
     },
     root: {
         height: '100%',
@@ -81,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
@@ -91,13 +96,17 @@ type ProcessLogsCardProps = ProcessLogsCardDataProps
 
 export const ProcessLogsCard = withStyles(styles)(
     ({ classes, process, filters, selectedFilter, lines,
-        onLogFilterChange, navigateToLog, onCopy,
-        doHidePanel, doMaximizePanel, panelMaximized, panelName }: ProcessLogsCardProps) => {
+        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}
@@ -144,15 +153,18 @@ export const ProcessLogsCard = withStyles(styles)(
                                 </IconButton>
                             </Tooltip>
                         </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 && <Grid item>
-                            <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
-                                <IconButton onClick={doHidePanel}><CloseIcon /></IconButton>
-                            </Tooltip>
-                        </Grid> }
+                        { doHidePanel &&
+                        <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
+                            <IconButton disabled={panelMaximized} onClick={doHidePanel}><CloseIcon /></IconButton>
+                        </Tooltip> }
                     </Grid>}
                     title={
                         <Typography noWrap variant='h6' className={classes.title}>
@@ -178,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
+    }));
diff --git a/src/views/process-panel/process-output-collection-files.ts b/src/views/process-panel/process-output-collection-files.ts
new file mode 100644 (file)
index 0000000..d0b44cd
--- /dev/null
@@ -0,0 +1,58 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import {
+    CollectionPanelFiles as Component,
+    CollectionPanelFilesProps
+} from "components/collection-panel-files/collection-panel-files";
+import { Dispatch } from "redux";
+import { collectionPanelFilesAction } from "store/collection-panel/collection-panel-files/collection-panel-files-actions";
+import { ContextMenuKind } from "views-components/context-menu/context-menu";
+import { openContextMenu, openCollectionFilesContextMenu } from 'store/context-menu/context-menu-actions';
+import { openUploadCollectionFilesDialog } from 'store/collections/collection-upload-actions';
+import { ResourceKind } from "models/resource";
+import { openDetailsPanel } from 'store/details-panel/details-panel-action';
+
+const mapDispatchToProps = (dispatch: Dispatch): Pick<CollectionPanelFilesProps, 'onSearchChange' | 'onFileClick' | 'onUploadDataClick' | 'onCollapseToggle' | 'onSelectionToggle' | 'onItemMenuOpen' | 'onOptionsMenuOpen'> => ({
+    onUploadDataClick: (targetLocation?: string) => {
+        dispatch<any>(openUploadCollectionFilesDialog(targetLocation));
+    },
+    onCollapseToggle: (id) => {
+        dispatch(collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_COLLAPSE({ id }));
+    },
+    onSelectionToggle: (event, item) => {
+        dispatch(collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: item.id }));
+    },
+    onItemMenuOpen: (event, item, isWritable) => {
+        const isDirectory = item.data.type === 'directory';
+        dispatch<any>(openContextMenu(
+            event,
+            {
+                menuKind: isWritable
+                    ? isDirectory
+                        ? ContextMenuKind.COLLECTION_DIRECTORY_ITEM
+                        : ContextMenuKind.COLLECTION_FILE_ITEM
+                    : isDirectory
+                        ? ContextMenuKind.READONLY_COLLECTION_DIRECTORY_ITEM
+                        : ContextMenuKind.READONLY_COLLECTION_FILE_ITEM,
+                kind: ResourceKind.COLLECTION,
+                name: item.data.name,
+                uuid: item.id,
+                ownerUuid: ''
+            }
+        ));
+    },
+    onSearchChange: (searchValue: string) => {
+        dispatch(collectionPanelFilesAction.ON_SEARCH_CHANGE(searchValue));
+    },
+    onOptionsMenuOpen: (event, isWritable) => {
+        dispatch<any>(openCollectionFilesContextMenu(event, isWritable));
+    },
+    onFileClick: (id) => {
+        dispatch<any>(openDetailsPanel(id));
+    },
+});
+
+export const ProcessOutputCollectionFiles = connect(null, mapDispatchToProps)(Component);
index f8ff84304dcb3fb4acc7554ef0f26882ef9cc6d6..c972c0a6cf9ebf130463c72b39ee69b750970945 100644 (file)
@@ -2,26 +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 { 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 { ProcessCmdCard } from './process-cmd-card';
+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%",
     },
 });
 
@@ -30,67 +38,181 @@ export interface ProcessPanelRootDataProps {
     subprocesses: Array<Process>;
     filters: Array<SubprocessFilterDataProps>;
     processLogsPanel: ProcessLogsPanel;
+    auth: AuthState;
+    inputRaw: WorkflowInputsData | null;
+    inputParams: ProcessIOParameter[] | null;
+    outputRaw: OutputDetails | null;
+    outputDefinitions: CommandOutputParameter[];
+    outputParams: ProcessIOParameter[] | null;
+    nodeInfo: NodeInstanceType | null;
 }
 
 export interface ProcessPanelRootActionProps {
     onContextMenu: (event: React.MouseEvent<HTMLElement>, process: Process) => void;
     onToggle: (status: string) => void;
     cancelProcess: (uuid: string) => void;
+    startProcess: (uuid: string) => void;
+    resumeOnHoldWorkflow: (uuid: string) => void;
     onLogFilterChange: (filter: FilterOption) => void;
     navigateToLog: (uuid: string) => void;
     onCopyToClipboard: (uuid: string) => void;
+    loadInputs: (containerRequest: ContainerRequestResource) => void;
+    loadOutputs: (containerRequest: ContainerRequestResource) => void;
+    loadNodeJson: (containerRequest: ContainerRequestResource) => void;
+    loadOutputDefinitions: (containerRequest: ContainerRequestResource) => void;
+    updateOutputParams: () => void;
+    pollProcessLogs: (processUuid: string) => Promise<void>;
 }
 
 export type ProcessPanelRootProps = ProcessPanelRootDataProps & ProcessPanelRootActionProps & WithStyles<CssRules>;
 
 const panelsData: MPVPanelState[] = [
-    {name: "Details"},
-    {name: "Command"},
-    {name: "Logs", visible: true},
-    {name: "Subprocesses"},
+    { name: "Details" },
+    { name: "Command" },
+    { name: "Logs", visible: true },
+    { name: "Inputs" },
+    { name: "Outputs" },
+    { name: "Resources" },
+    { name: "Subprocesses" },
 ];
 
 export const ProcessPanelRoot = withStyles(styles)(
-    ({ process, processLogsPanel, ...props }: ProcessPanelRootProps) =>
-    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)}
-                    cancelProcess={props.cancelProcess}
-                />
-            </MPVPanelContent>
-            <MPVPanelContent forwardProps xs="auto" data-cy="process-cmd">
-                <ProcessCmdCard
-                    onCopy={props.onCopyToClipboard}
-                    process={process} />
-            </MPVPanelContent>
-            <MPVPanelContent forwardProps xs maxHeight='50%' data-cy="process-logs">
-                <ProcessLogsCard
-                    onCopy={props.onCopyToClipboard}
-                    process={process}
-                    lines={getProcessPanelLogs(processLogsPanel)}
-                    selectedFilter={{
-                        label: processLogsPanel.selectedFilter,
-                        value: processLogsPanel.selectedFilter
-                    }}
-                    filters={processLogsPanel.filters.map(
-                        filter => ({ label: filter, value: filter })
-                    )}
-                    onLogFilterChange={props.onLogFilterChange}
-                    navigateToLog={props.navigateToLog}
-                />
-            </MPVPanelContent>
-            <MPVPanelContent forwardProps xs maxHeight='50%' data-cy="process-children">
-                <SubprocessPanel />
-            </MPVPanelContent>
-        </MPVContainer>
-        : <Grid container
-            alignItems='center'
-            justify='center'
-            style={{ minHeight: '100%' }}>
-            <DefaultView
+    ({
+        process,
+        auth,
+        processLogsPanel,
+        inputRaw,
+        inputParams,
+        outputRaw,
+        outputDefinitions,
+        outputParams,
+        nodeInfo,
+        loadInputs,
+        loadOutputs,
+        loadNodeJson,
+        loadOutputDefinitions,
+        updateOutputParams,
+        ...props
+    }: ProcessPanelRootProps) => {
+        const outputUuid = process?.containerRequest.outputUuid;
+        const containerRequest = process?.containerRequest;
+        const inputMounts = getInputCollectionMounts(process?.containerRequest);
+
+        React.useEffect(() => {
+            if (containerRequest) {
+                // Load inputs from mounts or props
+                loadInputs(containerRequest);
+                // Fetch raw output (loads from props or keep)
+                loadOutputs(containerRequest);
+                // Loads output definitions from mounts into store
+                loadOutputDefinitions(containerRequest);
+                // load the assigned instance type from node.json in
+                // the log collection
+                loadNodeJson(containerRequest);
+            }
+        }, [containerRequest, loadInputs, loadOutputs, loadOutputDefinitions, loadNodeJson]);
+
+        // Trigger processing output params when raw or definitions change
+        React.useEffect(() => {
+            updateOutputParams();
+        }, [outputRaw, outputDefinitions, updateOutputParams]);
+
+        return process ? (
+            <MPVContainer
+                className={props.classes.root}
+                spacing={8}
+                panelStates={panelsData}
+                justify-content="flex-start"
+                direction="column"
+                wrap="nowrap">
+                <MPVPanelContent
+                    forwardProps
+                    xs="auto"
+                    data-cy="process-details">
+                    <ProcessDetailsCard
+                        process={process}
+                        onContextMenu={event => props.onContextMenu(event, process)}
+                        cancelProcess={props.cancelProcess}
+                        startProcess={props.startProcess}
+                        resumeOnHoldWorkflow={props.resumeOnHoldWorkflow}
+                    />
+                </MPVPanelContent>
+                <MPVPanelContent
+                    forwardProps
+                    xs="auto"
+                    data-cy="process-cmd">
+                    <ProcessCmdCard
+                        onCopy={props.onCopyToClipboard}
+                        process={process}
+                    />
+                </MPVPanelContent>
+                <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,
+                        }}
+                        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">
+                    <ProcessIOCard
+                        label={ProcessIOCardType.INPUT}
+                        process={process}
+                        params={inputParams}
+                        raw={inputRaw}
+                        mounts={inputMounts}
+                    />
+                </MPVPanelContent>
+                <MPVPanelContent
+                    forwardProps
+                    xs
+                    maxHeight="50%"
+                    data-cy="process-outputs">
+                    <ProcessIOCard
+                        label={ProcessIOCardType.OUTPUT}
+                        process={process}
+                        params={outputParams}
+                        raw={outputRaw?.rawOutputs}
+                        outputUuid={outputUuid || ""}
+                    />
+                </MPVPanelContent>
+                <MPVPanelContent
+                    forwardProps
+                    xs
+                    data-cy="process-resources">
+                    <ProcessResourceCard
+                        process={process}
+                        nodeInfo={nodeInfo}
+                    />
+                </MPVPanelContent>
+                <MPVPanelContent
+                    forwardProps
+                    xs
+                    maxHeight="50%"
+                    data-cy="process-children">
+                    <SubprocessPanel process={process} />
+                </MPVPanelContent>
+            </MPVContainer>
+        ) : (
+            <NotFoundView
                 icon={ProcessIcon}
-                messages={['Process not found']} />
-        </Grid>);
+                messages={["Process not found"]}
+            />
+        );
+    }
+);
index 7afaa04d94b95f90e47181f2a449985c0879fd62..4a6b5fd33344600e1a5e6af1d71e4ecbd09b0a29 100644 (file)
@@ -2,68 +2,83 @@
 //
 // 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,
-} from 'store/process-panel/process-panel-actions';
-import { cancelRunningWorkflow } from 'store/processes/processes-actions';
-import { navigateToLogCollection, setProcessLogsPanelFilter } from 'store/process-logs-panel/process-logs-panel-actions';
-import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+    updateOutputParams,
+    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, resources, processPanel, processLogsPanel }: RootState): ProcessPanelRootDataProps => {
-    const uuid = getProcessPanelCurrentUuid(router) || '';
+const mapStateToProps = ({ router, auth, resources, processPanel, processLogsPanel }: RootState): ProcessPanelRootDataProps => {
+    const uuid = getProcessPanelCurrentUuid(router) || "";
     const subprocesses = getSubprocesses(uuid)(resources);
     return {
         process: getProcess(uuid)(resources),
         subprocesses: subprocesses.filter(subprocess => processPanel.filters[getProcessStatus(subprocess)]),
         filters: getFilters(processPanel, subprocesses),
         processLogsPanel: processLogsPanel,
+        auth: auth,
+        inputRaw: processPanel.inputRaw,
+        inputParams: processPanel.inputParams,
+        outputRaw: processPanel.outputRaw,
+        outputDefinitions: processPanel.outputDefinitions,
+        outputParams: processPanel.outputParams,
+        nodeInfo: processPanel.nodeInfo,
     };
 };
 
 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)),
-    onLogFilterChange: (filter) => dispatch(setProcessLogsPanelFilter(filter.value)),
-    navigateToLog: (uuid) => dispatch<any>(navigateToLogCollection(uuid)),
+    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)),
+    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);
diff --git a/src/views/process-panel/process-resource-card.tsx b/src/views/process-panel/process-resource-card.tsx
new file mode 100644 (file)
index 0000000..b39f48e
--- /dev/null
@@ -0,0 +1,221 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    Card,
+    CardHeader,
+    IconButton,
+    CardContent,
+    Tooltip,
+    Typography,
+    Grid,
+} from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import {
+    CloseIcon,
+    MaximizeIcon,
+    MemoryIcon,
+    UnMaximizeIcon,
+} from 'components/icon/icon';
+import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
+import { connect } from 'react-redux';
+import { Process } from 'store/processes/process';
+import { NodeInstanceType } from 'store/process-panel/process-panel';
+import { DetailsAttribute } from "components/details-attribute/details-attribute";
+import { formatFileSize } from "common/formatters";
+import { MountKind } from 'models/mount-types';
+
+interface ProcessResourceCardDataProps {
+    process: Process;
+    nodeInfo: NodeInstanceType | null;
+}
+
+type CssRules = "card" | "header" | "title" | "avatar" | "iconHeader" | "content" | "sectionH3";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    card: {
+        height: '100%'
+    },
+    header: {
+        paddingBottom: "0px"
+    },
+    title: {
+        paddingTop: theme.spacing.unit * 0.5
+    },
+    avatar: {
+        paddingTop: theme.spacing.unit * 0.5
+    },
+    iconHeader: {
+        fontSize: '1.875rem',
+        color: theme.customs.colors.greyL,
+    },
+    content: {
+        paddingTop: "0px",
+        maxHeight: `calc(100% - ${theme.spacing.unit * 7.5}px)`,
+        overflow: "auto"
+    },
+    sectionH3: {
+        margin: "0.5em",
+        color: theme.customs.colors.greyD,
+        fontSize: "0.8125rem",
+        textTransform: "uppercase",
+    }
+});
+
+type ProcessResourceCardProps = ProcessResourceCardDataProps & WithStyles<CssRules> & MPVPanelProps;
+
+export const ProcessResourceCard = withStyles(styles)(connect()(
+    ({ classes, nodeInfo, doHidePanel, doMaximizePanel, doUnMaximizePanel, panelMaximized, panelName, process, }: ProcessResourceCardProps) => {
+        let diskRequest = 0;
+        if (process.container?.mounts) {
+            for (const mnt in process.container.mounts) {
+                const mp = process.container.mounts[mnt];
+                if (mp.kind === MountKind.TEMPORARY_DIRECTORY) {
+                    diskRequest += mp.capacity;
+                }
+            }
+        }
+
+        return <Card className={classes.card} data-cy="process-resources-card">
+            <CardHeader
+                className={classes.header}
+                classes={{
+                    content: classes.title,
+                    avatar: classes.avatar,
+                }}
+                avatar={<MemoryIcon className={classes.iconHeader} />}
+                title={
+                    <Typography noWrap variant='h6' color='inherit'>
+                        Resources
+                    </Typography>
+                }
+                action={
+                    <div>
+                        {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}>
+                <Grid container>
+                    <Grid item xs={4}>
+                        <h3 className={classes.sectionH3}>Requested Resources</h3>
+                        <Grid container>
+                            <Grid item xs={12}>
+                                <DetailsAttribute label="Cores" value={process.container?.runtimeConstraints.vcpus} />
+                            </Grid>
+                            <Grid item xs={12}>
+                                <DetailsAttribute label="RAM*" value={formatFileSize(process.container?.runtimeConstraints.ram)} />
+                            </Grid>
+                            <Grid item xs={12}>
+                                <DetailsAttribute label="Disk" value={formatFileSize(diskRequest)} />
+                            </Grid>
+
+                            {process.container?.runtimeConstraints.cuda &&
+                                process.container?.runtimeConstraints.cuda.device_count > 0 ?
+                                <>
+                                    <Grid item xs={12}>
+                                        <DetailsAttribute label="CUDA devices" value={process.container?.runtimeConstraints.cuda.device_count} />
+                                    </Grid>
+                                    <Grid item xs={12}>
+                                        <DetailsAttribute label="CUDA driver version" value={process.container?.runtimeConstraints.cuda.driver_version} />
+                                    </Grid>
+                                    <Grid item xs={12}>
+                                        <DetailsAttribute label="CUDA hardware capability" value={process.container?.runtimeConstraints.cuda.hardware_capability} />
+                                    </Grid>
+                                </> : null}
+
+                            {process.container?.runtimeConstraints.keep_cache_ram &&
+                                process.container?.runtimeConstraints.keep_cache_ram > 0 ?
+                                <Grid item xs={12}>
+                                    <DetailsAttribute label="Keep cache (RAM)" value={formatFileSize(process.container?.runtimeConstraints.keep_cache_ram)} />
+                                </Grid> : null}
+
+                            {process.container?.runtimeConstraints.keep_cache_disk &&
+                                process.container?.runtimeConstraints.keep_cache_disk > 0 ?
+                                <Grid item xs={12}>
+                                    <DetailsAttribute label="Keep cache (disk)" value={formatFileSize(process.container?.runtimeConstraints.keep_cache_disk)} />
+                                </Grid> : null}
+
+                            {process.container?.runtimeConstraints.API ? <Grid item xs={12}>
+                                <DetailsAttribute label="API access" value={process.container?.runtimeConstraints.API.toString()} />
+                            </Grid> : null}
+
+                        </Grid>
+                    </Grid>
+
+
+                    <Grid item xs={8}>
+                        <h3 className={classes.sectionH3}>Assigned Instance Type</h3>
+                        {nodeInfo === null ? <Grid item xs={8}>
+                            No instance type recorded
+                        </Grid>
+                            :
+                            <Grid container>
+                                <Grid item xs={6}>
+                                    <DetailsAttribute label="Cores" value={nodeInfo.VCPUs} />
+                                </Grid>
+
+                                <Grid item xs={6}>
+                                    <DetailsAttribute label="Provider type" value={nodeInfo.ProviderType} />
+                                </Grid>
+
+                                <Grid item xs={6}>
+                                    <DetailsAttribute label="RAM" value={formatFileSize(nodeInfo.RAM)} />
+                                </Grid>
+
+                                <Grid item xs={6}>
+                                    <DetailsAttribute label="Price" value={"$" + nodeInfo.Price.toString()} />
+                                </Grid>
+
+                                <Grid item xs={6}>
+                                    <DetailsAttribute label="Disk" value={formatFileSize(nodeInfo.IncludedScratch + nodeInfo.AddedScratch)} />
+                                </Grid>
+
+                                <Grid item xs={6}>
+                                    <DetailsAttribute label="Preemptible" value={nodeInfo.Preemptible.toString()} />
+                                </Grid>
+
+                                {nodeInfo.CUDA && nodeInfo.CUDA.DeviceCount > 0 &&
+                                    <>
+                                        <Grid item xs={6}>
+                                            <DetailsAttribute label="CUDA devices" value={nodeInfo.CUDA.DeviceCount} />
+                                        </Grid>
+
+                                        <Grid item xs={6}>
+                                        </Grid>
+
+                                        <Grid item xs={6}>
+                                            <DetailsAttribute label="CUDA driver version" value={nodeInfo.CUDA.DriverVersion} />
+                                        </Grid>
+
+                                        <Grid item xs={6}>
+                                        </Grid>
+
+                                        <Grid item xs={6}>
+                                            <DetailsAttribute label="CUDA hardware capability" value={nodeInfo.CUDA.HardwareCapability} />
+                                        </Grid>
+                                    </>
+                                }
+                            </Grid>}
+                    </Grid>
+                </Grid>
+                <Typography>* RAM available to the program is limited to Requested RAM, not Instance RAM</Typography>
+            </CardContent>
+        </Card >;
+    }
+));
index ccb40d53ba958b35645c14fb0af4a874e72f1b88..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';
@@ -16,156 +16,280 @@ import { ContainerRequestState } from 'models/container-request';
 import { SortDirection } from 'components/data-table/data-column';
 import { ResourceKind, Resource } from 'models/resource';
 import {
+    ResourceName,
+    ProcessStatus as ResourceStatus,
+    ResourceType,
+    ResourceOwnerWithName,
+    ResourcePortableDataHash,
     ResourceFileSize,
+    ResourceFileCount,
+    ResourceUUID,
+    ResourceContainerUuid,
+    ContainerRunTime,
+    ResourceOutputUuid,
+    ResourceLogUuid,
+    ResourceParentProcess,
+    ResourceModifiedByUserUuid,
+    ResourceVersion,
+    ResourceCreatedAtDate,
     ResourceLastModifiedDate,
-    ProcessStatus,
-    ResourceType,
-    ResourceOwnerWithName
+    ResourceTrashDate,
+    ResourceDeleteDate,
 } from 'views-components/data-explorer/renderers';
 import { ProjectIcon } from 'components/icon/icon';
-import { ResourceName } from 'views-components/data-explorer/renderers';
-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",
-    FILE_SIZE = "File size",
-    LAST_MODIFIED = "Last modified"
+    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 projectPanelColumns: DataColumns<string> = [
+export const projectPanelColumns: DataColumns<string, ProjectResource> = [
     {
         name: ProjectPanelColumnNames.NAME,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.NONE,
+        sort: { direction: SortDirection.NONE, field: 'name' },
         filters: createTree(),
-        render: uuid => <ResourceName uuid={uuid} />
+        render: (uuid) => <ResourceName uuid={uuid} />,
     },
     {
-        name: "Status",
+        name: ProjectPanelColumnNames.STATUS,
         selected: true,
         configurable: true,
         mutuallyExclusiveFilters: true,
         filters: getInitialProcessStatusFilters(),
-        render: uuid => <ProcessStatus 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} />,
     },
     {
         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} />,
+    },
+    {
+        name: ProjectPanelColumnNames.UUID,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceUUID uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.CONTAINER_UUID,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceContainerUuid uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.RUNTIME,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ContainerRunTime uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.OUTPUT_UUID,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceOutputUuid uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.LOG_UUID,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceLogUuid uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.PARENT_PROCESS,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceParentProcess uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.MODIFIED_BY_USER_UUID,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceModifiedByUserUuid uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.VERSION,
+        selected: false,
+        configurable: true,
+        filters: createTree(),
+        render: (uuid) => <ResourceVersion uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.CREATED_AT,
+        selected: false,
+        configurable: true,
+        sort: { direction: SortDirection.NONE, field: 'createdAt' },
+        filters: createTree(),
+        render: (uuid) => <ResourceCreatedAtDate uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.LAST_MODIFIED,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.DESC,
+        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' },
+        filters: createTree(),
+        render: (uuid) => <ResourceTrashDate uuid={uuid} />,
+    },
+    {
+        name: ProjectPanelColumnNames.DELETE_AT,
+        selected: false,
+        configurable: true,
+        sort: { direction: SortDirection.NONE, field: 'deleteAt' },
+        filters: createTree(),
+        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 } = this.props;
+                const { resources, isAdmin } = this.props;
                 const resource = getResource<GroupContentsResource>(resourceUuid)(resources);
                 // When viewing the contents of a filter group, all contents should be treated as read only.
                 let readonly = false;
@@ -176,29 +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,
-                        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 8eb2a87c37f52de69674db808bd7419f530dbd55..5cb10c4c66b9af0fdf5b71317c4aad9384b2b0af 100644 (file)
@@ -9,7 +9,6 @@ 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 {
@@ -37,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";
 
@@ -66,12 +66,11 @@ export interface FavoritePanelFilter extends DataTableFilterItem {
     type: ResourceKind | ContainerRequestState;
 }
 
-export const publicFavoritePanelColumns: DataColumns<string> = [
+export const publicFavoritePanelColumns: DataColumns<string, GroupContentsResource> = [
     {
         name: PublicFavoritePanelColumnNames.NAME,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.NONE,
         filters: createTree(),
         render: uuid => <ResourceName uuid={uuid} />
     },
@@ -107,7 +106,6 @@ export const publicFavoritePanelColumns: DataColumns<string> = [
         name: PublicFavoritePanelColumnNames.LAST_MODIFIED,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.DESC,
         filters: createTree(),
         render: uuid => <ResourceLastModifiedDate uuid={uuid} />
     }
@@ -148,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 1b04718df8bcb169177935b10a8a10e250864c4b..dd5bb2f8ea982da4e36b9078fc8e893c092d7bfb 100644 (file)
@@ -15,8 +15,8 @@ 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 { ProjectsTreePickerItem } from 'views-components/projects-tree-picker/generic-projects-tree-picker';
+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';
 import { identity, values, noop } from 'lodash';
@@ -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[]) => {
-
-            const deletedDirectories = this.state.directories
-                .reduce((deletedDirectories, directory) =>
-                    directories.some(({ uuid }) => uuid === directory.uuid)
-                        ? deletedDirectories
-                        : [...deletedDirectories, directory]
-                    , []);
+        setDirectoriesFromResources = async (directories: (CollectionResource | CollectionDirectory)[]) => {
+            const locations = (await Promise.all(
+                directories.map(directory => (this.props.getFileOperationLocation(directory)))
+            )).filter((location): location is FileOperationLocation => (
+                location !== undefined
+            ));
 
-            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}
@@ -231,31 +229,17 @@ const DirectoryArrayInputComponent = connect(mapStateToProps)(
                 onBlur={this.props.input.onBlur}
                 disabled={this.props.commandInput.disabled} />
 
-        dialog = () =>
-            <Dialog
-                open={this.state.open}
-                onClose={this.closeDialog}
-                fullWidth
-                maxWidth='md' >
-                <DialogTitle>Choose collections</DialogTitle>
-                <DialogContent>
-                    <this.dialogContent />
-                </DialogContent>
-                <DialogActions>
-                    <Button onClick={this.closeDialog}>Cancel</Button>
-                    <Button
-                        data-cy='ok-button'
-                        variant='contained'
-                        color='primary'
-                        onClick={this.submit}>Ok</Button>
-                </DialogActions>
-            </Dialog>
-
         dialogContentStyles: StyleRulesCallback<DialogContentCssRules> = ({ spacing }) => ({
             root: {
                 display: 'flex',
                 flexDirection: 'column',
-                height: `${spacing.unit * 8}vh`,
+            },
+            pickerWrapper: {
+                display: 'flex',
+                flexDirection: 'column',
+                flexBasis: `${spacing.unit * 8}vh`,
+                flexShrink: 1,
+                minHeight: 0,
             },
             tree: {
                 flex: 3,
@@ -270,32 +254,53 @@ const DirectoryArrayInputComponent = connect(mapStateToProps)(
                 padding: `${spacing.unit}px 0`,
                 overflowX: 'hidden',
             },
-        })
+        });
 
-        dialogContent = withStyles(this.dialogContentStyles)(
+        dialog = withStyles(this.dialogContentStyles)(
             ({ classes }: WithStyles<DialogContentCssRules>) =>
-                <div className={classes.root}>
-                    <div className={classes.tree}>
-                        <ProjectsTreePicker
-                            pickerId={this.props.commandInput.id}
-                            includeCollections
-                            showSelection
-                            options={this.props.options}
-                            toggleItemSelection={this.refreshDirectories} />
-                    </div>
-                    <Divider />
-                    <div className={classes.chips}>
-                        <Typography variant='subtitle1'>Selected collections ({this.state.directories.length}):</Typography>
-                        <Chips
-                            orderable
-                            deletable
-                            values={this.state.directories}
-                            onChange={this.setDirectories}
-                            getLabel={(directory: CollectionResource) => directory.name} />
-                    </div>
-                </div>
+                <Dialog
+                    open={this.state.open}
+                    onClose={this.closeDialog}
+                    fullWidth
+                    maxWidth='md' >
+                    <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>
+                            <Divider />
+                            <div className={classes.chips}>
+                                <Typography variant='subtitle1'>Selected collections ({this.state.directories.length}):</Typography>
+                                <Chips
+                                    orderable
+                                    deletable
+                                    values={this.state.directories}
+                                    onChange={this.setDirectories}
+                                    getLabel={(directory: CollectionResource) => directory.name} />
+                            </div>
+                        </div>
+
+                    </DialogContent>
+                    <DialogActions>
+                        <Button onClick={this.closeDialog}>Cancel</Button>
+                        <Button
+                            data-cy='ok-button'
+                            variant='contained'
+                            color='primary'
+                            onClick={this.submit}>Ok</Button>
+                    </DialogActions>
+                </Dialog>
         );
 
     });
 
-type DialogContentCssRules = 'root' | 'tree' | 'divider' | 'chips';
+type DialogContentCssRules = 'root' | 'pickerWrapper' | 'tree' | 'divider' | 'chips';
index ab1cf9d1af51146841143487bb3b9ac904439988..63c990fa9f2cb513759bc1d87caa52c1b440b220 100644 (file)
@@ -6,7 +6,7 @@ import React from 'react';
 import { connect, DispatchProp } from 'react-redux';
 import { memoize } from 'lodash/fp';
 import { Field } from 'redux-form';
-import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@material-ui/core';
+import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button, StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core';
 import {
     isRequiredInput,
     DirectoryCommandInputParameter,
@@ -15,17 +15,19 @@ 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 'views-components/projects-tree-picker/generic-projects-tree-picker';
-import { CollectionResource } from 'models/collection';
-import { ResourceKind } from 'models/resource';
+import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware';
 import { ERROR_MESSAGE } from 'validators/require';
+import { Dispatch } from 'redux';
 
 export interface DirectoryInputProps {
     input: DirectoryCommandInputParameter;
     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
 }
+
+type DialogContentCssRules = 'root' | 'pickerWrapper';
+
 export const DirectoryInput = ({ input, options }: DirectoryInputProps) =>
     <Field
         name={input.id}
@@ -40,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,
 });
 
@@ -56,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 = {
@@ -68,14 +80,13 @@ const DirectoryInputComponent = connect()(
         };
 
         componentDidMount() {
-            this.props.dispatch<any>(
-                initProjectsTreePicker(this.props.commandInput.id));
+            this.props.initProjectsTreePicker(this.props.commandInput.id);
         }
 
         render() {
             return <>
                 {this.renderInput()}
-                {this.renderDialog()}
+                <this.dialog />
             </>;
         }
 
@@ -92,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() {
@@ -114,32 +122,47 @@ const DirectoryInputComponent = connect()(
                 {...this.props} />;
         }
 
-        renderDialog() {
-            return <Dialog
-                open={this.state.open}
-                onClose={this.closeDialog}
-                fullWidth
-                data-cy="choose-a-directory-dialog"
-                maxWidth='md'>
-                <DialogTitle>Choose a directory</DialogTitle>
-                <DialogContent>
-                    <ProjectsTreePicker
-                        pickerId={this.props.commandInput.id}
-                        includeCollections
-                        options={this.props.options}
-                        toggleItemActive={this.setDirectory} />
-                </DialogContent>
-                <DialogActions>
-                    <Button onClick={this.closeDialog}>Cancel</Button>
-                    <Button
-                        disabled={!this.state.directory}
-                        variant='contained'
-                        color='primary'
-                        onClick={this.submit}>Ok</Button>
-                </DialogActions>
-            </Dialog>;
-        }
+        dialogContentStyles: StyleRulesCallback<DialogContentCssRules> = ({ spacing }) => ({
+            root: {
+                display: 'flex',
+                flexDirection: 'column',
+            },
+            pickerWrapper: {
+                flexBasis: `${spacing.unit * 8}vh`,
+                flexShrink: 1,
+                minHeight: 0,
+            },
+        });
+
+        dialog = withStyles(this.dialogContentStyles)(
+            ({ classes }: WithStyles<DialogContentCssRules>) =>
+                <Dialog
+                    open={this.state.open}
+                    onClose={this.closeDialog}
+                    fullWidth
+                    data-cy="choose-a-directory-dialog"
+                    maxWidth='md'>
+                    <DialogTitle>Choose a directory</DialogTitle>
+                    <DialogContent className={classes.root}>
+                        <div className={classes.pickerWrapper}>
+                            <ProjectsTreePicker
+                                pickerId={this.props.commandInput.id}
+                                includeCollections
+                                includeDirectories
+                                cascadeSelection={false}
+                                options={this.props.options}
+                                toggleItemActive={this.setDirectory} />
+                        </div>
+                    </DialogContent>
+                    <DialogActions>
+                        <Button onClick={this.closeDialog}>Cancel</Button>
+                        <Button
+                            disabled={!this.state.directory}
+                            variant='contained'
+                            color='primary'
+                            onClick={this.submit}>Ok</Button>
+                    </DialogActions>
+                </Dialog>
+        );
 
     });
-
-
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 ddb558b9e552afe99bb47a4663e7951ae5eb2515..99338738fa5e03c67b62482c4a25f28f68bd5c6e 100644 (file)
@@ -16,7 +16,7 @@ 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 } from 'store/tree-picker/tree-picker-actions';
-import { ProjectsTreePickerItem } from 'views-components/projects-tree-picker/generic-projects-tree-picker';
+import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware';
 import { CollectionFile, CollectionFileType } from 'models/collection-file';
 import { createSelector, createStructuredSelector } from 'reselect';
 import { ChipsInput } from 'components/chips-input/chips-input';
@@ -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,
                     })
                 );
             });
@@ -212,31 +216,17 @@ const FileArrayInputComponent = connect(mapStateToProps)(
                 onKeyPress={!this.props.commandInput.disabled ? this.openDialog : undefined}
                 onBlur={this.props.input.onBlur} />
 
-        dialog = () =>
-            <Dialog
-                open={this.state.open}
-                onClose={this.closeDialog}
-                fullWidth
-                maxWidth='md' >
-                <DialogTitle>Choose files</DialogTitle>
-                <DialogContent>
-                    <this.dialogContent />
-                </DialogContent>
-                <DialogActions>
-                    <Button onClick={this.closeDialog}>Cancel</Button>
-                    <Button
-                        data-cy='ok-button'
-                        variant='contained'
-                        color='primary'
-                        onClick={this.submit}>Ok</Button>
-                </DialogActions>
-            </Dialog>
-
         dialogContentStyles: StyleRulesCallback<DialogContentCssRules> = ({ spacing }) => ({
             root: {
                 display: 'flex',
                 flexDirection: 'column',
-                height: `${spacing.unit * 8}vh`,
+            },
+            pickerWrapper: {
+                display: 'flex',
+                flexDirection: 'column',
+                flexBasis: `${spacing.unit * 8}vh`,
+                flexShrink: 1,
+                minHeight: 0,
             },
             tree: {
                 flex: 3,
@@ -253,31 +243,52 @@ const FileArrayInputComponent = connect(mapStateToProps)(
             },
         })
 
-        dialogContent = withStyles(this.dialogContentStyles)(
+
+        dialog = withStyles(this.dialogContentStyles)(
             ({ classes }: WithStyles<DialogContentCssRules>) =>
-                <div className={classes.root}>
-                    <div className={classes.tree}>
-                        <ProjectsTreePicker
-                            pickerId={this.props.commandInput.id}
-                            includeCollections
-                            includeFiles
-                            showSelection
-                            options={this.props.options}
-                            toggleItemSelection={this.refreshFiles} />
-                    </div>
-                    <Divider />
-                    <div className={classes.chips}>
-                        <Typography variant='subtitle1'>Selected files ({this.state.files.length}):</Typography>
-                        <Chips
-                            orderable
-                            deletable
-                            values={this.state.files}
-                            onChange={this.setFiles}
-                            getLabel={(file: CollectionFile) => file.name} />
-                    </div>
-                </div>
+                <Dialog
+                    open={this.state.open}
+                    onClose={this.closeDialog}
+                    fullWidth
+                    maxWidth='md' >
+                    <DialogTitle>Choose files</DialogTitle>
+                    <DialogContent className={classes.root}>
+                        <div className={classes.pickerWrapper}>
+                            <div className={classes.tree}>
+                                <ProjectsTreePicker
+                                    pickerId={this.props.commandInput.id}
+                                    includeCollections
+                                    includeDirectories
+                                    includeFiles
+                                    showSelection
+                                    cascadeSelection={true}
+                                    options={this.props.options}
+                                    toggleItemSelection={this.refreshFiles} />
+                            </div>
+                            <Divider />
+                            <div className={classes.chips}>
+                                <Typography variant='subtitle1'>Selected files ({this.state.files.length}):</Typography>
+                                <Chips
+                                    orderable
+                                    deletable
+                                    values={this.state.files}
+                                    onChange={this.setFiles}
+                                    getLabel={(file: CollectionFile) => file.name} />
+                            </div>
+                        </div>
+
+                    </DialogContent>
+                    <DialogActions>
+                        <Button onClick={this.closeDialog}>Cancel</Button>
+                        <Button
+                            data-cy='ok-button'
+                            variant='contained'
+                            color='primary'
+                            onClick={this.submit}>Ok</Button>
+                    </DialogActions>
+                </Dialog>
         );
 
     });
 
-type DialogContentCssRules = 'root' | 'tree' | 'divider' | 'chips';
+type DialogContentCssRules = 'root' | 'pickerWrapper' | 'tree' | 'divider' | 'chips';
index c2e17c9502fe26dac7bad4bdf2663d6f87f6db51..6970e2a5b531c9cb1af50a410075845ded643578 100644 (file)
@@ -12,19 +12,22 @@ import {
 } from 'models/workflow';
 import { Field } from 'redux-form';
 import { ERROR_MESSAGE } from 'validators/require';
-import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@material-ui/core';
+import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button, StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core';
 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 } from 'store/tree-picker/tree-picker-actions';
 import { TreeItem } from 'components/tree/tree';
-import { ProjectsTreePickerItem } from 'views-components/projects-tree-picker/generic-projects-tree-picker';
+import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware';
 import { CollectionFile, CollectionFileType } from 'models/collection-file';
 
 export interface FileInputProps {
     input: FileCommandInputParameter;
     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
 }
+
+type DialogContentCssRules = 'root' | 'pickerWrapper';
+
 export const FileInput = ({ input, options }: FileInputProps) =>
     <Field
         name={input.id}
@@ -73,11 +76,12 @@ const FileInputComponent = connect()(
         render() {
             return <>
                 {this.renderInput()}
-                {this.renderDialog()}
+                <this.dialog />
             </>;
         }
 
         openDialog = () => {
+            this.componentDidMount();
             this.setState({ open: true });
         }
 
@@ -112,33 +116,47 @@ const FileInputComponent = connect()(
                 {...this.props} />;
         }
 
-        renderDialog() {
-            return <Dialog
-                open={this.state.open}
-                onClose={this.closeDialog}
-                fullWidth
-                data-cy="choose-a-file-dialog"
-                maxWidth='md'>
-                <DialogTitle>Choose a file</DialogTitle>
-                <DialogContent>
-                    <ProjectsTreePicker
-                        pickerId={this.props.commandInput.id}
-                        includeCollections
-                        includeFiles
-                        options={this.props.options}
-                        toggleItemActive={this.setFile} />
-                </DialogContent>
-                <DialogActions>
-                    <Button onClick={this.closeDialog}>Cancel</Button>
-                    <Button
-                        disabled={!this.state.file}
-                        variant='contained'
-                        color='primary'
-                        onClick={this.submit}>Ok</Button>
-                </DialogActions>
-            </Dialog>;
-        }
-
+        dialogContentStyles: StyleRulesCallback<DialogContentCssRules> = ({ spacing }) => ({
+            root: {
+                display: 'flex',
+                flexDirection: 'column',
+            },
+            pickerWrapper: {
+                flexBasis: `${spacing.unit * 8}vh`,
+                flexShrink: 1,
+                minHeight: 0,
+            },
+        });
+
+        dialog = withStyles(this.dialogContentStyles)(
+            ({ classes }: WithStyles<DialogContentCssRules>) =>
+                <Dialog
+                    open={this.state.open}
+                    onClose={this.closeDialog}
+                    fullWidth
+                    data-cy="choose-a-file-dialog"
+                    maxWidth='md'>
+                    <DialogTitle>Choose a file</DialogTitle>
+                    <DialogContent className={classes.root}>
+                        <div className={classes.pickerWrapper}>
+                            <ProjectsTreePicker
+                                pickerId={this.props.commandInput.id}
+                                includeCollections
+                                includeDirectories
+                                includeFiles
+                                cascadeSelection={false}
+                                options={this.props.options}
+                                toggleItemActive={this.setFile} />
+                        </div>
+                    </DialogContent>
+                    <DialogActions>
+                        <Button onClick={this.closeDialog}>Cancel</Button>
+                        <Button
+                            disabled={!this.state.file}
+                            variant='contained'
+                            color='primary'
+                            onClick={this.submit}>Ok</Button>
+                    </DialogActions>
+                </Dialog >
+        );
     });
-
-
index 8ca4ec89502f045b544c00cc64b0ee84778e382c..963998f1e5504b11f4cdb3a38ab20f88f6f161cc 100644 (file)
@@ -13,12 +13,13 @@ export type GenericInputProps = WrappedFieldProps & {
 
 type GenericInputContainerProps = GenericInputProps & {
     component: React.ComponentType<GenericInputProps>;
+    required?: boolean;
 };
 export const GenericInput = ({ component: Component, ...props }: GenericInputContainerProps) => {
     return <FormGroup>
         <FormLabel
             focused={props.meta.active}
-            required={isRequiredInput(props.commandInput)}
+            required={props.required !== undefined ? props.required : isRequiredInput(props.commandInput)}
             error={props.meta.touched && !!props.meta.error}>
             {getInputLabel(props.commandInput)}
         </FormLabel>
@@ -31,4 +32,4 @@ export const GenericInput = ({ component: Component, ...props }: GenericInputCon
             }
         </FormHelperText>
     </FormGroup>;
-};
\ No newline at end of file
+};
index 7b45a6d18e18ac59e0cdf479f67659c239f81669..438bbe8e7e40163b55b8d363a53b82dc5f23ab01 100644 (file)
@@ -5,7 +5,7 @@
 import React from 'react';
 import { connect, DispatchProp } from 'react-redux';
 import { Field } from 'redux-form';
-import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@material-ui/core';
+import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button, withStyles, WithStyles, StyleRulesCallback } from '@material-ui/core';
 import {
     GenericCommandInputParameter
 } from 'models/workflow';
@@ -13,7 +13,7 @@ import { GenericInput, GenericInputProps } from './generic-input';
 import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker';
 import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
 import { TreeItem } from 'components/tree/tree';
-import { ProjectsTreePickerItem } from 'views-components/projects-tree-picker/generic-projects-tree-picker';
+import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware';
 import { ProjectResource } from 'models/project';
 import { ResourceKind } from 'models/resource';
 import { RootState } from 'store/store';
@@ -24,18 +24,23 @@ export type ProjectCommandInputParameter = GenericCommandInputParameter<ProjectR
 const require: any = (value?: ProjectResource) => (value === undefined);
 
 export interface ProjectInputProps {
+    required: boolean;
     input: ProjectCommandInputParameter;
     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
 }
-export const ProjectInput = ({ input, options }: ProjectInputProps) =>
+
+type DialogContentCssRules = 'root' | 'pickerWrapper';
+
+export const ProjectInput = ({ required, input, options }: ProjectInputProps) =>
     <Field
         name={input.id}
         commandInput={input}
         component={ProjectInputComponent as any}
         format={format}
-        validate={require}
+        validate={required ? require : undefined}
         {...{
-            options
+            options,
+            required
         }} />;
 
 const format = (value?: ProjectResource) => value ? value.name : '';
@@ -54,6 +59,7 @@ const mapStateToProps = (state: RootState) => ({ userUuid: getUserUuid(state) })
 export const ProjectInputComponent = connect(mapStateToProps)(
     class ProjectInputComponent extends React.Component<GenericInputProps & DispatchProp & HasUserUuid & {
         options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
+        required?: boolean;
     }, ProjectInputComponentState> {
         state: ProjectInputComponentState = {
             open: false,
@@ -67,11 +73,12 @@ export const ProjectInputComponent = connect(mapStateToProps)(
         render() {
             return <>
                 {this.renderInput()}
-                {this.renderDialog()}
+                <this.dialog />
             </>;
         }
 
         openDialog = () => {
+            this.componentDidMount();
             this.setState({ open: true });
         }
 
@@ -92,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
@@ -108,29 +115,45 @@ export const ProjectInputComponent = connect(mapStateToProps)(
                 {...this.props} />;
         }
 
-        renderDialog() {
-            return <Dialog
-                open={this.state.open}
-                onClose={this.closeDialog}
-                fullWidth
-                data-cy="choose-a-project-dialog"
-                maxWidth='md'>
-                <DialogTitle>Choose a project</DialogTitle>
-                <DialogContent>
-                    <ProjectsTreePicker
-                        pickerId={this.props.commandInput.id}
-                        options={this.props.options}
-                        toggleItemActive={this.setProject} />
-                </DialogContent>
-                <DialogActions>
-                    <Button onClick={this.closeDialog}>Cancel</Button>
-                    <Button
-                        disabled={this.invalid()}
-                        variant='contained'
-                        color='primary'
-                        onClick={this.submit}>Ok</Button>
-                </DialogActions>
-            </Dialog>;
-        }
+        dialogContentStyles: StyleRulesCallback<DialogContentCssRules> = ({ spacing }) => ({
+            root: {
+                display: 'flex',
+                flexDirection: 'column',
+            },
+            pickerWrapper: {
+                flexBasis: `${spacing.unit * 8}vh`,
+                flexShrink: 1,
+                minHeight: 0,
+            },
+        });
+
+        dialog = withStyles(this.dialogContentStyles)(
+            ({ classes }: WithStyles<DialogContentCssRules>) =>
+                this.state.open ? <Dialog
+                    open={this.state.open}
+                    onClose={this.closeDialog}
+                    fullWidth
+                    data-cy="choose-a-project-dialog"
+                    maxWidth='md'>
+                    <DialogTitle>Choose a project</DialogTitle>
+                    <DialogContent className={classes.root}>
+                        <div className={classes.pickerWrapper}>
+                            <ProjectsTreePicker
+                                pickerId={this.props.commandInput.id}
+                                cascadeSelection={false}
+                                options={this.props.options}
+                                toggleItemActive={this.setProject} />
+                        </div>
+                    </DialogContent>
+                    <DialogActions>
+                        <Button onClick={this.closeDialog}>Cancel</Button>
+                        <Button
+                            disabled={this.invalid()}
+                            variant='contained'
+                            color='primary'
+                            onClick={this.submit}>Ok</Button>
+                    </DialogActions>
+                </Dialog> : null
+        );
 
     });
index 32a126a458fdcbd666969a95d09b34628889e104..a6f7a70693c4f9fdd043f3882f39a4edc550584c 100644 (file)
@@ -40,7 +40,7 @@ export const RunProcessBasicForm =
                         label="Optional description of this workflow run" />
                 </Grid>
                 <Grid item xs={12} md={6}>
-                    <ProjectInput input={{
+                    <ProjectInput required input={{
                         id: "owner",
                         label: "Project where the workflow will run"
                     } as ProjectCommandInputParameter}
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 e281035c3025656f296d04ec88067f9633e38788..d9b9002e3ea1a33d2d5b8f668e0108138d7e59c9 100644 (file)
@@ -29,6 +29,7 @@ import { StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core';
 import { ArvadosTheme } from 'common/custom-theme';
 import { getSearchSessions } from 'store/search-bar/search-bar-actions';
 import { camelCase } from 'lodash';
+import { GroupContentsResource } from 'services/groups-service/groups-service';
 
 export enum SearchResultsPanelColumnNames {
     CLUSTER = "Cluster",
@@ -56,7 +57,7 @@ export interface WorkflowPanelFilter extends DataTableFilterItem {
     type: ResourceKind | ContainerRequestState;
 }
 
-export const searchResultsPanelColumns: DataColumns<string> = [
+export const searchResultsPanelColumns: DataColumns<string, GroupContentsResource> = [
     {
         name: SearchResultsPanelColumnNames.CLUSTER,
         selected: true,
@@ -68,7 +69,7 @@ export const searchResultsPanelColumns: DataColumns<string> = [
         name: SearchResultsPanelColumnNames.NAME,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.NONE,
+        sort: {direction: SortDirection.NONE, field: "name"},
         filters: createTree(),
         render: (uuid: string) => <ResourceName uuid={uuid} />
     },
@@ -104,7 +105,7 @@ export const searchResultsPanelColumns: DataColumns<string> = [
         name: SearchResultsPanelColumnNames.LAST_MODIFIED,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.DESC,
+        sort: {direction: SortDirection.DESC, field: "modifiedAt"},
         filters: createTree(),
         render: uuid => <ResourceLastModifiedDate uuid={uuid} />
     }
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 d4ccae9c03abd5cf9cc58ae7bbf48f52e44b9569..65c723f6d891864b13b3bd14988b66e56c8a14f6 100644 (file)
@@ -17,6 +17,24 @@ import { createTree } from 'models/tree';
 import { getInitialProcessStatusFilters } from 'store/resource-type-filters/resource-type-filters';
 import { ResourcesState } from 'store/resources/resources';
 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';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    iconHeader: {
+        fontSize: '1.875rem',
+        color: theme.customs.colors.greyL,
+        marginRight: theme.spacing.unit * 2,
+    },
+    cardHeader: {
+        display: 'flex',
+    },
+});
 
 export enum SubprocessPanelColumnNames {
     NAME = "Name",
@@ -29,12 +47,12 @@ export interface SubprocessPanelFilter extends DataTableFilterItem {
     type: ResourceKind | ContainerRequestState;
 }
 
-export const subprocessPanelColumns: DataColumns<string> = [
+export const subprocessPanelColumns: DataColumns<string, ProcessResource> = [
     {
         name: SubprocessPanelColumnNames.NAME,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.NONE,
+        sort: {direction: SortDirection.NONE, field: "name"},
         filters: createTree(),
         render: uuid => <ResourceName uuid={uuid} />
     },
@@ -50,7 +68,7 @@ export const subprocessPanelColumns: DataColumns<string> = [
         name: SubprocessPanelColumnNames.CREATED_AT,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.DESC,
+        sort: {direction: SortDirection.DESC, field: "createdAt"},
         filters: createTree(),
         render: uuid => <ResourceCreatedAtDate uuid={uuid} />
     },
@@ -64,11 +82,12 @@ export const subprocessPanelColumns: DataColumns<string> = [
 ];
 
 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;
 }
@@ -80,10 +99,22 @@ const DEFAULT_VIEW_MESSAGES = [
     'The current process may not have any or none matches current filtering.'
 ];
 
+type SubProcessesTitleProps = WithStyles<CssRules>;
+
+const SubProcessesTitle = withStyles(styles)(
+    ({classes}: SubProcessesTitleProps) =>
+        <div className={classes.cardHeader}>
+            <ProcessIcon className={classes.iconHeader} /><span></span>
+            <Typography noWrap variant='h6' color='inherit'>
+                Subprocesses
+            </Typography>
+        </div>
+);
+
 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}
@@ -91,6 +122,9 @@ export const SubprocessPanelRoot = (props: SubprocessPanelProps & MPVPanelProps)
         defaultViewMessages={DEFAULT_VIEW_MESSAGES}
         doHidePanel={props.doHidePanel}
         doMaximizePanel={props.doMaximizePanel}
+        doUnMaximizePanel={props.doUnMaximizePanel}
         panelMaximized={props.panelMaximized}
-        panelName={props.panelName} />;
+        panelName={props.panelName}
+        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 67326829b6ae84763f401baeb3b3e98403cc4fbf..2a96ffe0d7cf76f2b34c500dfecde6e6e9f8a071 100644 (file)
@@ -34,6 +34,8 @@ import { createTree } from 'models/tree';
 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";
 
@@ -83,12 +85,12 @@ export const ResourceRestore =
         </Tooltip>
     );
 
-export const trashPanelColumns: DataColumns<string> = [
+export const trashPanelColumns: DataColumns<string, CollectionResource> = [
     {
         name: TrashPanelColumnNames.NAME,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.NONE,
+        sort: {direction: SortDirection.NONE, field: "name"},
         filters: createTree(),
         render: uuid => <ResourceName uuid={uuid} />
     },
@@ -96,7 +98,6 @@ export const trashPanelColumns: DataColumns<string> = [
         name: TrashPanelColumnNames.TYPE,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.NONE,
         filters: getTrashPanelTypeFilters(),
         render: uuid => <ResourceType uuid={uuid} />,
     },
@@ -104,7 +105,7 @@ export const trashPanelColumns: DataColumns<string> = [
         name: TrashPanelColumnNames.FILE_SIZE,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.NONE,
+        sort: {direction: SortDirection.NONE, field: "fileSizeTotal"},
         filters: createTree(),
         render: uuid => <ResourceFileSize uuid={uuid} />
     },
@@ -112,7 +113,7 @@ export const trashPanelColumns: DataColumns<string> = [
         name: TrashPanelColumnNames.TRASHED_DATE,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.DESC,
+        sort: {direction: SortDirection.DESC, field: "trashAt"},
         filters: createTree(),
         render: uuid => <ResourceTrashDate uuid={uuid} />
     },
@@ -120,7 +121,7 @@ export const trashPanelColumns: DataColumns<string> = [
         name: TrashPanelColumnNames.TO_BE_DELETED,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.NONE,
+        sort: {direction: SortDirection.NONE, field: "deleteAt"},
         filters: createTree(),
         render: uuid => <ResourceDeleteDate uuid={uuid} />
     },
@@ -128,7 +129,6 @@ export const trashPanelColumns: DataColumns<string> = [
         name: '',
         selected: true,
         configurable: false,
-        sortDirection: SortDirection.NONE,
         filters: createTree(),
         render: uuid => <ResourceRestore uuid={uuid} />
     }
@@ -179,6 +179,7 @@ export const TrashPanel = withStyles(styles)(
             }
 
             handleRowClick = (uuid: string) => {
+                this.props.dispatch<any>(toggleOne(uuid))
                 this.props.dispatch<any>(loadDetailsPanel(uuid));
             }
         }
index f2491dc27182e351d466dd9a7d09b8a1308a0a68..950262d8c6693936b83c7dff60c6df86ba41e516 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import React from 'react';
-import { WithStyles, withStyles, Paper, Button, Grid } from '@material-ui/core';
+import { WithStyles, withStyles, Paper, Typography } 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';
@@ -23,7 +23,7 @@ import { navigateToUserProfile } from "store/navigation/navigation-action";
 import { createTree } from 'models/tree';
 import { compose, Dispatch } from 'redux';
 import { UserResource } from 'models/user';
-import { ShareMeIcon, AddIcon } from 'components/icon/icon';
+import { ShareMeIcon } from 'components/icon/icon';
 import { USERS_PANEL_ID, openUserCreateDialog } from 'store/users/users-actions';
 import { noop } from 'lodash';
 
@@ -51,12 +51,12 @@ export enum UserPanelColumnNames {
     USERNAME = "Username"
 }
 
-export const userPanelColumns: DataColumns<string> = [
+export const userPanelColumns: DataColumns<string, UserResource> = [
     {
         name: UserPanelColumnNames.NAME,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.NONE,
+        sort: {direction: SortDirection.NONE, field: "firstName"},
         filters: createTree(),
         render: uuid => <UserResourceFullName uuid={uuid} link={true} />
     },
@@ -64,7 +64,7 @@ export const userPanelColumns: DataColumns<string> = [
         name: UserPanelColumnNames.UUID,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.NONE,
+        sort: {direction: SortDirection.NONE, field: "uuid"},
         filters: createTree(),
         render: uuid => <ResourceUuid uuid={uuid} />
     },
@@ -72,7 +72,7 @@ export const userPanelColumns: DataColumns<string> = [
         name: UserPanelColumnNames.EMAIL,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.NONE,
+        sort: {direction: SortDirection.NONE, field: "email"},
         filters: createTree(),
         render: uuid => <ResourceEmail uuid={uuid} />
     },
@@ -94,7 +94,7 @@ export const userPanelColumns: DataColumns<string> = [
         name: UserPanelColumnNames.USERNAME,
         selected: true,
         configurable: false,
-        sortDirection: SortDirection.NONE,
+        sort: {direction: SortDirection.NONE, field: "username"},
         filters: createTree(),
         render: uuid => <ResourceUsername uuid={uuid} />
     }
@@ -132,18 +132,20 @@ export const UserPanel = compose(
                 return <Paper className={this.props.classes.root}>
                     <DataExplorer
                         id={USERS_PANEL_ID}
+                        title={
+                            <>
+                                <Typography>
+                                    User records are created automatically on first log in.
+                                </Typography>
+                                <Typography>
+                                    To add a new user, add them to your configured log in provider.
+                                </Typography>
+                            </>}
                         onRowClick={noop}
                         onRowDoubleClick={noop}
                         onContextMenu={this.handleContextMenu}
                         contextMenuColumn={true}
                         hideColumnSelector
-                        actions={
-                            <Grid container justify='flex-end'>
-                                <Button variant="contained" color="primary" onClick={this.props.openUserCreateDialog}>
-                                    <AddIcon /> NEW USER
-                                </Button>
-                            </Grid>
-                        }
                         paperProps={{
                             elevation: 0,
                         }}
index 53c0799f79b48432d0aa56636b8f0bf2bde45cbf..4a2083711efbee0f3d996216609e45441ebc99a1 100644 (file)
@@ -27,15 +27,16 @@ 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';
 import { getResource, ResourcesState } from 'store/resources/resources';
 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: {
@@ -80,6 +81,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         '& svg': {
             fontSize: '1rem'
         }
+    },
+    userProfileFormMessage: {
+        fontSize: '1.1rem',
     }
 });
 
@@ -96,6 +100,7 @@ export interface UserProfilePanelRootDataProps {
     userUuid: string;
     resources: ResourcesState;
     localCluster: string;
+    userProfileFormMessage: string;
 }
 
 const RoleTypes = [
@@ -125,7 +130,7 @@ enum TABS {
 
 }
 
-export const userProfileGroupsColumns: DataColumns<string> = [
+export const userProfileGroupsColumns: DataColumns<string, PermissionResource> = [
     {
         name: UserProfileGroupsColumnNames.NAME,
         selected: true,
@@ -164,7 +169,7 @@ export const userProfileGroupsColumns: DataColumns<string> = [
 ];
 
 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}
@@ -183,7 +188,7 @@ export const UserProfilePanelRoot = withStyles(styles)(
         };
 
         componentDidMount() {
-            this.setState({ value: TABS.PROFILE});
+            this.setState({ value: TABS.PROFILE });
         }
 
         render() {
@@ -212,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>
@@ -260,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"
@@ -315,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 468ef35a984da4f7a1198d886c576e4d9a9e33ba..20665f178d1d80dae898357244738790faa6e0ee 100644 (file)
@@ -11,12 +11,12 @@ 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';
 
-type CssRules = 'moreOptionsButton' | 'moreOptions' | 'chipsRoot';
+type CssRules = 'moreOptionsButton' | 'moreOptions' | 'chipsRoot' | 'vmTableWrapper';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     moreOptionsButton: {
@@ -31,6 +31,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     chipsRoot: {
         margin: `0px -${theme.spacing.unit / 2}px`,
     },
+    vmTableWrapper: {
+        overflowX: 'auto',
+    },
 });
 
 const mapStateToProps = (state: RootState) => {
@@ -95,7 +98,7 @@ export const VirtualMachineAdminPanel = compose(
 const CardContentWithVirtualMachines = (props: VirtualMachineProps) =>
     <Grid item xs={12}>
         <Card>
-            <CardContent>
+            <CardContent className={props.classes.vmTableWrapper}>
                 {virtualMachinesTable(props)}
             </CardContent>
         </Card>
@@ -136,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 70f97daf029cff6def9f86089030eea0edf7fb82..56c92805e24946a0499821fd31c7afb77dc48dce 100644 (file)
@@ -18,8 +18,9 @@ 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' | 'webshellButton';
+type CssRules = 'button' | 'codeSnippet' | 'link' | 'linkIcon' | 'rightAlign' | 'cardWithoutMachines' | 'icon' | 'chipsRoot' | 'copyIcon' | 'tableWrapper' | 'webshellButton';
 
 const EXTRA_TOKEN = "exraToken";
 
@@ -72,6 +73,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
             fontSize: '1rem'
         }
     },
+    tableWrapper: {
+        overflowX: 'auto',
+    },
     webshellButton: {
         textTransform: "initial",
     },
@@ -176,7 +180,9 @@ const CardContentWithVirtualMachines = (props: VirtualMachineProps) =>
                             </Tooltip>
                         </a>
                     </div>
-                    {virtualMachinesTable(props)}
+                    <div className={props.classes.tableWrapper}>
+                        {virtualMachinesTable(props)}
+                    </div>
                 </span>
 
             </CardContent>
@@ -264,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 471ecc40a2e63995e9a38ce27168ac65bab58459..fe5dff8a48f7c30ca474bb634abba980d043da98 100644 (file)
@@ -14,6 +14,8 @@ import { CustomTheme } from 'common/custom-theme';
 import { createServices } from "services/services";
 import 'jest-localstorage-mock';
 
+jest.mock('views-components/baner/banner', () => ({ Banner: () => 'Banner' }))
+
 const history = createBrowserHistory();
 
 it('renders without crashing', () => {
index a6c49e348495e6f48a21e1d8a99e6a78016a1e83..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 { 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",
+        },
     },
     asidePanel: {
         paddingTop: theme.spacing.unit,
-        height: '100%'
+        height: "100%",
     },
     contentWrapper: {
         paddingTop: theme.spacing.unit,
-        minWidth: 0
+        minWidth: 0,
     },
     content: {
         minWidth: 0,
@@ -129,14 +140,15 @@ 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 {
     isUserActive: boolean;
     isNotLinking: boolean;
     sessionIdleTimeout: number;
+    sidePanelIsCollapsed: boolean;
 }
 
 type WorkbenchPanelProps = WithStyles<CssRules> & WorkbenchDataProps;
@@ -144,67 +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))
+);
 
-export const WorkbenchPanel =
-    withStyles(styles)((props: WorkbenchPanelProps) =>
-        <Grid container item xs className={props.classes.root}>
+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");
+};
+
+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);
+
+    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>
@@ -221,6 +379,7 @@ export const WorkbenchPanel =
             <ChangeWorkflowDialog />
             <ContextMenu />
             <CopyCollectionDialog />
+            <CopyMultiCollectionDialog />
             <CopyProcessDialog />
             <CreateCollectionDialog />
             <CreateProjectDialog />
@@ -238,8 +397,12 @@ export const WorkbenchPanel =
             <MoveProjectDialog />
             <MultipleFilesRemoveDialog />
             <PublicKeyDialog />
-            <PartialCopyCollectionDialog />
-            <PartialCopyToCollectionDialog />
+            <PartialCopyToNewCollectionDialog />
+            <PartialCopyToExistingCollectionDialog />
+            <PartialCopyToSeparateCollectionsDialog />
+            <PartialMoveToNewCollectionDialog />
+            <PartialMoveToExistingCollectionDialog />
+            <PartialMoveToSeparateCollectionsDialog />
             <ProcessInputDialog />
             <RestoreCollectionVersionDialog />
             <RemoveApiClientAuthorizationDialog />
@@ -270,6 +433,8 @@ export const WorkbenchPanel =
             <VirtualMachineAttributesDialog />
             <FedLogin />
             <WebDavS3InfoDialog />
+            <Banner />
             {React.createElement(React.Fragment, null, pluginConfig.dialogs)}
         </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 44e14fd3c333c2cd5ac00fe7a0259567a0e090aa..7d9d746ddf2bbbac242c1f2724166ca0103808d3 100644 (file)
@@ -63,12 +63,12 @@ export enum ResourceStatus {
 //     }
 // };
 
-export const workflowPanelColumns: DataColumns<string> = [
+export const workflowPanelColumns: DataColumns<string, WorkflowResource> = [
     {
         name: WorkflowPanelColumnNames.NAME,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.ASC,
+        sort: {direction: SortDirection.ASC, field: "name"},
         filters: createTree(),
         render: (uuid: string) => <ResourceWorkflowName uuid={uuid} />
     },
@@ -101,7 +101,7 @@ export const workflowPanelColumns: DataColumns<string> = [
         name: WorkflowPanelColumnNames.LAST_MODIFIED,
         selected: true,
         configurable: true,
-        sortDirection: SortDirection.NONE,
+        sort: {direction: SortDirection.NONE, field: "modifiedAt"},
         filters: createTree(),
         render: (uuid: string) => <ResourceLastModifiedDate uuid={uuid} />
     },
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 3b2ecd8d8fe8f93aabbddd3392917c5f6906d9b3..1ef77b86ce8aa6b71e58ea83e91c9eec31bcf32f 100644 (file)
@@ -12,13 +12,12 @@ Clusters:
       CollectionVersioning: true
       PreserveVersionIfIdle: -1s
       BlobSigningKey: zfhgfenhffzltr9dixws36j1yhksjoll2grmku38mi7yxd66h5j4q9w4jzanezacp8s6q0ro3hxakfye02152hncy6zml2ed0uc
-      TrustAllContent: false
+      TrustAllContent: true
       ForwardSlashNameSubstitution: /
       ManagedProperties:
         original_owner_uuid: {Function: original_owner, Protected: true}
-      WebDAVCache:
-        UUIDTTL: 0s
     Login:
+      TrustPrivateNetworks: true
       PAM:
         Enable: true
     StorageClasses:
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 6dfb5b18b8aae518b209c72c9888a5fbb65d00cc..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"
@@ -2203,6 +2245,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@popperjs/core@npm:^2.9.0":
+  version: 2.11.6
+  resolution: "@popperjs/core@npm:2.11.6"
+  checksum: 47fb328cec1924559d759b48235c78574f2d71a8a6c4c03edb6de5d7074078371633b91e39bbf3f901b32aa8af9b9d8f82834856d2f5737a23475036b16817f0
+  languageName: node
+  linkType: hard
+
 "@samverschueren/stream-to-observable@npm:^0.3.0":
   version: 0.3.1
   resolution: "@samverschueren/stream-to-observable@npm:0.3.1"
@@ -2226,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"
@@ -2385,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"
@@ -2456,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"
@@ -2617,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"
@@ -2624,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"
@@ -2852,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"
@@ -3302,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"
@@ -3426,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
@@ -3512,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
@@ -3526,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
 
@@ -3716,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
@@ -3755,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
@@ -3770,24 +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
-    moment: 2.29.1
-    node-sass: ^4.9.4
-    node-sass-chokidar: 1.5.0
+    mime: ^3.0.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
@@ -3795,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
@@ -3803,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
@@ -3810,6 +3924,7 @@ __metadata:
     set-value: 2.0.1
     shell-escape: ^0.2.0
     sinon: 7.3
+    tippy.js: ^6.3.7
     ts-mock-imports: 1.3.7
     tslint: 5.20.0
     tslint-etc: 1.6.0
@@ -3909,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
 
@@ -4006,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
 
@@ -4455,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"
@@ -4524,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"
@@ -4534,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"
@@ -4552,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:
@@ -4679,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
 
@@ -4818,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:
@@ -4844,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"
@@ -4937,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"
@@ -4970,13 +5130,20 @@ __metadata:
   languageName: node
   linkType: hard
 
-"caniuse-lite@npm:1.0.30001299, 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":
+"caniuse-lite@npm:1.0.30001299":
   version: 1.0.30001299
   resolution: "caniuse-lite@npm:1.0.30001299"
   checksum: c770f60ebf3e0cc8043ba4db0ebec12d7a595a6b50cb4437c3c5c55b04de9d2413f711f2828be761e8c37bb46b927a8abe6b199b8f0ffc1a34af0ebdee84be27
   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.30001541":
+  version: 1.0.30001561
+  resolution: "caniuse-lite@npm:1.0.30001561"
+  checksum: 949829fe037e23346595614e01d362130245920503a12677f2506ce68e1240360113d6383febed41e8aa38cd0f5fd9c69c21b0af65a71c0246d560db489f1373
+  languageName: node
+  linkType: hard
+
 "capture-exit@npm:^2.0.0":
   version: 2.0.0
   resolution: "capture-exit@npm:2.0.0"
@@ -5011,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:
@@ -5024,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
 
@@ -5107,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
@@ -5121,7 +5288,7 @@ __metadata:
   dependenciesMeta:
     fsevents:
       optional: true
-  checksum: d1fda32fcd67d9f6170a8468ad2630a3c6194949c9db3f6a91b16478c328b2800f433fb5d2592511b6cb145a47c013ea1cce60b432b1a001ae3ee978a8bffc2d
+  checksum: b49fcde40176ba007ff361b198a2d35df60d9bb2a5aab228279eb810feae9294a6b4649ab15981304447afe1e6ffbf4788ad5db77235dc770ab777c6e771980c
   languageName: node
   linkType: hard
 
@@ -5310,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"
@@ -5418,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:
@@ -5437,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
@@ -5575,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
@@ -5719,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"
@@ -5796,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
 
@@ -5815,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"
@@ -5838,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:
@@ -5961,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
 
@@ -6002,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"
@@ -6346,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
@@ -6354,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
 
@@ -6733,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"
@@ -6743,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:
@@ -6754,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"
@@ -6909,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"
@@ -6973,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:
@@ -7192,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"
@@ -7584,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
 
@@ -7843,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
 
@@ -7915,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
@@ -7924,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
 
@@ -8100,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:
@@ -8173,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
 
@@ -8327,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:
@@ -8414,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:
@@ -8459,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"
@@ -8476,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"
@@ -8600,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:
@@ -8630,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"
@@ -8698,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
 
@@ -8790,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"
@@ -8834,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
@@ -8991,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"
@@ -9096,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
 
@@ -9154,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"
@@ -9303,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
 
@@ -9402,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"
@@ -9598,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"
@@ -9742,6 +9992,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"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"
@@ -10001,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
@@ -10733,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
@@ -10901,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
 
@@ -10948,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
 
@@ -11002,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
 
@@ -11101,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
 
@@ -11162,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
@@ -11249,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
 
@@ -11385,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
 
@@ -11435,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
@@ -11635,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"
@@ -11670,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"
@@ -11713,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"
@@ -11752,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"
@@ -11875,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"
@@ -11900,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
@@ -11942,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
 
@@ -11998,6 +12319,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"mime@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "mime@npm:3.0.0"
+  bin:
+    mime: cli.js
+  checksum: f43f9b7bfa64534e6b05bd6062961681aeb406a5b53673b53b683f27fcc4e739989941836a355eef831f4478923651ecc739f4a5f6e20a76487b432bfd4db928
+  languageName: node
+  linkType: hard
+
 "mimic-fn@npm:^1.0.0":
   version: 1.2.0
   resolution: "mimic-fn@npm:1.2.0"
@@ -12012,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"
@@ -12040,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:
@@ -12049,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
 
@@ -12065,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"
@@ -12080,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"
@@ -12116,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"
@@ -12125,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:
@@ -12173,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:
@@ -12193,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
 
@@ -12284,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"
@@ -12334,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
@@ -12385,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"
@@ -12402,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"
@@ -12409,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
 
@@ -12509,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
 
@@ -12583,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:
@@ -12595,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"
@@ -12662,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
 
@@ -12695,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
 
@@ -12958,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"
@@ -12990,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"
@@ -13437,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"
@@ -13673,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"
@@ -14317,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"
@@ -14376,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
 
@@ -14448,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"
@@ -14571,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"
@@ -14666,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
 
@@ -14721,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"
@@ -14878,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
 
@@ -15032,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
@@ -15047,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
 
@@ -15212,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
 
@@ -15254,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"
@@ -15287,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:
@@ -15313,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"
@@ -15423,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"
@@ -16128,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
 
@@ -16197,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"
@@ -16249,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"
@@ -16266,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:
@@ -16292,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
 
@@ -16369,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
@@ -16665,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"
@@ -16676,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"
@@ -16686,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"
@@ -16764,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"
@@ -16888,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:
@@ -16897,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"
@@ -17042,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:
@@ -17130,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"
@@ -17157,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:
@@ -17193,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:
@@ -17253,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"
@@ -17385,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
 
@@ -17449,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
 
@@ -17548,6 +18094,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"tippy.js@npm:^6.3.7":
+  version: 6.3.7
+  resolution: "tippy.js@npm:6.3.7"
+  dependencies:
+    "@popperjs/core": ^2.9.0
+  checksum: cac955318a65288e8d2dca05059878b003c6e66f92c94f7810f5bc5448eb6646abdf7dacc9bd00020e2611592598d0aae3a28ec9a45349a159603c3fdddce5fb
+  languageName: node
+  linkType: hard
+
 "tmp@npm:^0.0.33":
   version: 0.0.33
   resolution: "tmp@npm:0.0.33"
@@ -17567,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
 
@@ -17667,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
@@ -17690,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"
@@ -17850,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"
@@ -17857,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"
@@ -17915,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
 
@@ -18007,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"
@@ -18016,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"
@@ -18068,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"
@@ -18102,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
 
@@ -18374,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"
@@ -18561,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"
@@ -18610,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:
@@ -18632,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:
@@ -18651,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
 
@@ -18982,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"
@@ -19033,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"
@@ -19083,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"