From: Lisa Knox Date: Thu, 21 Dec 2023 14:06:01 +0000 (-0500) Subject: Merge branch '21128-toolbar-context-menu' X-Git-Url: https://git.arvados.org/arvados-workbench2.git/commitdiff_plain/HEAD?hp=d3518cf6eb602101cad0fb90c534499b7eb6cbca Merge branch '21128-toolbar-context-menu' closes #21128 Arvados-DCO-1.1-Signed-off-by: Lisa Knox --- diff --git a/.yarn/releases/yarn-3.2.0.cjs b/.yarn/releases/yarn-3.2.0.cjs index 59267757..b30d0655 100755 --- a/.yarn/releases/yarn-3.2.0.cjs +++ b/.yarn/releases/yarn-3.2.0.cjs @@ -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)||"":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=` 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=` 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||""} `)}};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. diff --git a/Makefile b/Makefile index 07c3b200..c7a9cbfb 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ GIT_COMMIT?=$(shell git rev-parse --short HEAD) # changes in the package. (i.e. example config files externally added ITERATION?=1 -TARGETS?=centos7 debian10 debian11 ubuntu1804 ubuntu2004 +TARGETS?=centos7 rocky8 debian10 debian11 ubuntu1804 ubuntu2004 ARVADOS_DIRECTORY?=unset @@ -36,6 +36,7 @@ DEB_FILE=$(APP_NAME)_$(VERSION)-$(ITERATION)_amd64.deb # redHat package file RPM_FILE=$(APP_NAME)-$(VERSION)-$(ITERATION).x86_64.rpm +GOPATH=$(shell go env GOPATH) export WORKSPACE?=$(shell pwd) .PHONY: help clean* yarn-install test build packages packages-with-version integration-tests-in-docker @@ -62,14 +63,15 @@ clean-node-modules: clean: clean-rpm clean-deb clean-node-modules -arvados-server-install: +arvados-server-install: check-arvados-directory cd $(ARVADOS_DIRECTORY) go mod download cd cmd/arvados-server - go install + echo GOPATH is $(GOPATH) + GOFLAGS=-buildvcs=false go install cd - - ls -l ~/go/bin/arvados-server - ~/go/bin/arvados-server install -type test + ls -l $(GOPATH)/bin/arvados-server + $(GOPATH)/bin/arvados-server install -type test yarn-install: arvados-server-install yarn install @@ -77,13 +79,19 @@ yarn-install: arvados-server-install unit-tests: yarn-install yarn test --no-watchAll --bail --ci -integration-tests: yarn-install +integration-tests: yarn-install check-arvados-directory yarn run cypress install $(WORKSPACE)/tools/run-integration-tests.sh -a $(ARVADOS_DIRECTORY) -integration-tests-in-docker: workbench2-build-image +integration-tests-in-docker: workbench2-build-image check-arvados-directory docker run -ti -v$(PWD):/usr/src/workbench2 -v$(ARVADOS_DIRECTORY):/usr/src/arvados -w /usr/src/workbench2 -e ARVADOS_DIRECTORY=/usr/src/arvados workbench2-build make integration-tests +unit-tests-in-docker: workbench2-build-image check-arvados-directory + docker run -ti -v$(PWD):/usr/src/workbench2 -v$(ARVADOS_DIRECTORY):/usr/src/arvados -w /usr/src/workbench2 -e ARVADOS_DIRECTORY=/usr/src/arvados workbench2-build make unit-tests + +tests-in-docker: workbench2-build-image check-arvados-directory + docker run -t -v$(PWD):/usr/src/workbench2 -v$(ARVADOS_DIRECTORY):/usr/src/arvados -w /usr/src/workbench2 -e ARVADOS_DIRECTORY=/usr/src/arvados -e ci="${ci}" workbench2-build make test + test: unit-tests integration-tests build: yarn-install @@ -122,16 +130,15 @@ $(RPM_FILE): build etc/arvados/workbench2/workbench2.example.json=/etc/arvados/$(APP_NAME)/workbench2.example.json copy: $(DEB_FILE) $(RPM_FILE) - for target in $(TARGETS) ; do \ - mkdir -p packages/$$target - if [[ $$target =~ ^centos ]]; then - cp -p $(RPM_FILE) packages/$$target ; \ - else - cp -p $(DEB_FILE) packages/$$target ; \ - fi - done - rm -f $(RPM_FILE) - rm -f $(DEB_FILE) + for target in $(TARGETS); do \ + mkdir -p "packages/$$target" && \ + case "$$target" in \ + centos*|rocky*) cp -p "$(RPM_FILE)" "packages/$$target" ;; \ + debian*|ubuntu*) cp -p "$(DEB_FILE)" "packages/$$target" ;; \ + *) echo "Unknown copy target $$target"; exit 1 ;; \ + esac ; \ + done ; \ + rm -f "$(DEB_FILE)" "$(RPM_FILE)" # use FPM to create DEB and RPM packages: copy @@ -144,12 +151,15 @@ packages-in-docker: check-arvados-directory workbench2-build-image docker run --env ci="true" \ --env ARVADOS_DIRECTORY=/tmp/arvados \ --env APP_NAME=${APP_NAME} \ + --env VERSION="${VERSION}" \ --env ITERATION=${ITERATION} \ --env TARGETS="${TARGETS}" \ + --env MAINTAINER="${MAINTAINER}" \ + --env DESCRIPTION="${DESCRIPTION}" \ -w="/tmp/workbench2" \ -t -v ${WORKSPACE}:/tmp/workbench2 \ -v ${ARVADOS_DIRECTORY}:/tmp/arvados workbench2-build:latest \ - make packages + sh -c 'git config --global --add safe.directory /tmp/workbench2 && make packages' workbench2-build-image: (cd docker && docker build -t workbench2-build .) diff --git a/cypress/fixtures/workflow_directory_array.yaml b/cypress/fixtures/workflow_directory_array.yaml new file mode 100644 index 00000000..fbdbd32c --- /dev/null +++ b/cypress/fixtures/workflow_directory_array.yaml @@ -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 index 6156909c..295bc380 100644 --- a/cypress/integration/banner-tooltip.spec.js +++ b/cypress/integration/banner-tooltip.spec.js @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0 -describe('Collection panel tests', function () { +describe('Banner / tooltip tests', function () { let activeUser; let adminUser; let collectionUUID; @@ -94,7 +94,6 @@ describe('Collection panel tests', function () { cy.getAll('@banner', '@tooltips') .then(([banner, tooltips]) => { - console.log(tooltips) cy.get('[data-cy=drag-and-drop]').upload(banner, 'banner.html', false); cy.get('[data-cy=drag-and-drop]').upload(tooltips, 'tooltips.json', false); }); diff --git a/cypress/integration/collection.spec.js b/cypress/integration/collection.spec.js index efde53e5..54c570f7 100644 --- a/cypress/integration/collection.spec.js +++ b/cypress/integration/collection.spec.js @@ -2,9 +2,9 @@ // // SPDX-License-Identifier: AGPL-3.0 -const path = require('path'); +const path = require("path"); -describe('Collection panel tests', function () { +describe("Collection panel tests", function () { let activeUser; let adminUser; let downloadsFolder; @@ -14,17 +14,17 @@ describe('Collection panel tests', function () { // aliases are cleaned up after every test. Also it doesn't make sense // to set the same users on beforeEach() over and over again, so we // separate a little from Cypress' 'Best Practices' here. - cy.getUser('admin', 'Admin', 'User', true, true) - .as('adminUser').then(function () { + cy.getUser("admin", "Admin", "User", true, true) + .as("adminUser") + .then(function () { adminUser = this.adminUser; - } - ); - cy.getUser('collectionuser1', 'Collection', 'User', false, true) - .as('activeUser').then(function () { + }); + cy.getUser("collectionuser1", "Collection", "User", false, true) + .as("activeUser") + .then(function () { activeUser = this.activeUser; - } - ); - downloadsFolder = Cypress.config('downloadsFolder'); + }); + downloadsFolder = Cypress.config("downloadsFolder"); }); beforeEach(function () { @@ -32,328 +32,364 @@ describe('Collection panel tests', function () { cy.clearLocalStorage(); }); - it('allows to download mountain duck config for a collection', () => { + it('shows the appropriate buttons in the toolbar', () => { + + const msButtonTooltips = [ + 'API Details', + 'Add to Favorites', + 'Copy to clipboard', + 'Edit collection', + 'Make a copy', + 'Move to', + 'Move to trash', + 'Open in new tab', + 'Open with 3rd party client', + 'Share', + 'View details', + ]; + + cy.loginAs(activeUser); + const name = `Test collection ${Math.floor(Math.random() * 999999)}`; + cy.get("[data-cy=side-panel-button]").click({force: true}); + cy.get("[data-cy=side-panel-new-collection]").click(); + cy.get("[data-cy=form-dialog]") + .should("contain", "New collection") + .within(() => { + cy.get("[data-cy=name-field]").within(() => { + cy.get("input").type(name); + }); + cy.get("[data-cy=form-submit-btn]").click(); + }); + cy.get("[data-cy=side-panel-tree]").contains("Home Projects").click(); + cy.waitForDom() + cy.get('[data-cy=data-table-row]').contains(name).should('exist').parent().parent().parent().parent().click() + cy.get('[data-cy=multiselect-button]').should('have.length', msButtonTooltips.length) + for (let i = 0; i < msButtonTooltips.length; i++) { + cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseover'); + cy.get('body').contains(msButtonTooltips[i]).should('exist') + cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseout'); + } + }) + + it("allows to download mountain duck config for a collection", () => { cy.createCollection(adminUser.token, { name: `Test collection ${Math.floor(Math.random() * 999999)}`, owner_uuid: activeUser.user.uuid, - manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n" + manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n", }) - .as('testCollection').then(function (testCollection) { - cy.loginAs(activeUser); - cy.goToPath(`/collections/${testCollection.uuid}`); - - cy.get('[data-cy=collection-panel-options-btn]').click(); - cy.get('[data-cy=context-menu]').contains('Open with 3rd party client').click(); - cy.get('[data-cy=download-button').click(); - - const filename = path.join(downloadsFolder, `${testCollection.name}.duck`); - - cy.readFile(filename, { timeout: 15000 }) - .then((body) => { - const childrenCollection = Array.prototype.slice.call(Cypress.$(body).find('dict')[0].children); - const map = {}; - let i, j = 2; - - for (i=0; i < childrenCollection.length; i += j) { - map[childrenCollection[i].outerText] = childrenCollection[i + 1].outerText; - } + .as("testCollection") + .then(function (testCollection) { + cy.loginAs(activeUser); + cy.goToPath(`/collections/${testCollection.uuid}`); - cy.get('#simple-tabpanel-0').find('a') - .then((a) => { - const [host, port] = a.text().split('@')[1].split('/')[0].split(':'); - expect(map['Protocol']).to.equal('davs'); - expect(map['UUID']).to.equal(testCollection.uuid); - expect(map['Username']).to.equal(activeUser.user.username); - expect(map['Port']).to.equal(port); - expect(map['Hostname']).to.equal(host); - if (map['Path']) { - expect(map['Path']).to.equal(`/c=${testCollection.uuid}`); - } - }); - }) - .then(() => cy.task('clearDownload', { filename })); - }); + cy.get("[data-cy=collection-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]").contains("Open with 3rd party client").click(); + cy.get("[data-cy=download-button").click(); + + const filename = path.join(downloadsFolder, `${testCollection.name}.duck`); + + cy.readFile(filename, { timeout: 15000 }) + .then(body => { + const childrenCollection = Array.prototype.slice.call(Cypress.$(body).find("dict")[0].children); + const map = {}; + let i, + j = 2; + + for (i = 0; i < childrenCollection.length; i += j) { + map[childrenCollection[i].outerText] = childrenCollection[i + 1].outerText; + } + + cy.get("#simple-tabpanel-0") + .find("a") + .then(a => { + const [host, port] = a.text().split("@")[1].split("/")[0].split(":"); + expect(map["Protocol"]).to.equal("davs"); + expect(map["UUID"]).to.equal(testCollection.uuid); + expect(map["Username"]).to.equal(activeUser.user.username); + expect(map["Port"]).to.equal(port); + expect(map["Hostname"]).to.equal(host); + if (map["Path"]) { + expect(map["Path"]).to.equal(`/c=${testCollection.uuid}`); + } + }); + }) + .then(() => cy.task("clearDownload", { filename })); + }); }); - it('attempts to use a preexisting name creating or updating a collection', function() { + it("attempts to use a preexisting name creating or updating a collection", function () { const name = `Test collection ${Math.floor(Math.random() * 999999)}`; cy.createCollection(adminUser.token, { name: name, owner_uuid: activeUser.user.uuid, - manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n" + manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n", }); cy.loginAs(activeUser); cy.goToPath(`/projects/${activeUser.user.uuid}`); - cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects'); - cy.get('[data-cy=breadcrumb-last]').should('not.exist'); + cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects"); + cy.get("[data-cy=breadcrumb-last]").should("not.exist"); // Attempt to create new collection with a duplicate name - cy.get('[data-cy=side-panel-button]').click(); - cy.get('[data-cy=side-panel-new-collection]').click(); - cy.get('[data-cy=form-dialog]') - .should('contain', 'New collection') + cy.get("[data-cy=side-panel-button]").click(); + cy.get("[data-cy=side-panel-new-collection]").click(); + cy.get("[data-cy=form-dialog]") + .should("contain", "New collection") .within(() => { - cy.get('[data-cy=name-field]').within(() => { - cy.get('input').type(name); + cy.get("[data-cy=name-field]").within(() => { + cy.get("input").type(name); }); - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[data-cy=form-submit-btn]").click(); }); // Error message should display, allowing editing the name - cy.get('[data-cy=form-dialog]').should('exist') - .and('contain', 'Collection with the same name already exists') + cy.get("[data-cy=form-dialog]") + .should("exist") + .and("contain", "Collection with the same name already exists") .within(() => { - cy.get('[data-cy=name-field]').within(() => { - cy.get('input').type(' renamed'); + cy.get("[data-cy=name-field]").within(() => { + cy.get("input").type(" renamed"); }); - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[data-cy=form-submit-btn]").click(); }); - cy.get('[data-cy=form-dialog]').should('not.exist'); + cy.get("[data-cy=form-dialog]").should("not.exist"); // Attempt to rename the collection with the duplicate name - cy.get('[data-cy=collection-panel-options-btn]').click(); - cy.get('[data-cy=context-menu]').contains('Edit collection').click(); - cy.get('[data-cy=form-dialog]') - .should('contain', 'Edit Collection') + cy.get("[data-cy=collection-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]").contains("Edit collection").click(); + cy.get("[data-cy=form-dialog]") + .should("contain", "Edit Collection") .within(() => { - cy.get('[data-cy=name-field]').within(() => { - cy.get('input') - .type('{selectall}{backspace}') - .type(name); + cy.get("[data-cy=name-field]").within(() => { + cy.get("input").type("{selectall}{backspace}").type(name); }); - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[data-cy=form-submit-btn]").click(); }); - cy.get('[data-cy=form-dialog]').should('exist') - .and('contain', 'Collection with the same name already exists'); + cy.get("[data-cy=form-dialog]").should("exist").and("contain", "Collection with the same name already exists"); }); - it('uses the property editor (from edit dialog) with vocabulary terms', function () { + + + it("uses the property editor (from edit dialog) with vocabulary terms", function () { cy.createCollection(adminUser.token, { name: `Test collection ${Math.floor(Math.random() * 999999)}`, owner_uuid: activeUser.user.uuid, - manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n" + manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n", }) - .as('testCollection').then(function () { + .as("testCollection") + .then(function () { cy.loginAs(activeUser); cy.goToPath(`/collections/${this.testCollection.uuid}`); - cy.get('[data-cy=collection-info-panel') - .should('contain', this.testCollection.name) - .and('not.contain', 'Color: Magenta'); + cy.get("[data-cy=collection-info-panel").should("contain", this.testCollection.name).and("not.contain", "Color: Magenta"); - cy.get('[data-cy=collection-panel-options-btn]').click(); - cy.get('[data-cy=context-menu]').contains('Edit collection').click(); - cy.get('[data-cy=form-dialog]').should('contain', 'Properties'); + cy.get("[data-cy=collection-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]").contains("Edit collection").click(); + cy.get("[data-cy=form-dialog]").should("contain", "Properties"); // Key: Color (IDTAGCOLORS) - Value: Magenta (IDVALCOLORS3) - cy.get('[data-cy=resource-properties-form]').within(() => { - cy.get('[data-cy=property-field-key]').within(() => { - cy.get('input').type('Color'); + cy.get("[data-cy=resource-properties-form]").within(() => { + cy.get("[data-cy=property-field-key]").within(() => { + cy.get("input").type("Color"); }); - cy.get('[data-cy=property-field-value]').within(() => { - cy.get('input').type('Magenta'); + cy.get("[data-cy=property-field-value]").within(() => { + cy.get("input").type("Magenta"); }); cy.root().submit(); }); // Confirm proper vocabulary labels are displayed on the UI. - cy.get('[data-cy=form-dialog]').should('contain', 'Color: Magenta'); - cy.get('[data-cy=form-dialog]').contains('Save').click(); - cy.get('[data-cy=form-dialog]').should('not.exist'); + cy.get("[data-cy=form-dialog]").should("contain", "Color: Magenta"); + cy.get("[data-cy=form-dialog]").contains("Save").click(); + cy.get("[data-cy=form-dialog]").should("not.exist"); // Confirm proper vocabulary IDs were saved on the backend. - cy.doRequest('GET', `/arvados/v1/collections/${this.testCollection.uuid}`) - .its('body').as('collection') + cy.doRequest("GET", `/arvados/v1/collections/${this.testCollection.uuid}`) + .its("body") + .as("collection") .then(function () { - expect(this.collection.properties.IDTAGCOLORS).to.equal('IDVALCOLORS3'); + expect(this.collection.properties.IDTAGCOLORS).to.equal("IDVALCOLORS3"); }); // Confirm the property is displayed on the UI. - cy.get('[data-cy=collection-info-panel') - .should('contain', this.testCollection.name) - .and('contain', 'Color: Magenta'); + cy.get("[data-cy=collection-info-panel").should("contain", this.testCollection.name).and("contain", "Color: Magenta"); }); }); - it('uses the editor (from details panel) with vocabulary terms', function () { + + + it("uses the editor (from details panel) with vocabulary terms", function () { cy.createCollection(adminUser.token, { name: `Test collection ${Math.floor(Math.random() * 999999)}`, owner_uuid: activeUser.user.uuid, - manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n" + manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n", }) - .as('testCollection').then(function () { + .as("testCollection") + .then(function () { cy.loginAs(activeUser); cy.goToPath(`/collections/${this.testCollection.uuid}`); - cy.get('[data-cy=collection-info-panel') - .should('contain', this.testCollection.name) - .and('not.contain', 'Color: Magenta') - .and('not.contain', 'Size: S'); - cy.get('[data-cy=additional-info-icon]').click(); + cy.get("[data-cy=collection-info-panel") + .should("contain", this.testCollection.name) + .and("not.contain", "Color: Magenta") + .and("not.contain", "Size: S"); + cy.get("[data-cy=additional-info-icon]").click(); - cy.get('[data-cy=details-panel]').within(() => { - cy.get('[data-cy=details-panel-edit-btn]').click(); + cy.get("[data-cy=details-panel]").within(() => { + cy.get("[data-cy=details-panel-edit-btn]").click(); }); - cy.get('[data-cy=form-dialog').contains('Edit Collection'); + cy.get("[data-cy=form-dialog").contains("Edit Collection"); // Key: Color (IDTAGCOLORS) - Value: Magenta (IDVALCOLORS3) - cy.get('[data-cy=resource-properties-form]').within(() => { - cy.get('[data-cy=property-field-key]').within(() => { - cy.get('input').type('Color'); + cy.get("[data-cy=resource-properties-form]").within(() => { + cy.get("[data-cy=property-field-key]").within(() => { + cy.get("input").type("Color"); }); - cy.get('[data-cy=property-field-value]').within(() => { - cy.get('input').type('Magenta'); + cy.get("[data-cy=property-field-value]").within(() => { + cy.get("input").type("Magenta"); }); cy.root().submit(); }); // Confirm proper vocabulary labels are displayed on the UI. - cy.get('[data-cy=form-dialog]') - .should('contain', 'Color: Magenta'); + cy.get("[data-cy=form-dialog]").should("contain", "Color: Magenta"); // Case-insensitive on-blur auto-selection test // Key: Size (IDTAGSIZES) - Value: Small (IDVALSIZES2) - cy.get('[data-cy=resource-properties-form]').within(() => { - cy.get('[data-cy=property-field-key]').within(() => { - cy.get('input').type('sIzE'); + cy.get("[data-cy=resource-properties-form]").within(() => { + cy.get("[data-cy=property-field-key]").within(() => { + cy.get("input").type("sIzE"); }); - cy.get('[data-cy=property-field-value]').within(() => { - cy.get('input').type('sMaLL'); + cy.get("[data-cy=property-field-value]").within(() => { + cy.get("input").type("sMaLL"); }); // Cannot "type()" TAB on Cypress so let's click another field // to trigger the onBlur event. - cy.get('[data-cy=property-field-key]').click(); + cy.get("[data-cy=property-field-key]").click(); cy.root().submit(); }); // Confirm proper vocabulary labels are displayed on the UI. - cy.get('[data-cy=form-dialog]') - .should('contain', 'Size: S'); + cy.get("[data-cy=form-dialog]").should("contain", "Size: S"); - cy.get('[data-cy=form-dialog]').contains('Save').click(); - cy.get('[data-cy=form-dialog]').should('not.exist'); + cy.get("[data-cy=form-dialog]").contains("Save").click(); + cy.get("[data-cy=form-dialog]").should("not.exist"); // Confirm proper vocabulary IDs were saved on the backend. - cy.doRequest('GET', `/arvados/v1/collections/${this.testCollection.uuid}`) - .its('body').as('collection') + cy.doRequest("GET", `/arvados/v1/collections/${this.testCollection.uuid}`) + .its("body") + .as("collection") .then(function () { - expect(this.collection.properties.IDTAGCOLORS).to.equal('IDVALCOLORS3'); - expect(this.collection.properties.IDTAGSIZES).to.equal('IDVALSIZES2'); + expect(this.collection.properties.IDTAGCOLORS).to.equal("IDVALCOLORS3"); + expect(this.collection.properties.IDTAGSIZES).to.equal("IDVALSIZES2"); }); // Confirm properties display on the UI. - cy.get('[data-cy=collection-info-panel') - .should('contain', this.testCollection.name) - .and('contain', 'Color: Magenta') - .and('contain', 'Size: S'); + cy.get("[data-cy=collection-info-panel") + .should("contain", this.testCollection.name) + .and("contain", "Color: Magenta") + .and("contain", "Size: S"); }); }); - it('shows collection by URL', function () { + it("shows collection by URL", function () { cy.loginAs(activeUser); [true, false].map(function (isWritable) { // Using different file names to avoid test flakyness: the second iteration // on this loop may pass an assertion from the first iteration by looking // for the same file name. - const fileName = isWritable ? 'bar' : 'foo'; - const subDirName = 'subdir'; + const fileName = isWritable ? "bar" : "foo"; + const subDirName = "subdir"; cy.createGroup(adminUser.token, { - name: 'Shared project', - group_class: 'project', - }).as('sharedGroup').then(function () { - // Creates the collection using the admin token so we can set up - // a bogus manifest text without block signatures. - cy.doRequest('GET', '/arvados/v1/config', null, null) - .its('body').should((clusterConfig) => { - expect(clusterConfig.Collections, "clusterConfig").to.have.property("TrustAllContent", true); - expect(clusterConfig.Services, "clusterConfig").to.have.property("WebDAV").have.property("ExternalURL"); - expect(clusterConfig.Services, "clusterConfig").to.have.property("WebDAVDownload").have.property("ExternalURL"); - const inlineUrl = clusterConfig.Services.WebDAV.ExternalURL !== "" - ? clusterConfig.Services.WebDAV.ExternalURL - : clusterConfig.Services.WebDAVDownload.ExternalURL; - expect(inlineUrl).to.not.contain("*"); - }) - .createCollection(adminUser.token, { - name: 'Test collection', - owner_uuid: this.sharedGroup.uuid, - properties: { someKey: 'someValue' }, - manifest_text: `. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n./${subDirName} 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n` - }) - .as('testCollection').then(function () { - // Share the group with active user. - cy.createLink(adminUser.token, { - name: isWritable ? 'can_write' : 'can_read', - link_class: 'permission', - head_uuid: this.sharedGroup.uuid, - tail_uuid: activeUser.user.uuid + name: "Shared project", + group_class: "project", + }) + .as("sharedGroup") + .then(function () { + // Creates the collection using the admin token so we can set up + // a bogus manifest text without block signatures. + cy.doRequest("GET", "/arvados/v1/config", null, null) + .its("body") + .should(clusterConfig => { + expect(clusterConfig.Collections, "clusterConfig").to.have.property("TrustAllContent", true); + expect(clusterConfig.Services, "clusterConfig").to.have.property("WebDAV").have.property("ExternalURL"); + expect(clusterConfig.Services, "clusterConfig").to.have.property("WebDAVDownload").have.property("ExternalURL"); + const inlineUrl = + clusterConfig.Services.WebDAV.ExternalURL !== "" + ? clusterConfig.Services.WebDAV.ExternalURL + : clusterConfig.Services.WebDAVDownload.ExternalURL; + expect(inlineUrl).to.not.contain("*"); + }) + .createCollection(adminUser.token, { + name: "Test collection", + owner_uuid: this.sharedGroup.uuid, + properties: { someKey: "someValue" }, + manifest_text: `. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n./${subDirName} 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n`, }) - cy.goToPath(`/collections/${this.testCollection.uuid}`); - - // Check that name & uuid are correct. - cy.get('[data-cy=collection-info-panel]') - .should('contain', this.testCollection.name) - .and('contain', this.testCollection.uuid) - .and('not.contain', 'This is an old version'); - // Check for the read-only icon - cy.get('[data-cy=read-only-icon]').should(`${isWritable ? 'not.' : ''}exist`); - // Check that both read and write operations are available on - // the 'More options' menu. - cy.get('[data-cy=collection-panel-options-btn]') - .click() - cy.get('[data-cy=context-menu]') - .should('contain', 'Add to favorites') - .and(`${isWritable ? '' : 'not.'}contain`, 'Edit collection'); - cy.get('body').click(); // Collapse the menu avoiding details panel expansion - cy.get('[data-cy=collection-info-panel]') - .should('contain', 'someKey: someValue') - .and('not.contain', 'anotherKey: anotherValue'); - // Check that the file listing show both read & write operations - cy.waitForDom().get('[data-cy=collection-files-panel]').within(() => { - cy.get('[data-cy=collection-files-right-panel]', { timeout: 5000 }) - .should('contain', fileName); - if (isWritable) { - cy.get('[data-cy=upload-button]') - .should(`${isWritable ? '' : 'not.'}contain`, 'Upload data'); - } + .as("testCollection") + .then(function () { + // Share the group with active user. + cy.createLink(adminUser.token, { + name: isWritable ? "can_write" : "can_read", + link_class: "permission", + head_uuid: this.sharedGroup.uuid, + tail_uuid: activeUser.user.uuid, + }); + cy.goToPath(`/collections/${this.testCollection.uuid}`); + + // Check that name & uuid are correct. + cy.get("[data-cy=collection-info-panel]") + .should("contain", this.testCollection.name) + .and("contain", this.testCollection.uuid) + .and("not.contain", "This is an old version"); + // Check for the read-only icon + cy.get("[data-cy=read-only-icon]").should(`${isWritable ? "not." : ""}exist`); + // Check that both read and write operations are available on + // the 'More options' menu. + cy.get("[data-cy=collection-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]") + .should("contain", "Add to favorites") + .and(`${isWritable ? "" : "not."}contain`, "Edit collection"); + cy.get("body").click(); // Collapse the menu avoiding details panel expansion + cy.get("[data-cy=collection-info-panel]") + .should("contain", "someKey: someValue") + .and("not.contain", "anotherKey: anotherValue"); + // Check that the file listing show both read & write operations + cy.waitForDom() + .get("[data-cy=collection-files-panel]") + .within(() => { + cy.get("[data-cy=collection-files-right-panel]", { timeout: 5000 }).should("contain", fileName); + if (isWritable) { + cy.get("[data-cy=upload-button]").should(`${isWritable ? "" : "not."}contain`, "Upload data"); + } + }); + // Test context menus + cy.get("[data-cy=collection-files-panel]").contains(fileName).rightclick(); + cy.get("[data-cy=context-menu]") + .should("contain", "Download") + .and("contain", "Open in new tab") + .and("contain", "Copy to clipboard") + .and(`${isWritable ? "" : "not."}contain`, "Rename") + .and(`${isWritable ? "" : "not."}contain`, "Remove"); + cy.get("body").click(); // Collapse the menu + cy.get("[data-cy=collection-files-panel]").contains(subDirName).rightclick(); + cy.get("[data-cy=context-menu]") + .should("not.contain", "Download") + .and("contain", "Open in new tab") + .and("contain", "Copy to clipboard") + .and(`${isWritable ? "" : "not."}contain`, "Rename") + .and(`${isWritable ? "" : "not."}contain`, "Remove"); + cy.get("body").click(); // Collapse the menu + // File/dir item 'more options' button + cy.get("[data-cy=file-item-options-btn").first().click(); + cy.get("[data-cy=context-menu]").should(`${isWritable ? "" : "not."}contain`, "Remove"); + cy.get("body").click(); // Collapse the menu + // Hamburger 'more options' menu button + cy.get("[data-cy=collection-files-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]").should("contain", "Select all").click(); + cy.get("[data-cy=collection-files-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]").should(`${isWritable ? "" : "not."}contain`, "Remove selected"); + cy.get("body").click(); // Collapse the menu }); - // Test context menus - cy.get('[data-cy=collection-files-panel]') - .contains(fileName).rightclick(); - cy.get('[data-cy=context-menu]') - .should('contain', 'Download') - .and('contain', 'Open in new tab') - .and('contain', 'Copy to clipboard') - .and(`${isWritable ? '' : 'not.'}contain`, 'Rename') - .and(`${isWritable ? '' : 'not.'}contain`, 'Remove'); - cy.get('body').click(); // Collapse the menu - cy.get('[data-cy=collection-files-panel]') - .contains(subDirName).rightclick(); - cy.get('[data-cy=context-menu]') - .should('not.contain', 'Download') - .and('contain', 'Open in new tab') - .and('contain', 'Copy to clipboard') - .and(`${isWritable ? '' : 'not.'}contain`, 'Rename') - .and(`${isWritable ? '' : 'not.'}contain`, 'Remove'); - cy.get('body').click(); // Collapse the menu - // File/dir item 'more options' button - cy.get('[data-cy=file-item-options-btn') - .first() - .click() - cy.get('[data-cy=context-menu]') - .should(`${isWritable ? '' : 'not.'}contain`, 'Remove'); - cy.get('body').click(); // Collapse the menu - // Hamburger 'more options' menu button - cy.get('[data-cy=collection-files-panel-options-btn]') - .click() - cy.get('[data-cy=context-menu]') - .should('contain', 'Select all') - .click() - cy.get('[data-cy=collection-files-panel-options-btn]') - .click() - cy.get('[data-cy=context-menu]') - .should(`${isWritable ? '' : 'not.'}contain`, 'Remove selected') - cy.get('body').click(); // Collapse the menu - }) - }) - }) - }) + }); + }); + }); - it('renames a file using valid names', function () { - function eachPair(lst, func){ - for(var i=0; i < lst.length - 1; i++){ - func(lst[i], lst[i + 1]) + it("renames a file using valid names", function () { + function eachPair(lst, func) { + for (var i = 0; i < lst.length - 1; i++) { + func(lst[i], lst[i + 1]); } } // Creates the collection using the admin token so we can set up @@ -361,185 +397,171 @@ describe('Collection panel tests', function () { cy.createCollection(adminUser.token, { name: `Test collection ${Math.floor(Math.random() * 999999)}`, owner_uuid: activeUser.user.uuid, - manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n" + manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n", }) - .as('testCollection').then(function () { + .as("testCollection") + .then(function () { cy.loginAs(activeUser); cy.goToPath(`/collections/${this.testCollection.uuid}`); const names = [ - 'bar', // initial name already set - '&', - 'foo', - '&', - '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", + "&", + "I ❤️ ⛵️", + "...", + "#..", + "some name with whitespaces", + "some name with #2", + "is this name legal? I hope it is", + "some_file.pdf#", + "some_file.pdf?", + "?some_file.pdf", + "some%file.pdf", + "some%2Ffile.pdf", + "some%22file.pdf", + "some%20file.pdf", "G%C3%BCnter's%20file.pdf", - 'table%&?*2', - 'bar' // make sure we can go back to the original name as a last step + "table%&?*2", + "bar", // make sure we can go back to the original name as a last step ]; + cy.intercept({ method: "PUT", url: "**/arvados/v1/collections/*" }).as("renameRequest"); eachPair(names, (from, to) => { - cy.waitForDom().get('[data-cy=collection-files-panel]') - .contains(`${from}`).rightclick(); - cy.get('[data-cy=context-menu]') - .contains('Rename') - .click(); - cy.get('[data-cy=form-dialog]') - .should('contain', 'Rename') + cy.waitForDom().get("[data-cy=collection-files-panel]").contains(`${from}`).rightclick(); + cy.get("[data-cy=context-menu]").contains("Rename").click(); + cy.get("[data-cy=form-dialog]") + .should("contain", "Rename") .within(() => { - cy.get('input') - .type('{selectall}{backspace}') - .type(to, { parseSpecialCharSequences: false }); + cy.get("input").type("{selectall}{backspace}").type(to, { parseSpecialCharSequences: false }); }); - cy.get('[data-cy=form-submit-btn]').click(); - cy.get('[data-cy=collection-files-panel]') - .should('not.contain', `${from}`) - .and('contain', `${to}`); - }) + cy.get("[data-cy=form-submit-btn]").click(); + cy.wait("@renameRequest"); + cy.get("[data-cy=collection-files-panel]").should("not.contain", `${from}`).and("contain", `${to}`); + }); }); }); - it('renames a file to a different directory', function () { + it("renames a file to a different directory", function () { // Creates the collection using the admin token so we can set up // a bogus manifest text without block signatures. cy.createCollection(adminUser.token, { name: `Test collection ${Math.floor(Math.random() * 999999)}`, owner_uuid: activeUser.user.uuid, - manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n" + manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n", }) - .as('testCollection').then(function () { + .as("testCollection") + .then(function () { cy.loginAs(activeUser); cy.goToPath(`/collections/${this.testCollection.uuid}`); - ['subdir', 'G%C3%BCnter\'s%20file', 'table%&?*2'].forEach((subdir) => { - cy.waitForDom().get('[data-cy=collection-files-panel]') - .contains('bar').rightclick(); - cy.get('[data-cy=context-menu]') - .contains('Rename') - .click(); - cy.get('[data-cy=form-dialog]') - .should('contain', 'Rename') + ["subdir", "G%C3%BCnter's%20file", "table%&?*2"].forEach(subdir => { + cy.waitForDom().get("[data-cy=collection-files-panel]").contains("bar").rightclick(); + cy.get("[data-cy=context-menu]").contains("Rename").click(); + cy.get("[data-cy=form-dialog]") + .should("contain", "Rename") .within(() => { - cy.get('input').type(`{selectall}{backspace}${subdir}/foo`); + cy.get("input").type(`{selectall}{backspace}${subdir}/foo`); }); - cy.get('[data-cy=form-submit-btn]').click(); - cy.get('[data-cy=collection-files-panel]') - .should('not.contain', 'bar') - .and('contain', subdir); - cy.get('[data-cy=collection-files-panel]').contains(subdir).click(); + cy.get("[data-cy=form-submit-btn]").click(); + cy.get("[data-cy=collection-files-panel]").should("not.contain", "bar").and("contain", subdir); + cy.get("[data-cy=collection-files-panel]").contains(subdir).click(); // Rename 'subdir/foo' to 'bar' cy.wait(1000); - cy.get('[data-cy=collection-files-panel]') - .contains('foo').rightclick(); - cy.get('[data-cy=context-menu]') - .contains('Rename') - .click(); - cy.get('[data-cy=form-dialog]') - .should('contain', 'Rename') + cy.get("[data-cy=collection-files-panel]").contains("foo").rightclick(); + cy.get("[data-cy=context-menu]").contains("Rename").click(); + cy.get("[data-cy=form-dialog]") + .should("contain", "Rename") .within(() => { - cy.get('input') - .should('have.value', `${subdir}/foo`) - .type(`{selectall}{backspace}bar`); + cy.get("input").should("have.value", `${subdir}/foo`).type(`{selectall}{backspace}bar`); }); - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[data-cy=form-submit-btn]").click(); + + // need to wait for dialog to dismiss + cy.get("[data-cy=form-dialog]").should("not.exist"); - cy.get('[data-cy=collection-files-panel]') - .contains('Home') - .click(); + cy.waitForDom().get("[data-cy=collection-files-panel]").contains("Home").click(); cy.wait(2000); - cy.get('[data-cy=collection-files-panel]') - .should('contain', subdir) // empty dir kept - .and('contain', 'bar'); - - cy.get('[data-cy=collection-files-panel]') - .contains(subdir).rightclick(); - cy.get('[data-cy=context-menu]') - .contains('Remove') - .click(); - cy.get('[data-cy=confirmation-dialog-ok-btn]').click(); + cy.get("[data-cy=collection-files-panel]") + .should("contain", subdir) // empty dir kept + .and("contain", "bar"); + + cy.get("[data-cy=collection-files-panel]").contains(subdir).rightclick(); + cy.get("[data-cy=context-menu]").contains("Remove").click(); + cy.get("[data-cy=confirmation-dialog-ok-btn]").click(); + cy.get("[data-cy=form-dialog]").should("not.exist"); }); }); }); - it('shows collection owner', () => { + it("shows collection owner", () => { cy.createCollection(adminUser.token, { name: `Test collection ${Math.floor(Math.random() * 999999)}`, owner_uuid: activeUser.user.uuid, - manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n" + manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n", }) - .as('testCollection').then((testCollection) => { + .as("testCollection") + .then(testCollection => { cy.loginAs(activeUser); cy.goToPath(`/collections/${testCollection.uuid}`); cy.wait(5000); - cy.get('[data-cy=collection-info-panel]').contains(`Collection User`); + cy.get("[data-cy=collection-info-panel]").contains(`Collection User`); }); }); - it('tries to rename a file with illegal names', function () { + it("tries to rename a file with illegal names", function () { // Creates the collection using the admin token so we can set up // a bogus manifest text without block signatures. cy.createCollection(adminUser.token, { name: `Test collection ${Math.floor(Math.random() * 999999)}`, owner_uuid: activeUser.user.uuid, - manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n" + manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n", }) - .as('testCollection').then(function () { + .as("testCollection") + .then(function () { cy.loginAs(activeUser); cy.goToPath(`/collections/${this.testCollection.uuid}`); const illegalNamesFromUI = [ - ['.', "Name cannot be '.' or '..'"], - ['..', "Name cannot be '.' or '..'"], - ['', 'This field is required'], - [' ', 'Leading/trailing whitespaces not allowed'], - [' foo', 'Leading/trailing whitespaces not allowed'], - ['foo ', 'Leading/trailing whitespaces not allowed'], - ['//foo', 'Empty dir name not allowed'] - ] + [".", "Name cannot be '.' or '..'"], + ["..", "Name cannot be '.' or '..'"], + ["", "This field is required"], + [" ", "Leading/trailing whitespaces not allowed"], + [" foo", "Leading/trailing whitespaces not allowed"], + ["foo ", "Leading/trailing whitespaces not allowed"], + ["//foo", "Empty dir name not allowed"], + ]; illegalNamesFromUI.forEach(([name, errMsg]) => { - cy.get('[data-cy=collection-files-panel]') - .contains('bar').rightclick(); - cy.get('[data-cy=context-menu]') - .contains('Rename') - .click(); - cy.get('[data-cy=form-dialog]') - .should('contain', 'Rename') + cy.get("[data-cy=collection-files-panel]").contains("bar").rightclick(); + cy.get("[data-cy=context-menu]").contains("Rename").click(); + cy.get("[data-cy=form-dialog]") + .should("contain", "Rename") .within(() => { - cy.get('input').type(`{selectall}{backspace}${name}`); + cy.get("input").type(`{selectall}{backspace}${name}`); }); - cy.get('[data-cy=form-dialog]') - .should('contain', 'Rename') + cy.get("[data-cy=form-dialog]") + .should("contain", "Rename") .within(() => { cy.contains(`${errMsg}`); }); - cy.get('[data-cy=form-cancel-btn]').click(); - }) + cy.get("[data-cy=form-cancel-btn]").click(); + }); }); }); - it('can correctly display old versions', function () { + it("can correctly display old versions", function () { const colName = `Versioned Collection ${Math.floor(Math.random() * 999999)}`; - let colUuid = ''; - let oldVersionUuid = ''; + let colUuid = ""; + let oldVersionUuid = ""; // Make sure no other collections with this name exist - cy.doRequest('GET', '/arvados/v1/collections', null, { + cy.doRequest("GET", "/arvados/v1/collections", null, { filters: `[["name", "=", "${colName}"]]`, - include_old_versions: true + include_old_versions: true, }) - .its('body.items').as('collections') + .its("body.items") + .as("collections") .then(function () { expect(this.collections).to.be.empty; }); @@ -549,21 +571,23 @@ describe('Collection panel tests', function () { name: colName, owner_uuid: activeUser.user.uuid, preserve_version: true, - manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n" + manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n", }) - .as('originalVersion').then(function () { + .as("originalVersion") + .then(function () { // Change the file name to create a new version. cy.updateCollection(adminUser.token, this.originalVersion.uuid, { - manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n" - }) + manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n", + }); colUuid = this.originalVersion.uuid; }); // Confirm that there are 2 versions of the collection - cy.doRequest('GET', '/arvados/v1/collections', null, { + cy.doRequest("GET", "/arvados/v1/collections", null, { filters: `[["name", "=", "${colName}"]]`, - include_old_versions: true + include_old_versions: true, }) - .its('body.items').as('collections') + .its("body.items") + .as("collections") .then(function () { expect(this.collections).to.have.lengthOf(2); this.collections.map(function (aCollection) { @@ -573,82 +597,80 @@ describe('Collection panel tests', function () { } }); // Check the old version displays as what it is. - cy.loginAs(activeUser) + cy.loginAs(activeUser); cy.goToPath(`/collections/${oldVersionUuid}`); - cy.get('[data-cy=collection-info-panel]').should('contain', 'This is an old version'); - cy.get('[data-cy=read-only-icon]').should('exist'); - cy.get('[data-cy=collection-info-panel]').should('contain', colName); - cy.get('[data-cy=collection-files-panel]').should('contain', 'bar'); + cy.get("[data-cy=collection-info-panel]").should("contain", "This is an old version"); + cy.get("[data-cy=read-only-icon]").should("exist"); + cy.get("[data-cy=collection-info-panel]").should("contain", colName); + cy.get("[data-cy=collection-files-panel]").should("contain", "bar"); }); }); - it('views & edits storage classes data', function () { - const colName= `Test Collection ${Math.floor(Math.random() * 999999)}`; + it("views & edits storage classes data", function () { + const colName = `Test Collection ${Math.floor(Math.random() * 999999)}`; cy.createCollection(adminUser.token, { name: colName, owner_uuid: activeUser.user.uuid, manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:some-file\n", - }).as('collection').then(function () { - expect(this.collection.storage_classes_desired).to.deep.equal(['default']) - - cy.loginAs(activeUser) - cy.goToPath(`/collections/${this.collection.uuid}`); - - // Initial check: it should show the 'default' storage class - cy.get('[data-cy=collection-info-panel]') - .should('contain', 'Storage classes') - .and('contain', 'default') - .and('not.contain', 'foo') - .and('not.contain', 'bar'); - // Edit collection: add storage class 'foo' - cy.get('[data-cy=collection-panel-options-btn]').click(); - cy.get('[data-cy=context-menu]').contains('Edit collection').click(); - cy.get('[data-cy=form-dialog]') - .should('contain', 'Edit Collection') - .and('contain', 'Storage classes') - .and('contain', 'default') - .and('contain', 'foo') - .and('contain', 'bar') - .within(() => { - cy.get('[data-cy=checkbox-foo]').click(); - }); - cy.get('[data-cy=form-submit-btn]').click(); - cy.get('[data-cy=collection-info-panel]') - .should('contain', 'default') - .and('contain', 'foo') - .and('not.contain', 'bar'); - cy.doRequest('GET', `/arvados/v1/collections/${this.collection.uuid}`) - .its('body').as('updatedCollection') - .then(function () { - expect(this.updatedCollection.storage_classes_desired).to.deep.equal(['default', 'foo']); - }); - // Edit collection: remove storage class 'default' - cy.get('[data-cy=collection-panel-options-btn]').click(); - cy.get('[data-cy=context-menu]').contains('Edit collection').click(); - cy.get('[data-cy=form-dialog]') - .should('contain', 'Edit Collection') - .and('contain', 'Storage classes') - .and('contain', 'default') - .and('contain', 'foo') - .and('contain', 'bar') - .within(() => { - cy.get('[data-cy=checkbox-default]').click(); - }); - cy.get('[data-cy=form-submit-btn]').click(); - cy.get('[data-cy=collection-info-panel]') - .should('not.contain', 'default') - .and('contain', 'foo') - .and('not.contain', 'bar'); - cy.doRequest('GET', `/arvados/v1/collections/${this.collection.uuid}`) - .its('body').as('updatedCollection') - .then(function () { - expect(this.updatedCollection.storage_classes_desired).to.deep.equal(['foo']); - }); }) + .as("collection") + .then(function () { + expect(this.collection.storage_classes_desired).to.deep.equal(["default"]); + + cy.loginAs(activeUser); + cy.goToPath(`/collections/${this.collection.uuid}`); + + // Initial check: it should show the 'default' storage class + cy.get("[data-cy=collection-info-panel]") + .should("contain", "Storage classes") + .and("contain", "default") + .and("not.contain", "foo") + .and("not.contain", "bar"); + // Edit collection: add storage class 'foo' + cy.get("[data-cy=collection-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]").contains("Edit collection").click(); + cy.get("[data-cy=form-dialog]") + .should("contain", "Edit Collection") + .and("contain", "Storage classes") + .and("contain", "default") + .and("contain", "foo") + .and("contain", "bar") + .within(() => { + cy.get("[data-cy=checkbox-foo]").click(); + }); + cy.get("[data-cy=form-submit-btn]").click(); + cy.get("[data-cy=collection-info-panel]").should("contain", "default").and("contain", "foo").and("not.contain", "bar"); + cy.doRequest("GET", `/arvados/v1/collections/${this.collection.uuid}`) + .its("body") + .as("updatedCollection") + .then(function () { + expect(this.updatedCollection.storage_classes_desired).to.deep.equal(["default", "foo"]); + }); + // Edit collection: remove storage class 'default' + cy.get("[data-cy=collection-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]").contains("Edit collection").click(); + cy.get("[data-cy=form-dialog]") + .should("contain", "Edit Collection") + .and("contain", "Storage classes") + .and("contain", "default") + .and("contain", "foo") + .and("contain", "bar") + .within(() => { + cy.get("[data-cy=checkbox-default]").click(); + }); + cy.get("[data-cy=form-submit-btn]").click(); + cy.get("[data-cy=collection-info-panel]").should("not.contain", "default").and("contain", "foo").and("not.contain", "bar"); + cy.doRequest("GET", `/arvados/v1/collections/${this.collection.uuid}`) + .its("body") + .as("updatedCollection") + .then(function () { + expect(this.updatedCollection.storage_classes_desired).to.deep.equal(["foo"]); + }); + }); }); - it('moves a collection to a different project', function () { + it("moves a collection to a different project", function () { const collName = `Test Collection ${Math.floor(Math.random() * 999999)}`; const projName = `Test Project ${Math.floor(Math.random() * 999999)}`; const fileName = `Test_File_${Math.floor(Math.random() * 999999)}`; @@ -657,86 +679,73 @@ describe('Collection panel tests', function () { name: collName, owner_uuid: activeUser.user.uuid, manifest_text: `. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n`, - }).as('testCollection'); + }).as("testCollection"); cy.createGroup(adminUser.token, { name: projName, - group_class: 'project', + group_class: "project", owner_uuid: activeUser.user.uuid, - }).as('testProject'); + }).as("testProject"); - cy.getAll('@testCollection', '@testProject') - .then(function ([testCollection, testProject]) { - cy.loginAs(activeUser); - cy.goToPath(`/collections/${testCollection.uuid}`); - cy.get('[data-cy=collection-files-panel]').should('contain', fileName); - cy.get('[data-cy=collection-info-panel]') - .should('not.contain', projName) - .and('not.contain', testProject.uuid); - cy.get('[data-cy=collection-panel-options-btn]').click(); - cy.get('[data-cy=context-menu]').contains('Move to').click(); - cy.get('[data-cy=form-dialog]') - .should('contain', 'Move to') - .within(() => { - // must use .then to avoid selecting instead of expanding https://github.com/cypress-io/cypress/issues/5529 - cy.get('[data-cy=projects-tree-home-tree-picker]') - .find('i') - .then(el => el.click()); - cy.get('[data-cy=projects-tree-home-tree-picker]') - .contains(projName) - .click(); - }); - cy.get('[data-cy=form-submit-btn]').click(); - cy.get('[data-cy=snackbar]') - .contains('Collection has been moved') - cy.get('[data-cy=collection-info-panel]') - .contains(projName).and('contain', testProject.uuid); - // Double check that the collection is in the project - cy.goToPath(`/projects/${testProject.uuid}`); - cy.waitForDom().get('[data-cy=project-panel]').should('contain', collName); - }); + cy.getAll("@testCollection", "@testProject").then(function ([testCollection, testProject]) { + cy.loginAs(activeUser); + cy.goToPath(`/collections/${testCollection.uuid}`); + cy.get("[data-cy=collection-files-panel]").should("contain", fileName); + cy.get("[data-cy=collection-info-panel]").should("not.contain", projName).and("not.contain", testProject.uuid); + cy.get("[data-cy=collection-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]").contains("Move to").click(); + cy.get("[data-cy=form-dialog]") + .should("contain", "Move to") + .within(() => { + // must use .then to avoid selecting instead of expanding https://github.com/cypress-io/cypress/issues/5529 + cy.get("[data-cy=projects-tree-home-tree-picker]") + .find("i") + .then(el => el.click()); + cy.get("[data-cy=projects-tree-home-tree-picker]").contains(projName).click(); + }); + cy.get("[data-cy=form-submit-btn]").click(); + cy.get("[data-cy=snackbar]").contains("Collection has been moved"); + cy.get("[data-cy=collection-info-panel]").contains(projName).and("contain", testProject.uuid); + // Double check that the collection is in the project + cy.goToPath(`/projects/${testProject.uuid}`); + cy.waitForDom().get("[data-cy=project-panel]").should("contain", collName); + }); }); - it('automatically updates the collection UI contents without using the Refresh button', function () { + it("automatically updates the collection UI contents without using the Refresh button", function () { const collName = `Test Collection ${Math.floor(Math.random() * 999999)}`; cy.createCollection(adminUser.token, { name: collName, owner_uuid: activeUser.user.uuid, - }).as('testCollection'); + }).as("testCollection"); - cy.getAll('@testCollection').then(function ([testCollection]) { + cy.getAll("@testCollection").then(function ([testCollection]) { cy.loginAs(activeUser); - const files = [ - "foobar", - "anotherFile", - "", - "finalName", - ]; + const files = ["foobar", "anotherFile", "", "finalName"]; cy.goToPath(`/collections/${testCollection.uuid}`); - cy.get('[data-cy=collection-files-panel]').should('contain', 'This collection is empty'); - cy.get('[data-cy=collection-files-panel]').should('not.contain', files[0]); - cy.get('[data-cy=collection-info-panel]').should('contain', collName); + cy.get("[data-cy=collection-files-panel]").should("contain", "This collection is empty"); + cy.get("[data-cy=collection-files-panel]").should("not.contain", files[0]); + cy.get("[data-cy=collection-info-panel]").should("contain", collName); files.map((fileName, i, files) => { cy.updateCollection(adminUser.token, testCollection.uuid, { - name: `${collName + ' updated'}`, + name: `${collName + " updated"}`, manifest_text: fileName ? `. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n` : "", - }).as('updatedCollection'); - cy.getAll('@updatedCollection').then(function ([updatedCollection]) { - expect(updatedCollection.name).to.equal(`${collName + ' updated'}`); - cy.get('[data-cy=collection-info-panel]').should('contain', updatedCollection.name); + }).as("updatedCollection"); + cy.getAll("@updatedCollection").then(function ([updatedCollection]) { + expect(updatedCollection.name).to.equal(`${collName + " updated"}`); + cy.get("[data-cy=collection-info-panel]").should("contain", updatedCollection.name); fileName - ? cy.get('[data-cy=collection-files-panel]').should('contain', fileName) - : cy.get('[data-cy=collection-files-panel]').should('not.contain', files[i-1]);; + ? cy.get("[data-cy=collection-files-panel]").should("contain", fileName) + : cy.get("[data-cy=collection-files-panel]").should("not.contain", files[i - 1]); }); }); - }); }); - it('makes a copy of an existing collection', function() { + it("makes a copy of an existing collection", function () { const collName = `Test Collection ${Math.floor(Math.random() * 999999)}`; const copyName = `Copy of: ${collName}`; @@ -744,32 +753,28 @@ describe('Collection panel tests', function () { name: collName, owner_uuid: activeUser.user.uuid, manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:some-file\n", - }).as('collection').then(function () { - cy.loginAs(activeUser) - cy.goToPath(`/collections/${this.collection.uuid}`); - cy.get('[data-cy=collection-files-panel]') - .should('contain', 'some-file'); - cy.get('[data-cy=collection-panel-options-btn]').click(); - cy.get('[data-cy=context-menu]').contains('Make a copy').click(); - cy.get('[data-cy=form-dialog]') - .should('contain', 'Make a copy') - .within(() => { - cy.get('[data-cy=projects-tree-home-tree-picker]') - .contains('Projects') - .click(); - cy.get('[data-cy=form-submit-btn]').click(); - }); - cy.get('[data-cy=snackbar]') - .contains('Collection has been copied.') - cy.get('[data-cy=snackbar-goto-action]').click(); - cy.get('[data-cy=project-panel]') - .contains(copyName).click(); - cy.get('[data-cy=collection-files-panel]') - .should('contain', 'some-file'); - }); + }) + .as("collection") + .then(function () { + cy.loginAs(activeUser); + cy.goToPath(`/collections/${this.collection.uuid}`); + cy.get("[data-cy=collection-files-panel]").should("contain", "some-file"); + cy.get("[data-cy=collection-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]").contains("Make a copy").click(); + cy.get("[data-cy=form-dialog]") + .should("contain", "Make a copy") + .within(() => { + cy.get("[data-cy=projects-tree-home-tree-picker]").contains("Projects").click(); + cy.get("[data-cy=form-submit-btn]").click(); + }); + cy.get("[data-cy=snackbar]").contains("Collection has been copied."); + cy.get("[data-cy=snackbar-goto-action]").click(); + cy.get("[data-cy=project-panel]").contains(copyName).click(); + cy.get("[data-cy=collection-files-panel]").should("contain", "some-file"); + }); }); - it('uses the collection version browser to view a previous version', function () { + it("uses the collection version browser to view a previous version", function () { const colName = `Test Collection ${Math.floor(Math.random() * 999999)}`; // Creates the collection using the admin token so we can set up @@ -778,386 +783,562 @@ describe('Collection panel tests', function () { name: colName, owner_uuid: activeUser.user.uuid, preserve_version: true, - manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n" + manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n", }) - .as('collection').then(function () { + .as("collection") + .then(function () { // Visit collection, check basic information - cy.loginAs(activeUser) + cy.loginAs(activeUser); cy.goToPath(`/collections/${this.collection.uuid}`); - cy.get('[data-cy=collection-info-panel]').should('not.contain', 'This is an old version'); - cy.get('[data-cy=read-only-icon]').should('not.exist'); - cy.get('[data-cy=collection-version-number]').should('contain', '1'); - cy.get('[data-cy=collection-info-panel]').should('contain', colName); - cy.get('[data-cy=collection-files-panel]').should('contain', 'foo').and('contain', 'bar'); + cy.get("[data-cy=collection-info-panel]").should("not.contain", "This is an old version"); + cy.get("[data-cy=read-only-icon]").should("not.exist"); + cy.get("[data-cy=collection-version-number]").should("contain", "1"); + cy.get("[data-cy=collection-info-panel]").should("contain", colName); + cy.get("[data-cy=collection-files-panel]").should("contain", "foo").and("contain", "bar"); // Modify collection, expect version number change - cy.get('[data-cy=collection-files-panel]').contains('foo').rightclick(); - cy.get('[data-cy=context-menu]').contains('Remove').click(); - cy.get('[data-cy=confirmation-dialog]').should('contain', 'Removing file'); - cy.get('[data-cy=confirmation-dialog-ok-btn]').click(); - cy.get('[data-cy=collection-version-number]').should('contain', '2'); - cy.get('[data-cy=collection-files-panel]').should('not.contain', 'foo').and('contain', 'bar'); + cy.get("[data-cy=collection-files-panel]").contains("foo").rightclick(); + cy.get("[data-cy=context-menu]").contains("Remove").click(); + cy.get("[data-cy=confirmation-dialog]").should("contain", "Removing file"); + cy.get("[data-cy=confirmation-dialog-ok-btn]").click(); + cy.get("[data-cy=collection-version-number]").should("contain", "2"); + cy.get("[data-cy=collection-files-panel]").should("not.contain", "foo").and("contain", "bar"); // Click on version number, check version browser. Click on past version. - cy.get('[data-cy=collection-version-browser]').should('not.exist'); - cy.get('[data-cy=collection-version-number]').contains('2').click(); - cy.get('[data-cy=collection-version-browser]') - .should('contain', 'Nr').and('contain', 'Size').and('contain', 'Date') + cy.get("[data-cy=collection-version-browser]").should("not.exist"); + cy.get("[data-cy=collection-version-number]").contains("2").click(); + cy.get("[data-cy=collection-version-browser]") + .should("contain", "Nr") + .and("contain", "Size") + .and("contain", "Date") .within(() => { // Version 1: 6 bytes in size - cy.get('[data-cy=collection-version-browser-select-1]') - .should('contain', '1') - .and('contain', '6 B') - .and('contain', adminUser.user.uuid); + cy.get("[data-cy=collection-version-browser-select-1]") + .should("contain", "1") + .and("contain", "6 B") + .and("contain", adminUser.user.full_name); // Version 2: 3 bytes in size (one file removed) - cy.get('[data-cy=collection-version-browser-select-2]') - .should('contain', '2') - .and('contain', '3 B') - .and('contain', activeUser.user.full_name); - cy.get('[data-cy=collection-version-browser-select-3]') - .should('not.exist'); - cy.get('[data-cy=collection-version-browser-select-1]') - .click(); + cy.get("[data-cy=collection-version-browser-select-2]") + .should("contain", "2") + .and("contain", "3 B") + .and("contain", activeUser.user.full_name); + cy.get("[data-cy=collection-version-browser-select-3]").should("not.exist"); + cy.get("[data-cy=collection-version-browser-select-1]").click(); }); - cy.get('[data-cy=collection-info-panel]').should('contain', 'This is an old version'); - cy.get('[data-cy=read-only-icon]').should('exist'); - cy.get('[data-cy=collection-version-number]').should('contain', '1'); - cy.get('[data-cy=collection-info-panel]').should('contain', colName); - cy.get('[data-cy=collection-files-panel]') - .should('contain', 'foo').and('contain', 'bar'); + cy.get("[data-cy=collection-info-panel]").should("contain", "This is an old version"); + cy.get("[data-cy=read-only-icon]").should("exist"); + cy.get("[data-cy=collection-version-number]").should("contain", "1"); + cy.get("[data-cy=collection-info-panel]").should("contain", colName); + cy.get("[data-cy=collection-files-panel]").should("contain", "foo").and("contain", "bar"); // Check that only old collection action are available on context menu - cy.get('[data-cy=collection-panel-options-btn]').click(); - cy.get('[data-cy=context-menu]') - .should('contain', 'Restore version') - .and('not.contain', 'Add to favorites'); - cy.get('body').click(); // Collapse the menu avoiding details panel expansion + cy.get("[data-cy=collection-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]").should("contain", "Restore version").and("not.contain", "Add to favorites"); + cy.get("body").click(); // Collapse the menu avoiding details panel expansion // Click on "head version" link, confirm that it's the latest version. - cy.get('[data-cy=collection-info-panel]').contains('head version').click(); - cy.get('[data-cy=collection-info-panel]') - .should('not.contain', 'This is an old version'); - cy.get('[data-cy=read-only-icon]').should('not.exist'); - cy.get('[data-cy=collection-version-number]').should('contain', '2'); - cy.get('[data-cy=collection-info-panel]').should('contain', colName); - cy.get('[data-cy=collection-files-panel]'). - should('not.contain', 'foo').and('contain', 'bar'); + cy.get("[data-cy=collection-info-panel]").contains("head version").click(); + cy.get("[data-cy=collection-info-panel]").should("not.contain", "This is an old version"); + cy.get("[data-cy=read-only-icon]").should("not.exist"); + cy.get("[data-cy=collection-version-number]").should("contain", "2"); + cy.get("[data-cy=collection-info-panel]").should("contain", colName); + cy.get("[data-cy=collection-files-panel]").should("not.contain", "foo").and("contain", "bar"); // Check that old collection action isn't available on context menu - cy.get('[data-cy=collection-panel-options-btn]').click() - cy.get('[data-cy=context-menu]').should('not.contain', 'Restore version') - cy.get('body').click(); // Collapse the menu avoiding details panel expansion + cy.get("[data-cy=collection-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]").should("not.contain", "Restore version"); + cy.get("body").click(); // Collapse the menu avoiding details panel expansion // Make another change, confirm new version. - cy.get('[data-cy=collection-panel-options-btn]').click(); - cy.get('[data-cy=context-menu]').contains('Edit collection').click(); - cy.get('[data-cy=form-dialog]') - .should('contain', 'Edit Collection') + cy.get("[data-cy=collection-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]").contains("Edit collection").click(); + cy.get("[data-cy=form-dialog]") + .should("contain", "Edit Collection") .within(() => { // appends some text - cy.get('input').first().type(' renamed'); + cy.get("input").first().type(" renamed"); }); - cy.get('[data-cy=form-submit-btn]').click(); - cy.get('[data-cy=collection-info-panel]') - .should('not.contain', 'This is an old version'); - cy.get('[data-cy=read-only-icon]').should('not.exist'); - cy.get('[data-cy=collection-version-number]').should('contain', '3'); - cy.get('[data-cy=collection-info-panel]').should('contain', colName + ' renamed'); - cy.get('[data-cy=collection-files-panel]') - .should('not.contain', 'foo').and('contain', 'bar'); - cy.get('[data-cy=collection-version-browser-select-3]') - .should('contain', '3').and('contain', '3 B'); + cy.get("[data-cy=form-submit-btn]").click(); + cy.get("[data-cy=collection-info-panel]").should("not.contain", "This is an old version"); + cy.get("[data-cy=read-only-icon]").should("not.exist"); + cy.get("[data-cy=collection-version-number]").should("contain", "3"); + cy.get("[data-cy=collection-info-panel]").should("contain", colName + " renamed"); + cy.get("[data-cy=collection-files-panel]").should("not.contain", "foo").and("contain", "bar"); + cy.get("[data-cy=collection-version-browser-select-3]").should("contain", "3").and("contain", "3 B"); // Check context menus on version browser - cy.get('[data-cy=collection-version-browser-select-3]').rightclick() - cy.get('[data-cy=context-menu]') - .should('contain', 'Add to favorites') - .and('contain', 'Make a copy') - .and('contain', 'Edit collection'); - cy.get('body').click(); + cy.waitForDom(); + cy.get("[data-cy=collection-version-browser-select-3]").rightclick(); + cy.get("[data-cy=context-menu]") + .should("contain", "Add to favorites") + .and("contain", "Make a copy") + .and("contain", "Edit collection"); + cy.get("body").click(); // (and now an old version...) - cy.get('[data-cy=collection-version-browser-select-1]').rightclick() - cy.get('[data-cy=context-menu]') - .should('not.contain', 'Add to favorites') - .and('contain', 'Make a copy') - .and('not.contain', 'Edit collection'); - cy.get('body').click(); + cy.get("[data-cy=collection-version-browser-select-1]").rightclick(); + cy.get("[data-cy=context-menu]") + .should("not.contain", "Add to favorites") + .and("contain", "Make a copy") + .and("not.contain", "Edit collection"); + cy.get("body").click(); // Restore first version - cy.get('[data-cy=collection-version-browser]').within(() => { - cy.get('[data-cy=collection-version-browser-select-1]').click(); + cy.get("[data-cy=collection-version-browser]").within(() => { + cy.get("[data-cy=collection-version-browser-select-1]").click(); }); - cy.get('[data-cy=collection-panel-options-btn]').click() - cy.get('[data-cy=context-menu]').contains('Restore version').click(); - cy.get('[data-cy=confirmation-dialog]').should('contain', 'Restore version'); - cy.get('[data-cy=confirmation-dialog-ok-btn]').click(); - cy.get('[data-cy=collection-info-panel]') - .should('not.contain', 'This is an old version'); - cy.get('[data-cy=collection-version-number]').should('contain', '4'); - cy.get('[data-cy=collection-info-panel]').should('contain', colName); - cy.get('[data-cy=collection-files-panel]') - .should('contain', 'foo').and('contain', 'bar'); + cy.get("[data-cy=collection-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]").contains("Restore version").click(); + cy.get("[data-cy=confirmation-dialog]").should("contain", "Restore version"); + cy.get("[data-cy=confirmation-dialog-ok-btn]").click(); + cy.get("[data-cy=collection-info-panel]").should("not.contain", "This is an old version"); + cy.get("[data-cy=collection-version-number]").should("contain", "4"); + cy.get("[data-cy=collection-info-panel]").should("contain", colName); + cy.get("[data-cy=collection-files-panel]").should("contain", "foo").and("contain", "bar"); }); }); - it('creates collection from selected files of another collection', () => { + it("copies selected files into new collection", () => { cy.createCollection(adminUser.token, { name: `Test Collection ${Math.floor(Math.random() * 999999)}`, owner_uuid: activeUser.user.uuid, preserve_version: true, - manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n" + manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n", }) - .as('collection').then(function () { + .as("collection") + .then(function () { // Visit collection, check basic information - cy.loginAs(activeUser) + cy.loginAs(activeUser); cy.goToPath(`/collections/${this.collection.uuid}`); - cy.get('[data-cy=collection-files-panel]').within(() => { - cy.get('input[type=checkbox]').first().click(); + cy.get("[data-cy=collection-files-panel]").within(() => { + cy.get("input[type=checkbox]").first().click(); }); - cy.get('[data-cy=collection-files-panel-options-btn]').click(); - cy.get('[data-cy=context-menu]').contains('Create a new collection with selected').click(); + cy.get("[data-cy=collection-files-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]").contains("Copy selected into new collection").click(); - cy.get('[data-cy=form-dialog]').contains('Projects').click(); + cy.get("[data-cy=form-dialog]").contains("Projects").click(); - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[data-cy=form-submit-btn]").click(); - cy.waitForDom().get('.layout-pane-primary', { timeout: 12000 }).contains('Projects').click(); + cy.waitForDom().get(".layout-pane-primary", { timeout: 12000 }).contains("Projects").click(); - cy.get('main').contains(`Files extracted from: ${this.collection.name}`).should('exist'); + cy.waitForDom().get("main").contains(`Files extracted from: ${this.collection.name}`).click(); + cy.get("[data-cy=collection-files-panel]").and("contain", "bar"); }); }); - it('creates new collection with properties on home project', function () { + it("copies selected files into existing collection", () => { + cy.createCollection(adminUser.token, { + name: `Test Collection ${Math.floor(Math.random() * 999999)}`, + owner_uuid: activeUser.user.uuid, + preserve_version: true, + manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n", + }).as("sourceCollection"); + + cy.createCollection(adminUser.token, { + name: `Destination Collection ${Math.floor(Math.random() * 999999)}`, + owner_uuid: activeUser.user.uuid, + preserve_version: true, + manifest_text: "", + }).as("destinationCollection"); + + cy.getAll("@sourceCollection", "@destinationCollection").then(function ([sourceCollection, destinationCollection]) { + // Visit collection, check basic information + cy.loginAs(activeUser); + cy.goToPath(`/collections/${sourceCollection.uuid}`); + + cy.get("[data-cy=collection-files-panel]").within(() => { + cy.get("input[type=checkbox]").first().click(); + }); + + cy.get("[data-cy=collection-files-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]").contains("Copy selected into existing collection").click(); + + cy.get("[data-cy=form-dialog]").contains(destinationCollection.name).click(); + + cy.get("[data-cy=form-submit-btn]").click(); + cy.wait(2000); + + cy.goToPath(`/collections/${destinationCollection.uuid}`); + + cy.get("main").contains(destinationCollection.name).should("exist"); + cy.get("[data-cy=collection-files-panel]").and("contain", "bar"); + }); + }); + + it("copies selected files into separate collections", () => { + cy.createCollection(adminUser.token, { + name: `Test Collection ${Math.floor(Math.random() * 999999)}`, + owner_uuid: activeUser.user.uuid, + preserve_version: true, + manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n", + }).as("sourceCollection"); + + cy.getAll("@sourceCollection").then(function ([sourceCollection]) { + // Visit collection, check basic information + cy.loginAs(activeUser); + cy.goToPath(`/collections/${sourceCollection.uuid}`); + + // Select both files + cy.waitForDom() + .get("[data-cy=collection-files-panel]") + .within(() => { + cy.get("input[type=checkbox]").first().click(); + cy.get("input[type=checkbox]").last().click(); + }); + + // Copy to separate collections + cy.get("[data-cy=collection-files-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]").contains("Copy selected into separate collections").click(); + cy.get("[data-cy=form-dialog]").contains("Projects").click(); + cy.get("[data-cy=form-submit-btn]").click(); + + // Verify created collections + cy.waitForDom().get(".layout-pane-primary", { timeout: 12000 }).contains("Projects").click(); + cy.get("main").contains(`File copied from collection ${sourceCollection.name}/foo`).click(); + cy.get("[data-cy=collection-files-panel]").and("contain", "foo"); + cy.get(".layout-pane-primary").contains("Projects").click(); + cy.get("main").contains(`File copied from collection ${sourceCollection.name}/bar`).click(); + cy.get("[data-cy=collection-files-panel]").and("contain", "bar"); + + // Verify separate collection menu items not present when single file selected + // Wait for dom for collection to re-render + cy.waitForDom() + .get("[data-cy=collection-files-panel]") + .within(() => { + cy.get("input[type=checkbox]").first().click(); + }); + cy.get("[data-cy=collection-files-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]").should("not.contain", "Copy selected into separate collections"); + cy.get("[data-cy=context-menu]").should("not.contain", "Move selected into separate collections"); + }); + }); + + it("moves selected files into new collection", () => { + cy.createCollection(adminUser.token, { + name: `Test Collection ${Math.floor(Math.random() * 999999)}`, + owner_uuid: activeUser.user.uuid, + preserve_version: true, + manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n", + }) + .as("collection") + .then(function () { + // Visit collection, check basic information + cy.loginAs(activeUser); + cy.goToPath(`/collections/${this.collection.uuid}`); + + cy.get("[data-cy=collection-files-panel]").within(() => { + cy.get("input[type=checkbox]").first().click(); + }); + + cy.get("[data-cy=collection-files-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]").contains("Move selected into new collection").click(); + + cy.get("[data-cy=form-dialog]").contains("Projects").click(); + + cy.get("[data-cy=form-submit-btn]").click(); + + cy.waitForDom().get(".layout-pane-primary", { timeout: 12000 }).contains("Projects").click(); + + cy.get("main").contains(`Files moved from: ${this.collection.name}`).click(); + cy.get("[data-cy=collection-files-panel]").and("contain", "bar"); + }); + }); + + it("moves selected files into existing collection", () => { + cy.createCollection(adminUser.token, { + name: `Test Collection ${Math.floor(Math.random() * 999999)}`, + owner_uuid: activeUser.user.uuid, + preserve_version: true, + manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n", + }).as("sourceCollection"); + + cy.createCollection(adminUser.token, { + name: `Destination Collection ${Math.floor(Math.random() * 999999)}`, + owner_uuid: activeUser.user.uuid, + preserve_version: true, + manifest_text: "", + }).as("destinationCollection"); + + cy.getAll("@sourceCollection", "@destinationCollection").then(function ([sourceCollection, destinationCollection]) { + // Visit collection, check basic information + cy.loginAs(activeUser); + cy.goToPath(`/collections/${sourceCollection.uuid}`); + + cy.get("[data-cy=collection-files-panel]").within(() => { + cy.get("input[type=checkbox]").first().click(); + }); + + cy.get("[data-cy=collection-files-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]").contains("Move selected into existing collection").click(); + + cy.get("[data-cy=form-dialog]").contains(destinationCollection.name).click(); + + cy.get("[data-cy=form-submit-btn]").click(); + cy.wait(2000); + + cy.goToPath(`/collections/${destinationCollection.uuid}`); + + cy.get("main").contains(destinationCollection.name).should("exist"); + cy.get("[data-cy=collection-files-panel]").and("contain", "bar"); + }); + }); + + it("moves selected files into separate collections", () => { + cy.createCollection(adminUser.token, { + name: `Test Collection ${Math.floor(Math.random() * 999999)}`, + owner_uuid: activeUser.user.uuid, + preserve_version: true, + manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n", + }).as("sourceCollection"); + + cy.getAll("@sourceCollection").then(function ([sourceCollection]) { + // Visit collection, check basic information + cy.loginAs(activeUser); + cy.goToPath(`/collections/${sourceCollection.uuid}`); + + // Select both files + cy.get("[data-cy=collection-files-panel]").within(() => { + cy.get("input[type=checkbox]").first().click(); + cy.get("input[type=checkbox]").last().click(); + }); + + // Copy to separate collections + cy.get("[data-cy=collection-files-panel-options-btn]").click(); + cy.get("[data-cy=context-menu]").contains("Move selected into separate collections").click(); + cy.get("[data-cy=form-dialog]").contains("Projects").click(); + cy.get("[data-cy=form-submit-btn]").click(); + + // Verify created collections + cy.waitForDom().get(".layout-pane-primary", { timeout: 12000 }).contains("Projects").click(); + cy.get("main").contains(`File moved from collection ${sourceCollection.name}/foo`).click(); + cy.get("[data-cy=collection-files-panel]").and("contain", "foo"); + cy.get(".layout-pane-primary").contains("Projects").click(); + cy.get("main").contains(`File moved from collection ${sourceCollection.name}/bar`).click(); + cy.get("[data-cy=collection-files-panel]").and("contain", "bar"); + }); + }); + + it("creates new collection with properties on home project", function () { cy.loginAs(activeUser); cy.goToPath(`/projects/${activeUser.user.uuid}`); - cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects'); - cy.get('[data-cy=breadcrumb-last]').should('not.exist'); + cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects"); + cy.get("[data-cy=breadcrumb-last]").should("not.exist"); // Create new collection - cy.get('[data-cy=side-panel-button]').click(); - cy.get('[data-cy=side-panel-new-collection]').click(); + cy.get("[data-cy=side-panel-button]").click(); + cy.get("[data-cy=side-panel-new-collection]").click(); // Name between brackets tests bugfix #17582 const collName = `[Test collection (${Math.floor(999999 * Math.random())})]`; // Select a storage class. - cy.get('[data-cy=form-dialog]') - .should('contain', 'New collection') - .and('contain', 'Storage classes') - .and('contain', 'default') - .and('contain', 'foo') - .and('contain', 'bar') + cy.get("[data-cy=form-dialog]") + .should("contain", "New collection") + .and("contain", "Storage classes") + .and("contain", "default") + .and("contain", "foo") + .and("contain", "bar") .within(() => { - cy.get('[data-cy=parent-field]').within(() => { - cy.get('input').should('have.value', 'Home project'); + cy.get("[data-cy=parent-field]").within(() => { + cy.get("input").should("have.value", "Home project"); }); - cy.get('[data-cy=name-field]').within(() => { - cy.get('input').type(collName); + cy.get("[data-cy=name-field]").within(() => { + cy.get("input").type(collName); }); - cy.get('[data-cy=checkbox-foo]').click(); - }) + cy.get("[data-cy=checkbox-foo]").click(); + }); // Add a property. // Key: Color (IDTAGCOLORS) - Value: Magenta (IDVALCOLORS3) - cy.get('[data-cy=form-dialog]').should('not.contain', 'Color: Magenta'); - cy.get('[data-cy=resource-properties-form]').within(() => { - cy.get('[data-cy=property-field-key]').within(() => { - cy.get('input').type('Color'); + cy.get("[data-cy=form-dialog]").should("not.contain", "Color: Magenta"); + cy.get("[data-cy=resource-properties-form]").within(() => { + cy.get("[data-cy=property-field-key]").within(() => { + cy.get("input").type("Color"); }); - cy.get('[data-cy=property-field-value]').within(() => { - cy.get('input').type('Magenta'); + cy.get("[data-cy=property-field-value]").within(() => { + cy.get("input").type("Magenta"); }); cy.root().submit(); }); // Confirm proper vocabulary labels are displayed on the UI. - cy.get('[data-cy=form-dialog]').should('contain', 'Color: Magenta'); + cy.get("[data-cy=form-dialog]").should("contain", "Color: Magenta"); // Value field should not complain about being required just after // adding a new property. See #19732 - cy.get('[data-cy=form-dialog]').should('not.contain', 'This field is required'); + cy.get("[data-cy=form-dialog]").should("not.contain", "This field is required"); - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[data-cy=form-submit-btn]").click(); // Confirm that the user was taken to the newly created collection - cy.get('[data-cy=form-dialog]').should('not.exist'); - cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects'); - cy.get('[data-cy=breadcrumb-last]').should('contain', collName); - cy.get('[data-cy=collection-info-panel]') - .should('contain', 'default') - .and('contain', 'foo') - .and('contain', 'Color: Magenta') - .and('not.contain', 'bar'); + cy.get("[data-cy=form-dialog]").should("not.exist"); + cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects"); + cy.get("[data-cy=breadcrumb-last]").should("contain", collName); + cy.get("[data-cy=collection-info-panel]") + .should("contain", "default") + .and("contain", "foo") + .and("contain", "Color: Magenta") + .and("not.contain", "bar"); // Confirm that the collection's properties has the real values. - cy.doRequest('GET', '/arvados/v1/collections', null, { + cy.doRequest("GET", "/arvados/v1/collections", null, { filters: `[["name", "=", "${collName}"]]`, }) - .its('body.items').as('collections') - .then(function() { - expect(this.collections).to.have.lengthOf(1); - expect(this.collections[0].properties).to.have.property( - 'IDTAGCOLORS', 'IDVALCOLORS3'); - }); + .its("body.items") + .as("collections") + .then(function () { + expect(this.collections).to.have.lengthOf(1); + expect(this.collections[0].properties).to.have.property("IDTAGCOLORS", "IDVALCOLORS3"); + }); }); - it('shows responsible person for collection if available', () => { + it("shows responsible person for collection if available", () => { cy.createCollection(adminUser.token, { name: `Test collection ${Math.floor(Math.random() * 999999)}`, owner_uuid: activeUser.user.uuid, - manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n" - }).as('testCollection1'); + manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n", + }).as("testCollection1"); cy.createCollection(adminUser.token, { name: `Test collection ${Math.floor(Math.random() * 999999)}`, owner_uuid: adminUser.user.uuid, - manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n" - }).as('testCollection2').then(function (testCollection2) { - cy.shareWith(adminUser.token, activeUser.user.uuid, testCollection2.uuid, 'can_write'); - }); + manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n", + }) + .as("testCollection2") + .then(function (testCollection2) { + cy.shareWith(adminUser.token, activeUser.user.uuid, testCollection2.uuid, "can_write"); + }); - cy.getAll('@testCollection1', '@testCollection2') - .then(function ([testCollection1, testCollection2]) { - cy.loginAs(activeUser); + cy.getAll("@testCollection1", "@testCollection2").then(function ([testCollection1, testCollection2]) { + cy.loginAs(activeUser); - cy.goToPath(`/collections/${testCollection1.uuid}`); - cy.get('[data-cy=responsible-person-wrapper]') - .contains(activeUser.user.uuid); + cy.goToPath(`/collections/${testCollection1.uuid}`); + cy.get("[data-cy=responsible-person-wrapper]").contains(activeUser.user.uuid); - cy.goToPath(`/collections/${testCollection2.uuid}`); - cy.get('[data-cy=responsible-person-wrapper]') - .contains(adminUser.user.uuid); - }); + cy.goToPath(`/collections/${testCollection2.uuid}`); + cy.get("[data-cy=responsible-person-wrapper]").contains(adminUser.user.uuid); + }); }); - describe('file upload', () => { + describe("file upload", () => { beforeEach(() => { cy.createCollection(adminUser.token, { name: `Test collection ${Math.floor(Math.random() * 999999)}`, owner_uuid: activeUser.user.uuid, - manifest_text: "./subdir 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n" - }).as('testCollection1'); + manifest_text: "./subdir 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n", + }).as("testCollection1"); }); - it('uploads a file and checks the collection UI to be fresh', () => { - cy.getAll('@testCollection1') - .then(function([testCollection1]) { - cy.loginAs(activeUser); - cy.goToPath(`/collections/${testCollection1.uuid}`); - cy.get('[data-cy=upload-button]').click(); - cy.get('[data-cy=collection-files-panel]') - .contains('5mb_a.bin').should('not.exist'); - cy.get('[data-cy=collection-file-count]').should('contain', '2'); - cy.fixture('files/5mb.bin', 'base64').then(content => { - cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_a.bin'); - cy.get('[data-cy=form-submit-btn]').click(); - cy.get('[data-cy=form-submit-btn]').should('not.exist'); - cy.get('[data-cy=collection-files-panel]') - .contains('5mb_a.bin').should('exist'); - cy.get('[data-cy=collection-file-count]').should('contain', '3'); - - cy.get('[data-cy=collection-files-panel]').contains('subdir').click(); - cy.get('[data-cy=upload-button]').click(); - cy.fixture('files/5mb.bin', 'base64').then(content => { - cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_b.bin'); - cy.get('[data-cy=form-submit-btn]').click(); - cy.get('[data-cy=form-submit-btn]').should('not.exist'); - cy.get('[data-cy=collection-files-right-panel]') - .contains('5mb_b.bin').should('exist'); - }); + it("uploads a file and checks the collection UI to be fresh", () => { + cy.getAll("@testCollection1").then(function ([testCollection1]) { + cy.loginAs(activeUser); + cy.goToPath(`/collections/${testCollection1.uuid}`); + cy.get("[data-cy=upload-button]").click(); + cy.get("[data-cy=collection-files-panel]").contains("5mb_a.bin").should("not.exist"); + cy.get("[data-cy=collection-file-count]").should("contain", "2"); + cy.fixture("files/5mb.bin", "base64").then(content => { + cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_a.bin"); + cy.get("[data-cy=form-submit-btn]").click(); + cy.get("[data-cy=form-submit-btn]").should("not.exist"); + cy.get("[data-cy=collection-files-panel]").contains("5mb_a.bin").should("exist"); + cy.get("[data-cy=collection-file-count]").should("contain", "3"); + + cy.get("[data-cy=collection-files-panel]").contains("subdir").click(); + cy.get("[data-cy=upload-button]").click(); + cy.fixture("files/5mb.bin", "base64").then(content => { + cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_b.bin"); + cy.get("[data-cy=form-submit-btn]").click(); + cy.waitForDom().get("[data-cy=form-submit-btn]").should("not.exist"); + // subdir gets unselected, I think this is a bug but + // for the time being let's just make sure the test works. + cy.get("[data-cy=collection-files-panel]").contains("subdir").click(); + cy.waitForDom().get("[data-cy=collection-files-right-panel]").contains("5mb_b.bin").should("exist"); }); }); + }); }); - it('allows to cancel running upload', () => { - cy.getAll('@testCollection1') - .then(function([testCollection1]) { - cy.loginAs(activeUser); + it("allows to cancel running upload", () => { + cy.getAll("@testCollection1").then(function ([testCollection1]) { + cy.loginAs(activeUser); - cy.goToPath(`/collections/${testCollection1.uuid}`); + cy.goToPath(`/collections/${testCollection1.uuid}`); - cy.get('[data-cy=upload-button]').click(); + cy.get("[data-cy=upload-button]").click(); - cy.fixture('files/5mb.bin', 'base64').then(content => { - cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_a.bin'); - cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_b.bin'); + cy.fixture("files/5mb.bin", "base64").then(content => { + cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_a.bin"); + cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_b.bin"); - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[data-cy=form-submit-btn]").click(); - cy.get('button').contains('Cancel').click(); + cy.get("button").contains("Cancel").click(); - cy.get('[data-cy=form-submit-btn]').should('not.exist'); - }); + cy.get("[data-cy=form-submit-btn]").should("not.exist"); }); + }); }); - it('allows to cancel single file from the running upload', () => { - cy.getAll('@testCollection1') - .then(function([testCollection1]) { - cy.loginAs(activeUser); + it("allows to cancel single file from the running upload", () => { + cy.getAll("@testCollection1").then(function ([testCollection1]) { + cy.loginAs(activeUser); - cy.goToPath(`/collections/${testCollection1.uuid}`); + cy.goToPath(`/collections/${testCollection1.uuid}`); - cy.get('[data-cy=upload-button]').click(); + cy.get("[data-cy=upload-button]").click(); - cy.fixture('files/5mb.bin', 'base64').then(content => { - cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_a.bin'); - cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_b.bin'); + cy.fixture("files/5mb.bin", "base64").then(content => { + cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_a.bin"); + cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_b.bin"); - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[data-cy=form-submit-btn]").click(); - cy.get('button[aria-label=Remove]').eq(1).click(); + cy.get("button[aria-label=Remove]").eq(1).click(); - cy.get('[data-cy=form-submit-btn]').should('not.exist'); + cy.get("[data-cy=form-submit-btn]").should("not.exist"); - cy.get('[data-cy=collection-files-panel]').contains('5mb_a.bin').should('exist'); - }); + cy.get("[data-cy=collection-files-panel]").contains("5mb_a.bin").should("exist"); }); + }); }); - it('allows to cancel all files from the running upload', () => { - cy.getAll('@testCollection1') - .then(function([testCollection1]) { - cy.loginAs(activeUser); - - cy.goToPath(`/collections/${testCollection1.uuid}`); - - // Confirm initial collection state. - cy.get('[data-cy=collection-files-panel]') - .contains('bar').should('exist'); - cy.get('[data-cy=collection-files-panel]') - .contains('5mb_a.bin').should('not.exist'); - cy.get('[data-cy=collection-files-panel]') - .contains('5mb_b.bin').should('not.exist'); - - cy.get('[data-cy=upload-button]').click(); - - cy.fixture('files/5mb.bin', 'base64').then(content => { - cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_a.bin'); - cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_b.bin'); - - cy.get('[data-cy=form-submit-btn]').click(); - - cy.get('button[aria-label=Remove]').should('exist'); - cy.get('button[aria-label=Remove]') - .click({ multiple: true, force: true }); - - cy.get('[data-cy=form-submit-btn]').should('not.exist'); - - // Confirm final collection state. - cy.get('[data-cy=collection-files-panel]') - .contains('bar').should('exist'); - // The following fails, but doesn't seem to happen - // in the real world. Maybe there's a race between - // the PUT request finishing and the 'Remove' button - // dissapearing, because sometimes just one of the 2 - // files gets uploaded. - // Maybe this will be needed to simulate a slow network: - // https://docs.cypress.io/api/commands/intercept#Convenience-functions-1 - // cy.get('[data-cy=collection-files-panel]') - // .contains('5mb_a.bin').should('not.exist'); - // cy.get('[data-cy=collection-files-panel]') - // .contains('5mb_b.bin').should('not.exist'); - }); + it("allows to cancel all files from the running upload", () => { + cy.getAll("@testCollection1").then(function ([testCollection1]) { + cy.loginAs(activeUser); + + cy.goToPath(`/collections/${testCollection1.uuid}`); + + // Confirm initial collection state. + cy.get("[data-cy=collection-files-panel]").contains("bar").should("exist"); + cy.get("[data-cy=collection-files-panel]").contains("5mb_a.bin").should("not.exist"); + cy.get("[data-cy=collection-files-panel]").contains("5mb_b.bin").should("not.exist"); + + cy.get("[data-cy=upload-button]").click(); + + cy.fixture("files/5mb.bin", "base64").then(content => { + cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_a.bin"); + cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_b.bin"); + + cy.get("[data-cy=form-submit-btn]").click(); + + cy.get("button[aria-label=Remove]").should("exist"); + cy.get("button[aria-label=Remove]").click({ multiple: true, force: true }); + + cy.get("[data-cy=form-submit-btn]").should("not.exist"); + + // Confirm final collection state. + cy.get("[data-cy=collection-files-panel]").contains("bar").should("exist"); + // The following fails, but doesn't seem to happen + // in the real world. Maybe there's a race between + // the PUT request finishing and the 'Remove' button + // dissapearing, because sometimes just one of the 2 + // files gets uploaded. + // Maybe this will be needed to simulate a slow network: + // https://docs.cypress.io/api/commands/intercept#Convenience-functions-1 + // cy.get('[data-cy=collection-files-panel]') + // .contains('5mb_a.bin').should('not.exist'); + // cy.get('[data-cy=collection-files-panel]') + // .contains('5mb_b.bin').should('not.exist'); }); + }); }); }); -}) +}); diff --git a/cypress/integration/create-workflow.spec.js b/cypress/integration/create-workflow.spec.js index ca56e404..e6469039 100644 --- a/cypress/integration/create-workflow.spec.js +++ b/cypress/integration/create-workflow.spec.js @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0 -describe('Multi-file deletion tests', function () { +describe('Create workflow tests', function () { let activeUser; let adminUser; @@ -166,8 +166,8 @@ describe('Multi-file deletion tests', function () { cy.get(`[data-id=${testCollection.uuid}]`) .find('i').click(); + cy.wait(1000); cy.contains('bar').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').click(); - cy.contains('baz').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').click(); cy.get('[data-cy=ok-button]').click(); @@ -204,4 +204,81 @@ describe('Multi-file deletion tests', function () { }); }); })); + + it('allows selecting collection subdirectories and reselects existing selections', () => { + cy.createProject({ + owningUser: activeUser, + projectName: 'myProject1', + addToFavorites: true + }); + + cy.createCollection(adminUser.token, { + name: `Test collection ${Math.floor(Math.random() * 999999)}`, + owner_uuid: activeUser.user.uuid, + manifest_text: "./subdir/dir1 d41d8cd98f00b204e9800998ecf8427e+0 0:0:\\056\n./subdir/dir2 d41d8cd98f00b204e9800998ecf8427e+0 0:0:\\056\n" + }) + .as('testCollection'); + + cy.getAll('@myProject1', '@testCollection') + .then(function ([myProject1, testCollection]) { + cy.readFile('cypress/fixtures/workflow_directory_array.yaml').then(workflow => { + cy.createWorkflow(adminUser.token, { + name: `TestWorkflow${Math.floor(Math.random() * 999999)}.cwl`, + definition: workflow, + owner_uuid: myProject1.uuid, + }) + .as('testWorkflow'); + }); + + cy.loginAs(activeUser); + + cy.get('main').contains(myProject1.name).click(); + + cy.get('[data-cy=side-panel-button]').click(); + + cy.get('#aside-menu-list').contains('Run a workflow').click(); + + cy.get('@testWorkflow') + .then((testWorkflow) => { + cy.get('main').contains(testWorkflow.name).click(); + cy.get('[data-cy=run-process-next-button]').click(); + + cy.get('label').contains('directoryInputName').parent('div').find('input').click(); + cy.get('div[role=dialog]') + .within(() => { + // must use .then to avoid selecting instead of expanding https://github.com/cypress-io/cypress/issues/5529 + cy.get('p').contains('Home Projects').closest('ul') + .find('i') + .then(el => el.click()); + + cy.get(`[data-id=${testCollection.uuid}]`) + .find('i').click(); + + cy.get(`[data-id="${testCollection.uuid}/subdir"]`) + .find('i').click(); + + cy.contains('dir1').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').click(); + cy.contains('dir2').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').click(); + + cy.get('[data-cy=ok-button]').click(); + }); + + // Verify subdirectories were selected + cy.get('label').contains('directoryInputName').parent('div') + .within(() => { + cy.contains('dir1'); + cy.contains('dir2'); + }); + + // Reopen tree picker and verify subdirectories are preselected + cy.get('label').contains('directoryInputName').parent('div').find('input').click(); + cy.waitForDom().get('div[role=dialog]') + .within(() => { + cy.contains('dir1').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').should('be.checked'); + cy.contains('dir2').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').should('be.checked'); + }); + }); + + }); + }) }) diff --git a/cypress/integration/group-manage.spec.js b/cypress/integration/group-manage.spec.js index 1fd9e416..c4731bb3 100644 --- a/cypress/integration/group-manage.spec.js +++ b/cypress/integration/group-manage.spec.js @@ -77,7 +77,7 @@ describe('Group manage tests', function() { cy.get('[data-cy=invite-people-field] input').type("admin"); }); cy.get('[role=tooltip]').click(); - cy.get('.sharing-dialog').contains('Save').click(); + cy.get('.sharing-dialog').get('[data-cy=add-invited-people]').click(); cy.get('.sharing-dialog').contains('Close').click(); // Check that both users are present with appropriate permissions diff --git a/cypress/integration/multiselect-toolbar.spec.js b/cypress/integration/multiselect-toolbar.spec.js new file mode 100644 index 00000000..ef503f7e --- /dev/null +++ b/cypress/integration/multiselect-toolbar.spec.js @@ -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'); + }); +}); diff --git a/cypress/integration/page-not-found.spec.js b/cypress/integration/page-not-found.spec.js index 4df4135c..6eab27c8 100644 --- a/cypress/integration/page-not-found.spec.js +++ b/cypress/integration/page-not-found.spec.js @@ -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 +}) diff --git a/cypress/integration/process.spec.js b/cypress/integration/process.spec.js index 19544c9c..9ea026b9 100644 --- a/cypress/integration/process.spec.js +++ b/cypress/integration/process.spec.js @@ -2,28 +2,30 @@ // // SPDX-License-Identifier: AGPL-3.0 -describe('Process tests', function() { +import { ContainerState } from "models/container"; + +describe("Process tests", function () { let activeUser; let adminUser; - before(function() { + before(function () { // Only set up common users once. These aren't set up as aliases because // aliases are cleaned up after every test. Also it doesn't make sense // to set the same users on beforeEach() over and over again, so we // separate a little from Cypress' 'Best Practices' here. - cy.getUser('admin', 'Admin', 'User', true, true) - .as('adminUser').then(function() { + cy.getUser("admin", "Admin", "User", true, true) + .as("adminUser") + .then(function () { adminUser = this.adminUser; - } - ); - cy.getUser('user', 'Active', 'User', false, true) - .as('activeUser').then(function() { + }); + cy.getUser("user", "Active", "User", false, true) + .as("activeUser") + .then(function () { activeUser = this.activeUser; - } - ); + }); }); - beforeEach(function() { + beforeEach(function () { cy.clearCookies(); cy.clearLocalStorage(); }); @@ -31,42 +33,46 @@ describe('Process tests', function() { function setupDockerImage(image_name) { // Create a collection that will be used as a docker image for the tests. cy.createCollection(adminUser.token, { - name: 'docker_image', - manifest_text: ". d21353cfe035e3e384563ee55eadbb2f+67108864 5c77a43e329b9838cbec18ff42790e57+55605760 0:122714624:sha256:d8309758b8fe2c81034ffc8a10c36460b77db7bc5e7b448c4e5b684f9d95a678.tar\n" - }).as('dockerImage').then(function(dockerImage) { - // Give read permissions to the active user on the docker image. - cy.createLink(adminUser.token, { - link_class: 'permission', - name: 'can_read', - tail_uuid: activeUser.user.uuid, - head_uuid: dockerImage.uuid - }).as('dockerImagePermission').then(function() { - // Set-up docker image collection tags - cy.createLink(activeUser.token, { - link_class: 'docker_image_repo+tag', - name: image_name, - head_uuid: dockerImage.uuid, - }).as('dockerImageRepoTag'); - cy.createLink(activeUser.token, { - link_class: 'docker_image_hash', - name: 'sha256:d8309758b8fe2c81034ffc8a10c36460b77db7bc5e7b448c4e5b684f9d95a678', + name: "docker_image", + manifest_text: + ". d21353cfe035e3e384563ee55eadbb2f+67108864 5c77a43e329b9838cbec18ff42790e57+55605760 0:122714624:sha256:d8309758b8fe2c81034ffc8a10c36460b77db7bc5e7b448c4e5b684f9d95a678.tar\n", + }) + .as("dockerImage") + .then(function (dockerImage) { + // Give read permissions to the active user on the docker image. + cy.createLink(adminUser.token, { + link_class: "permission", + name: "can_read", + tail_uuid: activeUser.user.uuid, head_uuid: dockerImage.uuid, - }).as('dockerImageHash'); - }) - }); - return cy.getAll('@dockerImage', '@dockerImageRepoTag', '@dockerImageHash', - '@dockerImagePermission').then(function([dockerImage]) { - return dockerImage; + }) + .as("dockerImagePermission") + .then(function () { + // Set-up docker image collection tags + cy.createLink(activeUser.token, { + link_class: "docker_image_repo+tag", + name: image_name, + head_uuid: dockerImage.uuid, + }).as("dockerImageRepoTag"); + cy.createLink(activeUser.token, { + link_class: "docker_image_hash", + name: "sha256:d8309758b8fe2c81034ffc8a10c36460b77db7bc5e7b448c4e5b684f9d95a678", + head_uuid: dockerImage.uuid, + }).as("dockerImageHash"); + }); }); + return cy.getAll("@dockerImage", "@dockerImageRepoTag", "@dockerImageHash", "@dockerImagePermission").then(function ([dockerImage]) { + return dockerImage; + }); } - function createContainerRequest(user, name, docker_image, command, reuse = false, state = 'Uncommitted') { - return setupDockerImage(docker_image).then(function(dockerImage) { + function createContainerRequest(user, name, docker_image, command, reuse = false, state = "Uncommitted") { + return setupDockerImage(docker_image).then(function (dockerImage) { return cy.createContainerRequest(user.token, { name: name, command: command, container_image: dockerImage.portable_data_hash, // for some reason, docker_image doesn't work here - output_path: 'stdout.txt', + output_path: "stdout.txt", priority: 1, runtime_constraints: { vcpus: 1, @@ -76,1285 +82,1438 @@ describe('Process tests', function() { state: state, mounts: { foo: { - kind: 'tmp', - path: '/tmp/foo', - } - } + kind: "tmp", + path: "/tmp/foo", + }, + }, }); }); } - it('shows process logs', function() { - const crName = 'test_container_request'; - createContainerRequest( - activeUser, - crName, - 'arvados/jobs', - ['echo', 'hello world'], - false, 'Committed') - .then(function(containerRequest) { - cy.loginAs(activeUser); - cy.goToPath(`/processes/${containerRequest.uuid}`); - cy.get('[data-cy=process-details]').should('contain', crName); - cy.get('[data-cy=process-logs]') - .should('contain', 'No logs yet') - .and('not.contain', 'hello world'); - cy.createLog(activeUser.token, { - object_uuid: containerRequest.container_uuid, - properties: { - text: 'hello world' - }, - event_type: 'stdout' - }).then(function(log) { - cy.get('[data-cy=process-logs]', {timeout: 7000}) - .should('not.contain', 'No logs yet') - .and('contain', 'hello world'); - }) + describe('Multiselect Toolbar', () => { + it('shows the appropriate buttons in the toolbar', () => { + + const msButtonTooltips = [ + 'API Details', + 'Add to Favorites', + 'CANCEL', + 'Copy and re-run process', + 'Edit process', + 'Move to', + 'Open in new tab', + 'Outputs', + 'Remove', + 'Share', + 'View details', + ]; + + createContainerRequest( + activeUser, + `test_container_request ${Math.floor(Math.random() * 999999)}`, + "arvados/jobs", + ["echo", "hello world"], + false, + "Committed" + ).then(function (containerRequest) { + cy.loginAs(activeUser); + cy.goToPath(`/processes/${containerRequest.uuid}`); + cy.get("[data-cy=process-details]").should("contain", containerRequest.name); + cy.get("[data-cy=process-details-attributes-modifiedby-user]").contains(`Active User (${activeUser.user.uuid})`); + cy.get("[data-cy=process-details-attributes-runtime-user]").should("not.exist"); + cy.get("[data-cy=side-panel-tree]").contains("Home Projects").click(); + cy.waitForDom() + cy.get('[data-cy=data-table-row]').contains(containerRequest.name).should('exist').parent().parent().parent().parent().click() + cy.get('[data-cy=multiselect-button]').should('have.length', msButtonTooltips.length) + for (let i = 0; i < msButtonTooltips.length; i++) { + cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseover'); + cy.get('body').contains(msButtonTooltips[i]).should('exist') + cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseout'); + } + }); + }) + }) + + describe("Details panel", function () { + it("shows process details", function () { + createContainerRequest( + activeUser, + `test_container_request ${Math.floor(Math.random() * 999999)}`, + "arvados/jobs", + ["echo", "hello world"], + false, + "Committed" + ).then(function (containerRequest) { + cy.loginAs(activeUser); + cy.goToPath(`/processes/${containerRequest.uuid}`); + cy.get("[data-cy=process-details]").should("contain", containerRequest.name); + cy.get("[data-cy=process-details-attributes-modifiedby-user]").contains(`Active User (${activeUser.user.uuid})`); + cy.get("[data-cy=process-details-attributes-runtime-user]").should("not.exist"); + }); + + // Fake submitted by another user + cy.intercept({ method: "GET", url: "**/arvados/v1/container_requests/*" }, req => { + req.reply(res => { + res.body.modified_by_user_uuid = "zzzzz-tpzed-000000000000000"; + }); + }); + + createContainerRequest( + activeUser, + `test_container_request ${Math.floor(Math.random() * 999999)}`, + "arvados/jobs", + ["echo", "hello world"], + false, + "Committed" + ).then(function (containerRequest) { + cy.loginAs(activeUser); + cy.goToPath(`/processes/${containerRequest.uuid}`); + cy.get("[data-cy=process-details]").should("contain", containerRequest.name); + cy.get("[data-cy=process-details-attributes-modifiedby-user]").contains(`zzzzz-tpzed-000000000000000`); + cy.get("[data-cy=process-details-attributes-runtime-user]").contains(`Active User (${activeUser.user.uuid})`); + }); }); - }); - it('shows process details', function() { - createContainerRequest( - activeUser, - `test_container_request ${Math.floor(Math.random() * 999999)}`, - 'arvados/jobs', - ['echo', 'hello world'], - false, 'Committed') - .then(function(containerRequest) { - cy.loginAs(activeUser); - cy.goToPath(`/processes/${containerRequest.uuid}`); - cy.get('[data-cy=process-details]').should('contain', containerRequest.name); - cy.get('[data-cy=process-details-attributes-modifiedby-user]').contains(`Active User (${activeUser.user.uuid})`); - cy.get('[data-cy=process-details-attributes-runtime-user]').should('not.exist'); + it("should show runtime status indicators", function () { + // Setup running container with runtime_status error & warning messages + createContainerRequest(activeUser, "test_container_request", "arvados/jobs", ["echo", "hello world"], false, "Committed") + .as("containerRequest") + .then(function (containerRequest) { + expect(containerRequest.state).to.equal("Committed"); + expect(containerRequest.container_uuid).not.to.be.equal(""); + + cy.getContainer(activeUser.token, containerRequest.container_uuid).then(function (queuedContainer) { + expect(queuedContainer.state).to.be.equal("Queued"); + }); + cy.updateContainer(adminUser.token, containerRequest.container_uuid, { + state: "Locked", + }).then(function (lockedContainer) { + expect(lockedContainer.state).to.be.equal("Locked"); + + cy.updateContainer(adminUser.token, lockedContainer.uuid, { + state: "Running", + runtime_status: { + error: "Something went wrong", + errorDetail: "Process exited with status 1", + warning: "Free disk space is low", + }, + }) + .as("runningContainer") + .then(function (runningContainer) { + expect(runningContainer.state).to.be.equal("Running"); + expect(runningContainer.runtime_status).to.be.deep.equal({ + error: "Something went wrong", + errorDetail: "Process exited with status 1", + warning: "Free disk space is low", + }); + }); + }); + }); + // Test that the UI shows the error and warning messages + cy.getAll("@containerRequest", "@runningContainer").then(function ([containerRequest]) { + cy.loginAs(activeUser); + cy.goToPath(`/processes/${containerRequest.uuid}`); + cy.get("[data-cy=process-runtime-status-error]") + .should("contain", "Something went wrong") + .and("contain", "Process exited with status 1"); + cy.get("[data-cy=process-runtime-status-warning]") + .should("contain", "Free disk space is low") + .and("contain", "No additional warning details available"); + }); + + // Force container_count for testing + let containerCount = 2; + cy.intercept({ method: "GET", url: "**/arvados/v1/container_requests/*" }, req => { + req.reply(res => { + res.body.container_count = containerCount; + }); + }); + + cy.getAll("@containerRequest").then(function ([containerRequest]) { + cy.goToPath(`/processes/${containerRequest.uuid}`); + cy.get("[data-cy=process-runtime-status-retry-warning]", { timeout: 7000 }).should("contain", "Process retried 1 time"); + }); + + cy.getAll("@containerRequest").then(function ([containerRequest]) { + containerCount = 3; + cy.goToPath(`/processes/${containerRequest.uuid}`); + cy.get("[data-cy=process-runtime-status-retry-warning]", { timeout: 7000 }).should("contain", "Process retried 2 times"); + }); }); - // Fake submitted by another user - cy.intercept({method: 'GET', url: '**/arvados/v1/container_requests/*'}, (req) => { - req.reply((res) => { - res.body.modified_by_user_uuid = 'zzzzz-tpzed-000000000000000'; + it("allows copying processes", function () { + const crName = "first_container_request"; + const copiedCrName = "copied_container_request"; + createContainerRequest(activeUser, crName, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (containerRequest) { + cy.loginAs(activeUser); + cy.goToPath(`/processes/${containerRequest.uuid}`); + cy.get("[data-cy=process-details]").should("contain", crName); + + cy.get("[data-cy=process-details]").find('button[title="More options"]').click(); + cy.get("ul[data-cy=context-menu]").contains("Copy and re-run process").click(); + }); + + cy.get("[data-cy=form-dialog]").within(() => { + cy.get("input[name=name]").clear().type(copiedCrName); + cy.get("[data-cy=projects-tree-home-tree-picker]").click(); + cy.get("[data-cy=form-submit-btn]").click(); }); + + cy.get("[data-cy=process-details]").should("contain", copiedCrName); + cy.get("[data-cy=process-details]").find("button").contains("Run"); }); - createContainerRequest( - activeUser, - `test_container_request ${Math.floor(Math.random() * 999999)}`, - 'arvados/jobs', - ['echo', 'hello world'], - false, 'Committed') - .then(function(containerRequest) { - cy.loginAs(activeUser); - cy.goToPath(`/processes/${containerRequest.uuid}`); - cy.get('[data-cy=process-details]').should('contain', containerRequest.name); - cy.get('[data-cy=process-details-attributes-modifiedby-user]').contains(`zzzzz-tpzed-000000000000000`); - cy.get('[data-cy=process-details-attributes-runtime-user]').contains(`Active User (${activeUser.user.uuid})`); + const getFakeContainer = fakeContainerUuid => ({ + href: `/containers/${fakeContainerUuid}`, + kind: "arvados#container", + etag: "ecfosljpnxfari9a8m7e4yv06", + uuid: fakeContainerUuid, + owner_uuid: "zzzzz-tpzed-000000000000000", + created_at: "2023-02-13T15:55:47.308915000Z", + modified_by_client_uuid: "zzzzz-ozdt8-q6dzdi1lcc03155", + modified_by_user_uuid: "zzzzz-tpzed-000000000000000", + modified_at: "2023-02-15T19:12:45.987086000Z", + command: [ + "arvados-cwl-runner", + "--api=containers", + "--local", + "--project-uuid=zzzzz-j7d0g-yr18k784zplfeza", + "/var/lib/cwl/workflow.json#main", + "/var/lib/cwl/cwl.input.json", + ], + container_image: "4ad7d11381df349e464694762db14e04+303", + cwd: "/var/spool/cwl", + environment: {}, + exit_code: null, + finished_at: null, + locked_by_uuid: null, + log: null, + output: null, + output_path: "/var/spool/cwl", + progress: null, + runtime_constraints: { + API: true, + cuda: { + device_count: 0, + driver_version: "", + hardware_capability: "", + }, + keep_cache_disk: 2147483648, + keep_cache_ram: 0, + ram: 1342177280, + vcpus: 1, + }, + runtime_status: {}, + started_at: null, + auth_uuid: null, + scheduling_parameters: { + max_run_time: 0, + partitions: [], + preemptible: false, + }, + runtime_user_uuid: "zzzzz-tpzed-vllbpebicy84rd5", + runtime_auth_scopes: ["all"], + lock_count: 2, + gateway_address: null, + interactive_session_started: false, + output_storage_classes: ["default"], + output_properties: {}, + cost: 0.0, + subrequests_cost: 0.0, }); - }); - it('filters process logs by event type', function() { - const nodeInfoLogs = [ - 'Host Information', - 'Linux compute-99cb150b26149780de44b929577e1aed-19rgca8vobuvc4p 5.4.0-1059-azure #62~18.04.1-Ubuntu SMP Tue Sep 14 17:53:18 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux', - 'CPU Information', - 'processor : 0', - 'vendor_id : GenuineIntel', - 'cpu family : 6', - 'model : 79', - 'model name : Intel(R) Xeon(R) CPU E5-2673 v4 @ 2.30GHz' - ]; - const crunchRunLogs = [ - '2022-03-22T13:56:22.542417997Z using local keepstore process (pid 3733) at http://localhost:46837, writing logs to keepstore.txt in log collection', - '2022-03-22T13:56:26.237571754Z crunch-run 2.4.0~dev20220321141729 (go1.17.1) started', - '2022-03-22T13:56:26.244704134Z crunch-run process has uid=0(root) gid=0(root) groups=0(root)', - '2022-03-22T13:56:26.244862836Z Executing container \'zzzzz-dz642-1wokwvcct9s9du3\' using docker runtime', - '2022-03-22T13:56:26.245037738Z Executing on host \'compute-99cb150b26149780de44b929577e1aed-19rgca8vobuvc4p\'', - ]; - const stdoutLogs = [ - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec dui nisi, hendrerit porta sapien a, pretium dignissim purus.', - 'Integer viverra, mauris finibus aliquet ultricies, dui mauris cursus justo, ut venenatis nibh ex eget neque.', - 'In hac habitasse platea dictumst.', - 'Fusce fringilla turpis id accumsan faucibus. Donec congue congue ex non posuere. In semper mi quis tristique rhoncus.', - 'Interdum et malesuada fames ac ante ipsum primis in faucibus.', - 'Quisque fermentum tortor ex, ut suscipit velit feugiat faucibus.', - 'Donec vitae porta risus, at luctus nulla. Mauris gravida iaculis ipsum, id sagittis tortor egestas ac.', - 'Maecenas condimentum volutpat nulla. Integer lacinia maximus risus eu posuere.', - 'Donec vitae leo id augue gravida bibendum.', - 'Nam libero libero, pretium ac faucibus elementum, mattis nec ex.', - 'Nullam id laoreet nibh. Vivamus tellus metus, pretium quis justo ut, bibendum varius metus. Pellentesque vitae accumsan lorem, quis tincidunt augue.', - 'Aliquam viverra nisi nulla, et efficitur dolor mattis in.', - 'Sed at enim sit amet nulla tincidunt mattis. Aenean eget aliquet ex, non ultrices ex. Nulla ex tortor, vestibulum aliquam tempor ac, aliquam vel est.', - 'Fusce auctor faucibus libero id venenatis. Etiam sodales, odio eu cursus efficitur, quam sem blandit ex, quis porttitor enim dui quis lectus. In id tincidunt felis.', - 'Phasellus non ex quis arcu tempus faucibus molestie in sapien.', - 'Duis tristique semper dolor, vitae pulvinar risus.', - 'Aliquam tortor elit, luctus nec tortor eget, porta tristique nulla.', - 'Nulla eget mollis ipsum.', - ]; + it("shows cancel button when appropriate", function () { + // Ignore collection requests + cy.intercept( + { method: "GET", url: `**/arvados/v1/collections/*` }, + { + statusCode: 200, + body: {}, + } + ); + + // Uncommitted container + const crUncommitted = `Test process ${Math.floor(Math.random() * 999999)}`; + createContainerRequest(activeUser, crUncommitted, "arvados/jobs", ["echo", "hello world"], false, "Uncommitted").then(function ( + containerRequest + ) { + // Navigate to process and verify run / cancel button + cy.goToPath(`/processes/${containerRequest.uuid}`); + cy.waitForDom(); + cy.get("[data-cy=process-details]").should("contain", crUncommitted); + cy.get("[data-cy=process-run-button]").should("exist"); + cy.get("[data-cy=process-cancel-button]").should("not.exist"); + }); - createContainerRequest( - activeUser, - 'test_container_request', - 'arvados/jobs', - ['echo', 'hello world'], - false, 'Committed') - .then(function(containerRequest) { - cy.logsForContainer(activeUser.token, containerRequest.container_uuid, - 'node-info', nodeInfoLogs).as('nodeInfoLogs'); - cy.logsForContainer(activeUser.token, containerRequest.container_uuid, - 'crunch-run', crunchRunLogs).as('crunchRunLogs'); - cy.logsForContainer(activeUser.token, containerRequest.container_uuid, - 'stdout', stdoutLogs).as('stdoutLogs'); - cy.getAll('@stdoutLogs', '@nodeInfoLogs', '@crunchRunLogs').then(function() { - cy.loginAs(activeUser); + // Queued container + const crQueued = `Test process ${Math.floor(Math.random() * 999999)}`; + const fakeCrUuid = "zzzzz-dz642-000000000000001"; + createContainerRequest(activeUser, crQueued, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function ( + containerRequest + ) { + // Fake container uuid + cy.intercept({ method: "GET", url: `**/arvados/v1/container_requests/${containerRequest.uuid}` }, req => { + req.reply(res => { + res.body.output_uuid = fakeCrUuid; + res.body.priority = 500; + res.body.state = "Committed"; + }); + }); + + // Fake container + const container = getFakeContainer(fakeCrUuid); + cy.intercept( + { method: "GET", url: `**/arvados/v1/container/${fakeCrUuid}` }, + { + statusCode: 200, + body: { ...container, state: "Queued", priority: 500 }, + } + ); + + // Navigate to process and verify cancel button cy.goToPath(`/processes/${containerRequest.uuid}`); - // Should show main logs by default - cy.get('[data-cy=process-logs-filter]', {timeout: 7000}).should('contain', 'Main logs'); - cy.get('[data-cy=process-logs]') - .should('contain', stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)]) - .and('not.contain', nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)]) - .and('contain', crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]); - // Select 'All logs' - cy.get('[data-cy=process-logs-filter]').click(); - cy.get('body').contains('li', 'All logs').click(); - cy.get('[data-cy=process-logs]') - .should('contain', stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)]) - .and('contain', nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)]) - .and('contain', crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]); - // Select 'node-info' logs - cy.get('[data-cy=process-logs-filter]').click(); - cy.get('body').contains('li', 'node-info').click(); - cy.get('[data-cy=process-logs]') - .should('not.contain', stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)]) - .and('contain', nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)]) - .and('not.contain', crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]); - // Select 'stdout' logs - cy.get('[data-cy=process-logs-filter]').click(); - cy.get('body').contains('li', 'stdout').click(); - cy.get('[data-cy=process-logs]') - .should('contain', stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)]) - .and('not.contain', nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)]) - .and('not.contain', crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]); + cy.waitForDom(); + cy.get("[data-cy=process-details]").should("contain", crQueued); + cy.get("[data-cy=process-cancel-button]").contains("Cancel"); + }); + + // Locked container + const crLocked = `Test process ${Math.floor(Math.random() * 999999)}`; + const fakeCrLockedUuid = "zzzzz-dz642-000000000000002"; + createContainerRequest(activeUser, crLocked, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function ( + containerRequest + ) { + // Fake container uuid + cy.intercept({ method: "GET", url: `**/arvados/v1/container_requests/${containerRequest.uuid}` }, req => { + req.reply(res => { + res.body.output_uuid = fakeCrLockedUuid; + res.body.priority = 500; + res.body.state = "Committed"; + }); + }); + + // Fake container + const container = getFakeContainer(fakeCrLockedUuid); + cy.intercept( + { method: "GET", url: `**/arvados/v1/container/${fakeCrLockedUuid}` }, + { + statusCode: 200, + body: { ...container, state: "Locked", priority: 500 }, + } + ); + + // Navigate to process and verify cancel button + cy.goToPath(`/processes/${containerRequest.uuid}`); + cy.waitForDom(); + cy.get("[data-cy=process-details]").should("contain", crLocked); + cy.get("[data-cy=process-cancel-button]").contains("Cancel"); + }); + + // On Hold container + const crOnHold = `Test process ${Math.floor(Math.random() * 999999)}`; + const fakeCrOnHoldUuid = "zzzzz-dz642-000000000000003"; + createContainerRequest(activeUser, crOnHold, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function ( + containerRequest + ) { + // Fake container uuid + cy.intercept({ method: "GET", url: `**/arvados/v1/container_requests/${containerRequest.uuid}` }, req => { + req.reply(res => { + res.body.output_uuid = fakeCrOnHoldUuid; + res.body.priority = 0; + res.body.state = "Committed"; + }); + }); + + // Fake container + const container = getFakeContainer(fakeCrOnHoldUuid); + cy.intercept( + { method: "GET", url: `**/arvados/v1/container/${fakeCrOnHoldUuid}` }, + { + statusCode: 200, + body: { ...container, state: "Queued", priority: 0 }, + } + ); + + // Navigate to process and verify cancel button + cy.goToPath(`/processes/${containerRequest.uuid}`); + cy.waitForDom(); + cy.get("[data-cy=process-details]").should("contain", crOnHold); + cy.get("[data-cy=process-run-button]").should("exist"); + cy.get("[data-cy=process-cancel-button]").should("not.exist"); }); }); }); - it('should show runtime status indicators', function() { - // Setup running container with runtime_status error & warning messages - createContainerRequest( - activeUser, - 'test_container_request', - 'arvados/jobs', - ['echo', 'hello world'], - false, 'Committed') - .as('containerRequest') - .then(function(containerRequest) { - expect(containerRequest.state).to.equal('Committed'); - expect(containerRequest.container_uuid).not.to.be.equal(''); - - cy.getContainer(activeUser.token, containerRequest.container_uuid) - .then(function(queuedContainer) { - expect(queuedContainer.state).to.be.equal('Queued'); + describe("Logs panel", function () { + it("shows live process logs", function () { + cy.intercept({ method: "GET", url: "**/arvados/v1/containers/*" }, req => { + req.reply(res => { + res.body.state = ContainerState.RUNNING; + }); }); - cy.updateContainer(adminUser.token, containerRequest.container_uuid, { - state: 'Locked' - }).then(function(lockedContainer) { - expect(lockedContainer.state).to.be.equal('Locked'); - - cy.updateContainer(adminUser.token, lockedContainer.uuid, { - state: 'Running', - runtime_status: { - error: 'Something went wrong', - errorDetail: 'Process exited with status 1', - warning: 'Free disk space is low', - } - }) - .as('runningContainer') - .then(function(runningContainer) { - expect(runningContainer.state).to.be.equal('Running'); - expect(runningContainer.runtime_status).to.be.deep.equal({ - 'error': 'Something went wrong', - 'errorDetail': 'Process exited with status 1', - 'warning': 'Free disk space is low', - }); + + const crName = "test_container_request"; + createContainerRequest(activeUser, crName, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (containerRequest) { + // Create empty log file before loading process page + cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", [""]); + + cy.loginAs(activeUser); + cy.goToPath(`/processes/${containerRequest.uuid}`); + cy.get("[data-cy=process-details]").should("contain", crName); + cy.get("[data-cy=process-logs]").should("contain", "No logs yet").and("not.contain", "hello world"); + + // Append a log line + cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", ["2023-07-18T20:14:48.128642814Z hello world"]).then(() => { + cy.get("[data-cy=process-logs]", { timeout: 7000 }).should("not.contain", "No logs yet").and("contain", "hello world"); }); - }) + + // Append new log line to different file + cy.appendLog(adminUser.token, containerRequest.uuid, "stderr.txt", ["2023-07-18T20:14:49.128642814Z hello new line"]).then(() => { + cy.get("[data-cy=process-logs]", { timeout: 7000 }).should("not.contain", "No logs yet").and("contain", "hello new line"); + }); + }); }); - // Test that the UI shows the error and warning messages - cy.getAll('@containerRequest', '@runningContainer').then(function([containerRequest]) { - cy.loginAs(activeUser); - cy.goToPath(`/processes/${containerRequest.uuid}`); - cy.get('[data-cy=process-runtime-status-error]') - .should('contain', 'Something went wrong') - .and('contain', 'Process exited with status 1'); - cy.get('[data-cy=process-runtime-status-warning]') - .should('contain', 'Free disk space is low') - .and('contain', 'No additional warning details available'); + + it("filters process logs by event type", function () { + const nodeInfoLogs = [ + "Host Information", + "Linux compute-99cb150b26149780de44b929577e1aed-19rgca8vobuvc4p 5.4.0-1059-azure #62~18.04.1-Ubuntu SMP Tue Sep 14 17:53:18 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux", + "CPU Information", + "processor : 0", + "vendor_id : GenuineIntel", + "cpu family : 6", + "model : 79", + "model name : Intel(R) Xeon(R) CPU E5-2673 v4 @ 2.30GHz", + ]; + const crunchRunLogs = [ + "2022-03-22T13:56:22.542417997Z using local keepstore process (pid 3733) at http://localhost:46837, writing logs to keepstore.txt in log collection", + "2022-03-22T13:56:26.237571754Z crunch-run 2.4.0~dev20220321141729 (go1.17.1) started", + "2022-03-22T13:56:26.244704134Z crunch-run process has uid=0(root) gid=0(root) groups=0(root)", + "2022-03-22T13:56:26.244862836Z Executing container 'zzzzz-dz642-1wokwvcct9s9du3' using docker runtime", + "2022-03-22T13:56:26.245037738Z Executing on host 'compute-99cb150b26149780de44b929577e1aed-19rgca8vobuvc4p'", + ]; + const stdoutLogs = [ + "2022-03-22T13:56:22.542417987Z Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec dui nisi, hendrerit porta sapien a, pretium dignissim purus.", + "2022-03-22T13:56:22.542417997Z Integer viverra, mauris finibus aliquet ultricies, dui mauris cursus justo, ut venenatis nibh ex eget neque.", + "2022-03-22T13:56:22.542418007Z In hac habitasse platea dictumst.", + "2022-03-22T13:56:22.542418027Z Fusce fringilla turpis id accumsan faucibus. Donec congue congue ex non posuere. In semper mi quis tristique rhoncus.", + "2022-03-22T13:56:22.542418037Z Interdum et malesuada fames ac ante ipsum primis in faucibus.", + "2022-03-22T13:56:22.542418047Z Quisque fermentum tortor ex, ut suscipit velit feugiat faucibus.", + "2022-03-22T13:56:22.542418057Z Donec vitae porta risus, at luctus nulla. Mauris gravida iaculis ipsum, id sagittis tortor egestas ac.", + "2022-03-22T13:56:22.542418067Z Maecenas condimentum volutpat nulla. Integer lacinia maximus risus eu posuere.", + "2022-03-22T13:56:22.542418077Z Donec vitae leo id augue gravida bibendum.", + "2022-03-22T13:56:22.542418087Z Nam libero libero, pretium ac faucibus elementum, mattis nec ex.", + "2022-03-22T13:56:22.542418097Z Nullam id laoreet nibh. Vivamus tellus metus, pretium quis justo ut, bibendum varius metus. Pellentesque vitae accumsan lorem, quis tincidunt augue.", + "2022-03-22T13:56:22.542418107Z Aliquam viverra nisi nulla, et efficitur dolor mattis in.", + "2022-03-22T13:56:22.542418117Z Sed at enim sit amet nulla tincidunt mattis. Aenean eget aliquet ex, non ultrices ex. Nulla ex tortor, vestibulum aliquam tempor ac, aliquam vel est.", + "2022-03-22T13:56:22.542418127Z Fusce auctor faucibus libero id venenatis. Etiam sodales, odio eu cursus efficitur, quam sem blandit ex, quis porttitor enim dui quis lectus. In id tincidunt felis.", + "2022-03-22T13:56:22.542418137Z Phasellus non ex quis arcu tempus faucibus molestie in sapien.", + "2022-03-22T13:56:22.542418147Z Duis tristique semper dolor, vitae pulvinar risus.", + "2022-03-22T13:56:22.542418157Z Aliquam tortor elit, luctus nec tortor eget, porta tristique nulla.", + "2022-03-22T13:56:22.542418167Z Nulla eget mollis ipsum.", + ]; + + createContainerRequest(activeUser, "test_container_request", "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function ( + containerRequest + ) { + cy.appendLog(adminUser.token, containerRequest.uuid, "node-info.txt", nodeInfoLogs).as("nodeInfoLogs"); + cy.appendLog(adminUser.token, containerRequest.uuid, "crunch-run.txt", crunchRunLogs).as("crunchRunLogs"); + cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", stdoutLogs).as("stdoutLogs"); + + cy.getAll("@stdoutLogs", "@nodeInfoLogs", "@crunchRunLogs").then(function () { + cy.loginAs(activeUser); + cy.goToPath(`/processes/${containerRequest.uuid}`); + // Should show main logs by default + cy.get("[data-cy=process-logs-filter]", { timeout: 7000 }).should("contain", "Main logs"); + cy.get("[data-cy=process-logs]") + .should("contain", stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)]) + .and("not.contain", nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)]) + .and("contain", crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]); + // Select 'All logs' + cy.get("[data-cy=process-logs-filter]").click(); + cy.get("body").contains("li", "All logs").click(); + cy.get("[data-cy=process-logs]") + .should("contain", stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)]) + .and("contain", nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)]) + .and("contain", crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]); + // Select 'node-info' logs + cy.get("[data-cy=process-logs-filter]").click(); + cy.get("body").contains("li", "node-info").click(); + cy.get("[data-cy=process-logs]") + .should("not.contain", stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)]) + .and("contain", nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)]) + .and("not.contain", crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]); + // Select 'stdout' logs + cy.get("[data-cy=process-logs-filter]").click(); + cy.get("body").contains("li", "stdout").click(); + cy.get("[data-cy=process-logs]") + .should("contain", stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)]) + .and("not.contain", nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)]) + .and("not.contain", crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]); + }); + }); }); + it("sorts combined logs", function () { + const crName = "test_container_request"; + createContainerRequest(activeUser, crName, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (containerRequest) { + cy.appendLog(adminUser.token, containerRequest.uuid, "node-info.txt", [ + "3: nodeinfo 1", + "2: nodeinfo 2", + "1: nodeinfo 3", + "2: nodeinfo 4", + "3: nodeinfo 5", + ]).as("node-info"); + + cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", [ + "2023-07-18T20:14:48.128642814Z first", + "2023-07-18T20:14:49.128642814Z third", + ]).as("stdout"); + + cy.appendLog(adminUser.token, containerRequest.uuid, "stderr.txt", ["2023-07-18T20:14:48.528642814Z second"]).as("stderr"); - // Force container_count for testing - let containerCount = 2; - cy.intercept({method: 'GET', url: '**/arvados/v1/container_requests/*'}, (req) => { - req.reply((res) => { - res.body.container_count = containerCount; + cy.loginAs(activeUser); + cy.goToPath(`/processes/${containerRequest.uuid}`); + cy.get("[data-cy=process-details]").should("contain", crName); + cy.get("[data-cy=process-logs]").should("contain", "No logs yet"); + + cy.getAll("@node-info", "@stdout", "@stderr").then(() => { + // Verify sorted main logs + cy.get("[data-cy=process-logs] pre", { timeout: 7000 }).eq(0).should("contain", "2023-07-18T20:14:48.128642814Z first"); + cy.get("[data-cy=process-logs] pre").eq(1).should("contain", "2023-07-18T20:14:48.528642814Z second"); + cy.get("[data-cy=process-logs] pre").eq(2).should("contain", "2023-07-18T20:14:49.128642814Z third"); + + // Switch to All logs + cy.get("[data-cy=process-logs-filter]").click(); + cy.get("body").contains("li", "All logs").click(); + // Verify non-sorted lines were preserved + cy.get("[data-cy=process-logs] pre").eq(0).should("contain", "3: nodeinfo 1"); + cy.get("[data-cy=process-logs] pre").eq(1).should("contain", "2: nodeinfo 2"); + cy.get("[data-cy=process-logs] pre").eq(2).should("contain", "1: nodeinfo 3"); + cy.get("[data-cy=process-logs] pre").eq(3).should("contain", "2: nodeinfo 4"); + cy.get("[data-cy=process-logs] pre").eq(4).should("contain", "3: nodeinfo 5"); + // Verify sorted logs + cy.get("[data-cy=process-logs] pre").eq(5).should("contain", "2023-07-18T20:14:48.128642814Z first"); + cy.get("[data-cy=process-logs] pre").eq(6).should("contain", "2023-07-18T20:14:48.528642814Z second"); + cy.get("[data-cy=process-logs] pre").eq(7).should("contain", "2023-07-18T20:14:49.128642814Z third"); + }); }); }); - cy.getAll('@containerRequest').then(function([containerRequest]) { - cy.goToPath(`/processes/${containerRequest.uuid}`); - cy.get('[data-cy=process-runtime-status-retry-warning]', {timeout: 7000}) - .should('contain', 'Process retried 1 time'); + it("preserves original ordering of lines within the same log type", function () { + const crName = "test_container_request"; + createContainerRequest(activeUser, crName, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (containerRequest) { + cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", [ + // Should come first + "2023-07-18T20:14:46.000000000Z A out 1", + // Comes fourth in a contiguous block + "2023-07-18T20:14:48.128642814Z A out 2", + "2023-07-18T20:14:48.128642814Z X out 3", + "2023-07-18T20:14:48.128642814Z A out 4", + ]).as("stdout"); + + cy.appendLog(adminUser.token, containerRequest.uuid, "stderr.txt", [ + // Comes second + "2023-07-18T20:14:47.000000000Z Z err 1", + // Comes third in a contiguous block + "2023-07-18T20:14:48.128642814Z B err 2", + "2023-07-18T20:14:48.128642814Z C err 3", + "2023-07-18T20:14:48.128642814Z Y err 4", + "2023-07-18T20:14:48.128642814Z Z err 5", + "2023-07-18T20:14:48.128642814Z A err 6", + ]).as("stderr"); + + cy.loginAs(activeUser); + cy.goToPath(`/processes/${containerRequest.uuid}`); + cy.get("[data-cy=process-details]").should("contain", crName); + cy.get("[data-cy=process-logs]").should("contain", "No logs yet"); + + cy.getAll("@stdout", "@stderr").then(() => { + // Switch to All logs + cy.get("[data-cy=process-logs-filter]").click(); + cy.get("body").contains("li", "All logs").click(); + // Verify sorted logs + cy.get("[data-cy=process-logs] pre").eq(0).should("contain", "2023-07-18T20:14:46.000000000Z A out 1"); + cy.get("[data-cy=process-logs] pre").eq(1).should("contain", "2023-07-18T20:14:47.000000000Z Z err 1"); + cy.get("[data-cy=process-logs] pre").eq(2).should("contain", "2023-07-18T20:14:48.128642814Z B err 2"); + cy.get("[data-cy=process-logs] pre").eq(3).should("contain", "2023-07-18T20:14:48.128642814Z C err 3"); + cy.get("[data-cy=process-logs] pre").eq(4).should("contain", "2023-07-18T20:14:48.128642814Z Y err 4"); + cy.get("[data-cy=process-logs] pre").eq(5).should("contain", "2023-07-18T20:14:48.128642814Z Z err 5"); + cy.get("[data-cy=process-logs] pre").eq(6).should("contain", "2023-07-18T20:14:48.128642814Z A err 6"); + cy.get("[data-cy=process-logs] pre").eq(7).should("contain", "2023-07-18T20:14:48.128642814Z A out 2"); + cy.get("[data-cy=process-logs] pre").eq(8).should("contain", "2023-07-18T20:14:48.128642814Z X out 3"); + cy.get("[data-cy=process-logs] pre").eq(9).should("contain", "2023-07-18T20:14:48.128642814Z A out 4"); + }); + }); }); - cy.getAll('@containerRequest').then(function([containerRequest]) { - containerCount = 3; - cy.goToPath(`/processes/${containerRequest.uuid}`); - cy.get('[data-cy=process-runtime-status-retry-warning]', {timeout: 7000}) - .should('contain', 'Process retried 2 times'); + it("correctly generates sniplines", function () { + const SNIPLINE = `================ ✀ ================ ✀ ========= Some log(s) were skipped ========= ✀ ================ ✀ ================`; + const crName = "test_container_request"; + createContainerRequest(activeUser, crName, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (containerRequest) { + cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", [ + "X".repeat(63999) + "_" + "O".repeat(100) + "_" + "X".repeat(63999), + ]).as("stdout"); + + cy.loginAs(activeUser); + cy.goToPath(`/processes/${containerRequest.uuid}`); + cy.get("[data-cy=process-details]").should("contain", crName); + cy.get("[data-cy=process-logs]").should("contain", "No logs yet"); + + // Switch to stdout since lines are unsortable (no timestamp) + cy.get("[data-cy=process-logs-filter]").click(); + cy.get("body").contains("li", "stdout").click(); + + cy.getAll("@stdout").then(() => { + // Verify first 64KB and snipline + cy.get("[data-cy=process-logs] pre", { timeout: 7000 }) + .eq(0) + .should("contain", "X".repeat(63999) + "_\n" + SNIPLINE); + // Verify last 64KB + cy.get("[data-cy=process-logs] pre") + .eq(1) + .should("contain", "_" + "X".repeat(63999)); + // Verify none of the Os got through + cy.get("[data-cy=process-logs] pre").should("not.contain", "O"); + }); + }); }); }); - - const testInputs = [ - { - definition: { - "id": "#main/input_file", - "label": "Label Description", - "type": "File" + describe("I/O panel", function () { + const testInputs = [ + { + definition: { + id: "#main/input_file", + label: "Label Description", + type: "File", + }, + input: { + input_file: { + basename: "input1.tar", + class: "File", + location: "keep:00000000000000000000000000000000+01/input1.tar", + secondaryFiles: [ + { + basename: "input1-2.txt", + class: "File", + location: "keep:00000000000000000000000000000000+01/input1-2.txt", + }, + { + basename: "input1-3.txt", + class: "File", + location: "keep:00000000000000000000000000000000+01/input1-3.txt", + }, + { + basename: "input1-4.txt", + class: "File", + location: "keep:00000000000000000000000000000000+01/input1-4.txt", + }, + ], + }, + }, + }, + { + definition: { + id: "#main/input_dir", + doc: "Doc Description", + type: "Directory", + }, + input: { + input_dir: { + basename: "11111111111111111111111111111111+01", + class: "Directory", + location: "keep:11111111111111111111111111111111+01", + }, + }, + }, + { + definition: { + id: "#main/input_bool", + doc: ["Doc desc 1", "Doc desc 2"], + type: "boolean", + }, + input: { + input_bool: true, + }, }, - input: { - "input_file": { - "basename": "input1.tar", - "class": "File", - "location": "keep:00000000000000000000000000000000+01/input1.tar", - "secondaryFiles": [ + { + definition: { + id: "#main/input_int", + type: "int", + }, + input: { + input_int: 1, + }, + }, + { + definition: { + id: "#main/input_long", + type: "long", + }, + input: { + input_long: 1, + }, + }, + { + definition: { + id: "#main/input_float", + type: "float", + }, + input: { + input_float: 1.5, + }, + }, + { + definition: { + id: "#main/input_double", + type: "double", + }, + input: { + input_double: 1.3, + }, + }, + { + definition: { + id: "#main/input_string", + type: "string", + }, + input: { + input_string: "Hello World", + }, + }, + { + definition: { + id: "#main/input_file_array", + type: { + items: "File", + type: "array", + }, + }, + input: { + input_file_array: [ { - "basename": "input1-2.txt", - "class": "File", - "location": "keep:00000000000000000000000000000000+01/input1-2.txt" + basename: "input2.tar", + class: "File", + location: "keep:00000000000000000000000000000000+02/input2.tar", }, { - "basename": "input1-3.txt", - "class": "File", - "location": "keep:00000000000000000000000000000000+01/input1-3.txt" + basename: "input3.tar", + class: "File", + location: "keep:00000000000000000000000000000000+03/input3.tar", + secondaryFiles: [ + { + basename: "input3-2.txt", + class: "File", + location: "keep:00000000000000000000000000000000+03/input3-2.txt", + }, + ], }, { - "basename": "input1-4.txt", - "class": "File", - "location": "keep:00000000000000000000000000000000+01/input1-4.txt" - } - ] - } - } - }, - { - definition: { - "id": "#main/input_dir", - "doc": "Doc Description", - "type": "Directory" - }, - input: { - "input_dir": { - "basename": "11111111111111111111111111111111+01", - "class": "Directory", - "location": "keep:11111111111111111111111111111111+01" - } - } - }, - { - definition: { - "id": "#main/input_bool", - "doc": ["Doc desc 1", "Doc desc 2"], - "type": "boolean" - }, - input: { - "input_bool": true, - } - }, - { - definition: { - "id": "#main/input_int", - "type": "int" + $import: "import_path", + }, + ], + }, }, - input: { - "input_int": 1, - } - }, - { - definition: { - "id": "#main/input_long", - "type": "long" + { + definition: { + id: "#main/input_dir_array", + type: { + items: "Directory", + type: "array", + }, + }, + input: { + input_dir_array: [ + { + basename: "11111111111111111111111111111111+02", + class: "Directory", + location: "keep:11111111111111111111111111111111+02", + }, + { + basename: "11111111111111111111111111111111+03", + class: "Directory", + location: "keep:11111111111111111111111111111111+03", + }, + { + $import: "import_path", + }, + ], + }, }, - input: { - "input_long" : 1, - } - }, - { - definition: { - "id": "#main/input_float", - "type": "float" + { + definition: { + id: "#main/input_int_array", + type: { + items: "int", + type: "array", + }, + }, + input: { + input_int_array: [ + 1, + 3, + 5, + { + $import: "import_path", + }, + ], + }, }, - input: { - "input_float": 1.5, - } - }, - { - definition: { - "id": "#main/input_double", - "type": "double" + { + definition: { + id: "#main/input_long_array", + type: { + items: "long", + type: "array", + }, + }, + input: { + input_long_array: [ + 10, + 20, + { + $import: "import_path", + }, + ], + }, }, - input: { - "input_double": 1.3, - } - }, - { - definition: { - "id": "#main/input_string", - "type": "string" + { + definition: { + id: "#main/input_float_array", + type: { + items: "float", + type: "array", + }, + }, + input: { + input_float_array: [ + 10.2, + 10.4, + 10.6, + { + $import: "import_path", + }, + ], + }, }, - input: { - "input_string": "Hello World", - } - }, - { - definition: { - "id": "#main/input_file_array", - "type": { - "items": "File", - "type": "array" - } + { + definition: { + id: "#main/input_double_array", + type: { + items: "double", + type: "array", + }, + }, + input: { + input_double_array: [ + 20.1, + 20.2, + 20.3, + { + $import: "import_path", + }, + ], + }, }, - input: { - "input_file_array": [ - { - "basename": "input2.tar", - "class": "File", - "location": "keep:00000000000000000000000000000000+02/input2.tar" + { + definition: { + id: "#main/input_string_array", + type: { + items: "string", + type: "array", }, - { - "basename": "input3.tar", - "class": "File", - "location": "keep:00000000000000000000000000000000+03/input3.tar", - "secondaryFiles": [ - { - "basename": "input3-2.txt", - "class": "File", - "location": "keep:00000000000000000000000000000000+03/input3-2.txt" - } - ] + }, + input: { + input_string_array: [ + "Hello", + "World", + "!", + { + $import: "import_path", + }, + ], + }, + }, + { + definition: { + id: "#main/input_bool_include", + type: "boolean", + }, + input: { + input_bool_include: { + $include: "include_path", }, - { - "$import": "import_path" - } - ] - } - }, - { - definition: { - "id": "#main/input_dir_array", - "type": { - "items": "Directory", - "type": "array" - } + }, }, - input: { - "input_dir_array": [ - { - "basename": "11111111111111111111111111111111+02", - "class": "Directory", - "location": "keep:11111111111111111111111111111111+02" + { + definition: { + id: "#main/input_int_include", + type: "int", + }, + input: { + input_int_include: { + $include: "include_path", }, - { - "basename": "11111111111111111111111111111111+03", - "class": "Directory", - "location": "keep:11111111111111111111111111111111+03" + }, + }, + { + definition: { + id: "#main/input_float_include", + type: "float", + }, + input: { + input_float_include: { + $include: "include_path", }, - { - "$import": "import_path" - } - ] - } - }, - { - definition: { - "id": "#main/input_int_array", - "type": { - "items": "int", - "type": "array" - } + }, }, - input: { - "input_int_array": [ - 1, - 3, - 5, - { - "$import": "import_path" - } - ] - } - }, - { - definition: { - "id": "#main/input_long_array", - "type": { - "items": "long", - "type": "array" - } + { + definition: { + id: "#main/input_string_include", + type: "string", + }, + input: { + input_string_include: { + $include: "include_path", + }, + }, }, - input: { - "input_long_array": [ - 10, - 20, - { - "$import": "import_path" - } - ] - } - }, - { - definition: { - "id": "#main/input_float_array", - "type": { - "items": "float", - "type": "array" - } + { + definition: { + id: "#main/input_file_include", + type: "File", + }, + input: { + input_file_include: { + $include: "include_path", + }, + }, }, - input: { - "input_float_array": [ - 10.2, - 10.4, - 10.6, - { - "$import": "import_path" - } - ] - } - }, - { - definition: { - "id": "#main/input_double_array", - "type": { - "items": "double", - "type": "array" - } + { + definition: { + id: "#main/input_directory_include", + type: "Directory", + }, + input: { + input_directory_include: { + $include: "include_path", + }, + }, }, - input: { - "input_double_array": [ - 20.1, - 20.2, - 20.3, - { - "$import": "import_path" - } - ] - } - }, - { - definition: { - "id": "#main/input_string_array", - "type": { - "items": "string", - "type": "array" - } + { + definition: { + id: "#main/input_file_url", + type: "File", + }, + input: { + input_file_url: { + basename: "index.html", + class: "File", + location: "http://example.com/index.html", + }, + }, }, - input: { - "input_string_array": [ - "Hello", - "World", - "!", - { - "$import": "import_path" - } - ] - } - }, - { - definition: { - "id": "#main/input_bool_include", - "type": "boolean" + ]; + + const testOutputs = [ + { + definition: { + id: "#main/output_file", + label: "Label Description", + type: "File", + }, + output: { + output_file: { + basename: "cat.png", + class: "File", + location: "cat.png", + }, + }, }, - input: { - "input_bool_include": { - "$include": "include_path" - } - } - }, - { - definition: { - "id": "#main/input_int_include", - "type": "int" + { + definition: { + id: "#main/output_file_with_secondary", + doc: "Doc Description", + type: "File", + }, + output: { + output_file_with_secondary: { + basename: "main.dat", + class: "File", + location: "main.dat", + secondaryFiles: [ + { + basename: "secondary.dat", + class: "File", + location: "secondary.dat", + }, + { + basename: "secondary2.dat", + class: "File", + location: "secondary2.dat", + }, + ], + }, + }, }, - input: { - "input_int_include": { - "$include": "include_path" - } - } - }, - { - definition: { - "id": "#main/input_float_include", - "type": "float" + { + definition: { + id: "#main/output_dir", + doc: ["Doc desc 1", "Doc desc 2"], + type: "Directory", + }, + output: { + output_dir: { + basename: "outdir1", + class: "Directory", + location: "outdir1", + }, + }, }, - input: { - "input_float_include": { - "$include": "include_path" - } - } - }, - { - definition: { - "id": "#main/input_string_include", - "type": "string" + { + definition: { + id: "#main/output_bool", + type: "boolean", + }, + output: { + output_bool: true, + }, }, - input: { - "input_string_include": { - "$include": "include_path" - } - } - }, - { - definition: { - "id": "#main/input_file_include", - "type": "File" + { + definition: { + id: "#main/output_int", + type: "int", + }, + output: { + output_int: 1, + }, }, - input: { - "input_file_include": { - "$include": "include_path" - } - } - }, - { - definition: { - "id": "#main/input_directory_include", - "type": "Directory" + { + definition: { + id: "#main/output_long", + type: "long", + }, + output: { + output_long: 1, + }, }, - input: { - "input_directory_include": { - "$include": "include_path" - } - } - }, - { - definition: { - "id": "#main/input_file_url", - "type": "File" + { + definition: { + id: "#main/output_float", + type: "float", + }, + output: { + output_float: 100.5, + }, }, - input: { - "input_file_url": { - "basename": "index.html", - "class": "File", - "location": "http://example.com/index.html" - } - } - } - ]; - - const testOutputs = [ - { - definition: { - "id": "#main/output_file", - "label": "Label Description", - "type": "File" + { + definition: { + id: "#main/output_double", + type: "double", + }, + output: { + output_double: 100.3, + }, }, - output: { - "output_file": { - "basename": "cat.png", - "class": "File", - "location": "cat.png" - } - } - }, - { - definition: { - "id": "#main/output_file_with_secondary", - "doc": "Doc Description", - "type": "File" + { + definition: { + id: "#main/output_string", + type: "string", + }, + output: { + output_string: "Hello output", + }, }, - output: { - "output_file_with_secondary": { - "basename": "main.dat", - "class": "File", - "location": "main.dat", - "secondaryFiles": [ + { + definition: { + id: "#main/output_file_array", + type: { + items: "File", + type: "array", + }, + }, + output: { + output_file_array: [ { - "basename": "secondary.dat", - "class": "File", - "location": "secondary.dat" + basename: "output2.tar", + class: "File", + location: "output2.tar", }, { - "basename": "secondary2.dat", - "class": "File", - "location": "secondary2.dat" - } - ] - } - } - }, - { - definition: { - "id": "#main/output_dir", - "doc": ["Doc desc 1", "Doc desc 2"], - "type": "Directory" - }, - output: { - "output_dir": { - "basename": "outdir1", - "class": "Directory", - "location": "outdir1" - } - } - }, - { - definition: { - "id": "#main/output_bool", - "type": "boolean" - }, - output: { - "output_bool": true - } - }, - { - definition: { - "id": "#main/output_int", - "type": "int" - }, - output: { - "output_int": 1 - } - }, - { - definition: { - "id": "#main/output_long", - "type": "long" - }, - output: { - "output_long": 1 - } - }, - { - definition: { - "id": "#main/output_float", - "type": "float" - }, - output: { - "output_float": 100.5 - } - }, - { - definition: { - "id": "#main/output_double", - "type": "double" - }, - output: { - "output_double": 100.3 - } - }, - { - definition: { - "id": "#main/output_string", - "type": "string" - }, - output: { - "output_string": "Hello output" - } - }, - { - definition: { - "id": "#main/output_file_array", - "type": { - "items": "File", - "type": "array" - } + basename: "output3.tar", + class: "File", + location: "output3.tar", + }, + ], + }, }, - output: { - "output_file_array": [ - { - "basename": "output2.tar", - "class": "File", - "location": "output2.tar" + { + definition: { + id: "#main/output_dir_array", + type: { + items: "Directory", + type: "array", }, - { - "basename": "output3.tar", - "class": "File", - "location": "output3.tar" - } - ] - } - }, - { - definition: { - "id": "#main/output_dir_array", - "type": { - "items": "Directory", - "type": "array" - } + }, + output: { + output_dir_array: [ + { + basename: "outdir2", + class: "Directory", + location: "outdir2", + }, + { + basename: "outdir3", + class: "Directory", + location: "outdir3", + }, + ], + }, }, - output: { - "output_dir_array": [ - { - "basename": "outdir2", - "class": "Directory", - "location": "outdir2" + { + definition: { + id: "#main/output_int_array", + type: { + items: "int", + type: "array", }, - { - "basename": "outdir3", - "class": "Directory", - "location": "outdir3" - } - ] - } - }, - { - definition: { - "id": "#main/output_int_array", - "type": { - "items": "int", - "type": "array" - } + }, + output: { + output_int_array: [10, 11, 12], + }, }, - output: { - "output_int_array": [ - 10, - 11, - 12 - ] - } - }, - { - definition: { - "id": "#main/output_long_array", - "type": { - "items": "long", - "type": "array" - } + { + definition: { + id: "#main/output_long_array", + type: { + items: "long", + type: "array", + }, + }, + output: { + output_long_array: [51, 52], + }, }, - output: { - "output_long_array": [ - 51, - 52 - ] - } - }, - { - definition: { - "id": "#main/output_float_array", - "type": { - "items": "float", - "type": "array" - } + { + definition: { + id: "#main/output_float_array", + type: { + items: "float", + type: "array", + }, + }, + output: { + output_float_array: [100.2, 100.4, 100.6], + }, }, - output: { - "output_float_array": [ - 100.2, - 100.4, - 100.6 - ] - } - }, - { - definition: { - "id": "#main/output_double_array", - "type": { - "items": "double", - "type": "array" - } + { + definition: { + id: "#main/output_double_array", + type: { + items: "double", + type: "array", + }, + }, + output: { + output_double_array: [100.1, 100.2, 100.3], + }, }, - output: { - "output_double_array": [ - 100.1, - 100.2, - 100.3 - ] - } - }, - { - definition: { - "id": "#main/output_string_array", - "type": { - "items": "string", - "type": "array" - } + { + definition: { + id: "#main/output_string_array", + type: { + items: "string", + type: "array", + }, + }, + output: { + output_string_array: ["Hello", "Output", "!"], + }, }, - output: { - "output_string_array": [ - "Hello", - "Output", - "!" - ] - } - } - ]; - - const verifyIOParameter = (name, label, doc, val, collection, multipleRows) => { - cy.get('table tr').contains(name).parents('tr').within(($mainRow) => { - label && cy.contains(label); - - if (multipleRows) { - cy.get($mainRow).nextUntil('[data-cy="process-io-param"]').as('secondaryRows'); - if (val) { - if (Array.isArray(val)) { - val.forEach(v => cy.get('@secondaryRows').contains(v)); - } else { - cy.get('@secondaryRows').contains(val); - } - } - if (collection) { - cy.get('@secondaryRows').contains(collection); - } - } else { - if (val) { - if (Array.isArray(val)) { - val.forEach(v => cy.contains(v)); + ]; + + const verifyIOParameter = (name, label, doc, val, collection, multipleRows) => { + cy.get("table tr") + .contains(name) + .parents("tr") + .within($mainRow => { + label && cy.contains(label); + + if (multipleRows) { + cy.get($mainRow).nextUntil('[data-cy="process-io-param"]').as("secondaryRows"); + if (val) { + if (Array.isArray(val)) { + val.forEach(v => cy.get("@secondaryRows").contains(v)); + } else { + cy.get("@secondaryRows").contains(val); + } + } + if (collection) { + cy.get("@secondaryRows").contains(collection); + } } else { - cy.contains(val); + if (val) { + if (Array.isArray(val)) { + val.forEach(v => cy.contains(v)); + } else { + cy.contains(val); + } + } + if (collection) { + cy.contains(collection); + } } - } - if (collection) { - cy.contains(collection); - } - } - - - }); - }; - - const verifyIOParameterImage = (name, url) => { - cy.get('table tr').contains(name).parents('tr').within(() => { - cy.get('[alt="Inline Preview"]') - .should('be.visible') - .and(($img) => { - expect($img[0].naturalWidth).to.be.greaterThan(0); - expect($img[0].src).contains(url); - }) - }); - }; - - it('displays IO parameters with keep links and previews', function() { - // Create output collection for real files - cy.createCollection(adminUser.token, { - name: `Test collection ${Math.floor(Math.random() * 999999)}`, - owner_uuid: activeUser.user.uuid, - }).then((testOutputCollection) => { - cy.loginAs(activeUser); - - cy.goToPath(`/collections/${testOutputCollection.uuid}`); - - cy.get('[data-cy=upload-button]').click(); - - cy.fixture('files/cat.png', 'base64').then(content => { - cy.get('[data-cy=drag-and-drop]').upload(content, 'cat.png'); - cy.get('[data-cy=form-submit-btn]').click(); - cy.waitForDom().get('[data-cy=form-submit-btn]').should('not.exist'); - // Confirm final collection state. - cy.get('[data-cy=collection-files-panel]') - .contains('cat.png').should('exist'); - }); - - cy.getCollection(activeUser.token, testOutputCollection.uuid).as('testOutputCollection'); }); - - // Get updated collection pdh - cy.getAll('@testOutputCollection').then(([testOutputCollection]) => { - // Add output uuid and inputs to container request - cy.intercept({method: 'GET', url: '**/arvados/v1/container_requests/*'}, (req) => { - req.reply((res) => { - res.body.output_uuid = testOutputCollection.uuid; - res.body.mounts["/var/lib/cwl/cwl.input.json"] = { - content: testInputs.map((param) => (param.input)).reduce((acc, val) => (Object.assign(acc, val)), {}) - }; - res.body.mounts["/var/lib/cwl/workflow.json"] = { - content: { - $graph: [{ - id: "#main", - inputs: testInputs.map((input) => (input.definition)), - outputs: testOutputs.map((output) => (output.definition)) - }] - } - }; + }; + + const verifyIOParameterImage = (name, url) => { + cy.get("table tr") + .contains(name) + .parents("tr") + .within(() => { + cy.get('[alt="Inline Preview"]') + .should("be.visible") + .and($img => { + expect($img[0].naturalWidth).to.be.greaterThan(0); + expect($img[0].src).contains(url); + }); }); - }); + }; + + it("displays IO parameters with keep links and previews", function () { + // Create output collection for real files + cy.createCollection(adminUser.token, { + name: `Test collection ${Math.floor(Math.random() * 999999)}`, + owner_uuid: activeUser.user.uuid, + }).then(testOutputCollection => { + cy.loginAs(activeUser); - // Stub fake output collection - cy.intercept({method: 'GET', url: `**/arvados/v1/collections/${testOutputCollection.uuid}*`}, { - statusCode: 200, - body: { - uuid: testOutputCollection.uuid, - portable_data_hash: testOutputCollection.portable_data_hash, - } - }); + cy.goToPath(`/collections/${testOutputCollection.uuid}`); - // Stub fake output json - cy.intercept({method: 'GET', url: '**/c%3Dzzzzz-4zz18-zzzzzzzzzzzzzzz/cwl.output.json'}, { - statusCode: 200, - body: testOutputs.map((param) => (param.output)).reduce((acc, val) => (Object.assign(acc, val)), {}) - }); + cy.get("[data-cy=upload-button]").click(); + + cy.fixture("files/cat.png", "base64").then(content => { + cy.get("[data-cy=drag-and-drop]").upload(content, "cat.png"); + cy.get("[data-cy=form-submit-btn]").click(); + cy.waitForDom().get("[data-cy=form-submit-btn]").should("not.exist"); + // Confirm final collection state. + cy.get("[data-cy=collection-files-panel]").contains("cat.png").should("exist"); + }); - // Stub webdav response, points to output json - cy.intercept({method: 'PROPFIND', url: '*'}, { - fixture: 'webdav-propfind-outputs.xml', + cy.getCollection(activeUser.token, testOutputCollection.uuid).as("testOutputCollection"); }); - }); - createContainerRequest( - activeUser, - 'test_container_request', - 'arvados/jobs', - ['echo', 'hello world'], - false, 'Committed') - .as('containerRequest'); - - cy.getAll('@containerRequest', '@testOutputCollection').then(function([containerRequest, testOutputCollection]) { - cy.goToPath(`/processes/${containerRequest.uuid}`); - cy.get('[data-cy=process-io-card] h6').contains('Inputs') - .parents('[data-cy=process-io-card]').within(() => { - verifyIOParameter('input_file', null, "Label Description", 'input1.tar', '00000000000000000000000000000000+01'); - verifyIOParameter('input_file', null, "Label Description", 'input1-2.txt', undefined, true); - verifyIOParameter('input_file', null, "Label Description", 'input1-3.txt', undefined, true); - verifyIOParameter('input_file', null, "Label Description", 'input1-4.txt', undefined, true); - verifyIOParameter('input_dir', null, "Doc Description", '/', '11111111111111111111111111111111+01'); - verifyIOParameter('input_bool', null, "Doc desc 1, Doc desc 2", 'true'); - verifyIOParameter('input_int', null, null, '1'); - verifyIOParameter('input_long', null, null, '1'); - verifyIOParameter('input_float', null, null, '1.5'); - verifyIOParameter('input_double', null, null, '1.3'); - verifyIOParameter('input_string', null, null, 'Hello World'); - verifyIOParameter('input_file_array', null, null, 'input2.tar', '00000000000000000000000000000000+02'); - verifyIOParameter('input_file_array', null, null, 'input3.tar', undefined, true); - verifyIOParameter('input_file_array', null, null, 'input3-2.txt', undefined, true); - verifyIOParameter('input_file_array', null, null, 'Cannot display value', undefined, true); - verifyIOParameter('input_dir_array', null, null, '/', '11111111111111111111111111111111+02'); - verifyIOParameter('input_dir_array', null, null, '/', '11111111111111111111111111111111+03', true); - verifyIOParameter('input_dir_array', null, null, 'Cannot display value', undefined, true); - verifyIOParameter('input_int_array', null, null, ["1", "3", "5", "Cannot display value"]); - verifyIOParameter('input_long_array', null, null, ["10", "20", "Cannot display value"]); - verifyIOParameter('input_float_array', null, null, ["10.2", "10.4", "10.6", "Cannot display value"]); - verifyIOParameter('input_double_array', null, null, ["20.1", "20.2", "20.3", "Cannot display value"]); - verifyIOParameter('input_string_array', null, null, ["Hello", "World", "!", "Cannot display value"]); - verifyIOParameter('input_bool_include', null, null, "Cannot display value"); - verifyIOParameter('input_int_include', null, null, "Cannot display value"); - verifyIOParameter('input_float_include', null, null, "Cannot display value"); - verifyIOParameter('input_string_include', null, null, "Cannot display value"); - verifyIOParameter('input_file_include', null, null, "Cannot display value"); - verifyIOParameter('input_directory_include', null, null, "Cannot display value"); - verifyIOParameter('input_file_url', null, null, "http://example.com/index.html"); - }); - cy.get('[data-cy=process-io-card] h6').contains('Outputs') - .parents('[data-cy=process-io-card]').within((ctx) => { - cy.get(ctx).scrollIntoView(); - cy.get('[data-cy="io-preview-image-toggle"]').click(); - const outPdh = testOutputCollection.portable_data_hash; - - verifyIOParameter('output_file', null, "Label Description", 'cat.png', `${outPdh}`); - verifyIOParameterImage('output_file', `/c=${outPdh}/cat.png`); - verifyIOParameter('output_file_with_secondary', null, "Doc Description", 'main.dat', `${outPdh}`); - verifyIOParameter('output_file_with_secondary', null, "Doc Description", 'secondary.dat', undefined, true); - verifyIOParameter('output_file_with_secondary', null, "Doc Description", 'secondary2.dat', undefined, true); - verifyIOParameter('output_dir', null, "Doc desc 1, Doc desc 2", 'outdir1', `${outPdh}`); - verifyIOParameter('output_bool', null, null, 'true'); - verifyIOParameter('output_int', null, null, '1'); - verifyIOParameter('output_long', null, null, '1'); - verifyIOParameter('output_float', null, null, '100.5'); - verifyIOParameter('output_double', null, null, '100.3'); - verifyIOParameter('output_string', null, null, 'Hello output'); - verifyIOParameter('output_file_array', null, null, 'output2.tar', `${outPdh}`); - verifyIOParameter('output_file_array', null, null, 'output3.tar', undefined, true); - verifyIOParameter('output_dir_array', null, null, 'outdir2', `${outPdh}`); - verifyIOParameter('output_dir_array', null, null, 'outdir3', undefined, true); - verifyIOParameter('output_int_array', null, null, ["10", "11", "12"]); - verifyIOParameter('output_long_array', null, null, ["51", "52"]); - verifyIOParameter('output_float_array', null, null, ["100.2", "100.4", "100.6"]); - verifyIOParameter('output_double_array', null, null, ["100.1", "100.2", "100.3"]); - verifyIOParameter('output_string_array', null, null, ["Hello", "Output", "!"]); + // Get updated collection pdh + cy.getAll("@testOutputCollection").then(([testOutputCollection]) => { + // Add output uuid and inputs to container request + cy.intercept({ method: "GET", url: "**/arvados/v1/container_requests/*" }, req => { + req.reply(res => { + res.body.output_uuid = testOutputCollection.uuid; + res.body.mounts["/var/lib/cwl/cwl.input.json"] = { + content: testInputs.map(param => param.input).reduce((acc, val) => Object.assign(acc, val), {}), + }; + res.body.mounts["/var/lib/cwl/workflow.json"] = { + content: { + $graph: [ + { + id: "#main", + inputs: testInputs.map(input => input.definition), + outputs: testOutputs.map(output => output.definition), + }, + ], + }, + }; + }); }); - }); - }); - it('displays IO parameters with no value', function() { - - const fakeOutputUUID = 'zzzzz-4zz18-abcdefghijklmno'; - const fakeOutputPDH = '11111111111111111111111111111111+99/'; - - cy.loginAs(activeUser); - - // Add output uuid and inputs to container request - cy.intercept({method: 'GET', url: '**/arvados/v1/container_requests/*'}, (req) => { - req.reply((res) => { - res.body.output_uuid = fakeOutputUUID; - res.body.mounts["/var/lib/cwl/cwl.input.json"] = { - content: {} - }; - res.body.mounts["/var/lib/cwl/workflow.json"] = { - content: { - $graph: [{ - id: "#main", - inputs: testInputs.map((input) => (input.definition)), - outputs: testOutputs.map((output) => (output.definition)) - }] + // Stub fake output collection + cy.intercept( + { method: "GET", url: `**/arvados/v1/collections/${testOutputCollection.uuid}*` }, + { + statusCode: 200, + body: { + uuid: testOutputCollection.uuid, + portable_data_hash: testOutputCollection.portable_data_hash, + }, } - }; - }); - }); + ); - // Stub fake output collection - cy.intercept({method: 'GET', url: `**/arvados/v1/collections/${fakeOutputUUID}*`}, { - statusCode: 200, - body: { - uuid: fakeOutputUUID, - portable_data_hash: fakeOutputPDH, - } - }); - - // Stub fake output json - cy.intercept({method: 'GET', url: `**/c%3D${fakeOutputUUID}/cwl.output.json`}, { - statusCode: 200, - body: {} - }); + // Stub fake output json + cy.intercept( + { method: "GET", url: "**/c%3Dzzzzz-4zz18-zzzzzzzzzzzzzzz/cwl.output.json" }, + { + statusCode: 200, + body: testOutputs.map(param => param.output).reduce((acc, val) => Object.assign(acc, val), {}), + } + ); - cy.readFile('cypress/fixtures/webdav-propfind-outputs.xml').then((data) => { - // Stub webdav response, points to output json - cy.intercept({method: 'PROPFIND', url: '*'}, { - statusCode: 200, - body: data.replace(/zzzzz-4zz18-zzzzzzzzzzzzzzz/g, fakeOutputUUID) + // Stub webdav response, points to output json + cy.intercept( + { method: "PROPFIND", url: "*" }, + { + fixture: "webdav-propfind-outputs.xml", + } + ); }); - }); - createContainerRequest( - activeUser, - 'test_container_request', - 'arvados/jobs', - ['echo', 'hello world'], - false, 'Committed') - .as('containerRequest'); - - cy.getAll('@containerRequest').then(function([containerRequest]) { - cy.goToPath(`/processes/${containerRequest.uuid}`); - cy.get('[data-cy=process-io-card] h6').contains('Inputs') - .parents('[data-cy=process-io-card]').within(() => { - cy.wait(2000); - cy.waitForDom(); - cy.get('tbody tr').each((item) => { - cy.wrap(item).contains('No value'); + createContainerRequest(activeUser, "test_container_request", "arvados/jobs", ["echo", "hello world"], false, "Committed").as( + "containerRequest" + ); + + cy.getAll("@containerRequest", "@testOutputCollection").then(function ([containerRequest, testOutputCollection]) { + cy.goToPath(`/processes/${containerRequest.uuid}`); + cy.get("[data-cy=process-io-card] h6") + .contains("Inputs") + .parents("[data-cy=process-io-card]") + .within(() => { + verifyIOParameter("input_file", null, "Label Description", "input1.tar", "00000000000000000000000000000000+01"); + verifyIOParameter("input_file", null, "Label Description", "input1-2.txt", undefined, true); + verifyIOParameter("input_file", null, "Label Description", "input1-3.txt", undefined, true); + verifyIOParameter("input_file", null, "Label Description", "input1-4.txt", undefined, true); + verifyIOParameter("input_dir", null, "Doc Description", "/", "11111111111111111111111111111111+01"); + verifyIOParameter("input_bool", null, "Doc desc 1, Doc desc 2", "true"); + verifyIOParameter("input_int", null, null, "1"); + verifyIOParameter("input_long", null, null, "1"); + verifyIOParameter("input_float", null, null, "1.5"); + verifyIOParameter("input_double", null, null, "1.3"); + verifyIOParameter("input_string", null, null, "Hello World"); + verifyIOParameter("input_file_array", null, null, "input2.tar", "00000000000000000000000000000000+02"); + verifyIOParameter("input_file_array", null, null, "input3.tar", undefined, true); + verifyIOParameter("input_file_array", null, null, "input3-2.txt", undefined, true); + verifyIOParameter("input_file_array", null, null, "Cannot display value", undefined, true); + verifyIOParameter("input_dir_array", null, null, "/", "11111111111111111111111111111111+02"); + verifyIOParameter("input_dir_array", null, null, "/", "11111111111111111111111111111111+03", true); + verifyIOParameter("input_dir_array", null, null, "Cannot display value", undefined, true); + verifyIOParameter("input_int_array", null, null, ["1", "3", "5", "Cannot display value"]); + verifyIOParameter("input_long_array", null, null, ["10", "20", "Cannot display value"]); + verifyIOParameter("input_float_array", null, null, ["10.2", "10.4", "10.6", "Cannot display value"]); + verifyIOParameter("input_double_array", null, null, ["20.1", "20.2", "20.3", "Cannot display value"]); + verifyIOParameter("input_string_array", null, null, ["Hello", "World", "!", "Cannot display value"]); + verifyIOParameter("input_bool_include", null, null, "Cannot display value"); + verifyIOParameter("input_int_include", null, null, "Cannot display value"); + verifyIOParameter("input_float_include", null, null, "Cannot display value"); + verifyIOParameter("input_string_include", null, null, "Cannot display value"); + verifyIOParameter("input_file_include", null, null, "Cannot display value"); + verifyIOParameter("input_directory_include", null, null, "Cannot display value"); + verifyIOParameter("input_file_url", null, null, "http://example.com/index.html"); }); - }); - cy.get('[data-cy=process-io-card] h6').contains('Outputs') - .parents('[data-cy=process-io-card]').within(() => { - cy.get('tbody tr').each((item) => { - cy.wrap(item).contains('No value'); + cy.get("[data-cy=process-io-card] h6") + .contains("Outputs") + .parents("[data-cy=process-io-card]") + .within(ctx => { + cy.get(ctx).scrollIntoView(); + cy.get('[data-cy="io-preview-image-toggle"]').click({ waitForAnimations: false }); + const outPdh = testOutputCollection.portable_data_hash; + + verifyIOParameter("output_file", null, "Label Description", "cat.png", `${outPdh}`); + verifyIOParameterImage("output_file", `/c=${outPdh}/cat.png`); + verifyIOParameter("output_file_with_secondary", null, "Doc Description", "main.dat", `${outPdh}`); + verifyIOParameter("output_file_with_secondary", null, "Doc Description", "secondary.dat", undefined, true); + verifyIOParameter("output_file_with_secondary", null, "Doc Description", "secondary2.dat", undefined, true); + verifyIOParameter("output_dir", null, "Doc desc 1, Doc desc 2", "outdir1", `${outPdh}`); + verifyIOParameter("output_bool", null, null, "true"); + verifyIOParameter("output_int", null, null, "1"); + verifyIOParameter("output_long", null, null, "1"); + verifyIOParameter("output_float", null, null, "100.5"); + verifyIOParameter("output_double", null, null, "100.3"); + verifyIOParameter("output_string", null, null, "Hello output"); + verifyIOParameter("output_file_array", null, null, "output2.tar", `${outPdh}`); + verifyIOParameter("output_file_array", null, null, "output3.tar", undefined, true); + verifyIOParameter("output_dir_array", null, null, "outdir2", `${outPdh}`); + verifyIOParameter("output_dir_array", null, null, "outdir3", undefined, true); + verifyIOParameter("output_int_array", null, null, ["10", "11", "12"]); + verifyIOParameter("output_long_array", null, null, ["51", "52"]); + verifyIOParameter("output_float_array", null, null, ["100.2", "100.4", "100.6"]); + verifyIOParameter("output_double_array", null, null, ["100.1", "100.2", "100.3"]); + verifyIOParameter("output_string_array", null, null, ["Hello", "Output", "!"]); }); - }); + }); }); - }); + it("displays IO parameters with no value", function () { + const fakeOutputUUID = "zzzzz-4zz18-abcdefghijklmno"; + const fakeOutputPDH = "11111111111111111111111111111111+99/"; - it('allows copying processes', function() { - const crName = 'first_container_request'; - const copiedCrName = 'copied_container_request'; - createContainerRequest( - activeUser, - crName, - 'arvados/jobs', - ['echo', 'hello world'], - false, 'Committed') - .then(function(containerRequest) { cy.loginAs(activeUser); - cy.goToPath(`/processes/${containerRequest.uuid}`); - cy.get('[data-cy=process-details]').should('contain', crName); - - cy.get('[data-cy=process-details]').find('button[title="More options"]').click(); - cy.get('ul[data-cy=context-menu]').contains("Copy and re-run process").click(); - }); - - cy.get('[data-cy=form-dialog]').within(() => { - cy.get('input[name=name]').clear().type(copiedCrName); - cy.get('[data-cy=projects-tree-home-tree-picker]').click(); - cy.get('[data-cy=form-submit-btn]').click(); - }); - - cy.get('[data-cy=process-details]').should('contain', copiedCrName); - cy.get('[data-cy=process-details]').find('button').contains('Run'); - }); - const getFakeContainer = (fakeContainerUuid) => ({ - href: `/containers/${fakeContainerUuid}`, - kind: "arvados#container", - etag: "ecfosljpnxfari9a8m7e4yv06", - uuid: fakeContainerUuid, - owner_uuid: "zzzzz-tpzed-000000000000000", - created_at: "2023-02-13T15:55:47.308915000Z", - modified_by_client_uuid: "zzzzz-ozdt8-q6dzdi1lcc03155", - modified_by_user_uuid: "zzzzz-tpzed-000000000000000", - modified_at: "2023-02-15T19:12:45.987086000Z", - command: [ - "arvados-cwl-runner", - "--api=containers", - "--local", - "--project-uuid=zzzzz-j7d0g-yr18k784zplfeza", - "/var/lib/cwl/workflow.json#main", - "/var/lib/cwl/cwl.input.json", - ], - container_image: "4ad7d11381df349e464694762db14e04+303", - cwd: "/var/spool/cwl", - environment: {}, - exit_code: null, - finished_at: null, - locked_by_uuid: null, - log: null, - output: null, - output_path: "/var/spool/cwl", - progress: null, - runtime_constraints: { - API: true, - cuda: { - device_count: 0, - driver_version: "", - hardware_capability: "", - }, - keep_cache_disk: 2147483648, - keep_cache_ram: 0, - ram: 1342177280, - vcpus: 1, - }, - runtime_status: {}, - started_at: null, - auth_uuid: null, - scheduling_parameters: { - max_run_time: 0, - partitions: [], - preemptible: false, - }, - runtime_user_uuid: "zzzzz-tpzed-vllbpebicy84rd5", - runtime_auth_scopes: ["all"], - lock_count: 2, - gateway_address: null, - interactive_session_started: false, - output_storage_classes: ["default"], - output_properties: {}, - cost: 0.0, - subrequests_cost: 0.0, - }); - - it('shows cancel button when appropriate', function() { - // Ignore collection requests - cy.intercept({method: 'GET', url: `**/arvados/v1/collections/*`}, { - statusCode: 200, - body: {} - }); - - // Uncommitted container - const crUncommitted = `Test process ${Math.floor(Math.random() * 999999)}`; - createContainerRequest( - activeUser, - crUncommitted, - 'arvados/jobs', - ['echo', 'hello world'], - false, 'Uncommitted') - .then(function(containerRequest) { - // Navigate to process and verify run / cancel button - cy.goToPath(`/processes/${containerRequest.uuid}`); - cy.waitForDom(); - cy.get('[data-cy=process-details]').should('contain', crUncommitted); - cy.get('[data-cy=process-run-button]').should('exist'); - cy.get('[data-cy=process-cancel-button]').should('not.exist'); - }); - - // Queued container - const crQueued = `Test process ${Math.floor(Math.random() * 999999)}`; - const fakeCrUuid = 'zzzzz-dz642-000000000000001'; - createContainerRequest( - activeUser, - crQueued, - 'arvados/jobs', - ['echo', 'hello world'], - false, 'Committed') - .then(function(containerRequest) { - // Fake container uuid - cy.intercept({method: 'GET', url: `**/arvados/v1/container_requests/${containerRequest.uuid}`}, (req) => { - req.reply((res) => { - res.body.output_uuid = fakeCrUuid; - res.body.priority = 500; - res.body.state = "Committed"; + // Add output uuid and inputs to container request + cy.intercept({ method: "GET", url: "**/arvados/v1/container_requests/*" }, req => { + req.reply(res => { + res.body.output_uuid = fakeOutputUUID; + res.body.mounts["/var/lib/cwl/cwl.input.json"] = { + content: {}, + }; + res.body.mounts["/var/lib/cwl/workflow.json"] = { + content: { + $graph: [ + { + id: "#main", + inputs: testInputs.map(input => input.definition), + outputs: testOutputs.map(output => output.definition), + }, + ], + }, + }; }); }); - // Fake container - const container = getFakeContainer(fakeCrUuid); - cy.intercept({method: 'GET', url: `**/arvados/v1/container/${fakeCrUuid}`}, { - statusCode: 200, - body: {...container, state: "Queued", priority: 500} - }); - - // Navigate to process and verify cancel button - cy.goToPath(`/processes/${containerRequest.uuid}`); - cy.waitForDom(); - cy.get('[data-cy=process-details]').should('contain', crQueued); - cy.get('[data-cy=process-cancel-button]').contains('Cancel'); - }); + // Stub fake output collection + cy.intercept( + { method: "GET", url: `**/arvados/v1/collections/${fakeOutputUUID}*` }, + { + statusCode: 200, + body: { + uuid: fakeOutputUUID, + portable_data_hash: fakeOutputPDH, + }, + } + ); - // Locked container - const crLocked = `Test process ${Math.floor(Math.random() * 999999)}`; - const fakeCrLockedUuid = 'zzzzz-dz642-000000000000002'; - createContainerRequest( - activeUser, - crLocked, - 'arvados/jobs', - ['echo', 'hello world'], - false, 'Committed') - .then(function(containerRequest) { - // Fake container uuid - cy.intercept({method: 'GET', url: `**/arvados/v1/container_requests/${containerRequest.uuid}`}, (req) => { - req.reply((res) => { - res.body.output_uuid = fakeCrLockedUuid; - res.body.priority = 500; - res.body.state = "Committed"; - }); - }); + // Stub fake output json + cy.intercept( + { method: "GET", url: `**/c%3D${fakeOutputUUID}/cwl.output.json` }, + { + statusCode: 200, + body: {}, + } + ); - // Fake container - const container = getFakeContainer(fakeCrLockedUuid); - cy.intercept({method: 'GET', url: `**/arvados/v1/container/${fakeCrLockedUuid}`}, { - statusCode: 200, - body: {...container, state: "Locked", priority: 500} + cy.readFile("cypress/fixtures/webdav-propfind-outputs.xml").then(data => { + // Stub webdav response, points to output json + cy.intercept( + { method: "PROPFIND", url: "*" }, + { + statusCode: 200, + body: data.replace(/zzzzz-4zz18-zzzzzzzzzzzzzzz/g, fakeOutputUUID), + } + ); }); - // Navigate to process and verify cancel button - cy.goToPath(`/processes/${containerRequest.uuid}`); - cy.waitForDom(); - cy.get('[data-cy=process-details]').should('contain', crLocked); - cy.get('[data-cy=process-cancel-button]').contains('Cancel'); - }); + createContainerRequest(activeUser, "test_container_request", "arvados/jobs", ["echo", "hello world"], false, "Committed").as( + "containerRequest" + ); - // On Hold container - const crOnHold = `Test process ${Math.floor(Math.random() * 999999)}`; - const fakeCrOnHoldUuid = 'zzzzz-dz642-000000000000003'; - createContainerRequest( - activeUser, - crOnHold, - 'arvados/jobs', - ['echo', 'hello world'], - false, 'Committed') - .then(function(containerRequest) { - // Fake container uuid - cy.intercept({method: 'GET', url: `**/arvados/v1/container_requests/${containerRequest.uuid}`}, (req) => { - req.reply((res) => { - res.body.output_uuid = fakeCrOnHoldUuid; - res.body.priority = 0; - res.body.state = "Committed"; - }); - }); - - // Fake container - const container = getFakeContainer(fakeCrOnHoldUuid); - cy.intercept({method: 'GET', url: `**/arvados/v1/container/${fakeCrOnHoldUuid}`}, { - statusCode: 200, - body: {...container, state: "Queued", priority: 0} + cy.getAll("@containerRequest").then(function ([containerRequest]) { + cy.goToPath(`/processes/${containerRequest.uuid}`); + cy.get("[data-cy=process-io-card] h6") + .contains("Inputs") + .parents("[data-cy=process-io-card]") + .within(() => { + cy.wait(2000); + cy.waitForDom(); + cy.get("tbody tr").each(item => { + cy.wrap(item).contains("No value"); + }); + }); + cy.get("[data-cy=process-io-card] h6") + .contains("Outputs") + .parents("[data-cy=process-io-card]") + .within(() => { + cy.get("tbody tr").each(item => { + cy.wrap(item).contains("No value"); + }); + }); }); - - // Navigate to process and verify cancel button - cy.goToPath(`/processes/${containerRequest.uuid}`); - cy.waitForDom(); - cy.get('[data-cy=process-details]').should('contain', crOnHold); - cy.get('[data-cy=process-run-button]').should('exist'); - cy.get('[data-cy=process-cancel-button]').should('not.exist'); }); }); - }); diff --git a/cypress/integration/project.spec.js b/cypress/integration/project.spec.js index cdb49c86..e6185c10 100644 --- a/cypress/integration/project.spec.js +++ b/cypress/integration/project.spec.js @@ -2,288 +2,332 @@ // // SPDX-License-Identifier: AGPL-3.0 -describe('Project tests', function() { +describe("Project tests", function () { let activeUser; let adminUser; - before(function() { + before(function () { // Only set up common users once. These aren't set up as aliases because // aliases are cleaned up after every test. Also it doesn't make sense // to set the same users on beforeEach() over and over again, so we // separate a little from Cypress' 'Best Practices' here. - cy.getUser('admin', 'Admin', 'User', true, true) - .as('adminUser').then(function() { + cy.getUser("admin", "Admin", "User", true, true) + .as("adminUser") + .then(function () { adminUser = this.adminUser; - } - ); - cy.getUser('user', 'Active', 'User', false, true) - .as('activeUser').then(function() { + }); + cy.getUser("user", "Active", "User", false, true) + .as("activeUser") + .then(function () { activeUser = this.activeUser; - } - ); + }); }); - beforeEach(function() { + beforeEach(function () { cy.clearCookies(); cy.clearLocalStorage(); }); - it('creates a new project with multiple properties', function() { + it("creates a new project with multiple properties", function () { const projName = `Test project (${Math.floor(999999 * Math.random())})`; cy.loginAs(activeUser); - cy.get('[data-cy=side-panel-button]').click(); - cy.get('[data-cy=side-panel-new-project]').click(); - cy.get('[data-cy=form-dialog]') - .should('contain', 'New Project') + cy.get("[data-cy=side-panel-button]").click(); + cy.get("[data-cy=side-panel-new-project]").click(); + cy.get("[data-cy=form-dialog]") + .should("contain", "New Project") .within(() => { - cy.get('[data-cy=name-field]').within(() => { - cy.get('input').type(projName); + cy.get("[data-cy=name-field]").within(() => { + cy.get("input").type(projName); }); - }); // Key: Color (IDTAGCOLORS) - Value: Magenta (IDVALCOLORS3) - cy.get('[data-cy=form-dialog]').should('not.contain', 'Color: Magenta'); - cy.get('[data-cy=resource-properties-form]').within(() => { - cy.get('[data-cy=property-field-key]').within(() => { - cy.get('input').type('Color'); + cy.get("[data-cy=form-dialog]").should("not.contain", "Color: Magenta"); + cy.get("[data-cy=resource-properties-form]").within(() => { + cy.get("[data-cy=property-field-key]").within(() => { + cy.get("input").type("Color"); }); - cy.get('[data-cy=property-field-value]').within(() => { - cy.get('input').type('Magenta'); + cy.get("[data-cy=property-field-value]").within(() => { + cy.get("input").type("Magenta"); }); cy.root().submit(); - cy.get('[data-cy=property-field-value]').within(() => { - cy.get('input').type('Pink'); + cy.get("[data-cy=property-field-value]").within(() => { + cy.get("input").type("Pink"); }); cy.root().submit(); - cy.get('[data-cy=property-field-value]').within(() => { - cy.get('input').type('Yellow'); + cy.get("[data-cy=property-field-value]").within(() => { + cy.get("input").type("Yellow"); }); cy.root().submit(); }); // Confirm proper vocabulary labels are displayed on the UI. - cy.get('[data-cy=form-dialog]').should('contain', 'Color: Magenta'); - cy.get('[data-cy=form-dialog]').should('contain', 'Color: Pink'); - cy.get('[data-cy=form-dialog]').should('contain', 'Color: Yellow'); + cy.get("[data-cy=form-dialog]").should("contain", "Color: Magenta"); + cy.get("[data-cy=form-dialog]").should("contain", "Color: Pink"); + cy.get("[data-cy=form-dialog]").should("contain", "Color: Yellow"); - cy.get('[data-cy=resource-properties-form]').within(() => { - cy.get('[data-cy=property-field-key]').within(() => { - cy.get('input').focus(); + cy.get("[data-cy=resource-properties-form]").within(() => { + cy.get("[data-cy=property-field-key]").within(() => { + cy.get("input").focus(); }); - cy.get('[data-cy=property-field-key]').should('not.contain', 'Color'); + cy.get("[data-cy=property-field-key]").should("not.contain", "Color"); }); // Create project and confirm the properties' real values. - cy.get('[data-cy=form-submit-btn]').click(); - cy.get('[data-cy=breadcrumb-last]').should('contain', projName); - cy.doRequest('GET', '/arvados/v1/groups', null, { + cy.get("[data-cy=form-submit-btn]").click(); + cy.get("[data-cy=breadcrumb-last]").should("contain", projName); + cy.doRequest("GET", "/arvados/v1/groups", null, { filters: `[["name", "=", "${projName}"], ["group_class", "=", "project"]]`, }) - .its('body.items').as('projects') - .then(function() { - expect(this.projects).to.have.lengthOf(1); - expect(this.projects[0].properties).to.deep.equal( - // Pink is not in the test vocab - {IDTAGCOLORS: ['IDVALCOLORS3', 'Pink', 'IDVALCOLORS1']}); - }); + .its("body.items") + .as("projects") + .then(function () { + expect(this.projects).to.have.lengthOf(1); + expect(this.projects[0].properties).to.deep.equal( + // Pink is not in the test vocab + { IDTAGCOLORS: ["IDVALCOLORS3", "Pink", "IDVALCOLORS1"] } + ); + }); // Open project edit via breadcrumbs - cy.get('[data-cy=breadcrumbs]').contains(projName).rightclick(); - cy.get('[data-cy=context-menu]').contains('Edit').click(); - cy.get('[data-cy=form-dialog]').within(() => { - cy.get('[data-cy=resource-properties-list]').within(() => { - cy.get('div[role=button]').contains('Color: Magenta'); - cy.get('div[role=button]').contains('Color: Pink'); - cy.get('div[role=button]').contains('Color: Yellow'); + cy.get("[data-cy=breadcrumbs]").contains(projName).rightclick(); + cy.get("[data-cy=context-menu]").contains("Edit").click(); + cy.get("[data-cy=form-dialog]").within(() => { + cy.get("[data-cy=resource-properties-list]").within(() => { + cy.get("div[role=button]").contains("Color: Magenta"); + cy.get("div[role=button]").contains("Color: Pink"); + cy.get("div[role=button]").contains("Color: Yellow"); }); }); // Add another property - cy.get('[data-cy=resource-properties-form]').within(() => { - cy.get('[data-cy=property-field-key]').within(() => { - cy.get('input').type('Animal'); + cy.get("[data-cy=resource-properties-form]").within(() => { + cy.get("[data-cy=property-field-key]").within(() => { + cy.get("input").type("Animal"); }); - cy.get('[data-cy=property-field-value]').within(() => { - cy.get('input').type('Dog'); + cy.get("[data-cy=property-field-value]").within(() => { + cy.get("input").type("Dog"); }); cy.root().submit(); }); - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[data-cy=form-submit-btn]").click({ force: true }); // Reopen edit via breadcrumbs and verify properties - cy.get('[data-cy=breadcrumbs]').contains(projName).rightclick(); - cy.get('[data-cy=context-menu]').contains('Edit').click(); - cy.get('[data-cy=form-dialog]').within(() => { - cy.get('[data-cy=resource-properties-list]').within(() => { - cy.get('div[role=button]').contains('Color: Magenta'); - cy.get('div[role=button]').contains('Color: Pink'); - cy.get('div[role=button]').contains('Color: Yellow'); - cy.get('div[role=button]').contains('Animal: Dog'); + cy.get("[data-cy=breadcrumbs]").contains(projName).rightclick(); + cy.get("[data-cy=context-menu]").contains("Edit").click(); + cy.get("[data-cy=form-dialog]").within(() => { + cy.get("[data-cy=resource-properties-list]").within(() => { + cy.get("div[role=button]").contains("Color: Magenta"); + cy.get("div[role=button]").contains("Color: Pink"); + cy.get("div[role=button]").contains("Color: Yellow"); + cy.get("div[role=button]").contains("Animal: Dog"); }); }); }); - it('creates a project without and with description', function() { + it("creates a project without and with description", function () { const projName = `Test project (${Math.floor(999999 * Math.random())})`; cy.loginAs(activeUser); // Create project - cy.get('[data-cy=side-panel-button]').click(); - cy.get('[data-cy=side-panel-new-project]').click(); - cy.get('[data-cy=form-dialog]') - .should('contain', 'New Project') + cy.get("[data-cy=side-panel-button]").click(); + cy.get("[data-cy=side-panel-new-project]").click(); + cy.get("[data-cy=form-dialog]") + .should("contain", "New Project") .within(() => { - cy.get('[data-cy=name-field]').within(() => { - cy.get('input').type(projName); + cy.get("[data-cy=name-field]").within(() => { + cy.get("input").type(projName); }); }); - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[data-cy=form-submit-btn]").click(); + cy.get("[data-cy=form-dialog]").should("not.exist"); const editProjectDescription = (name, type) => { - cy.get('[data-cy=side-panel-tree]').contains('Home Projects').click(); - cy.get('[data-cy=project-panel] tbody tr').contains(name).rightclick(); - cy.get('[data-cy=context-menu]').contains('Edit').click(); - cy.get('[data-cy=form-dialog]').within(() => { - cy.get('div[contenteditable=true]') - .click() - .type(type); - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[data-cy=side-panel-tree]").contains("Home Projects").click(); + cy.get("[data-cy=project-panel] tbody tr").contains(name).rightclick({ force: true }); + cy.get("[data-cy=context-menu]").contains("Edit").click(); + cy.get("[data-cy=form-dialog]").within(() => { + cy.get("div[contenteditable=true]").click().type(type); + cy.get("[data-cy=form-submit-btn]").click(); }); }; const verifyProjectDescription = (name, description) => { - cy.doRequest('GET', '/arvados/v1/groups', null, { + cy.doRequest("GET", "/arvados/v1/groups", null, { filters: `[["name", "=", "${name}"], ["group_class", "=", "project"]]`, }) - .its('body.items').as('projects') - .then(function() { - expect(this.projects).to.have.lengthOf(1); - expect(this.projects[0].description).to.equal(description); - }); + .its("body.items") + .as("projects") + .then(function () { + expect(this.projects).to.have.lengthOf(1); + expect(this.projects[0].description).to.equal(description); + }); }; // Edit description - editProjectDescription(projName, 'Test description'); + editProjectDescription(projName, "Test description"); // Check description is set verifyProjectDescription(projName, "

Test description

"); // Clear description - editProjectDescription(projName, '{selectall}{backspace}'); + editProjectDescription(projName, "{selectall}{backspace}"); // Check description is null verifyProjectDescription(projName, null); // Set description to contain whitespace - editProjectDescription(projName, '{selectall}{backspace} x'); - editProjectDescription(projName, '{backspace}'); + editProjectDescription(projName, "{selectall}{backspace} x"); + editProjectDescription(projName, "{backspace}"); // Check description is null verifyProjectDescription(projName, null); - }); - it('creates new project on home project and then a subproject inside it', function() { - const createProject = function(name, parentName) { - cy.get('[data-cy=side-panel-button]').click(); - cy.get('[data-cy=side-panel-new-project]').click(); - cy.get('[data-cy=form-dialog]') - .should('contain', 'New Project') + it('shows the appropriate buttons in the multiselect toolbar', () => { + + const msButtonTooltips = [ + 'API Details', + 'Add to Favorites', + 'Copy to clipboard', + 'Edit project', + 'Freeze Project', + 'Move to', + 'Move to trash', + 'New project', + 'Open in new tab', + 'Open with 3rd party client', + 'Share', + 'View details', + ]; + + cy.loginAs(activeUser); + const projName = `Test project (${Math.floor(999999 * Math.random())})`; + cy.get('[data-cy=side-panel-button]').click(); + cy.get('[data-cy=side-panel-new-project]').click(); + cy.get('[data-cy=form-dialog]') + .should('contain', 'New Project') + .within(() => { + cy.get('[data-cy=name-field]').within(() => { + cy.get('input').type(projName); + }); + }) + cy.get("[data-cy=form-submit-btn]").click(); + cy.waitForDom() + cy.go('back') + + cy.get('[data-cy=data-table-row]').contains(projName).should('exist').parent().parent().parent().click() + cy.get('[data-cy=multiselect-button]').should('have.length', msButtonTooltips.length) + for (let i = 0; i < msButtonTooltips.length; i++) { + cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseover'); + cy.get('body').contains(msButtonTooltips[i]).should('exist') + cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseout'); + } + }) + + it("creates new project on home project and then a subproject inside it", function () { + const createProject = function (name, parentName) { + cy.get("[data-cy=side-panel-button]").click(); + cy.get("[data-cy=side-panel-new-project]").click(); + cy.get("[data-cy=form-dialog]") + .should("contain", "New Project") .within(() => { - cy.get('[data-cy=parent-field]').within(() => { - cy.get('input').invoke('val').then((val) => { - expect(val).to.include(parentName); - }); + cy.get("[data-cy=parent-field]").within(() => { + cy.get("input") + .invoke("val") + .then(val => { + expect(val).to.include(parentName); + }); }); - cy.get('[data-cy=name-field]').within(() => { - cy.get('input').type(name); + cy.get("[data-cy=name-field]").within(() => { + cy.get("input").type(name); }); }); - cy.get('[data-cy=form-submit-btn]').click(); - } + cy.get("[data-cy=form-submit-btn]").click(); + }; cy.loginAs(activeUser); cy.goToPath(`/projects/${activeUser.user.uuid}`); - cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects'); - cy.get('[data-cy=breadcrumb-last]').should('not.exist'); + cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects"); + cy.get("[data-cy=breadcrumb-last]").should("not.exist"); // Create new project const projName = `Test project (${Math.floor(999999 * Math.random())})`; - createProject(projName, 'Home project'); + createProject(projName, "Home project"); // Confirm that the user was taken to the newly created thing - cy.get('[data-cy=form-dialog]').should('not.exist'); - cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects'); - cy.get('[data-cy=breadcrumb-last]').should('contain', projName); + cy.get("[data-cy=form-dialog]").should("not.exist"); + cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects"); + cy.get("[data-cy=breadcrumb-last]").should("contain", projName); // Create a subproject const subProjName = `Test project (${Math.floor(999999 * Math.random())})`; createProject(subProjName, projName); - cy.get('[data-cy=form-dialog]').should('not.exist'); - cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects'); - cy.get('[data-cy=breadcrumb-last]').should('contain', subProjName); + cy.get("[data-cy=form-dialog]").should("not.exist"); + cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects"); + cy.get("[data-cy=breadcrumb-last]").should("contain", subProjName); }); - it('attempts to use a preexisting name creating a project', function() { + it("attempts to use a preexisting name creating a project", function () { const name = `Test project ${Math.floor(Math.random() * 999999)}`; cy.createGroup(activeUser.token, { name: name, - group_class: 'project', + group_class: "project", }); cy.loginAs(activeUser); cy.goToPath(`/projects/${activeUser.user.uuid}`); // Attempt to create new collection with a duplicate name - cy.get('[data-cy=side-panel-button]').click(); - cy.get('[data-cy=side-panel-new-project]').click(); - cy.get('[data-cy=form-dialog]') - .should('contain', 'New Project') + cy.get("[data-cy=side-panel-button]").click(); + cy.get("[data-cy=side-panel-new-project]").click(); + cy.get("[data-cy=form-dialog]") + .should("contain", "New Project") .within(() => { - cy.get('[data-cy=name-field]').within(() => { - cy.get('input').type(name); + cy.get("[data-cy=name-field]").within(() => { + cy.get("input").type(name); }); - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[data-cy=form-submit-btn]").click(); }); // Error message should display, allowing editing the name - cy.get('[data-cy=form-dialog]').should('exist') - .and('contain', 'Project with the same name already exists') + cy.get("[data-cy=form-dialog]") + .should("exist") + .and("contain", "Project with the same name already exists") .within(() => { - cy.get('[data-cy=name-field]').within(() => { - cy.get('input').type(' renamed'); + cy.get("[data-cy=name-field]").within(() => { + cy.get("input").type(" renamed"); }); - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[data-cy=form-submit-btn]").click(); }); - cy.get('[data-cy=form-dialog]').should('not.exist'); + cy.get("[data-cy=form-dialog]").should("not.exist"); }); - it('navigates to the parent project after trashing the one being displayed', function() { + it("navigates to the parent project after trashing the one being displayed", function () { cy.createGroup(activeUser.token, { name: `Test root project ${Math.floor(Math.random() * 999999)}`, - group_class: 'project', - }).as('testRootProject').then(function() { - cy.createGroup(activeUser.token, { - name : `Test subproject ${Math.floor(Math.random() * 999999)}`, - group_class: 'project', - owner_uuid: this.testRootProject.uuid, - }).as('testSubProject'); - }); - cy.getAll('@testRootProject', '@testSubProject').then(function([testRootProject, testSubProject]) { + group_class: "project", + }) + .as("testRootProject") + .then(function () { + cy.createGroup(activeUser.token, { + name: `Test subproject ${Math.floor(Math.random() * 999999)}`, + group_class: "project", + owner_uuid: this.testRootProject.uuid, + }).as("testSubProject"); + }); + cy.getAll("@testRootProject", "@testSubProject").then(function ([testRootProject, testSubProject]) { cy.loginAs(activeUser); // Go to subproject and trash it. cy.goToPath(`/projects/${testSubProject.uuid}`); - cy.get('[data-cy=side-panel-tree]').should('contain', testSubProject.name); - cy.get('[data-cy=breadcrumb-last]') - .should('contain', testSubProject.name) - .rightclick(); - cy.get('[data-cy=context-menu]').contains('Move to trash').click(); + cy.get("[data-cy=side-panel-tree]").should("contain", testSubProject.name); + cy.get("[data-cy=breadcrumb-last]").should("contain", testSubProject.name).rightclick(); + cy.get("[data-cy=context-menu]").contains("Move to trash").click(); // Confirm that the parent project should be displayed. - cy.get('[data-cy=breadcrumb-last]').should('contain', testRootProject.name); - cy.url().should('contain', `/projects/${testRootProject.uuid}`); - cy.get('[data-cy=side-panel-tree]').should('not.contain', testSubProject.name); + cy.get("[data-cy=breadcrumb-last]").should("contain", testRootProject.name); + cy.url().should("contain", `/projects/${testRootProject.uuid}`); + cy.get("[data-cy=side-panel-tree]").should("not.contain", testSubProject.name); // Checks for bugfix #17637. - cy.get('[data-cy=not-found-content]').should('not.exist'); - cy.get('[data-cy=not-found-page]').should('not.exist'); + cy.get("[data-cy=not-found-content]").should("not.exist"); + cy.get("[data-cy=not-found-page]").should("not.exist"); }); }); - it('resets the search box only when navigating out of the current project', function() { + it("resets the search box only when navigating out of the current project", function () { const fooProjectNameA = `Test foo project ${Math.floor(Math.random() * 999999)}`; const fooProjectNameB = `Test foo project ${Math.floor(Math.random() * 999999)}`; const barProjectNameA = `Test bar project ${Math.floor(Math.random() * 999999)}`; @@ -291,322 +335,323 @@ describe('Project tests', function() { [fooProjectNameA, fooProjectNameB, barProjectNameA].forEach(projName => { cy.createGroup(activeUser.token, { name: projName, - group_class: 'project', + group_class: "project", }); }); cy.loginAs(activeUser); - cy.get('[data-cy=project-panel]') - .should('contain', fooProjectNameA) - .and('contain', fooProjectNameB) - .and('contain', barProjectNameA); + cy.get("[data-cy=project-panel]").should("contain", fooProjectNameA).and("contain", fooProjectNameB).and("contain", barProjectNameA); - cy.get('[data-cy=search-input]').type('foo'); - cy.get('[data-cy=project-panel]') - .should('contain', fooProjectNameA) - .and('contain', fooProjectNameB) - .and('not.contain', barProjectNameA); + cy.get("[data-cy=search-input]").type("foo"); + cy.get("[data-cy=project-panel]").should("contain", fooProjectNameA).and("contain", fooProjectNameB).and("not.contain", barProjectNameA); // Click on the table row to select it, search should remain the same. - cy.get(`p:contains(${fooProjectNameA})`) - .parent().parent().parent().parent().click(); - cy.get('[data-cy=search-input] input').should('have.value', 'foo'); + cy.get(`p:contains(${fooProjectNameA})`).parent().parent().parent().parent().click(); + cy.get("[data-cy=search-input] input").should("have.value", "foo"); // Click to navigate to the project, search should be reset cy.get(`p:contains(${fooProjectNameA})`).click(); - cy.get('[data-cy=search-input] input').should('not.have.value', 'foo'); + cy.get("[data-cy=search-input] input").should("not.have.value", "foo"); }); - it('navigates to the root project after trashing the parent of the one being displayed', function() { + it("navigates to the root project after trashing the parent of the one being displayed", function () { cy.createGroup(activeUser.token, { name: `Test root project ${Math.floor(Math.random() * 999999)}`, - group_class: 'project', - }).as('testRootProject').then(function() { - cy.createGroup(activeUser.token, { - name : `Test subproject ${Math.floor(Math.random() * 999999)}`, - group_class: 'project', - owner_uuid: this.testRootProject.uuid, - }).as('testSubProject').then(function() { + group_class: "project", + }) + .as("testRootProject") + .then(function () { cy.createGroup(activeUser.token, { - name : `Test sub subproject ${Math.floor(Math.random() * 999999)}`, - group_class: 'project', - owner_uuid: this.testSubProject.uuid, - }).as('testSubSubProject'); + name: `Test subproject ${Math.floor(Math.random() * 999999)}`, + group_class: "project", + owner_uuid: this.testRootProject.uuid, + }) + .as("testSubProject") + .then(function () { + cy.createGroup(activeUser.token, { + name: `Test sub subproject ${Math.floor(Math.random() * 999999)}`, + group_class: "project", + owner_uuid: this.testSubProject.uuid, + }).as("testSubSubProject"); + }); }); - }); - cy.getAll('@testRootProject', '@testSubProject', '@testSubSubProject').then(function([testRootProject, testSubProject, testSubSubProject]) { + cy.getAll("@testRootProject", "@testSubProject", "@testSubSubProject").then(function ([testRootProject, testSubProject, testSubSubProject]) { cy.loginAs(activeUser); // Go to innermost project and trash its parent. cy.goToPath(`/projects/${testSubSubProject.uuid}`); - cy.get('[data-cy=side-panel-tree]').should('contain', testSubSubProject.name); - cy.get('[data-cy=breadcrumb-last]').should('contain', testSubSubProject.name); - cy.get('[data-cy=side-panel-tree]') - .contains(testSubProject.name) - .rightclick(); - cy.get('[data-cy=context-menu]').contains('Move to trash').click(); + cy.get("[data-cy=side-panel-tree]").should("contain", testSubSubProject.name); + cy.get("[data-cy=breadcrumb-last]").should("contain", testSubSubProject.name); + cy.get("[data-cy=side-panel-tree]").contains(testSubProject.name).rightclick(); + cy.get("[data-cy=context-menu]").contains("Move to trash").click(); // Confirm that the trashed project's parent should be displayed. - cy.get('[data-cy=breadcrumb-last]').should('contain', testRootProject.name); - cy.url().should('contain', `/projects/${testRootProject.uuid}`); - cy.get('[data-cy=side-panel-tree]').should('not.contain', testSubProject.name); - cy.get('[data-cy=side-panel-tree]').should('not.contain', testSubSubProject.name); + cy.get("[data-cy=breadcrumb-last]").should("contain", testRootProject.name); + cy.url().should("contain", `/projects/${testRootProject.uuid}`); + cy.get("[data-cy=side-panel-tree]").should("not.contain", testSubProject.name); + cy.get("[data-cy=side-panel-tree]").should("not.contain", testSubSubProject.name); // Checks for bugfix #17637. - cy.get('[data-cy=not-found-content]').should('not.exist'); - cy.get('[data-cy=not-found-page]').should('not.exist'); + cy.get("[data-cy=not-found-content]").should("not.exist"); + cy.get("[data-cy=not-found-page]").should("not.exist"); }); }); - it('shows details panel when clicking on the info icon', () => { + it("shows details panel when clicking on the info icon", () => { cy.createGroup(activeUser.token, { name: `Test root project ${Math.floor(Math.random() * 999999)}`, - group_class: 'project', - }).as('testRootProject').then(function(testRootProject) { - cy.loginAs(activeUser); + group_class: "project", + }) + .as("testRootProject") + .then(function (testRootProject) { + cy.loginAs(activeUser); - cy.get('[data-cy=side-panel-tree]').contains(testRootProject.name).click(); + cy.get("[data-cy=side-panel-tree]").contains(testRootProject.name).click(); - cy.get('[data-cy=additional-info-icon]').click(); + cy.get("[data-cy=additional-info-icon]").click(); - cy.contains(testRootProject.uuid).should('exist'); - }); + cy.contains(testRootProject.uuid).should("exist"); + }); }); - it('clears search input when changing project', () => { + it("clears search input when changing project", () => { cy.createGroup(activeUser.token, { name: `Test root project ${Math.floor(Math.random() * 999999)}`, - group_class: 'project', - }).as('testProject1').then((testProject1) => { - cy.shareWith(adminUser.token, activeUser.user.uuid, testProject1.uuid, 'can_write'); - }); + group_class: "project", + }) + .as("testProject1") + .then(testProject1 => { + cy.shareWith(adminUser.token, activeUser.user.uuid, testProject1.uuid, "can_write"); + }); - cy.getAll('@testProject1').then(function([testProject1]) { + cy.getAll("@testProject1").then(function ([testProject1]) { cy.loginAs(activeUser); - cy.get('[data-cy=side-panel-tree]').contains(testProject1.name).click(); + cy.get("[data-cy=side-panel-tree]").contains(testProject1.name).click(); - cy.get('[data-cy=search-input] input').type('test123'); + cy.get("[data-cy=search-input] input").type("test123"); - cy.get('[data-cy=side-panel-tree]').contains('Projects').click(); + cy.get("[data-cy=side-panel-tree]").contains("Projects").click(); - cy.get('[data-cy=search-input] input').should('not.have.value', 'test123'); + cy.get("[data-cy=search-input] input").should("not.have.value", "test123"); }); }); - it('opens advanced popup for project with username', () => { + it("opens advanced popup for project with username", () => { const projectName = `Test project ${Math.floor(Math.random() * 999999)}`; cy.createGroup(adminUser.token, { name: projectName, - group_class: 'project', - }).as('mainProject') + group_class: "project", + }).as("mainProject"); - cy.getAll('@mainProject') - .then(function ([mainProject]) { - cy.loginAs(adminUser); + cy.getAll("@mainProject").then(function ([mainProject]) { + cy.loginAs(adminUser); - cy.get('[data-cy=side-panel-tree]').contains('Groups').click(); + cy.get("[data-cy=side-panel-tree]").contains("Groups").click(); - cy.get('[data-cy=uuid]').eq(0).invoke('text').then(uuid => { + cy.get("[data-cy=uuid]") + .eq(0) + .invoke("text") + .then(uuid => { cy.createLink(adminUser.token, { - name: 'can_write', - link_class: 'permission', + name: "can_write", + link_class: "permission", head_uuid: mainProject.uuid, - tail_uuid: uuid + tail_uuid: uuid, }); cy.createLink(adminUser.token, { - name: 'can_write', - link_class: 'permission', + name: "can_write", + link_class: "permission", head_uuid: mainProject.uuid, - tail_uuid: activeUser.user.uuid + tail_uuid: activeUser.user.uuid, }); - cy.get('[data-cy=side-panel-tree]').contains('Projects').click(); + cy.get("[data-cy=side-panel-tree]").contains("Projects").click(); - cy.get('main').contains(projectName).rightclick(); + cy.get("main").contains(projectName).rightclick(); - cy.get('[data-cy=context-menu]').contains('API Details').click(); + cy.get("[data-cy=context-menu]").contains("API Details").click(); - cy.get('[role=tablist]').contains('METADATA').click(); + cy.get("[role=tablist]").contains("METADATA").click(); - cy.get('td').contains(uuid).should('exist'); + cy.get("td").contains(uuid).should("exist"); - cy.get('td').contains(activeUser.user.uuid).should('exist'); + cy.get("td").contains(activeUser.user.uuid).should("exist"); }); }); }); - describe('Frozen projects', () => { + describe("Frozen projects", () => { beforeEach(() => { cy.createGroup(activeUser.token, { name: `Main project ${Math.floor(Math.random() * 999999)}`, - group_class: 'project', - }).as('mainProject'); + group_class: "project", + }).as("mainProject"); cy.createGroup(adminUser.token, { name: `Admin project ${Math.floor(Math.random() * 999999)}`, - group_class: 'project', - }).as('adminProject').then((mainProject) => { - cy.shareWith(adminUser.token, activeUser.user.uuid, mainProject.uuid, 'can_write'); - }); + group_class: "project", + }) + .as("adminProject") + .then(mainProject => { + cy.shareWith(adminUser.token, activeUser.user.uuid, mainProject.uuid, "can_write"); + }); - cy.get('@mainProject').then((mainProject) => { + cy.get("@mainProject").then(mainProject => { cy.createGroup(adminUser.token, { - name : `Sub project ${Math.floor(Math.random() * 999999)}`, - group_class: 'project', + name: `Sub project ${Math.floor(Math.random() * 999999)}`, + group_class: "project", owner_uuid: mainProject.uuid, - }).as('subProject'); + }).as("subProject"); cy.createCollection(adminUser.token, { name: `Main collection ${Math.floor(Math.random() * 999999)}`, owner_uuid: mainProject.uuid, - manifest_text: "./subdir 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n" - }).as('mainCollection'); + manifest_text: "./subdir 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n", + }).as("mainCollection"); }); }); - it('should be able to froze own project', () => { - cy.getAll('@mainProject').then(([mainProject]) => { + it("should be able to freeze own project", () => { + cy.getAll("@mainProject").then(([mainProject]) => { cy.loginAs(activeUser); - cy.get('[data-cy=project-panel]').contains(mainProject.name).rightclick(); + cy.get("[data-cy=project-panel]").contains(mainProject.name).rightclick(); - cy.get('[data-cy=context-menu]').contains('Freeze').click(); + cy.get("[data-cy=context-menu]").contains("Freeze").click(); - cy.get('[data-cy=project-panel]').contains(mainProject.name).rightclick(); + cy.get("[data-cy=project-panel]").contains(mainProject.name).rightclick(); - cy.get('[data-cy=context-menu]').contains('Freeze').should('not.exist'); + cy.get("[data-cy=context-menu]").contains("Freeze").should("not.exist"); }); }); - it('should not be able to modify items within the frozen project', () => { - cy.getAll('@mainProject', '@mainCollection').then(([mainProject, mainCollection]) => { + it("should not be able to modify items within the frozen project", () => { + cy.getAll("@mainProject", "@mainCollection").then(([mainProject, mainCollection]) => { cy.loginAs(activeUser); - cy.get('[data-cy=project-panel]').contains(mainProject.name).rightclick(); + cy.get("[data-cy=project-panel]").contains(mainProject.name).rightclick(); - cy.get('[data-cy=context-menu]').contains('Freeze').click(); + cy.get("[data-cy=context-menu]").contains("Freeze").click(); - cy.get('[data-cy=project-panel]').contains(mainProject.name).click(); + cy.get("[data-cy=project-panel]").contains(mainProject.name).click(); - cy.get('[data-cy=project-panel]').contains(mainCollection.name).rightclick(); + cy.get("[data-cy=project-panel]").contains(mainCollection.name).rightclick(); - cy.get('[data-cy=context-menu]').contains('Move to trash').should('not.exist'); + cy.get("[data-cy=context-menu]").contains("Move to trash").should("not.exist"); }); }); - it('should be able to froze not owned project', () => { - cy.getAll('@adminProject').then(([adminProject]) => { + it("should be able to freeze not owned project", () => { + cy.getAll("@adminProject").then(([adminProject]) => { cy.loginAs(activeUser); - cy.get('[data-cy=side-panel-tree]').contains('Shared with me').click(); + cy.get("[data-cy=side-panel-tree]").contains("Shared with me").click(); - cy.get('main').contains(adminProject.name).rightclick(); + cy.get("main").contains(adminProject.name).rightclick(); - cy.get('[data-cy=context-menu]').contains('Freeze').should('not.exist'); + cy.get("[data-cy=context-menu]").contains("Freeze").should("not.exist"); }); }); - it('should be able to unfroze project if user is an admin', () => { - cy.getAll('@adminProject').then(([adminProject]) => { + it("should be able to unfreeze project if user is an admin", () => { + cy.getAll("@adminProject").then(([adminProject]) => { cy.loginAs(adminUser); - cy.get('main').contains(adminProject.name).rightclick(); + cy.get("main").contains(adminProject.name).rightclick(); - cy.get('[data-cy=context-menu]').contains('Freeze').click(); + cy.get("[data-cy=context-menu]").contains("Freeze").click(); cy.wait(1000); - cy.get('main').contains(adminProject.name).rightclick(); + cy.get("main").contains(adminProject.name).rightclick(); - cy.get('[data-cy=context-menu]').contains('Unfreeze').click(); + cy.get("[data-cy=context-menu]").contains("Unfreeze").click(); - cy.get('main').contains(adminProject.name).rightclick(); + cy.get("main").contains(adminProject.name).rightclick(); - cy.get('[data-cy=context-menu]').contains('Freeze').should('exist'); + cy.get("[data-cy=context-menu]").contains("Freeze").should("exist"); }); }); }); - it('copies project URL to clipboard', () => { + it("copies project URL to clipboard", () => { const projectName = `Test project (${Math.floor(999999 * Math.random())})`; cy.loginAs(activeUser); - cy.get('[data-cy=side-panel-button]').click(); - cy.get('[data-cy=side-panel-new-project]').click(); - cy.get('[data-cy=form-dialog]') - .should('contain', 'New Project') + cy.get("[data-cy=side-panel-button]").click(); + cy.get("[data-cy=side-panel-new-project]").click(); + cy.get("[data-cy=form-dialog]") + .should("contain", "New Project") .within(() => { - cy.get('[data-cy=name-field]').within(() => { - cy.get('input').type(projectName); + cy.get("[data-cy=name-field]").within(() => { + cy.get("input").type(projectName); }); - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[data-cy=form-submit-btn]").click(); }); - - cy.get('[data-cy=side-panel-tree]').contains('Projects').click(); - cy.get('[data-cy=project-panel]').contains(projectName).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\:\/\/localhost\:[0-9]+\/projects\/[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}/,); + cy.get("[data-cy=form-dialog]").should("not.exist"); + cy.get("[data-cy=snackbar]").contains("created"); + cy.get("[data-cy=snackbar]").should("not.exist"); + cy.get("[data-cy=side-panel-tree]").contains("Projects").click(); + cy.waitForDom(); + cy.get("[data-cy=project-panel]").contains(projectName).should("be.visible").rightclick(); + cy.get("[data-cy=context-menu]").contains("Copy to clipboard").click(); + cy.window().then(win => + win.navigator.clipboard.readText().then(text => { + expect(text).to.match(/https\:\/\/127\.0\.0\.1\:[0-9]+\/projects\/[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}/); }) - )); - + ); }); - it('sorts displayed items correctly', () => { + it("sorts displayed items correctly", () => { cy.loginAs(activeUser); cy.get('[data-cy=project-panel] button[title="Select columns"]').click(); - cy.get('div[role=presentation] ul > div[role=button]').contains('Date Created').click(); - cy.get('div[role=presentation] ul > div[role=button]').contains('Trash at').click(); - cy.get('div[role=presentation] ul > div[role=button]').contains('Delete at').click(); - cy.get('div[role=presentation] > div[aria-hidden=true]').click(); + cy.get("div[role=presentation] ul > div[role=button]").contains("Date Created").click(); + cy.get("div[role=presentation] ul > div[role=button]").contains("Trash at").click(); + cy.get("div[role=presentation] ul > div[role=button]").contains("Delete at").click(); + cy.get("div[role=presentation] > div[aria-hidden=true]").click(); - cy.intercept({method: 'GET', url: '**/arvados/v1/groups/*/contents*'}).as('filteredQuery'); + cy.intercept({ method: "GET", url: "**/arvados/v1/groups/*/contents*" }).as("filteredQuery"); [ { name: "Name", - asc: "collections.name asc,container_requests.name asc,groups.name asc", - desc: "collections.name desc,container_requests.name desc,groups.name desc" + asc: "collections.name asc,container_requests.name asc,groups.name asc,container_requests.created_at desc", + desc: "collections.name desc,container_requests.name desc,groups.name desc,container_requests.created_at desc", }, { name: "Last Modified", - asc: "collections.modified_at asc,container_requests.modified_at asc,groups.modified_at asc", - desc: "collections.modified_at desc,container_requests.modified_at desc,groups.modified_at desc" + asc: "collections.modified_at asc,container_requests.modified_at asc,groups.modified_at asc,container_requests.created_at desc", + desc: "collections.modified_at desc,container_requests.modified_at desc,groups.modified_at desc,container_requests.created_at desc", }, { name: "Date Created", - asc: "collections.created_at asc,container_requests.created_at asc,groups.created_at asc", - desc: "collections.created_at desc,container_requests.created_at desc,groups.created_at desc" - + asc: "collections.created_at asc,container_requests.created_at asc,groups.created_at asc,container_requests.created_at desc", + desc: "collections.created_at desc,container_requests.created_at desc,groups.created_at desc,container_requests.created_at desc", }, { name: "Trash at", - asc: "collections.trash_at asc,container_requests.trash_at asc,groups.trash_at asc", - desc: "collections.trash_at desc,container_requests.trash_at desc,groups.trash_at desc" - + asc: "collections.trash_at asc,container_requests.trash_at asc,groups.trash_at asc,container_requests.created_at desc", + desc: "collections.trash_at desc,container_requests.trash_at desc,groups.trash_at desc,container_requests.created_at desc", }, { name: "Delete at", - asc: "collections.delete_at asc,container_requests.delete_at asc,groups.delete_at asc", - desc: "collections.delete_at desc,container_requests.delete_at desc,groups.delete_at desc" - + asc: "collections.delete_at asc,container_requests.delete_at asc,groups.delete_at asc,container_requests.created_at desc", + desc: "collections.delete_at desc,container_requests.delete_at desc,groups.delete_at desc,container_requests.created_at desc", }, - ].forEach((test) => { - cy.get('[data-cy=project-panel] table thead th').contains(test.name).click(); - cy.wait('@filteredQuery').then(interception => { - const searchParams = new URLSearchParams((new URL(interception.request.url).search)); - expect(searchParams.get('order')).to.eq(test.asc); + ].forEach(test => { + cy.get("[data-cy=project-panel] table thead th").contains(test.name).click(); + cy.wait("@filteredQuery").then(interception => { + const searchParams = new URLSearchParams(new URL(interception.request.url).search); + expect(searchParams.get("order")).to.eq(test.asc); }); - cy.get('[data-cy=project-panel] table thead th').contains(test.name).click(); - cy.wait('@filteredQuery').then(interception => { - const searchParams = new URLSearchParams((new URL(interception.request.url).search)); - expect(searchParams.get('order')).to.eq(test.desc); + cy.get("[data-cy=project-panel] table thead th").contains(test.name).click(); + cy.wait("@filteredQuery").then(interception => { + const searchParams = new URLSearchParams(new URL(interception.request.url).search); + expect(searchParams.get("order")).to.eq(test.desc); }); }); - }); }); diff --git a/cypress/integration/search.spec.js b/cypress/integration/search.spec.js index c8e262f0..d8aa35d3 100644 --- a/cypress/integration/search.spec.js +++ b/cypress/integration/search.spec.js @@ -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}`); }); - }); }); }); diff --git a/cypress/integration/sharing.spec.js b/cypress/integration/sharing.spec.js index 1d3112c2..f742d090 100644 --- a/cypress/integration/sharing.spec.js +++ b/cypress/integration/sharing.spec.js @@ -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'); + }); + }); +}); diff --git a/cypress/integration/side-panel.spec.js b/cypress/integration/side-panel.spec.js index e187d533..d6ac754d 100644 --- a/cypress/integration/side-panel.spec.js +++ b/cypress/integration/side-panel.spec.js @@ -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]) + } + }) }) + diff --git a/cypress/integration/user-profile.spec.js b/cypress/integration/user-profile.spec.js index d91dbb0b..0a06eaf3 100644 --- a/cypress/integration/user-profile.spec.js +++ b/cypress/integration/user-profile.spec.js @@ -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() { diff --git a/cypress/integration/virtual-machine-admin.spec.js b/cypress/integration/virtual-machine-admin.spec.js index 49cb1239..92011b20 100644 --- a/cypress/integration/virtual-machine-admin.spec.js +++ b/cypress/integration/virtual-machine-admin.spec.js @@ -2,293 +2,271 @@ // // SPDX-License-Identifier: AGPL-3.0 -describe('Virtual machine login manage tests', function() { +describe("Virtual machine login manage tests", function () { let activeUser; let adminUser; const vmHost = `vm-${Math.floor(999999 * Math.random())}.host`; - before(function() { + before(function () { // Only set up common users once. These aren't set up as aliases because // aliases are cleaned up after every test. Also it doesn't make sense // to set the same users on beforeEach() over and over again, so we // separate a little from Cypress' 'Best Practices' here. - cy.getUser('admin', 'VMAdmin', 'User', true, true) - .as('adminUser').then(function() { + cy.getUser("admin", "VMAdmin", "User", true, true) + .as("adminUser") + .then(function () { adminUser = this.adminUser; - } - ); - cy.getUser('user', 'VMActive', 'User', false, true) - .as('activeUser').then(function() { + }); + cy.getUser("user", "VMActive", "User", false, true) + .as("activeUser") + .then(function () { activeUser = this.activeUser; - } - ); + }); }); - it('adds and removes vm logins', function() { + it("adds and removes vm logins", function () { cy.loginAs(adminUser); - cy.createVirtualMachine(adminUser.token, {hostname: vmHost}); + cy.createVirtualMachine(adminUser.token, { hostname: vmHost }); // Navigate to VM admin cy.get('header button[title="Admin Panel"]').click(); - cy.get('#admin-menu').contains('Virtual Machines').click(); + cy.get("#admin-menu").contains("Virtual Machines").click(); // Add login permission to admin - cy.get('[data-cy=vm-admin-table]') + cy.get("[data-cy=vm-admin-table]") .contains(vmHost) - .parents('tr') + .parents("tr") .within(() => { cy.get('button[title="Add Login Permission"]').click(); }); - cy.get('[data-cy=form-dialog]') - .should('contain', 'Add login permission') + cy.get("[data-cy=form-dialog]") + .should("contain", "Add login permission") .within(() => { - cy.get('label') - .contains('Search for user') - .parent() - .within(() => { - cy.get('input').type('VMAdmin'); - }) + cy.get("label") + .contains("Search for user") + .parent() + .within(() => { + cy.get("input").type("VMAdmin"); + }); }); - cy.get('[role=tooltip]').click(); - cy.get('[data-cy=form-dialog]').as('add-login-dialog') - .should('contain', 'Add login permission') + cy.waitForDom().get("[role=tooltip]").click(); + cy.get("[data-cy=form-dialog]") + .as("add-login-dialog") + .should("contain", "Add login permission") .within(() => { - cy.get('label') - .contains('Add groups') - .parent() - .within(() => { - cy.get('input').type('docker '); - // Veryfy submit enabled (form has changed) - cy.get('@add-login-dialog').within(() => { - cy.get('[data-cy=form-submit-btn]').should('be.enabled'); - }); - cy.get('input').type('sudo'); - // Veryfy submit disabled (partial input in chips) - cy.get('@add-login-dialog').within(() => { - cy.get('[data-cy=form-submit-btn]').should('be.disabled'); + cy.get("label") + .contains("Add groups") + .parent() + .within(() => { + cy.get("input").type("docker "); + // Veryfy submit enabled (form has changed) + cy.get("@add-login-dialog").within(() => { + cy.get("[data-cy=form-submit-btn]").should("be.enabled"); + }); + cy.get("input").type("sudo"); + // Veryfy submit disabled (partial input in chips) + cy.get("@add-login-dialog").within(() => { + cy.get("[data-cy=form-submit-btn]").should("be.disabled"); + }); + cy.get("input").type("{enter}"); }); - cy.get('input').type('{enter}'); - }) }); - cy.get('[data-cy=form-dialog]').within(() => { - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[data-cy=form-dialog]").within(() => { + cy.get("[data-cy=form-submit-btn]").click(); }); - cy.get('[data-cy=vm-admin-table]') + cy.get("[data-cy=vm-admin-table]") .contains(vmHost) - .parents('tr') + .parents("tr") .within(() => { - cy.get('td').contains('admin'); - }); + cy.get("td").contains("admin"); + }); // Add login permission to activeUser - cy.get('[data-cy=vm-admin-table]') + cy.get("[data-cy=vm-admin-table]") .contains(vmHost) - .parents('tr') + .parents("tr") .within(() => { cy.get('button[title="Add Login Permission"]').click(); }); - cy.get('[data-cy=form-dialog]') - .should('contain', 'Add login permission') + cy.get("[data-cy=form-dialog]") + .should("contain", "Add login permission") .within(() => { - cy.get('label') - .contains('Search for user') - .parent() - .within(() => { - cy.get('input').type('VMActive user'); - }) + cy.get("label") + .contains("Search for user") + .parent() + .within(() => { + cy.get("input").type("VMActive user"); + }); }); - cy.get('[role=tooltip]').click(); - cy.get('[data-cy=form-dialog]').within(() => { - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[role=tooltip]").click(); + cy.get("[data-cy=form-dialog]").within(() => { + cy.get("[data-cy=form-submit-btn]").click(); }); - cy.get('[data-cy=vm-admin-table]') + cy.get("[data-cy=vm-admin-table]") .contains(vmHost) - .parents('tr') + .parents("tr") .within(() => { - cy.get('td').contains('user'); - }); + cy.get("td").contains("user"); + }); // Check admin's vm page for login cy.get('header button[title="Account Management"]').click(); - cy.get('#account-menu').contains('Virtual Machines').click(); + cy.get("#account-menu").contains("Virtual Machines").click(); - cy.get('[data-cy=vm-user-table]') + cy.get("[data-cy=vm-user-table]") .contains(vmHost) - .parents('tr') + .parents("tr") .within(() => { - cy.get('td').contains('admin'); - cy.get('td').contains('docker'); - cy.get('td').contains('sudo'); - cy.get('td').contains('ssh admin@' + vmHost); - }); + cy.get("td").contains("admin"); + cy.get("td").contains("docker"); + cy.get("td").contains("sudo"); + cy.get("td").contains("ssh admin@" + vmHost); + }); // Check activeUser's vm page for login cy.loginAs(activeUser); cy.get('header button[title="Account Management"]').click(); - cy.get('#account-menu').contains('Virtual Machines').click(); + cy.get("#account-menu").contains("Virtual Machines").click(); - cy.get('[data-cy=vm-user-table]') + cy.get("[data-cy=vm-user-table]") .contains(vmHost) - .parents('tr') + .parents("tr") .within(() => { - cy.get('td').contains('user'); - cy.get('td').should('not.contain', 'docker'); - cy.get('td').should('not.contain', 'sudo'); - cy.get('td').contains('ssh user@' + vmHost); - }); + cy.get("td").contains("user"); + cy.get("td").should("not.contain", "docker"); + cy.get("td").should("not.contain", "sudo"); + cy.get("td").contains("ssh user@" + vmHost); + }); // Edit login permissions cy.loginAs(adminUser); cy.get('header button[title="Admin Panel"]').click(); - cy.get('#admin-menu').contains('Virtual Machines').click(); + cy.get("#admin-menu").contains("Virtual Machines").click(); - cy.get('[data-cy=vm-admin-table]') - .contains('admin'); // Wait for page to finish + cy.get("[data-cy=vm-admin-table]").contains("admin"); // Wait for page to finish - cy.get('[data-cy=vm-admin-table]') - .contains(vmHost) - .parents('tr') - .contains('admin') - .click(); + cy.get("[data-cy=vm-admin-table]").contains(vmHost).parents("tr").contains("admin").click(); - cy.get('[data-cy=form-dialog]') - .should('contain', 'Update login permission') + cy.get("[data-cy=form-dialog]") + .should("contain", "Update login permission") .within(() => { - cy.get('label') - .contains('Add groups') - .parent() - .as('groupInput'); + cy.get("label").contains("Add groups").parent().as("groupInput"); }); - cy.get('@groupInput').within(() => { - cy.get('div[role=button]').contains('sudo').parent().find('svg').click(); - cy.get('div[role=button]').contains('docker').parent().find('svg').click(); + cy.get("@groupInput").within(() => { + cy.get("div[role=button]").contains("sudo").parent().find("svg").click(); + cy.get("div[role=button]").contains("docker").parent().find("svg").click(); }); - cy.get('[data-cy=form-dialog]').within(() => { - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[data-cy=form-dialog]").within(() => { + cy.get("[data-cy=form-submit-btn]").click(); }); // Wait for page to finish loading - cy.get('[data-cy=vm-admin-table]') + cy.get("[data-cy=vm-admin-table]") .contains(vmHost) - .parents('tr') + .parents("tr") .within(() => { - cy.get('div[role=button]') - .parent() - .first() - .contains('admin') + cy.get("div[role=button]").parent().first().contains("admin"); }); - cy.get('[data-cy=vm-admin-table]') - .contains(vmHost) - .parents('tr') - .contains('user') - .click(); + cy.get("[data-cy=vm-admin-table]").contains(vmHost).parents("tr").contains("user").click(); - cy.get('[data-cy=form-dialog]') - .should('contain', 'Update login permission') + cy.get("[data-cy=form-dialog]") + .should("contain", "Update login permission") .within(() => { - cy.get('label') - .contains('Add groups') + cy.get("label") + .contains("Add groups") .parent() .within(() => { - cy.get('input').type('docker{enter}'); - }) + cy.get("input").type("docker{enter}"); + }); }); - cy.get('[data-cy=form-dialog]').within(() => { - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[data-cy=form-dialog]").within(() => { + cy.get("[data-cy=form-submit-btn]").click(); }); // Verify new login permissions // Check admin's vm page for login cy.get('header button[title="Account Management"]').click(); - cy.get('#account-menu').contains('Virtual Machines').click(); + cy.get("#account-menu").contains("Virtual Machines").click(); - cy.get('[data-cy=vm-user-table]') + cy.get("[data-cy=vm-user-table]") .contains(vmHost) - .parents('tr') + .parents("tr") .within(() => { - cy.get('td').contains('admin'); - cy.get('td').should('not.contain', 'docker'); - cy.get('td').should('not.contain', 'sudo'); - cy.get('td').contains('ssh admin@' + vmHost); - }); + cy.get("td").contains("admin"); + cy.get("td").should("not.contain", "docker"); + cy.get("td").should("not.contain", "sudo"); + cy.get("td").contains("ssh admin@" + vmHost); + }); // Verify new login permissions // Check activeUser's vm page for login cy.loginAs(activeUser); cy.get('header button[title="Account Management"]').click(); - cy.get('#account-menu').contains('Virtual Machines').click(); + cy.get("#account-menu").contains("Virtual Machines").click(); - cy.get('[data-cy=vm-user-table]') + cy.get("[data-cy=vm-user-table]") .contains(vmHost) - .parents('tr') + .parents("tr") .within(() => { - cy.get('td').contains('user'); - cy.get('td').contains('docker'); - cy.get('td').should('not.contain', 'sudo'); - cy.get('td').contains('ssh user@' + vmHost); - }); + cy.get("td").contains("user"); + cy.get("td").contains("docker"); + cy.get("td").should("not.contain", "sudo"); + cy.get("td").contains("ssh user@" + vmHost); + }); // Remove login permissions cy.loginAs(adminUser); cy.get('header button[title="Admin Panel"]').click(); - cy.get('#admin-menu').contains('Virtual Machines').click(); + cy.get("#admin-menu").contains("Virtual Machines").click(); - cy.get('[data-cy=vm-admin-table]') - .contains('user'); // Wait for page to finish + cy.get("[data-cy=vm-admin-table]").contains("user"); // Wait for page to finish - cy.get('[data-cy=vm-admin-table]') + cy.get("[data-cy=vm-admin-table]") .contains(vmHost) - .parents('tr') - .as('vmRow') - .contains('user') - .parents('[role=button]') - .find('svg') - .as('removeButton'); - cy.get('@removeButton').click(); - cy.get('[data-cy=confirmation-dialog-ok-btn]').click(); - - cy.get('@vmRow') - .within(() => { - cy.get('div[role=button]').should('not.contain', 'user'); - cy.get('div[role=button]').should('have.length', 1) - }); + .parents("tr") + .as("vmRow") + .contains("user") + .parents("[role=button]") + .find("svg") + .as("removeButton"); + cy.get("@removeButton").click(); + cy.get("[data-cy=confirmation-dialog-ok-btn]").click(); + + cy.get("@vmRow").within(() => { + cy.get("div[role=button]").should("not.contain", "user"); + cy.get("div[role=button]").should("have.length", 1); + }); - cy.get('@vmRow') - .find('div[role=button]') - .contains('admin') - .parents('[role=button]') - .find('svg') - .as('removeButton'); - cy.get('@removeButton').click(); - cy.get('[data-cy=confirmation-dialog-ok-btn]').click(); + cy.get("@vmRow").find("div[role=button]").contains("admin").parents("[role=button]").find("svg").as("removeButton"); + cy.get("@removeButton").click(); + cy.get("[data-cy=confirmation-dialog-ok-btn]").click(); - cy.get('[data-cy=vm-admin-table]') + cy.waitForDom() + .get("[data-cy=vm-admin-table]") .contains(vmHost) - .parents('tr') + .parents("tr") .within(() => { - cy.get('div[role=button]').should('not.contain', 'admin'); + cy.get("div[role=button]").should("not.exist"); }); // Check admin's vm page for login cy.get('header button[title="Account Management"]').click(); - cy.get('#account-menu').contains('Virtual Machines').click(); + cy.get("#account-menu").contains("Virtual Machines").click(); - cy.get('[data-cy=vm-user-panel]') - .should('not.contain', vmHost); + cy.get("[data-cy=vm-user-panel]").should("not.contain", vmHost); // Check activeUser's vm page for login cy.loginAs(activeUser); cy.get('header button[title="Account Management"]').click(); - cy.get('#account-menu').contains('Virtual Machines').click(); + cy.get("#account-menu").contains("Virtual Machines").click(); - cy.get('[data-cy=vm-user-panel]') - .should('not.contain', vmHost); + cy.get("[data-cy=vm-user-panel]").should("not.contain", vmHost); }); }); diff --git a/cypress/integration/workflow.spec.js b/cypress/integration/workflow.spec.js new file mode 100644 index 00000000..844e87d8 --- /dev/null +++ b/cypress/integration/workflow.spec.js @@ -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 ", + "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 ", + "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'); + } + }); + }) + +}); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index f09d959b..1682a8a8 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -28,17 +28,23 @@ // -- This will overwrite an existing command -- // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) -const controllerURL = Cypress.env('controller_url'); -const systemToken = Cypress.env('system_token'); +import { extractFilesData } from "services/collection-service/collection-service-files-response"; + +const controllerURL = Cypress.env("controller_url"); +const systemToken = Cypress.env("system_token"); let createdResources = []; -// Clean up on a 'before' hook to allow post-mortem analysis on individual tests. -beforeEach(function () { +const containerLogFolderPrefix = "log for container "; + +// Clean up anything that was created. You can temporarily add +// 'return' to the top if you need the resources to hang around to +// debug a specific test. +afterEach(function () { if (createdResources.length === 0) { return; } - cy.log(`Cleaning ${createdResources.length} previously created resource(s)`); - createdResources.forEach(function({suffix, uuid}) { + cy.log(`Cleaning ${createdResources.length} previously created resource(s).`); + createdResources.forEach(function ({ suffix, uuid }) { // Don't fail when a resource isn't already there, some objects may have // been removed, directly or indirectly, from the test that created them. cy.deleteResource(systemToken, suffix, uuid, false); @@ -47,363 +53,416 @@ beforeEach(function () { }); Cypress.Commands.add( - "doRequest", (method = 'GET', path = '', data = null, qs = null, - token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => { - return cy.request({ - method: method, - url: `${controllerURL.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`, - body: data, - qs: auth ? qs : Object.assign({ api_token: token }, qs), - auth: auth ? { bearer: `${token}` } : undefined, - followRedirect: followRedirect, - failOnStatusCode: failOnStatusCode - }); -}); + "doRequest", + (method = "GET", path = "", data = null, qs = null, token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => { + return cy.request({ + method: method, + url: `${controllerURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`, + body: data, + qs: auth ? qs : Object.assign({ api_token: token }, qs), + auth: auth ? { bearer: `${token}` } : undefined, + followRedirect: followRedirect, + failOnStatusCode: failOnStatusCode, + }); + } +); Cypress.Commands.add( - "getUser", (username, first_name = '', last_name = '', is_admin = false, is_active = true) => { - // Create user if not already created - return cy.doRequest('POST', '/auth/controller/callback', { - auth_info: JSON.stringify({ - email: `${username}@example.local`, - username: username, - first_name: first_name, - last_name: last_name, - alternate_emails: [] - }), - return_to: ',https://example.local' - }, null, systemToken, true, false) // Don't follow redirects so we can catch the token - .its('headers.location').as('location') - // Get its token and set the account up as admin and/or active - .then(function () { - this.userToken = this.location.split("=")[1] - assert.isString(this.userToken) - return cy.doRequest('GET', '/arvados/v1/users', null, { - filters: `[["username", "=", "${username}"]]` - }) - .its('body.items.0').as('aUser') + "doWebDAVRequest", + (method = "GET", path = "", data = null, qs = null, token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => { + return cy.doRequest("GET", "/arvados/v1/config", null, null).then(({ body: config }) => { + return cy.request({ + method: method, + url: `${config.Services.WebDAVDownload.ExternalURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`, + body: data, + qs: auth ? qs : Object.assign({ api_token: token }, qs), + auth: auth ? { bearer: `${token}` } : undefined, + followRedirect: followRedirect, + failOnStatusCode: failOnStatusCode, + }); + }); + } +); + +Cypress.Commands.add("getUser", (username, first_name = "", last_name = "", is_admin = false, is_active = true) => { + // Create user if not already created + return ( + cy + .doRequest( + "POST", + "/auth/controller/callback", + { + auth_info: JSON.stringify({ + email: `${username}@example.local`, + username: username, + first_name: first_name, + last_name: last_name, + alternate_emails: [], + }), + return_to: ",https://controller.api.client.invalid", + }, + null, + systemToken, + true, + false + ) // Don't follow redirects so we can catch the token + .its("headers.location") + .as("location") + // Get its token and set the account up as admin and/or active .then(function () { - cy.doRequest('PUT', `/arvados/v1/users/${this.aUser.uuid}`, { - user: { - is_admin: is_admin, - is_active: is_active - } - }) - .its('body').as('theUser') - .then(function () { - cy.doRequest('GET', '/arvados/v1/api_clients', null, { - filters: `[["is_trusted", "=", false]]`, - order: `["created_at desc"]` - }) - .its('body.items').as('apiClients') - .then(function () { - if (this.apiClients.length > 0) { - cy.doRequest('PUT', `/arvados/v1/api_clients/${this.apiClients[0].uuid}`, { - api_client: { - is_trusted: true - } - }) - .its('body').as('updatedApiClient') - .then(function() { - assert(this.updatedApiClient.is_trusted); - }) - } + this.userToken = this.location.split("=")[1]; + assert.isString(this.userToken); + return cy + .doRequest("GET", "/arvados/v1/users", null, { + filters: `[["username", "=", "${username}"]]`, }) + .its("body.items.0") + .as("aUser") .then(function () { - return { user: this.theUser, token: this.userToken }; - }) - }) + cy.doRequest("PUT", `/arvados/v1/users/${this.aUser.uuid}`, { + user: { + is_admin: is_admin, + is_active: is_active, + }, + }) + .its("body") + .as("theUser") + .then(function () { + cy.doRequest("GET", "/arvados/v1/api_clients", null, { + filters: `[["is_trusted", "=", false]]`, + order: `["created_at desc"]`, + }) + .its("body.items") + .as("apiClients") + .then(function () { + if (this.apiClients.length > 0) { + cy.doRequest("PUT", `/arvados/v1/api_clients/${this.apiClients[0].uuid}`, { + api_client: { + is_trusted: true, + }, + }) + .its("body") + .as("updatedApiClient") + .then(function () { + assert(this.updatedApiClient.is_trusted); + }); + } + }) + .then(function () { + return { user: this.theUser, token: this.userToken }; + }); + }); + }); }) - }) - } -) + ); +}); -Cypress.Commands.add( - "createLink", (token, data) => { - return cy.createResource(token, 'links', { - link: JSON.stringify(data) - }) - } -) +Cypress.Commands.add("createLink", (token, data) => { + return cy.createResource(token, "links", { + link: JSON.stringify(data), + }); +}); -Cypress.Commands.add( - "createGroup", (token, data) => { - return cy.createResource(token, 'groups', { - group: JSON.stringify(data), - ensure_unique_name: true - }) - } -) +Cypress.Commands.add("createGroup", (token, data) => { + return cy.createResource(token, "groups", { + group: JSON.stringify(data), + ensure_unique_name: true, + }); +}); -Cypress.Commands.add( - "trashGroup", (token, uuid) => { - return cy.deleteResource(token, 'groups', uuid); - } -) +Cypress.Commands.add("trashGroup", (token, uuid) => { + return cy.deleteResource(token, "groups", uuid); +}); +Cypress.Commands.add("createWorkflow", (token, data) => { + return cy.createResource(token, "workflows", { + workflow: JSON.stringify(data), + ensure_unique_name: true, + }); +}); -Cypress.Commands.add( - "createWorkflow", (token, data) => { - return cy.createResource(token, 'workflows', { - workflow: JSON.stringify(data), - ensure_unique_name: true - }) - } -) +Cypress.Commands.add("createCollection", (token, data) => { + return cy.createResource(token, "collections", { + collection: JSON.stringify(data), + ensure_unique_name: true, + }); +}); -Cypress.Commands.add( - "getCollection", (token, uuid) => { - return cy.getResource(token, 'collections', uuid) - } -) +Cypress.Commands.add("getCollection", (token, uuid) => { + return cy.getResource(token, "collections", uuid); +}); -Cypress.Commands.add( - "createCollection", (token, data) => { - return cy.createResource(token, 'collections', { - collection: JSON.stringify(data), - ensure_unique_name: true - }) - } -) +Cypress.Commands.add("updateCollection", (token, uuid, data) => { + return cy.updateResource(token, "collections", uuid, { + collection: JSON.stringify(data), + }); +}); -Cypress.Commands.add( - "updateCollection", (token, uuid, data) => { - return cy.updateResource(token, 'collections', uuid, { - collection: JSON.stringify(data) - }) - } -) +Cypress.Commands.add("collectionReplaceFiles", (token, uuid, data) => { + return cy.updateResource(token, "collections", uuid, { + collection: { + preserve_version: true, + }, + replace_files: JSON.stringify(data), + }); +}); -Cypress.Commands.add( - "getContainer", (token, uuid) => { - return cy.getResource(token, 'containers', uuid) - } -) +Cypress.Commands.add("getContainer", (token, uuid) => { + return cy.getResource(token, "containers", uuid); +}); -Cypress.Commands.add( - "updateContainer", (token, uuid, data) => { - return cy.updateResource(token, 'containers', uuid, { - container: JSON.stringify(data) - }) - } -) +Cypress.Commands.add("updateContainer", (token, uuid, data) => { + return cy.updateResource(token, "containers", uuid, { + container: JSON.stringify(data), + }); +}); -Cypress.Commands.add( - 'createContainerRequest', (token, data) => { - return cy.createResource(token, 'container_requests', { - container_request: JSON.stringify(data), - ensure_unique_name: true - }) - } -) +Cypress.Commands.add("getContainerRequest", (token, uuid) => { + return cy.getResource(token, "container_requests", uuid); +}); -Cypress.Commands.add( - "updateContainerRequest", (token, uuid, data) => { - return cy.updateResource(token, 'container_requests', uuid, { - container_request: JSON.stringify(data) - }) - } -) +Cypress.Commands.add("createContainerRequest", (token, data) => { + return cy.createResource(token, "container_requests", { + container_request: JSON.stringify(data), + ensure_unique_name: true, + }); +}); -Cypress.Commands.add( - "createLog", (token, data) => { - return cy.createResource(token, 'logs', { - log: JSON.stringify(data) - }) - } -) +Cypress.Commands.add("updateContainerRequest", (token, uuid, data) => { + return cy.updateResource(token, "container_requests", uuid, { + container_request: JSON.stringify(data), + }); +}); -Cypress.Commands.add( - "logsForContainer", (token, uuid, logType, logTextArray = []) => { - let logs = []; - for (const logText of logTextArray) { - logs.push(cy.createLog(token, { - object_uuid: uuid, - event_type: logType, - properties: { - text: logText +/** + * Requires an admin token for log_uuid modification to succeed + */ +Cypress.Commands.add("appendLog", (token, crUuid, fileName, lines = []) => + cy.getContainerRequest(token, crUuid).then(containerRequest => { + if (containerRequest.log_uuid) { + cy.listContainerRequestLogs(token, crUuid).then(logFiles => { + const filePath = `${containerRequest.log_uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}/${fileName}`; + if (logFiles.find(file => file.name === fileName)) { + // File exists, fetch and append + return cy + .doWebDAVRequest("GET", `c=${filePath}`, null, null, token) + .then(({ body: contents }) => + cy.doWebDAVRequest("PUT", `c=${filePath}`, contents.split("\n").concat(lines).join("\n"), null, token) + ); + } else { + // File not exists, put new file + cy.doWebDAVRequest("PUT", `c=${filePath}`, lines.join("\n"), null, token); } - }).as('lastLogRecord')) + }); + } else { + // Create log collection + return cy + .createCollection(token, { + name: `Test log collection ${Math.floor(Math.random() * 999999)}`, + owner_uuid: containerRequest.owner_uuid, + manifest_text: "", + }) + .then(collection => { + // Update CR log_uuid to fake log collection + cy.updateContainerRequest(token, containerRequest.uuid, { + log_uuid: collection.uuid, + }).then(() => + // Create empty directory for container uuid + cy + .collectionReplaceFiles(token, collection.uuid, { + [`/${containerLogFolderPrefix}${containerRequest.container_uuid}`]: "d41d8cd98f00b204e9800998ecf8427e+0", + }) + .then(() => + // Put new log file with contents into fake log collection + cy.doWebDAVRequest( + "PUT", + `c=${collection.uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}/${fileName}`, + lines.join("\n"), + null, + token + ) + ) + ); + }); } - cy.getAll('@lastLogRecord').then(function () { - return logs; - }) - } -) - -Cypress.Commands.add( - "createVirtualMachine", (token, data) => { - return cy.createResource(token, 'virtual_machines', { - virtual_machine: JSON.stringify(data), - ensure_unique_name: true - }) - } -) - -Cypress.Commands.add( - "getResource", (token, suffix, uuid) => { - return cy.doRequest('GET', `/arvados/v1/${suffix}/${uuid}`, null, {}, token) - .its('body') - .then(function (resource) { - return resource; + }) +); + +Cypress.Commands.add("listContainerRequestLogs", (token, crUuid) => + cy.getContainerRequest(token, crUuid).then(containerRequest => + cy + .doWebDAVRequest( + "PROPFIND", + `c=${containerRequest.log_uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}`, + null, + null, + token + ) + .then(({ body: data }) => { + return extractFilesData(new DOMParser().parseFromString(data, "text/xml")); }) - } -) + ) +); -Cypress.Commands.add( - "createResource", (token, suffix, data) => { - return cy.doRequest('POST', '/arvados/v1/' + suffix, data, null, token, true) - .its('body') - .then(function (resource) { - createdResources.push({suffix, uuid: resource.uuid}); - return resource; - }) - } -) +Cypress.Commands.add("createVirtualMachine", (token, data) => { + return cy.createResource(token, "virtual_machines", { + virtual_machine: JSON.stringify(data), + ensure_unique_name: true, + }); +}); -Cypress.Commands.add( - "deleteResource", (token, suffix, uuid, failOnStatusCode = true) => { - return cy.doRequest('DELETE', '/arvados/v1/' + suffix + '/' + uuid, null, null, token, false, true, failOnStatusCode) - .its('body') - .then(function (resource) { - return resource; - }) - } -) +Cypress.Commands.add("getResource", (token, suffix, uuid) => { + return cy + .doRequest("GET", `/arvados/v1/${suffix}/${uuid}`, null, {}, token) + .its("body") + .then(function (resource) { + return resource; + }); +}); -Cypress.Commands.add( - "updateResource", (token, suffix, uuid, data) => { - return cy.doRequest('PATCH', '/arvados/v1/' + suffix + '/' + uuid, data, null, token, true) - .its('body') - .then(function (resource) { - return resource; - }) - } -) +Cypress.Commands.add("createResource", (token, suffix, data) => { + return cy + .doRequest("POST", "/arvados/v1/" + suffix, data, null, token, true) + .its("body") + .then(function (resource) { + createdResources.push({ suffix, uuid: resource.uuid }); + return resource; + }); +}); -Cypress.Commands.add( - "loginAs", (user) => { - cy.clearCookies() - cy.clearLocalStorage() - cy.visit(`/token/?api_token=${user.token}`); - cy.url({timeout: 10000}).should('contain', '/projects/'); - cy.get('div#root').should('contain', 'Arvados Workbench (zzzzz)'); - cy.get('div#root').should('not.contain', 'Your account is inactive'); - } -) +Cypress.Commands.add("deleteResource", (token, suffix, uuid, failOnStatusCode = true) => { + return cy + .doRequest("DELETE", "/arvados/v1/" + suffix + "/" + uuid, null, null, token, false, true, failOnStatusCode) + .its("body") + .then(function (resource) { + return resource; + }); +}); -Cypress.Commands.add( - "testEditProjectOrCollection", (container, oldName, newName, newDescription, isProject = true) => { - cy.get(container).contains(oldName).rightclick(); - cy.get('[data-cy=context-menu]').contains(isProject ? 'Edit project' : 'Edit collection').click(); - cy.get('[data-cy=form-dialog]').within(() => { - cy.get('input[name=name]').clear().type(newName); - cy.get(isProject ? 'div[contenteditable=true]' : 'input[name=description]').clear().type(newDescription); - cy.get('[data-cy=form-submit-btn]').click(); +Cypress.Commands.add("updateResource", (token, suffix, uuid, data) => { + return cy + .doRequest("PATCH", "/arvados/v1/" + suffix + "/" + uuid, data, null, token, true) + .its("body") + .then(function (resource) { + return resource; }); +}); - cy.get(container).contains(newName).rightclick(); - cy.get('[data-cy=context-menu]').contains(isProject ? 'Edit project' : 'Edit collection').click(); - cy.get('[data-cy=form-dialog]').within(() => { - cy.get('input[name=name]').should('have.value', newName); +Cypress.Commands.add("loginAs", user => { + cy.clearCookies(); + cy.clearLocalStorage(); + cy.visit(`/token/?api_token=${user.token}`); + cy.url({ timeout: 10000 }).should("contain", "/projects/"); + cy.get("div#root").should("contain", "Arvados Workbench (zzzzz)"); + cy.get("div#root").should("not.contain", "Your account is inactive"); +}); - if (isProject) { - cy.get('span[data-text=true]').contains(newDescription); - } else { - cy.get('input[name=description]').should('have.value', newDescription); - } +Cypress.Commands.add("testEditProjectOrCollection", (container, oldName, newName, newDescription, isProject = true) => { + cy.get(container).contains(oldName).rightclick(); + cy.get("[data-cy=context-menu]") + .contains(isProject ? "Edit project" : "Edit collection") + .click(); + cy.get("[data-cy=form-dialog]").within(() => { + cy.get("input[name=name]").clear().type(newName); + cy.get(isProject ? "div[contenteditable=true]" : "input[name=description]") + .clear() + .type(newDescription); + cy.get("[data-cy=form-submit-btn]").click(); + }); - cy.get('[data-cy=form-cancel-btn]').click(); - }); - } -) + cy.get(container).contains(newName).rightclick(); + cy.get("[data-cy=context-menu]") + .contains(isProject ? "Edit project" : "Edit collection") + .click(); + cy.get("[data-cy=form-dialog]").within(() => { + cy.get("input[name=name]").should("have.value", newName); + + if (isProject) { + cy.get("span[data-text=true]").contains(newDescription); + } else { + cy.get("input[name=description]").should("have.value", newDescription); + } -Cypress.Commands.add( - "doSearch", (searchTerm) => { - cy.get('[data-cy=searchbar-input-field]').type(`{selectall}${searchTerm}{enter}`); - } -) + cy.get("[data-cy=form-cancel-btn]").click(); + }); +}); -Cypress.Commands.add( - "goToPath", (path) => { - return cy.window().its('appHistory').invoke('push', path); - } -) +Cypress.Commands.add("doSearch", searchTerm => { + cy.get("[data-cy=searchbar-input-field]").type(`{selectall}${searchTerm}{enter}`); +}); + +Cypress.Commands.add("goToPath", path => { + return cy.window().its("appHistory").invoke("push", path); +}); -Cypress.Commands.add('getAll', (...elements) => { - const promise = cy.wrap([], { log: false }) +Cypress.Commands.add("getAll", (...elements) => { + const promise = cy.wrap([], { log: false }); for (let element of elements) { - promise.then(arr => cy.get(element).then(got => cy.wrap([...arr, got]))) + promise.then(arr => cy.get(element).then(got => cy.wrap([...arr, got]))); } - return promise -}) + return promise; +}); -Cypress.Commands.add('shareWith', (srcUserToken, targetUserUUID, itemUUID, permission = 'can_write') => { +Cypress.Commands.add("shareWith", (srcUserToken, targetUserUUID, itemUUID, permission = "can_write") => { cy.createLink(srcUserToken, { name: permission, - link_class: 'permission', + link_class: "permission", head_uuid: itemUUID, - tail_uuid: targetUserUUID + tail_uuid: targetUserUUID, }); -}) +}); -Cypress.Commands.add('addToFavorites', (userToken, userUUID, itemUUID) => { +Cypress.Commands.add("addToFavorites", (userToken, userUUID, itemUUID) => { cy.createLink(userToken, { head_uuid: itemUUID, - link_class: 'star', - name: '', + link_class: "star", + name: "", owner_uuid: userUUID, tail_uuid: userUUID, }); -}) +}); -Cypress.Commands.add('createProject', ({ - owningUser, - targetUser, - projectName, - canWrite, - addToFavorites -}) => { - const writePermission = canWrite ? 'can_write' : 'can_read'; +Cypress.Commands.add("createProject", ({ owningUser, targetUser, projectName, canWrite, addToFavorites }) => { + const writePermission = canWrite ? "can_write" : "can_read"; cy.createGroup(owningUser.token, { name: `${projectName} ${Math.floor(Math.random() * 999999)}`, - group_class: 'project', - }).as(`${projectName}`).then((project) => { - if (targetUser && targetUser !== owningUser) { - cy.shareWith(owningUser.token, targetUser.user.uuid, project.uuid, writePermission); - } - if (addToFavorites) { - const user = targetUser ? targetUser : owningUser; - cy.addToFavorites(user.token, user.user.uuid, project.uuid); - } - }); + group_class: "project", + }) + .as(`${projectName}`) + .then(project => { + if (targetUser && targetUser !== owningUser) { + cy.shareWith(owningUser.token, targetUser.user.uuid, project.uuid, writePermission); + } + if (addToFavorites) { + const user = targetUser ? targetUser : owningUser; + cy.addToFavorites(user.token, user.user.uuid, project.uuid); + } + }); }); Cypress.Commands.add( - 'upload', + "upload", { - prevSubject: 'element', + prevSubject: "element", }, (subject, file, fileName, binaryMode = true) => { cy.window().then(window => { - const blob = binaryMode - ? b64toBlob(file, '', 512) - : new Blob([file], {type: 'text/plain'}); + const blob = binaryMode ? b64toBlob(file, "", 512) : new Blob([file], { type: "text/plain" }); const testFile = new window.File([blob], fileName); - cy.wrap(subject).trigger('drop', { + cy.wrap(subject).trigger("drop", { dataTransfer: { files: [testFile] }, }); - }) + }); } -) +); -function b64toBlob(b64Data, contentType = '', sliceSize = 512) { - const byteCharacters = atob(b64Data) - const byteArrays = [] +function b64toBlob(b64Data, contentType = "", sliceSize = 512) { + const byteCharacters = atob(b64Data); + const byteArrays = []; for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { const slice = byteCharacters.slice(offset, offset + sliceSize); @@ -419,78 +478,85 @@ function b64toBlob(b64Data, contentType = '', sliceSize = 512) { } const blob = new Blob(byteArrays, { type: contentType }); - return blob + return blob; } // From https://github.com/cypress-io/cypress/issues/7306#issuecomment-1076451070= // This command requires the async package (https://www.npmjs.com/package/async) -Cypress.Commands.add('waitForDom', () => { - cy.window().then({ - // Don't timeout before waitForDom finishes - timeout: 10000 - }, win => { - let timeElapsed = 0; - - cy.log("Waiting for DOM mutations to complete"); - - return new Cypress.Promise((resolve) => { - // set the required variables - let async = require("async"); - let observerConfig = { attributes: true, childList: true, subtree: true }; - let items = Array.apply(null, { length: 50 }).map(Number.call, Number); - win.mutationCount = 0; - win.previousMutationCount = null; - - // create an observer instance - let observer = new win.MutationObserver((mutations) => { - mutations.forEach((mutation) => { - // Only record "attributes" type mutations that are not a "class" mutation. - // If the mutation is not an "attributes" type, then we always record it. - if (mutation.type === 'attributes' && mutation.attributeName !== 'class') { - win.mutationCount += 1; - } else if (mutation.type !== 'attributes') { - win.mutationCount += 1; - } - }); - - // initialize the previousMutationCount - if (win.previousMutationCount == null) win.previousMutationCount = 0; - }); - - // watch the document body for the specified mutations - observer.observe(win.document.body, observerConfig); - - // check the DOM for mutations up to 50 times for a maximum time of 5 seconds - async.eachSeries(items, function iteratee(item, callback) { - // keep track of the elapsed time so we can log it at the end of the command - timeElapsed = timeElapsed + 100; - - // make each iteration of the loop 100ms apart - setTimeout(() => { - if (win.mutationCount === win.previousMutationCount) { - // pass an argument to the async callback to exit the loop - return callback('Resolved - DOM changes complete.'); - } else if (win.previousMutationCount != null) { - // only set the previous count if the observer has checked the DOM at least once - win.previousMutationCount = win.mutationCount; - return callback(); - } else if (win.mutationCount === 0 && win.previousMutationCount == null && item === 4) { - // this is an early exit in case nothing is changing in the DOM. That way we only - // wait 500ms instead of the full 5 seconds when no DOM changes are occurring. - return callback('Resolved - Exiting early since no DOM changes were detected.'); - } else { - // proceed to the next iteration - return callback(); - } - }, 100); - }, function done() { - // Log the total wait time so users can see it - cy.log(`DOM mutations ${timeElapsed >= 5000 ? "did not complete" : "completed"} in ${timeElapsed} ms`); - - // disconnect the observer and resolve the promise - observer.disconnect(); - resolve(); - }); - }); - }); - }); +Cypress.Commands.add("waitForDom", () => { + cy.window().then( + { + // Don't timeout before waitForDom finishes + timeout: 10000, + }, + win => { + let timeElapsed = 0; + + cy.log("Waiting for DOM mutations to complete"); + + return new Cypress.Promise(resolve => { + // set the required variables + let async = require("async"); + let observerConfig = { attributes: true, childList: true, subtree: true }; + let items = Array.apply(null, { length: 50 }).map(Number.call, Number); + win.mutationCount = 0; + win.previousMutationCount = null; + + // create an observer instance + let observer = new win.MutationObserver(mutations => { + mutations.forEach(mutation => { + // Only record "attributes" type mutations that are not a "class" mutation. + // If the mutation is not an "attributes" type, then we always record it. + if (mutation.type === "attributes" && mutation.attributeName !== "class") { + win.mutationCount += 1; + } else if (mutation.type !== "attributes") { + win.mutationCount += 1; + } + }); + + // initialize the previousMutationCount + if (win.previousMutationCount == null) win.previousMutationCount = 0; + }); + + // watch the document body for the specified mutations + observer.observe(win.document.body, observerConfig); + + // check the DOM for mutations up to 50 times for a maximum time of 5 seconds + async.eachSeries( + items, + function iteratee(item, callback) { + // keep track of the elapsed time so we can log it at the end of the command + timeElapsed = timeElapsed + 100; + + // make each iteration of the loop 100ms apart + setTimeout(() => { + if (win.mutationCount === win.previousMutationCount) { + // pass an argument to the async callback to exit the loop + return callback("Resolved - DOM changes complete."); + } else if (win.previousMutationCount != null) { + // only set the previous count if the observer has checked the DOM at least once + win.previousMutationCount = win.mutationCount; + return callback(); + } else if (win.mutationCount === 0 && win.previousMutationCount == null && item === 4) { + // this is an early exit in case nothing is changing in the DOM. That way we only + // wait 500ms instead of the full 5 seconds when no DOM changes are occurring. + return callback("Resolved - Exiting early since no DOM changes were detected."); + } else { + // proceed to the next iteration + return callback(); + } + }, 100); + }, + function done() { + // Log the total wait time so users can see it + cy.log(`DOM mutations ${timeElapsed >= 5000 ? "did not complete" : "completed"} in ${timeElapsed} ms`); + + // disconnect the observer and resolve the promise + observer.disconnect(); + resolve(); + } + ); + }); + } + ); +}); diff --git a/package.json b/package.json index c17fc917..abb20490 100644 --- a/package.json +++ b/package.json @@ -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", @@ -26,34 +29,36 @@ "axios": "^0.21.1", "babel-core": "6.26.3", "babel-runtime": "6.26.0", + "bootstrap": "^5.3.2", "caniuse-lite": "1.0.30001299", "classnames": "2.2.6", "cwlts": "1.15.29", "date-fns": "^2.28.0", "debounce": "1.2.0", + "dompurify": "^3.0.6", "elliptic": "6.5.4", "file-saver": "2.0.1", "fstream": "1.0.12", "is-image": "3.0.0", "js-yaml": "3.13.1", "jssha": "2.3.1", - "jszip": "3.1.5", + "jszip": "^3.10.1", "lodash": "^4.17.21", - "lodash-es": "4.17.14", + "lodash-es": "^4.17.21", "lodash.mergewith": "4.6.2", "lodash.template": "4.5.0", "material-ui-pickers": "^2.2.4", "mem": "4.0.0", "mime": "^3.0.0", - "moment": "2.29.1", + "moment": "^2.29.4", "parse-duration": "0.4.4", "prop-types": "15.7.2", "query-string": "6.9.0", - "react": "16.8.6", + "react": "16.14.0", "react-copy-to-clipboard": "5.0.3", "react-dnd": "5.0.0", "react-dnd-html5-backend": "5.0.1", - "react-dom": "16.8.6", + "react-dom": "16.14.0", "react-dropzone": "5.1.1", "react-highlight-words": "0.14.0", "react-idle-timer": "4.3.6", @@ -61,13 +66,14 @@ "react-router": "4.3.1", "react-router-dom": "4.3.1", "react-router-redux": "5.0.0-alpha.9", - "react-rte": "0.16.3", + "react-rte": "^0.16.5", "react-scripts": "3.4.4", "react-splitter-layout": "3.0.1", "react-transition-group": "2.5.0", "react-virtualized-auto-sizer": "1.0.2", "react-window": "1.8.5", "redux": "4.0.3", + "redux-devtools-extension": "^2.13.9", "redux-form": "7.4.2", "redux-thunk": "2.3.0", "reselect": "4.0.0", @@ -75,8 +81,6 @@ "shell-escape": "^0.2.0", "sinon": "7.3", "tippy.js": "^6.3.7", - "tslint": "5.20.0", - "tslint-etc": "1.6.0", "unionize": "2.1.2", "uuid": "3.3.2" }, @@ -92,6 +96,7 @@ "watch-css": "npm run build-css && node-sass-chokidar src/ -o src/ --watch --recursive" }, "devDependencies": { + "@sinonjs/fake-timers": "^10.3.0", "@types/classnames": "2.2.6", "@types/enzyme": "3.1.14", "@types/enzyme-adapter-react-16": "1.0.3", @@ -112,11 +117,13 @@ "enzyme": "3.11.0", "enzyme-adapter-react-16": "1.15.6", "jest-localstorage-mock": "2.2.0", - "node-sass": "^4.9.4", - "node-sass-chokidar": "1.5.0", + "node-sass": "^9.0.0", + "node-sass-chokidar": "^2.0.0", "redux-devtools": "3.4.1", "redux-mock-store": "1.5.4", "ts-mock-imports": "1.3.7", + "tslint": "5.20.0", + "tslint-etc": "1.6.0", "typescript": "4.3.4", "wait-on": "4.0.2", "yamljs": "0.3.0" diff --git a/public/arrow-to-left.png b/public/arrow-to-left.png deleted file mode 100644 index 262c1483..00000000 Binary files a/public/arrow-to-left.png and /dev/null differ diff --git a/public/arrow-to-right.png b/public/arrow-to-right.png deleted file mode 100644 index 8205c215..00000000 Binary files a/public/arrow-to-right.png and /dev/null differ diff --git a/public/collapseLHS-New.svg b/public/collapseLHS-New.svg deleted file mode 100644 index ce2eac8c..00000000 --- a/public/collapseLHS-New.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - diff --git a/public/mui-start-icon.svg b/public/mui-start-icon.svg new file mode 100644 index 00000000..3140cc33 --- /dev/null +++ b/public/mui-start-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/common/config.ts b/src/common/config.ts index 9b054282..eff998ae 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -5,12 +5,12 @@ import Axios from 'axios'; export const WORKBENCH_CONFIG_URL = - process.env.REACT_APP_ARVADOS_CONFIG_URL || '/config.json'; + process.env.REACT_APP_ARVADOS_CONFIG_URL || '/config.json'; interface WorkbenchConfig { - API_HOST: string; - VOCABULARY_URL?: string; - FILE_VIEWERS_CONFIG_URL?: string; + API_HOST: string; + VOCABULARY_URL?: string; + FILE_VIEWERS_CONFIG_URL?: string; } export interface ClusterConfigJSON { @@ -28,18 +28,42 @@ export interface ClusterConfigJSON { Scheme: string } }; - Mail?: { - SupportEmailAddress: string; - }; - Services: { - Controller: { - ExternalURL: string; + Mail?: { + SupportEmailAddress: string; }; - Workbench1: { - ExternalURL: string; - }; - Workbench2: { - ExternalURL: string; + Services: { + Controller: { + ExternalURL: string; + }; + Workbench1: { + ExternalURL: string; + }; + Workbench2: { + ExternalURL: string; + }; + Workbench: { + DisableSharingURLsUI: boolean; + ArvadosDocsite: string; + FileViewersConfigURL: string; + WelcomePageHTML: string; + InactivePageHTML: string; + SSHHelpPageHTML: string; + SSHHelpHostSuffix: string; + SiteName: string; + IdleTimeout: string; + }; + Websocket: { + ExternalURL: string; + }; + WebDAV: { + ExternalURL: string; + }; + WebDAVDownload: { + ExternalURL: string; + }; + WebShell: { + ExternalURL: string; + }; }; Workbench: { DisableSharingURLsUI: boolean; @@ -51,322 +75,308 @@ export interface ClusterConfigJSON { SSHHelpHostSuffix: string; SiteName: string; IdleTimeout: string; + BannerUUID: string; + UserProfileFormFields: {}; + UserProfileFormMessage: string; }; - Websocket: { - ExternalURL: string; - }; - WebDAV: { - ExternalURL: string; - }; - WebDAVDownload: { - ExternalURL: string; - }; - WebShell: { - ExternalURL: string; - }; - }; - Workbench: { - DisableSharingURLsUI: boolean; - ArvadosDocsite: string; - FileViewersConfigURL: string; - WelcomePageHTML: string; - InactivePageHTML: string; - SSHHelpPageHTML: string; - SSHHelpHostSuffix: string; - SiteName: string; - IdleTimeout: string; - BannerUUID: string; - }; - Login: { - LoginCluster: string; - Google: { - Enable: boolean; - }; - LDAP: { - Enable: boolean; - }; - OpenIDConnect: { - Enable: boolean; - }; - PAM: { - Enable: boolean; + Login: { + LoginCluster: string; + Google: { + Enable: boolean; + }; + LDAP: { + Enable: boolean; + }; + OpenIDConnect: { + Enable: boolean; + }; + PAM: { + Enable: boolean; + }; + SSO: { + Enable: boolean; + }; + Test: { + Enable: boolean; + }; }; - SSO: { - Enable: boolean; + Collections: { + ForwardSlashNameSubstitution: string; + ManagedProperties?: { + [key: string]: { + Function: string; + Value: string; + Protected?: boolean; + }; + }; + TrustAllContent: boolean; }; - Test: { - Enable: boolean; - }; - }; - Collections: { - ForwardSlashNameSubstitution: string; - ManagedProperties?: { - [key: string]: { - Function: string; - Value: string; - Protected?: boolean; - }; + Volumes: { + [key: string]: { + StorageClasses: { + [key: string]: boolean; + }; + }; }; - TrustAllContent: boolean; - }; - Volumes: { - [key: string]: { - StorageClasses: { - [key: string]: boolean; - }; + Users: { + AnonymousUserToken: string; }; - }; } export class Config { - baseUrl!: string; - keepWebServiceUrl!: string; - keepWebInlineServiceUrl!: string; - remoteHosts!: { - [key: string]: string; - }; - rootUrl!: string; - uuidPrefix!: string; - websocketUrl!: string; - workbenchUrl!: string; - workbench2Url!: string; - vocabularyUrl!: string; - fileViewersConfigUrl!: string; - loginCluster!: string; - clusterConfig!: ClusterConfigJSON; - apiRevision!: number; + baseUrl!: string; + keepWebServiceUrl!: string; + keepWebInlineServiceUrl!: string; + remoteHosts!: { + [key: string]: string; + }; + rootUrl!: string; + uuidPrefix!: string; + websocketUrl!: string; + workbenchUrl!: string; + workbench2Url!: string; + vocabularyUrl!: string; + fileViewersConfigUrl!: string; + loginCluster!: string; + clusterConfig!: ClusterConfigJSON; + apiRevision!: number; } export const buildConfig = (clusterConfig: ClusterConfigJSON): Config => { - const clusterConfigJSON = removeTrailingSlashes(clusterConfig); - const config = new Config(); - config.rootUrl = clusterConfigJSON.Services.Controller.ExternalURL; - config.baseUrl = `${config.rootUrl}/${ARVADOS_API_PATH}`; - config.uuidPrefix = clusterConfigJSON.ClusterID; - config.websocketUrl = clusterConfigJSON.Services.Websocket.ExternalURL; - config.workbench2Url = clusterConfigJSON.Services.Workbench2.ExternalURL; - config.workbenchUrl = clusterConfigJSON.Services.Workbench1.ExternalURL; - config.keepWebServiceUrl = - clusterConfigJSON.Services.WebDAVDownload.ExternalURL; - config.keepWebInlineServiceUrl = - clusterConfigJSON.Services.WebDAV.ExternalURL; - config.loginCluster = clusterConfigJSON.Login.LoginCluster; - config.clusterConfig = clusterConfigJSON; - config.apiRevision = 0; - mapRemoteHosts(clusterConfigJSON, config); - return config; + const clusterConfigJSON = removeTrailingSlashes(clusterConfig); + const config = new Config(); + config.rootUrl = clusterConfigJSON.Services.Controller.ExternalURL; + config.baseUrl = `${config.rootUrl}/${ARVADOS_API_PATH}`; + config.uuidPrefix = clusterConfigJSON.ClusterID; + config.websocketUrl = clusterConfigJSON.Services.Websocket.ExternalURL; + config.workbench2Url = clusterConfigJSON.Services.Workbench2.ExternalURL; + config.workbenchUrl = clusterConfigJSON.Services.Workbench1.ExternalURL; + config.keepWebServiceUrl = + clusterConfigJSON.Services.WebDAVDownload.ExternalURL; + config.keepWebInlineServiceUrl = + clusterConfigJSON.Services.WebDAV.ExternalURL; + config.loginCluster = clusterConfigJSON.Login.LoginCluster; + config.clusterConfig = clusterConfigJSON; + config.apiRevision = 0; + mapRemoteHosts(clusterConfigJSON, config); + return config; }; export const getStorageClasses = (config: Config): string[] => { - const classes: Set = new Set(['default']); - const volumes = config.clusterConfig.Volumes; - Object.keys(volumes).forEach((v) => { - Object.keys(volumes[v].StorageClasses || {}).forEach((sc) => { - if (volumes[v].StorageClasses[sc]) { - classes.add(sc); - } + const classes: Set = new Set(['default']); + const volumes = config.clusterConfig.Volumes; + Object.keys(volumes).forEach((v) => { + Object.keys(volumes[v].StorageClasses || {}).forEach((sc) => { + if (volumes[v].StorageClasses[sc]) { + classes.add(sc); + } + }); }); - }); - return Array.from(classes); + return Array.from(classes); }; const getApiRevision = async (apiUrl: string) => { - try { - const dd = (await Axios.get(`${apiUrl}/${DISCOVERY_DOC_PATH}`)).data; - return parseInt(dd.revision, 10) || 0; - } catch { - console.warn( - 'Unable to get API Revision number, defaulting to zero. Some features may not work properly.' - ); - return 0; - } + try { + const dd = (await Axios.get(`${apiUrl}/${DISCOVERY_DOC_PATH}`)).data; + return parseInt(dd.revision, 10) || 0; + } catch { + console.warn( + 'Unable to get API Revision number, defaulting to zero. Some features may not work properly.' + ); + return 0; + } }; const removeTrailingSlashes = ( - config: ClusterConfigJSON + config: ClusterConfigJSON ): ClusterConfigJSON => { - const svcs: any = {}; - Object.keys(config.Services).forEach((s) => { - svcs[s] = config.Services[s]; - if (svcs[s].hasOwnProperty('ExternalURL')) { - svcs[s].ExternalURL = svcs[s].ExternalURL.replace(/\/+$/, ''); - } - }); - return { ...config, Services: svcs }; + const svcs: any = {}; + Object.keys(config.Services).forEach((s) => { + svcs[s] = config.Services[s]; + if (svcs[s].hasOwnProperty('ExternalURL')) { + svcs[s].ExternalURL = svcs[s].ExternalURL.replace(/\/+$/, ''); + } + }); + return { ...config, Services: svcs }; }; export const fetchConfig = () => { - return Axios.get( - WORKBENCH_CONFIG_URL + '?nocache=' + new Date().getTime() - ) - .then((response) => response.data) - .catch(() => { - console.warn( - `There was an exception getting the Workbench config file at ${WORKBENCH_CONFIG_URL}. Using defaults instead.` - ); - return Promise.resolve(getDefaultConfig()); - }) - .then((workbenchConfig) => { - if (workbenchConfig.API_HOST === undefined) { - throw new Error( - `Unable to start Workbench. API_HOST is undefined in ${WORKBENCH_CONFIG_URL} or the environment.` - ); - } - return Axios.get( - getClusterConfigURL(workbenchConfig.API_HOST) - ).then(async (response) => { - const apiRevision = await getApiRevision( - response.data.Services.Controller.ExternalURL.replace(/\/+$/, '') - ); - const config = { ...buildConfig(response.data), apiRevision }; - const warnLocalConfig = (varName: string) => - console.warn( - `A value for ${varName} was found in ${WORKBENCH_CONFIG_URL}. To use the Arvados centralized configuration instead, \ + return Axios.get( + WORKBENCH_CONFIG_URL + '?nocache=' + new Date().getTime() + ) + .then((response) => response.data) + .catch(() => { + console.warn( + `There was an exception getting the Workbench config file at ${WORKBENCH_CONFIG_URL}. Using defaults instead.` + ); + return Promise.resolve(getDefaultConfig()); + }) + .then((workbenchConfig) => { + if (workbenchConfig.API_HOST === undefined) { + throw new Error( + `Unable to start Workbench. API_HOST is undefined in ${WORKBENCH_CONFIG_URL} or the environment.` + ); + } + return Axios.get( + getClusterConfigURL(workbenchConfig.API_HOST) + ).then(async (response) => { + const apiRevision = await getApiRevision( + response.data.Services.Controller.ExternalURL.replace(/\/+$/, '') + ); + const config = { ...buildConfig(response.data), apiRevision }; + const warnLocalConfig = (varName: string) => + console.warn( + `A value for ${varName} was found in ${WORKBENCH_CONFIG_URL}. To use the Arvados centralized configuration instead, \ remove the entire ${varName} entry from ${WORKBENCH_CONFIG_URL}` - ); + ); - // Check if the workbench config has an entry for vocabulary and file viewer URLs - // If so, use these values (even if it is an empty string), but print a console warning. - // Otherwise, use the cluster config. - let fileViewerConfigUrl; - if (workbenchConfig.FILE_VIEWERS_CONFIG_URL !== undefined) { - warnLocalConfig('FILE_VIEWERS_CONFIG_URL'); - fileViewerConfigUrl = workbenchConfig.FILE_VIEWERS_CONFIG_URL; - } else { - fileViewerConfigUrl = - config.clusterConfig.Workbench.FileViewersConfigURL || - '/file-viewers-example.json'; - } - config.fileViewersConfigUrl = fileViewerConfigUrl; + // Check if the workbench config has an entry for vocabulary and file viewer URLs + // If so, use these values (even if it is an empty string), but print a console warning. + // Otherwise, use the cluster config. + let fileViewerConfigUrl; + if (workbenchConfig.FILE_VIEWERS_CONFIG_URL !== undefined) { + warnLocalConfig('FILE_VIEWERS_CONFIG_URL'); + fileViewerConfigUrl = workbenchConfig.FILE_VIEWERS_CONFIG_URL; + } else { + fileViewerConfigUrl = + config.clusterConfig.Workbench.FileViewersConfigURL || + '/file-viewers-example.json'; + } + config.fileViewersConfigUrl = fileViewerConfigUrl; - if (workbenchConfig.VOCABULARY_URL !== undefined) { - console.warn( - `A value for VOCABULARY_URL was found in ${WORKBENCH_CONFIG_URL}. It will be ignored as the cluster already provides its own endpoint, you can safely remove it.` - ); - } - config.vocabularyUrl = getVocabularyURL(workbenchConfig.API_HOST); + if (workbenchConfig.VOCABULARY_URL !== undefined) { + console.warn( + `A value for VOCABULARY_URL was found in ${WORKBENCH_CONFIG_URL}. It will be ignored as the cluster already provides its own endpoint, you can safely remove it.` + ); + } + config.vocabularyUrl = getVocabularyURL(workbenchConfig.API_HOST); - return { config, apiHost: workbenchConfig.API_HOST }; - }); - }); + return { config, apiHost: workbenchConfig.API_HOST }; + }); + }); }; // Maps remote cluster hosts and removes the default RemoteCluster entry export const mapRemoteHosts = ( - clusterConfigJSON: ClusterConfigJSON, - config: Config + clusterConfigJSON: ClusterConfigJSON, + config: Config ) => { - config.remoteHosts = {}; - Object.keys(clusterConfigJSON.RemoteClusters).forEach((k) => { - config.remoteHosts[k] = clusterConfigJSON.RemoteClusters[k].Host; - }); - delete config.remoteHosts['*']; + config.remoteHosts = {}; + Object.keys(clusterConfigJSON.RemoteClusters).forEach((k) => { + config.remoteHosts[k] = clusterConfigJSON.RemoteClusters[k].Host; + }); + delete config.remoteHosts['*']; }; export const mockClusterConfigJSON = ( - config: Partial + config: Partial ): ClusterConfigJSON => ({ - API: { - UnfreezeProjectRequiresAdmin: false, - MaxItemsPerResponse: 1000, - }, - ClusterID: '', - RemoteClusters: {}, - Services: { - Controller: { ExternalURL: '' }, - Workbench1: { ExternalURL: '' }, - Workbench2: { ExternalURL: '' }, - Websocket: { ExternalURL: '' }, - WebDAV: { ExternalURL: '' }, - WebDAVDownload: { ExternalURL: '' }, - WebShell: { ExternalURL: '' }, - Workbench: { - DisableSharingURLsUI: false, - ArvadosDocsite: "", - FileViewersConfigURL: "", - WelcomePageHTML: "", - InactivePageHTML: "", - SSHHelpPageHTML: "", - SSHHelpHostSuffix: "", - SiteName: "", - IdleTimeout: "0s" - }, - }, - Workbench: { - DisableSharingURLsUI: false, - ArvadosDocsite: '', - FileViewersConfigURL: '', - WelcomePageHTML: '', - InactivePageHTML: '', - SSHHelpPageHTML: '', - SSHHelpHostSuffix: '', - SiteName: '', - IdleTimeout: '0s', - BannerUUID: "" - }, - Login: { - LoginCluster: '', - Google: { - Enable: false, + API: { + UnfreezeProjectRequiresAdmin: false, + MaxItemsPerResponse: 1000, }, - LDAP: { - Enable: false, + ClusterID: '', + RemoteClusters: {}, + Services: { + Controller: { ExternalURL: '' }, + Workbench1: { ExternalURL: '' }, + Workbench2: { ExternalURL: '' }, + Websocket: { ExternalURL: '' }, + WebDAV: { ExternalURL: '' }, + WebDAVDownload: { ExternalURL: '' }, + WebShell: { ExternalURL: '' }, + Workbench: { + DisableSharingURLsUI: false, + ArvadosDocsite: "", + FileViewersConfigURL: "", + WelcomePageHTML: "", + InactivePageHTML: "", + SSHHelpPageHTML: "", + SSHHelpHostSuffix: "", + SiteName: "", + IdleTimeout: "0s" + }, }, - OpenIDConnect: { - Enable: false, + Workbench: { + DisableSharingURLsUI: false, + ArvadosDocsite: '', + FileViewersConfigURL: '', + WelcomePageHTML: '', + InactivePageHTML: '', + SSHHelpPageHTML: '', + SSHHelpHostSuffix: '', + SiteName: '', + IdleTimeout: '0s', + BannerUUID: "", + UserProfileFormFields: {}, + UserProfileFormMessage: '', }, - PAM: { - Enable: false, + Login: { + LoginCluster: '', + Google: { + Enable: false, + }, + LDAP: { + Enable: false, + }, + OpenIDConnect: { + Enable: false, + }, + PAM: { + Enable: false, + }, + SSO: { + Enable: false, + }, + Test: { + Enable: false, + }, }, - SSO: { - Enable: false, + Collections: { + ForwardSlashNameSubstitution: '', + TrustAllContent: false, }, - Test: { - Enable: false, + Volumes: {}, + Users: { + AnonymousUserToken: "" }, - }, - Collections: { - ForwardSlashNameSubstitution: '', - TrustAllContent: false, - }, - Volumes: {}, - ...config, + ...config, }); export const mockConfig = (config: Partial): Config => ({ - baseUrl: '', - keepWebServiceUrl: '', - keepWebInlineServiceUrl: '', - remoteHosts: {}, - rootUrl: '', - uuidPrefix: '', - websocketUrl: '', - workbenchUrl: '', - workbench2Url: '', - vocabularyUrl: '', - fileViewersConfigUrl: '', - loginCluster: '', - clusterConfig: mockClusterConfigJSON({}), - apiRevision: 0, - ...config, + baseUrl: '', + keepWebServiceUrl: '', + keepWebInlineServiceUrl: '', + remoteHosts: {}, + rootUrl: '', + uuidPrefix: '', + websocketUrl: '', + workbenchUrl: '', + workbench2Url: '', + vocabularyUrl: '', + fileViewersConfigUrl: '', + loginCluster: '', + clusterConfig: mockClusterConfigJSON({}), + apiRevision: 0, + ...config, }); const getDefaultConfig = (): WorkbenchConfig => { - let apiHost = ''; - const envHost = process.env.REACT_APP_ARVADOS_API_HOST; - if (envHost !== undefined) { - console.warn(`Using default API host ${envHost}.`); - apiHost = envHost; - } else { - console.warn( - `No API host was found in the environment. Workbench may not be able to communicate with Arvados components.` - ); - } - return { - API_HOST: apiHost, - VOCABULARY_URL: undefined, - FILE_VIEWERS_CONFIG_URL: undefined, - }; + let apiHost = ''; + const envHost = process.env.REACT_APP_ARVADOS_API_HOST; + if (envHost !== undefined) { + console.warn(`Using default API host ${envHost}.`); + apiHost = envHost; + } else { + console.warn( + `No API host was found in the environment. Workbench may not be able to communicate with Arvados components.` + ); + } + return { + API_HOST: apiHost, + VOCABULARY_URL: undefined, + FILE_VIEWERS_CONFIG_URL: undefined, + }; }; export const ARVADOS_API_PATH = 'arvados/v1'; @@ -374,6 +384,6 @@ export const CLUSTER_CONFIG_PATH = 'arvados/v1/config'; export const VOCABULARY_PATH = 'arvados/v1/vocabulary'; export const DISCOVERY_DOC_PATH = 'discovery/v1/apis/arvados/v1/rest'; export const getClusterConfigURL = (apiHost: string) => - `https://${apiHost}/${CLUSTER_CONFIG_PATH}?nocache=${new Date().getTime()}`; + `https://${apiHost}/${CLUSTER_CONFIG_PATH}?nocache=${new Date().getTime()}`; export const getVocabularyURL = (apiHost: string) => - `https://${apiHost}/${VOCABULARY_PATH}?nocache=${new Date().getTime()}`; + `https://${apiHost}/${VOCABULARY_PATH}?nocache=${new Date().getTime()}`; diff --git a/src/common/html-sanitize.ts b/src/common/html-sanitize.ts new file mode 100644 index 00000000..e7c66f11 --- /dev/null +++ b/src/common/html-sanitize.ts @@ -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); + diff --git a/src/common/redirect-to.test.ts b/src/common/redirect-to.test.ts index 0168fd80..adb52f4b 100644 --- a/src/common/redirect-to.test.ts +++ b/src/common/redirect-to.test.ts @@ -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)); }); }); diff --git a/src/common/redirect-to.ts b/src/common/redirect-to.ts index 73c94843..e71ebde7 100644 --- a/src/common/redirect-to.ts +++ b/src/common/redirect-to.ts @@ -39,7 +39,7 @@ export const storeRedirects = () => { const redirectStoreKey = redirectKey === REDIRECT_TO_KEY ? REDIRECT_TO_PREVIEW_KEY : redirectKey; if (localStorage && redirectKey && redirectStoreKey) { - localStorage.setItem(redirectStoreKey, href.split(`${redirectKey}=`)[1]); + localStorage.setItem(redirectStoreKey, decodeURIComponent(href.split(`${redirectKey}=`)[1])); } }; diff --git a/src/common/use-async-interval.test.tsx b/src/common/use-async-interval.test.tsx new file mode 100644 index 00000000..188f184b --- /dev/null +++ b/src/common/use-async-interval.test.tsx @@ -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 ; +}; + +describe('useAsyncInterval', () => { + it('should fire repeatedly after the interval', async () => { + const mockedReact = React as jest.Mocked; + const ref = { current: {} }; + mockedReact.useRef.mockReturnValue(ref); + + const syncCallback = jest.fn(); + const testComponent = mount(); + + // 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; + const ref = { current: {} }; + mockedReact.useRef.mockReturnValue(ref); + + const delayedCallback = jest.fn(() => ( + new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 2000); + }) + )); + const testComponent = mount(); + + // 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 index 00000000..3be7309a --- /dev/null +++ b/src/common/use-async-interval.ts @@ -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, 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]); +}; diff --git a/src/components/autocomplete/autocomplete.tsx b/src/components/autocomplete/autocomplete.tsx index b5c634c3..17d85e85 100644 --- a/src/components/autocomplete/autocomplete.tsx +++ b/src/components/autocomplete/autocomplete.tsx @@ -175,17 +175,17 @@ export class Autocomplete extends React.Component { const tooltip = this.props.renderChipTooltip ? this.props.renderChipTooltip(item) : ''; if (tooltip && tooltip.length) { - return + return + onDelete(item, index)) : undefined} /> - + } else { - return onDelete(item, index)) : undefined} /> + onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} /> } } ); diff --git a/src/components/collection-panel-files/collection-panel-files.tsx b/src/components/collection-panel-files/collection-panel-files.tsx index fb36ebce..f1e50e0f 100644 --- a/src/components/collection-panel-files/collection-panel-files.tsx +++ b/src/components/collection-panel-files/collection-panel-files.tsx @@ -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,513 +79,630 @@ type CssRules = "backButton" const styles: StyleRulesCallback = (theme: Theme) => ({ wrapper: { - display: 'flex', - minHeight: '600px', - color: 'rgba(0,0,0,0.87)', - fontSize: '0.875rem', + display: "flex", + minHeight: "600px", + color: "rgba(0,0,0,0.87)", + fontSize: "0.875rem", fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', fontWeight: 400, - lineHeight: '1.5', - letterSpacing: '0.01071em' + lineHeight: "1.5", + letterSpacing: "0.01071em", }, backButton: { - color: '#00bfa5', - cursor: 'pointer', - float: 'left', + color: "#00bfa5", + cursor: "pointer", + float: "left", }, backButtonHidden: { - display: 'none', + display: "none", }, dataWrapper: { - minHeight: '500px' + minHeight: "500px", }, row: { - display: 'flex', - marginTop: '0.5rem', - marginBottom: '0.5rem', - cursor: 'pointer', + display: "flex", + marginTop: "0.5rem", + marginBottom: "0.5rem", + cursor: "pointer", "&:hover": { - backgroundColor: 'rgba(0, 0, 0, 0.08)', - } + backgroundColor: "rgba(0, 0, 0, 0.08)", + }, }, rowEmpty: { - top: '40%', - width: '100%', - textAlign: 'center', - position: 'absolute' + top: "40%", + width: "100%", + textAlign: "center", + position: "absolute", }, loader: { - top: '50%', - left: '50%', - marginTop: '-15px', - marginLeft: '-15px', - position: 'absolute' + top: "50%", + left: "50%", + marginTop: "-15px", + marginLeft: "-15px", + position: "absolute", }, rowName: { - display: 'inline-flex', - flexDirection: 'column', - justifyContent: 'center' + display: "inline-flex", + flexDirection: "column", + justifyContent: "center", }, searchWrapper: { - display: 'inline-block', - marginBottom: '1rem', - marginLeft: '1rem', + display: "inline-block", + marginBottom: "1rem", + marginLeft: "1rem", }, searchWrapperHidden: { - width: '0px' + width: "0px", }, rowSelection: { - padding: '0px', + padding: "0px", }, rowActive: { color: `${theme.palette.primary.main} !important`, }, listItemIcon: { - display: 'inline-flex', - flexDirection: 'column', - justifyContent: 'center' + display: "inline-flex", + flexDirection: "column", + justifyContent: "center", }, pathPanelMenu: { - float: 'right', - marginTop: '-15px', + float: "right", + marginTop: "-15px", }, pathPanel: { - padding: '0.5rem', - marginBottom: '0.5rem', - backgroundColor: '#fff', - boxShadow: '0px 1px 3px 0px rgb(0 0 0 / 20%), 0px 1px 1px 0px rgb(0 0 0 / 14%), 0px 2px 1px -1px rgb(0 0 0 / 12%)', + padding: "0.5rem", + marginBottom: "0.5rem", + backgroundColor: "#fff", + boxShadow: "0px 1px 3px 0px rgb(0 0 0 / 20%), 0px 1px 1px 0px rgb(0 0 0 / 14%), 0px 2px 1px -1px rgb(0 0 0 / 12%)", }, pathPanelPathWrapper: { - display: 'inline-block', + display: "inline-block", }, leftPanel: { flex: 0, - padding: '0 1rem 1rem', - marginRight: '1rem', - whiteSpace: 'nowrap', - position: 'relative', - backgroundColor: '#fff', - boxShadow: '0px 3px 3px 0px rgb(0 0 0 / 20%), 0px 3px 1px 0px rgb(0 0 0 / 14%), 0px 3px 1px -1px rgb(0 0 0 / 12%)', + padding: "0 1rem 1rem", + marginRight: "1rem", + whiteSpace: "nowrap", + position: "relative", + backgroundColor: "#fff", + boxShadow: "0px 3px 3px 0px rgb(0 0 0 / 20%), 0px 3px 1px 0px rgb(0 0 0 / 14%), 0px 3px 1px -1px rgb(0 0 0 / 12%)", }, leftPanelVisible: { opacity: 1, - flex: '50%', - animation: `animateVisible 1000ms ${theme.transitions.easing.easeOut}` + flex: "50%", + animation: `animateVisible 1000ms ${theme.transitions.easing.easeOut}`, }, leftPanelHidden: { opacity: 0, - flex: 'initial', - padding: '0', - marginRight: '0', + flex: "initial", + padding: "0", + marginRight: "0", }, "@keyframes animateVisible": { "0%": { opacity: 0, - flex: 'initial', + flex: "initial", }, "100%": { opacity: 1, - flex: '50%', - } + flex: "50%", + }, }, rightPanel: { - flex: '50%', - padding: '1rem', - paddingTop: '0.5rem', - marginTop: '-0.5rem', - position: 'relative', - backgroundColor: '#fff', - boxShadow: '0px 3px 3px 0px rgb(0 0 0 / 20%), 0px 3px 1px 0px rgb(0 0 0 / 14%), 0px 3px 1px -1px rgb(0 0 0 / 12%)', + flex: "50%", + padding: "1rem", + paddingTop: "0.5rem", + marginTop: "-0.5rem", + position: "relative", + backgroundColor: "#fff", + boxShadow: "0px 3px 3px 0px rgb(0 0 0 / 20%), 0px 3px 1px 0px rgb(0 0 0 / 14%), 0px 3px 1px -1px rgb(0 0 0 / 12%)", }, pathPanelItem: { - cursor: 'pointer', + cursor: "pointer", }, uploadIcon: { - transform: 'rotate(180deg)' + transform: "rotate(180deg)", }, uploadButton: { - float: 'right', + float: "right", }, moreOptionsButton: { width: theme.spacing.unit * 3, height: theme.spacing.unit * 3, marginRight: theme.spacing.unit, - marginTop: 'auto', - marginBottom: 'auto', - justifyContent: 'center', + marginTop: "auto", + marginBottom: "auto", + justifyContent: "center", }, moreOptions: { - position: 'absolute' + position: "absolute", }, }); const pathPromise = {}; -export const CollectionPanelFiles = withStyles(styles)(connect((state: RootState) => ({ - auth: state.auth, - collectionPanel: state.collectionPanel, - collectionPanelFiles: state.collectionPanelFiles, -}))((props: CollectionPanelFilesProps & WithStyles & { auth: AuthState }) => { - const { classes, onItemMenuOpen, onUploadDataClick, isWritable, dispatch, collectionPanelFiles, collectionPanel } = props; - const { apiToken, config } = props.auth; - - const webdavClient = new WebDAV({ - baseURL: config.keepWebServiceUrl, - headers: { - Authorization: `Bearer ${apiToken}` - }, - }); - - const webDAVRequestConfig: WebDAVRequestConfig = { - headers: { - Depth: '1', - }, - }; - - const parentRef = React.useRef(null); - const [path, setPath] = React.useState([]); - 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 & { 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([]); + 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((state) => ({ ...state, ...newState })); - }) - .finally(() => { - setIsLoading(false); - keyArray.forEach(key => delete pathPromise[key]); - }); - }; - - React.useEffect(() => { - if (rightKey) { - fetchData(rightKey); - setLeftSearch(''); - setRightSearch(''); - } - }, [rightKey]); // eslint-disable-line react-hooks/exhaustive-deps - - const currentPDH = (collectionPanel.item || {}).portableDataHash; - React.useEffect(() => { - if (currentPDH) { - fetchData([leftKey, rightKey], true); - } - }, [currentPDH]); // eslint-disable-line react-hooks/exhaustive-deps - - React.useEffect(() => { - if (rightData) { - const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1); - setCollectionFiles(filtered, false)(dispatch); - } - }, [rightData, dispatch, rightSearch]); - - const handleRightClick = React.useCallback( - (event) => { - event.preventDefault(); - let elem = event.target; - - while (elem && elem.dataset && !elem.dataset.item) { - elem = elem.parentNode; - } + const leftData = pathData[leftKey] || []; + const rightData = pathData[rightKey]; - if (!elem || !elem.dataset) { - return; + React.useEffect(() => { + if (props.currentItemUuid && extractUuidKind(props.currentItemUuid) === ResourceKind.COLLECTION) { + setPathData({}); + setPath([props.currentItemUuid]); } + }, [props.currentItemUuid]); - const { id } = elem.dataset; + const fetchData = (keys, ignoreCache = false) => { + const keyArray = Array.isArray(keys) ? keys : [keys]; - const item: any = { - id, - data: rightData.find((elem) => elem.id === id), - }; + Promise.all( + keyArray + .filter(key => !!key) + .map(key => { + const dataExists = !!pathData[key]; + const runningRequest = pathPromise[key]; - if (id) { - onItemMenuOpen(event, item, isWritable); - } - }, - [onItemMenuOpen, isWritable, rightData]); + if (ignoreCache || (!dataExists && !runningRequest)) { + if (!isLoading) { + setIsLoading(true); + } - React.useEffect(() => { - let node = null; + pathPromise[key] = true; - if (parentRef?.current) { - node = parentRef.current; - (node as any).addEventListener('contextmenu', handleRightClick); - } + return webdavClient.propfind(`c=${key}`, webDAVRequestConfig); + } - return () => { - if (node) { - (node as any).removeEventListener('contextmenu', handleRightClick); - } + return Promise.resolve(null); + }) + .filter(promise => !!promise) + ) + .then(requests => { + const newState = requests + .map((request, index) => { + if (request && request.responseXML != null) { + const key = keyArray[index]; + const result: any = extractFilesData(request.responseXML); + const sortedResult = sortBy(result, n => n.name).sort((n1, n2) => { + if (n1.type === "directory" && n2.type !== "directory") { + return -1; + } + if (n1.type !== "directory" && n2.type === "directory") { + return 1; + } + return 0; + }); + + return { [key]: sortedResult }; + } + return {}; + }) + .reduce((prev, next) => { + return { ...next, ...prev }; + }, {}); + setPathData(state => ({ ...state, ...newState })); + }, () => { + // Nothing to do + }) + .finally(() => { + setIsLoading(false); + keyArray.forEach(key => delete pathPromise[key]); + }); }; - }, [parentRef, handleRightClick]); - const handleClick = React.useCallback( - (event: any) => { - let isCheckbox = false; - let isMoreButton = false; - let elem = event.target; - - if (elem.type === 'checkbox') { - isCheckbox = true; - } - // The "More options" button click event could be triggered on its - // internal graphic element. - else if ((elem.dataset && elem.dataset.id === 'moreOptions') || (elem.parentNode && elem.parentNode.dataset && elem.parentNode.dataset.id === 'moreOptions')) { - isMoreButton = true; + React.useEffect(() => { + if (rightKey) { + fetchData(rightKey); + setLeftSearch(""); + setRightSearch(""); } + }, [rightKey, rightData]); // eslint-disable-line react-hooks/exhaustive-deps - while (elem && elem.dataset && !elem.dataset.item) { - elem = elem.parentNode; + const currentPDH = (collectionPanel.item || {}).portableDataHash; + React.useEffect(() => { + if (currentPDH) { + fetchData([leftKey, rightKey], true); } + }, [currentPDH]); // eslint-disable-line react-hooks/exhaustive-deps - if (elem && elem.dataset && !isCheckbox && !isMoreButton) { - const { parentPath, subfolderPath, breadcrumbPath, type } = elem.dataset; - - if (breadcrumbPath) { - const index = path.indexOf(breadcrumbPath); - setPath((state) => ([...state.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((state) => ([...state, parentPath])); - } + const handleRightClick = React.useCallback( + event => { + event.preventDefault(); + let elem = event.target; - if (subfolderPath && type === 'directory') { - setPath((state) => ([...state, 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 ( - - - - ) - }, - [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
-
-
- { path.map( (p: string, index: number) => - - {index === 0 ? 'Home' : p} /  - ) + if (parentRef?.current) { + node = parentRef.current; + (node as any).addEventListener("contextmenu", handleRightClick); } -
- - { - onOptionsMenuOpen(ev, isWritable); - }}> - - - -
-
-
1 ? classes.leftPanelVisible : classes.leftPanelHidden)} data-cy="collection-files-left-panel"> - 1 ? classes.backButton : classes.backButtonHidden}> - setPath((state) => ([...state.slice(0, state.length -1)]))}> - - - -
1 ? classes.searchWrapper : classes.searchWrapperHidden}> - + + 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 ( + + + + ); + }, + [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 ( +
+
+
+ {path.map((p: string, index: number) => ( + + {index === 0 ? "Home" : p} /  + + ))} +
+ + { + onOptionsMenuOpen(ev, isWritable); + }} + > + + +
-
{ leftData - ? {({ height, width }) => { - const filtered = leftData.filter(({ name }) => name.indexOf(leftSearch) > -1); - return !!filtered.length - ? { ({ index, style }) => { - const { id, type, name } = filtered[index]; - return
- { getItemIcon(type, getActiveClass(name)) } -
- {name} +
+
1 ? classes.leftPanelVisible : classes.leftPanelHidden)} + data-cy="collection-files-left-panel" + > + 1 ? classes.backButton : classes.backButtonHidden} + > + setPath(state => [...state.slice(0, state.length - 1)])}> + + + +
1 ? classes.searchWrapper : classes.searchWrapperHidden}> + +
+
+ {leftData ? ( + + {({ height, width }) => { + const filtered = leftData.filter(({ name }) => name.indexOf(leftSearch) > -1); + return !!filtered.length ? ( + + {({ index, style }) => { + const { id, type, name } = filtered[index]; + return ( +
+ {getItemIcon(type, getActiveClass(name))} +
{name}
+ {getActiveClass(name) ? ( + + ) : null} +
+ ); + }} +
+ ) : ( +
No directories available
+ ); + }} +
+ ) : ( +
+
- { getActiveClass(name) - ? - : null - } -
; - }} - :
No directories available
- }} - - :
} -
-
-
-
- -
- { isWritable && - } -
{ rightData && !isLoading - ? {({ height, width }) => { - const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1); - return !!filtered.length - ? { ({ index, style }) => { - const { id, type, name, size } = filtered[index]; - - return
-   - {getItemIcon(type, null)} -
- {name} -
- - { formatFileSize(size) } - - - - - - + )} +
+
+
+
+ +
+ {isWritable && ( + + )} +
+ {rightData && !isLoading ? ( + + {({ height, width }) => { + const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1); + return !!filtered.length ? ( + + {({ index, style }) => { + const { id, type, name, size } = filtered[index]; + + return ( +
+ +   + {getItemIcon(type, null)} +
{name}
+ + {formatFileSize(size)} + + + + + + +
+ ); + }} +
+ ) : ( +
This collection is empty
+ ); + }} +
+ ) : ( +
+
- } } - :
This collection is empty
- }} - :
- -
} + )} +
+
-
-
})); + ); + }) +); diff --git a/src/components/confirmation-dialog/confirmation-dialog.tsx b/src/components/confirmation-dialog/confirmation-dialog.tsx index 28b19bb9..fa09ffc6 100644 --- a/src/components/confirmation-dialog/confirmation-dialog.tsx +++ b/src/components/confirmation-dialog/confirmation-dialog.tsx @@ -26,8 +26,8 @@ export const ConfirmationDialog = (props: ConfirmationDialogProps & WithDialogPr -
{props.data.text}
-
{props.data.info}
+ {props.data.text} + {props.data.info}
diff --git a/src/components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar.tsx b/src/components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar.tsx index 3b2ff68a..3ef483df 100644 --- a/src/components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar.tsx +++ b/src/components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar.tsx @@ -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 = (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 & DispatchProp; -export const CopyToClipboardSnackbar = connect()(withStyles(styles)( - class CopyToClipboardSnackbar extends React.Component { - 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 { + onCopy = () => { + this.props.dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: 'Copied', + hideDuration: 2000, + kind: SnackbarKind.SUCCESS, + }) + ); + }; - render() { - const { children, value, classes } = this.props; - return ( - - - - {children || } - - - - ); - } - } -)); + render() { + const { children, value, classes } = this.props; + return ( + ev.stopPropagation()}> + + + {children || } + + + + ); + } + } + ) +); diff --git a/src/components/data-explorer/data-explorer.test.tsx b/src/components/data-explorer/data-explorer.test.tsx index dc7e8725..b86567a5 100644 --- a/src/components/data-explorer/data-explorer.test.tsx +++ b/src/components/data-explorer/data-explorer.test.tsx @@ -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("", () => { + 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 ", () => { const onSearch = jest.fn(); const onSetColumns = jest.fn(); - const dataExplorer = mount(); + + const dataExplorer = mount( + + + + ); 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("", () => { 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(); + const dataExplorer = mount( + + + + ); expect(dataExplorer.find(ColumnSelector).prop("columns")).toBe(columns); dataExplorer.find(ColumnSelector).prop("onColumnToggle")("columns"); expect(onColumnToggle).toHaveBeenCalledWith("columns"); @@ -54,15 +83,20 @@ describe("", () => { 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(); - expect(dataExplorer.find(DataTable).prop("columns").slice(0, -1)).toEqual(columns); + const dataExplorer = mount( + + + + ); + 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("", () => { const onChangePage = jest.fn(); const onChangeRowsPerPage = jest.fn(); const onSetColumns = jest.fn(); - const dataExplorer = mount(); + const dataExplorer = mount( + + + + ); 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: {}, }); diff --git a/src/components/data-explorer/data-explorer.tsx b/src/components/data-explorer/data-explorer.tsx index fcee0c54..27e46d58 100644 --- a/src/components/data-explorer/data-explorer.tsx +++ b/src/components/data-explorer/data-explorer.tsx @@ -2,61 +2,72 @@ // // SPDX-License-Identifier: AGPL-3.0 -import React from 'react'; -import { Grid, Paper, Toolbar, StyleRulesCallback, withStyles, WithStyles, TablePagination, IconButton, Tooltip, Button } from '@material-ui/core'; +import React from "react"; +import { Grid, Paper, Toolbar, StyleRulesCallback, withStyles, WithStyles, TablePagination, IconButton, Tooltip, Button } from "@material-ui/core"; import { ColumnSelector } from "components/column-selector/column-selector"; import { DataTable, DataColumns, DataTableFetchMode } from "components/data-table/data-table"; import { DataColumn } from "components/data-table/data-column"; -import { SearchInput } from 'components/search-input/search-input'; +import { SearchInput } from "components/search-input/search-input"; import { ArvadosTheme } from "common/custom-theme"; -import { createTree } from 'models/tree'; -import { DataTableFilters } from 'components/data-table-filters/data-table-filters-tree'; -import { - CloseIcon, - IconType, - MaximizeIcon, - UnMaximizeIcon, - MoreOptionsIcon -} from 'components/icon/icon'; -import { PaperProps } from '@material-ui/core/Paper'; -import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view'; +import { MultiselectToolbar } from "components/multiselect-toolbar/MultiselectToolbar"; +import { TCheckedList } from "components/data-table/data-table"; +import { createTree } from "models/tree"; +import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree"; +import { CloseIcon, IconType, MaximizeIcon, UnMaximizeIcon, MoreVerticalIcon } from "components/icon/icon"; +import { PaperProps } from "@material-ui/core/Paper"; +import { MPVPanelProps } from "components/multi-panel-view/multi-panel-view"; -type CssRules = 'searchBox' | 'headerMenu' | "toolbar" | "footer" | "root" | 'moreOptionsButton' | 'title' | 'dataTable' | 'container'; +type CssRules = "titleWrapper" | "searchBox" | "headerMenu" | "toolbar" | "footer" | "root" | "moreOptionsButton" | "title" | 'subProcessTitle' | "dataTable" | "container"; const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ + titleWrapper: { + display: "flex", + justifyContent: "space-between", + }, searchBox: { paddingBottom: 0, }, toolbar: { paddingTop: 0, paddingRight: theme.spacing.unit, + paddingLeft: "10px", }, footer: { - overflow: 'auto' + overflow: "auto", }, root: { - height: '100%', + height: "100%", }, moreOptionsButton: { - padding: 0 + padding: 0, }, title: { - display: 'inline-block', + display: "inline-block", + paddingLeft: theme.spacing.unit * 2, + paddingTop: theme.spacing.unit * 2, + fontSize: "18px", + paddingRight: "10px", + }, + subProcessTitle: { + display: "inline-block", paddingLeft: theme.spacing.unit * 2, paddingTop: theme.spacing.unit * 2, - fontSize: '18px' + fontSize: "18px", + flexGrow: 0, + paddingRight: "10px", }, dataTable: { - height: '100%', - overflow: 'auto', + height: "100%", + overflow: "auto", }, container: { - height: '100%', + height: "100%", }, headerMenu: { - float: 'right', - display: 'inline-block', - } + marginLeft: "auto", + flexBasis: "initial", + flexGrow: 0, + }, }); interface DataExplorerDataProps { @@ -80,9 +91,12 @@ interface DataExplorerDataProps { actions?: React.ReactNode; hideSearchInput?: boolean; title?: React.ReactNode; + progressBar?: React.ReactNode; paperKey?: string; currentItemUuid: string; elementPath?: string; + isMSToolbarVisible: boolean; + checkedList: TCheckedList; } interface DataExplorerActionProps { @@ -98,22 +112,25 @@ interface DataExplorerActionProps { onChangeRowsPerPage: (rowsPerPage: number) => void; onLoadMore: (page: number) => void; extractKey?: (item: T) => React.Key; + toggleMSToolbar: (isVisible: boolean) => void; + setCheckedListOnStore: (checkedList: TCheckedList) => void; } -type DataExplorerProps = DataExplorerDataProps & - DataExplorerActionProps & WithStyles & MPVPanelProps; +type DataExplorerProps = DataExplorerDataProps & DataExplorerActionProps & WithStyles & MPVPanelProps; export const DataExplorer = withStyles(styles)( class DataExplorerGeneric extends React.Component> { state = { showLoading: false, - prevRefresh: '', - prevRoute: '', + prevRefresh: "", + prevRoute: "", }; + multiSelectToolbarInTitle = !this.props.title && !this.props.progressBar; + componentDidUpdate(prevProps: DataExplorerProps) { - const currentRefresh = this.props.currentRefresh || ''; - const currentRoute = this.props.currentRoute || ''; + const currentRefresh = this.props.currentRefresh || ""; + const currentRoute = this.props.currentRoute || ""; if (currentRoute !== this.state.prevRoute) { // Component already mounted, but the user comes from a route change, @@ -146,119 +163,243 @@ export const DataExplorer = withStyles(styles)( // Component just mounted, so we need to show the loading indicator. this.setState({ showLoading: this.props.working, - prevRefresh: this.props.currentRefresh || '', - prevRoute: this.props.currentRoute || '', + prevRefresh: this.props.currentRefresh || "", + prevRoute: this.props.currentRoute || "", }); } render() { const { - columns, onContextMenu, onFiltersChange, onSortToggle, extractKey, - rowsPerPage, rowsPerPageOptions, onColumnToggle, searchLabel, searchValue, onSearch, - items, itemsAvailable, onRowClick, onRowDoubleClick, classes, - defaultViewIcon, defaultViewMessages, hideColumnSelector, actions, paperProps, hideSearchInput, - paperKey, fetchMode, currentItemUuid, title, - doHidePanel, doMaximizePanel, doUnMaximizePanel, panelName, panelMaximized, elementPath + columns, + onContextMenu, + onFiltersChange, + onSortToggle, + extractKey, + rowsPerPage, + rowsPerPageOptions, + onColumnToggle, + searchLabel, + searchValue, + onSearch, + items, + itemsAvailable, + onRowClick, + onRowDoubleClick, + classes, + defaultViewIcon, + defaultViewMessages, + hideColumnSelector, + actions, + paperProps, + hideSearchInput, + paperKey, + fetchMode, + currentItemUuid, + currentRoute, + title, + progressBar, + doHidePanel, + doMaximizePanel, + doUnMaximizePanel, + panelName, + panelMaximized, + elementPath, + toggleMSToolbar, + setCheckedListOnStore, + checkedList, } = this.props; - return - -
- {title && {title}} - { - (!hideColumnSelector || !hideSearchInput || !!actions) && - - - {!hideSearchInput &&
- {!hideSearchInput && } -
} - {actions} - {!hideColumnSelector && } - { doUnMaximizePanel && panelMaximized && - - - } - { doMaximizePanel && !panelMaximized && - - - } - { doHidePanel && - - - } -
-
- } -
- 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} /> - - { - elementPath && - - - {elementPath} - + return ( + + +
+ {title && ( + + {title} + + )} + {!!progressBar && progressBar} + {this.multiSelectToolbarInTitle && } + {(!hideColumnSelector || !hideSearchInput || !!actions) && ( + + + + {!hideSearchInput && ( +
+ {!hideSearchInput && ( + + )} +
+ )} + {actions} + {!hideColumnSelector && ( + + )} +
+ {doUnMaximizePanel && panelMaximized && ( + + + + + + )} + {doMaximizePanel && !panelMaximized && ( + + + + + + )} + {doHidePanel && ( + + + + + + )} +
+
+ )} +
+ {!this.multiSelectToolbarInTitle && } + + 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} + /> + + + + {elementPath && ( + + {elementPath} + + )} + + {fetchMode === DataTableFetchMode.PAGINATED ? ( + 0 ? {} : { disabled: true }} + component="div" + /> + ) : ( + + )} + + - } - - {fetchMode === DataTableFetchMode.PAGINATED ? 0) ? {} : {disabled: true}} - component="div" /> : } -
-
-
; + + ); } changePage = (event: React.MouseEvent, page: number) => { this.props.onChangePage(page); - } + }; - changeRowsPerPage: React.ChangeEventHandler = (event) => { + changeRowsPerPage: React.ChangeEventHandler = event => { this.props.onChangeRowsPerPage(parseInt(event.target.value, 10)); - } + }; loadMore = () => { this.props.onLoadMore(this.props.page + 1); - } + }; - renderContextMenuTrigger = (item: T) => - - - this.props.onContextMenu(event, item)}> - + renderContextMenuTrigger = (item: T) => ( + + + { + event.stopPropagation() + this.props.onContextMenu(event, item) + }} + > + + ); contextMenuColumn: DataColumn = { name: "Actions", @@ -266,7 +407,7 @@ export const DataExplorer = withStyles(styles)( configurable: false, filters: createTree(), key: "context-actions", - render: this.renderContextMenuTrigger + render: this.renderContextMenuTrigger, }; } ); diff --git a/src/components/data-table-filters/data-table-filters-popover.tsx b/src/components/data-table-filters/data-table-filters-popover.tsx index b5187866..557abd82 100644 --- a/src/components/data-table-filters/data-table-filters-popover.tsx +++ b/src/components/data-table-filters/data-table-filters-popover.tsx @@ -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 = (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 = (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 = (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 <> - - - {children} - - - - - - - - - - {name} - - - - {this.props.mutuallyExclusive || - - - - } - - - - ; + const isActive = getNodeDescendants('')(this.state.filters).some((f) => (defaultSelection === SelectionMode.ALL ? !f.selected : f.selected)); + return ( + <> + + + {children} + + + + + + + + + {name} + + + <> + {this.props.mutuallyExclusive || ( + + + + )} + + + + + + ); } 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, })); - } - + }; } ); diff --git a/src/components/data-table-filters/data-table-filters-tree.tsx b/src/components/data-table-filters/data-table-filters-tree.tsx index 7b97865b..d52b58f5 100644 --- a/src/components/data-table-filters/data-table-filters-tree.tsx +++ b/src/components/data-table-filters/data-table-filters-tree.tsx @@ -59,14 +59,14 @@ export class DataTableFiltersTree extends React.Component 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) => { 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) => { 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 index 00000000..0248c826 --- /dev/null +++ b/src/components/data-table-multiselect-popover/data-table-multiselect-popover.tsx @@ -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 = (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, DataTableFMultiselectPopState> { + state: DataTableFMultiselectPopState = { + anchorEl: undefined, + }; + icon = React.createRef(); + + render() { + const { classes, children, options, checkedList, disabled } = this.props; + return ( + <> + + {} : this.open} + disableRipple + > + {children} + + + + + + + +
+ {options.length && + options.map((option, i) => ( +
{ + option.fn(checkedList); + this.close(); + }} + > + {option.name} +
+ ))} +
+
+
+ + ); + } + + open = () => { + this.setState({ anchorEl: this.icon.current || undefined }); + }; + + close = () => { + this.setState(prev => ({ + ...prev, + anchorEl: undefined, + })); + }; + } +); diff --git a/src/components/data-table/data-table.test.tsx b/src/components/data-table/data-table.test.tsx index a72056d1..880868bd 100644 --- a/src/components/data-table/data-table.test.tsx +++ b/src/components/data-table/data-table.test.tsx @@ -4,13 +4,13 @@ import React from "react"; import { mount, configure } from "enzyme"; -import { pipe } from 'lodash/fp'; +import { pipe } from "lodash/fp"; import { TableHead, TableCell, Typography, TableBody, Button, TableSortLabel } from "@material-ui/core"; import Adapter from "enzyme-adapter-react-16"; import { DataTable, DataColumns } from "./data-table"; import { SortDirection, createDataColumn } from "./data-column"; -import { DataTableFiltersPopover } from 'components/data-table-filters/data-table-filters-popover'; -import { createTree, setNode, initTreeNode } from 'models/tree'; +import { DataTableFiltersPopover } from "components/data-table-filters/data-table-filters-popover"; +import { createTree, setNode, initTreeNode } from "models/tree"; import { DataTableFilterItem } from "components/data-table-filters/data-table-filters-tree"; configure({ adapter: new Adapter() }); @@ -22,30 +22,34 @@ describe("", () => { name: "Column 1", render: () => , selected: true, - configurable: true + configurable: true, }), createDataColumn({ name: "Column 2", render: () => , selected: true, - configurable: true + configurable: true, }), createDataColumn({ name: "Column 3", render: () => , selected: false, - configurable: true + configurable: true, }), ]; - const dataTable = mount(); - expect(dataTable.find(TableHead).find(TableCell)).toHaveLength(2); + const dataTable = mount( + + ); + expect(dataTable.find(TableHead).find(TableCell)).toHaveLength(3); }); it("renders column name", () => { @@ -54,18 +58,22 @@ describe("", () => { name: "Column 1", render: () => , selected: true, - configurable: true + configurable: true, }), ]; - const dataTable = mount(); - expect(dataTable.find(TableHead).find(TableCell).text()).toBe("Column 1"); + const dataTable = mount( + + ); + expect(dataTable.find(TableHead).find(TableCell).last().text()).toBe("Column 1"); }); it("uses renderHeader instead of name prop", () => { @@ -75,18 +83,22 @@ describe("", () => { renderHeader: () => Column Header, render: () => , selected: true, - configurable: true + configurable: true, }), ]; - const dataTable = mount(); - expect(dataTable.find(TableHead).find(TableCell).text()).toBe("Column Header"); + const dataTable = mount( + + ); + expect(dataTable.find(TableHead).find(TableCell).last().text()).toBe("Column Header"); }); it("passes column key prop to corresponding cells", () => { @@ -96,116 +108,137 @@ describe("", () => { key: "column-1-key", render: () => , selected: true, - configurable: true - }) + configurable: true, + }), ]; - const dataTable = mount(); - 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( + + ); + expect(dataTable.find(TableBody).find(TableCell).last().key()).toBe("column-1-key"); }); it("renders items", () => { const columns: DataColumns = [ createDataColumn({ name: "Column 1", - render: (item) => {item}, + render: item => {item}, selected: true, - configurable: true + configurable: true, }), createDataColumn({ name: "Column 2", - render: (item) => , + render: item => , selected: true, - configurable: true - }) + configurable: true, + }), ]; - const dataTable = mount(); - expect(dataTable.find(TableBody).find(Typography).text()).toBe("item 1"); - expect(dataTable.find(TableBody).find(Button).text()).toBe("item 1"); + const dataTable = mount( + + ); + 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 ", () => { const columns: DataColumns = [ createDataColumn({ name: "Column 1", - sort: {direction: SortDirection.ASC, field: "length"}, + sort: { direction: SortDirection.ASC, field: "length" }, selected: true, configurable: true, - render: (item) => {item} - })]; + render: item => {item}, + }), + ]; const onSortToggle = jest.fn(); - const dataTable = mount(); + const dataTable = mount( + + ); 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 if there is no filters provided", () => { - const columns: DataColumns = [{ - name: "Column 1", - selected: true, - configurable: true, - filters: [], - render: (item) => {item} - }]; + const columns: DataColumns = [ + { + name: "Column 1", + selected: true, + configurable: true, + filters: [], + render: item => {item}, + }, + ]; const onFiltersChange = jest.fn(); - const dataTable = mount(); + const dataTable = mount( + + ); expect(dataTable.find(DataTableFiltersPopover)).toHaveLength(0); }); it("passes filter props to ", () => { - const filters = pipe( - () => createTree(), - setNode(initTreeNode({ id: 'filter', value: { name: 'filter' } })) - ); - const columns: DataColumns = [{ - name: "Column 1", - selected: true, - configurable: true, - filters: filters(), - render: (item) => {item} - }]; + const filters = pipe(() => createTree(), setNode(initTreeNode({ id: "filter", value: { name: "filter" } }))); + const columns: DataColumns = [ + { + name: "Column 1", + selected: true, + configurable: true, + filters: filters(), + render: item => {item}, + }, + ]; const onFiltersChange = jest.fn(); - const dataTable = mount(); - expect(dataTable.find(DataTableFiltersPopover).prop("filters")).toBe(columns[0].filters); + const dataTable = mount( + + ); + 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]); }); }); diff --git a/src/components/data-table/data-table.tsx b/src/components/data-table/data-table.tsx index 4a82b660..de3e272d 100644 --- a/src/components/data-table/data-table.tsx +++ b/src/components/data-table/data-table.tsx @@ -2,23 +2,39 @@ // // SPDX-License-Identifier: AGPL-3.0 -import React from 'react'; -import { Table, TableBody, TableRow, TableCell, TableHead, TableSortLabel, StyleRulesCallback, Theme, WithStyles, withStyles, IconButton } from '@material-ui/core'; -import classnames from 'classnames'; -import { DataColumn, SortDirection } from './data-column'; -import { DataTableDefaultView } from '../data-table-default-view/data-table-default-view'; -import { DataTableFilters } from '../data-table-filters/data-table-filters-tree'; -import { DataTableFiltersPopover } from '../data-table-filters/data-table-filters-popover'; -import { countNodes, getTreeDirty } from 'models/tree'; -import { IconType, PendingIcon } from 'components/icon/icon'; -import { SvgIconProps } from '@material-ui/core/SvgIcon'; -import ArrowDownwardIcon from '@material-ui/icons/ArrowDownward'; +import React from "react"; +import { + Table, + TableBody, + TableRow, + TableCell, + TableHead, + TableSortLabel, + StyleRulesCallback, + Theme, + WithStyles, + withStyles, + IconButton, + Tooltip, +} from "@material-ui/core"; +import classnames from "classnames"; +import { DataColumn, SortDirection } from "./data-column"; +import { DataTableDefaultView } from "../data-table-default-view/data-table-default-view"; +import { DataTableFilters } from "../data-table-filters/data-table-filters-tree"; +import { DataTableMultiselectPopover } from "../data-table-multiselect-popover/data-table-multiselect-popover"; +import { DataTableFiltersPopover } from "../data-table-filters/data-table-filters-popover"; +import { countNodes, getTreeDirty } from "models/tree"; +import { IconType, PendingIcon } from "components/icon/icon"; +import { SvgIconProps } from "@material-ui/core/SvgIcon"; +import ArrowDownwardIcon from "@material-ui/icons/ArrowDownward"; +import { createTree } from "models/tree"; +import { DataTableMultiselectOption } from "../data-table-multiselect-popover/data-table-multiselect-popover"; export type DataColumns = Array>; export enum DataTableFetchMode { PAGINATED, - INFINITE + INFINITE, } export interface DataTableDataProps { @@ -35,154 +51,358 @@ export interface DataTableDataProps { 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 = (theme: Theme) => ({ root: { - width: '100%', + width: "100%", }, content: { - display: 'inline-block', - width: '100%', + display: "inline-block", + width: "100%", }, tableBody: { - background: theme.palette.background.paper + background: theme.palette.background.paper, }, loader: { - left: '50%', - marginLeft: '-84px', - position: 'absolute' + left: "50%", + marginLeft: "-84px", + position: "absolute", }, noItemsInfo: { textAlign: "center", - padding: theme.spacing.unit + padding: theme.spacing.unit, + }, + checkBoxHead: { + padding: "0", + display: "flex", + }, + checkBoxCell: { + padding: "0", + paddingLeft: "10px", + }, + checkBox: { + cursor: "pointer", }, tableCell: { - wordWrap: 'break-word', - paddingRight: '24px', - color: '#737373' - + wordWrap: "break-word", + paddingRight: "24px", + color: "#737373", + }, + firstTableCell: { + paddingLeft: "5px", }, tableCellWorkflows: { - '&:nth-last-child(2)': { - padding: '0px', - maxWidth: '48px' + "&:nth-last-child(2)": { + padding: "0px", + maxWidth: "48px", + }, + "&:last-child": { + padding: "0px", + paddingRight: "24px", + width: "48px", }, - '&:last-child': { - padding: '0px', - paddingRight: '24px', - width: '48px' - } }, arrow: { - margin: 0 + margin: 0, }, arrowButton: { - color: theme.palette.text.primary - } + color: theme.palette.text.primary, + }, }); +export type TCheckedList = Record; + +type DataTableState = { + isSelected: boolean; +}; + type DataTableProps = DataTableDataProps & WithStyles; export const DataTable = withStyles(styles)( class Component extends React.Component> { + state: DataTableState = { + isSelected: false, + }; + + componentDidMount(): void { + this.initializeCheckedList([]); + } + + componentDidUpdate(prevProps: Readonly>, 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 = { + name: "checkBoxColumn", + selected: true, + configurable: false, + filters: createTree(), + render: uuid => { + const { classes, checkedList } = this.props; + return ( + this.handleSelectOne(uuid)} + onDoubleClick={ev => ev.stopPropagation()}> + ); + }, + }; + + 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
-
- - - - {this.mapVisibleColumns(this.renderHeadCell)} - - - - { !working && items.map(this.renderBodyRow) } - -
- { !!working && -
- -
} - {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 ( +
+
+ + + {this.mapVisibleColumns(this.renderHeadCell)} + + {!working && items.map(this.renderBodyRow)} +
+ {!!working && ( +
+ +
+ )} + {items.length === 0 && !working && this.renderNoItemsPlaceholder(this.props.columns)} +
-
; + ); } renderNoItemsPlaceholder = (columns: DataColumns) => { - const dirty = columns.some((column) => getTreeDirty('')(column.filters)); - return ; - } + const dirty = columns.some(column => getTreeDirty("")(column.filters)); + return ( + + ); + }; renderHeadCell = (column: DataColumn, index: number) => { const { name, key, renderHeader, filters, sort } = column; - const { onSortToggle, onFiltersChange, classes } = this.props; - return - {renderHeader ? - renderHeader() : - countNodes(filters) > 0 - ? +
+ + + + +
+
+ ) : ( + + {renderHeader ? ( + renderHeader() + ) : countNodes(filters) > 0 ? ( + - onFiltersChange && - onFiltersChange(filters, column)} + onChange={filters => onFiltersChange && onFiltersChange(filters, column)} filters={filters}> {name} - : sort - ? - onSortToggle && - onSortToggle(column)}> - {name} - - : - {name} - } - ; - } + ) : sort ? ( + onSortToggle && onSortToggle(column)}> + {name} + + ) : ( + {name} + )} + + ); + }; ArrowIcon = ({ className, ...props }: SvgIconProps) => ( - - + + - ) + ); renderBodyRow = (item: any, index: number) => { const { onRowClick, onRowDoubleClick, extractKey, classes, currentItemUuid, currentRoute } = this.props; - return onRowClick && onRowClick(event, item)} - onContextMenu={this.handleRowContextMenu(item)} - onDoubleClick={event => onRowDoubleClick && onRowDoubleClick(event, item)} - selected={item === currentItemUuid}> - {this.mapVisibleColumns((column, index) => - {column.render(item)} - - )} - ; - } + return ( + onRowClick && onRowClick(event, item)} + onContextMenu={this.handleRowContextMenu(item)} + onDoubleClick={event => onRowDoubleClick && onRowDoubleClick(event, item)} + selected={item === currentItemUuid}> + {this.mapVisibleColumns((column, index) => ( + + {column.render(item)} + + ))} + + ); + }; mapVisibleColumns = (fn: (column: DataColumn, index: number) => React.ReactElement) => { return this.props.columns.filter(column => column.selected).map(fn); - } - - handleRowContextMenu = (item: T) => - (event: React.MouseEvent) => - this.props.onContextMenu(event, item) + }; + handleRowContextMenu = (item: T) => (event: React.MouseEvent) => this.props.onContextMenu(event, item); } ); diff --git a/src/components/dropdown-menu/dropdown-menu.tsx b/src/components/dropdown-menu/dropdown-menu.tsx index bb661bc2..39cce048 100644 --- a/src/components/dropdown-menu/dropdown-menu.tsx +++ b/src/components/dropdown-menu/dropdown-menu.tsx @@ -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 { state = { - anchorEl: undefined + anchorEl: undefined, }; transformOrigin: PopoverOrigin = { vertical: -50, - horizontal: 0 + horizontal: 0, }; render() { @@ -33,7 +33,9 @@ export class DropdownMenu extends React.Component - + { this.setState({ anchorEl: undefined }); - } + }; handleOpen = (event: React.MouseEvent) => { this.setState({ anchorEl: event.currentTarget }); - } + }; } diff --git a/src/components/icon/icon.tsx b/src/components/icon/icon.tsx index 20b87b20..2dd97c16 100644 --- a/src/components/icon/icon.tsx +++ b/src/components/icon/icon.tsx @@ -2,218 +2,268 @@ // // SPDX-License-Identifier: AGPL-3.0 -import React from 'react'; -import { Badge, SvgIcon, Tooltip } from '@material-ui/core'; -import Add from '@material-ui/icons/Add'; -import ArrowBack from '@material-ui/icons/ArrowBack'; -import ArrowDropDown from '@material-ui/icons/ArrowDropDown'; -import Build from '@material-ui/icons/Build'; -import Cached from '@material-ui/icons/Cached'; -import DescriptionIcon from '@material-ui/icons/Description'; -import ChevronLeft from '@material-ui/icons/ChevronLeft'; -import CloudUpload from '@material-ui/icons/CloudUpload'; -import Code from '@material-ui/icons/Code'; -import Create from '@material-ui/icons/Create'; -import ImportContacts from '@material-ui/icons/ImportContacts'; -import ChevronRight from '@material-ui/icons/ChevronRight'; -import Close from '@material-ui/icons/Close'; -import ContentCopy from '@material-ui/icons/FileCopyOutlined'; -import CreateNewFolder from '@material-ui/icons/CreateNewFolder'; -import Delete from '@material-ui/icons/Delete'; -import DeviceHub from '@material-ui/icons/DeviceHub'; -import Edit from '@material-ui/icons/Edit'; -import ErrorRoundedIcon from '@material-ui/icons/ErrorRounded'; -import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import FlipToFront from '@material-ui/icons/FlipToFront'; -import Folder from '@material-ui/icons/Folder'; -import FolderShared from '@material-ui/icons/FolderShared'; -import Pageview from '@material-ui/icons/Pageview'; -import GetApp from '@material-ui/icons/GetApp'; -import Help from '@material-ui/icons/Help'; -import HelpOutline from '@material-ui/icons/HelpOutline'; -import History from '@material-ui/icons/History'; -import Inbox from '@material-ui/icons/Inbox'; -import Memory from '@material-ui/icons/Memory'; -import MoveToInbox from '@material-ui/icons/MoveToInbox'; -import Info from '@material-ui/icons/Info'; -import Input from '@material-ui/icons/Input'; -import InsertDriveFile from '@material-ui/icons/InsertDriveFile'; -import LastPage from '@material-ui/icons/LastPage'; -import LibraryBooks from '@material-ui/icons/LibraryBooks'; -import ListAlt from '@material-ui/icons/ListAlt'; -import Menu from '@material-ui/icons/Menu'; -import MoreVert from '@material-ui/icons/MoreVert'; -import Mail from '@material-ui/icons/Mail'; -import Notifications from '@material-ui/icons/Notifications'; -import OpenInNew from '@material-ui/icons/OpenInNew'; -import People from '@material-ui/icons/People'; -import Person from '@material-ui/icons/Person'; -import PersonAdd from '@material-ui/icons/PersonAdd'; -import PlayArrow from '@material-ui/icons/PlayArrow'; -import Public from '@material-ui/icons/Public'; -import RateReview from '@material-ui/icons/RateReview'; -import RestoreFromTrash from '@material-ui/icons/History'; -import Search from '@material-ui/icons/Search'; -import SettingsApplications from '@material-ui/icons/SettingsApplications'; -import SettingsEthernet from '@material-ui/icons/SettingsEthernet'; -import Settings from '@material-ui/icons/Settings'; -import Star from '@material-ui/icons/Star'; -import StarBorder from '@material-ui/icons/StarBorder'; -import Warning from '@material-ui/icons/Warning'; -import VpnKey from '@material-ui/icons/VpnKey'; -import LinkOutlined from '@material-ui/icons/LinkOutlined'; -import RemoveRedEye from '@material-ui/icons/RemoveRedEye'; -import Computer from '@material-ui/icons/Computer'; -import WrapText from '@material-ui/icons/WrapText'; -import TextIncrease from '@material-ui/icons/ZoomIn'; -import TextDecrease from '@material-ui/icons/ZoomOut'; -import FullscreenSharp from '@material-ui/icons/FullscreenSharp'; -import FullscreenExitSharp from '@material-ui/icons/FullscreenExitSharp'; -import ExitToApp from '@material-ui/icons/ExitToApp'; -import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline'; -import RemoveCircleOutline from '@material-ui/icons/RemoveCircleOutline'; -import NotInterested from '@material-ui/icons/NotInterested'; -import Image from '@material-ui/icons/Image'; -import Stop from '@material-ui/icons/Stop'; +import React from "react"; +import { Badge, SvgIcon, Tooltip } from "@material-ui/core"; +import Add from "@material-ui/icons/Add"; +import ArrowBack from "@material-ui/icons/ArrowBack"; +import ArrowDropDown from "@material-ui/icons/ArrowDropDown"; +import Build from "@material-ui/icons/Build"; +import Cached from "@material-ui/icons/Cached"; +import DescriptionIcon from "@material-ui/icons/Description"; +import ChevronLeft from "@material-ui/icons/ChevronLeft"; +import CloudUpload from "@material-ui/icons/CloudUpload"; +import Code from "@material-ui/icons/Code"; +import Create from "@material-ui/icons/Create"; +import ImportContacts from "@material-ui/icons/ImportContacts"; +import ChevronRight from "@material-ui/icons/ChevronRight"; +import Close from "@material-ui/icons/Close"; +import ContentCopy from "@material-ui/icons/FileCopyOutlined"; +import CreateNewFolder from "@material-ui/icons/CreateNewFolder"; +import Delete from "@material-ui/icons/Delete"; +import DeviceHub from "@material-ui/icons/DeviceHub"; +import Edit from "@material-ui/icons/Edit"; +import ErrorRoundedIcon from "@material-ui/icons/ErrorRounded"; +import ExpandMoreIcon from "@material-ui/icons/ExpandMore"; +import FlipToFront from "@material-ui/icons/FlipToFront"; +import Folder from "@material-ui/icons/Folder"; +import FolderShared from "@material-ui/icons/FolderShared"; +import Pageview from "@material-ui/icons/Pageview"; +import GetApp from "@material-ui/icons/GetApp"; +import Help from "@material-ui/icons/Help"; +import HelpOutline from "@material-ui/icons/HelpOutline"; +import History from "@material-ui/icons/History"; +import Inbox from "@material-ui/icons/Inbox"; +import Memory from "@material-ui/icons/Memory"; +import MoveToInbox from "@material-ui/icons/MoveToInbox"; +import Info from "@material-ui/icons/Info"; +import Input from "@material-ui/icons/Input"; +import InsertDriveFile from "@material-ui/icons/InsertDriveFile"; +import LastPage from "@material-ui/icons/LastPage"; +import LibraryBooks from "@material-ui/icons/LibraryBooks"; +import ListAlt from "@material-ui/icons/ListAlt"; +import Menu from "@material-ui/icons/Menu"; +import MoreVert from "@material-ui/icons/MoreVert"; +import MoreHoriz from "@material-ui/icons/MoreHoriz"; +import Mail from "@material-ui/icons/Mail"; +import Notifications from "@material-ui/icons/Notifications"; +import OpenInNew from "@material-ui/icons/OpenInNew"; +import People from "@material-ui/icons/People"; +import Person from "@material-ui/icons/Person"; +import PersonAdd from "@material-ui/icons/PersonAdd"; +import PlayArrow from "@material-ui/icons/PlayArrow"; +import Public from "@material-ui/icons/Public"; +import RateReview from "@material-ui/icons/RateReview"; +import RestoreFromTrash from "@material-ui/icons/History"; +import Search from "@material-ui/icons/Search"; +import SettingsApplications from "@material-ui/icons/SettingsApplications"; +import SettingsEthernet from "@material-ui/icons/SettingsEthernet"; +import Settings from "@material-ui/icons/Settings"; +import Star from "@material-ui/icons/Star"; +import StarBorder from "@material-ui/icons/StarBorder"; +import Warning from "@material-ui/icons/Warning"; +import VpnKey from "@material-ui/icons/VpnKey"; +import LinkOutlined from "@material-ui/icons/LinkOutlined"; +import RemoveRedEye from "@material-ui/icons/RemoveRedEye"; +import Computer from "@material-ui/icons/Computer"; +import WrapText from "@material-ui/icons/WrapText"; +import TextIncrease from "@material-ui/icons/ZoomIn"; +import TextDecrease from "@material-ui/icons/ZoomOut"; +import FullscreenSharp from "@material-ui/icons/FullscreenSharp"; +import FullscreenExitSharp from "@material-ui/icons/FullscreenExitSharp"; +import ExitToApp from "@material-ui/icons/ExitToApp"; +import CheckCircleOutline from "@material-ui/icons/CheckCircleOutline"; +import RemoveCircleOutline from "@material-ui/icons/RemoveCircleOutline"; +import NotInterested from "@material-ui/icons/NotInterested"; +import Image from "@material-ui/icons/Image"; +import Stop from "@material-ui/icons/Stop"; +import FileCopy from "@material-ui/icons/FileCopy"; // Import FontAwesome icons -import { library } from '@fortawesome/fontawesome-svg-core'; -import { faPencilAlt, faSlash, faUsers, faEllipsisH } from '@fortawesome/free-solid-svg-icons'; -import { FormatAlignLeft } from '@material-ui/icons'; -library.add( - faPencilAlt, - faSlash, - faUsers, - faEllipsisH, -); +import { library } from "@fortawesome/fontawesome-svg-core"; +import { faPencilAlt, faSlash, faUsers, faEllipsisH } from "@fortawesome/free-solid-svg-icons"; +import { FormatAlignLeft } from "@material-ui/icons"; +library.add(faPencilAlt, faSlash, faUsers, faEllipsisH); -export const FreezeIcon = (props: any) => +export const FreezeIcon: IconType = (props: any) => ( +); -export const UnfreezeIcon = (props: any) => +export const UnfreezeIcon: IconType = (props: any) => ( +); -export const PendingIcon = (props: any) => +export const PendingIcon = (props: any) => ( - + +); -export const ReadOnlyIcon = (props: any) => +export const ReadOnlyIcon = (props: any) => (
- +
-
; +
+); -export const GroupsIcon = (props: any) => +export const GroupsIcon = (props: any) => ( - ; + +); -export const CollectionOldVersionIcon = (props: any) => - - }> +export const CollectionOldVersionIcon = (props: any) => ( + + }> - ; + +); // https://materialdesignicons.com/icon/image-off -export const ImageOffIcon = (props: any) => +export const ImageOffIcon = (props: any) => ( - ; + +); // https://materialdesignicons.com/icon/inbox-arrow-up -export const OutputIcon: IconType = (props: any) => +export const OutputIcon: IconType = (props: any) => ( - ; - -export type IconType = React.SFC<{ className?: string, style?: object }>; - -export const AddIcon: IconType = (props) => ; -export const AddFavoriteIcon: IconType = (props) => ; -export const AdminMenuIcon: IconType = (props) => ; -export const AdvancedIcon: IconType = (props) => ; -export const AttributesIcon: IconType = (props) => ; -export const BackIcon: IconType = (props) => ; -export const CustomizeTableIcon: IconType = (props) => ; -export const CommandIcon: IconType = (props) => ; -export const CopyIcon: IconType = (props) => ; -export const CollectionIcon: IconType = (props) => ; -export const CloseIcon: IconType = (props) => ; -export const CloudUploadIcon: IconType = (props) => ; -export const DefaultIcon: IconType = (props) => ; -export const DetailsIcon: IconType = (props) => ; -export const DirectoryIcon: IconType = (props) => ; -export const DownloadIcon: IconType = (props) => ; -export const EditSavedQueryIcon: IconType = (props) => ; -export const ExpandIcon: IconType = (props) => ; -export const ErrorIcon: IconType = (props) => ; -export const FavoriteIcon: IconType = (props) => ; -export const FileIcon: IconType = (props) => ; -export const HelpIcon: IconType = (props) => ; -export const HelpOutlineIcon: IconType = (props) => ; -export const ImportContactsIcon: IconType = (props) => ; -export const InfoIcon: IconType = (props) => ; -export const FileInputIcon: IconType = (props) => ; -export const KeyIcon: IconType = (props) => ; -export const LogIcon: IconType = (props) => ; -export const MailIcon: IconType = (props) => ; -export const MaximizeIcon: IconType = (props) => ; -export const MemoryIcon: IconType = (props) => ; -export const UnMaximizeIcon: IconType = (props) => ; -export const MoreOptionsIcon: IconType = (props) => ; -export const MoveToIcon: IconType = (props) => ; -export const NewProjectIcon: IconType = (props) => ; -export const NotificationIcon: IconType = (props) => ; -export const OpenIcon: IconType = (props) => ; -export const InputIcon: IconType = (props) => ; -export const PaginationDownIcon: IconType = (props) => ; -export const PaginationLeftArrowIcon: IconType = (props) => ; -export const PaginationRightArrowIcon: IconType = (props) => ; -export const ProcessIcon: IconType = (props) => ; -export const ProjectIcon: IconType = (props) => ; -export const FilterGroupIcon: IconType = (props) => ; -export const ProjectsIcon: IconType = (props) => ; -export const ProvenanceGraphIcon: IconType = (props) => ; -export const RemoveIcon: IconType = (props) => ; -export const RemoveFavoriteIcon: IconType = (props) => ; -export const PublicFavoriteIcon: IconType = (props) => ; -export const RenameIcon: IconType = (props) => ; -export const RestoreVersionIcon: IconType = (props) => ; -export const RestoreFromTrashIcon: IconType = (props) => ; -export const ReRunProcessIcon: IconType = (props) => ; -export const SearchIcon: IconType = (props) => ; -export const ShareIcon: IconType = (props) => ; -export const ShareMeIcon: IconType = (props) => ; -export const SidePanelRightArrowIcon: IconType = (props) => ; -export const TrashIcon: IconType = (props) => ; -export const UserPanelIcon: IconType = (props) => ; -export const UsedByIcon: IconType = (props) => ; -export const WorkflowIcon: IconType = (props) => ; -export const WarningIcon: IconType = (props) => ; -export const Link: IconType = (props) => ; -export const FolderSharedIcon: IconType = (props) => ; -export const CanReadIcon: IconType = (props) => ; -export const CanWriteIcon: IconType = (props) => ; -export const CanManageIcon: IconType = (props) => ; -export const AddUserIcon: IconType = (props) => ; -export const WordWrapOnIcon: IconType = (props) => ; -export const WordWrapOffIcon: IconType = (props) => ; -export const TextIncreaseIcon: IconType = (props) => ; -export const TextDecreaseIcon: IconType = (props) => ; -export const DeactivateUserIcon: IconType = (props) => ; -export const LoginAsIcon: IconType = (props) => ; -export const ActiveIcon: IconType = (props) => ; -export const SetupIcon: IconType = (props) => ; -export const InactiveIcon: IconType = (props) => ; -export const ImageIcon: IconType = (props) => ; -export const StartIcon: IconType = (props) => ; -export const StopIcon: IconType = (props) => ; + +); + +// https://pictogrammers.com/library/mdi/icon/file-move/ +export const FileMoveIcon: IconType = (props: any) => ( + + + +); + +// https://pictogrammers.com/library/mdi/icon/checkbox-multiple-outline/ +export const CheckboxMultipleOutline: IconType = (props: any) => ( + + + +); + +// https://pictogrammers.com/library/mdi/icon/checkbox-multiple-blank-outline/ +export const CheckboxMultipleBlankOutline: IconType = (props: any) => ( + + + +); + +//https://pictogrammers.com/library/mdi/icon/console/ +export const TerminalIcon: IconType = (props: any) => ( + + + +) + +export type IconType = React.SFC<{ className?: string; style?: object }>; + +export const AddIcon: IconType = props => ; +export const AddFavoriteIcon: IconType = props => ; +export const AdminMenuIcon: IconType = props => ; +export const AdvancedIcon: IconType = props => ; +export const AttributesIcon: IconType = props => ; +export const BackIcon: IconType = props => ; +export const CustomizeTableIcon: IconType = props => ; +export const CommandIcon: IconType = props => ; +export const CopyIcon: IconType = props => ; +export const FileCopyIcon: IconType = props => ; +export const CollectionIcon: IconType = props => ; +export const CloseIcon: IconType = props => ; +export const CloudUploadIcon: IconType = props => ; +export const DefaultIcon: IconType = props => ; +export const DetailsIcon: IconType = props => ; +export const DirectoryIcon: IconType = props => ; +export const DownloadIcon: IconType = props => ; +export const EditSavedQueryIcon: IconType = props => ; +export const ExpandIcon: IconType = props => ; +export const ErrorIcon: IconType = props => ( + +); +export const FavoriteIcon: IconType = props => ; +export const FileIcon: IconType = props => ; +export const HelpIcon: IconType = props => ; +export const HelpOutlineIcon: IconType = props => ; +export const ImportContactsIcon: IconType = props => ; +export const InfoIcon: IconType = props => ; +export const FileInputIcon: IconType = props => ; +export const KeyIcon: IconType = props => ; +export const LogIcon: IconType = props => ; +export const MailIcon: IconType = props => ; +export const MaximizeIcon: IconType = props => ; +export const MemoryIcon: IconType = props => ; +export const UnMaximizeIcon: IconType = props => ; +export const MoreVerticalIcon: IconType = props => ; +export const MoreHorizontalIcon: IconType = props => ; +export const MoveToIcon: IconType = props => ; +export const NewProjectIcon: IconType = props => ; +export const NotificationIcon: IconType = props => ; +export const OpenIcon: IconType = props => ; +export const InputIcon: IconType = props => ; +export const PaginationDownIcon: IconType = props => ; +export const PaginationLeftArrowIcon: IconType = props => ; +export const PaginationRightArrowIcon: IconType = props => ; +export const ProcessIcon: IconType = props => ; +export const ProjectIcon: IconType = props => ; +export const FilterGroupIcon: IconType = props => ; +export const ProjectsIcon: IconType = props => ; +export const ProvenanceGraphIcon: IconType = props => ; +export const RemoveIcon: IconType = props => ; +export const RemoveFavoriteIcon: IconType = props => ; +export const PublicFavoriteIcon: IconType = props => ; +export const RenameIcon: IconType = props => ; +export const RestoreVersionIcon: IconType = props => ; +export const RestoreFromTrashIcon: IconType = props => ; +export const ReRunProcessIcon: IconType = props => ; +export const SearchIcon: IconType = props => ; +export const ShareIcon: IconType = props => ; +export const ShareMeIcon: IconType = props => ; +export const SidePanelRightArrowIcon: IconType = props => ; +export const TrashIcon: IconType = props => ; +export const UserPanelIcon: IconType = props => ; +export const UsedByIcon: IconType = props => ; +export const WorkflowIcon: IconType = props => ; +export const WarningIcon: IconType = props => ( + +); +export const Link: IconType = props => ; +export const FolderSharedIcon: IconType = props => ; +export const CanReadIcon: IconType = props => ; +export const CanWriteIcon: IconType = props => ; +export const CanManageIcon: IconType = props => ; +export const AddUserIcon: IconType = props => ; +export const WordWrapOnIcon: IconType = props => ; +export const WordWrapOffIcon: IconType = props => ; +export const TextIncreaseIcon: IconType = props => ; +export const TextDecreaseIcon: IconType = props => ; +export const DeactivateUserIcon: IconType = props => ; +export const LoginAsIcon: IconType = props => ; +export const ActiveIcon: IconType = props => ; +export const SetupIcon: IconType = props => ; +export const InactiveIcon: IconType = props => ; +export const ImageIcon: IconType = props => ; +export const StartIcon: IconType = props => ; +export const StopIcon: IconType = props => ; +export const SelectAllIcon: IconType = props => ; +export const SelectNoneIcon: IconType = props => ; diff --git a/src/components/multiselect-toolbar/MultiselectToolbar.tsx b/src/components/multiselect-toolbar/MultiselectToolbar.tsx new file mode 100644 index 00000000..f92c0dcf --- /dev/null +++ b/src/components/multiselect-toolbar/MultiselectToolbar.tsx @@ -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 = (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 + 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) => { + 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 ( + + + {actions.length ? ( + actions.map((action, i) =>{ + const { hasAlts, useAlts, name, altName, icon, altIcon } = action; + return hasAlts ? ( + + + props.executeMulti(action, checkedList, iconProps.resources)} + > + {currentPathIsTrash || (useAlts && useAlts(singleSelectedUuid, iconProps)) ? altIcon && altIcon({}) : icon({})} + + + + ) : ( + + + props.executeMulti(action, checkedList, iconProps.resources)} + > + {action.icon({})} + + + + ); + }) + ) : ( + <> + )} + + + ) + }) +); + +export function selectedToArray(checkedList: TCheckedList): Array { + const arrayifiedSelectedList: Array = []; + for (const [key, value] of Object.entries(checkedList)) { + if (value === true) { + arrayifiedSelectedList.push(key); + } + } + return arrayifiedSelectedList; +} + +export function selectedToKindSet(checkedList: TCheckedList): Set { + const setifiedList = new Set(); + 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 { + 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): Array { + 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(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(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, filterSet: TMultiselectActionsFilters) { + const rawResult: Set = 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(); + 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(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(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 index 00000000..5a84d4c5 --- /dev/null +++ b/src/components/multiselect-toolbar/ms-kind-action-differentiator.ts @@ -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 = { + [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 index 00000000..b34cc22c --- /dev/null +++ b/src/components/multiselect-toolbar/ms-toolbar-action-filters.ts @@ -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]>; + +const allActionNames = (actionSet: MultiSelectMenuActionSet): Set => 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], +}; diff --git a/src/components/select-field/select-field.tsx b/src/components/select-field/select-field.tsx index 6fa7ddea..bea06496 100644 --- a/src/components/select-field/select-field.tsx +++ b/src/components/select-field/select-field.tsx @@ -11,55 +11,58 @@ type CssRules = 'formControl' | 'selectWrapper' | 'select' | 'option'; const styles: StyleRulesCallback = (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 & { items: any[] }) => - - - - ); +export const NativeSelectField = withStyles(styles)((props: WrappedFieldProps & NativeSelectFieldProps & WithStyles & { items: any[] }) => ( + + + +)); interface SelectFieldProps { children: React.ReactNode; @@ -70,19 +73,15 @@ type SelectFieldCssRules = 'formControl'; const selectFieldStyles: StyleRulesCallback = (theme: ArvadosTheme) => ({ formControl: { - marginBottom: theme.spacing.unit * 3 + marginBottom: theme.spacing.unit * 3, }, }); -export const SelectField = withStyles(selectFieldStyles)( - (props: WrappedFieldProps & SelectFieldProps & WithStyles) => - - - {props.label} - - - {props.meta.error} - -); +export const SelectField = withStyles(selectFieldStyles)((props: WrappedFieldProps & SelectFieldProps & WithStyles) => ( + + {props.label} + + {props.meta.error} + +)); 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 index 00000000..bd8603f9 --- /dev/null +++ b/src/components/subprocess-progress-bar/subprocess-progress-bar.test.tsx @@ -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("", () => { + 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( + + + ); + }); + 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( + + + ); + }); + 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 index 00000000..b21d8791 --- /dev/null +++ b/src/components/subprocess-progress-bar/subprocess-progress-bar.tsx @@ -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 = (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; +} + +type ProgressBarProps = ProgressBarDataProps & ProgressBarActionProps & WithStyles; + +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(fetchSubprocessProgress(requestingContainerUuid)); + }, +}); + +export const SubprocessProgressBar = connect(null, mapDispatchToProps)(withStyles(styles)( + ({process, classes, fetchSubprocessProgress}: ProgressBarProps) => { + + const [progressData, setProgressData] = useState(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 ?
+ + + + + + + + + + + + + + +
: <>; + } +)); + +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); diff --git a/src/components/tree/tree.tsx b/src/components/tree/tree.tsx index e3708621..11a95402 100644 --- a/src/components/tree/tree.tsx +++ b/src/components/tree/tree.tsx @@ -2,10 +2,10 @@ // // SPDX-License-Identifier: AGPL-3.0 -import React from 'react'; +import React, { useCallback, useState } from 'react'; import { List, ListItem, ListItemIcon, Checkbox, Radio, Collapse } from "@material-ui/core"; import { StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core/styles'; -import { CollectionIcon, DefaultIcon, DirectoryIcon, FileIcon, ProjectIcon, FilterGroupIcon, FreezeIcon } from 'components/icon/icon'; +import { CollectionIcon, DefaultIcon, DirectoryIcon, FileIcon, ProjectIcon, ProcessIcon, FilterGroupIcon, FreezeIcon } from 'components/icon/icon'; import { ReactElement } from "react"; import CircularProgress from '@material-ui/core/CircularProgress'; import classnames from "classnames"; @@ -14,6 +14,7 @@ import { ArvadosTheme } from 'common/custom-theme'; import { SidePanelRightArrowIcon } from '../icon/icon'; import { ResourceKind } from 'models/resource'; import { GroupClass } from 'models/group'; +import { SidePanelTreeCategory } from 'store/side-panel-tree/side-panel-tree-actions'; type CssRules = 'list' | 'listItem' @@ -27,7 +28,8 @@ type CssRules = 'list' | 'checkbox' | 'childItem' | 'childItemIcon' - | 'frozenIcon'; + | 'frozenIcon' + | 'indentSpacer'; const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ list: { @@ -45,9 +47,10 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ color: theme.palette.grey["700"], height: '14px', width: '14px', + marginBottom: '0.4rem', }, toggableIcon: { - fontSize: '14px' + fontSize: '14px', }, renderContainer: { flex: 1 @@ -89,6 +92,9 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ color: theme.palette.grey["600"], marginLeft: '10px', }, + indentSpacer: { + width: '0.25rem' + } }); export enum TreeItemStatus { @@ -99,6 +105,7 @@ export enum TreeItemStatus { export interface TreeItem { data: T; + depth?: number; id: string; open: boolean; active: boolean; @@ -125,6 +132,7 @@ export interface TreeProps { toggleItemActive: (event: React.MouseEvent, item: TreeItem) => void; toggleItemOpen: (event: React.MouseEvent, item: TreeItem) => void; toggleItemSelection?: (event: React.MouseEvent, item: TreeItem) => void; + selectedRef?: (node: HTMLDivElement | null) => void; /** * When set to true use radio buttons instead of checkboxes for item selection. @@ -154,6 +162,10 @@ const getActionAndId = (event: any, initAction: string | undefined = undefined) return [action, id]; }; +const isInFavoritesTree = (item: TreeItem): boolean => { + return item.id === SidePanelTreeCategory.FAVORITES || item.id === SidePanelTreeCategory.PUBLIC_FAVORITES; +} + interface FlatTreeProps { it: TreeItem; levelIndentation: number; @@ -167,6 +179,7 @@ interface FlatTreeProps { showSelection: any; useRadioButtons?: boolean; handleCheckboxChange: Function; + selectedRef?: (node: HTMLDivElement | null) => void; } const FLAT_TREE_ACTIONS = { @@ -175,7 +188,7 @@ const FLAT_TREE_ACTIONS = { toggleActive: 'TOGGLE_ACTIVE', }; -const ItemIcon = React.memo(({ type, kind, active, groupClass, classes }: any) => { +const ItemIcon = React.memo(({ type, kind, headKind, active, groupClass, classes }: any) => { let Icon = ProjectIcon; if (groupClass === GroupClass.FILTER) { @@ -196,10 +209,14 @@ const ItemIcon = React.memo(({ type, kind, active, groupClass, classes }: any) = } if (kind) { + if(kind === ResourceKind.LINK && headKind) kind = headKind; switch (kind) { case ResourceKind.COLLECTION: Icon = CollectionIcon; break; + case ResourceKind.CONTAINER_REQUEST: + Icon = ProcessIcon; + break; default: break; } @@ -238,11 +255,14 @@ const FlatTree = (props: FlatTreeProps) => .map((item: any) =>
- - - {props.getProperArrowAnimation(item.status, item.items!)} - - + {isInFavoritesTree(props.it) ? +
+ : + + + {props.getProperArrowAnimation(item.status, item.items!)} + + } {props.showSelection(item) && !props.useRadioButtons && checked={item.selected} className={props.classes.checkbox} color="primary" />} -
+
- + {item.data.name} @@ -270,98 +290,26 @@ const FlatTree = (props: FlatTreeProps) =>
; export const Tree = withStyles(styles)( - class Component extends React.Component & WithStyles, {}> { - render(): ReactElement { - 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 - {items && items.map((it: TreeItem, idx: number) => -
- toggleItemActive(event, it)} - selected={showSelection(it) && it.id === currentItemUuid} - onContextMenu={(event) => this.props.onContextMenu(event, it)}> - {it.status === TreeItemStatus.PENDING ? - : null} - this.handleToggleItemOpen(it, e)} - className={toggableIconContainer}> - - {this.getProperArrowAnimation(it.status, it.items!)} - - - {showSelection(it) && !useRadioButtons && - } - {showSelection(it) && useRadioButtons && - } -
- {render(it, level)} -
-
- { - it.open && it.items && it.items.length > 0 && - it.flatTree ? - : - - - - } -
)} -
; - } + function(props: TreeProps & WithStyles) { + 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>) => { - return this.isSidePanelIconNotNeeded(status, items) ? : ; + const getProperArrowAnimation = (status: string, items: Array>) => { + return isSidePanelIconNotNeeded(status, items) ? : ; } - isSidePanelIconNotNeeded = (status: string, items: Array>) => { + const isSidePanelIconNotNeeded = (status: string, items: Array>) => { return status === TreeItemStatus.PENDING || (status === TreeItemStatus.LOADED && !items) || (status === TreeItemStatus.LOADED && items && items.length === 0); } - getToggableIconClassNames = (isOpen?: boolean, isActive?: boolean) => { - const { iconOpen, iconClose, active, toggableIcon } = this.props.classes; + const getToggableIconClassNames = (isOpen?: boolean, isActive?: boolean) => { + const { iconOpen, iconClose, active, toggableIcon } = props.classes; return classnames(toggableIcon, { [iconOpen]: isOpen, [iconClose]: !isOpen, @@ -369,8 +317,8 @@ export const Tree = withStyles(styles)( }); } - handleCheckboxChange = (item: TreeItem) => { - const { toggleItemSelection } = this.props; + const handleCheckboxChange = (item: TreeItem) => { + const { toggleItemSelection } = props; return toggleItemSelection ? (event: React.MouseEvent) => { event.stopPropagation(); @@ -379,9 +327,95 @@ export const Tree = withStyles(styles)( : undefined; } - handleToggleItemOpen = (item: TreeItem, event: React.MouseEvent) => { + const handleToggleItemOpen = (item: TreeItem, event: React.MouseEvent) => { 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(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 + {items && items.map((it: TreeItem, 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
+ toggleItemActive(event, it)} + selected={showSelection(it) && it.id === currentItemUuid} + onContextMenu={(event) => props.onContextMenu(event, it)}> + {it.status === TreeItemStatus.PENDING ? + : null} + handleToggleItemOpen(it, e)} + className={toggableIconContainer}> + + {getProperArrowAnimation(it.status, it.items!)} + + + {showSelection(it) && !useRadioButtons && + } + {showSelection(it) && useRadioButtons && + } +
+ {render(it, level)} +
+
+ { + it.open && it.items && it.items.length > 0 && + it.flatTree ? + : + + + + } +
; + })} +
; } ); diff --git a/src/index.tsx b/src/index.tsx index 244d1387..ef9ff9c9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,71 +2,97 @@ // // SPDX-License-Identifier: AGPL-3.0 -import React from 'react'; -import ReactDOM from 'react-dom'; +import React from "react"; +import ReactDOM from "react-dom"; import { Provider } from "react-redux"; -import { MainPanel } from 'views/main-panel/main-panel'; -import 'index.css'; -import { Route, Switch } from 'react-router'; +import { MainPanel } from "views/main-panel/main-panel"; +import "index.css"; +import { Route, Switch } from "react-router"; import { createBrowserHistory } from "history"; import { History } from "history"; -import { configureStore, RootStore } from 'store/store'; +import { configureStore, RootStore } from "store/store"; import { ConnectedRouter } from "react-router-redux"; import { ApiToken } from "views-components/api-token/api-token"; import { AddSession } from "views-components/add-session/add-session"; import { initAuth, logout } from "store/auth/auth-action"; import { createServices } from "services/services"; -import { MuiThemeProvider } from '@material-ui/core/styles'; -import { CustomTheme } from 'common/custom-theme'; -import { fetchConfig } from 'common/config'; -import servicesProvider from 'common/service-provider'; -import { addMenuActionSet, ContextMenuKind } from 'views-components/context-menu/context-menu'; +import { MuiThemeProvider } from "@material-ui/core/styles"; +import { CustomTheme } from "common/custom-theme"; +import { fetchConfig } from "common/config"; +import servicesProvider from "common/service-provider"; +import { addMenuActionSet, ContextMenuKind } from "views-components/context-menu/context-menu"; import { rootProjectActionSet } from "views-components/context-menu/action-sets/root-project-action-set"; -import { filterGroupActionSet, frozenActionSet, projectActionSet, readOnlyProjectActionSet } from "views-components/context-menu/action-sets/project-action-set"; -import { resourceActionSet } from 'views-components/context-menu/action-sets/resource-action-set'; +import { + filterGroupActionSet, + frozenActionSet, + projectActionSet, + readOnlyProjectActionSet, +} from "views-components/context-menu/action-sets/project-action-set"; +import { resourceActionSet } from "views-components/context-menu/action-sets/resource-action-set"; import { favoriteActionSet } from "views-components/context-menu/action-sets/favorite-action-set"; -import { collectionFilesActionSet, readOnlyCollectionFilesActionSet } from 'views-components/context-menu/action-sets/collection-files-action-set'; -import { collectionDirectoryItemActionSet, collectionFileItemActionSet, readOnlyCollectionDirectoryItemActionSet, readOnlyCollectionFileItemActionSet } from 'views-components/context-menu/action-sets/collection-files-item-action-set'; -import { collectionFilesNotSelectedActionSet } from 'views-components/context-menu/action-sets/collection-files-not-selected-action-set'; -import { collectionActionSet, collectionAdminActionSet, oldCollectionVersionActionSet, readOnlyCollectionActionSet } from 'views-components/context-menu/action-sets/collection-action-set'; -import { loadWorkbench } from 'store/workbench/workbench-actions'; -import { Routes } from 'routes/routes'; +import { + collectionFilesActionSet, + collectionFilesMultipleActionSet, + readOnlyCollectionFilesActionSet, + readOnlyCollectionFilesMultipleActionSet, +} from "views-components/context-menu/action-sets/collection-files-action-set"; +import { + collectionDirectoryItemActionSet, + collectionFileItemActionSet, + readOnlyCollectionDirectoryItemActionSet, + readOnlyCollectionFileItemActionSet, +} from "views-components/context-menu/action-sets/collection-files-item-action-set"; +import { collectionFilesNotSelectedActionSet } from "views-components/context-menu/action-sets/collection-files-not-selected-action-set"; +import { + collectionActionSet, + collectionAdminActionSet, + oldCollectionVersionActionSet, + readOnlyCollectionActionSet, +} from "views-components/context-menu/action-sets/collection-action-set"; +import { loadWorkbench } from "store/workbench/workbench-actions"; +import { Routes } from "routes/routes"; import { trashActionSet } from "views-components/context-menu/action-sets/trash-action-set"; -import { ServiceRepository } from 'services/services'; -import { initWebSocket } from 'websocket/websocket'; -import { Config } from 'common/config'; -import { addRouteChangeHandlers } from './routes/route-change-handlers'; -import { setTokenDialogApiHost } from 'store/token-dialog/token-dialog-actions'; +import { ServiceRepository } from "services/services"; +import { initWebSocket } from "websocket/websocket"; +import { Config } from "common/config"; +import { addRouteChangeHandlers } from "./routes/route-change-handlers"; +import { setTokenDialogApiHost } from "store/token-dialog/token-dialog-actions"; import { processResourceActionSet, + runningProcessResourceActionSet, processResourceAdminActionSet, - readOnlyProcessResourceActionSet -} from 'views-components/context-menu/action-sets/process-resource-action-set'; -import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions'; -import { trashedCollectionActionSet } from 'views-components/context-menu/action-sets/trashed-collection-action-set'; -import { setBuildInfo } from 'store/app-info/app-info-actions'; -import { getBuildInfo } from 'common/app-info'; -import { DragDropContextProvider } from 'react-dnd'; -import HTML5Backend from 'react-dnd-html5-backend'; -import { initAdvancedFormProjectsTree } from 'store/search-bar/search-bar-actions'; -import { repositoryActionSet } from 'views-components/context-menu/action-sets/repository-action-set'; -import { sshKeyActionSet } from 'views-components/context-menu/action-sets/ssh-key-action-set'; -import { keepServiceActionSet } from 'views-components/context-menu/action-sets/keep-service-action-set'; -import { loadVocabulary } from 'store/vocabulary/vocabulary-actions'; -import { virtualMachineActionSet } from 'views-components/context-menu/action-sets/virtual-machine-action-set'; -import { userActionSet } from 'views-components/context-menu/action-sets/user-action-set'; -import { apiClientAuthorizationActionSet } from 'views-components/context-menu/action-sets/api-client-authorization-action-set'; -import { groupActionSet } from 'views-components/context-menu/action-sets/group-action-set'; -import { groupMemberActionSet } from 'views-components/context-menu/action-sets/group-member-action-set'; -import { linkActionSet } from 'views-components/context-menu/action-sets/link-action-set'; -import { loadFileViewersConfig } from 'store/file-viewers/file-viewers-actions'; -import { filterGroupAdminActionSet, frozenAdminActionSet, projectAdminActionSet } from 'views-components/context-menu/action-sets/project-admin-action-set'; -import { permissionEditActionSet } from 'views-components/context-menu/action-sets/permission-edit-action-set'; -import { workflowActionSet } from 'views-components/context-menu/action-sets/workflow-action-set'; -import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions"; -import { openNotFoundDialog } from './store/not-found-panel/not-found-panel-action'; -import { storeRedirects } from './common/redirect-to'; -import { searchResultsActionSet } from 'views-components/context-menu/action-sets/search-results-action-set'; + runningProcessResourceAdminActionSet, + readOnlyProcessResourceActionSet, +} from "views-components/context-menu/action-sets/process-resource-action-set"; +import { trashedCollectionActionSet } from "views-components/context-menu/action-sets/trashed-collection-action-set"; +import { setBuildInfo } from "store/app-info/app-info-actions"; +import { getBuildInfo } from "common/app-info"; +import { DragDropContextProvider } from "react-dnd"; +import HTML5Backend from "react-dnd-html5-backend"; +import { initAdvancedFormProjectsTree } from "store/search-bar/search-bar-actions"; +import { repositoryActionSet } from "views-components/context-menu/action-sets/repository-action-set"; +import { sshKeyActionSet } from "views-components/context-menu/action-sets/ssh-key-action-set"; +import { keepServiceActionSet } from "views-components/context-menu/action-sets/keep-service-action-set"; +import { loadVocabulary } from "store/vocabulary/vocabulary-actions"; +import { virtualMachineActionSet } from "views-components/context-menu/action-sets/virtual-machine-action-set"; +import { userActionSet } from "views-components/context-menu/action-sets/user-action-set"; +import { apiClientAuthorizationActionSet } from "views-components/context-menu/action-sets/api-client-authorization-action-set"; +import { groupActionSet } from "views-components/context-menu/action-sets/group-action-set"; +import { groupMemberActionSet } from "views-components/context-menu/action-sets/group-member-action-set"; +import { linkActionSet } from "views-components/context-menu/action-sets/link-action-set"; +import { loadFileViewersConfig } from "store/file-viewers/file-viewers-actions"; +import { + filterGroupAdminActionSet, + frozenAdminActionSet, + projectAdminActionSet, +} from "views-components/context-menu/action-sets/project-admin-action-set"; +import { permissionEditActionSet } from "views-components/context-menu/action-sets/permission-edit-action-set"; +import { workflowActionSet, readOnlyWorkflowActionSet } from "views-components/context-menu/action-sets/workflow-action-set"; +import { storeRedirects } from "./common/redirect-to"; +import { searchResultsActionSet } from "views-components/context-menu/action-sets/search-results-action-set"; + +import 'bootstrap/dist/css/bootstrap.min.css'; +import '@coreui/coreui/dist/css/coreui.min.css'; console.log(`Starting arvados [${getBuildInfo()}]`); @@ -77,7 +103,9 @@ addMenuActionSet(ContextMenuKind.FILTER_GROUP, filterGroupActionSet); addMenuActionSet(ContextMenuKind.RESOURCE, resourceActionSet); addMenuActionSet(ContextMenuKind.FAVORITE, favoriteActionSet); addMenuActionSet(ContextMenuKind.COLLECTION_FILES, collectionFilesActionSet); +addMenuActionSet(ContextMenuKind.COLLECTION_FILES_MULTIPLE, collectionFilesMultipleActionSet); addMenuActionSet(ContextMenuKind.READONLY_COLLECTION_FILES, readOnlyCollectionFilesActionSet); +addMenuActionSet(ContextMenuKind.READONLY_COLLECTION_FILES_MULTIPLE, readOnlyCollectionFilesMultipleActionSet); addMenuActionSet(ContextMenuKind.COLLECTION_FILES_NOT_SELECTED, collectionFilesNotSelectedActionSet); addMenuActionSet(ContextMenuKind.COLLECTION_DIRECTORY_ITEM, collectionDirectoryItemActionSet); addMenuActionSet(ContextMenuKind.READONLY_COLLECTION_DIRECTORY_ITEM, readOnlyCollectionDirectoryItemActionSet); @@ -88,6 +116,7 @@ addMenuActionSet(ContextMenuKind.READONLY_COLLECTION, readOnlyCollectionActionSe addMenuActionSet(ContextMenuKind.OLD_VERSION_COLLECTION, oldCollectionVersionActionSet); addMenuActionSet(ContextMenuKind.TRASHED_COLLECTION, trashedCollectionActionSet); addMenuActionSet(ContextMenuKind.PROCESS_RESOURCE, processResourceActionSet); +addMenuActionSet(ContextMenuKind.RUNNING_PROCESS_RESOURCE, runningProcessResourceActionSet); addMenuActionSet(ContextMenuKind.READONLY_PROCESS_RESOURCE, readOnlyProcessResourceActionSet); addMenuActionSet(ContextMenuKind.TRASH, trashActionSet); addMenuActionSet(ContextMenuKind.REPOSITORY, repositoryActionSet); @@ -101,94 +130,106 @@ addMenuActionSet(ContextMenuKind.GROUPS, groupActionSet); addMenuActionSet(ContextMenuKind.GROUP_MEMBER, groupMemberActionSet); addMenuActionSet(ContextMenuKind.COLLECTION_ADMIN, collectionAdminActionSet); addMenuActionSet(ContextMenuKind.PROCESS_ADMIN, processResourceAdminActionSet); +addMenuActionSet(ContextMenuKind.RUNNING_PROCESS_ADMIN, runningProcessResourceAdminActionSet); addMenuActionSet(ContextMenuKind.PROJECT_ADMIN, projectAdminActionSet); addMenuActionSet(ContextMenuKind.FROZEN_PROJECT, frozenActionSet); addMenuActionSet(ContextMenuKind.FROZEN_PROJECT_ADMIN, frozenAdminActionSet); addMenuActionSet(ContextMenuKind.FILTER_GROUP_ADMIN, filterGroupAdminActionSet); addMenuActionSet(ContextMenuKind.PERMISSION_EDIT, permissionEditActionSet); +addMenuActionSet(ContextMenuKind.READONLY_WORKFLOW, readOnlyWorkflowActionSet); addMenuActionSet(ContextMenuKind.WORKFLOW, workflowActionSet); addMenuActionSet(ContextMenuKind.SEARCH_RESULTS, searchResultsActionSet); storeRedirects(); -fetchConfig() - .then(({ config, apiHost }) => { - const history = createBrowserHistory(); - - // Provide browser's history access to Cypress to allow programmatic - // navigation. - if ((window as any).Cypress) { - (window as any).appHistory = history; - } - - const services = createServices(config, { - progressFn: (id, working) => { - store.dispatch(progressIndicatorActions.TOGGLE_WORKING({ id, working })); - }, - errorFn: (id, error, showSnackBar: boolean) => { - if (showSnackBar) { - console.error("Backend error:", error); - - if (error.status === 404) { - store.dispatch(openNotFoundDialog()); - } else if (error.status === 401 && error.errors[0].indexOf("Not logged in") > -1) { - // Catch auth errors when navigating and redirect to login preserving url location - store.dispatch(logout(false, true)); - } else { - store.dispatch(snackbarActions.OPEN_SNACKBAR({ - message: `${error.errors - ? error.errors[0] - : error.message}`, - kind: SnackbarKind.ERROR, - hideDuration: 8000 - }) - ); - } +fetchConfig().then(({ config, apiHost }) => { + const history = createBrowserHistory(); + + // Provide browser's history access to Cypress to allow programmatic + // navigation. + if ((window as any).Cypress) { + (window as any).appHistory = history; + } + + const services = createServices(config, { + progressFn: (id, working) => { + }, + errorFn: (id, error, showSnackBar: boolean) => { + if (showSnackBar) { + console.error("Backend error:", error); + if (error.status === 401 && error.errors[0].indexOf("Not logged in") > -1) { + // Catch auth errors when navigating and redirect to login preserving url location + store.dispatch(logout(false, true)); } } - }); - - // be sure this is initiated before the app starts - servicesProvider.setServices(services); - - const store = configureStore(history, services, config); - - servicesProvider.setStore(store); - - store.subscribe(initListener(history, store, services, config)); - store.dispatch(initAuth(config)); - store.dispatch(setBuildInfo()); - store.dispatch(setTokenDialogApiHost(apiHost)); - store.dispatch(loadVocabulary); - store.dispatch(loadFileViewersConfig); - - const TokenComponent = (props: any) => ; - const AddSessionComponent = (props: any) => ; - const FedTokenComponent = (props: any) => ; - const MainPanelComponent = (props: any) => ; - - const App = () => - - - - - - - - - - - - - - ; - - ReactDOM.render( - , - 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) => ( + + ); + const AddSessionComponent = (props: any) => ; + const FedTokenComponent = (props: any) => ( + + ); + const MainPanelComponent = (props: any) => ; + + const App = () => ( + + + + + + + + + + + + + + + ); + + ReactDOM.render(, document.getElementById("root") as HTMLElement); +}); + const initListener = (history: History, store: RootStore, services: ServiceRepository, config: Config) => { let initialized = false; return async () => { diff --git a/src/models/collection-file.ts b/src/models/collection-file.ts index 91008d1f..3688557a 100644 --- a/src/models/collection-file.ts +++ b/src/models/collection-file.ts @@ -71,10 +71,10 @@ export const createCollectionFilesTree = (data: Array 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 +); diff --git a/src/models/container-request.ts b/src/models/container-request.ts index aa5e0f79..d3adb03a 100644 --- a/src/models/container-request.ts +++ b/src/models/container-request.ts @@ -8,41 +8,41 @@ import { RuntimeConstraints } from './runtime-constraints'; import { SchedulingParameters } from './scheduling-parameters'; export enum ContainerRequestState { - UNCOMMITTED = 'Uncommitted', - COMMITTED = 'Committed', - FINAL = 'Final', + UNCOMMITTED = 'Uncommitted', + COMMITTED = 'Committed', + FINAL = 'Final', } export interface ContainerRequestResource - extends Resource, + extends Resource, ResourceWithProperties { - command: string[]; - containerCountMax: number; - containerCount: number; - containerImage: string; - containerUuid: string | null; - cumulativeCost: number; - cwd: string; - description: string; - environment: any; - expiresAt: string; - filters: string; - kind: ResourceKind.CONTAINER_REQUEST; - logUuid: string | null; - mounts: { [path: string]: MountType }; - name: string; - outputName: string; - outputPath: string; - outputProperties: any; - outputStorageClasses: string[]; - outputTtl: number; - outputUuid: string | null; - priority: number | null; - requestingContainerUuid: string | null; - runtimeConstraints: RuntimeConstraints; - schedulingParameters: SchedulingParameters; - state: ContainerRequestState; - useExisting: boolean; + command: string[]; + containerCountMax: number; + containerCount: number; + containerImage: string; + containerUuid: string | null; + cumulativeCost: number; + cwd: string; + description: string; + environment: any; + expiresAt: string; + filters: string; + kind: ResourceKind.CONTAINER_REQUEST; + logUuid: string | null; + mounts: { [path: string]: MountType }; + name: string; + outputName: string; + outputPath: string; + outputProperties: any; + outputStorageClasses: string[]; + outputTtl: number; + outputUuid: string | null; + priority: number | null; + requestingContainerUuid: string | null; + runtimeConstraints: RuntimeConstraints; + schedulingParameters: SchedulingParameters; + state: ContainerRequestState; + useExisting: boolean; } // Until the api supports unselecting fields, we need a list of all other fields to omit mounts @@ -53,6 +53,7 @@ export const containerRequestFieldsNoMounts = [ "container_image", "container_uuid", "created_at", + "cumulative_cost", "cwd", "description", "environment", diff --git a/src/models/group.ts b/src/models/group.ts index f6a72c53..0932b3c9 100644 --- a/src/models/group.ts +++ b/src/models/group.ts @@ -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(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" +]; diff --git a/src/models/log.ts b/src/models/log.ts index 0109ad61..f5d351ac 100644 --- a/src/models/log.ts +++ b/src/models/log.ts @@ -18,7 +18,6 @@ export enum LogEventType { STDERR = 'stderr', CONTAINER = 'container', KEEPSTORE = 'keepstore', - SNIP = 'snip-line', // This type is for internal use only. See #19851 } export interface LogResource extends Resource, ResourceWithProperties { diff --git a/src/models/project.ts b/src/models/project.ts index 04dae4d2..8dd2e716 100644 --- a/src/models/project.ts +++ b/src/models/project.ts @@ -5,8 +5,7 @@ import { GroupClass, GroupResource } from "./group"; export interface ProjectResource extends GroupResource { - frozenByUuid: null|string; - canManage: boolean; + frozenByUuid: null | string; groupClass: GroupClass.PROJECT | GroupClass.FILTER | GroupClass.ROLE; } diff --git a/src/models/test-utils.ts b/src/models/test-utils.ts index 1e1041a1..74667a91 100644 --- a/src/models/test-utils.ts +++ b/src/models/test-utils.ts @@ -23,8 +23,9 @@ export const mockGroupResource = (data: Partial = {}): GroupResou properties: "", trashAt: "", uuid: "", - writableBy: [], ensure_unique_name: true, + canWrite: false, + canManage: false, ...data }); diff --git a/src/models/tree.test.ts b/src/models/tree.test.ts index 3c7fdca9..0e8063b0 100644 --- a/src/models/tree.test.ts +++ b/src/models/tree.test.ts @@ -99,4 +99,35 @@ describe('Tree', () => { const mappedTree = Tree.mapTreeValues(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); + }); }); diff --git a/src/models/tree.ts b/src/models/tree.ts index 996f98a4..aeb41541 100644 --- a/src/models/tree.ts +++ b/src/models/tree.ts @@ -138,6 +138,11 @@ export const deactivateNode = (tree: Tree) => export const expandNode = (...ids: string[]) => (tree: Tree) => mapTree((node: TreeNode) => ids.some(id => id === node.id) ? { ...node, expanded: true } : node)(tree); +export const expandNodeAncestors = (...ids: string[]) => (tree: Tree) => { + const ancestors = ids.reduce((acc, id): string[] => ([...acc, ...getNodeAncestorsIds(id)(tree)]), [] as string[]); + return mapTree((node: TreeNode) => ancestors.some(id => id === node.id) ? { ...node, expanded: true } : node)(tree); +} + export const collapseNode = (...ids: string[]) => (tree: Tree) => mapTree((node: TreeNode) => ids.some(id => id === node.id) ? { ...node, expanded: false } : node)(tree); @@ -151,37 +156,40 @@ export const setNodeStatus = (id: string) => (status: TreeNodeStatus) => (tre : tree; }; -export const toggleNodeSelection = (id: string) => (tree: Tree) => { +export const toggleNodeSelection = (id: string, cascade: boolean) => (tree: Tree) => { 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) => (tree: Tree) => { +export const selectNode = (id: string, cascade: boolean) => (tree: Tree) => { const node = getNode(id)(tree); return node && node.selected ? tree - : toggleNodeSelection(id)(tree); + : toggleNodeSelection(id, cascade)(tree); }; -export const selectNodes = (id: string | string[]) => (tree: Tree) => { +export const selectNodes = (id: string | string[], cascade: boolean) => (tree: Tree) => { 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) => (tree: Tree) => { +export const deselectNode = (id: string, cascade: boolean) => (tree: Tree) => { const node = getNode(id)(tree); return node && node.selected - ? toggleNodeSelection(id)(tree) + ? toggleNodeSelection(id, cascade)(tree) : tree; }; -export const deselectNodes = (id: string | string[]) => (tree: Tree) => { +export const deselectNodes = (id: string | string[], cascade: boolean) => (tree: Tree) => { 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 = (tree: Tree) => diff --git a/src/models/user.ts b/src/models/user.ts index 87a2e8c1..0df6eac2 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -24,6 +24,8 @@ export interface User { prefs: UserPrefs; isAdmin: boolean; isActive: boolean; + canWrite: boolean; + canManage: boolean; } export const getUserFullname = (user: User) => { @@ -62,5 +64,4 @@ export const getUserClusterID = (user: User): string | undefined => { export interface UserResource extends Resource, User { kind: ResourceKind.USER; defaultOwnerUuid: string; - writableBy: string[]; } diff --git a/src/models/workflow.ts b/src/models/workflow.ts index e85dce7a..369db4c7 100644 --- a/src/models/workflow.ts +++ b/src/models/workflow.ts @@ -185,10 +185,31 @@ export const isPrimitiveOfType = (input: GenericCommandInputParameter, : input.type === type; export const isArrayOfType = (input: GenericCommandInputParameter, 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) => { + 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') { diff --git a/src/routes/route-change-handlers.ts b/src/routes/route-change-handlers.ts index 237b6d96..bdc1ddc0 100644 --- a/src/routes/route-change-handlers.ts +++ b/src/routes/route-change-handlers.ts @@ -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)); } }; diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 22c8f4c8..4dfd998e 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -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(route, { path: Routes.REGISTEREDWORKFLOW }); + export const matchProjectRoute = (route: string) => matchPath(route, { path: Routes.PROJECTS }); diff --git a/src/services/ancestors-service/ancestors-service.ts b/src/services/ancestors-service/ancestors-service.ts index 90a0bf84..188c233e 100644 --- a/src/services/ancestors-service/ancestors-service.ts +++ b/src/services/ancestors-service/ancestors-service.ts @@ -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> { + async ancestors(startUuid: string, endUuid: string): Promise> { return this._ancestors(startUuid, endUuid); } - private async _ancestors(startUuid: string, endUuid: string, previousUuid = ''): Promise> { + private async _ancestors(startUuid: string, endUuid: string, previousUuid = ''): Promise> { 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; } diff --git a/src/services/auth-service/auth-service.ts b/src/services/auth-service/auth-service.ts index b530e4cd..79a6b7e1 100644 --- a/src/services/auth-service/auth-service.ts +++ b/src/services/auth-service/auth-service.ts @@ -35,6 +35,8 @@ export interface UserDetailsResponse { is_active: boolean; username: string; prefs: UserPrefs; + can_write: boolean; + can_manage: boolean; } export class AuthService { @@ -146,6 +148,8 @@ export class AuthService { isAdmin: resp.data.is_admin, isActive: resp.data.is_active, username: resp.data.username, + canWrite: resp.data.can_write, + canManage: resp.data.can_manage, prefs }; }) diff --git a/src/services/collection-service/collection-service-files-response.ts b/src/services/collection-service/collection-service-files-response.ts index b7e1f9c7..db56e317 100644 --- a/src/services/collection-service/collection-service-files-response.ts +++ b/src/services/collection-service/collection-service-files-response.ts @@ -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 +}; diff --git a/src/services/collection-service/collection-service.test.ts b/src/services/collection-service/collection-service.test.ts index 304cbfd3..3b4f423a 100644 --- a/src/services/collection-service/collection-service.test.ts +++ b/src/services/collection-service/collection-service.test.ts @@ -13,14 +13,14 @@ describe('collection-service', () => { let collectionService: CollectionService; let serverApi: AxiosInstance; let axiosMock: MockAdapter; - let webdavClient: any; + let keepWebdavClient: any; let authService; let actions; beforeEach(() => { serverApi = axios.create(); axiosMock = new MockAdapter(serverApi); - webdavClient = { + keepWebdavClient = { delete: jest.fn(), upload: jest.fn(), mkdir: jest.fn(), @@ -30,7 +30,7 @@ describe('collection-service', () => { progressFn: jest.fn(), errorFn: jest.fn(), } as any; - collectionService = new CollectionService(serverApi, webdavClient, authService, actions); + collectionService = new CollectionService(serverApi, keepWebdavClient, authService, actions); collectionService.update = jest.fn(); }); @@ -79,7 +79,7 @@ describe('collection-service', () => { }, select: ['uuid', 'name', 'version', 'modified_at'], } - collectionService = new CollectionService(serverApi, webdavClient, authService, actions); + collectionService = new CollectionService(serverApi, keepWebdavClient, authService, actions); await collectionService.update('uuid', data); expect(serverApi.put).toHaveBeenCalledWith('/collections/uuid', expected); }); @@ -95,7 +95,7 @@ describe('collection-service', () => { await collectionService.uploadFiles(collectionUUID, files); // then - expect(webdavClient.upload).not.toHaveBeenCalled(); + expect(keepWebdavClient.upload).not.toHaveBeenCalled(); }); it('should upload files', async () => { @@ -107,8 +107,8 @@ describe('collection-service', () => { await collectionService.uploadFiles(collectionUUID, files); // then - expect(webdavClient.upload).toHaveBeenCalledTimes(1); - expect(webdavClient.upload.mock.calls[0][0]).toEqual("c=zzzzz-4zz18-0123456789abcde/test-file1"); + expect(keepWebdavClient.upload).toHaveBeenCalledTimes(1); + expect(keepWebdavClient.upload.mock.calls[0][0]).toEqual("c=zzzzz-4zz18-0123456789abcde/test-file1"); }); it('should upload files with custom uplaod target', async () => { @@ -121,8 +121,8 @@ describe('collection-service', () => { await collectionService.uploadFiles(collectionUUID, files, undefined, customTarget); // then - expect(webdavClient.upload).toHaveBeenCalledTimes(1); - expect(webdavClient.upload.mock.calls[0][0]).toEqual("c=zzzzz-4zz18-0123456789adddd/test-path/test-file1"); + expect(keepWebdavClient.upload).toHaveBeenCalledTimes(1); + expect(keepWebdavClient.upload.mock.calls[0][0]).toEqual("c=zzzzz-4zz18-0123456789adddd/test-path/test-file1"); }); }); @@ -234,7 +234,7 @@ describe('collection-service', () => { const destinationPath = '/destinationPath'; // when - await collectionService.copyFiles(sourcePdh, filePaths, destinationUuid, destinationPath); + await collectionService.copyFiles(sourcePdh, filePaths, {uuid: destinationUuid}, destinationPath); // then expect(serverApi.put).toHaveBeenCalledTimes(1); @@ -261,7 +261,7 @@ describe('collection-service', () => { const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq'; const destinationPath = '/destinationPath'; - await collectionService.copyFiles(sourcePdh, filePaths, destinationUuid, destinationPath); + await collectionService.copyFiles(sourcePdh, filePaths, {uuid: destinationUuid}, destinationPath); expect(serverApi.put).toHaveBeenCalledTimes(1); expect(serverApi.put).toHaveBeenCalledWith( @@ -285,7 +285,7 @@ describe('collection-service', () => { const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq'; const destinationPath = '/'; - await collectionService.copyFiles(sourcePdh, filePaths, destinationUuid, destinationPath); + await collectionService.copyFiles(sourcePdh, filePaths, {uuid: destinationUuid}, destinationPath); expect(serverApi.put).toHaveBeenCalledTimes(1); expect(serverApi.put).toHaveBeenCalledWith( @@ -313,7 +313,7 @@ describe('collection-service', () => { const destinationPath = '/destinationPath'; // when - await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, destinationUuid, destinationPath); + await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, {uuid: destinationUuid}, destinationPath); // then expect(serverApi.put).toHaveBeenCalledTimes(2); @@ -357,7 +357,7 @@ describe('collection-service', () => { const destinationPath = '/destinationPath'; // when - await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, srcCollectionUUID, destinationPath); + await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, {uuid: srcCollectionUUID}, destinationPath); // then expect(serverApi.put).toHaveBeenCalledTimes(1); @@ -399,7 +399,7 @@ describe('collection-service', () => { // when try { - await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, destinationUuid, destinationPath); + await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, {uuid: destinationUuid}, destinationPath); } catch {} // then diff --git a/src/services/collection-service/collection-service.ts b/src/services/collection-service/collection-service.ts index 74cf7595..e50e5ed3 100644 --- a/src/services/collection-service/collection-service.ts +++ b/src/services/collection-service/collection-service.ts @@ -12,22 +12,28 @@ import { TrashableResourceService } from "services/common-service/trashable-reso import { ApiActions } from "services/api/api-actions"; import { Session } from "models/session"; import { CommonService } from "services/common-service/common-service"; +import { snakeCase } from "lodash"; +import { CommonResourceServiceError } from "services/common-service/common-resource-service"; export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void; +type CollectionPartialUpdateOrCreate = + | (Partial & Pick) + | (Partial & Pick); -export const emptyCollectionPdh = 'd41d8cd98f00b204e9800998ecf8427e+0'; +export const emptyCollectionPdh = "d41d8cd98f00b204e9800998ecf8427e+0"; +export const SOURCE_DESTINATION_EQUAL_ERROR_MESSAGE = "Source and destination cannot be the same"; export class CollectionService extends TrashableResourceService { - constructor(serverApi: AxiosInstance, private webdavClient: WebDAV, private authService: AuthService, actions: ApiActions) { + constructor(serverApi: AxiosInstance, private keepWebdavClient: WebDAV, private authService: AuthService, actions: ApiActions) { super(serverApi, "collections", actions, [ - 'fileCount', - 'fileSizeTotal', - 'replicationConfirmed', - 'replicationConfirmedAt', - 'storageClassesConfirmed', - 'storageClassesConfirmedAt', - 'unsignedManifestText', - 'version', + "fileCount", + "fileSizeTotal", + "replicationConfirmed", + "replicationConfirmedAt", + "storageClassesConfirmed", + "storageClassesConfirmedAt", + "unsignedManifestText", + "version", ]); } @@ -42,14 +48,18 @@ export class CollectionService extends TrashableResourceService, showErrors?: boolean) { - const select = [...Object.keys(data), 'version', 'modifiedAt']; + const select = [...Object.keys(data), "version", "modifiedAt"]; return super.update(uuid, { ...data, preserveVersion: true }, showErrors, select); } async files(uuid: string) { - const request = await this.webdavClient.propfind(`c=${uuid}`); - if (request.responseXML != null) { - return extractFilesData(request.responseXML); + try { + const request = await this.keepWebdavClient.propfind(`c=${uuid}`); + if (request.responseXML != null) { + return extractFilesData(request.responseXML); + } + } catch (e) { + return Promise.reject(e); } return Promise.reject(); } @@ -57,9 +67,9 @@ export class CollectionService extends TrashableResourceService { // Trim leading and trailing slashes - const trimmedPart = part.split('/').filter(Boolean).join('/'); + const trimmedPart = part.split("/").filter(Boolean).join("/"); if (trimmedPart.length) { - const separator = path.endsWith('/') ? '' : '/'; + const separator = path.endsWith("/") ? "" : "/"; return `${path}${separator}${trimmedPart}`; } else { return path; @@ -67,25 +77,37 @@ export class CollectionService extends TrashableResourceService(`/${this.resourceType}/${collectionUuid}`, payload), - this.actions, - true, // mapKeys - showErrors - ); + if (data.uuid) { + return CommonService.defaultResponse( + this.serverApi.put(`/${this.resourceType}/${data.uuid}`, payload), + this.actions, + true, // mapKeys + showErrors + ); + } else { + return CommonService.defaultResponse( + this.serverApi.post(`/${this.resourceType}`, payload), + this.actions, + true, // mapKeys + showErrors + ); + } } - async uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress, targetLocation: string = '') { - if (collectionUuid === "" || files.length === 0) { return; } + async uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress, targetLocation: string = "") { + if (collectionUuid === "" || files.length === 0) { + return; + } // files have to be uploaded sequentially for (let idx = 0; idx < files.length; idx++) { await this.uploadFile(collectionUuid, files[idx], idx, onProgress, targetLocation); @@ -94,49 +116,60 @@ export class CollectionService extends TrashableResourceService { - const baseUrl = this.webdavClient.getBaseUrl().endsWith('/') - ? this.webdavClient.getBaseUrl().slice(0, -1) - : this.webdavClient.getBaseUrl(); + const baseUrl = this.keepWebdavClient.getBaseUrl().endsWith("/") + ? this.keepWebdavClient.getBaseUrl().slice(0, -1) + : this.keepWebdavClient.getBaseUrl(); const apiToken = this.authService.getApiToken(); - const encodedApiToken = apiToken ? encodeURI(apiToken) : ''; + const encodedApiToken = apiToken ? encodeURI(apiToken) : ""; const userApiToken = `/t=${encodedApiToken}/`; - const splittedPrevFileUrl = file.url.split('/'); - const url = `${baseUrl}/${splittedPrevFileUrl[1]}${userApiToken}${splittedPrevFileUrl.slice(2).join('/')}`; + const splittedPrevFileUrl = file.url.split("/"); + const url = `${baseUrl}/${splittedPrevFileUrl[1]}${userApiToken}${splittedPrevFileUrl.slice(2).join("/")}`; return { ...file, - url + url, }; - } + }; async getFileContents(file: CollectionFile) { - return (await this.webdavClient.get(`c=${file.id}`)).response; - } - - private async uploadFile(collectionUuid: string, file: File, fileId: number, onProgress: UploadProgress = () => { return; }, targetLocation: string = '') { - const fileURL = `c=${targetLocation !== '' ? targetLocation : collectionUuid}/${file.name}`.replace('//', '/'); + return (await this.keepWebdavClient.get(`c=${file.id}`)).response; + } + + private async uploadFile( + collectionUuid: string, + file: File, + fileId: number, + onProgress: UploadProgress = () => { + return; + }, + targetLocation: string = "" + ) { + const fileURL = `c=${targetLocation !== "" ? targetLocation : collectionUuid}/${file.name}`.replace("//", "/"); const requestConfig = { headers: { - 'Content-Type': 'text/octet-stream' + "Content-Type": "text/octet-stream", }, onUploadProgress: (e: ProgressEvent) => { onProgress(fileId, e.loaded, e.total, Date.now()); }, }; - return this.webdavClient.upload(fileURL, [file], requestConfig); + return this.keepWebdavClient.upload(fileURL, [file], requestConfig); } deleteFiles(collectionUuid: string, files: string[], showErrors?: boolean) { const optimizedFiles = files .sort((a, b) => a.length - b.length) .reduce((acc, currentPath) => { - const parentPathFound = acc.find((parentPath) => currentPath.indexOf(`${parentPath}/`) > -1); + const parentPathFound = acc.find(parentPath => currentPath.indexOf(`${parentPath}/`) > -1); if (!parentPathFound) { return [...acc, currentPath]; @@ -148,49 +181,74 @@ export class CollectionService extends TrashableResourceService { return { ...obj, - [this.combineFilePath([filePath])]: '' - } - }, {}) + [this.combineFilePath([filePath])]: "", + }; + }, {}); - return this.replaceFiles(collectionUuid, fileMap, showErrors); + return this.replaceFiles({ uuid: collectionUuid }, fileMap, showErrors); } - copyFiles(sourcePdh: string, files: string[], destinationCollectionUuid: string, destinationPath: string, showErrors?: boolean) { + copyFiles( + sourcePdh: string, + files: string[], + destinationCollection: CollectionPartialUpdateOrCreate, + destinationPath: string, + showErrors?: boolean + ) { const fileMap = files.reduce((obj, sourceFile) => { - const sourceFileName = sourceFile.split('/').filter(Boolean).slice(-1).join(""); + const fileBasename = sourceFile.split("/").filter(Boolean).slice(-1).join(""); return { ...obj, - [this.combineFilePath([destinationPath, sourceFileName])]: `${sourcePdh}${this.combineFilePath([sourceFile])}` + [this.combineFilePath([destinationPath, fileBasename])]: `${sourcePdh}${this.combineFilePath([sourceFile])}`, }; }, {}); - return this.replaceFiles(destinationCollectionUuid, fileMap, showErrors); + return this.replaceFiles(destinationCollection, fileMap, showErrors); } - moveFiles(sourceUuid: string, sourcePdh: string, files: string[], destinationCollectionUuid: string, destinationPath: string, showErrors?: boolean) { - if (sourceUuid === destinationCollectionUuid) { + moveFiles( + sourceUuid: string, + sourcePdh: string, + files: string[], + destinationCollection: CollectionPartialUpdateOrCreate, + destinationPath: string, + showErrors?: boolean + ) { + if (sourceUuid === destinationCollection.uuid) { + let errors: CommonResourceServiceError[] = []; const fileMap = files.reduce((obj, sourceFile) => { - const sourceFileName = sourceFile.split('/').filter(Boolean).slice(-1).join(""); - return { - ...obj, - [this.combineFilePath([destinationPath, sourceFileName])]: `${sourcePdh}${this.combineFilePath([sourceFile])}`, - [this.combineFilePath([sourceFile])]: '', - }; + const fileBasename = sourceFile.split("/").filter(Boolean).slice(-1).join(""); + const fileDestinationPath = this.combineFilePath([destinationPath, fileBasename]); + const fileSourcePath = this.combineFilePath([sourceFile]); + const fileSourceUri = `${sourcePdh}${fileSourcePath}`; + + if (fileDestinationPath !== fileSourcePath) { + return { + ...obj, + [fileDestinationPath]: fileSourceUri, + [fileSourcePath]: "", + }; + } else { + errors.push(CommonResourceServiceError.SOURCE_DESTINATION_CANNOT_BE_SAME); + return obj; + } }, {}); - return this.replaceFiles(sourceUuid, fileMap, showErrors) + if (errors.length === 0) { + return this.replaceFiles({ uuid: sourceUuid }, fileMap, showErrors); + } else { + return Promise.reject({ errors }); + } } else { - return this.copyFiles(sourcePdh, files, destinationCollectionUuid, destinationPath, showErrors) - .then(() => { - return this.deleteFiles(sourceUuid, files, showErrors); - }); + return this.copyFiles(sourcePdh, files, destinationCollection, destinationPath, showErrors).then(() => { + return this.deleteFiles(sourceUuid, files, showErrors); + }); } } createDirectory(collectionUuid: string, path: string, showErrors?: boolean) { - const fileMap = {[this.combineFilePath([path])]: emptyCollectionPdh}; + const fileMap = { [this.combineFilePath([path])]: emptyCollectionPdh }; - return this.replaceFiles(collectionUuid, fileMap, showErrors); + return this.replaceFiles({ uuid: collectionUuid }, fileMap, showErrors); } - } diff --git a/src/services/common-service/common-resource-service.ts b/src/services/common-service/common-resource-service.ts index d9be8dae..907f0081 100644 --- a/src/services/common-service/common-resource-service.ts +++ b/src/services/common-service/common-resource-service.ts @@ -13,6 +13,8 @@ export enum CommonResourceServiceError { OWNERSHIP_CYCLE = 'OwnershipCycle', MODIFYING_CONTAINER_REQUEST_FINAL_STATE = 'ModifyingContainerRequestFinalState', NAME_HAS_ALREADY_BEEN_TAKEN = 'NameHasAlreadyBeenTaken', + PERMISSION_ERROR_FORBIDDEN = 'PermissionErrorForbidden', + SOURCE_DESTINATION_CANNOT_BE_SAME = 'SourceDestinationCannotBeSame', UNKNOWN = 'Unknown', NONE = 'None' } @@ -22,14 +24,20 @@ export class CommonResourceService extends CommonService super(serverApi, resourceType, actions, readOnlyFields.concat([ 'uuid', 'etag', - 'kind' + 'kind', + 'canWrite', + 'canManage', + 'createdAt', + 'modifiedAt', + 'modifiedByClientUuid', + 'modifiedByUserUuid' ])); } create(data?: Partial, showErrors?: boolean) { let payload: any; if (data !== undefined) { - this.readOnlyFields.forEach( field => delete data[field] ); + this.readOnlyFields.forEach(field => delete data[field]); payload = { [this.resourceType.slice(0, -1)]: CommonService.mapKeys(snakeCase)(data), }; @@ -40,7 +48,7 @@ export class CommonResourceService extends CommonService update(uuid: string, data: Partial, showErrors?: boolean, select?: string[]) { let payload: any; if (data !== undefined) { - this.readOnlyFields.forEach( field => delete data[field] ); + this.readOnlyFields.forEach(field => delete data[field]); payload = { [this.resourceType.slice(0, -1)]: CommonService.mapKeys(snakeCase)(data), }; @@ -53,7 +61,7 @@ export class CommonResourceService extends CommonService } export const getCommonResourceServiceError = (errorResponse: any) => { - if ('errors' in errorResponse) { + if (errorResponse && 'errors' in errorResponse) { const error = errorResponse.errors.join(''); switch (true) { case /UniqueViolation/.test(error): @@ -64,11 +72,13 @@ export const getCommonResourceServiceError = (errorResponse: any) => { return CommonResourceServiceError.MODIFYING_CONTAINER_REQUEST_FINAL_STATE; case /Name has already been taken/.test(error): return CommonResourceServiceError.NAME_HAS_ALREADY_BEEN_TAKEN; + case /403 Forbidden/.test(error): + return CommonResourceServiceError.PERMISSION_ERROR_FORBIDDEN; + case new RegExp(CommonResourceServiceError.SOURCE_DESTINATION_CANNOT_BE_SAME).test(error): + return CommonResourceServiceError.SOURCE_DESTINATION_CANNOT_BE_SAME; default: return CommonResourceServiceError.UNKNOWN; } } return CommonResourceServiceError.NONE; }; - - diff --git a/src/services/common-service/common-service.ts b/src/services/common-service/common-service.ts index 4b857edd..8e9fe631 100644 --- a/src/services/common-service/common-service.ts +++ b/src/services/common-service/common-service.ts @@ -107,12 +107,14 @@ export class CommonService { ); } - delete(uuid: string): Promise { + delete(uuid: string, showErrors?: boolean): Promise { this.validateUuid(uuid); return CommonService.defaultResponse( this.serverApi .delete(`/${this.resourceType}/${uuid}`), - this.actions + this.actions, + true, // mapKeys + showErrors ); } diff --git a/src/services/common-service/trashable-resource-service.ts b/src/services/common-service/trashable-resource-service.ts index 4d6b130b..5e4704b6 100644 --- a/src/services/common-service/trashable-resource-service.ts +++ b/src/services/common-service/trashable-resource-service.ts @@ -9,29 +9,25 @@ import { CommonResourceService } from "services/common-service/common-resource-s import { ApiActions } from "services/api/api-actions"; export class TrashableResourceService extends CommonResourceService { - constructor(serverApi: AxiosInstance, resourceType: string, actions: ApiActions, readOnlyFields: string[] = []) { super(serverApi, resourceType, actions, readOnlyFields); } trash(uuid: string): Promise { - 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 { 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 ); } } diff --git a/src/services/groups-service/groups-service.ts b/src/services/groups-service/groups-service.ts index a36ddba8..b9f47df0 100644 --- a/src/services/groups-service/groups-service.ts +++ b/src/services/groups-service/groups-service.ts @@ -6,8 +6,8 @@ import { CancelToken } from 'axios'; import { snakeCase, camelCase } from "lodash"; import { CommonResourceService } from 'services/common-service/common-resource-service'; import { - ListResults, - ListArguments, + ListResults, + ListArguments, } from 'services/common-service/common-service'; import { AxiosInstance, AxiosRequestConfig } from 'axios'; import { CollectionResource } from 'models/collection'; @@ -20,78 +20,85 @@ import { GroupResource } from 'models/group'; import { Session } from 'models/session'; export interface ContentsArguments { - limit?: number; - offset?: number; - order?: string; - filters?: string; - recursive?: boolean; - includeTrash?: boolean; - excludeHomeProject?: boolean; + limit?: number; + offset?: number; + order?: string; + filters?: string; + recursive?: boolean; + includeTrash?: boolean; + excludeHomeProject?: boolean; + select?: string[]; } export interface SharedArguments extends ListArguments { - include?: string; + include?: string; } export type GroupContentsResource = - | CollectionResource - | ProjectResource - | ProcessResource - | WorkflowResource; + | CollectionResource + | ProjectResource + | ProcessResource + | WorkflowResource; export class GroupsService< - T extends GroupResource = GroupResource -> extends TrashableResourceService { - constructor(serverApi: AxiosInstance, actions: ApiActions) { - super(serverApi, 'groups', actions); - } + T extends GroupResource = GroupResource + > extends TrashableResourceService { + constructor(serverApi: AxiosInstance, actions: ApiActions) { + super(serverApi, 'groups', actions); + } -async contents(uuid: string, args: ContentsArguments = {}, session?: Session, cancelToken?: CancelToken): Promise> { - const { filters, order, ...other } = args; - const params = { - ...other, - filters: filters ? `[${filters}]` : undefined, - order: order ? order : undefined - }; - const pathUrl = uuid ? `/${uuid}/contents` : '/contents'; - const cfg: AxiosRequestConfig = { - params: CommonResourceService.mapKeys(snakeCase)(params), - }; + async contents(uuid: string, args: ContentsArguments = {}, session?: Session, cancelToken?: CancelToken): Promise> { + const { filters, order, select, ...other } = args; + const params = { + ...other, + filters: filters ? `[${filters}]` : undefined, + order: order ? order : undefined, + select: select + ? JSON.stringify(select.map(sel => { + const sp = sel.split("."); + return sp.length === 2 ? (sp[0] + "." + snakeCase(sp[1])) : snakeCase(sel); + })) + : undefined + }; + const pathUrl = (uuid !== '') ? `/${uuid}/contents` : '/contents'; + const cfg: AxiosRequestConfig = { + params: CommonResourceService.mapKeys(snakeCase)(params), + }; - if (session) { - cfg.baseURL = session.baseUrl; - cfg.headers = { Authorization: 'Bearer ' + session.token }; - } + if (session) { + cfg.baseURL = session.baseUrl; + cfg.headers = { Authorization: 'Bearer ' + session.token }; + } - if (cancelToken) { - cfg.cancelToken = cancelToken; - } + if (cancelToken) { + cfg.cancelToken = cancelToken; + } - const response = await CommonResourceService.defaultResponse( - this.serverApi.get(this.resourceType + pathUrl, cfg), - this.actions, - false - ); + const response = await CommonResourceService.defaultResponse( + this.serverApi.get(this.resourceType + pathUrl, cfg), + this.actions, + false + ); - return { - ...TrashableResourceService.mapKeys(camelCase)(response), - clusterId: session && session.clusterId, - }; - } + return { + ...TrashableResourceService.mapKeys(camelCase)(response), + clusterId: session && session.clusterId, + }; + } - shared( - params: SharedArguments = {} - ): Promise> { - return CommonResourceService.defaultResponse( - this.serverApi.get(this.resourceType + '/shared', { params }), - this.actions - ); - } + shared( + params: SharedArguments = {} + ): Promise> { + return CommonResourceService.defaultResponse( + this.serverApi.get(this.resourceType + '/shared', { params }), + this.actions + ); + } } export enum GroupContentsResourcePrefix { - COLLECTION = 'collections', - PROJECT = 'groups', - PROCESS = 'container_requests', - WORKFLOW = 'workflows', + COLLECTION = 'collections', + PROJECT = 'groups', + PROCESS = 'container_requests', + WORKFLOW = 'workflows', } diff --git a/src/services/log-service/log-service.test.ts b/src/services/log-service/log-service.test.ts new file mode 100644 index 00000000..2519155b --- /dev/null +++ b/src/services/log-service/log-service.test.ts @@ -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 = ` + + + /arvados/v1/container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}/ + + + + + + Tue, 15 Aug 2023 12:54:37 GMT + + + + + + + + + + + + + HTTP/1.1 200 OK + + + + /arvados/v1/container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}/stdout.txt + + + stdout.txt + 15 + text/plain; charset=utf-8 + "177b8fb161ff9f58f" + + + + + + + + + + + + Tue, 15 Aug 2023 12:54:37 GMT + + HTTP/1.1 200 OK + + + + /arvados/v1/container_requests/${containerRequest.uuid}/wrongpath.txt + + + wrongpath.txt + 15 + text/plain; charset=utf-8 + "177b8fb161ff9f58f" + + + + + + + + + + + + Tue, 15 Aug 2023 12:54:37 GMT + + HTTP/1.1 200 OK + + + `; + 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']); + }); + +}); diff --git a/src/services/log-service/log-service.ts b/src/services/log-service/log-service.ts index 9772e0b6..f36044f4 100644 --- a/src/services/log-service/log-service.ts +++ b/src/services/log-service/log-service.ts @@ -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 { - constructor(serverApi: AxiosInstance, actions: ApiActions) { + constructor(serverApi: AxiosInstance, private apiWebdavClient: WebDAV, actions: ApiActions) { super(serverApi, "logs", actions); } + + async listLogFiles(containerRequest: Pick) { + 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, fileRecord: Pick, startByte: number, endByte: number): Promise { + 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) => (file.name.replace(/\.(txt|json)$/, '') as LogEventType); diff --git a/src/services/services.ts b/src/services/services.ts index 4e4a682e..cd04a65f 100644 --- a/src/services/services.ts +++ b/src/services/services.ts @@ -39,12 +39,14 @@ export function setAuthorizationHeader(services: ServiceRepository, token: strin services.apiClient.defaults.headers.common = { Authorization: `Bearer ${token}` }; - services.webdavClient.setAuthorization(`Bearer ${token}`); + services.keepWebdavClient.setAuthorization(`Bearer ${token}`); + services.apiWebdavClient.setAuthorization(`Bearer ${token}`); } export function removeAuthorizationHeader(services: ServiceRepository) { delete services.apiClient.defaults.headers.common; - services.webdavClient.setAuthorization(undefined); + services.keepWebdavClient.setAuthorization(undefined); + services.apiWebdavClient.setAuthorization(undefined); } export const createServices = (config: Config, actions: ApiActions, useApiClient?: AxiosInstance) => { @@ -55,10 +57,14 @@ export const createServices = (config: Config, actions: ApiActions, useApiClient const apiClient = useApiClient || Axios.create({ headers: {} }); apiClient.defaults.baseURL = config.baseUrl; - const webdavClient = new WebDAV({ + const keepWebdavClient = new WebDAV({ baseURL: config.keepWebServiceUrl }); + const apiWebdavClient = new WebDAV({ + baseURL: config.baseUrl + }); + const apiClientAuthorizationService = new ApiClientAuthorizationService(apiClient, actions); const authorizedKeysService = new AuthorizedKeysService(apiClient, actions); const containerRequestService = new ContainerRequestService(apiClient, actions); @@ -66,7 +72,7 @@ export const createServices = (config: Config, actions: ApiActions, useApiClient const groupsService = new GroupsService(apiClient, actions); const keepService = new KeepService(apiClient, actions); const linkService = new LinkService(apiClient, actions); - const logService = new LogService(apiClient, actions); + const logService = new LogService(apiClient, apiWebdavClient, actions); const permissionService = new PermissionService(apiClient, actions); const projectService = new ProjectService(apiClient, actions); const repositoriesService = new RepositoriesService(apiClient, actions); @@ -75,13 +81,12 @@ export const createServices = (config: Config, actions: ApiActions, useApiClient const workflowService = new WorkflowService(apiClient, actions); const linkAccountService = new LinkAccountService(apiClient, actions); - const ancestorsService = new AncestorService(groupsService, userService); - const idleTimeout = (config && config.clusterConfig && config.clusterConfig.Workbench.IdleTimeout) || '0s'; const authService = new AuthService(apiClient, config.rootUrl, actions, (parse(idleTimeout, 's') || 0) > 0); - const collectionService = new CollectionService(apiClient, webdavClient, authService, actions); + const collectionService = new CollectionService(apiClient, keepWebdavClient, authService, actions); + const ancestorsService = new AncestorService(groupsService, userService, collectionService); const favoriteService = new FavoriteService(linkService, groupsService); const tagService = new TagService(linkService); const searchService = new SearchService(); @@ -110,7 +115,8 @@ export const createServices = (config: Config, actions: ApiActions, useApiClient tagService, userService, virtualMachineService, - webdavClient, + keepWebdavClient, + apiWebdavClient, workflowService, vocabularyService, linkAccountService diff --git a/src/services/user-service/user-service.ts b/src/services/user-service/user-service.ts index 8581b267..75131f92 100644 --- a/src/services/user-service/user-service.ts +++ b/src/services/user-service/user-service.ts @@ -12,8 +12,7 @@ export class UserService extends CommonResourceService { constructor(serverApi: AxiosInstance, actions: ApiActions, readOnlyFields: string[] = []) { super(serverApi, "users", actions, readOnlyFields.concat([ 'fullName', - 'isInvited', - 'writableBy', + 'isInvited' ])); } diff --git a/src/store/advanced-tab/advanced-tab.tsx b/src/store/advanced-tab/advanced-tab.tsx index ac088f02..fedd5518 100644 --- a/src/store/advanced-tab/advanced-tab.tsx +++ b/src/store/advanced-tab/advanced-tab.tsx @@ -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, getState: () => RootState, services: ServiceRepository) => { @@ -267,6 +273,23 @@ export const openAdvancedTabDialog = (uuid: string) => }); dispatch(initAdvancedTabDialog(advanceDataLink)); break; + case ResourceKind.WORKFLOW: + const wfResources = getState().resources; + const dataWf = getResource(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(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 {'{'} {response} {'\n'} {'}'}; }; @@ -600,3 +626,22 @@ const linkApiResponse = (apiResponse: LinkResource): JSX.Element => { return {'{'} {response} {'\n'} {'}'}; }; + + +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 {'{'} {response} {'\n'} {'}'}; +}; diff --git a/src/store/all-processes-panel/all-processes-panel-middleware-service.ts b/src/store/all-processes-panel/all-processes-panel-middleware-service.ts index 227d2fa0..955d9689 100644 --- a/src/store/all-processes-panel/all-processes-panel-middleware-service.ts +++ b/src/store/all-processes-panel/all-processes-panel-middleware-service.ts @@ -27,13 +27,13 @@ export class AllProcessesPanelMiddlewareService extends DataExplorerMiddlewareSe super(id); } - async requestItems(api: MiddlewareAPI) { + async requestItems(api: MiddlewareAPI, criteriaChanged?: boolean, background?: boolean) { const dataExplorer = getDataExplorer(api.getState().dataExplorer, this.getId()); if (!dataExplorer) { api.dispatch(allProcessesPanelDataExplorerIsNotSet()); } else { try { - api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); + if (!background) { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); } const processItems = await this.services.containerRequestService.list( { ...getParams(dataExplorer), @@ -41,7 +41,7 @@ export class AllProcessesPanelMiddlewareService extends DataExplorerMiddlewareSe select: containerRequestFieldsNoMounts, }); - api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); + if (!background) { api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); } api.dispatch(resourcesActions.SET_RESOURCES(processItems.items)); await api.dispatch(loadMissingProcessesInformation(processItems.items)); api.dispatch(allProcessesPanelActions.SET_ITEMS({ @@ -51,7 +51,7 @@ export class AllProcessesPanelMiddlewareService extends DataExplorerMiddlewareSe rowsPerPage: processItems.limit })); } catch { - api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); + if (!background) { api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); } api.dispatch(allProcessesPanelActions.SET_ITEMS({ items: [], itemsAvailable: 0, @@ -64,13 +64,13 @@ export class AllProcessesPanelMiddlewareService extends DataExplorerMiddlewareSe } } -const getParams = ( dataExplorer: DataExplorer ) => ({ +const getParams = (dataExplorer: DataExplorer) => ({ ...dataExplorerToListParams(dataExplorer), order: getOrder(dataExplorer), filters: getFilters(dataExplorer) }); -const getFilters = ( dataExplorer: DataExplorer ) => { +const getFilters = (dataExplorer: DataExplorer) => { const columns = dataExplorer.columns as DataColumns; const statusColumnFilters = getDataExplorerColumnFilters(columns, 'Status'); const activeStatusFilter = Object.keys(statusColumnFilters).find( diff --git a/src/store/auth/auth-action.test.ts b/src/store/auth/auth-action.test.ts index cba93965..ede41925 100644 --- a/src/store/auth/auth-action.test.ts +++ b/src/store/auth/auth-action.test.ts @@ -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", diff --git a/src/store/auth/auth-middleware.ts b/src/store/auth/auth-middleware.ts index eb1e42b5..16584313 100644 --- a/src/store/auth/auth-middleware.ts +++ b/src/store/auth/auth-middleware.ts @@ -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(initSessions(services.authService, state.auth.remoteHostsConfig[state.auth.localCluster], user)); + if (Object.keys(state.auth.config.clusterConfig.Workbench.UserProfileFormFields).length > 0 && + user.isActive && + (Object.keys(user.prefs).length === 0 || + user.prefs.profile === undefined || + Object.keys(user.prefs.profile!).length === 0)) { + // If the user doesn't have a profile set, send them + // to the user profile page to encourage them to fill it out. + store.dispatch(navigateToMyAccount); + } if (!user.isActive) { // As a special case, if the user is inactive, they // may be able to self-activate using the "activate" @@ -56,7 +66,7 @@ export const authMiddleware = (services: ServiceRepository): Middleware => store } }, SET_CONFIG: ({ config }) => { - document.title = `Arvados Workbench (${config.uuidPrefix})`; + document.title = `Arvados (${config.uuidPrefix})`; next(action); }, LOGOUT: ({ deleteLinkData, preservePath }) => { diff --git a/src/store/breadcrumbs/breadcrumbs-actions.ts b/src/store/breadcrumbs/breadcrumbs-actions.ts index 74cfde00..9aebeb90 100644 --- a/src/store/breadcrumbs/breadcrumbs-actions.ts +++ b/src/store/breadcrumbs/breadcrumbs-actions.ts @@ -6,8 +6,6 @@ import { Dispatch } from 'redux'; import { RootState } from 'store/store'; import { getUserUuid } from "common/getuser"; import { getResource } from 'store/resources/resources'; -import { TreePicker } from '../tree-picker/tree-picker'; -import { getSidePanelTreeBranch, getSidePanelTreeNodeAncestorsIds } from '../side-panel-tree/side-panel-tree-actions'; import { propertiesActions } from '../properties/properties-actions'; import { getProcess } from 'store/processes/process'; import { ServiceRepository } from 'services/services'; @@ -22,20 +20,22 @@ import { ProcessResource } from 'models/process'; import { OrderBuilder } from 'services/api/order-builder'; import { Breadcrumb } from 'components/breadcrumbs/breadcrumbs'; import { ContainerRequestResource, containerRequestFieldsNoMounts } from 'models/container-request'; -import { CollectionIcon, IconType, ProcessIcon, ProjectIcon } from 'components/icon/icon'; +import { CollectionIcon, IconType, ProcessIcon, ProjectIcon, WorkflowIcon } from 'components/icon/icon'; import { CollectionResource } from 'models/collection'; import { getSidePanelIcon } from 'views-components/side-panel-tree/side-panel-tree'; +import { WorkflowResource } from 'models/workflow'; +import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions"; export const BREADCRUMBS = 'breadcrumbs'; -export const setBreadcrumbs = (breadcrumbs: any, currentItem?: CollectionResource | ContainerRequestResource | GroupResource) => { +export const setBreadcrumbs = (breadcrumbs: any, currentItem?: CollectionResource | ContainerRequestResource | GroupResource | WorkflowResource) => { if (currentItem) { breadcrumbs.push(resourceToBreadcrumb(currentItem)); } return propertiesActions.SET_PROPERTY({ key: BREADCRUMBS, value: breadcrumbs }); }; -const resourceToBreadcrumbIcon = (resource: CollectionResource | ContainerRequestResource | GroupResource): IconType | undefined => { +const resourceToBreadcrumbIcon = (resource: CollectionResource | ContainerRequestResource | GroupResource | WorkflowResource): IconType | undefined => { switch (resource.kind) { case ResourceKind.PROJECT: return ProjectIcon; @@ -43,55 +43,80 @@ const resourceToBreadcrumbIcon = (resource: CollectionResource | ContainerReques return ProcessIcon; case ResourceKind.COLLECTION: return CollectionIcon; + case ResourceKind.WORKFLOW: + return WorkflowIcon; default: return undefined; } } -const resourceToBreadcrumb = (resource: CollectionResource | ContainerRequestResource | GroupResource): Breadcrumb => ({ +const resourceToBreadcrumb = (resource: CollectionResource | ContainerRequestResource | GroupResource | WorkflowResource): Breadcrumb => ({ label: resource.name, uuid: resource.uuid, icon: resourceToBreadcrumbIcon(resource), }) -const getSidePanelTreeBreadcrumbs = (uuid: string) => (treePicker: TreePicker): Breadcrumb[] => { - const nodes = getSidePanelTreeBranch(uuid)(treePicker); - return nodes.map(node => - typeof node.value === 'string' - ? { - label: node.value, - uuid: node.id, - icon: getSidePanelIcon(node.value) - } - : resourceToBreadcrumb(node.value)); -}; - export const setSidePanelBreadcrumbs = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const { treePicker, collectionPanel: { item } } = getState(); - const breadcrumbs = getSidePanelTreeBreadcrumbs(uuid)(treePicker); - const path = getState().router.location!.pathname; - const currentUuid = path.split('/')[2]; - const uuidKind = extractUuidKind(currentUuid); + try { + dispatch(progressIndicatorActions.START_WORKING(uuid + "-breadcrumbs")); + const ancestors = await services.ancestorsService.ancestors(uuid, ''); + dispatch(updateResources(ancestors)); + + let breadcrumbs: Breadcrumb[] = []; + const { collectionPanel: { item } } = getState(); - if (uuidKind === ResourceKind.COLLECTION) { - const collectionItem = item ? item : await services.collectionService.get(currentUuid); - const parentProcessItem = await getCollectionParent(collectionItem)(services); - if (parentProcessItem) { - const mainProcessItem = await getProcessParent(parentProcessItem)(services); - mainProcessItem && breadcrumbs.push(resourceToBreadcrumb(mainProcessItem)); - breadcrumbs.push(resourceToBreadcrumb(parentProcessItem)); + const path = getState().router.location!.pathname; + const currentUuid = path.split('/')[2]; + const uuidKind = extractUuidKind(currentUuid); + const rootUuid = getUserUuid(getState()); + + if (ancestors.find(ancestor => ancestor.uuid === rootUuid)) { + // Handle home project uuid root + breadcrumbs.push({ + label: SidePanelTreeCategory.PROJECTS, + uuid: SidePanelTreeCategory.PROJECTS, + icon: getSidePanelIcon(SidePanelTreeCategory.PROJECTS) + }); + } else if (Object.values(SidePanelTreeCategory).includes(uuid as SidePanelTreeCategory)) { + // Handle SidePanelTreeCategory root + breadcrumbs.push({ + label: uuid, + uuid: uuid, + icon: getSidePanelIcon(uuid) + }); } - dispatch(setBreadcrumbs(breadcrumbs, collectionItem)); - } else if (uuidKind === ResourceKind.PROCESS) { - const processItem = await services.containerRequestService.get(currentUuid); - const parentProcessItem = await getProcessParent(processItem)(services); - if (parentProcessItem) { - breadcrumbs.push(resourceToBreadcrumb(parentProcessItem)); + + breadcrumbs = ancestors.reduce((breadcrumbs, ancestor) => + ancestor.kind === ResourceKind.GROUP + ? [...breadcrumbs, resourceToBreadcrumb(ancestor)] + : breadcrumbs, + breadcrumbs); + + if (uuidKind === ResourceKind.COLLECTION) { + const collectionItem = item ? item : await services.collectionService.get(currentUuid); + const parentProcessItem = await getCollectionParent(collectionItem)(services); + if (parentProcessItem) { + const mainProcessItem = await getProcessParent(parentProcessItem)(services); + mainProcessItem && breadcrumbs.push(resourceToBreadcrumb(mainProcessItem)); + breadcrumbs.push(resourceToBreadcrumb(parentProcessItem)); + } + dispatch(setBreadcrumbs(breadcrumbs, collectionItem)); + } else if (uuidKind === ResourceKind.PROCESS) { + const processItem = await services.containerRequestService.get(currentUuid); + const parentProcessItem = await getProcessParent(processItem)(services); + if (parentProcessItem) { + breadcrumbs.push(resourceToBreadcrumb(parentProcessItem)); + } + dispatch(setBreadcrumbs(breadcrumbs, processItem)); + } else if (uuidKind === ResourceKind.WORKFLOW) { + const workflowItem = await services.workflowService.get(currentUuid); + dispatch(setBreadcrumbs(breadcrumbs, workflowItem)); } - dispatch(setBreadcrumbs(breadcrumbs, processItem)); + dispatch(setBreadcrumbs(breadcrumbs)); + } finally { + dispatch(progressIndicatorActions.STOP_WORKING(uuid + "-breadcrumbs")); } - dispatch(setBreadcrumbs(breadcrumbs)); }; export const setSharedWithMeBreadcrumbs = (uuid: string) => @@ -102,42 +127,50 @@ export const setTrashBreadcrumbs = (uuid: string) => export const setCategoryBreadcrumbs = (uuid: string, category: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const ancestors = await services.ancestorsService.ancestors(uuid, ''); - dispatch(updateResources(ancestors)); - const initialBreadcrumbs: Breadcrumb[] = [ - { - label: category, - uuid: category, - icon: getSidePanelIcon(category) - } - ]; - const { collectionPanel: { item } } = getState(); - const path = getState().router.location!.pathname; - const currentUuid = path.split('/')[2]; - const uuidKind = extractUuidKind(currentUuid); - let breadcrumbs = ancestors.reduce((breadcrumbs, ancestor) => - ancestor.kind === ResourceKind.GROUP - ? [...breadcrumbs, resourceToBreadcrumb(ancestor)] - : breadcrumbs, - initialBreadcrumbs); - if (uuidKind === ResourceKind.COLLECTION) { - const collectionItem = item ? item : await services.collectionService.get(currentUuid); - const parentProcessItem = await getCollectionParent(collectionItem)(services); - if (parentProcessItem) { - const mainProcessItem = await getProcessParent(parentProcessItem)(services); - mainProcessItem && breadcrumbs.push(resourceToBreadcrumb(mainProcessItem)); - breadcrumbs.push(resourceToBreadcrumb(parentProcessItem)); - } - dispatch(setBreadcrumbs(breadcrumbs, collectionItem)); - } else if (uuidKind === ResourceKind.PROCESS) { - const processItem = await services.containerRequestService.get(currentUuid); - const parentProcessItem = await getProcessParent(processItem)(services); - if (parentProcessItem) { - breadcrumbs.push(resourceToBreadcrumb(parentProcessItem)); + try { + dispatch(progressIndicatorActions.START_WORKING(uuid + "-breadcrumbs")); + const ancestors = await services.ancestorsService.ancestors(uuid, ''); + dispatch(updateResources(ancestors)); + const initialBreadcrumbs: Breadcrumb[] = [ + { + label: category, + uuid: category, + icon: getSidePanelIcon(category) + } + ]; + const { collectionPanel: { item } } = getState(); + const path = getState().router.location!.pathname; + const currentUuid = path.split('/')[2]; + const uuidKind = extractUuidKind(currentUuid); + let breadcrumbs = ancestors.reduce((breadcrumbs, ancestor) => + ancestor.kind === ResourceKind.GROUP + ? [...breadcrumbs, resourceToBreadcrumb(ancestor)] + : breadcrumbs, + initialBreadcrumbs); + if (uuidKind === ResourceKind.COLLECTION) { + const collectionItem = item ? item : await services.collectionService.get(currentUuid); + const parentProcessItem = await getCollectionParent(collectionItem)(services); + if (parentProcessItem) { + const mainProcessItem = await getProcessParent(parentProcessItem)(services); + mainProcessItem && breadcrumbs.push(resourceToBreadcrumb(mainProcessItem)); + breadcrumbs.push(resourceToBreadcrumb(parentProcessItem)); + } + dispatch(setBreadcrumbs(breadcrumbs, collectionItem)); + } else if (uuidKind === ResourceKind.PROCESS) { + const processItem = await services.containerRequestService.get(currentUuid); + const parentProcessItem = await getProcessParent(processItem)(services); + if (parentProcessItem) { + breadcrumbs.push(resourceToBreadcrumb(parentProcessItem)); + } + dispatch(setBreadcrumbs(breadcrumbs, processItem)); + } else if (uuidKind === ResourceKind.WORKFLOW) { + const workflowItem = await services.workflowService.get(currentUuid); + dispatch(setBreadcrumbs(breadcrumbs, workflowItem)); } - dispatch(setBreadcrumbs(breadcrumbs, processItem)); + dispatch(setBreadcrumbs(breadcrumbs)); + } finally { + dispatch(progressIndicatorActions.STOP_WORKING(uuid + "-breadcrumbs")); } - dispatch(setBreadcrumbs(breadcrumbs)); }; const getProcessParent = (childProcess: ContainerRequestResource) => @@ -172,18 +205,18 @@ const getCollectionParent = (collection: CollectionResource) => }); const [parentOutput, parentLog] = await Promise.all([parentOutputPromise, parentLogPromise]); return parentOutput.items.length > 0 ? - parentOutput.items[0] : - parentLog.items.length > 0 ? - parentLog.items[0] : - undefined; + parentOutput.items[0] : + parentLog.items.length > 0 ? + parentLog.items[0] : + undefined; } export const setProjectBreadcrumbs = (uuid: string) => - (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const ancestors = getSidePanelTreeNodeAncestorsIds(uuid)(getState().treePicker); + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const ancestors = await services.ancestorsService.ancestors(uuid, ''); const rootUuid = getUserUuid(getState()); - if (uuid === rootUuid || ancestors.find(uuid => uuid === rootUuid)) { + if (uuid === rootUuid || ancestors.find(ancestor => ancestor.uuid === rootUuid)) { dispatch(setSidePanelBreadcrumbs(uuid)); } else { dispatch(setSharedWithMeBreadcrumbs(uuid)); @@ -234,7 +267,7 @@ export const setUserProfileBreadcrumbs = (userUuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { try { const user = getResource(userUuid)(getState().resources) - || await services.userService.get(userUuid, false); + || await services.userService.get(userUuid, false); const breadcrumbs: Breadcrumb[] = [ { label: USERS_PANEL_LABEL, uuid: USERS_PANEL_LABEL }, { label: user ? user.username : userUuid, uuid: userUuid }, diff --git a/src/store/collection-panel/collection-panel-action.ts b/src/store/collection-panel/collection-panel-action.ts index 7bab8632..49573215 100644 --- a/src/store/collection-panel/collection-panel-action.ts +++ b/src/store/collection-panel/collection-panel-action.ts @@ -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(), @@ -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; } diff --git a/src/store/collection-panel/collection-panel-files/collection-panel-files-state.ts b/src/store/collection-panel/collection-panel-files/collection-panel-files-state.ts index 0044a66d..298a5a1e 100644 --- a/src/store/collection-panel/collection-panel-files/collection-panel-files-state.ts +++ b/src/store/collection-panel/collection-panel-files/collection-panel-files-state.ts @@ -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; @@ -16,6 +18,11 @@ export interface CollectionPanelFile extends CollectionFile { selected: boolean; } +export interface CollectionFileSelection { + collection: CollectionResource; + selectedPaths: string[]; +} + export const mapCollectionFileToCollectionPanelFile = (node: TreeNode): TreeNode => { 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})`), ''); diff --git a/src/store/collections-content-address-panel/collections-content-address-middleware-service.ts b/src/store/collections-content-address-panel/collections-content-address-middleware-service.ts index 18023aff..2d89cccd 100644 --- a/src/store/collections-content-address-panel/collections-content-address-middleware-service.ts +++ b/src/store/collections-content-address-panel/collections-content-address-middleware-service.ts @@ -13,7 +13,6 @@ import { resourcesActions } from 'store/resources/resources-actions'; import { FilterBuilder } from 'services/api/filter-builder'; import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions'; import { collectionsContentAddressActions } from './collections-content-address-panel-actions'; -import { navigateTo } from 'store/navigation/navigation-action'; import { updateFavorites } from 'store/favorites/favorites-actions'; import { updatePublicFavorites } from 'store/public-favorites/public-favorites-actions'; import { setBreadcrumbs } from '../breadcrumbs/breadcrumbs-actions'; @@ -21,6 +20,8 @@ import { ResourceKind, extractUuidKind } from 'models/resource'; import { ownerNameActions } from 'store/owner-name/owner-name-actions'; import { getUserDisplayName } from 'models/user'; import { CollectionResource } from 'models/collection'; +import { replace } from "react-router-redux"; +import { getNavUrl } from 'routes/routes'; export class CollectionsWithSameContentAddressMiddlewareService extends DataExplorerMiddlewareService { constructor(private services: ServiceRepository, id: string) { @@ -89,7 +90,7 @@ export class CollectionsWithSameContentAddressMiddlewareService extends DataExpl api.dispatch(updateFavorites(response.items.map(item => item.uuid))); api.dispatch(updatePublicFavorites(response.items.map(item => item.uuid))); if (response.itemsAvailable === 1) { - api.dispatch(navigateTo(response.items[0].uuid)); + api.dispatch(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())); diff --git a/src/store/collections/collection-copy-actions.ts b/src/store/collections/collection-copy-actions.ts index eb9c64fd..c332ef5f 100644 --- a/src/store/collections/collection-copy-actions.ts +++ b/src/store/collections/collection-copy-actions.ts @@ -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(resetPickerProjectTree()); - dispatch(initProjectsTreePicker(COLLECTION_COPY_FORM_NAME)); - const initialData: CopyFormDialogData = { name: `Copy of: ${resource.name}`, ownerUuid: '', uuid: resource.uuid }; - dispatch(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(resetPickerProjectTree()); + dispatch(initProjectsTreePicker(COLLECTION_COPY_FORM_NAME)); + const initialData: CopyFormDialogData = { name: `Copy of: ${resource.name}`, ownerUuid: "", uuid: resource.uuid, fromContextMenu: resource.fromContextMenu }; + dispatch(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(resetPickerProjectTree()); + dispatch(initProjectsTreePicker(COLLECTION_MULTI_COPY_FORM_NAME)); + const initialData: CopyFormDialogData = { name: `Copy of: ${resource.name}`, ownerUuid: "", uuid: resource.uuid, fromContextMenu: resource.fromContextMenu }; + dispatch(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(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)); } }; diff --git a/src/store/collections/collection-info-actions.ts b/src/store/collections/collection-info-actions.ts index 6107c409..772def29 100644 --- a/src/store/collections/collection-info-actions.ts +++ b/src/store/collections/collection-info-actions.ts @@ -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(), + SET_SSH_KEYS: ofType(), + ADD_SSH_KEY: ofType(), + REMOVE_SSH_KEY: ofType(), + SET_HOME_CLUSTER: ofType(), + SET_SESSIONS: ofType(), + ADD_SESSION: ofType(), + REMOVE_SESSION: ofType(), + UPDATE_SESSION: ofType(), + 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(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 diff --git a/src/store/collections/collection-move-actions.ts b/src/store/collections/collection-move-actions.ts index 929f1612..56c7b24c 100644 --- a/src/store/collections/collection-move-actions.ts +++ b/src/store/collections/collection-move-actions.ts @@ -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(resetPickerProjectTree()); - dispatch(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(resetPickerProjectTree()); + dispatch(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(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; diff --git a/src/store/collections/collection-partial-copy-actions.ts b/src/store/collections/collection-partial-copy-actions.ts index a0c4e4e4..a0933c64 100644 --- a/src/store/collections/collection-partial-copy-actions.ts +++ b/src/store/collections/collection-partial-copy-actions.ts @@ -3,84 +3,106 @@ // 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(resetPickerProjectTree()); - dispatch(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(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(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(resetPickerProjectTree()); - dispatch(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(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(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(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 index 00000000..56f7302d --- /dev/null +++ b/src/store/collections/collection-partial-move-actions.ts @@ -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(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(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(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(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(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)); + } + } + }; diff --git a/src/store/context-menu/context-menu-actions.test.ts b/src/store/context-menu/context-menu-actions.test.ts index d9e87b1a..623c4508 100644 --- a/src/store/context-menu/context-menu-actions.test.ts +++ b/src/store/context-menu/context-menu-actions.test.ts @@ -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]: { diff --git a/src/store/context-menu/context-menu-actions.ts b/src/store/context-menu/context-menu-actions.ts index 3bc91ae0..46431487 100644 --- a/src/store/context-menu/context-menu-actions.ts +++ b/src/store/context-menu/context-menu-actions.ts @@ -2,31 +2,33 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { unionize, ofType, UnionOf } from 'common/unionize'; +import { unionize, ofType, UnionOf } from "common/unionize"; import { ContextMenuPosition } from "./context-menu-reducer"; -import { ContextMenuKind } from 'views-components/context-menu/context-menu'; -import { Dispatch } from 'redux'; -import { RootState } from 'store/store'; -import { getResource, getResourceWithEditableStatus } from '../resources/resources'; -import { UserResource } from 'models/user'; -import { isSidePanelTreeCategory } from 'store/side-panel-tree/side-panel-tree-actions'; -import { extractUuidKind, ResourceKind, EditableResource, Resource } from 'models/resource'; -import { Process } from 'store/processes/process'; -import { RepositoryResource } from 'models/repositories'; -import { SshKeyResource } from 'models/ssh-key'; -import { VirtualMachinesResource } from 'models/virtual-machines'; -import { KeepServiceResource } from 'models/keep-services'; -import { ProcessResource } from 'models/process'; -import { CollectionResource } from 'models/collection'; -import { GroupClass, GroupResource } from 'models/group'; -import { GroupContentsResource } from 'services/groups-service/groups-service'; -import { LinkResource } from 'models/link'; -import { resourceIsFrozen } from 'common/frozen-resources'; -import { ProjectResource } from 'models/project'; +import { ContextMenuKind } from "views-components/context-menu/context-menu"; +import { Dispatch } from "redux"; +import { RootState } from "store/store"; +import { getResource, getResourceWithEditableStatus } from "../resources/resources"; +import { UserResource } from "models/user"; +import { isSidePanelTreeCategory } from "store/side-panel-tree/side-panel-tree-actions"; +import { extractUuidKind, ResourceKind, EditableResource, Resource } from "models/resource"; +import { Process, isProcessCancelable } from "store/processes/process"; +import { RepositoryResource } from "models/repositories"; +import { SshKeyResource } from "models/ssh-key"; +import { VirtualMachinesResource } from "models/virtual-machines"; +import { KeepServiceResource } from "models/keep-services"; +import { ProcessResource } from "models/process"; +import { CollectionResource } from "models/collection"; +import { GroupClass, GroupResource } from "models/group"; +import { GroupContentsResource } from "services/groups-service/groups-service"; +import { LinkResource } from "models/link"; +import { resourceIsFrozen } from "common/frozen-resources"; +import { ProjectResource } from "models/project"; +import { getProcess } from "store/processes/process"; +import { filterCollectionFilesBySelection } from "store/collection-panel/collection-panel-files/collection-panel-files-state"; export const contextMenuActions = unionize({ - OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition, resource: ContextMenuResource }>(), - CLOSE_CONTEXT_MENU: ofType<{}>() + OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition; resource: ContextMenuResource }>(), + CLOSE_CONTEXT_MENU: ofType<{}>(), }); export type ContextMenuAction = UnionOf; @@ -36,7 +38,7 @@ export type ContextMenuResource = { uuid: string; ownerUuid: string; description?: string; - kind: ResourceKind, + kind: ResourceKind; menuKind: ContextMenuKind | string; isTrashed?: boolean; isEditable?: boolean; @@ -46,191 +48,214 @@ export type ContextMenuResource = { isFrozen?: boolean; storageClassesDesired?: string[]; properties?: { [key: string]: string | string[] }; + isMulti?: boolean; + fromContextMenu?: boolean; }; export const isKeyboardClick = (event: React.MouseEvent) => event.nativeEvent.detail === 0; -export const openContextMenu = (event: React.MouseEvent, 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, 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, isWritable: boolean) => (dispatch: Dispatch, getState: () => RootState) => { + const selectedCount = filterCollectionFilesBySelection(getState().collectionPanelFiles, true).length; + const multiple = selectedCount > 1; + dispatch( + 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, isWritable: boolean) => - (dispatch: Dispatch, getState: () => RootState) => { - const isCollectionFileSelected = JSON.stringify(getState().collectionPanelFiles).includes('"selected":true'); - dispatch(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, repository: RepositoryResource) => - (dispatch: Dispatch, getState: () => RootState) => { - dispatch(openContextMenu(event, { - name: '', - uuid: repository.uuid, - ownerUuid: repository.ownerUuid, - kind: ResourceKind.REPOSITORY, - menuKind: ContextMenuKind.REPOSITORY - })); +export const openRepositoryContextMenu = + (event: React.MouseEvent, repository: RepositoryResource) => (dispatch: Dispatch, getState: () => RootState) => { + dispatch( + openContextMenu(event, { + name: "", + uuid: repository.uuid, + ownerUuid: repository.ownerUuid, + kind: ResourceKind.REPOSITORY, + menuKind: ContextMenuKind.REPOSITORY, + }) + ); }; -export const openVirtualMachinesContextMenu = (event: React.MouseEvent, repository: VirtualMachinesResource) => - (dispatch: Dispatch, getState: () => RootState) => { - dispatch(openContextMenu(event, { - name: '', - uuid: repository.uuid, - ownerUuid: repository.ownerUuid, - kind: ResourceKind.VIRTUAL_MACHINE, - menuKind: ContextMenuKind.VIRTUAL_MACHINE - })); +export const openVirtualMachinesContextMenu = + (event: React.MouseEvent, repository: VirtualMachinesResource) => (dispatch: Dispatch, getState: () => RootState) => { + dispatch( + openContextMenu(event, { + name: "", + uuid: repository.uuid, + ownerUuid: repository.ownerUuid, + kind: ResourceKind.VIRTUAL_MACHINE, + menuKind: ContextMenuKind.VIRTUAL_MACHINE, + }) + ); }; -export const openSshKeyContextMenu = (event: React.MouseEvent, sshKey: SshKeyResource) => - (dispatch: Dispatch) => { - dispatch(openContextMenu(event, { - name: '', +export const openSshKeyContextMenu = (event: React.MouseEvent, sshKey: SshKeyResource) => (dispatch: Dispatch) => { + dispatch( + 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, keepService: KeepServiceResource) => - (dispatch: Dispatch) => { - dispatch(openContextMenu(event, { - name: '', +export const openKeepServiceContextMenu = (event: React.MouseEvent, keepService: KeepServiceResource) => (dispatch: Dispatch) => { + dispatch( + 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, resourceUuid: string) => - (dispatch: Dispatch) => { - dispatch(openContextMenu(event, { - name: '', - uuid: resourceUuid, - ownerUuid: '', - kind: ResourceKind.API_CLIENT_AUTHORIZATION, - menuKind: ContextMenuKind.API_CLIENT_AUTHORIZATION - })); - }; +export const openApiClientAuthorizationContextMenu = (event: React.MouseEvent, resourceUuid: string) => (dispatch: Dispatch) => { + dispatch( + openContextMenu(event, { + name: "", + uuid: resourceUuid, + ownerUuid: "", + kind: ResourceKind.API_CLIENT_AUTHORIZATION, + menuKind: ContextMenuKind.API_CLIENT_AUTHORIZATION, + }) + ); +}; -export const openRootProjectContextMenu = (event: React.MouseEvent, projectUuid: string) => - (dispatch: Dispatch, getState: () => RootState) => { +export const openRootProjectContextMenu = + (event: React.MouseEvent, projectUuid: string) => (dispatch: Dispatch, getState: () => RootState) => { const res = getResource(projectUuid)(getState().resources); if (res) { - dispatch(openContextMenu(event, { - name: '', - uuid: res.uuid, - ownerUuid: res.uuid, - kind: res.kind, - menuKind: ContextMenuKind.ROOT_PROJECT, - isTrashed: false - })); + dispatch( + openContextMenu(event, { + name: "", + uuid: res.uuid, + ownerUuid: res.uuid, + kind: res.kind, + menuKind: ContextMenuKind.ROOT_PROJECT, + isTrashed: false, + }) + ); } }; -export const openProjectContextMenu = (event: React.MouseEvent, resourceUuid: string) => - (dispatch: Dispatch, getState: () => RootState) => { +export const openProjectContextMenu = + (event: React.MouseEvent, resourceUuid: string) => (dispatch: Dispatch, getState: () => RootState) => { const res = getResource(resourceUuid)(getState().resources); const menuKind = dispatch(resourceUuidToContextMenuKind(resourceUuid)); if (res && menuKind) { - dispatch(openContextMenu(event, { - name: res.name, - uuid: res.uuid, - kind: res.kind, - menuKind, - description: res.description, - ownerUuid: res.ownerUuid, - isTrashed: ('isTrashed' in res) ? res.isTrashed : false, - isFrozen: !!(res as ProjectResource).frozenByUuid, - })); + dispatch( + 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, id: string) => - (dispatch: Dispatch, getState: () => RootState) => { - if (!isSidePanelTreeCategory(id)) { - const kind = extractUuidKind(id); - if (kind === ResourceKind.USER) { - dispatch(openRootProjectContextMenu(event, id)); - } else if (kind === ResourceKind.PROJECT) { - dispatch(openProjectContextMenu(event, id)); - } +export const openSidePanelContextMenu = (event: React.MouseEvent, id: string) => (dispatch: Dispatch, getState: () => RootState) => { + if (!isSidePanelTreeCategory(id)) { + const kind = extractUuidKind(id); + if (kind === ResourceKind.USER) { + dispatch(openRootProjectContextMenu(event, id)); + } else if (kind === ResourceKind.PROJECT) { + dispatch(openProjectContextMenu(event, id)); } - }; + } +}; -export const openProcessContextMenu = (event: React.MouseEvent, process: Process) => - (dispatch: Dispatch, getState: () => RootState) => { - const res = getResource(process.containerRequest.uuid)(getState().resources); - if (res) { - dispatch(openContextMenu(event, { +export const openProcessContextMenu = (event: React.MouseEvent, process: Process) => (dispatch: Dispatch, getState: () => RootState) => { + const res = getResource(process.containerRequest.uuid)(getState().resources); + if (res) { + dispatch( + 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, link: LinkResource) => - (dispatch: Dispatch, getState: () => RootState) => { +export const openPermissionEditContextMenu = + (event: React.MouseEvent, link: LinkResource) => (dispatch: Dispatch, getState: () => RootState) => { if (link) { - dispatch(openContextMenu(event, { - name: link.name, - uuid: link.uuid, - kind: link.kind, - menuKind: ContextMenuKind.PERMISSION_EDIT, - ownerUuid: link.ownerUuid, - })); + dispatch( + openContextMenu(event, { + name: link.name, + uuid: link.uuid, + kind: link.kind, + menuKind: ContextMenuKind.PERMISSION_EDIT, + ownerUuid: link.ownerUuid, + }) + ); } }; -export const openUserContextMenu = (event: React.MouseEvent, user: UserResource) => - (dispatch: Dispatch, getState: () => RootState) => { - dispatch(openContextMenu(event, { - name: '', +export const openUserContextMenu = (event: React.MouseEvent, user: UserResource) => (dispatch: Dispatch, getState: () => RootState) => { + dispatch( + 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(uuid, userUuid)(getState().resources); const isFrozen = resourceIsFrozen(resource, getState().resources); - const isEditable = (isAdminUser || (resource || {} as EditableResource).isEditable) && !readonly && !isFrozen; + const isEditable = (isAdminUser || (resource || ({} as EditableResource)).isEditable) && !readonly && !isFrozen; switch (kind) { case ResourceKind.PROJECT: @@ -238,56 +263,64 @@ export const resourceUuidToContextMenuKind = (uuid: string, readonly = false) => return isAdminUser ? ContextMenuKind.FROZEN_PROJECT_ADMIN : ContextMenuKind.FROZEN_PROJECT; } - return (isAdminUser && !readonly) - ? (resource && resource.groupClass !== GroupClass.FILTER) + return isAdminUser && !readonly + ? resource && resource.groupClass !== GroupClass.FILTER ? ContextMenuKind.PROJECT_ADMIN : ContextMenuKind.FILTER_GROUP_ADMIN : isEditable - ? (resource && resource.groupClass !== GroupClass.FILTER) - ? ContextMenuKind.PROJECT - : ContextMenuKind.FILTER_GROUP - : ContextMenuKind.READONLY_PROJECT; + ? resource && resource.groupClass !== GroupClass.FILTER + ? ContextMenuKind.PROJECT + : ContextMenuKind.FILTER_GROUP + : ContextMenuKind.READONLY_PROJECT; case ResourceKind.COLLECTION: const c = getResource(uuid)(getState().resources); - if (c === undefined) { return; } + if (c === undefined) { + return; + } const isOldVersion = c.uuid !== c.currentVersionUuid; const isTrashed = c.isTrashed; return isOldVersion ? ContextMenuKind.OLD_VERSION_COLLECTION - : (isTrashed && isEditable) - ? ContextMenuKind.TRASHED_COLLECTION - : (isAdminUser && isEditable) - ? ContextMenuKind.COLLECTION_ADMIN - : isEditable - ? ContextMenuKind.COLLECTION - : ContextMenuKind.READONLY_COLLECTION; + : isTrashed && isEditable + ? ContextMenuKind.TRASHED_COLLECTION + : isAdminUser && isEditable + ? ContextMenuKind.COLLECTION_ADMIN + : isEditable + ? ContextMenuKind.COLLECTION + : ContextMenuKind.READONLY_COLLECTION; case ResourceKind.PROCESS: - return (isAdminUser && isEditable) - ? ContextMenuKind.PROCESS_ADMIN + return isAdminUser && isEditable + ? resource && isProcessCancelable(getProcess(resource.uuid)(getState().resources) as Process) + ? ContextMenuKind.RUNNING_PROCESS_ADMIN + : ContextMenuKind.PROCESS_ADMIN : readonly - ? ContextMenuKind.READONLY_PROCESS_RESOURCE - : ContextMenuKind.PROCESS_RESOURCE; + ? ContextMenuKind.READONLY_PROCESS_RESOURCE + : resource && isProcessCancelable(getProcess(resource.uuid)(getState().resources) as Process) + ? ContextMenuKind.RUNNING_PROCESS_RESOURCE + : ContextMenuKind.PROCESS_RESOURCE; case ResourceKind.USER: return ContextMenuKind.ROOT_PROJECT; case ResourceKind.LINK: return ContextMenuKind.LINK; case ResourceKind.WORKFLOW: - return ContextMenuKind.WORKFLOW; + return isEditable ? ContextMenuKind.WORKFLOW : ContextMenuKind.READONLY_WORKFLOW; default: return; } }; -export const openSearchResultsContextMenu = (event: React.MouseEvent, uuid: string) => - (dispatch: Dispatch, getState: () => RootState) => { +export const openSearchResultsContextMenu = + (event: React.MouseEvent, uuid: string) => (dispatch: Dispatch, getState: () => RootState) => { const res = getResource(uuid)(getState().resources); if (res) { - dispatch(openContextMenu(event, { - name: '', - uuid: res.uuid, - ownerUuid: '', - kind: res.kind, - menuKind: ContextMenuKind.SEARCH_RESULTS, - })); + dispatch( + openContextMenu(event, { + name: "", + uuid: res.uuid, + ownerUuid: "", + kind: res.kind, + menuKind: ContextMenuKind.SEARCH_RESULTS, + }) + ); } }; diff --git a/src/store/copy-dialog/copy-dialog.ts b/src/store/copy-dialog/copy-dialog.ts index 4450cfc6..dfae5c2c 100644 --- a/src/store/copy-dialog/copy-dialog.ts +++ b/src/store/copy-dialog/copy-dialog.ts @@ -6,4 +6,5 @@ export interface CopyFormDialogData { name: string; uuid: string; ownerUuid: string; -} \ No newline at end of file + fromContextMenu?: boolean; +} diff --git a/src/store/data-explorer/data-explorer-action.ts b/src/store/data-explorer/data-explorer-action.ts index 22b786fd..ea050e60 100644 --- a/src/store/data-explorer/data-explorer-action.ts +++ b/src/store/data-explorer/data-explorer-action.ts @@ -4,64 +4,51 @@ import { unionize, ofType, UnionOf } from "common/unionize"; import { DataColumns, DataTableFetchMode } from "components/data-table/data-table"; -import { DataTableFilters } from 'components/data-table-filters/data-table-filters-tree'; +import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree"; export enum DataTableRequestState { IDLE, PENDING, - NEED_REFRESH + NEED_REFRESH, } export const dataExplorerActions = unionize({ CLEAR: ofType<{ id: string }>(), RESET_PAGINATION: ofType<{ id: string }>(), - REQUEST_ITEMS: ofType<{ id: string, criteriaChanged?: boolean }>(), - REQUEST_STATE: ofType<{ id: string, criteriaChanged?: boolean }>(), - SET_FETCH_MODE: ofType<({ id: string, fetchMode: DataTableFetchMode })>(), - SET_COLUMNS: ofType<{ id: string, columns: DataColumns }>(), - SET_FILTERS: ofType<{ id: string, columnName: string, filters: DataTableFilters }>(), - SET_ITEMS: ofType<{ id: string, items: any[], page: number, rowsPerPage: number, itemsAvailable: number }>(), - APPEND_ITEMS: ofType<{ id: string, items: any[], page: number, rowsPerPage: number, itemsAvailable: number }>(), - SET_PAGE: ofType<{ id: string, page: number }>(), - SET_ROWS_PER_PAGE: ofType<{ id: string, rowsPerPage: number }>(), - TOGGLE_COLUMN: ofType<{ id: string, columnName: string }>(), - TOGGLE_SORT: ofType<{ id: string, columnName: string }>(), - SET_EXPLORER_SEARCH_VALUE: ofType<{ id: string, searchValue: string }>(), + REQUEST_ITEMS: ofType<{ id: string; criteriaChanged?: boolean, background?: boolean }>(), + REQUEST_STATE: ofType<{ id: string; criteriaChanged?: boolean }>(), + SET_FETCH_MODE: ofType<{ id: string; fetchMode: DataTableFetchMode }>(), + SET_COLUMNS: ofType<{ id: string; columns: DataColumns }>(), + SET_FILTERS: ofType<{ id: string; columnName: string; filters: DataTableFilters }>(), + SET_ITEMS: ofType<{ id: string; items: any[]; page: number; rowsPerPage: number; itemsAvailable: number }>(), + APPEND_ITEMS: ofType<{ id: string; items: any[]; page: number; rowsPerPage: number; itemsAvailable: number }>(), + SET_PAGE: ofType<{ id: string; page: number }>(), + SET_ROWS_PER_PAGE: ofType<{ id: string; rowsPerPage: number }>(), + TOGGLE_COLUMN: ofType<{ id: string; columnName: string }>(), + TOGGLE_SORT: ofType<{ id: string; columnName: string }>(), + SET_EXPLORER_SEARCH_VALUE: ofType<{ id: string; searchValue: string }>(), RESET_EXPLORER_SEARCH_VALUE: ofType<{ id: string }>(), - SET_REQUEST_STATE: ofType<{ id: string, requestState: DataTableRequestState }>(), + SET_REQUEST_STATE: ofType<{ id: string; requestState: DataTableRequestState }>(), }); export type DataExplorerAction = UnionOf; 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 }) => - 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 }) => dataExplorerActions.SET_COLUMNS({ ...payload, id }), + SET_FILTERS: (payload: { columnName: string; filters: DataTableFilters }) => dataExplorerActions.SET_FILTERS({ ...payload, id }), + SET_ITEMS: (payload: { items: any[]; page: number; rowsPerPage: number; itemsAvailable: number }) => dataExplorerActions.SET_ITEMS({ ...payload, id }), - APPEND_ITEMS: (payload: { items: any[], page: number, rowsPerPage: number, itemsAvailable: number }) => + APPEND_ITEMS: (payload: { items: any[]; page: number; rowsPerPage: number; itemsAvailable: number }) => dataExplorerActions.APPEND_ITEMS({ ...payload, id }), - SET_PAGE: (payload: { page: number }) => - dataExplorerActions.SET_PAGE({ ...payload, id }), - SET_ROWS_PER_PAGE: (payload: { rowsPerPage: number }) => - dataExplorerActions.SET_ROWS_PER_PAGE({ ...payload, id }), - TOGGLE_COLUMN: (payload: { columnName: string }) => - dataExplorerActions.TOGGLE_COLUMN({ ...payload, id }), - TOGGLE_SORT: (payload: { columnName: string }) => - dataExplorerActions.TOGGLE_SORT({ ...payload, id }), - SET_EXPLORER_SEARCH_VALUE: (payload: { searchValue: string }) => - dataExplorerActions.SET_EXPLORER_SEARCH_VALUE({ ...payload, id }), - RESET_EXPLORER_SEARCH_VALUE: () => - dataExplorerActions.RESET_EXPLORER_SEARCH_VALUE({ id }), - SET_REQUEST_STATE: (payload: { requestState: DataTableRequestState }) => - dataExplorerActions.SET_REQUEST_STATE({ ...payload, id }) + SET_PAGE: (payload: { page: number }) => dataExplorerActions.SET_PAGE({ ...payload, id }), + SET_ROWS_PER_PAGE: (payload: { rowsPerPage: number }) => dataExplorerActions.SET_ROWS_PER_PAGE({ ...payload, id }), + TOGGLE_COLUMN: (payload: { columnName: string }) => dataExplorerActions.TOGGLE_COLUMN({ ...payload, id }), + TOGGLE_SORT: (payload: { columnName: string }) => dataExplorerActions.TOGGLE_SORT({ ...payload, id }), + SET_EXPLORER_SEARCH_VALUE: (payload: { searchValue: string }) => dataExplorerActions.SET_EXPLORER_SEARCH_VALUE({ ...payload, id }), + RESET_EXPLORER_SEARCH_VALUE: () => dataExplorerActions.RESET_EXPLORER_SEARCH_VALUE({ id }), + SET_REQUEST_STATE: (payload: { requestState: DataTableRequestState }) => dataExplorerActions.SET_REQUEST_STATE({ ...payload, id }), }); diff --git a/src/store/data-explorer/data-explorer-middleware-service.ts b/src/store/data-explorer/data-explorer-middleware-service.ts index 01964fa4..6bb95a9a 100644 --- a/src/store/data-explorer/data-explorer-middleware-service.ts +++ b/src/store/data-explorer/data-explorer-middleware-service.ts @@ -33,7 +33,8 @@ export abstract class DataExplorerMiddlewareService { abstract requestItems( api: MiddlewareAPI, - criteriaChanged?: boolean + criteriaChanged?: boolean, + background?: boolean ): Promise; } @@ -58,8 +59,10 @@ export const getOrder = (dataExplorer: DataExplor ? OrderDirection.ASC : OrderDirection.DESC; + // Use createdAt as a secondary sort column so we break ties consistently. return order .addOrder(sortDirection, sortColumn.sort.field) + .addOrder(OrderDirection.DESC, "createdAt") .getOrder(); } else { return order.getOrder(); diff --git a/src/store/data-explorer/data-explorer-middleware.ts b/src/store/data-explorer/data-explorer-middleware.ts index f83b0646..3404b375 100644 --- a/src/store/data-explorer/data-explorer-middleware.ts +++ b/src/store/data-explorer/data-explorer-middleware.ts @@ -16,98 +16,98 @@ import { DataExplorerMiddlewareService } from './data-explorer-middleware-servic export const dataExplorerMiddleware = (service: DataExplorerMiddlewareService): Middleware => - (api) => - (next) => { - const actions = bindDataExplorerActions(service.getId()); + (api) => + (next) => { + const actions = bindDataExplorerActions(service.getId()); - return (action) => { - const handleAction = - (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(async ( - dispatch: Dispatch, - getState: () => RootState, - services: ServiceRepository - ) => { - while (true) { - let de = getDataExplorer( - getState().dataExplorer, - service.getId() - ); - switch (de.requestState) { - case DataTableRequestState.IDLE: - // Start a new request. - try { - dispatch( - actions.SET_REQUEST_STATE({ - requestState: DataTableRequestState.PENDING, - }) - ); - await service.requestItems(api, criteriaChanged); - } catch { - dispatch( - actions.SET_REQUEST_STATE({ - requestState: DataTableRequestState.NEED_REFRESH, - }) - ); - } - // Now check if the state is still PENDING, if it moved to NEED_REFRESH - // then we need to reissue requestItems - de = getDataExplorer( + return (action) => { + const handleAction = + (handler: (data: T) => void) => + (data: T) => { + next(action); + if (data.id === service.getId()) { + handler(data); + } + }; + dataExplorerActions.match(action, { + SET_PAGE: handleAction(() => { + api.dispatch(actions.REQUEST_ITEMS(false)); + }), + SET_ROWS_PER_PAGE: handleAction(() => { + api.dispatch(actions.REQUEST_ITEMS(true)); + }), + SET_FILTERS: handleAction(() => { + api.dispatch(actions.RESET_PAGINATION()); + api.dispatch(actions.REQUEST_ITEMS(true)); + }), + TOGGLE_SORT: handleAction(() => { + api.dispatch(actions.REQUEST_ITEMS(true)); + }), + SET_EXPLORER_SEARCH_VALUE: handleAction(() => { + api.dispatch(actions.RESET_PAGINATION()); + api.dispatch(actions.REQUEST_ITEMS(true)); + }), + REQUEST_ITEMS: handleAction(({ criteriaChanged, background }) => { + api.dispatch(async ( + dispatch: Dispatch, + getState: () => RootState, + services: ServiceRepository + ) => { + while (true) { + let de = getDataExplorer( getState().dataExplorer, service.getId() ); - const complete = - de.requestState === DataTableRequestState.PENDING; - dispatch( - actions.SET_REQUEST_STATE({ - requestState: DataTableRequestState.IDLE, - }) - ); - if (complete) { - return; + switch (de.requestState) { + case DataTableRequestState.IDLE: + // Start a new request. + try { + dispatch( + actions.SET_REQUEST_STATE({ + requestState: DataTableRequestState.PENDING, + }) + ); + await service.requestItems(api, criteriaChanged, background); + } catch { + dispatch( + actions.SET_REQUEST_STATE({ + requestState: DataTableRequestState.NEED_REFRESH, + }) + ); + } + // Now check if the state is still PENDING, if it moved to NEED_REFRESH + // then we need to reissue requestItems + de = getDataExplorer( + getState().dataExplorer, + service.getId() + ); + const complete = + de.requestState === DataTableRequestState.PENDING; + dispatch( + actions.SET_REQUEST_STATE({ + requestState: DataTableRequestState.IDLE, + }) + ); + if (complete) { + return; + } + break; + case DataTableRequestState.PENDING: + // State is PENDING, move it to NEED_REFRESH so that when the current request finishes it starts a new one. + dispatch( + actions.SET_REQUEST_STATE({ + requestState: DataTableRequestState.NEED_REFRESH, + }) + ); + return; + case DataTableRequestState.NEED_REFRESH: + // Nothing to do right now. + return; } - break; - case DataTableRequestState.PENDING: - // State is PENDING, move it to NEED_REFRESH so that when the current request finishes it starts a new one. - dispatch( - actions.SET_REQUEST_STATE({ - requestState: DataTableRequestState.NEED_REFRESH, - }) - ); - return; - case DataTableRequestState.NEED_REFRESH: - // Nothing to do right now. - return; - } - } + } + }); + }), + default: () => next(action), }); - }), - default: () => next(action), - }); - }; - }; + }; + }; diff --git a/src/store/data-explorer/data-explorer-reducer.ts b/src/store/data-explorer/data-explorer-reducer.ts index e93d291d..a0a7eb64 100644 --- a/src/store/data-explorer/data-explorer-reducer.ts +++ b/src/store/data-explorer/data-explorer-reducer.ts @@ -70,14 +70,24 @@ export const dataExplorerReducer = ( SET_FILTERS: ({ id, columnName, filters }) => update(state, id, mapColumns(setFilters(columnName, filters))), - SET_ITEMS: ({ id, items, itemsAvailable, page, rowsPerPage }) => - update(state, id, (explorer) => ({ - ...explorer, - items, - itemsAvailable, - page: page || 0, - rowsPerPage, - })), + SET_ITEMS: ({ id, items, itemsAvailable, page, rowsPerPage }) => ( + update(state, id, (explorer) => { + // Reject updates to pages other than current, + // DataExplorer middleware should retry + const updatedPage = page || 0; + if (explorer.page === updatedPage) { + return { + ...explorer, + items, + itemsAvailable, + page: updatedPage, + rowsPerPage, + } + } else { + return explorer; + } + }) + ), APPEND_ITEMS: ({ id, items, itemsAvailable, page, rowsPerPage }) => update(state, id, (explorer) => ({ diff --git a/src/store/dialog/dialog-reducer.ts b/src/store/dialog/dialog-reducer.ts index 30368685..548d0a78 100644 --- a/src/store/dialog/dialog-reducer.ts +++ b/src/store/dialog/dialog-reducer.ts @@ -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>; @@ -12,16 +12,14 @@ export interface Dialog { } 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 = (state: DialogState, id: string) => - state[id] ? state[id] as Dialog : undefined; +export const getDialog = (state: DialogState, id: string) => (state[id] ? (state[id] as Dialog) : undefined); diff --git a/src/store/dialog/with-dialog.ts b/src/store/dialog/with-dialog.ts index ea96ca0d..7a253860 100644 --- a/src/store/dialog/with-dialog.ts +++ b/src/store/dialog/with-dialog.ts @@ -18,7 +18,8 @@ export type WithDialogDispatchProps = { }; export type WithDialogProps = WithDialogStateProps & 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 (component: React.ComponentType & any>) => @@ -26,13 +27,17 @@ export const withDialog = (id: string) => const emptyData = {}; -export const mapStateToProps = (id: string) => (state: { dialog: DialogState }): WithDialogStateProps => { - const dialog = state.dialog[id]; - return dialog ? dialog : { open: false, data: emptyData }; -}; +export const mapStateToProps = + (id: string) => + (state: { dialog: DialogState }): WithDialogStateProps => { + 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 })); + }, + }); diff --git a/src/store/favorites/favorites-actions.ts b/src/store/favorites/favorites-actions.ts index bd4d878e..da454ed7 100644 --- a/src/store/favorites/favorites-actions.ts +++ b/src/store/favorites/favorites-actions.ts @@ -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(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(removeDisabledButton(MultiSelectMenuActionNames.ADD_TO_FAVORITES)) dispatch(progressIndicatorActions.STOP_WORKING("toggleFavorite")); + dispatch(loadFavoritesTree()) }) .catch((e: any) => { dispatch(progressIndicatorActions.STOP_WORKING("toggleFavorite")); diff --git a/src/store/group-details-panel/group-details-panel-members-middleware-service.ts b/src/store/group-details-panel/group-details-panel-members-middleware-service.ts index 3a58927a..507b4eb3 100644 --- a/src/store/group-details-panel/group-details-panel-members-middleware-service.ts +++ b/src/store/group-details-panel/group-details-panel-members-middleware-service.ts @@ -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())); } } } diff --git a/src/store/link-panel/link-panel-middleware-service.ts b/src/store/link-panel/link-panel-middleware-service.ts index 87bcba0c..cc6ea8cf 100644 --- a/src/store/link-panel/link-panel-middleware-service.ts +++ b/src/store/link-panel/link-panel-middleware-service.ts @@ -12,6 +12,7 @@ import { updateResources } from 'store/resources/resources-actions'; import { ListResults } from 'services/common-service/common-service'; import { LinkResource } from 'models/link'; import { linkPanelActions } from 'store/link-panel/link-panel-actions'; +import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions"; export class LinkMiddlewareService extends DataExplorerMiddlewareService { constructor(private services: ServiceRepository, id: string) { @@ -22,11 +23,14 @@ export class LinkMiddlewareService extends DataExplorerMiddlewareService { const state = api.getState(); const dataExplorer = getDataExplorer(state.dataExplorer, this.getId()); try { + api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); const response = await this.services.linkService.list(getParams(dataExplorer)); api.dispatch(updateResources(response.items)); api.dispatch(setItems(response)); } catch { api.dispatch(couldNotFetchLinks()); + } finally { + api.dispatch(progressIndicatorActions.STOP_WORKING(this.getId())); } } } diff --git a/src/store/move-to-dialog/move-to-dialog.ts b/src/store/move-to-dialog/move-to-dialog.ts index 6261a795..e58f3984 100644 --- a/src/store/move-to-dialog/move-to-dialog.ts +++ b/src/store/move-to-dialog/move-to-dialog.ts @@ -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 index 00000000..a246ddbc --- /dev/null +++ b/src/store/multiselect/multiselect-actions.tsx @@ -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, getState: () => RootState, services: ServiceRepository) => { + try { + await services.collectionService.get(resource.outputUuid || ''); + dispatch(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 index 00000000..26b85393 --- /dev/null +++ b/src/store/multiselect/multiselect-reducer.tsx @@ -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; + } +}; diff --git a/src/store/navigation/navigation-action.ts b/src/store/navigation/navigation-action.ts index 146530ca..55112fb0 100644 --- a/src/store/navigation/navigation-action.ts +++ b/src/store/navigation/navigation-action.ts @@ -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(pushOrGoto(getNavUrl(uuid, getState().auth))); - return; - case ResourceKind.VIRTUAL_MACHINE: - dispatch(navigateToAdminVirtualMachines); - return; - case ResourceKind.WORKFLOW: - dispatch(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(pushOrGoto(getNavUrl(usr.uuid, getState().auth))); - } - return; - case SidePanelTreeCategory.FAVORITES: - dispatch(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(pushOrGoto(getNavUrl(uuid, getState().auth))); + return; + case ResourceKind.VIRTUAL_MACHINE: + dispatch(navigateToAdminVirtualMachines); + return; + case ResourceKind.WORKFLOW: + dispatch(pushOrGoto(getNavUrl(uuid, getState().auth))); + // dispatch(openDetailsPanel(uuid)); + return; + } - dispatch(navigationNotAvailable(uuid)); - }; + switch (uuid) { + case SidePanelTreeCategory.PROJECTS: + const usr = getState().auth.user; + if (usr) { + dispatch(pushOrGoto(getNavUrl(usr.uuid, getState().auth))); + } + return; + case SidePanelTreeCategory.FAVORITES: + dispatch(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 }); } diff --git a/src/store/open-in-new-tab/open-in-new-tab.actions.ts b/src/store/open-in-new-tab/open-in-new-tab.actions.ts index 6b9db6a5..83055e32 100644 --- a/src/store/open-in-new-tab/open-in-new-tab.actions.ts +++ b/src/store/open-in-new-tab/open-in-new-tab.actions.ts @@ -2,28 +2,39 @@ // // SPDX-License-Identifier: AGPL-3.0 -import copy from 'copy-to-clipboard'; -import { Dispatch } from 'redux'; -import { getNavUrl } from 'routes/routes'; -import { RootState } from 'store/store'; +import copy from "copy-to-clipboard"; +import { Dispatch } from "redux"; +import { getNavUrl } from "routes/routes"; +import { RootState } from "store/store"; +import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions"; export const openInNewTabAction = (resource: any) => (dispatch: Dispatch, getState: () => RootState) => { const url = getNavUrl(resource.uuid, getState().auth); - if (url[0] === '/') { - window.open(`${window.location.origin}${url}`, '_blank'); + if (url[0] === "/") { + window.open(`${window.location.origin}${url}`, "_blank"); } else if (url.length) { - window.open(url, '_blank'); + window.open(url, "_blank"); } }; -export const copyToClipboardAction = (resource: any) => (dispatch: Dispatch, getState: () => RootState) => { +export const copyToClipboardAction = (resources: Array) => (dispatch: Dispatch, getState: () => RootState) => { // Copy to clipboard omits token to avoid accidental sharing - const url = getNavUrl(resource.uuid, getState().auth, false); - if (url[0] === '/') { - copy(`${window.location.origin}${url}`); - } else if (url.length) { - copy(url); + let url = getNavUrl(resources[0].uuid, getState().auth, false); + let wasCopied; + + if (url[0] === "/") wasCopied = copy(`${window.location.origin}${url}`); + else if (url.length) { + wasCopied = copy(url); } + + if (wasCopied) + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: "Copied", + hideDuration: 2000, + kind: SnackbarKind.SUCCESS, + }) + ); }; diff --git a/src/store/process-logs-panel/process-logs-panel-actions.ts b/src/store/process-logs-panel/process-logs-panel-actions.ts index 16177f18..88b56a2c 100644 --- a/src/store/process-logs-panel/process-logs-panel-actions.ts +++ b/src/store/process-logs-panel/process-logs-panel-actions.ts @@ -3,28 +3,42 @@ // SPDX-License-Identifier: AGPL-3.0 import { unionize, ofType, UnionOf } from "common/unionize"; -import { ProcessLogs, getProcessLogsPanelCurrentUuid } from './process-logs-panel'; +import { ProcessLogs } from './process-logs-panel'; import { LogEventType } from 'models/log'; import { RootState } from 'store/store'; import { ServiceRepository } from 'services/services'; import { Dispatch } from 'redux'; -import { groupBy, min, reverse } from 'lodash'; -import { LogResource } from 'models/log'; -import { LogService } from 'services/log-service/log-service'; -import { ResourceEventMessage } from 'websocket/resource-event-message'; -import { getProcess } from 'store/processes/process'; -import { FilterBuilder } from "services/api/filter-builder"; -import { OrderBuilder } from "services/api/order-builder"; +import { LogFragment, LogService, logFileToLogType } from 'services/log-service/log-service'; +import { Process, getProcess } from 'store/processes/process'; import { navigateTo } from 'store/navigation/navigation-action'; import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; +import { CollectionFile, CollectionFileType } from "models/collection-file"; +import { ContainerRequestResource, ContainerRequestState } from "models/container-request"; + +const SNIPLINE = `================ ✀ ================ ✀ ========= Some log(s) were skipped ========= ✀ ================ ✀ ================`; +const LOG_TIMESTAMP_PATTERN = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{9}Z/; export const processLogsPanelActions = unionize({ RESET_PROCESS_LOGS_PANEL: ofType<{}>(), INIT_PROCESS_LOGS_PANEL: ofType<{ filters: string[], logs: ProcessLogs }>(), SET_PROCESS_LOGS_PANEL_FILTER: ofType(), - ADD_PROCESS_LOGS_PANEL_ITEM: ofType<{ logType: string, log: string }>(), + ADD_PROCESS_LOGS_PANEL_ITEM: ofType(), }); +// 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; export const setProcessLogsPanelFilter = (filter: string) => @@ -32,112 +46,289 @@ export const setProcessLogsPanelFilter = (filter: string) => export const initProcessLogsPanel = (processUuid: string) => async (dispatch: Dispatch, getState: () => RootState, { logService }: ServiceRepository) => { - dispatch(processLogsPanelActions.RESET_PROCESS_LOGS_PANEL()); - const process = getProcess(processUuid)(getState().resources); - const maxPageSize = getState().auth.config.clusterConfig.API.MaxItemsPerResponse; - if (process && process.container) { - const logResources = await loadContainerLogs(process.container.uuid, logService, maxPageSize); - const initialState = createInitialLogPanelState(logResources); + let process: Process | undefined; + try { + dispatch(processLogsPanelActions.RESET_PROCESS_LOGS_PANEL()); + process = getProcess(processUuid)(getState().resources); + if (process?.containerRequest?.uuid) { + // Get log file size info + const logFiles = await loadContainerLogFileList(process.containerRequest, logService); + + // Populate lastbyte 0 for each file + const filesWithProgress = logFiles.map((file) => ({ file, lastByte: 0 })); + + // Fetch array of LogFragments + const logLines = await loadContainerLogFileContents(filesWithProgress, logService, process); + + // Populate initial state with filters + const initialState = createInitialLogPanelState(logFiles, logLines); + dispatch(processLogsPanelActions.INIT_PROCESS_LOGS_PANEL(initialState)); + } + } catch (e) { + // On error, populate empty state to allow polling to start + const initialState = createInitialLogPanelState([], []); dispatch(processLogsPanelActions.INIT_PROCESS_LOGS_PANEL(initialState)); + // Only show toast on errors other than 404 since 404 is expected when logs do not exist yet + if (e.status !== 404) { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Error loading process logs', hideDuration: 4000, kind: SnackbarKind.ERROR })); + } + if (e.status === 404 && process?.containerRequest.state === ContainerRequestState.FINAL) { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Log collection was trashed or deleted.', hideDuration: 4000, kind: SnackbarKind.WARNING })); + } } }; -export const addProcessLogsPanelItem = (message: ResourceEventMessage<{ text: string }>) => +export const pollProcessLogs = (processUuid: string) => async (dispatch: Dispatch, getState: () => RootState, { logService }: ServiceRepository) => { - if (PROCESS_PANEL_LOG_EVENT_TYPES.indexOf(message.eventType) > -1) { - const uuid = getProcessLogsPanelCurrentUuid(getState().router); - if (!uuid) { return } - const process = getProcess(uuid)(getState().resources); - if (!process) { return } - const { containerRequest, container } = process; - if (message.objectUuid === containerRequest.uuid - || (container && message.objectUuid === container.uuid)) { - dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM({ - logType: ALL_FILTER_TYPE, - log: message.properties.text - })); - dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM({ - logType: message.eventType, - log: message.properties.text - })); - if (MAIN_EVENT_TYPES.indexOf(message.eventType) > -1) { - dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM({ - logType: MAIN_FILTER_TYPE, - log: message.properties.text - })); + try { + // Get log panel state and process from store + const currentState = getState().processLogsPanel; + const process = getProcess(processUuid)(getState().resources); + + // Check if container request is present and initial logs state loaded + if (process?.containerRequest?.uuid && Object.keys(currentState.logs).length > 0) { + const logFiles = await loadContainerLogFileList(process.containerRequest, logService); + + // Determine byte to fetch from while filtering unchanged files + const filesToUpdateWithProgress = logFiles.reduce((acc, updatedFile) => { + // Fetch last byte or 0 for new log files + const currentStateLogLastByte = currentState.logs[logFileToLogType(updatedFile)]?.lastByte || 0; + + const isNew = !Object.keys(currentState.logs).find((currentStateLogName) => (updatedFile.name.startsWith(currentStateLogName))); + const isChanged = !isNew && currentStateLogLastByte < updatedFile.size; + + if (isNew || isChanged) { + return acc.concat({ file: updatedFile, lastByte: currentStateLogLastByte }); + } else { + return acc; + } + }, [] as FileWithProgress[]); + + // Perform range request(s) for each file + const logFragments = await loadContainerLogFileContents(filesToUpdateWithProgress, logService, process); + + if (logFragments.length) { + // Convert LogFragments to ProcessLogs with All/Main sorting & line-merging + const groupedLogs = groupLogs(logFiles, logFragments); + await dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM(groupedLogs)); } } + return Promise.resolve(); + } catch (e) { + // Remove log when polling error is handled in some way instead of being ignored + console.error("Error occurred in pollProcessLogs:", e); + return Promise.reject(); } }; -const loadContainerLogs = async (containerUuid: string, logService: LogService, maxPageSize: number) => { - const requestFilters = new FilterBuilder() - .addEqual('object_uuid', containerUuid) - .addIn('event_type', PROCESS_PANEL_LOG_EVENT_TYPES) - .getFilters(); - const requestOrderAsc = new OrderBuilder() - .addAsc('eventAt') - .getOrder(); - const requestOrderDesc = new OrderBuilder() - .addDesc('eventAt') - .getOrder(); - const { items, itemsAvailable } = await logService.list({ - limit: maxPageSize, - filters: requestFilters, - order: requestOrderAsc, - }); - - // Request additional logs if necessary - const remainingLogs = itemsAvailable - items.length; - if (remainingLogs > 0) { - const { items: itemsLast } = await logService.list({ - limit: min([maxPageSize, remainingLogs]), - filters: requestFilters, - order: requestOrderDesc, - count: 'none', - }) - if (remainingLogs - itemsLast.length > 0) { - const snipLine = { - ...items[items.length - 1], - eventType: LogEventType.SNIP, - properties: { - text: `================ 8< ================ 8< ========= Some log(s) were skipped ========= 8< ================ 8< ================` - }, - } - return [...items, snipLine, ...reverse(itemsLast)]; +const loadContainerLogFileList = async (containerRequest: ContainerRequestResource, logService: LogService) => { + const logCollectionContents = await logService.listLogFiles(containerRequest); + + // Filter only root directory files matching log event types which have bytes + return logCollectionContents.filter((file): file is CollectionFile => ( + file.type === CollectionFileType.FILE && + PROCESS_PANEL_LOG_EVENT_TYPES.indexOf(logFileToLogType(file)) > -1 && + file.size > 0 + )); +}; + +/** + * Loads the contents of each file from each file's lastByte simultaneously + * while respecting the maxLogFetchSize by requesting the start and end + * of the desired block and inserting a snipline. + * @param logFilesWithProgress CollectionFiles with the last byte previously loaded + * @param logService + * @param process + * @returns LogFragment[] containing a single LogFragment corresponding to each input file + */ +const loadContainerLogFileContents = async (logFilesWithProgress: FileWithProgress[], logService: LogService, process: Process) => ( + (await Promise.allSettled(logFilesWithProgress.filter(({ file }) => file.size > 0).map(({ file, lastByte }) => { + const requestSize = file.size - lastByte; + if (requestSize > maxLogFetchSize) { + const chunkSize = Math.floor(maxLogFetchSize / 2); + const firstChunkEnd = lastByte + chunkSize - 1; + return Promise.all([ + logService.getLogFileContents(process.containerRequest, file, lastByte, firstChunkEnd), + logService.getLogFileContents(process.containerRequest, file, file.size - chunkSize, file.size - 1) + ] as Promise<(LogFragment)>[]); + } else { + return Promise.all([logService.getLogFileContents(process.containerRequest, file, lastByte, file.size - 1)]); } - return [...items, ...reverse(itemsLast)]; + })).then((res) => { + if (res.length && res.every(promiseResult => (promiseResult.status === 'rejected'))) { + // Since allSettled does not pass promise rejection we throw an + // error if every request failed + const error = res.find( + (promiseResult): promiseResult is PromiseRejectedResult => promiseResult.status === 'rejected' + )?.reason; + return Promise.reject(error); + } + return res.filter((promiseResult): promiseResult is PromiseFulfilledResult => ( + // Filter out log files with rejected promises + // (Promise.all rejects on any failure) + promiseResult.status === 'fulfilled' && + // Filter out files where any fragment is empty + // (prevent incorrect snipline generation or an un-resumable situation) + !!promiseResult.value.every(logFragment => logFragment.contents.length) + )).map(one => one.value) + })).map((logResponseSet) => { + // For any multi fragment response set, modify the last line of non-final chunks to include a line break and snip line + // Don't add snip line as a separate line so that sorting won't reorder it + for (let i = 1; i < logResponseSet.length; i++) { + const fragment = logResponseSet[i - 1]; + const lastLineIndex = fragment.contents.length - 1; + const lastLineContents = fragment.contents[lastLineIndex]; + const newLastLine = `${lastLineContents}\n${SNIPLINE}`; + + logResponseSet[i - 1].contents[lastLineIndex] = newLastLine; + } + + // Merge LogFragment Array (representing multiple log line arrays) into single LogLine[] / LogFragment + return logResponseSet.reduce((acc, curr: LogFragment) => ({ + logType: curr.logType, + contents: [...(acc.contents || []), ...curr.contents] + }), {} as LogFragment); + }) +); + +const createInitialLogPanelState = (logFiles: CollectionFile[], logFragments: LogFragment[]): { filters: string[], logs: ProcessLogs } => { + const logs = groupLogs(logFiles, logFragments); + const filters = Object.keys(logs); + return { filters, logs }; +} + +/** + * Converts LogFragments into ProcessLogs, grouping and sorting All/Main logs + * @param logFiles + * @param logFragments + * @returns ProcessLogs for the store + */ +const groupLogs = (logFiles: CollectionFile[], logFragments: LogFragment[]): ProcessLogs => { + const sortableLogFragments = mergeMultilineLoglines(logFragments); + + const allLogs = mergeSortLogFragments(sortableLogFragments); + const mainLogs = mergeSortLogFragments(sortableLogFragments.filter((fragment) => (MAIN_EVENT_TYPES.includes(fragment.logType)))); + + const groupedLogs = logFragments.reduce((grouped, fragment) => ({ + ...grouped, + [fragment.logType as string]: { lastByte: fetchLastByteNumber(logFiles, fragment.logType), contents: fragment.contents } + }), {}); + + return { + [MAIN_FILTER_TYPE]: { lastByte: undefined, contents: mainLogs }, + [ALL_FILTER_TYPE]: { lastByte: undefined, contents: allLogs }, + ...groupedLogs, } - return items; }; -const createInitialLogPanelState = (logResources: LogResource[]) => { - const allLogs = logsToLines(logResources); - const mainLogs = logsToLines(logResources.filter( - e => MAIN_EVENT_TYPES.indexOf(e.eventType) > -1 - )); - const groupedLogResources = groupBy(logResources, log => log.eventType); - const groupedLogs = Object - .keys(groupedLogResources) - .reduce((grouped, key) => ({ - ...grouped, - [key]: logsToLines(groupedLogResources[key]) - }), {}); - const filters = [ - MAIN_FILTER_TYPE, - ALL_FILTER_TYPE, - ...Object.keys(groupedLogs) - ].filter(e => e !== LogEventType.SNIP); - const logs = { - [MAIN_FILTER_TYPE]: mainLogs, - [ALL_FILTER_TYPE]: allLogs, - ...groupedLogs - }; - return { filters, logs }; +/** + * Checks for non-timestamped log lines and merges them with the previous line, assumes they are multi-line logs + * If there is no previous line (first line has no timestamp), the line is deleted. + * Only used for combined logs that need sorting by timestamp after merging + * @param logFragments + * @returns Modified LogFragment[] + */ +const mergeMultilineLoglines = (logFragments: LogFragment[]) => ( + logFragments.map((fragment) => { + // Avoid altering the original fragment copy + let fragmentCopy: LogFragment = { + logType: fragment.logType, + contents: [...fragment.contents], + } + // Merge any non-timestamped lines in sortable log types with previous line + if (fragmentCopy.contents.length && !NON_SORTED_LOG_TYPES.includes(fragmentCopy.logType)) { + for (let i = 0; i < fragmentCopy.contents.length; i++) { + const lineContents = fragmentCopy.contents[i]; + if (!lineContents.match(LOG_TIMESTAMP_PATTERN)) { + // Partial line without timestamp detected + if (i > 0) { + // If not first line, copy line to previous line + const previousLineContents = fragmentCopy.contents[i - 1]; + const newPreviousLineContents = `${previousLineContents}\n${lineContents}`; + fragmentCopy.contents[i - 1] = newPreviousLineContents; + } + // Delete the current line and prevent iterating + fragmentCopy.contents.splice(i, 1); + i--; + } + } + } + return fragmentCopy; + }) +); + +/** + * Merges log lines of different types and sorts types that contain timestamps (are sortable) + * @param logFragments + * @returns string[] of merged and sorted log lines + */ +const mergeSortLogFragments = (logFragments: LogFragment[]): string[] => { + const sortableFragments = logFragments + .filter((fragment) => (!NON_SORTED_LOG_TYPES.includes(fragment.logType))); + + const nonSortableLines = fragmentsToLines(logFragments + .filter((fragment) => (NON_SORTED_LOG_TYPES.includes(fragment.logType))) + .sort((a, b) => (a.logType.localeCompare(b.logType)))); + + return [...nonSortableLines, ...sortLogFragments(sortableFragments)]; +}; + +/** + * Performs merge and sort of input log fragment lines + * @param logFragments set of sortable log fragments to be merged and sorted + * @returns A string array containing all lines, sorted by timestamp and + * preserving line ordering and type grouping when timestamps match + */ +const sortLogFragments = (logFragments: LogFragment[]): string[] => { + const linesWithType: SortableLine[] = logFragments + // Map each logFragment into an array of SortableLine + .map((fragment: LogFragment): SortableLine[] => ( + fragment.contents.map((singleLine: string) => { + const timestampMatch = singleLine.match(LOG_TIMESTAMP_PATTERN); + const timestamp = timestampMatch && timestampMatch[0] ? timestampMatch[0] : ""; + return { + logType: fragment.logType, + timestamp: timestamp, + contents: singleLine, + }; + }) + // Merge each array of SortableLine into single array + )).reduce((acc: SortableLine[], lines: SortableLine[]) => ( + [...acc, ...lines] + ), [] as SortableLine[]); + + return linesWithType + .sort(sortableLineSortFunc) + .map(lineWithType => lineWithType.contents); +}; + +/** + * Sort func to sort lines + * Preserves original ordering of lines from the same source + * Stably orders lines of differing type but same timestamp + * (produces a block of same-timestamped lines of one type before a block + * of same timestamped lines of another type for readability) + * Sorts all other lines by contents (ie by timestamp) + */ +const sortableLineSortFunc = (a: SortableLine, b: SortableLine) => { + if (a.logType === b.logType) { + return 0; + } else if (a.timestamp === b.timestamp) { + return a.logType.localeCompare(b.logType); + } else { + return a.contents.localeCompare(b.contents); + } }; -const logsToLines = (logs: LogResource[]) => - logs.map(({ properties }) => properties.text); +const fragmentsToLines = (fragments: LogFragment[]): string[] => ( + fragments.reduce((acc, fragment: LogFragment) => ( + acc.concat(...fragment.contents) + ), [] as string[]) +); + +const fetchLastByteNumber = (logFiles: CollectionFile[], key: string) => { + return logFiles.find((file) => (file.name.startsWith(key)))?.size +}; export const navigateToLogCollection = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { @@ -145,7 +336,7 @@ export const navigateToLogCollection = (uuid: string) => await services.collectionService.get(uuid); dispatch(navigateTo(uuid)); } catch { - dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not request collection', hideDuration: 2000, kind: SnackbarKind.ERROR })); + dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Log collection was trashed or deleted.', hideDuration: 4000, kind: SnackbarKind.WARNING })); } }; @@ -156,7 +347,6 @@ const MAIN_EVENT_TYPES = [ LogEventType.CRUNCH_RUN, LogEventType.STDERR, LogEventType.STDOUT, - LogEventType.SNIP, ]; const PROCESS_PANEL_LOG_EVENT_TYPES = [ @@ -170,5 +360,9 @@ const PROCESS_PANEL_LOG_EVENT_TYPES = [ LogEventType.STDOUT, LogEventType.CONTAINER, LogEventType.KEEPSTORE, - LogEventType.SNIP, +]; + +const NON_SORTED_LOG_TYPES = [ + LogEventType.NODE_INFO, + LogEventType.CONTAINER, ]; diff --git a/src/store/process-logs-panel/process-logs-panel-reducer.ts b/src/store/process-logs-panel/process-logs-panel-reducer.ts index c502f1b1..e7dd3562 100644 --- a/src/store/process-logs-panel/process-logs-panel-reducer.ts +++ b/src/store/process-logs-panel/process-logs-panel-reducer.ts @@ -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, diff --git a/src/store/process-logs-panel/process-logs-panel.ts b/src/store/process-logs-panel/process-logs-panel.ts index 0ca5d679..531d3723 100644 --- a/src/store/process-logs-panel/process-logs-panel.ts +++ b/src/store/process-logs-panel/process-logs-panel.ts @@ -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) => { diff --git a/src/store/process-panel/process-panel-actions.ts b/src/store/process-panel/process-panel-actions.ts index b361f7ac..2111afdb 100644 --- a/src/store/process-panel/process-panel-actions.ts +++ b/src/store/process-panel/process-panel-actions.ts @@ -3,24 +3,24 @@ // SPDX-License-Identifier: AGPL-3.0 import { unionize, ofType, UnionOf } from "common/unionize"; -import { getInputs, getOutputParameters, getRawInputs, getRawOutputs, loadProcess } from 'store/processes/processes-actions'; -import { Dispatch } from 'redux'; -import { ProcessStatus } from 'store/processes/process'; -import { RootState } from 'store/store'; +import { getInputs, getOutputParameters, getRawInputs, getRawOutputs, loadProcess } from "store/processes/processes-actions"; +import { Dispatch } from "redux"; +import { ProcessStatus } from "store/processes/process"; +import { RootState } from "store/store"; import { ServiceRepository } from "services/services"; -import { navigateTo, navigateToWorkflows } from 'store/navigation/navigation-action'; -import { snackbarActions } from 'store/snackbar/snackbar-actions'; -import { SnackbarKind } from '../snackbar/snackbar-actions'; -import { showWorkflowDetails } from 'store/workflow-panel/workflow-panel-actions'; -import { loadSubprocessPanel } from "../subprocess-panel/subprocess-panel-actions"; +import { navigateTo } from "store/navigation/navigation-action"; +import { snackbarActions } from "store/snackbar/snackbar-actions"; +import { SnackbarKind } from "../snackbar/snackbar-actions"; +import { loadSubprocessPanel, subprocessPanelActions } from "../subprocess-panel/subprocess-panel-actions"; import { initProcessLogsPanel, processLogsPanelActions } from "store/process-logs-panel/process-logs-panel-actions"; import { CollectionFile } from "models/collection-file"; import { ContainerRequestResource } from "models/container-request"; -import { CommandOutputParameter } from 'cwlts/mappings/v1.0/CommandOutputParameter'; -import { CommandInputParameter, getIOParamId, WorkflowInputsData } from 'models/workflow'; +import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter"; +import { CommandInputParameter, getIOParamId, WorkflowInputsData } from "models/workflow"; import { getIOParamDisplayValue, ProcessIOParameter } from "views/process-panel/process-io-card"; import { OutputDetails, NodeInstanceType, NodeInfo } from "./process-panel"; import { AuthState } from "store/auth/auth-reducer"; +import { ContextMenuResource } from "store/context-menu/context-menu-actions"; export const processPanelActions = unionize({ RESET_PROCESS_PANEL: ofType<{}>(), @@ -39,40 +39,44 @@ export type ProcessPanelAction = UnionOf; export const toggleProcessPanelFilter = processPanelActions.TOGGLE_PROCESS_PANEL_FILTER; -export const loadProcessPanel = (uuid: string) => - async (dispatch: Dispatch) => { - dispatch(processPanelActions.RESET_PROCESS_PANEL()); - dispatch(processLogsPanelActions.RESET_PROCESS_LOGS_PANEL()); - dispatch(processPanelActions.SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID(uuid)); - await dispatch(loadProcess(uuid)); - dispatch(initProcessPanelFilters); - dispatch(initProcessLogsPanel(uuid)); - dispatch(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(processPanelActions.SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID(uuid)); + await dispatch(loadProcess(uuid)); + dispatch(initProcessPanelFilters); + dispatch(initProcessLogsPanel(uuid)); + dispatch(loadSubprocessPanel()); +}; -export const navigateToOutput = (uuid: string) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - try { - await services.collectionService.get(uuid); - dispatch(navigateTo(uuid)); - } catch { - dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'This collection does not exists!', hideDuration: 2000, kind: SnackbarKind.ERROR })); - } - }; +export const navigateToOutput = (resource: ContextMenuResource | ContainerRequestResource) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + try { + await services.collectionService.get(resource.outputUuid || ''); + dispatch(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, getState: () => RootState, services: ServiceRepository) => { +export const loadInputs = + (containerRequest: ContainerRequestResource) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { dispatch(processPanelActions.SET_INPUT_RAW(getRawInputs(containerRequest))); dispatch(processPanelActions.SET_INPUT_PARAMS(formatInputData(getInputs(containerRequest), getState().auth))); }; -export const loadOutputs = (containerRequest: ContainerRequestResource) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { +export const loadOutputs = + (containerRequest: ContainerRequestResource) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const noOutputs = { rawOutputs: {} }; + if (!containerRequest.outputUuid) { - dispatch(processPanelActions.SET_OUTPUT_RAW(noOutputs)); + dispatch(processPanelActions.SET_OUTPUT_RAW({ uuid: containerRequest.uuid, outputRaw: noOutputs })); return; - }; + } try { const propsOutputs = getRawOutputs(containerRequest); const filesPromise = services.collectionService.files(containerRequest.outputUuid); @@ -81,48 +85,53 @@ export const loadOutputs = (containerRequest: ContainerRequestResource) => // If has propsOutput, skip fetching cwl.output.json if (propsOutputs !== undefined) { - dispatch(processPanelActions.SET_OUTPUT_RAW({ - rawOutputs: propsOutputs, - pdh: collection.portableDataHash - })); + dispatch( + processPanelActions.SET_OUTPUT_RAW({ + rawOutputs: propsOutputs, + pdh: collection.portableDataHash, + }) + ); } else { // Fetch outputs from keep - const outputFile = files.find((file) => file.name === 'cwl.output.json') as CollectionFile | undefined; + const outputFile = files.find(file => file.name === "cwl.output.json") as CollectionFile | undefined; let outputData = outputFile ? await services.collectionService.getFileContents(outputFile) : undefined; if (outputData && (outputData = JSON.parse(outputData)) && collection.portableDataHash) { - dispatch(processPanelActions.SET_OUTPUT_RAW({ - rawOutputs: outputData, - pdh: collection.portableDataHash, - })); + dispatch( + processPanelActions.SET_OUTPUT_RAW({ + uuid: containerRequest.uuid, + outputRaw: { rawOutputs: outputData, pdh: collection.portableDataHash }, + }) + ); } else { - dispatch(processPanelActions.SET_OUTPUT_RAW(noOutputs)); + dispatch(processPanelActions.SET_OUTPUT_RAW({ uuid: containerRequest.uuid, outputRaw: noOutputs })); } } } catch { - dispatch(processPanelActions.SET_OUTPUT_RAW(noOutputs)); + dispatch(processPanelActions.SET_OUTPUT_RAW({ uuid: containerRequest.uuid, outputRaw: noOutputs })); } }; - -export const loadNodeJson = (containerRequest: ContainerRequestResource) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { +export const loadNodeJson = + (containerRequest: ContainerRequestResource) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const noLog = { nodeInfo: null }; if (!containerRequest.logUuid) { dispatch(processPanelActions.SET_NODE_INFO(noLog)); return; - }; + } try { const filesPromise = services.collectionService.files(containerRequest.logUuid); const collectionPromise = services.collectionService.get(containerRequest.logUuid); const [files] = await Promise.all([filesPromise, collectionPromise]); // Fetch node.json from keep - const nodeFile = files.find((file) => file.name === 'node.json') as CollectionFile | undefined; + const nodeFile = files.find(file => file.name === "node.json") as CollectionFile | undefined; let nodeData = nodeFile ? await services.collectionService.getFileContents(nodeFile) : undefined; if (nodeData && (nodeData = JSON.parse(nodeData))) { - dispatch(processPanelActions.SET_NODE_INFO({ - nodeInfo: nodeData as NodeInstanceType - })); + dispatch( + processPanelActions.SET_NODE_INFO({ + nodeInfo: nodeData as NodeInstanceType, + }) + ); } else { dispatch(processPanelActions.SET_NODE_INFO(noLog)); } @@ -131,28 +140,27 @@ export const loadNodeJson = (containerRequest: ContainerRequestResource) => } }; -export const loadOutputDefinitions = (containerRequest: ContainerRequestResource) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { +export const loadOutputDefinitions = + (containerRequest: ContainerRequestResource) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { if (containerRequest && containerRequest.mounts) { dispatch(processPanelActions.SET_OUTPUT_DEFINITIONS(getOutputParameters(containerRequest))); } }; -export const updateOutputParams = () => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const outputDefinitions = getState().processPanel.outputDefinitions; - const outputRaw = getState().processPanel.outputRaw; +export const updateOutputParams = () => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const outputDefinitions = getState().processPanel.outputDefinitions; + const outputRaw = getState().processPanel.outputRaw; - if (outputRaw !== null && outputRaw.rawOutputs) { - dispatch(processPanelActions.SET_OUTPUT_PARAMS(formatOutputData(outputDefinitions, outputRaw.rawOutputs, outputRaw.pdh, getState().auth))); - } - }; + if (outputRaw && outputRaw.rawOutputs) { + dispatch( + processPanelActions.SET_OUTPUT_PARAMS(formatOutputData(outputDefinitions, outputRaw.rawOutputs, outputRaw.pdh, getState().auth)) + ); + } +}; -export const openWorkflow = (uuid: string) => - (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - dispatch(navigateToWorkflows); - dispatch(showWorkflowDetails(uuid)); - }; +export const openWorkflow = (uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(navigateTo(uuid)); +}; export const initProcessPanelFilters = processPanelActions.SET_PROCESS_PANEL_FILTERS([ ProcessStatus.QUEUED, @@ -162,25 +170,30 @@ export const initProcessPanelFilters = processPanelActions.SET_PROCESS_PANEL_FIL ProcessStatus.ONHOLD, ProcessStatus.FAILING, ProcessStatus.WARNING, - ProcessStatus.CANCELLED + ProcessStatus.CANCELLED, ]); -const formatInputData = (inputs: CommandInputParameter[], auth: AuthState): ProcessIOParameter[] => { +export const formatInputData = (inputs: CommandInputParameter[], auth: AuthState): ProcessIOParameter[] => { return inputs.map(input => { return { id: getIOParamId(input), label: input.label || "", - value: getIOParamDisplayValue(auth, input) + value: getIOParamDisplayValue(auth, input), }; }); }; -const formatOutputData = (definitions: CommandOutputParameter[], values: any, pdh: string | undefined, auth: AuthState): ProcessIOParameter[] => { +export const formatOutputData = ( + definitions: CommandOutputParameter[], + values: any, + pdh: string | undefined, + auth: AuthState +): ProcessIOParameter[] => { return definitions.map(output => { return { id: getIOParamId(output), label: output.label || "", - value: getIOParamDisplayValue(auth, Object.assign(output, { value: values[getIOParamId(output)] || [] }), pdh) + value: getIOParamDisplayValue(auth, Object.assign(output, { value: values[getIOParamId(output)] || [] }), pdh), }; }); }; diff --git a/src/store/process-panel/process-panel-reducer.ts b/src/store/process-panel/process-panel-reducer.ts index 8e190ead..ea6de66d 100644 --- a/src/store/process-panel/process-panel-reducer.ts +++ b/src/store/process-panel/process-panel-reducer.ts @@ -2,8 +2,8 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { ProcessPanel } from 'store/process-panel/process-panel'; -import { ProcessPanelAction, processPanelActions } from 'store/process-panel/process-panel-actions'; +import { ProcessPanel } from "store/process-panel/process-panel"; +import { ProcessPanelAction, processPanelActions } from "store/process-panel/process-panel-actions"; const initialState: ProcessPanel = { containerRequestUuid: "", @@ -20,7 +20,8 @@ export const processPanelReducer = (state = initialState, action: ProcessPanelAc processPanelActions.match(action, { RESET_PROCESS_PANEL: () => initialState, SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID: containerRequestUuid => ({ - ...state, containerRequestUuid + ...state, + containerRequestUuid, }), SET_PROCESS_PANEL_FILTERS: statuses => { const filters = statuses.reduce((filters, status) => ({ ...filters, [status]: true }), {}); @@ -48,8 +49,12 @@ export const processPanelReducer = (state = initialState, action: ProcessPanelAc return state; } }, - SET_OUTPUT_RAW: outputRaw => { - return { ...state, outputRaw }; + SET_OUTPUT_RAW: (data: any) => { + //never set output to {} unless initializing + if (state.outputRaw?.rawOutputs && Object.keys(state.outputRaw?.rawOutputs).length && state.containerRequestUuid === data.uuid) { + return state; + } + return { ...state, outputRaw: data.outputRaw }; }, SET_NODE_INFO: ({ nodeInfo }) => { return { ...state, nodeInfo }; @@ -57,7 +62,7 @@ export const processPanelReducer = (state = initialState, action: ProcessPanelAc SET_OUTPUT_DEFINITIONS: outputDefinitions => { // Set output definitions is only additive to avoid clearing when mounts go temporarily missing if (outputDefinitions.length) { - return { ...state, outputDefinitions } + return { ...state, outputDefinitions }; } else { return state; } diff --git a/src/store/processes/process-copy-actions.ts b/src/store/processes/process-copy-actions.ts index 3c55a9ad..36d73940 100644 --- a/src/store/processes/process-copy-actions.ts +++ b/src/store/processes/process-copy-actions.ts @@ -2,22 +2,23 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { Dispatch } from "redux"; -import { dialogActions } from "store/dialog/dialog-actions"; +import { Dispatch } from 'redux'; +import { dialogActions } from 'store/dialog/dialog-actions'; import { initialize, startSubmit } from 'redux-form'; import { resetPickerProjectTree } from 'store/project-tree-picker/project-tree-picker-actions'; import { RootState } from 'store/store'; import { ServiceRepository } from 'services/services'; import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog'; import { getProcess } from 'store/processes/process'; -import {snackbarActions, SnackbarKind} from 'store/snackbar/snackbar-actions'; +import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions'; -import { ContainerRequestState } from "models/container-request"; +import { ContainerRequestState } from 'models/container-request'; export const PROCESS_COPY_FORM_NAME = 'processCopyFormName'; +export const MULTI_PROCESS_COPY_FORM_NAME = 'multiProcessCopyFormName'; -export const openCopyProcessDialog = (resource: { name: string, uuid: string }) => - (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { +export const openCopyProcessDialog = + (resource: { name: string; uuid: string }) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const process = getProcess(resource.uuid)(getState().resources); if (process) { dispatch(resetPickerProjectTree()); @@ -30,57 +31,56 @@ export const openCopyProcessDialog = (resource: { name: string, uuid: string }) } }; -export const copyProcess = (resource: CopyFormDialogData) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - dispatch(startSubmit(PROCESS_COPY_FORM_NAME)); - try { - const process = await services.containerRequestService.get(resource.uuid); - const { - command, - containerCountMax, - containerImage, - cwd, - description, - environment, - kind, - mounts, - outputName, - outputPath, - outputProperties, - outputStorageClasses, - outputTtl, - properties, - runtimeConstraints, - schedulingParameters, - useExisting, - } = process; - const newProcess = await services.containerRequestService.create({ - command, - containerCountMax, - containerImage, - cwd, - description, - environment, - kind, - mounts, - name: resource.name, - outputName, - outputPath, - outputProperties, - outputStorageClasses, - outputTtl, - ownerUuid: resource.ownerUuid, - priority: 500, - properties, - runtimeConstraints, - schedulingParameters, - state: ContainerRequestState.UNCOMMITTED, - useExisting, - }); - dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME })); - return newProcess; - } catch (e) { - dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME })); - throw new Error('Could not copy the process.'); - } - }; +export const copyProcess = (resource: CopyFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(startSubmit(PROCESS_COPY_FORM_NAME)); + try { + const process = await services.containerRequestService.get(resource.uuid); + const { + command, + containerCountMax, + containerImage, + cwd, + description, + environment, + kind, + mounts, + outputName, + outputPath, + outputProperties, + outputStorageClasses, + outputTtl, + properties, + runtimeConstraints, + schedulingParameters, + useExisting, + } = process; + const newProcess = await services.containerRequestService.create({ + command, + containerCountMax, + containerImage, + cwd, + description, + environment, + kind, + mounts, + name: resource.name, + outputName, + outputPath, + outputProperties, + outputStorageClasses, + outputTtl, + ownerUuid: resource.ownerUuid, + priority: 500, + properties, + runtimeConstraints, + schedulingParameters, + state: ContainerRequestState.UNCOMMITTED, + useExisting, + }); + dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME })); + return newProcess; + } catch (e) { + dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME })); + throw new Error('Could not copy the process.'); + } +}; diff --git a/src/store/processes/process-move-actions.ts b/src/store/processes/process-move-actions.ts index 78703e19..c3ac75f9 100644 --- a/src/store/processes/process-move-actions.ts +++ b/src/store/processes/process-move-actions.ts @@ -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(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; + } +}; diff --git a/src/store/processes/process-update-actions.ts b/src/store/processes/process-update-actions.ts index c7fd1b55..c7bd2c7b 100644 --- a/src/store/processes/process-update-actions.ts +++ b/src/store/processes/process-update-actions.ts @@ -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; } diff --git a/src/store/processes/process.ts b/src/store/processes/process.ts index ad0a14c7..a31fd9ea 100644 --- a/src/store/processes/process.ts +++ b/src/store/processes/process.ts @@ -26,6 +26,8 @@ export enum ProcessStatus { RUNNING = 'Running', WARNING = 'Warning', UNKNOWN = 'Unknown', + REUSED = 'Reused', + CANCELLING = 'Cancelling', } export const getProcess = (uuid: string) => (resources: ResourcesState): Process | undefined => { @@ -83,6 +85,7 @@ export const getProcessStatusStyles = (status: string, theme: ArvadosTheme): Rea running = true; break; case ProcessStatus.COMPLETED: + case ProcessStatus.REUSED: color = theme.customs.colors.green800; break; case ProcessStatus.WARNING: @@ -93,6 +96,10 @@ export const getProcessStatusStyles = (status: string, theme: ArvadosTheme): Rea color = theme.customs.colors.red900; running = true; break; + case ProcessStatus.CANCELLING: + color = theme.customs.colors.red900; + running = true; + break; case ProcessStatus.CANCELLED: case ProcessStatus.FAILED: color = theme.customs.colors.red900; @@ -113,7 +120,7 @@ export const getProcessStatusStyles = (status: string, theme: ArvadosTheme): Rea // Set text color to status color when running, else use white text for solid button color: running ? color : theme.palette.common.white, // Set border color when running, else omit the style entirely - ...(running ? {border: `2px solid ${color}`} : {}), + ...(running ? { border: `2px solid ${color}` } : {}), }; }; @@ -122,17 +129,39 @@ export const getProcessStatus = ({ containerRequest, container }: Process): Proc case containerRequest.containerUuid && !container: return ProcessStatus.UNKNOWN; + case containerRequest.state === ContainerRequestState.UNCOMMITTED: + return ProcessStatus.DRAFT; + + case containerRequest.state === ContainerRequestState.FINAL && + container?.state === ContainerState.RUNNING: + // It is about to be completed but we haven't + // gotten the updated container record yet, + // if we don't catch this and show it as "Running" + // it will flicker "Cancelled" briefly + return ProcessStatus.RUNNING; + case containerRequest.state === ContainerRequestState.FINAL && container?.state !== ContainerState.COMPLETE: // Request was finalized before its container started (or the // container was cancelled) return ProcessStatus.CANCELLED; - case containerRequest.state === ContainerRequestState.UNCOMMITTED: - return ProcessStatus.DRAFT; - - case container?.state === ContainerState.COMPLETE: + case container && container.state === ContainerState.COMPLETE: if (container?.exitCode === 0) { + if (containerRequest && container.finishedAt) { + // don't compare on createdAt because the container can + // have a slightly earlier creation time when it is created + // in the same transaction as the container request. + // use finishedAt because most people will assume "reused" means + // no additional work needed to be done, it's possible + // to share a running container but calling it "reused" in that case + // is more likely to just be confusing. + const finishedAt = new Date(container.finishedAt).getTime(); + const createdAt = new Date(containerRequest.createdAt).getTime(); + if (finishedAt < createdAt) { + return ProcessStatus.REUSED; + } + } return ProcessStatus.COMPLETED; } return ProcessStatus.FAILED; @@ -148,6 +177,9 @@ export const getProcessStatus = ({ containerRequest, container }: Process): Proc return ProcessStatus.QUEUED; case container?.state === ContainerState.RUNNING: + if (container?.priority === 0) { + return ProcessStatus.CANCELLING; + } if (!!container?.runtimeStatus.error) { return ProcessStatus.FAILING; } @@ -161,6 +193,10 @@ export const getProcessStatus = ({ containerRequest, container }: Process): Proc } }; +export const isProcessRunning = ({ container }: Process): boolean => ( + container?.state === ContainerState.RUNNING +); + export const isProcessRunnable = ({ containerRequest }: Process): boolean => ( containerRequest.state === ContainerRequestState.UNCOMMITTED ); @@ -170,15 +206,15 @@ export const isProcessResumable = ({ containerRequest, container }: Process): bo containerRequest.priority === 0 && // Don't show run button when container is present & running or cancelled !(container && (container.state === ContainerState.RUNNING || - container.state === ContainerState.CANCELLED || - container.state === ContainerState.COMPLETE)) + container.state === ContainerState.CANCELLED || + container.state === ContainerState.COMPLETE)) ); export const isProcessCancelable = ({ containerRequest, container }: Process): boolean => ( containerRequest.priority !== null && containerRequest.priority > 0 && container !== undefined && - (container.state === ContainerState.QUEUED || + (container.state === ContainerState.QUEUED || container.state === ContainerState.LOCKED || container.state === ContainerState.RUNNING) ); diff --git a/src/store/processes/processes-actions.ts b/src/store/processes/processes-actions.ts index b26c2017..eadb05e5 100644 --- a/src/store/processes/processes-actions.ts +++ b/src/store/processes/processes-actions.ts @@ -3,20 +3,20 @@ // SPDX-License-Identifier: AGPL-3.0 import { Dispatch } from "redux"; -import { RootState } from 'store/store'; -import { ServiceRepository } from 'services/services'; -import { updateResources } from 'store/resources/resources-actions'; -import { Process } from './process'; -import { dialogActions } from 'store/dialog/dialog-actions'; -import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; -import { projectPanelActions } from 'store/project-panel/project-panel-action'; -import { navigateToRunProcess } from 'store/navigation/navigation-action'; -import { goToStep, runProcessPanelActions } from 'store/run-process-panel/run-process-panel-actions'; -import { getResource } from 'store/resources/resources'; +import { RootState } from "store/store"; +import { ServiceRepository } from "services/services"; +import { updateResources } from "store/resources/resources-actions"; +import { Process } from "./process"; +import { dialogActions } from "store/dialog/dialog-actions"; +import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions"; +import { projectPanelActions } from "store/project-panel/project-panel-action-bind"; +import { navigateToRunProcess } from "store/navigation/navigation-action"; +import { goToStep, runProcessPanelActions } from "store/run-process-panel/run-process-panel-actions"; +import { getResource } from "store/resources/resources"; import { initialize } from "redux-form"; import { RUN_PROCESS_BASIC_FORM, RunProcessBasicFormData } from "views/run-process-panel/run-process-basic-form"; import { RunProcessAdvancedFormData, RUN_PROCESS_ADVANCED_FORM } from "views/run-process-panel/run-process-advanced-form"; -import { MOUNT_PATH_CWL_WORKFLOW, MOUNT_PATH_CWL_INPUT } from 'models/process'; +import { MOUNT_PATH_CWL_WORKFLOW, MOUNT_PATH_CWL_INPUT } from "models/process"; import { CommandInputParameter, getWorkflow, getWorkflowInputs, getWorkflowOutputs, WorkflowInputsData } from "models/workflow"; import { ProjectResource } from "models/project"; import { UserResource } from "models/user"; @@ -24,8 +24,13 @@ import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParamet import { ContainerResource } from "models/container"; import { ContainerRequestResource, ContainerRequestState } from "models/container-request"; import { FilterBuilder } from "services/api/filter-builder"; +import { selectedToArray } from "components/multiselect-toolbar/MultiselectToolbar"; +import { Resource, ResourceKind } from "models/resource"; +import { ContextMenuResource } from "store/context-menu/context-menu-actions"; +import { CommonResourceServiceError, getCommonResourceServiceError } from "services/common-service/common-resource-service"; -export const loadProcess = (containerRequestUuid: string) => +export const loadProcess = + (containerRequestUuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise => { let containerRequest: ContainerRequestResource | undefined = undefined; try { @@ -49,7 +54,7 @@ export const loadProcess = (containerRequestUuid: string) => dispatch(updateResources([container])); } catch {} - try{ + try { if (container && container.runtimeUserUuid) { const runtimeUser = await services.userService.get(container.runtimeUserUuid, false); dispatch(updateResources([runtimeUser])); @@ -61,12 +66,13 @@ export const loadProcess = (containerRequestUuid: string) => return { containerRequest }; }; -export const loadContainers = (containerUuids: string[], loadMounts: boolean = true) => +export const loadContainers = + (containerUuids: string[], loadMounts: boolean = true) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { let args: any = { - filters: new FilterBuilder().addIn('uuid', containerUuids).getFilters(), + filters: new FilterBuilder().addIn("uuid", containerUuids).getFilters(), limit: containerUuids.length, - }; + }; if (!loadMounts) { args.select = containerFieldsNoMounts; } @@ -111,62 +117,62 @@ const containerFieldsNoMounts = [ "scheduling_parameters", "started_at", "state", + "subrequests_cost", "uuid", -] +]; -export const cancelRunningWorkflow = (uuid: string) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - try { - const process = await services.containerRequestService.update(uuid, { priority: 0 }); - dispatch(updateResources([process])); - if (process.containerUuid) { - const container = await services.containerService.get(process.containerUuid, false); - dispatch(updateResources([container])); - } - return process; - } catch (e) { - throw new Error('Could not cancel the process.'); +export const cancelRunningWorkflow = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + try { + const process = await services.containerRequestService.update(uuid, { priority: 0 }); + dispatch(updateResources([process])); + if (process.containerUuid) { + const container = await services.containerService.get(process.containerUuid, false); + dispatch(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(updateResources([process])); - if (process.containerUuid) { - const container = await services.containerService.get(process.containerUuid, false); - dispatch(updateResources([container])); - } - return process; - } catch (e) { - throw new Error('Could not resume the process.'); +export const resumeOnHoldWorkflow = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + try { + const process = await services.containerRequestService.update(uuid, { priority: 500 }); + dispatch(updateResources([process])); + if (process.containerUuid) { + const container = await services.containerService.get(process.containerUuid, false); + dispatch(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(updateResources([process])); - dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Process started', hideDuration: 2000, kind: SnackbarKind.SUCCESS })); - } else { - dispatch(snackbarActions.OPEN_SNACKBAR({ message: `Failed to start process`, kind: SnackbarKind.ERROR })); - } - } catch (e) { +export const startWorkflow = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + try { + const process = await services.containerRequestService.update(uuid, { state: ContainerRequestState.COMMITTED }); + if (process) { + dispatch(updateResources([process])); + dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Process started", hideDuration: 2000, kind: SnackbarKind.SUCCESS })); + } else { dispatch(snackbarActions.OPEN_SNACKBAR({ message: `Failed to start process`, kind: SnackbarKind.ERROR })); } - }; + } catch (e) { + dispatch(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(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 }; @@ -180,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(initialize(RUN_PROCESS_ADVANCED_FORM, advancedInitialData)); @@ -199,34 +205,40 @@ export const reRunProcess = (processUuid: string, workflowUuid: string) => * Returns {} if inputs not found in mounts or props */ export const getRawInputs = (data: any): WorkflowInputsData | undefined => { - if (!data) { return undefined; } + if (!data) { + return undefined; + } const mountInput = data.mounts?.[MOUNT_PATH_CWL_INPUT]?.content; const propsInput = data.properties?.cwl_input; - if (!mountInput && !propsInput) { return {}; } - return (mountInput || propsInput); -} + if (!mountInput && !propsInput) { + return {}; + } + return mountInput || propsInput; +}; export const getInputs = (data: any): CommandInputParameter[] => { // Definitions from mounts are needed so we return early if missing - if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) { return []; } - const content = getRawInputs(data) as any; + if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) { + return []; + } + const content = getRawInputs(data) as any; // Only escape if content is falsy to allow displaying definitions if no inputs are present // (Don't check raw content length) - if (!content) { return []; } + if (!content) { + return []; + } const inputs = getWorkflowInputs(data.mounts[MOUNT_PATH_CWL_WORKFLOW].content); - return inputs ? inputs.map( - (it: any) => ( - { - type: it.type, - id: it.id, - label: it.label, - default: content[it.id], - value: content[it.id.split('/').pop()] || [], - doc: it.doc - } - ) - ) : []; + return inputs + ? inputs.map((it: any) => ({ + type: it.type, + id: it.id, + label: it.label, + default: content[it.id], + value: content[it.id.split("/").pop()] || [], + doc: it.doc, + })) + : []; }; /* @@ -234,25 +246,27 @@ export const getInputs = (data: any): CommandInputParameter[] => { * Assumes containerRequest is loaded */ export const getRawOutputs = (data: any): CommandInputParameter[] | undefined => { - if (!data || !data.properties || !data.properties.cwl_output) { return undefined; } - return (data.properties.cwl_output); -} + if (!data || !data.properties || !data.properties.cwl_output) { + return undefined; + } + return data.properties.cwl_output; +}; export type InputCollectionMount = { path: string; pdh: string; -} +}; export const getInputCollectionMounts = (data: any): InputCollectionMount[] => { - if (!data || !data.mounts) { return []; } + if (!data || !data.mounts) { + return []; + } return Object.keys(data.mounts) .map(key => ({ ...data.mounts[key], path: key, })) - .filter(mount => mount.kind === 'collection' && - mount.portable_data_hash && - mount.path) + .filter(mount => mount.kind === "collection" && mount.portable_data_hash && mount.path) .map(mount => ({ path: mount.path, pdh: mount.portable_data_hash, @@ -260,39 +274,70 @@ export const getInputCollectionMounts = (data: any): InputCollectionMount[] => { }; export const getOutputParameters = (data: any): CommandOutputParameter[] => { - if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) { return []; } + if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) { + return []; + } const outputs = getWorkflowOutputs(data.mounts[MOUNT_PATH_CWL_WORKFLOW].content); - return outputs ? outputs.map( - (it: any) => ( - { - type: it.type, - id: it.id, - label: it.label, - doc: it.doc - } - ) - ) : []; + return outputs + ? outputs.map((it: any) => ({ + type: it.type, + id: it.id, + label: it.label, + doc: it.doc, + })) + : []; }; -export const openRemoveProcessDialog = (uuid: string) => - (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - dispatch(dialogActions.OPEN_DIALOG({ - id: REMOVE_PROCESS_DIALOG, - data: { - title: 'Remove process permanently', - text: 'Are you sure you want to remove this process?', - confirmButtonLabel: 'Remove', - uuid - } - })); +export const openRemoveProcessDialog = + (resource: ContextMenuResource, numOfProcesses: Number) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const confirmationText = + numOfProcesses === 1 + ? "Are you sure you want to remove this process?" + : `Are you sure you want to remove these ${numOfProcesses} processes?`; + const titleText = numOfProcesses === 1 ? "Remove process permanently" : "Remove processes permanently"; + + dispatch( + dialogActions.OPEN_DIALOG({ + id: REMOVE_PROCESS_DIALOG, + data: { + title: titleText, + text: confirmationText, + confirmButtonLabel: "Remove", + uuid: resource.uuid, + resource, + }, + }) + ); }; -export const REMOVE_PROCESS_DIALOG = 'removeProcessDialog'; +export const REMOVE_PROCESS_DIALOG = "removeProcessDialog"; -export const removeProcessPermanently = (uuid: string) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...', kind: SnackbarKind.INFO })); - await services.containerRequestService.delete(uuid); - dispatch(projectPanelActions.REQUEST_ITEMS()); - dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS })); - }; +export const removeProcessPermanently = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const resource = getState().dialog.removeProcessDialog.data.resource; + const checkedList = getState().multiselect.checkedList; + + const uuidsToRemove: string[] = resource.fromContextMenu ? [resource.uuid] : selectedToArray(checkedList); + + //if no items in checkedlist, default to normal context menu behavior + if (!uuidsToRemove.length) uuidsToRemove.push(uuid); + + const processesToRemove = uuidsToRemove + .map(uuid => getResource(uuid)(getState().resources) as Resource) + .filter(resource => resource.kind === ResourceKind.PROCESS); + + for (const process of processesToRemove) { + try { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Removing ...", kind: SnackbarKind.INFO })); + await services.containerRequestService.delete(process.uuid, false); + dispatch(projectPanelActions.REQUEST_ITEMS()); + dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Removed.", hideDuration: 2000, kind: SnackbarKind.SUCCESS })); + } catch (e) { + const error = getCommonResourceServiceError(e); + if (error === CommonResourceServiceError.PERMISSION_ERROR_FORBIDDEN) { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: `Access denied`, hideDuration: 2000, kind: SnackbarKind.ERROR })); + } else { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: `Deletion failed`, hideDuration: 2000, kind: SnackbarKind.ERROR })); + } + } + } +}; diff --git a/src/store/project-panel/project-panel-action-bind.ts b/src/store/project-panel/project-panel-action-bind.ts new file mode 100644 index 00000000..31a5f8d6 --- /dev/null +++ b/src/store/project-panel/project-panel-action-bind.ts @@ -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); diff --git a/src/store/project-panel/project-panel-action.ts b/src/store/project-panel/project-panel-action.ts index 3b30f4aa..305799e8 100644 --- a/src/store/project-panel/project-panel-action.ts +++ b/src/store/project-panel/project-panel-action.ts @@ -2,25 +2,24 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { Dispatch } from 'redux'; -import { bindDataExplorerActions } from "store/data-explorer/data-explorer-action"; +import { Dispatch } from "redux"; import { propertiesActions } from "store/properties/properties-actions"; -import { RootState } from 'store/store'; +import { RootState } from "store/store"; import { getProperty } from "store/properties/properties"; +import { loadProject } from "store/workbench/workbench-actions"; +import { projectPanelActions } from "store/project-panel/project-panel-action-bind"; export const PROJECT_PANEL_ID = "projectPanel"; export const PROJECT_PANEL_CURRENT_UUID = "projectPanelCurrentUuid"; -export const IS_PROJECT_PANEL_TRASHED = 'isProjectPanelTrashed'; -export const projectPanelActions = bindDataExplorerActions(PROJECT_PANEL_ID); +export const IS_PROJECT_PANEL_TRASHED = "isProjectPanelTrashed"; -export const openProjectPanel = (projectUuid: string) => - (dispatch: Dispatch) => { - dispatch(propertiesActions.SET_PROPERTY({ key: PROJECT_PANEL_CURRENT_UUID, value: projectUuid })); - dispatch(projectPanelActions.RESET_EXPLORER_SEARCH_VALUE()); - dispatch(projectPanelActions.REQUEST_ITEMS()); - }; +export const openProjectPanel = (projectUuid: string) => async (dispatch: Dispatch) => { + await dispatch(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(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 }); diff --git a/src/store/project-panel/project-panel-middleware-service.ts b/src/store/project-panel/project-panel-middleware-service.ts index 7051d062..366e15ae 100644 --- a/src/store/project-panel/project-panel-middleware-service.ts +++ b/src/store/project-panel/project-panel-middleware-service.ts @@ -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) { + async requestItems(api: MiddlewareAPI, 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(updateFavorites(resourceUuids)); @@ -64,36 +63,40 @@ export class ProjectPanelMiddlewareService extends DataExplorerMiddlewareService await api.dispatch(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(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.includes(resource.containerUuid) - ? [...uuids, resource.containerUuid] - : uuids; - }, [] as string[]); - if (containerUuids.length > 0) { - await dispatch(loadContainers( - containerUuids, - 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(loadContainers(containerUuids, false)); + } +}; export const setItems = (listResults: ListResults) => 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; const typeFilters = serializeResourceTypeFilters(getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.TYPE)); - const statusColumnFilters = getDataExplorerColumnFilters(columns, 'Status'); - const activeStatusFilter = Object.keys(statusColumnFilters).find( - filterName => statusColumnFilters[filterName].selected - ); + const statusColumnFilters = getDataExplorerColumnFilters(columns, "Status"); + const activeStatusFilter = Object.keys(statusColumnFilters).find(filterName => statusColumnFilters[filterName].selected); // TODO: Extract group contents name filter const nameFilters = new FilterBuilder() @@ -124,30 +126,23 @@ export const getFilters = (dataExplorer: DataExplorer) => { .getFilters(); // Filter by container status - const statusFilters = buildProcessStatusFilters( - new FilterBuilder(), - activeStatusFilter || '', - GroupContentsResourcePrefix.PROCESS).getFilters(); + const statusFilters = buildProcessStatusFilters(new FilterBuilder(), activeStatusFilter || "", GroupContentsResourcePrefix.PROCESS).getFilters(); - return joinFilters( - statusFilters, - typeFilters, - nameFilters, - ); + return joinFilters(statusFilters, typeFilters, nameFilters); }; const getOrder = (dataExplorer: DataExplorer) => { const sortColumn = getSortColumn(dataExplorer); const order = new OrderBuilder(); if (sortColumn && sortColumn.sort) { - const sortDirection = sortColumn.sort.direction === SortDirection.ASC - ? OrderDirection.ASC - : OrderDirection.DESC; + const sortDirection = sortColumn.sort.direction === SortDirection.ASC ? OrderDirection.ASC : OrderDirection.DESC; + // Use createdAt as a secondary sort column so we break ties consistently. return order .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.COLLECTION) .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROCESS) .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROJECT) + .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS) .getOrder(); } else { return order.getOrder(); @@ -156,18 +151,18 @@ const getOrder = (dataExplorer: DataExplorer) => { const projectPanelCurrentUuidIsNotSet = () => snackbarActions.OPEN_SNACKBAR({ - message: 'Project panel is not opened.', - kind: SnackbarKind.ERROR + message: "Project panel is not opened.", + kind: SnackbarKind.ERROR, }); const couldNotFetchProjectContents = () => snackbarActions.OPEN_SNACKBAR({ - message: 'Could not fetch project contents.', - kind: SnackbarKind.ERROR + message: "Could not fetch project contents.", + kind: SnackbarKind.ERROR, }); const projectPanelDataExplorerIsNotSet = () => snackbarActions.OPEN_SNACKBAR({ - message: 'Project panel is not ready.', - kind: SnackbarKind.ERROR + message: "Project panel is not ready.", + kind: SnackbarKind.ERROR, }); diff --git a/src/store/projects/project-lock-actions.ts b/src/store/projects/project-lock-actions.ts index 98ebb384..28e934d1 100644 --- a/src/store/projects/project-lock-actions.ts +++ b/src/store/projects/project-lock-actions.ts @@ -4,31 +4,34 @@ import { Dispatch } from "redux"; import { ServiceRepository } from "services/services"; -import { projectPanelActions } from "store/project-panel/project-panel-action"; +import { projectPanelActions } from "store/project-panel/project-panel-action-bind"; import { loadResource } from "store/resources/resources-actions"; import { RootState } from "store/store"; +import { MultiSelectMenuActionNames } from "views-components/multiselect-toolbar/ms-menu-actions"; +import { addDisabledButton, removeDisabledButton } from "store/multiselect/multiselect-actions"; -export const freezeProject = (uuid: string) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const userUUID = getState().auth.user!.uuid; +export const freezeProject = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(addDisabledButton(MultiSelectMenuActionNames.FREEZE_PROJECT)) + const userUUID = getState().auth.user!.uuid; + + const updatedProject = await services.projectService.update(uuid, { + frozenByUuid: userUUID, + }); + + dispatch(projectPanelActions.REQUEST_ITEMS()); + dispatch(loadResource(uuid, false)); + dispatch(removeDisabledButton(MultiSelectMenuActionNames.FREEZE_PROJECT)) + return updatedProject; +}; - const updatedProject = await services.projectService.update(uuid, { - frozenByUuid: userUUID - }); +export const unfreezeProject = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(addDisabledButton(MultiSelectMenuActionNames.FREEZE_PROJECT)) + const updatedProject = await services.projectService.update(uuid, { + frozenByUuid: null, + }); - dispatch(projectPanelActions.REQUEST_ITEMS()); - dispatch(loadResource(uuid, false)); - return updatedProject; - }; - -export const unfreezeProject = (uuid: string) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - - const updatedProject = await services.projectService.update(uuid, { - frozenByUuid: null - }); - - dispatch(projectPanelActions.REQUEST_ITEMS()); - dispatch(loadResource(uuid, false)); - return updatedProject; - }; \ No newline at end of file + dispatch(projectPanelActions.REQUEST_ITEMS()); + dispatch(loadResource(uuid, false)); + dispatch(removeDisabledButton(MultiSelectMenuActionNames.FREEZE_PROJECT)) + return updatedProject; +}; diff --git a/src/store/projects/project-move-actions.ts b/src/store/projects/project-move-actions.ts index 963070ca..97cd5dbe 100644 --- a/src/store/projects/project-move-actions.ts +++ b/src/store/projects/project-move-actions.ts @@ -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(resetPickerProjectTree()); dispatch(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(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(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; + } +}; diff --git a/src/store/projects/project-update-actions.ts b/src/store/projects/project-update-actions.ts index 057c7cfa..81249031 100644 --- a/src/store/projects/project-update-actions.ts +++ b/src/store/projects/project-update-actions.ts @@ -3,22 +3,12 @@ // SPDX-License-Identifier: AGPL-3.0 import { Dispatch } from "redux"; -import { - FormErrors, - formValueSelector, - initialize, - reset, - startSubmit, - stopSubmit -} from 'redux-form'; +import { FormErrors, formValueSelector, initialize, reset, startSubmit, stopSubmit } from "redux-form"; import { RootState } from "store/store"; import { dialogActions } from "store/dialog/dialog-actions"; -import { - getCommonResourceServiceError, - CommonResourceServiceError -} from "services/common-service/common-resource-service"; +import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service"; import { ServiceRepository } from "services/services"; -import { projectPanelActions } from 'store/project-panel/project-panel-action'; +import { projectPanelActions } from "store/project-panel/project-panel-action-bind"; import { GroupClass } from "models/group"; import { Participant } from "views-components/sharing-dialog/participant-select"; import { ProjectProperties } from "./project-create-actions"; @@ -34,26 +24,27 @@ export interface ProjectUpdateFormDialogData { properties?: ProjectProperties; } -export const PROJECT_UPDATE_FORM_NAME = 'projectUpdateFormName'; -export const PROJECT_UPDATE_PROPERTIES_FORM_NAME = 'projectUpdatePropertiesFormName'; +export const PROJECT_UPDATE_FORM_NAME = "projectUpdateFormName"; +export const PROJECT_UPDATE_PROPERTIES_FORM_NAME = "projectUpdatePropertiesFormName"; export const PROJECT_UPDATE_FORM_SELECTOR = formValueSelector(PROJECT_UPDATE_FORM_NAME); -export const openProjectUpdateDialog = (resource: ProjectUpdateFormDialogData) => - (dispatch: Dispatch, getState: () => RootState) => { - // Get complete project resource from store to handle consumers passing in partial resources - const project = getResource(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(resource.uuid)(getState().resources); + dispatch(initialize(PROJECT_UPDATE_FORM_NAME, project)); + dispatch( + dialogActions.OPEN_DIALOG({ id: PROJECT_UPDATE_FORM_NAME, data: { sourcePanel: GroupClass.PROJECT, - } - })); - }; + }, + }) + ); +}; -export const updateProject = (project: ProjectUpdateFormDialogData) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const uuid = project.uuid || ''; +export const updateProject = + (project: ProjectUpdateFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const uuid = project.uuid || ""; dispatch(startSubmit(PROJECT_UPDATE_FORM_NAME)); try { const updatedProject = await services.projectService.update( @@ -63,7 +54,8 @@ export const updateProject = (project: ProjectUpdateFormDialogData) => description: project.description, properties: project.properties, }, - false); + false + ); dispatch(projectPanelActions.REQUEST_ITEMS()); dispatch(reset(PROJECT_UPDATE_FORM_NAME)); dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_UPDATE_FORM_NAME })); @@ -71,16 +63,17 @@ export const updateProject = (project: ProjectUpdateFormDialogData) => } catch (e) { const error = getCommonResourceServiceError(e); if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) { - dispatch(stopSubmit(PROJECT_UPDATE_FORM_NAME, { name: 'Project with the same name already exists.' } as FormErrors)); + dispatch(stopSubmit(PROJECT_UPDATE_FORM_NAME, { name: "Project with the same name already exists." } as FormErrors)); } else { dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_UPDATE_FORM_NAME })); - const errMsg = e.errors - ? e.errors.join('') - : 'There was an error while updating the project'; - dispatch(snackbarActions.OPEN_SNACKBAR({ - message: errMsg, - hideDuration: 2000, - kind: SnackbarKind.ERROR })); + const errMsg = e.errors ? e.errors.join("") : "There was an error while updating the project"; + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: errMsg, + hideDuration: 2000, + kind: SnackbarKind.ERROR, + }) + ); } return; } diff --git a/src/store/public-favorites/public-favorites-actions.ts b/src/store/public-favorites/public-favorites-actions.ts index 2d4539ad..0f8ed6c2 100644 --- a/src/store/public-favorites/public-favorites-actions.ts +++ b/src/store/public-favorites/public-favorites-actions.ts @@ -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; export const togglePublicFavorite = (resource: { uuid: string; name: string }) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise => { dispatch(progressIndicatorActions.START_WORKING("togglePublicFavorite")); + dispatch(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(removeDisabledButton(MultiSelectMenuActionNames.ADD_TO_PUBLIC_FAVORITES)) dispatch(progressIndicatorActions.STOP_WORKING("togglePublicFavorite")); + dispatch(loadPublicFavoritesTree()) }) .catch((e: any) => { dispatch(progressIndicatorActions.STOP_WORKING("togglePublicFavorite")); diff --git a/src/store/resource-type-filters/resource-type-filters.test.ts b/src/store/resource-type-filters/resource-type-filters.test.ts index 5972f60c..216a59c7 100644 --- a/src/store/resource-type-filters/resource-type-filters.test.ts +++ b/src/store/resource-type-filters/resource-type-filters.test.ts @@ -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); diff --git a/src/store/resource-type-filters/resource-type-filters.ts b/src/store/resource-type-filters/resource-type-filters.ts index 361b52a6..e1448f64 100644 --- a/src/store/resource-type-filters/resource-type-filters.ts +++ b/src/store/resource-type-filters/resource-type-filters.ts @@ -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; } diff --git a/src/store/resources/resources-actions.ts b/src/store/resources/resources-actions.ts index 1d1355a8..aff338f0 100644 --- a/src/store/resources/resources-actions.ts +++ b/src/store/resources/resources-actions.ts @@ -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(), + SET_RESOURCES: ofType(), DELETE_RESOURCES: ofType() }); diff --git a/src/store/resources/resources-reducer.ts b/src/store/resources/resources-reducer.ts index bb0cd383..02b8f38f 100644 --- a/src/store/resources/resources-reducer.ts +++ b/src/store/resources/resources-reducer.ts @@ -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 diff --git a/src/store/resources/resources.test.ts b/src/store/resources/resources.test.ts index 300e21d1..64e19fe5 100644 --- a/src/store/resources/resources.test.ts +++ b/src/store/resources/resources.test.ts @@ -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 +}); diff --git a/src/store/resources/resources.ts b/src/store/resources/resources.ts index 6f7acada..bf82fac1 100644 --- a/src/store/resources/resources.ts +++ b/src/store/resources/resources.ts @@ -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 = (id: string, userUuid?: string) => +export const getResourceWithEditableStatus = (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; diff --git a/src/store/run-process-panel/run-process-panel-actions.test.ts b/src/store/run-process-panel/run-process-panel-actions.test.ts index c615f216..77c8c4a7 100644 --- a/src/store/run-process-panel/run-process-panel-actions.test.ts +++ b/src/store/run-process-panel/run-process-panel-actions.test.ts @@ -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", diff --git a/src/store/run-process-panel/run-process-panel-actions.ts b/src/store/run-process-panel/run-process-panel-actions.ts index e0dada5c..000f0cd9 100644 --- a/src/store/run-process-panel/run-process-panel-actions.ts +++ b/src/store/run-process-panel/run-process-panel-actions.ts @@ -103,8 +103,7 @@ export const setWorkflow = (workflow: WorkflowResource, isWorkflowChanged = true const advancedFormValues = getWorkflowRunnerSettings(workflow); let owner = getResource(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, 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, diff --git a/src/store/search-results-panel/search-results-middleware-service.ts b/src/store/search-results-panel/search-results-middleware-service.ts index c13092d4..00a69cd2 100644 --- a/src/store/search-results-panel/search-results-middleware-service.ts +++ b/src/store/search-results-panel/search-results-middleware-service.ts @@ -76,7 +76,7 @@ export class SearchResultsMiddlewareService extends DataExplorerMiddlewareServic }).catch(() => { api.dispatch(couldNotFetchSearchResults(session.clusterId)); }); - } + } ); } } @@ -102,10 +102,12 @@ const getOrder = (dataExplorer: DataExplorer) => { ? OrderDirection.ASC : OrderDirection.DESC; + // Use createdAt as a secondary sort column so we break ties consistently. return order .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.COLLECTION) .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROCESS) .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROJECT) + .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS) .getOrder(); } else { return order.getOrder(); diff --git a/src/store/shared-with-me-panel/shared-with-me-middleware-service.ts b/src/store/shared-with-me-panel/shared-with-me-middleware-service.ts index a4197870..1a2bdaba 100644 --- a/src/store/shared-with-me-panel/shared-with-me-middleware-service.ts +++ b/src/store/shared-with-me-panel/shared-with-me-middleware-service.ts @@ -19,8 +19,9 @@ import { OrderBuilder, OrderDirection } from 'services/api/order-builder'; import { ProjectResource } from 'models/project'; import { getSortColumn } from "store/data-explorer/data-explorer-reducer"; import { updatePublicFavorites } from 'store/public-favorites/public-favorites-actions'; -import { FilterBuilder } from 'services/api/filter-builder'; +import { FilterBuilder, joinFilters } from 'services/api/filter-builder'; import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions'; +import { AuthState } from 'store/auth/auth-reducer'; export class SharedWithMeMiddlewareService extends DataExplorerMiddlewareService { constructor(private services: ServiceRepository, id: string) { @@ -33,11 +34,7 @@ export class SharedWithMeMiddlewareService extends DataExplorerMiddlewareService try { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); const response = await this.services.groupsService - .contents('', { - ...getParams(dataExplorer), - excludeHomeProject: true, - filters: new FilterBuilder().addDistinct('uuid', `${state.auth.config.uuidPrefix}-j7d0g-publicfavorites`).getFilters() - }); + .contents('', getParams(dataExplorer, state.auth)); api.dispatch(updateFavorites(response.items.map(item => item.uuid))); api.dispatch(updatePublicFavorites(response.items.map(item => item.uuid))); api.dispatch(updateResources(response.items)); @@ -51,10 +48,14 @@ export class SharedWithMeMiddlewareService extends DataExplorerMiddlewareService } } -export const getParams = (dataExplorer: DataExplorer) => ({ +export const getParams = (dataExplorer: DataExplorer, authState: AuthState) => ({ ...dataExplorerToListParams(dataExplorer), order: getOrder(dataExplorer), - filters: getFilters(dataExplorer), + filters: joinFilters( + getFilters(dataExplorer), + new FilterBuilder().addDistinct('uuid', `${authState.config.uuidPrefix}-j7d0g-publicfavorites`).getFilters(), + ), + excludeHomeProject: true, }); const getOrder = (dataExplorer: DataExplorer) => { @@ -65,10 +66,12 @@ const getOrder = (dataExplorer: DataExplorer) => { ? OrderDirection.ASC : OrderDirection.DESC; + // Use createdAt as a secondary sort column so we break ties consistently. return order .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.COLLECTION) .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROCESS) .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROJECT) + .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS) .getOrder(); } else { return order.getOrder(); diff --git a/src/store/sharing-dialog/sharing-dialog-actions.ts b/src/store/sharing-dialog/sharing-dialog-actions.ts index c0fdeda5..fb34398e 100644 --- a/src/store/sharing-dialog/sharing-dialog-actions.ts +++ b/src/store/sharing-dialog/sharing-dialog-actions.ts @@ -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(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(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(initializePublicAccessForm(permissionLinks)); - const filters = new FilterBuilder() - .addIn('uuid', Array.from(new Set(permissionLinks.map(({ tailUuid }) => tailUuid)))) - .getFilters(); - - const { items: users } = await userService.list({ filters, count: "none", limit: 1000 }); - const { items: groups } = await groupsService.list({ filters, count: "none", limit: 1000 }); + const dialog = getDialog(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(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(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); } }; diff --git a/src/store/sharing-dialog/sharing-dialog-types.ts b/src/store/sharing-dialog/sharing-dialog-types.ts index a05224e2..58ce3f0f 100644 --- a/src/store/sharing-dialog/sharing-dialog-types.ts +++ b/src/store/sharing-dialog/sharing-dialog-types.ts @@ -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); diff --git a/src/store/side-panel-tree/side-panel-tree-actions.ts b/src/store/side-panel-tree/side-panel-tree-actions.ts index f6015fbf..44dfe869 100644 --- a/src/store/side-panel-tree/side-panel-tree-actions.ts +++ b/src/store/side-panel-tree/side-panel-tree-actions.ts @@ -14,22 +14,23 @@ import { getNodeAncestors, getNodeAncestorsIds, getNode, TreeNode, initTreeNode, import { ProjectResource } from 'models/project'; import { OrderBuilder } from 'services/api/order-builder'; import { ResourceKind } from 'models/resource'; -import { GroupContentsResourcePrefix } from 'services/groups-service/groups-service'; -import { GroupClass } from 'models/group'; import { CategoriesListReducer } from 'common/plugintypes'; import { pluginConfig } from 'plugins'; +import { LinkClass } from 'models/link'; export enum SidePanelTreeCategory { PROJECTS = 'Home Projects', - SHARED_WITH_ME = 'Shared with me', - PUBLIC_FAVORITES = 'Public Favorites', FAVORITES = 'My Favorites', - TRASH = 'Trash', + PUBLIC_FAVORITES = 'Public Favorites', + SHARED_WITH_ME = 'Shared with me', ALL_PROCESSES = 'All Processes', + SHELL_ACCESS = 'Shell Access', GROUPS = 'Groups', + TRASH = 'Trash', } export const SIDE_PANEL_TREE = 'sidePanelTree'; +const SIDEPANEL_TREE_NODE_LIMIT = 50 export const getSidePanelTree = (treePicker: TreePicker) => getTreePicker(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(loadSharedRoot); + if (projectUuid === SidePanelTreeCategory.PUBLIC_FAVORITES) { + await dispatch(loadPublicFavoritesTree()); + } else if (projectUuid === SidePanelTreeCategory.FAVORITES) { + await dispatch(loadFavoritesTree()); } else if (node || projectUuid !== '') { await dispatch(loadProject(projectUuid)); } @@ -110,10 +114,13 @@ const loadProject = (projectUuid: string) => .addEqual('owner_uuid', projectUuid) .getFilters(), order: new OrderBuilder() - .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().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() - .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().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(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(loadSidePanelTreeProjects(SidePanelTreeCategory.SHARED_WITH_ME)); - } for (const ancestor of ancestors) { await dispatch(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(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) => diff --git a/src/store/store.ts b/src/store/store.ts index 1501fd4f..daa9812e 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -2,81 +2,83 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { createStore, applyMiddleware, compose, Middleware, combineReducers, Store, Action, Dispatch } from 'redux'; +import { createStore, applyMiddleware, compose, Middleware, combineReducers, Store, Action, Dispatch } from "redux"; import { routerMiddleware, routerReducer } from "react-router-redux"; -import thunkMiddleware from 'redux-thunk'; +import thunkMiddleware from "redux-thunk"; import { History } from "history"; -import { handleRedirects } from '../common/redirect-to'; +import { handleRedirects } from "../common/redirect-to"; import { authReducer } from "./auth/auth-reducer"; import { authMiddleware } from "./auth/auth-middleware"; -import { dataExplorerReducer } from './data-explorer/data-explorer-reducer'; -import { detailsPanelReducer } from './details-panel/details-panel-reducer'; -import { contextMenuReducer } from './context-menu/context-menu-reducer'; -import { reducer as formReducer } from 'redux-form'; -import { favoritesReducer } from './favorites/favorites-reducer'; -import { snackbarReducer } from './snackbar/snackbar-reducer'; -import { collectionPanelFilesReducer } from './collection-panel/collection-panel-files/collection-panel-files-reducer'; +import { dataExplorerReducer } from "./data-explorer/data-explorer-reducer"; +import { detailsPanelReducer } from "./details-panel/details-panel-reducer"; +import { contextMenuReducer } from "./context-menu/context-menu-reducer"; +import { reducer as formReducer } from "redux-form"; +import { favoritesReducer } from "./favorites/favorites-reducer"; +import { snackbarReducer } from "./snackbar/snackbar-reducer"; +import { collectionPanelFilesReducer } from "./collection-panel/collection-panel-files/collection-panel-files-reducer"; import { dataExplorerMiddleware } from "./data-explorer/data-explorer-middleware"; import { FAVORITE_PANEL_ID } from "./favorite-panel/favorite-panel-action"; import { PROJECT_PANEL_ID } from "./project-panel/project-panel-action"; import { ProjectPanelMiddlewareService } from "./project-panel/project-panel-middleware-service"; import { FavoritePanelMiddlewareService } from "./favorite-panel/favorite-panel-middleware-service"; import { AllProcessesPanelMiddlewareService } from "./all-processes-panel/all-processes-panel-middleware-service"; -import { collectionPanelReducer } from './collection-panel/collection-panel-reducer'; -import { dialogReducer } from './dialog/dialog-reducer'; +import { collectionPanelReducer } from "./collection-panel/collection-panel-reducer"; +import { dialogReducer } from "./dialog/dialog-reducer"; import { ServiceRepository } from "services/services"; -import { treePickerReducer, treePickerSearchReducer } from './tree-picker/tree-picker-reducer'; -import { treePickerSearchMiddleware } from './tree-picker/tree-picker-middleware'; -import { resourcesReducer } from 'store/resources/resources-reducer'; -import { propertiesReducer } from './properties/properties-reducer'; -import { fileUploaderReducer } from './file-uploader/file-uploader-reducer'; +import { treePickerReducer, treePickerSearchReducer } from "./tree-picker/tree-picker-reducer"; +import { treePickerSearchMiddleware } from "./tree-picker/tree-picker-middleware"; +import { resourcesReducer } from "store/resources/resources-reducer"; +import { propertiesReducer } from "./properties/properties-reducer"; +import { fileUploaderReducer } from "./file-uploader/file-uploader-reducer"; import { TrashPanelMiddlewareService } from "store/trash-panel/trash-panel-middleware-service"; import { TRASH_PANEL_ID } from "store/trash-panel/trash-panel-action"; -import { processLogsPanelReducer } from './process-logs-panel/process-logs-panel-reducer'; -import { processPanelReducer } from 'store/process-panel/process-panel-reducer'; -import { SHARED_WITH_ME_PANEL_ID } from 'store/shared-with-me-panel/shared-with-me-panel-actions'; -import { SharedWithMeMiddlewareService } from './shared-with-me-panel/shared-with-me-middleware-service'; -import { progressIndicatorReducer } from './progress-indicator/progress-indicator-reducer'; -import { runProcessPanelReducer } from 'store/run-process-panel/run-process-panel-reducer'; -import { WorkflowMiddlewareService } from './workflow-panel/workflow-middleware-service'; -import { WORKFLOW_PANEL_ID } from './workflow-panel/workflow-panel-actions'; -import { appInfoReducer } from 'store/app-info/app-info-reducer'; -import { searchBarReducer } from './search-bar/search-bar-reducer'; -import { SEARCH_RESULTS_PANEL_ID } from 'store/search-results-panel/search-results-panel-actions'; -import { SearchResultsMiddlewareService } from './search-results-panel/search-results-middleware-service'; +import { processLogsPanelReducer } from "./process-logs-panel/process-logs-panel-reducer"; +import { processPanelReducer } from "store/process-panel/process-panel-reducer"; +import { SHARED_WITH_ME_PANEL_ID } from "store/shared-with-me-panel/shared-with-me-panel-actions"; +import { SharedWithMeMiddlewareService } from "./shared-with-me-panel/shared-with-me-middleware-service"; +import { progressIndicatorReducer } from "./progress-indicator/progress-indicator-reducer"; +import { runProcessPanelReducer } from "store/run-process-panel/run-process-panel-reducer"; +import { WorkflowMiddlewareService } from "./workflow-panel/workflow-middleware-service"; +import { WORKFLOW_PANEL_ID } from "./workflow-panel/workflow-panel-actions"; +import { appInfoReducer } from "store/app-info/app-info-reducer"; +import { searchBarReducer } from "./search-bar/search-bar-reducer"; +import { SEARCH_RESULTS_PANEL_ID } from "store/search-results-panel/search-results-panel-actions"; +import { SearchResultsMiddlewareService } from "./search-results-panel/search-results-middleware-service"; import { virtualMachinesReducer } from "store/virtual-machines/virtual-machines-reducer"; -import { repositoriesReducer } from 'store/repositories/repositories-reducer'; -import { keepServicesReducer } from 'store/keep-services/keep-services-reducer'; -import { UserMiddlewareService } from 'store/users/user-panel-middleware-service'; -import { USERS_PANEL_ID } from 'store/users/users-actions'; -import { UserProfileGroupsMiddlewareService } from 'store/user-profile/user-profile-groups-middleware-service'; -import { USER_PROFILE_PANEL_ID } from 'store/user-profile/user-profile-actions' -import { GroupsPanelMiddlewareService } from 'store/groups-panel/groups-panel-middleware-service'; -import { GROUPS_PANEL_ID } from 'store/groups-panel/groups-panel-actions'; -import { GroupDetailsPanelMembersMiddlewareService } from 'store/group-details-panel/group-details-panel-members-middleware-service'; -import { GroupDetailsPanelPermissionsMiddlewareService } from 'store/group-details-panel/group-details-panel-permissions-middleware-service'; -import { GROUP_DETAILS_MEMBERS_PANEL_ID, GROUP_DETAILS_PERMISSIONS_PANEL_ID } from 'store/group-details-panel/group-details-panel-actions'; -import { LINK_PANEL_ID } from 'store/link-panel/link-panel-actions'; -import { LinkMiddlewareService } from 'store/link-panel/link-panel-middleware-service'; -import { API_CLIENT_AUTHORIZATION_PANEL_ID } from 'store/api-client-authorizations/api-client-authorizations-actions'; -import { ApiClientAuthorizationMiddlewareService } from 'store/api-client-authorizations/api-client-authorizations-middleware-service'; -import { PublicFavoritesMiddlewareService } from 'store/public-favorites-panel/public-favorites-middleware-service'; -import { PUBLIC_FAVORITE_PANEL_ID } from 'store/public-favorites-panel/public-favorites-action'; -import { publicFavoritesReducer } from 'store/public-favorites/public-favorites-reducer'; -import { linkAccountPanelReducer } from './link-account-panel/link-account-panel-reducer'; -import { CollectionsWithSameContentAddressMiddlewareService } from 'store/collections-content-address-panel/collections-content-address-middleware-service'; -import { COLLECTIONS_CONTENT_ADDRESS_PANEL_ID } from 'store/collections-content-address-panel/collections-content-address-panel-actions'; -import { ownerNameReducer } from 'store/owner-name/owner-name-reducer'; -import { SubprocessMiddlewareService } from 'store/subprocess-panel/subprocess-panel-middleware-service'; -import { SUBPROCESS_PANEL_ID } from 'store/subprocess-panel/subprocess-panel-actions'; -import { ALL_PROCESSES_PANEL_ID } from './all-processes-panel/all-processes-panel-action'; -import { Config } from 'common/config'; -import { pluginConfig } from 'plugins'; -import { MiddlewareListReducer } from 'common/plugintypes'; -import { tooltipsMiddleware } from './tooltips/tooltips-middleware'; -import { sidePanelReducer } from './side-panel/side-panel-reducer' -import { bannerReducer } from './banner/banner-reducer'; +import { repositoriesReducer } from "store/repositories/repositories-reducer"; +import { keepServicesReducer } from "store/keep-services/keep-services-reducer"; +import { UserMiddlewareService } from "store/users/user-panel-middleware-service"; +import { USERS_PANEL_ID } from "store/users/users-actions"; +import { UserProfileGroupsMiddlewareService } from "store/user-profile/user-profile-groups-middleware-service"; +import { USER_PROFILE_PANEL_ID } from "store/user-profile/user-profile-actions"; +import { GroupsPanelMiddlewareService } from "store/groups-panel/groups-panel-middleware-service"; +import { GROUPS_PANEL_ID } from "store/groups-panel/groups-panel-actions"; +import { GroupDetailsPanelMembersMiddlewareService } from "store/group-details-panel/group-details-panel-members-middleware-service"; +import { GroupDetailsPanelPermissionsMiddlewareService } from "store/group-details-panel/group-details-panel-permissions-middleware-service"; +import { GROUP_DETAILS_MEMBERS_PANEL_ID, GROUP_DETAILS_PERMISSIONS_PANEL_ID } from "store/group-details-panel/group-details-panel-actions"; +import { LINK_PANEL_ID } from "store/link-panel/link-panel-actions"; +import { LinkMiddlewareService } from "store/link-panel/link-panel-middleware-service"; +import { API_CLIENT_AUTHORIZATION_PANEL_ID } from "store/api-client-authorizations/api-client-authorizations-actions"; +import { ApiClientAuthorizationMiddlewareService } from "store/api-client-authorizations/api-client-authorizations-middleware-service"; +import { PublicFavoritesMiddlewareService } from "store/public-favorites-panel/public-favorites-middleware-service"; +import { PUBLIC_FAVORITE_PANEL_ID } from "store/public-favorites-panel/public-favorites-action"; +import { publicFavoritesReducer } from "store/public-favorites/public-favorites-reducer"; +import { linkAccountPanelReducer } from "./link-account-panel/link-account-panel-reducer"; +import { CollectionsWithSameContentAddressMiddlewareService } from "store/collections-content-address-panel/collections-content-address-middleware-service"; +import { COLLECTIONS_CONTENT_ADDRESS_PANEL_ID } from "store/collections-content-address-panel/collections-content-address-panel-actions"; +import { ownerNameReducer } from "store/owner-name/owner-name-reducer"; +import { SubprocessMiddlewareService } from "store/subprocess-panel/subprocess-panel-middleware-service"; +import { SUBPROCESS_PANEL_ID } from "store/subprocess-panel/subprocess-panel-actions"; +import { ALL_PROCESSES_PANEL_ID } from "./all-processes-panel/all-processes-panel-action"; +import { Config } from "common/config"; +import { pluginConfig } from "plugins"; +import { MiddlewareListReducer } from "common/plugintypes"; +import { tooltipsMiddleware } from "./tooltips/tooltips-middleware"; +import { sidePanelReducer } from "./side-panel/side-panel-reducer"; +import { bannerReducer } from "./banner/banner-reducer"; +import { multiselectReducer } from "./multiselect/multiselect-reducer"; +import { composeWithDevTools } from "redux-devtools-extension"; declare global { interface Window { @@ -84,11 +86,6 @@ declare global { } } -const composeEnhancers = - (process.env.NODE_ENV === 'development' && - window && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || - compose; - export type RootState = ReturnType>; export type RootStore = Store & { dispatch: Dispatch }; @@ -96,57 +93,32 @@ export type RootStore = Store & { dispatch: Dispatch }; export function configureStore(history: History, services: ServiceRepository, config: Config): RootStore { const rootReducer = createRootReducer(services); - const projectPanelMiddleware = dataExplorerMiddleware( - new ProjectPanelMiddlewareService(services, PROJECT_PANEL_ID) - ); - const favoritePanelMiddleware = dataExplorerMiddleware( - new FavoritePanelMiddlewareService(services, FAVORITE_PANEL_ID) - ); - const allProcessessPanelMiddleware = dataExplorerMiddleware( - new AllProcessesPanelMiddlewareService(services, ALL_PROCESSES_PANEL_ID) - ); - const trashPanelMiddleware = dataExplorerMiddleware( - new TrashPanelMiddlewareService(services, TRASH_PANEL_ID) - ); - const searchResultsPanelMiddleware = dataExplorerMiddleware( - new SearchResultsMiddlewareService(services, SEARCH_RESULTS_PANEL_ID) - ); - const sharedWithMePanelMiddleware = dataExplorerMiddleware( - new SharedWithMeMiddlewareService(services, SHARED_WITH_ME_PANEL_ID) - ); - const workflowPanelMiddleware = dataExplorerMiddleware( - new WorkflowMiddlewareService(services, WORKFLOW_PANEL_ID) - ); - const userPanelMiddleware = dataExplorerMiddleware( - new UserMiddlewareService(services, USERS_PANEL_ID) - ); - const userProfileGroupsMiddleware = dataExplorerMiddleware( - new UserProfileGroupsMiddlewareService(services, USER_PROFILE_PANEL_ID) - ); - const groupsPanelMiddleware = dataExplorerMiddleware( - new GroupsPanelMiddlewareService(services, GROUPS_PANEL_ID) - ); + const projectPanelMiddleware = dataExplorerMiddleware(new ProjectPanelMiddlewareService(services, PROJECT_PANEL_ID)); + const favoritePanelMiddleware = dataExplorerMiddleware(new FavoritePanelMiddlewareService(services, FAVORITE_PANEL_ID)); + const allProcessessPanelMiddleware = dataExplorerMiddleware(new AllProcessesPanelMiddlewareService(services, ALL_PROCESSES_PANEL_ID)); + const trashPanelMiddleware = dataExplorerMiddleware(new TrashPanelMiddlewareService(services, TRASH_PANEL_ID)); + const searchResultsPanelMiddleware = dataExplorerMiddleware(new SearchResultsMiddlewareService(services, SEARCH_RESULTS_PANEL_ID)); + const sharedWithMePanelMiddleware = dataExplorerMiddleware(new SharedWithMeMiddlewareService(services, SHARED_WITH_ME_PANEL_ID)); + const workflowPanelMiddleware = dataExplorerMiddleware(new WorkflowMiddlewareService(services, WORKFLOW_PANEL_ID)); + const userPanelMiddleware = dataExplorerMiddleware(new UserMiddlewareService(services, USERS_PANEL_ID)); + const userProfileGroupsMiddleware = dataExplorerMiddleware(new UserProfileGroupsMiddlewareService(services, USER_PROFILE_PANEL_ID)); + const groupsPanelMiddleware = dataExplorerMiddleware(new GroupsPanelMiddlewareService(services, GROUPS_PANEL_ID)); const groupDetailsPanelMembersMiddleware = dataExplorerMiddleware( new GroupDetailsPanelMembersMiddlewareService(services, GROUP_DETAILS_MEMBERS_PANEL_ID) ); const groupDetailsPanelPermissionsMiddleware = dataExplorerMiddleware( new GroupDetailsPanelPermissionsMiddlewareService(services, GROUP_DETAILS_PERMISSIONS_PANEL_ID) ); - const linkPanelMiddleware = dataExplorerMiddleware( - new LinkMiddlewareService(services, LINK_PANEL_ID) - ); + const linkPanelMiddleware = dataExplorerMiddleware(new LinkMiddlewareService(services, LINK_PANEL_ID)); const apiClientAuthorizationMiddlewareService = dataExplorerMiddleware( new ApiClientAuthorizationMiddlewareService(services, API_CLIENT_AUTHORIZATION_PANEL_ID) ); - const publicFavoritesMiddleware = dataExplorerMiddleware( - new PublicFavoritesMiddlewareService(services, PUBLIC_FAVORITE_PANEL_ID) - ); + const publicFavoritesMiddleware = dataExplorerMiddleware(new PublicFavoritesMiddlewareService(services, PUBLIC_FAVORITE_PANEL_ID)); const collectionsContentAddress = dataExplorerMiddleware( new CollectionsWithSameContentAddressMiddlewareService(services, COLLECTIONS_CONTENT_ADDRESS_PANEL_ID) ); - const subprocessMiddleware = dataExplorerMiddleware( - new SubprocessMiddlewareService(services, SUBPROCESS_PANEL_ID) - ); + const subprocessMiddleware = dataExplorerMiddleware(new SubprocessMiddlewareService(services, SUBPROCESS_PANEL_ID)); + const redirectToMiddleware = (store: any) => (next: any) => (action: any) => { const state = store.getState(); @@ -179,47 +151,50 @@ export function configureStore(history: History, services: ServiceRepository, co publicFavoritesMiddleware, collectionsContentAddress, subprocessMiddleware, - treePickerSearchMiddleware + treePickerSearchMiddleware, ]; - const reduceMiddlewaresFn: (a: Middleware[], - b: MiddlewareListReducer) => Middleware[] = (a, b) => b(a, services); + const reduceMiddlewaresFn: (a: Middleware[], b: MiddlewareListReducer) => Middleware[] = (a, b) => b(a, services); middlewares = pluginConfig.middlewares.reduce(reduceMiddlewaresFn, middlewares); - const enhancer = composeEnhancers(applyMiddleware(redirectToMiddleware, ...middlewares)); + const enhancer = composeWithDevTools({ + /* options */ + })(applyMiddleware(redirectToMiddleware, ...middlewares)); return createStore(rootReducer, enhancer); } -const createRootReducer = (services: ServiceRepository) => combineReducers({ - auth: authReducer(services), - banner: bannerReducer, - collectionPanel: collectionPanelReducer, - collectionPanelFiles: collectionPanelFilesReducer, - contextMenu: contextMenuReducer, - dataExplorer: dataExplorerReducer, - detailsPanel: detailsPanelReducer, - dialog: dialogReducer, - favorites: favoritesReducer, - ownerName: ownerNameReducer, - publicFavorites: publicFavoritesReducer, - form: formReducer, - processLogsPanel: processLogsPanelReducer, - properties: propertiesReducer, - resources: resourcesReducer, - router: routerReducer, - snackbar: snackbarReducer, - treePicker: treePickerReducer, - treePickerSearch: treePickerSearchReducer, - fileUploader: fileUploaderReducer, - processPanel: processPanelReducer, - progressIndicator: progressIndicatorReducer, - runProcessPanel: runProcessPanelReducer, - appInfo: appInfoReducer, - searchBar: searchBarReducer, - virtualMachines: virtualMachinesReducer, - repositories: repositoriesReducer, - keepServices: keepServicesReducer, - linkAccountPanel: linkAccountPanelReducer, - sidePanel: sidePanelReducer -}); +const createRootReducer = (services: ServiceRepository) => + combineReducers({ + auth: authReducer(services), + banner: bannerReducer, + collectionPanel: collectionPanelReducer, + collectionPanelFiles: collectionPanelFilesReducer, + contextMenu: contextMenuReducer, + dataExplorer: dataExplorerReducer, + detailsPanel: detailsPanelReducer, + dialog: dialogReducer, + favorites: favoritesReducer, + ownerName: ownerNameReducer, + publicFavorites: publicFavoritesReducer, + form: formReducer, + processLogsPanel: processLogsPanelReducer, + properties: propertiesReducer, + resources: resourcesReducer, + router: routerReducer, + snackbar: snackbarReducer, + treePicker: treePickerReducer, + treePickerSearch: treePickerSearchReducer, + fileUploader: fileUploaderReducer, + processPanel: processPanelReducer, + progressIndicator: progressIndicatorReducer, + runProcessPanel: runProcessPanelReducer, + appInfo: appInfoReducer, + searchBar: searchBarReducer, + virtualMachines: virtualMachinesReducer, + repositories: repositoriesReducer, + keepServices: keepServicesReducer, + linkAccountPanel: linkAccountPanelReducer, + sidePanel: sidePanelReducer, + multiselect: multiselectReducer, + }); diff --git a/src/store/subprocess-panel/subprocess-panel-actions.ts b/src/store/subprocess-panel/subprocess-panel-actions.ts index 0df89d6e..a67dd1f4 100644 --- a/src/store/subprocess-panel/subprocess-panel-actions.ts +++ b/src/store/subprocess-panel/subprocess-panel-actions.ts @@ -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; + +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 => { + + 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) + // 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 => { + // 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; + } + }; diff --git a/src/store/subprocess-panel/subprocess-panel-middleware-service.ts b/src/store/subprocess-panel/subprocess-panel-middleware-service.ts index 986c6ebd..5124c834 100644 --- a/src/store/subprocess-panel/subprocess-panel-middleware-service.ts +++ b/src/store/subprocess-panel/subprocess-panel-middleware-service.ts @@ -26,19 +26,19 @@ export class SubprocessMiddlewareService extends DataExplorerMiddlewareService { super(id); } - async requestItems(api: MiddlewareAPI) { + async requestItems(api: MiddlewareAPI, criteriaChanged?: boolean, background?: boolean) { const state = api.getState(); const parentContainerRequestUuid = state.processPanel.containerRequestUuid; if (parentContainerRequestUuid === "") { return; } const dataExplorer = getDataExplorer(state.dataExplorer, this.getId()); try { - api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); + if (!background) { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); } const parentContainerRequest = await this.services.containerRequestService.get(parentContainerRequestUuid); if (parentContainerRequest.containerUuid) { const containerRequests = await this.services.containerRequestService.list( { - ...getParams(dataExplorer, parentContainerRequest) , + ...getParams(dataExplorer, parentContainerRequest), select: containerRequestFieldsNoMounts }); api.dispatch(updateResources(containerRequests.items)); @@ -46,9 +46,9 @@ export class SubprocessMiddlewareService extends DataExplorerMiddlewareService { // Populate the actual user view api.dispatch(setItems(containerRequests)); } - api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); + if (!background) { api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); } } catch { - api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); + if (!background) { api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); } api.dispatch(couldNotFetchSubprocesses()); } } @@ -65,27 +65,27 @@ export const getParams = ( export const getFilters = ( dataExplorer: DataExplorer, parentContainerRequest: ContainerRequestResource) => { - const columns = dataExplorer.columns as DataColumns; - const statusColumnFilters = getDataExplorerColumnFilters(columns, 'Status'); - const activeStatusFilter = Object.keys(statusColumnFilters).find( - filterName => statusColumnFilters[filterName].selected - ) || ProcessStatusFilter.ALL; + const columns = dataExplorer.columns as DataColumns; + 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) => subprocessPanelActions.SET_ITEMS({ diff --git a/src/store/trash-panel/trash-panel-middleware-service.ts b/src/store/trash-panel/trash-panel-middleware-service.ts index bed3e628..c822cece 100644 --- a/src/store/trash-panel/trash-panel-middleware-service.ts +++ b/src/store/trash-panel/trash-panel-middleware-service.ts @@ -27,7 +27,8 @@ import { serializeResourceTypeFilters } from 'store//resource-type-filters/resou import { getDataExplorerColumnFilters } from 'store/data-explorer/data-explorer-middleware-service'; import { joinFilters } from 'services/api/filter-builder'; import { CollectionResource } from "models/collection"; - +import { MultiSelectMenuActionNames } from "views-components/multiselect-toolbar/ms-menu-actions"; +import { removeDisabledButton } from "store/multiselect/multiselect-actions"; export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService { constructor(private services: ServiceRepository, id: string) { super(id); @@ -56,7 +57,7 @@ export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService { try { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); const listResults = await this.services.groupsService - .contents(userUuid, { + .contents('', { ...dataExplorerToListParams(dataExplorer), order: getOrder(dataExplorer), filters, @@ -84,6 +85,7 @@ export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService { })); api.dispatch(couldNotFetchTrashContents()); } + api.dispatch(removeDisabledButton(MultiSelectMenuActionNames.MOVE_TO_TRASH)) } } @@ -95,9 +97,11 @@ const getOrder = (dataExplorer: DataExplorer) => { ? OrderDirection.ASC : OrderDirection.DESC; + // Use createdAt as a secondary sort column so we break ties consistently. return order .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.COLLECTION) .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROJECT) + .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS) .getOrder(); } else { return order.getOrder(); diff --git a/src/store/trash/trash-actions.ts b/src/store/trash/trash-actions.ts index 85ffd4a0..f4e3d3f0 100644 --- a/src/store/trash/trash-actions.ts +++ b/src/store/trash/trash-actions.ts @@ -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 => { - let errorMessage = ''; - let successMessage = ''; - try { - if (isTrashed) { - errorMessage = "Could not restore project from trash"; - successMessage = "Restored from trash"; - await services.groupsService.untrash(uuid); - dispatch(navigateTo(uuid)); - dispatch(activateSidePanelTreeItem(uuid)); - } else { - errorMessage = "Could not move project to trash"; - successMessage = "Added to trash"; - await services.groupsService.trash(uuid); - dispatch(loadSidePanelTreeProjects(ownerUuid)); - dispatch(navigateTo(ownerUuid)); +export const toggleProjectTrashed = + (uuid: string, ownerUuid: string, isTrashed: boolean, isMulti: boolean) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise => { + let errorMessage = ""; + let successMessage = ""; + let untrashedResource; + dispatch(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(isMulti || !untrashedResource ? navigateToTrash : navigateTo(uuid)); + dispatch(activateSidePanelTreeItem(uuid)); + } else { + errorMessage = "Could not move project to trash"; + successMessage = "Added project to trash"; + await services.groupsService.trash(uuid); + dispatch(loadSidePanelTreeProjects(ownerUuid)); + + const { location } = getState().router; + if (matchSharedWithMeRoute(location ? location.pathname : "")) { + dispatch(sharedWithMePanelActions.REQUEST_ITEMS()); + } + else { + dispatch(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 => { - 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 => { + let errorMessage = ""; + let successMessage = ""; + dispatch(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(toggleProjectTrashed(uuid, ownerUuid, isTrashed!!)); - } else if (kind === ResourceKind.COLLECTION) { - dispatch(toggleCollectionTrashed(uuid, isTrashed!!)); - } - }; +export const toggleTrashed = (kind: ResourceKind, uuid: string, ownerUuid: string, isTrashed: boolean) => (dispatch: Dispatch) => { + if (kind === ResourceKind.PROJECT) { + dispatch(toggleProjectTrashed(uuid, ownerUuid, isTrashed!!, false)); + } else if (kind === ResourceKind.COLLECTION) { + dispatch(toggleCollectionTrashed(uuid, isTrashed!!)); + } +}; diff --git a/src/store/tree-picker/picker-id.tsx b/src/store/tree-picker/picker-id.tsx index b0d5e353..5734ad70 100644 --- a/src/store/tree-picker/picker-id.tsx +++ b/src/store/tree-picker/picker-id.tsx @@ -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) => -

(Component: React.ComponentType

) => - (props: P) => - ; - \ No newline at end of file +

(Component: React.ComponentType

) => + (props: P) => { + return ( + + ); + }; 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 index 00000000..7a55503e --- /dev/null +++ b/src/store/tree-picker/tree-picker-actions.test.ts @@ -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; + }); + + 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); + + + }); +}); diff --git a/src/store/tree-picker/tree-picker-actions.ts b/src/store/tree-picker/tree-picker-actions.ts index 460a23e3..883847d8 100644 --- a/src/store/tree-picker/tree-picker-actions.ts +++ b/src/store/tree-picker/tree-picker-actions.ts @@ -3,8 +3,8 @@ // SPDX-License-Identifier: AGPL-3.0 import { unionize, ofType, UnionOf } from "common/unionize"; -import { TreeNode, initTreeNode, getNodeDescendants, TreeNodeStatus, getNode, TreePickerId, Tree } from 'models/tree'; -import { createCollectionFilesTree } from "models/collection-file"; +import { TreeNode, initTreeNode, getNodeDescendants, TreeNodeStatus, getNode, TreePickerId, Tree, setNode, createTree } from 'models/tree'; +import { CollectionFileType, createCollectionFilesTree, getCollectionResourceCollectionUuid } from "models/collection-file"; import { Dispatch } from 'redux'; import { RootState } from 'store/store'; import { getUserUuid } from "common/getuser"; @@ -22,6 +22,10 @@ import { LinkResource, LinkClass } from "models/link"; import { mapTreeValues } from "models/tree"; import { sortFilesTree } from "services/collection-service/collection-service-files-response"; import { GroupClass, GroupResource } from "models/group"; +import { CollectionResource } from "models/collection"; +import { getResource } from "store/resources/resources"; +import { updateResources } from "store/resources/resources-actions"; +import { SnackbarKind, snackbarActions } from "store/snackbar/snackbar-actions"; export const treePickerActions = unionize({ LOAD_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(), @@ -29,11 +33,12 @@ export const treePickerActions = unionize({ APPEND_TREE_PICKER_NODE_SUBTREE: ofType<{ id: string, subtree: Tree, pickerId: string }>(), TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ id: string, pickerId: string }>(), EXPAND_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(), + EXPAND_TREE_PICKER_NODE_ANCESTORS: ofType<{ id: string, pickerId: string }>(), ACTIVATE_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string, relatedTreePickers?: string[] }>(), DEACTIVATE_TREE_PICKER_NODE: ofType<{ pickerId: string }>(), - TOGGLE_TREE_PICKER_NODE_SELECTION: ofType<{ id: string, pickerId: string }>(), - SELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string }>(), - DESELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string }>(), + TOGGLE_TREE_PICKER_NODE_SELECTION: ofType<{ id: string, pickerId: string, cascade: boolean }>(), + SELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string, cascade: boolean }>(), + DESELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string, cascade: boolean }>(), EXPAND_TREE_PICKER_NODES: ofType<{ ids: string[], pickerId: string }>(), RESET_TREE_PICKER: ofType<{ pickerId: string }>() }); @@ -42,6 +47,7 @@ export type TreePickerAction = UnionOf; export interface LoadProjectParams { includeCollections?: boolean; + includeDirectories?: boolean; includeFiles?: boolean; includeFilterGroups?: boolean; options?: { showOnlyOwned: boolean; showOnlyWritable: boolean; }; @@ -51,6 +57,7 @@ export const treePickerSearchActions = unionize({ SET_TREE_PICKER_PROJECT_SEARCH: ofType<{ pickerId: string, projectSearchValue: string }>(), SET_TREE_PICKER_COLLECTION_FILTER: ofType<{ pickerId: string, collectionFilterValue: string }>(), SET_TREE_PICKER_LOAD_PARAMS: ofType<{ pickerId: string, params: LoadProjectParams }>(), + REFRESH_TREE_PICKER: ofType<{ pickerId: string }>(), }); export type TreePickerSearchAction = UnionOf; @@ -86,14 +93,31 @@ export const getAllNodes = (pickerId: string, filter = (node: TreeNode(pickerId: string) => (state: TreePicker) => getAllNodes(pickerId, node => node.selected)(state); -export const initProjectsTreePicker = (pickerId: string) => - async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => { +interface TreePickerPreloadParams { + selectedItemUuids: string[]; + includeDirectories: boolean; + includeFiles: boolean; + multi: boolean; +} + +export const initProjectsTreePicker = (pickerId: string, preloadParams?: TreePickerPreloadParams) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId); dispatch(initUserProject(home)); dispatch(initSharedProject(shared)); dispatch(initFavoritesProject(favorites)); dispatch(initPublicFavoritesProject(publicFavorites)); dispatch(initSearchProject(search)); + + if (preloadParams && preloadParams.selectedItemUuids.length) { + await dispatch(loadInitialValue( + preloadParams.selectedItemUuids, + pickerId, + preloadParams.includeDirectories, + preloadParams.includeFiles, + preloadParams.multi + )); + } }; interface ReceiveTreePickerDataParams { @@ -121,9 +145,23 @@ interface LoadProjectParamsWithId extends LoadProjectParams { searchProjects?: boolean; } +/** + * loadProject is used to load or refresh a project node in a tree picker + * Errors are caught and a toast is shown if the project fails to load + */ export const loadProject = (params: LoadProjectParamsWithId) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const { id, pickerId, includeCollections = false, includeFiles = false, includeFilterGroups = false, loadShared = false, options, searchProjects = false } = params; + const { + id, + pickerId, + includeCollections = false, + includeDirectories = false, + includeFiles = false, + includeFilterGroups = false, + loadShared = false, + options, + searchProjects = false + } = params; dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId })); @@ -149,59 +187,65 @@ export const loadProject = (params: LoadProjectParamsWithId) => const itemLimit = 200; - const { items, itemsAvailable } = await services.groupsService.contents((loadShared || searchProjects) ? '' : id, { filters, excludeHomeProject: loadShared || undefined, limit: itemLimit }); - - if (itemsAvailable > itemLimit) { - items.push({ - uuid: "more-items-available", - kind: ResourceKind.WORKFLOW, - name: `*** Not all items listed (${items.length} out of ${itemsAvailable}), reduce item count with search or filter ***`, - description: "", - definition: "", - ownerUuid: "", - createdAt: "", - modifiedByClientUuid: "", - modifiedByUserUuid: "", - modifiedAt: "", - href: "", - etag: "" - }); - } - - dispatch(receiveTreePickerData({ - id, - pickerId, - data: items.filter((item) => { - if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) { - return false; - } + try { + const { items, itemsAvailable } = await services.groupsService.contents((loadShared || searchProjects) ? '' : id, { filters, excludeHomeProject: loadShared || undefined, limit: itemLimit }); + dispatch(updateResources(items)); + + if (itemsAvailable > itemLimit) { + items.push({ + uuid: "more-items-available", + kind: ResourceKind.WORKFLOW, + name: `*** Not all items listed (${items.length} out of ${itemsAvailable}), reduce item count with search or filter ***`, + description: "", + definition: "", + ownerUuid: "", + createdAt: "", + modifiedByClientUuid: "", + modifiedByUserUuid: "", + modifiedAt: "", + href: "", + etag: "" + }); + } - if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) { - return false; - } + dispatch(receiveTreePickerData({ + id, + pickerId, + data: items.filter((item) => { + if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) { + return false; + } - return true; - }), - extractNodeData: item => ( - item.uuid === "more-items-available" ? - { - id: item.uuid, - value: item, - status: TreeNodeStatus.LOADED + if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) { + return false; } - : { - id: item.uuid, - value: item, - status: item.kind === ResourceKind.PROJECT - ? TreeNodeStatus.INITIAL - : includeFiles + + return true; + }), + extractNodeData: item => ( + item.uuid === "more-items-available" ? + { + id: item.uuid, + value: item, + status: TreeNodeStatus.LOADED + } + : { + id: item.uuid, + value: item, + status: item.kind === ResourceKind.PROJECT ? TreeNodeStatus.INITIAL - : TreeNodeStatus.LOADED - }), - })); + : includeDirectories || includeFiles + ? TreeNodeStatus.INITIAL + : TreeNodeStatus.LOADED + }), + })); + } catch(e) { + console.error("Failed to load project into tree picker:", e);; + dispatch(snackbarActions.OPEN_SNACKBAR({ message: `Failed to load project`, kind: SnackbarKind.ERROR })); + } }; -export const loadCollection = (id: string, pickerId: string) => +export const loadCollection = (id: string, pickerId: string, includeDirectories?: boolean, includeFiles?: boolean) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId })); @@ -210,24 +254,30 @@ export const loadCollection = (id: string, pickerId: string) => const node = getNode(id)(picker); if (node && 'kind' in node.value && node.value.kind === ResourceKind.COLLECTION) { - const files = await services.collectionService.files(node.value.uuid); + const files = (await services.collectionService.files(node.value.uuid)) + .filter((file) => ( + (includeFiles) || + (includeDirectories && file.type === CollectionFileType.DIRECTORY) + )); const tree = createCollectionFilesTree(files); const sorted = sortFilesTree(tree); const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted); - dispatch( + // await tree modifications so that consumers can guarantee node presence + await dispatch( treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({ id, pickerId, subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree) })); + // Expand collection root node dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId })); } } }; - +export const HOME_PROJECT_ID = 'Home Projects'; export const initUserProject = (pickerId: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const uuid = getUserUuid(getState()); @@ -235,7 +285,7 @@ export const initUserProject = (pickerId: string) => dispatch(receiveTreePickerData({ id: '', pickerId, - data: [{ uuid, name: 'Home Projects' }], + data: [{ uuid, name: HOME_PROJECT_ID }], extractNodeData: value => ({ id: value.uuid, status: TreeNodeStatus.INITIAL, @@ -244,11 +294,11 @@ export const initUserProject = (pickerId: string) => })); } }; -export const loadUserProject = (pickerId: string, includeCollections = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) => +export const loadUserProject = (pickerId: string, includeCollections = false, includeDirectories = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const uuid = getUserUuid(getState()); if (uuid) { - dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeFiles, options })); + dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeDirectories, includeFiles, options })); } }; @@ -267,6 +317,134 @@ export const initSharedProject = (pickerId: string) => })); }; +type PickerItemPreloadData = { + itemId: string; + mainItemUuid: string; + ancestors: (GroupResource | CollectionResource)[]; + isHomeProjectItem: boolean; +} + +type PickerTreePreloadData = { + tree: Tree; + pickerTreeId: string; + pickerTreeRootUuid: string; +}; + +export const loadInitialValue = (pickerItemIds: string[], pickerId: string, includeDirectories: boolean, includeFiles: boolean, multi: boolean,) => + async (dispatch: Dispatch, 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(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 => ( + 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(), + 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, getState: () => RootState, services: ServiceRepository) => { @@ -316,6 +494,7 @@ export const initSearchProject = (pickerId: string) => interface LoadFavoritesProjectParams { pickerId: string; includeCollections?: boolean; + includeDirectories?: boolean; includeFiles?: boolean; options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }; } @@ -323,7 +502,7 @@ interface LoadFavoritesProjectParams { export const loadFavoritesProject = (params: LoadFavoritesProjectParams, options: { showOnlyOwned: boolean, showOnlyWritable: boolean } = { showOnlyOwned: true, showOnlyWritable: false }) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const { pickerId, includeCollections = false, includeFiles = false } = params; + const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params; const uuid = getUserUuid(getState()); if (uuid) { const filters = pipe( @@ -339,7 +518,7 @@ export const loadFavoritesProject = (params: LoadFavoritesProjectParams, id: 'Favorites', pickerId, data: items.filter((item) => { - if (options.showOnlyWritable && (item as GroupResource).writableBy && (item as GroupResource).writableBy.indexOf(uuid) === -1) { + if (options.showOnlyWritable && !(item as GroupResource).canWrite) { return false; } @@ -354,7 +533,7 @@ export const loadFavoritesProject = (params: LoadFavoritesProjectParams, value: item, status: item.kind === ResourceKind.PROJECT ? TreeNodeStatus.INITIAL - : includeFiles + : includeDirectories || includeFiles ? TreeNodeStatus.INITIAL : TreeNodeStatus.LOADED }), @@ -364,7 +543,7 @@ export const loadFavoritesProject = (params: LoadFavoritesProjectParams, export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const { pickerId, includeCollections = false, includeFiles = false } = params; + const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params; const uuidPrefix = getState().auth.config.uuidPrefix; const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`; @@ -395,7 +574,7 @@ export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) = value: item, status: item.headKind === ResourceKind.PROJECT ? TreeNodeStatus.INITIAL - : includeFiles + : includeDirectories || includeFiles ? TreeNodeStatus.INITIAL : TreeNodeStatus.LOADED }), @@ -466,3 +645,74 @@ const buildParams = (ownerUuid: string) => { .getOrder() }; }; + +/** + * Given a tree picker item, return collection uuid and path + * if the item represents a valid target/destination location + */ +export type FileOperationLocation = { + name: string; + uuid: string; + pdh?: string; + subpath: string; +} +export const getFileOperationLocation = (item: ProjectsTreePickerItem) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise => { + 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(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, tailUuid: string, initialTree: Tree) => { + 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 index 8fa3ee4a..6f748a99 100644 --- a/src/store/tree-picker/tree-picker-middleware.ts +++ b/src/store/tree-picker/tree-picker-middleware.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { Dispatch } from 'redux'; +import { Dispatch, MiddlewareAPI } from 'redux'; import { RootState } from 'store/store'; import { ServiceRepository } from 'services/services'; import { Middleware } from "redux"; @@ -37,6 +37,8 @@ export const treePickerSearchMiddleware: Middleware = store => next => action => isSearchAction = true; searchChanged = store.getState().treePickerSearch.collectionFilterValues[pickerId] !== collectionFilterValue; }, + + REFRESH_TREE_PICKER: refreshPickers(store), default: () => { } }); @@ -62,57 +64,59 @@ export const treePickerSearchMiddleware: Middleware = store => next => action => } }), - SET_TREE_PICKER_COLLECTION_FILTER: ({ pickerId }) => - store.dispatch((dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const picker = getTreePicker(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(loadProject({ - ...loadParams, - id: node.id, - pickerId: pickerId, - })); - } - if (node.id === SHARED_PROJECT_ID) { - dispatch(loadProject({ - ...loadParams, - id: node.id, - pickerId: pickerId, - loadShared: true - })); - } - if (node.id === SEARCH_PROJECT_ID) { - dispatch(loadProject({ - ...loadParams, - id: node.id, - pickerId: pickerId, - searchProjects: true - })); - } - if (node.id === FAVORITES_PROJECT_ID) { - dispatch(loadFavoritesProject({ - ...loadParams, - pickerId: pickerId, - })); - } - if (node.id === PUBLIC_FAVORITES_PROJECT_ID) { - dispatch(loadPublicFavoritesProject({ - ...loadParams, - pickerId: pickerId, - })); - } - } - return id; - }); - } - }), + SET_TREE_PICKER_COLLECTION_FILTER: refreshPickers(store), default: () => { } }); return r; } + +const refreshPickers = (store: MiddlewareAPI) => ({ pickerId }) => + store.dispatch((dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const picker = getTreePicker(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(loadProject({ + ...loadParams, + id: node.id, + pickerId: pickerId, + })); + } + if (node.id === SHARED_PROJECT_ID) { + dispatch(loadProject({ + ...loadParams, + id: node.id, + pickerId: pickerId, + loadShared: true + })); + } + if (node.id === SEARCH_PROJECT_ID) { + dispatch(loadProject({ + ...loadParams, + id: node.id, + pickerId: pickerId, + searchProjects: true + })); + } + if (node.id === FAVORITES_PROJECT_ID) { + dispatch(loadFavoritesProject({ + ...loadParams, + pickerId: pickerId, + })); + } + if (node.id === PUBLIC_FAVORITES_PROJECT_ID) { + dispatch(loadPublicFavoritesProject({ + ...loadParams, + pickerId: pickerId, + })); + } + } + return id; + }); + } + }) diff --git a/src/store/tree-picker/tree-picker-reducer.test.ts b/src/store/tree-picker/tree-picker-reducer.test.ts index 25973bf6..2a5229ca 100644 --- a/src/store/tree-picker/tree-picker-reducer.test.ts +++ b/src/store/tree-picker/tree-picker-reducer.test.ts @@ -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' }), diff --git a/src/store/tree-picker/tree-picker-reducer.ts b/src/store/tree-picker/tree-picker-reducer.ts index df0ee0ad..84d5ed0c 100644 --- a/src/store/tree-picker/tree-picker-reducer.ts +++ b/src/store/tree-picker/tree-picker-reducer.ts @@ -5,7 +5,7 @@ import { createTree, TreeNode, setNode, Tree, TreeNodeStatus, setNodeStatus, expandNode, deactivateNode, selectNodes, deselectNodes, - activateNode, getNode, toggleNodeCollapse, toggleNodeSelection, appendSubtree + activateNode, getNode, toggleNodeCollapse, toggleNodeSelection, appendSubtree, expandNodeAncestors } from 'models/tree'; import { TreePicker } from "./tree-picker"; import { treePickerActions, treePickerSearchActions, TreePickerAction, TreePickerSearchAction, LoadProjectParams } from "./tree-picker-actions"; @@ -29,6 +29,9 @@ export const treePickerReducer = (state: TreePicker = {}, action: TreePickerActi EXPAND_TREE_PICKER_NODE: ({ id, pickerId }) => updateOrCreatePicker(state, pickerId, expandNode(id)), + EXPAND_TREE_PICKER_NODE_ANCESTORS: ({ id, pickerId }) => + updateOrCreatePicker(state, pickerId, expandNodeAncestors(id)), + ACTIVATE_TREE_PICKER_NODE: ({ id, pickerId, relatedTreePickers = [] }) => pipe( () => relatedTreePickers.reduce( @@ -41,14 +44,14 @@ export const treePickerReducer = (state: TreePicker = {}, action: TreePickerActi DEACTIVATE_TREE_PICKER_NODE: ({ pickerId }) => updateOrCreatePicker(state, pickerId, deactivateNode), - TOGGLE_TREE_PICKER_NODE_SELECTION: ({ id, pickerId }) => - updateOrCreatePicker(state, pickerId, toggleNodeSelection(id)), + TOGGLE_TREE_PICKER_NODE_SELECTION: ({ id, pickerId, cascade }) => + updateOrCreatePicker(state, pickerId, toggleNodeSelection(id, cascade)), - SELECT_TREE_PICKER_NODE: ({ id, pickerId }) => - updateOrCreatePicker(state, pickerId, selectNodes(id)), + SELECT_TREE_PICKER_NODE: ({ id, pickerId, cascade }) => + updateOrCreatePicker(state, pickerId, selectNodes(id, cascade)), - DESELECT_TREE_PICKER_NODE: ({ id, pickerId }) => - updateOrCreatePicker(state, pickerId, deselectNodes(id)), + DESELECT_TREE_PICKER_NODE: ({ id, pickerId, cascade }) => + updateOrCreatePicker(state, pickerId, deselectNodes(id, cascade)), RESET_TREE_PICKER: ({ pickerId }) => updateOrCreatePicker(state, pickerId, createTree), diff --git a/src/store/user-profile/user-profile-actions.ts b/src/store/user-profile/user-profile-actions.ts index 9935518b..44b17c60 100644 --- a/src/store/user-profile/user-profile-actions.ts +++ b/src/store/user-profile/user-profile-actions.ts @@ -29,149 +29,149 @@ export const getCurrentUserProfilePanelUuid = getProperty(USER_PROFILE_P export const getUserProfileIsInaccessible = getProperty(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, 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, 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, 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, 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, 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, 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, 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, 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(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(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, + })); + } + }; diff --git a/src/store/users/user-panel-middleware-service.ts b/src/store/users/user-panel-middleware-service.ts index e965cd00..b8b914c9 100644 --- a/src/store/users/user-panel-middleware-service.ts +++ b/src/store/users/user-panel-middleware-service.ts @@ -19,6 +19,7 @@ import { UserResource } from 'models/user'; import { UserPanelColumnNames } from 'views/user-panel/user-panel'; import { BuiltinGroups, getBuiltinGroupUuid } from 'models/group'; import { LinkClass } from 'models/link'; +import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions"; export class UserMiddlewareService extends DataExplorerMiddlewareService { constructor(private services: ServiceRepository, id: string) { @@ -29,6 +30,7 @@ export class UserMiddlewareService extends DataExplorerMiddlewareService { const state = api.getState(); const dataExplorer = getDataExplorer(state.dataExplorer, this.getId()); try { + api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); const users = await this.services.userService.list(getParams(dataExplorer)); api.dispatch(updateResources(users.items)); api.dispatch(setItems(users)); @@ -44,6 +46,8 @@ export class UserMiddlewareService extends DataExplorerMiddlewareService { api.dispatch(updateResources(allUserMemberships.items)); } catch { api.dispatch(couldNotFetchUsers()); + } finally { + api.dispatch(progressIndicatorActions.STOP_WORKING(this.getId())); } } } @@ -70,6 +74,9 @@ const getOrder = (dataExplorer: DataExplorer) => { } else { order.addOrder(sortDirection, sortColumn.sort.field); } + + // Use createdAt as a secondary sort column so we break ties consistently. + order.addOrder(OrderDirection.DESC, "createdAt"); } return order.getOrder(); }; diff --git a/src/store/virtual-machines/virtual-machines-actions.ts b/src/store/virtual-machines/virtual-machines-actions.ts index bd07efb6..12172e7f 100644 --- a/src/store/virtual-machines/virtual-machines-actions.ts +++ b/src/store/virtual-machines/virtual-machines-actions.ts @@ -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(), @@ -72,50 +73,61 @@ const loadRequestedDate = () => export const loadVirtualMachinesAdminData = () => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - dispatch(loadRequestedDate()); - - const virtualMachines = await services.virtualMachineService.list(); - dispatch(updateResources(virtualMachines.items)); - dispatch(virtualMachinesActions.SET_VIRTUAL_MACHINES(virtualMachines)); - - - const logins = await services.permissionService.list({ - filters: new FilterBuilder() - .addIn('head_uuid', virtualMachines.items.map(item => item.uuid)) - .addEqual('name', PermissionLevel.CAN_LOGIN) - .getFilters(), - limit: 1000 - }); - dispatch(updateResources(logins.items)); - dispatch(virtualMachinesActions.SET_LINKS(logins)); - - const users = await services.userService.list({ - filters: new FilterBuilder() - .addIn('uuid', logins.items.map(item => item.tailUuid)) - .getFilters(), - count: "none", // Necessary for federated queries - limit: 1000 - }); - dispatch(updateResources(users.items)); - - const getAllLogins = await services.virtualMachineService.getAllLogins(); - dispatch(virtualMachinesActions.SET_LOGINS(getAllLogins)); + try { + dispatch(progressIndicatorActions.START_WORKING("virtual-machines-admin")); + dispatch(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(loadRequestedDate()); - const user = getState().auth.user; - const virtualMachines = await services.virtualMachineService.list(); - const virtualMachinesUuids = virtualMachines.items.map(it => it.uuid); - const links = await services.linkService.list({ - filters: new FilterBuilder() - .addIn("head_uuid", virtualMachinesUuids) - .addEqual("tail_uuid", user?.uuid) - .getFilters() - }); - dispatch(virtualMachinesActions.SET_VIRTUAL_MACHINES(virtualMachines)); - dispatch(virtualMachinesActions.SET_LINKS(links)); + try { + dispatch(progressIndicatorActions.START_WORKING("virtual-machines-user")); + + dispatch(loadRequestedDate()); + const user = getState().auth.user; + const virtualMachines = await services.virtualMachineService.list(); + const virtualMachinesUuids = virtualMachines.items.map(it => it.uuid); + const links = await services.linkService.list({ + filters: new FilterBuilder() + .addIn("head_uuid", virtualMachinesUuids) + .addEqual("tail_uuid", user?.uuid) + .getFilters() + }); + dispatch(virtualMachinesActions.SET_VIRTUAL_MACHINES(virtualMachines)); + dispatch(virtualMachinesActions.SET_LINKS(links)); + } finally { + dispatch(progressIndicatorActions.STOP_WORKING("virtual-machines-user")); + } }; export const openAddVirtualMachineLoginDialog = (vmUuid: string) => @@ -125,17 +137,17 @@ export const openAddVirtualMachineLoginDialog = (vmUuid: string) => dispatch(updateResources(virtualMachines.items)); const logins = await services.permissionService.list({ filters: new FilterBuilder() - .addIn('head_uuid', virtualMachines.items.map(item => item.uuid)) - .addEqual('name', PermissionLevel.CAN_LOGIN) - .getFilters() + .addIn('head_uuid', virtualMachines.items.map(item => item.uuid)) + .addEqual('name', PermissionLevel.CAN_LOGIN) + .getFilters() }); dispatch(updateResources(logins.items)); dispatch(initialize(VIRTUAL_MACHINE_ADD_LOGIN_FORM, { - [VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD]: vmUuid, - [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: [], - })); - dispatch(dialogActions.OPEN_DIALOG( {id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: {excludedParticipants: logins.items.map(it => it.tailUuid)}} )); + [VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD]: vmUuid, + [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: [], + })); + dispatch(dialogActions.OPEN_DIALOG({ id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: { excludedParticipants: logins.items.map(it => it.tailUuid) } })); } export const openEditVirtualMachineLoginDialog = (permissionUuid: string) => @@ -143,11 +155,11 @@ export const openEditVirtualMachineLoginDialog = (permissionUuid: string) => const login = await services.permissionService.get(permissionUuid); const user = await services.userService.get(login.tailUuid); dispatch(initialize(VIRTUAL_MACHINE_ADD_LOGIN_FORM, { - [VIRTUAL_MACHINE_UPDATE_LOGIN_UUID_FIELD]: permissionUuid, - [VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD]: {name: getUserDisplayName(user, true, true), uuid: login.tailUuid}, - [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: login.properties.groups, - })); - dispatch(dialogActions.OPEN_DIALOG( {id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: {updating: true}} )); + [VIRTUAL_MACHINE_UPDATE_LOGIN_UUID_FIELD]: permissionUuid, + [VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD]: { name: getUserDisplayName(user, true, true), uuid: login.tailUuid }, + [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: login.properties.groups, + })); + dispatch(dialogActions.OPEN_DIALOG({ id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: { updating: true } })); } export interface AddLoginFormData { @@ -158,15 +170,15 @@ export interface AddLoginFormData { } -export const addUpdateVirtualMachineLogin = ({uuid, vmUuid, user, groups}: AddLoginFormData) => +export const addUpdateVirtualMachineLogin = ({ uuid, vmUuid, user, groups }: AddLoginFormData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { let userResource: UserResource | undefined = undefined; try { // Get user userResource = await services.userService.get(user.uuid, false); } catch (e) { - dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Failed to get user details.", hideDuration: 2000, kind: SnackbarKind.ERROR })); - return; + dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Failed to get user details.", hideDuration: 2000, kind: SnackbarKind.ERROR })); + return; } try { if (uuid) { diff --git a/src/store/workbench/workbench-actions.ts b/src/store/workbench/workbench-actions.ts index 1cf71706..188dba05 100644 --- a/src/store/workbench/workbench-actions.ts +++ b/src/store/workbench/workbench-actions.ts @@ -2,31 +2,24 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { Dispatch } from 'redux'; -import { RootState } from 'store/store'; -import { getUserUuid } from 'common/getuser'; -import { loadDetailsPanel } from 'store/details-panel/details-panel-action'; -import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; -import { - favoritePanelActions, - loadFavoritePanel, -} from 'store/favorite-panel/favorite-panel-action'; -import { - getProjectPanelCurrentUuid, - openProjectPanel, - projectPanelActions, - setIsProjectPanelTrashed, -} from 'store/project-panel/project-panel-action'; +import { Dispatch } from "redux"; +import { RootState } from "store/store"; +import { getUserUuid } from "common/getuser"; +import { loadDetailsPanel } from "store/details-panel/details-panel-action"; +import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions"; +import { favoritePanelActions, loadFavoritePanel } from "store/favorite-panel/favorite-panel-action"; +import { getProjectPanelCurrentUuid, setIsProjectPanelTrashed } from "store/project-panel/project-panel-action"; +import { projectPanelActions } from "store/project-panel/project-panel-action-bind"; import { activateSidePanelTreeItem, initSidePanelTree, loadSidePanelTreeProjects, SidePanelTreeCategory, -} from 'store/side-panel-tree/side-panel-tree-actions'; -import { updateResources } from 'store/resources/resources-actions'; -import { projectPanelColumns } from 'views/project-panel/project-panel'; -import { favoritePanelColumns } from 'views/favorite-panel/favorite-panel'; -import { matchRootRoute } from 'routes/routes'; +} from "store/side-panel-tree/side-panel-tree-actions"; +import { updateResources } from "store/resources/resources-actions"; +import { projectPanelColumns } from "views/project-panel/project-panel"; +import { favoritePanelColumns } from "views/favorite-panel/favorite-panel"; +import { matchRootRoute } from "routes/routes"; import { setBreadcrumbs, setGroupDetailsBreadcrumbs, @@ -38,222 +31,177 @@ import { setUsersBreadcrumbs, setMyAccountBreadcrumbs, setUserProfileBreadcrumbs, -} from 'store/breadcrumbs/breadcrumbs-actions'; -import { - navigateTo, - navigateToRootProject, -} from 'store/navigation/navigation-action'; -import { MoveToFormDialogData } from 'store/move-to-dialog/move-to-dialog'; -import { ServiceRepository } from 'services/services'; -import { getResource } from 'store/resources/resources'; -import * as projectCreateActions from 'store/projects/project-create-actions'; -import * as projectMoveActions from 'store/projects/project-move-actions'; -import * as projectUpdateActions from 'store/projects/project-update-actions'; -import * as collectionCreateActions from 'store/collections/collection-create-actions'; -import * as collectionCopyActions from 'store/collections/collection-copy-actions'; -import * as collectionMoveActions from 'store/collections/collection-move-actions'; -import * as processesActions from 'store/processes/processes-actions'; -import * as processMoveActions from 'store/processes/process-move-actions'; -import * as processUpdateActions from 'store/processes/process-update-actions'; -import * as processCopyActions from 'store/processes/process-copy-actions'; -import { trashPanelColumns } from 'views/trash-panel/trash-panel'; -import { - loadTrashPanel, - trashPanelActions, -} from 'store/trash-panel/trash-panel-action'; -import { loadProcessPanel } from 'store/process-panel/process-panel-actions'; -import { - loadSharedWithMePanel, - sharedWithMePanelActions, -} from 'store/shared-with-me-panel/shared-with-me-panel-actions'; -import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog'; -import { workflowPanelActions } from 'store/workflow-panel/workflow-panel-actions'; -import { loadSshKeysPanel } from 'store/auth/auth-action-ssh'; -import { - loadLinkAccountPanel, - linkAccountPanelActions, -} from 'store/link-account-panel/link-account-panel-actions'; -import { loadSiteManagerPanel } from 'store/auth/auth-action-session'; -import { workflowPanelColumns } from 'views/workflow-panel/workflow-panel-view'; -import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions'; -import { getProgressIndicator } from 'store/progress-indicator/progress-indicator-reducer'; -import { extractUuidKind, ResourceKind } from 'models/resource'; -import { FilterBuilder } from 'services/api/filter-builder'; -import { GroupContentsResource } from 'services/groups-service/groups-service'; -import { MatchCases, ofType, unionize, UnionOf } from 'common/unionize'; -import { loadRunProcessPanel } from 'store/run-process-panel/run-process-panel-actions'; -import { - collectionPanelActions, - loadCollectionPanel, -} from 'store/collection-panel/collection-panel-action'; -import { CollectionResource } from 'models/collection'; -import { - loadSearchResultsPanel, - searchResultsPanelActions, -} from 'store/search-results-panel/search-results-panel-actions'; -import { searchResultsPanelColumns } from 'views/search-results-panel/search-results-panel-view'; -import { loadVirtualMachinesPanel } from 'store/virtual-machines/virtual-machines-actions'; -import { loadRepositoriesPanel } from 'store/repositories/repositories-actions'; -import { loadKeepServicesPanel } from 'store/keep-services/keep-services-actions'; -import { loadUsersPanel, userBindedActions } from 'store/users/users-actions'; -import * as userProfilePanelActions from 'store/user-profile/user-profile-actions'; -import { - linkPanelActions, - loadLinkPanel, -} from 'store/link-panel/link-panel-actions'; -import { linkPanelColumns } from 'views/link-panel/link-panel-root'; -import { userPanelColumns } from 'views/user-panel/user-panel'; -import { - loadApiClientAuthorizationsPanel, - apiClientAuthorizationsActions, -} from 'store/api-client-authorizations/api-client-authorizations-actions'; -import { apiClientAuthorizationPanelColumns } from 'views/api-client-authorization-panel/api-client-authorization-panel-root'; -import * as groupPanelActions from 'store/groups-panel/groups-panel-actions'; -import { groupsPanelColumns } from 'views/groups-panel/groups-panel'; -import * as groupDetailsPanelActions from 'store/group-details-panel/group-details-panel-actions'; -import { - groupDetailsMembersPanelColumns, - groupDetailsPermissionsPanelColumns, -} from 'views/group-details-panel/group-details-panel'; -import { DataTableFetchMode } from 'components/data-table/data-table'; -import { - loadPublicFavoritePanel, - publicFavoritePanelActions, -} from 'store/public-favorites-panel/public-favorites-action'; -import { publicFavoritePanelColumns } from 'views/public-favorites-panel/public-favorites-panel'; +} from "store/breadcrumbs/breadcrumbs-actions"; +import { navigateTo, navigateToRootProject } from "store/navigation/navigation-action"; +import { MoveToFormDialogData } from "store/move-to-dialog/move-to-dialog"; +import { ServiceRepository } from "services/services"; +import { getResource } from "store/resources/resources"; +import * as projectCreateActions from "store/projects/project-create-actions"; +import * as projectMoveActions from "store/projects/project-move-actions"; +import * as projectUpdateActions from "store/projects/project-update-actions"; +import * as collectionCreateActions from "store/collections/collection-create-actions"; +import * as collectionCopyActions from "store/collections/collection-copy-actions"; +import * as collectionMoveActions from "store/collections/collection-move-actions"; +import * as processesActions from "store/processes/processes-actions"; +import * as processMoveActions from "store/processes/process-move-actions"; +import * as processUpdateActions from "store/processes/process-update-actions"; +import * as processCopyActions from "store/processes/process-copy-actions"; +import { trashPanelColumns } from "views/trash-panel/trash-panel"; +import { loadTrashPanel, trashPanelActions } from "store/trash-panel/trash-panel-action"; +import { loadProcessPanel } from "store/process-panel/process-panel-actions"; +import { loadSharedWithMePanel, sharedWithMePanelActions } from "store/shared-with-me-panel/shared-with-me-panel-actions"; +import { sharedWithMePanelColumns } from "views/shared-with-me-panel/shared-with-me-panel"; +import { CopyFormDialogData } from "store/copy-dialog/copy-dialog"; +import { workflowPanelActions } from "store/workflow-panel/workflow-panel-actions"; +import { loadSshKeysPanel } from "store/auth/auth-action-ssh"; +import { loadLinkAccountPanel, linkAccountPanelActions } from "store/link-account-panel/link-account-panel-actions"; +import { loadSiteManagerPanel } from "store/auth/auth-action-session"; +import { workflowPanelColumns } from "views/workflow-panel/workflow-panel-view"; +import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions"; +import { getProgressIndicator } from "store/progress-indicator/progress-indicator-reducer"; +import { extractUuidKind, Resource, ResourceKind } from "models/resource"; +import { FilterBuilder } from "services/api/filter-builder"; +import { GroupContentsResource } from "services/groups-service/groups-service"; +import { MatchCases, ofType, unionize, UnionOf } from "common/unionize"; +import { loadRunProcessPanel } from "store/run-process-panel/run-process-panel-actions"; +import { collectionPanelActions, loadCollectionPanel } from "store/collection-panel/collection-panel-action"; +import { CollectionResource } from "models/collection"; +import { WorkflowResource } from "models/workflow"; +import { loadSearchResultsPanel, searchResultsPanelActions } from "store/search-results-panel/search-results-panel-actions"; +import { searchResultsPanelColumns } from "views/search-results-panel/search-results-panel-view"; +import { loadVirtualMachinesPanel } from "store/virtual-machines/virtual-machines-actions"; +import { loadRepositoriesPanel } from "store/repositories/repositories-actions"; +import { loadKeepServicesPanel } from "store/keep-services/keep-services-actions"; +import { loadUsersPanel, userBindedActions } from "store/users/users-actions"; +import * as userProfilePanelActions from "store/user-profile/user-profile-actions"; +import { linkPanelActions, loadLinkPanel } from "store/link-panel/link-panel-actions"; +import { linkPanelColumns } from "views/link-panel/link-panel-root"; +import { userPanelColumns } from "views/user-panel/user-panel"; +import { loadApiClientAuthorizationsPanel, apiClientAuthorizationsActions } from "store/api-client-authorizations/api-client-authorizations-actions"; +import { apiClientAuthorizationPanelColumns } from "views/api-client-authorization-panel/api-client-authorization-panel-root"; +import * as groupPanelActions from "store/groups-panel/groups-panel-actions"; +import { groupsPanelColumns } from "views/groups-panel/groups-panel"; +import * as groupDetailsPanelActions from "store/group-details-panel/group-details-panel-actions"; +import { groupDetailsMembersPanelColumns, groupDetailsPermissionsPanelColumns } from "views/group-details-panel/group-details-panel"; +import { DataTableFetchMode } from "components/data-table/data-table"; +import { loadPublicFavoritePanel, publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action"; +import { publicFavoritePanelColumns } from "views/public-favorites-panel/public-favorites-panel"; import { loadCollectionsContentAddressPanel, collectionsContentAddressActions, -} from 'store/collections-content-address-panel/collections-content-address-panel-actions'; -import { collectionContentAddressPanelColumns } from 'views/collection-content-address-panel/collection-content-address-panel'; -import { subprocessPanelActions } from 'store/subprocess-panel/subprocess-panel-actions'; -import { subprocessPanelColumns } from 'views/subprocess-panel/subprocess-panel-root'; -import { - loadAllProcessesPanel, - allProcessesPanelActions, -} from '../all-processes-panel/all-processes-panel-action'; -import { allProcessesPanelColumns } from 'views/all-processes-panel/all-processes-panel'; -import { AdminMenuIcon } from 'components/icon/icon'; -import { userProfileGroupsColumns } from 'views/user-profile-panel/user-profile-panel-root'; - -export const WORKBENCH_LOADING_SCREEN = 'workbenchLoadingScreen'; +} from "store/collections-content-address-panel/collections-content-address-panel-actions"; +import { collectionContentAddressPanelColumns } from "views/collection-content-address-panel/collection-content-address-panel"; +import { subprocessPanelActions } from "store/subprocess-panel/subprocess-panel-actions"; +import { subprocessPanelColumns } from "views/subprocess-panel/subprocess-panel-root"; +import { loadAllProcessesPanel, allProcessesPanelActions } from "../all-processes-panel/all-processes-panel-action"; +import { allProcessesPanelColumns } from "views/all-processes-panel/all-processes-panel"; +import { AdminMenuIcon } from "components/icon/icon"; +import { userProfileGroupsColumns } from "views/user-profile-panel/user-profile-panel-root"; +import { selectedToArray, selectedToKindSet } from "components/multiselect-toolbar/MultiselectToolbar"; +import { multiselectActions } from "store/multiselect/multiselect-actions"; + +export const WORKBENCH_LOADING_SCREEN = "workbenchLoadingScreen"; export const isWorkbenchLoading = (state: RootState) => { - const progress = getProgressIndicator(WORKBENCH_LOADING_SCREEN)( - state.progressIndicator - ); + const progress = getProgressIndicator(WORKBENCH_LOADING_SCREEN)(state.progressIndicator); return progress ? progress.working : false; }; -export const handleFirstTimeLoad = - (action: any) => - async (dispatch: Dispatch, 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, getState: () => RootState) => { + try { + await dispatch(action); + } catch (e) { + snackbarActions.OPEN_SNACKBAR({ + message: "Error " + e, + hideDuration: 8000, + kind: SnackbarKind.WARNING, + }) + } finally { + if (isWorkbenchLoading(getState())) { + dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN)); + } + } +}; -export const loadWorkbench = - () => - async ( - dispatch: Dispatch, - getState: () => RootState, - services: ServiceRepository - ) => { - dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN)); - const { auth, router } = getState(); - const { user } = auth; - if (user) { - dispatch( - projectPanelActions.SET_COLUMNS({ columns: projectPanelColumns }) - ); - dispatch( - favoritePanelActions.SET_COLUMNS({ columns: favoritePanelColumns }) - ); - dispatch( - allProcessesPanelActions.SET_COLUMNS({ - columns: allProcessesPanelColumns, - }) - ); - dispatch( - publicFavoritePanelActions.SET_COLUMNS({ - columns: publicFavoritePanelColumns, - }) - ); - dispatch(trashPanelActions.SET_COLUMNS({ columns: trashPanelColumns })); - dispatch( - sharedWithMePanelActions.SET_COLUMNS({ columns: projectPanelColumns }) - ); - dispatch( - workflowPanelActions.SET_COLUMNS({ columns: workflowPanelColumns }) - ); - dispatch( - searchResultsPanelActions.SET_FETCH_MODE({ - fetchMode: DataTableFetchMode.INFINITE, - }) - ); - dispatch( - searchResultsPanelActions.SET_COLUMNS({ - columns: searchResultsPanelColumns, - }) - ); - dispatch(userBindedActions.SET_COLUMNS({ columns: userPanelColumns })); - dispatch( - groupPanelActions.GroupsPanelActions.SET_COLUMNS({ - columns: groupsPanelColumns, - }) - ); - dispatch( - groupDetailsPanelActions.GroupMembersPanelActions.SET_COLUMNS({ - columns: groupDetailsMembersPanelColumns, - }) - ); - dispatch( - groupDetailsPanelActions.GroupPermissionsPanelActions.SET_COLUMNS({ - columns: groupDetailsPermissionsPanelColumns, - }) - ); - dispatch( - userProfilePanelActions.UserProfileGroupsActions.SET_COLUMNS({ - columns: userProfileGroupsColumns, - }) - ); - dispatch(linkPanelActions.SET_COLUMNS({ columns: linkPanelColumns })); - dispatch( - apiClientAuthorizationsActions.SET_COLUMNS({ - columns: apiClientAuthorizationPanelColumns, - }) - ); - dispatch( - collectionsContentAddressActions.SET_COLUMNS({ - columns: collectionContentAddressPanelColumns, - }) - ); - dispatch( - subprocessPanelActions.SET_COLUMNS({ columns: subprocessPanelColumns }) - ); +export const loadWorkbench = () => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN)); + const { auth, router } = getState(); + const { user } = auth; + if (user) { + dispatch(projectPanelActions.SET_COLUMNS({ columns: projectPanelColumns })); + dispatch(favoritePanelActions.SET_COLUMNS({ columns: favoritePanelColumns })); + dispatch( + allProcessesPanelActions.SET_COLUMNS({ + columns: allProcessesPanelColumns, + }) + ); + dispatch( + publicFavoritePanelActions.SET_COLUMNS({ + columns: publicFavoritePanelColumns, + }) + ); + dispatch(trashPanelActions.SET_COLUMNS({ columns: trashPanelColumns })); + dispatch(sharedWithMePanelActions.SET_COLUMNS({ columns: sharedWithMePanelColumns })); + dispatch(workflowPanelActions.SET_COLUMNS({ columns: workflowPanelColumns })); + dispatch( + searchResultsPanelActions.SET_FETCH_MODE({ + fetchMode: DataTableFetchMode.INFINITE, + }) + ); + dispatch( + searchResultsPanelActions.SET_COLUMNS({ + columns: searchResultsPanelColumns, + }) + ); + dispatch(userBindedActions.SET_COLUMNS({ columns: userPanelColumns })); + dispatch( + groupPanelActions.GroupsPanelActions.SET_COLUMNS({ + columns: groupsPanelColumns, + }) + ); + dispatch( + groupDetailsPanelActions.GroupMembersPanelActions.SET_COLUMNS({ + columns: groupDetailsMembersPanelColumns, + }) + ); + dispatch( + groupDetailsPanelActions.GroupPermissionsPanelActions.SET_COLUMNS({ + columns: groupDetailsPermissionsPanelColumns, + }) + ); + dispatch( + userProfilePanelActions.UserProfileGroupsActions.SET_COLUMNS({ + columns: userProfileGroupsColumns, + }) + ); + dispatch(linkPanelActions.SET_COLUMNS({ columns: linkPanelColumns })); + dispatch( + apiClientAuthorizationsActions.SET_COLUMNS({ + columns: apiClientAuthorizationPanelColumns, + }) + ); + dispatch( + collectionsContentAddressActions.SET_COLUMNS({ + columns: collectionContentAddressPanelColumns, + }) + ); + dispatch(subprocessPanelActions.SET_COLUMNS({ columns: subprocessPanelColumns })); - if (services.linkAccountService.getAccountToLink()) { - dispatch(linkAccountPanelActions.HAS_SESSION_DATA()); - } + if (services.linkAccountService.getAccountToLink()) { + dispatch(linkAccountPanelActions.HAS_SESSION_DATA()); + } - dispatch(initSidePanelTree()); - if (router.location) { - const match = matchRootRoute(router.location.pathname); - if (match) { - dispatch(navigateToRootProject); - } - } - } else { - dispatch(userIsNotAuthenticated); + dispatch(initSidePanelTree()); + if (router.location) { + const match = matchRootRoute(router.location.pathname); + if (match) { + dispatch(navigateToRootProject); } - }; + } + } else { + dispatch(userIsNotAuthenticated); + } +}; export const loadFavorites = () => handleFirstTimeLoad((dispatch: Dispatch) => { @@ -262,11 +210,9 @@ export const loadFavorites = () => dispatch(setSidePanelBreadcrumbs(SidePanelTreeCategory.FAVORITES)); }); -export const loadCollectionContentAddress = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadCollectionsContentAddressPanel()); - } -); +export const loadCollectionContentAddress = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadCollectionsContentAddressPanel()); +}); export const loadTrash = () => handleFirstTimeLoad((dispatch: Dispatch) => { @@ -277,25 +223,20 @@ export const loadTrash = () => export const loadAllProcesses = () => handleFirstTimeLoad((dispatch: Dispatch) => { - dispatch( - activateSidePanelTreeItem(SidePanelTreeCategory.ALL_PROCESSES) - ); + dispatch(activateSidePanelTreeItem(SidePanelTreeCategory.ALL_PROCESSES)); dispatch(loadAllProcessesPanel()); dispatch(setSidePanelBreadcrumbs(SidePanelTreeCategory.ALL_PROCESSES)); }); export const loadProject = (uuid: string) => - handleFirstTimeLoad( - async ( - dispatch: Dispatch, - getState: () => RootState, - services: ServiceRepository - ) => { - const userUuid = getUserUuid(getState()); - dispatch(setIsProjectPanelTrashed(false)); - if (!userUuid) { - return; - } + handleFirstTimeLoad(async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const userUuid = getUserUuid(getState()); + dispatch(setIsProjectPanelTrashed(false)); + if (!userUuid) { + return; + } + try { + dispatch(progressIndicatorActions.START_WORKING(uuid)); if (extractUuidKind(uuid) === ResourceKind.USER && userUuid !== uuid) { // Load another users home projects dispatch(finishLoadingProject(uuid)); @@ -316,9 +257,7 @@ export const loadProject = (uuid: string) => dispatch(setSharedWithMeBreadcrumbs(uuid)); }, TRASHED: async () => { - await dispatch( - activateSidePanelTreeItem(SidePanelTreeCategory.TRASH) - ); + await dispatch(activateSidePanelTreeItem(SidePanelTreeCategory.TRASH)); dispatch(setTrashBreadcrumbs(uuid)); dispatch(setIsProjectPanelTrashed(true)); }, @@ -328,353 +267,429 @@ export const loadProject = (uuid: string) => await dispatch(activateSidePanelTreeItem(userUuid)); dispatch(setSidePanelBreadcrumbs(userUuid)); } + } finally { + dispatch(progressIndicatorActions.STOP_WORKING(uuid)); } - ); + }); -export const createProject = - (data: projectCreateActions.ProjectCreateFormDialogData) => - async (dispatch: Dispatch) => { - const newProject = await dispatch( - projectCreateActions.createProject(data) - ); - if (newProject) { - dispatch( - snackbarActions.OPEN_SNACKBAR({ - message: 'Project has been successfully created.', - hideDuration: 2000, - kind: SnackbarKind.SUCCESS, - }) - ); - await dispatch(loadSidePanelTreeProjects(newProject.ownerUuid)); - dispatch(navigateTo(newProject.uuid)); - } - }; +export const createProject = (data: projectCreateActions.ProjectCreateFormDialogData) => async (dispatch: Dispatch) => { + const newProject = await dispatch(projectCreateActions.createProject(data)); + if (newProject) { + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: "Project has been successfully created.", + hideDuration: 2000, + kind: SnackbarKind.SUCCESS, + }) + ); + await dispatch(loadSidePanelTreeProjects(newProject.ownerUuid)); + dispatch(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( - projectMoveActions.moveProject(data) - ); - if (movedProject) { + (data: MoveToFormDialogData, isSecondaryMove = false) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const checkedList = getState().multiselect.checkedList; + const uuidsToMove: string[] = data.fromContextMenu ? [data.uuid] : selectedToArray(checkedList); + + //if no items in checkedlist default to normal context menu behavior + if (!isSecondaryMove && !uuidsToMove.length) uuidsToMove.push(data.uuid); + + const sourceUuid = getResource(data.uuid)(getState().resources)?.ownerUuid; + const destinationUuid = data.ownerUuid; + + const projectsToMove: MoveableResource[] = uuidsToMove + .map(uuid => getResource(uuid)(getState().resources) as MoveableResource) + .filter(resource => resource.kind === ResourceKind.PROJECT); + + for (const project of projectsToMove) { + await moveSingleProject(project); + } + + //omly propagate if this call is the original + if (!isSecondaryMove) { + const kindsToMove: Set = 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(projectMoveActions.moveProject(oldProject)); + if (movedProject) { + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: "Project has been moved", + hideDuration: 2000, + kind: SnackbarKind.SUCCESS, + }) + ); + await dispatch(reloadProjectMatchingUuid([oldOwnerUuid, movedProject.ownerUuid, movedProject.uuid])); + } + } catch (e) { dispatch( snackbarActions.OPEN_SNACKBAR({ - message: 'Project has been moved', + message: !!(project as any).frozenByUuid ? 'Could not move frozen project.' : e.message, hideDuration: 2000, - kind: SnackbarKind.SUCCESS, + kind: SnackbarKind.ERROR, }) ); - if (oldProject) { - await dispatch(loadSidePanelTreeProjects(oldProject.ownerUuid)); - } - dispatch( - reloadProjectMatchingUuid([ - oldOwnerUuid, - movedProject.ownerUuid, - movedProject.uuid, - ]) - ); } - } catch (e) { - dispatch( - snackbarActions.OPEN_SNACKBAR({ - message: e.message, - hideDuration: 2000, - kind: SnackbarKind.ERROR, - }) - ); } + if (sourceUuid) await dispatch(loadSidePanelTreeProjects(sourceUuid)); + await dispatch(loadSidePanelTreeProjects(destinationUuid)); }; -export const updateProject = - (data: projectUpdateActions.ProjectUpdateFormDialogData) => - async (dispatch: Dispatch) => { - const updatedProject = await dispatch( - projectUpdateActions.updateProject(data) - ); - if (updatedProject) { - dispatch( - snackbarActions.OPEN_SNACKBAR({ - message: 'Project has been successfully updated.', - hideDuration: 2000, - kind: SnackbarKind.SUCCESS, - }) - ); - await dispatch(loadSidePanelTreeProjects(updatedProject.ownerUuid)); - dispatch( - reloadProjectMatchingUuid([ - updatedProject.ownerUuid, - updatedProject.uuid, - ]) - ); - } - }; +export const updateProject = (data: projectUpdateActions.ProjectUpdateFormDialogData) => async (dispatch: Dispatch) => { + const updatedProject = await dispatch(projectUpdateActions.updateProject(data)); + if (updatedProject) { + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: "Project has been successfully updated.", + hideDuration: 2000, + kind: SnackbarKind.SUCCESS, + }) + ); + await dispatch(loadSidePanelTreeProjects(updatedProject.ownerUuid)); + dispatch(reloadProjectMatchingUuid([updatedProject.ownerUuid, updatedProject.uuid])); + } +}; -export const updateGroup = - (data: projectUpdateActions.ProjectUpdateFormDialogData) => - async (dispatch: Dispatch) => { - const updatedGroup = await dispatch( - groupPanelActions.updateGroup(data) - ); - if (updatedGroup) { - dispatch( - snackbarActions.OPEN_SNACKBAR({ - message: 'Group has been successfully updated.', - hideDuration: 2000, - kind: SnackbarKind.SUCCESS, - }) - ); - await dispatch(loadSidePanelTreeProjects(updatedGroup.ownerUuid)); - dispatch( - reloadProjectMatchingUuid([updatedGroup.ownerUuid, updatedGroup.uuid]) - ); - } - }; +export const updateGroup = (data: projectUpdateActions.ProjectUpdateFormDialogData) => async (dispatch: Dispatch) => { + const updatedGroup = await dispatch(groupPanelActions.updateGroup(data)); + if (updatedGroup) { + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: "Group has been successfully updated.", + hideDuration: 2000, + kind: SnackbarKind.SUCCESS, + }) + ); + await dispatch(loadSidePanelTreeProjects(updatedGroup.ownerUuid)); + dispatch(reloadProjectMatchingUuid([updatedGroup.ownerUuid, updatedGroup.uuid])); + } +}; export const loadCollection = (uuid: string) => - handleFirstTimeLoad( - async ( - dispatch: Dispatch, - getState: () => RootState, - services: ServiceRepository - ) => { - const userUuid = getUserUuid(getState()); + handleFirstTimeLoad(async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const userUuid = getUserUuid(getState()); + try { + dispatch(progressIndicatorActions.START_WORKING(uuid)); if (userUuid) { const match = await loadGroupContentsResource({ uuid, userUuid, services, }); + let collection: CollectionResource | undefined; + let breadcrumbfunc: + | ((uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => Promise) + | 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(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(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(collectionCreateActions.createCollection(data)); + if (collection) { + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: "Collection has been successfully created.", + hideDuration: 2000, + kind: SnackbarKind.SUCCESS, + }) + ); + dispatch(updateResources([collection])); + dispatch(navigateTo(collection.uuid)); + } +}; + +export const copyCollection = (data: CopyFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const checkedList = getState().multiselect.checkedList; + const uuidsToCopy: string[] = data.fromContextMenu ? [data.uuid] : selectedToArray(checkedList); + + //if no items in checkedlist && no items passed in, default to normal context menu behavior + if (!uuidsToCopy.length) uuidsToCopy.push(data.uuid); + + const collectionsToCopy: CollectionCopyResource[] = uuidsToCopy + .map(uuid => getResource(uuid)(getState().resources) as CollectionCopyResource) + .filter(resource => resource.kind === ResourceKind.COLLECTION); + + for (const collection of collectionsToCopy) { + await copySingleCollection({ ...collection, ownerUuid: data.ownerUuid } as CollectionCopyResource); + } -export const createCollection = - (data: collectionCreateActions.CollectionCreateFormDialogData) => - async (dispatch: Dispatch) => { + async function copySingleCollection(copyToProject: CollectionCopyResource) { + const newName = data.fromContextMenu || collectionsToCopy.length === 1 ? data.name : `Copy of: ${copyToProject.name}`; + try { const collection = await dispatch( - collectionCreateActions.createCollection(data) + collectionCopyActions.copyCollection({ + ...copyToProject, + name: newName, + fromContextMenu: collectionsToCopy.length === 1 ? true : data.fromContextMenu, + }) ); - if (collection) { + if (copyToProject && collection) { + await dispatch(reloadProjectMatchingUuid([copyToProject.uuid])); dispatch( snackbarActions.OPEN_SNACKBAR({ - message: 'Collection has been successfully created.', - hideDuration: 2000, + message: "Collection has been copied.", + hideDuration: 3000, kind: SnackbarKind.SUCCESS, + link: collection.ownerUuid, }) ); - dispatch(updateResources([collection])); - dispatch(navigateTo(collection.uuid)); + dispatch(multiselectActions.deselectOne(copyToProject.uuid)); } - }; + } catch (e) { + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: e.message, + hideDuration: 2000, + kind: SnackbarKind.ERROR, + }) + ); + } + } + dispatch(projectPanelActions.REQUEST_ITEMS()); +}; -export const copyCollection = - (data: CopyFormDialogData) => - async ( - dispatch: Dispatch, - getState: () => RootState, - services: ServiceRepository - ) => { - try { - const copyToProject = getResource(data.ownerUuid)(getState().resources); - const collection = await dispatch( - collectionCopyActions.copyCollection(data) - ); - if (copyToProject && collection) { - dispatch(reloadProjectMatchingUuid([copyToProject.uuid])); +export const moveCollection = + (data: MoveToFormDialogData, isSecondaryMove = false) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const checkedList = getState().multiselect.checkedList; + const uuidsToMove: string[] = data.fromContextMenu ? [data.uuid] : selectedToArray(checkedList); + + //if no items in checkedlist && no items passed in, default to normal context menu behavior + if (!isSecondaryMove && !uuidsToMove.length) uuidsToMove.push(data.uuid); + + const collectionsToMove: MoveableResource[] = uuidsToMove + .map(uuid => getResource(uuid)(getState().resources) as MoveableResource) + .filter(resource => resource.kind === ResourceKind.COLLECTION); + + for (const collection of collectionsToMove) { + await moveSingleCollection(collection); + } + + //omly propagate if this call is the original + if (!isSecondaryMove) { + const kindsToMove: Set = 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(collectionMoveActions.moveCollection(oldCollection)); + dispatch(updateResources([movedCollection])); + dispatch(reloadProjectMatchingUuid([movedCollection.ownerUuid])); dispatch( snackbarActions.OPEN_SNACKBAR({ - message: 'Collection has been copied.', - hideDuration: 3000, + message: "Collection has been moved.", + hideDuration: 2000, kind: SnackbarKind.SUCCESS, - link: collection.ownerUuid, + }) + ); + } catch (e) { + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: e.message, + hideDuration: 2000, + kind: SnackbarKind.ERROR, }) ); } - } catch (e) { - dispatch( - snackbarActions.OPEN_SNACKBAR({ - message: e.message, - hideDuration: 2000, - kind: SnackbarKind.ERROR, - }) - ); - } - }; - -export const moveCollection = - (data: MoveToFormDialogData) => - async ( - dispatch: Dispatch, - getState: () => RootState, - services: ServiceRepository - ) => { - try { - const collection = await dispatch( - collectionMoveActions.moveCollection(data) - ); - dispatch(updateResources([collection])); - dispatch(reloadProjectMatchingUuid([collection.ownerUuid])); - dispatch( - snackbarActions.OPEN_SNACKBAR({ - message: 'Collection has been moved.', - hideDuration: 2000, - kind: SnackbarKind.SUCCESS, - }) - ); - } catch (e) { - dispatch( - snackbarActions.OPEN_SNACKBAR({ - message: e.message, - hideDuration: 2000, - kind: SnackbarKind.ERROR, - }) - ); } }; export const loadProcess = (uuid: string) => handleFirstTimeLoad(async (dispatch: Dispatch, getState: () => RootState) => { - dispatch(loadProcessPanel(uuid)); - const process = await dispatch(processesActions.loadProcess(uuid)); + try { + dispatch(progressIndicatorActions.START_WORKING(uuid)); + dispatch(loadProcessPanel(uuid)); + const process = await dispatch(processesActions.loadProcess(uuid)); + if (process) { + await dispatch(finishLoadingProject(process.containerRequest.ownerUuid)); + await dispatch(activateSidePanelTreeItem(process.containerRequest.ownerUuid)); + dispatch(setProcessBreadcrumbs(uuid)); + dispatch(loadDetailsPanel(uuid)); + } + } finally { + dispatch(progressIndicatorActions.STOP_WORKING(uuid)); + } + }); + +export const loadRegisteredWorkflow = (uuid: string) => + handleFirstTimeLoad(async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const userUuid = getUserUuid(getState()); + if (userUuid) { + const match = await loadGroupContentsResource({ + uuid, + userUuid, + services, + }); + let workflow: WorkflowResource | undefined; + let breadcrumbfunc: + | ((uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => Promise) + | 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(finishLoadingProject(workflow.ownerUuid)); + await dispatch(activateSidePanelTreeItem(workflow.ownerUuid)); + dispatch(breadcrumbfunc(workflow.ownerUuid)); + } + } + }); + +export const updateProcess = (data: processUpdateActions.ProcessUpdateFormDialogData) => async (dispatch: Dispatch) => { + try { + const process = await dispatch(processUpdateActions.updateProcess(data)); if (process) { - await dispatch( - activateSidePanelTreeItem(process.containerRequest.ownerUuid) + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: "Process has been successfully updated.", + hideDuration: 2000, + kind: SnackbarKind.SUCCESS, + }) ); - dispatch(setProcessBreadcrumbs(uuid)); - dispatch(loadDetailsPanel(uuid)); + dispatch(updateResources([process])); + dispatch(reloadProjectMatchingUuid([process.ownerUuid])); } - }); + } catch (e) { + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: e.message, + hideDuration: 2000, + kind: SnackbarKind.ERROR, + }) + ); + } +}; -export const updateProcess = - (data: processUpdateActions.ProcessUpdateFormDialogData) => - async (dispatch: Dispatch) => { - try { - const process = await dispatch( - processUpdateActions.updateProcess(data) - ); - if (process) { +export const moveProcess = + (data: MoveToFormDialogData, isSecondaryMove = false) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const checkedList = getState().multiselect.checkedList; + const uuidsToMove: string[] = data.fromContextMenu ? [data.uuid] : selectedToArray(checkedList); + + //if no items in checkedlist && no items passed in, default to normal context menu behavior + if (!isSecondaryMove && !uuidsToMove.length) uuidsToMove.push(data.uuid); + + const processesToMove: MoveableResource[] = uuidsToMove + .map(uuid => getResource(uuid)(getState().resources) as MoveableResource) + .filter(resource => resource.kind === ResourceKind.PROCESS); + + for (const process of processesToMove) { + await moveSingleProcess(process); + } + + //omly propagate if this call is the original + if (!isSecondaryMove) { + const kindsToMove: Set = 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(processMoveActions.moveProcess(oldProcess)); + dispatch(updateResources([movedProcess])); + dispatch(reloadProjectMatchingUuid([movedProcess.ownerUuid])); dispatch( snackbarActions.OPEN_SNACKBAR({ - message: 'Process has been successfully updated.', + message: "Process has been moved.", hideDuration: 2000, kind: SnackbarKind.SUCCESS, }) ); - dispatch(updateResources([process])); - dispatch(reloadProjectMatchingUuid([process.ownerUuid])); + } catch (e) { + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: e.message, + hideDuration: 2000, + kind: SnackbarKind.ERROR, + }) + ); } - } catch (e) { - dispatch( - snackbarActions.OPEN_SNACKBAR({ - message: e.message, - hideDuration: 2000, - kind: SnackbarKind.ERROR, - }) - ); } }; -export const moveProcess = - (data: MoveToFormDialogData) => - async ( - dispatch: Dispatch, - getState: () => RootState, - services: ServiceRepository - ) => { - try { - const process = await dispatch(processMoveActions.moveProcess(data)); - dispatch(updateResources([process])); - dispatch(reloadProjectMatchingUuid([process.ownerUuid])); - dispatch( - snackbarActions.OPEN_SNACKBAR({ - message: 'Process has been moved.', - hideDuration: 2000, - kind: SnackbarKind.SUCCESS, - }) - ); - } catch (e) { - dispatch( - snackbarActions.OPEN_SNACKBAR({ - message: e.message, - hideDuration: 2000, - kind: SnackbarKind.ERROR, - }) - ); - } - }; - -export const copyProcess = - (data: CopyFormDialogData) => - async ( - dispatch: Dispatch, - getState: () => RootState, - services: ServiceRepository - ) => { - try { - const process = await dispatch(processCopyActions.copyProcess(data)); - dispatch(updateResources([process])); - dispatch(reloadProjectMatchingUuid([process.ownerUuid])); - dispatch( - snackbarActions.OPEN_SNACKBAR({ - message: 'Process has been copied.', - hideDuration: 2000, - kind: SnackbarKind.SUCCESS, - }) - ); - dispatch(navigateTo(process.uuid)); - } catch (e) { - dispatch( - snackbarActions.OPEN_SNACKBAR({ - message: e.message, - hideDuration: 2000, - kind: SnackbarKind.ERROR, - }) - ); - } - }; +export const copyProcess = (data: CopyFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + try { + const process = await dispatch(processCopyActions.copyProcess(data)); + dispatch(updateResources([process])); + dispatch(reloadProjectMatchingUuid([process.ownerUuid])); + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: "Process has been copied.", + hideDuration: 2000, + kind: SnackbarKind.SUCCESS, + }) + ); + dispatch(navigateTo(process.uuid)); + } catch (e) { + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: e.message, + hideDuration: 2000, + kind: SnackbarKind.ERROR, + }) + ); + } +}; export const resourceIsNotLoaded = (uuid: string) => snackbarActions.OPEN_SNACKBAR({ @@ -683,106 +698,70 @@ export const resourceIsNotLoaded = (uuid: string) => }); export const userIsNotAuthenticated = snackbarActions.OPEN_SNACKBAR({ - message: 'User is not authenticated', + message: "User is not authenticated", kind: SnackbarKind.ERROR, }); export const couldNotLoadUser = snackbarActions.OPEN_SNACKBAR({ - message: 'Could not load user', + message: "Could not load user", kind: SnackbarKind.ERROR, }); export const reloadProjectMatchingUuid = - (matchingUuids: string[]) => - async ( - dispatch: Dispatch, - getState: () => RootState, - services: ServiceRepository - ) => { - const currentProjectPanelUuid = getProjectPanelCurrentUuid(getState()); - if ( - currentProjectPanelUuid && - matchingUuids.some((uuid) => uuid === currentProjectPanelUuid) - ) { - dispatch(loadProject(currentProjectPanelUuid)); - } - }; + (matchingUuids: string[]) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const currentProjectPanelUuid = getProjectPanelCurrentUuid(getState()); + if (currentProjectPanelUuid && matchingUuids.some(uuid => uuid === currentProjectPanelUuid)) { + dispatch(loadProject(currentProjectPanelUuid)); + } + }; -export const loadSharedWithMe = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - dispatch(loadSharedWithMePanel()); - await dispatch( - activateSidePanelTreeItem(SidePanelTreeCategory.SHARED_WITH_ME) - ); - await dispatch( - setSidePanelBreadcrumbs(SidePanelTreeCategory.SHARED_WITH_ME) - ); - } -); +export const loadSharedWithMe = handleFirstTimeLoad(async (dispatch: Dispatch) => { + dispatch(loadSharedWithMePanel()); + await dispatch(activateSidePanelTreeItem(SidePanelTreeCategory.SHARED_WITH_ME)); + await dispatch(setSidePanelBreadcrumbs(SidePanelTreeCategory.SHARED_WITH_ME)); +}); -export const loadRunProcess = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadRunProcessPanel()); - } -); +export const loadRunProcess = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadRunProcessPanel()); +}); export const loadPublicFavorites = () => handleFirstTimeLoad((dispatch: Dispatch) => { - dispatch( - activateSidePanelTreeItem(SidePanelTreeCategory.PUBLIC_FAVORITES) - ); + dispatch(activateSidePanelTreeItem(SidePanelTreeCategory.PUBLIC_FAVORITES)); dispatch(loadPublicFavoritePanel()); - dispatch( - setSidePanelBreadcrumbs(SidePanelTreeCategory.PUBLIC_FAVORITES) - ); + dispatch(setSidePanelBreadcrumbs(SidePanelTreeCategory.PUBLIC_FAVORITES)); }); -export const loadSearchResults = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadSearchResultsPanel()); - } -); +export const loadSearchResults = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadSearchResultsPanel()); +}); -export const loadLinks = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadLinkPanel()); - } -); +export const loadLinks = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadLinkPanel()); +}); -export const loadVirtualMachines = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadVirtualMachinesPanel()); - dispatch(setBreadcrumbs([{ label: 'Virtual Machines' }])); - } -); +export const loadVirtualMachines = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadVirtualMachinesPanel()); + dispatch(setBreadcrumbs([{ label: "Virtual Machines" }])); +}); -export const loadVirtualMachinesAdmin = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadVirtualMachinesPanel()); - dispatch( - setBreadcrumbs([{ label: 'Virtual Machines Admin', icon: AdminMenuIcon }]) - ); - } -); +export const loadVirtualMachinesAdmin = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadVirtualMachinesPanel()); + dispatch(setBreadcrumbs([{ label: "Virtual Machines Admin", icon: AdminMenuIcon }])); +}); -export const loadRepositories = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadRepositoriesPanel()); - dispatch(setBreadcrumbs([{ label: 'Repositories' }])); - } -); +export const loadRepositories = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadRepositoriesPanel()); + dispatch(setBreadcrumbs([{ label: "Repositories" }])); +}); -export const loadSshKeys = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadSshKeysPanel()); - } -); +export const loadSshKeys = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadSshKeysPanel()); +}); -export const loadSiteManager = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadSiteManagerPanel()); - } -); +export const loadSiteManager = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadSiteManagerPanel()); +}); export const loadUserProfile = (userUuid?: string) => handleFirstTimeLoad((dispatch: Dispatch) => { @@ -795,37 +774,27 @@ export const loadUserProfile = (userUuid?: string) => } }); -export const loadLinkAccount = handleFirstTimeLoad( - (dispatch: Dispatch) => { - dispatch(loadLinkAccountPanel()); - } -); +export const loadLinkAccount = handleFirstTimeLoad((dispatch: Dispatch) => { + dispatch(loadLinkAccountPanel()); +}); -export const loadKeepServices = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadKeepServicesPanel()); - } -); +export const loadKeepServices = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadKeepServicesPanel()); +}); -export const loadUsers = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadUsersPanel()); - dispatch(setUsersBreadcrumbs()); - } -); +export const loadUsers = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadUsersPanel()); + dispatch(setUsersBreadcrumbs()); +}); -export const loadApiClientAuthorizations = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadApiClientAuthorizationsPanel()); - } -); +export const loadApiClientAuthorizations = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadApiClientAuthorizationsPanel()); +}); -export const loadGroupsPanel = handleFirstTimeLoad( - (dispatch: Dispatch) => { - dispatch(setGroupsBreadcrumbs()); - dispatch(groupPanelActions.loadGroupsPanel()); - } -); +export const loadGroupsPanel = handleFirstTimeLoad((dispatch: Dispatch) => { + dispatch(setGroupsBreadcrumbs()); + dispatch(groupPanelActions.loadGroupsPanel()); +}); export const loadGroupDetailsPanel = (groupUuid: string) => handleFirstTimeLoad((dispatch: Dispatch) => { @@ -833,40 +802,26 @@ export const loadGroupDetailsPanel = (groupUuid: string) => dispatch(groupDetailsPanelActions.loadGroupDetailsPanel(groupUuid)); }); -const finishLoadingProject = - (project: GroupContentsResource | string) => - async (dispatch: Dispatch) => { - const uuid = typeof project === 'string' ? project : project.uuid; - dispatch(openProjectPanel(uuid)); - dispatch(loadDetailsPanel(uuid)); - if (typeof project !== 'string') { - dispatch(updateResources([project])); - } - }; +const finishLoadingProject = (project: GroupContentsResource | string) => async (dispatch: Dispatch) => { + const uuid = typeof project === "string" ? project : project.uuid; + dispatch(loadDetailsPanel(uuid)); + if (typeof project !== "string") { + dispatch(updateResources([project])); + } +}; -const loadGroupContentsResource = async (params: { - uuid: string; - userUuid: string; - services: ServiceRepository; -}) => { - const filters = new FilterBuilder() - .addEqual('uuid', params.uuid) - .getFilters(); - const { items } = await params.services.groupsService.contents( - params.userUuid, - { - filters, - recursive: true, - includeTrash: true, - } - ); +const loadGroupContentsResource = async (params: { uuid: string; userUuid: string; services: ServiceRepository }) => { + const filters = new FilterBuilder().addEqual("uuid", params.uuid).getFilters(); + const { items } = await params.services.groupsService.contents(params.userUuid, { + filters, + recursive: true, + includeTrash: true, + }); const resource = items.shift(); let handler: GroupContentsHandler; if (resource) { handler = - (resource.kind === ResourceKind.COLLECTION || - resource.kind === ResourceKind.PROJECT) && - resource.isTrashed + (resource.kind === ResourceKind.COLLECTION || resource.kind === ResourceKind.PROJECT) && resource.isTrashed ? groupContentsHandlers.TRASHED(resource) : groupContentsHandlers.OWNED(resource); } else { @@ -876,18 +831,16 @@ const loadGroupContentsResource = async (params: { resource = await params.services.collectionService.get(params.uuid); } else if (kind === ResourceKind.PROJECT) { resource = await params.services.projectService.get(params.uuid); - } else { + } else if (kind === ResourceKind.WORKFLOW) { + resource = await params.services.workflowService.get(params.uuid); + } else if (kind === ResourceKind.CONTAINER_REQUEST) { resource = await params.services.containerRequestService.get(params.uuid); + } else { + throw new Error("loadGroupContentsResource unsupported kind " + kind); } handler = groupContentsHandlers.SHARED(resource); } - return ( - cases: MatchCases< - typeof groupContentsHandlersRecord, - GroupContentsHandler, - void - > - ) => groupContentsHandlers.match(handler, cases); + return (cases: MatchCases) => groupContentsHandlers.match(handler, cases); }; const groupContentsHandlersRecord = { @@ -899,3 +852,18 @@ const groupContentsHandlersRecord = { const groupContentsHandlers = unionize(groupContentsHandlersRecord); type GroupContentsHandler = UnionOf; + +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; + +const secondaryMove: Record = { + [ResourceKind.PROJECT]: moveProject, + [ResourceKind.PROCESS]: moveProcess, + [ResourceKind.COLLECTION]: moveCollection, +}; diff --git a/src/store/workflow-panel/workflow-panel-actions.ts b/src/store/workflow-panel/workflow-panel-actions.ts index 66a15a9e..d8c3b651 100644 --- a/src/store/workflow-panel/workflow-panel-actions.ts +++ b/src/store/workflow-panel/workflow-panel-actions.ts @@ -8,14 +8,14 @@ import { ServiceRepository } from 'services/services'; import { bindDataExplorerActions } from 'store/data-explorer/data-explorer-action'; import { propertiesActions } from 'store/properties/properties-actions'; import { getProperty } from 'store/properties/properties'; -import { navigateToRunProcess } from 'store/navigation/navigation-action'; +import { navigateToRunProcess, navigateTo } from 'store/navigation/navigation-action'; import { goToStep, runProcessPanelActions, loadPresets, getWorkflowRunnerSettings } from 'store/run-process-panel/run-process-panel-actions'; -import { snackbarActions } from 'store/snackbar/snackbar-actions'; +import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; import { initialize } from 'redux-form'; import { RUN_PROCESS_BASIC_FORM } from 'views/run-process-panel/run-process-basic-form'; import { RUN_PROCESS_INPUTS_FORM } from 'views/run-process-panel/run-process-inputs-form'; @@ -23,7 +23,6 @@ import { RUN_PROCESS_ADVANCED_FORM } from 'views/run-process-panel/run-process-a import { getResource } from 'store/resources/resources'; import { ProjectResource } from 'models/project'; import { UserResource } from 'models/user'; -import { getUserUuid } from "common/getuser"; import { getWorkflowInputs, parseWorkflowDefinition } from 'models/workflow'; export const WORKFLOW_PANEL_ID = "workflowPanel"; @@ -63,9 +62,8 @@ export const openRunProcess = (workflowUuid: string, ownerUuid?: string, name?: let owner; if (ownerUuid) { // Must be writable. - const userUuid = getUserUuid(getState()); owner = getResource(ownerUuid)(getState().resources); - if (!owner || !userUuid || owner.writableBy.indexOf(userUuid) === -1) { + if (!owner || !owner.canWrite) { owner = undefined; } } @@ -103,6 +101,10 @@ export const getPublicGroupUuid = (state: RootState) => { const prefix = state.auth.localCluster; return `${prefix}-j7d0g-anonymouspublic`; }; +export const getAllUsersGroupUuid = (state: RootState) => { + const prefix = state.auth.localCluster; + return `${prefix}-j7d0g-fffffffffffffff`; +}; export const showWorkflowDetails = (uuid: string) => propertiesActions.SET_PROPERTY({ key: WORKFLOW_PANEL_DETAILS_UUID, value: uuid }); @@ -113,3 +115,11 @@ export const getWorkflowDetails = (state: RootState) => { const workflow = workflows.find(workflow => workflow.uuid === uuid); return workflow || undefined; }; + +export const deleteWorkflow = (workflowUuid: string, ownerUuid: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(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 })); + }; diff --git a/src/views-components/advanced-tab-dialog/advanced-tab-dialog.tsx b/src/views-components/advanced-tab-dialog/advanced-tab-dialog.tsx index bc84ed2c..3505faed 100644 --- a/src/views-components/advanced-tab-dialog/advanced-tab-dialog.tsx +++ b/src/views-components/advanced-tab-dialog/advanced-tab-dialog.tsx @@ -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} ; } diff --git a/src/views-components/baner/banner.tsx b/src/views-components/baner/banner.tsx index 9fae6381..ac5b8943 100644 --- a/src/views-components/baner/banner.tsx +++ b/src/views-components/baner/banner.tsx @@ -2,39 +2,41 @@ // // SPDX-License-Identifier: AGPL-3.0 -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useState, useCallback, useEffect } from "react"; import { Dialog, DialogContent, DialogActions, Button, StyleRulesCallback, withStyles, WithStyles } from "@material-ui/core"; import { connect } from "react-redux"; import { RootState } from "store/store"; import bannerActions from "store/banner/banner-action"; -import { ArvadosTheme } from 'common/custom-theme'; -import servicesProvider from 'common/service-provider'; -import { Dispatch } from 'redux'; +import { ArvadosTheme } from "common/custom-theme"; +import servicesProvider from "common/service-provider"; +import { Dispatch } from "redux"; +import { sanitizeHTML } from "common/html-sanitize"; -type CssRules = 'dialogContent' | 'dialogContentIframe'; +type CssRules = "dialogContent" | "dialogContentIframe"; const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ dialogContent: { - minWidth: '550px', - minHeight: '500px', - display: 'block' + minWidth: "550px", + minHeight: "500px", + display: "block", }, dialogContentIframe: { - minWidth: '550px', - minHeight: '500px' - } + minWidth: "550px", + minHeight: "500px", + }, }); interface BannerProps { isOpen: boolean; bannerUUID?: string; keepWebInlineServiceUrl: string; -}; +} -type BannerComponentProps = BannerProps & WithStyles & { - openBanner: Function, - closeBanner: Function, -}; +type BannerComponentProps = BannerProps & + WithStyles & { + openBanner: Function; + closeBanner: Function; + }; const mapStateToProps = (state: RootState): BannerProps => ({ isOpen: state.banner.isOpen, @@ -47,27 +49,23 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({ closeBanner: () => dispatch(bannerActions.closeBanner()), }); -export const BANNER_LOCAL_STORAGE_KEY = 'bannerFileData'; +export const BANNER_LOCAL_STORAGE_KEY = "bannerFileData"; export const BannerComponent = (props: BannerComponentProps) => { - const { - isOpen, - openBanner, - closeBanner, - bannerUUID, - keepWebInlineServiceUrl - } = props; - const [bannerContents, setBannerContents] = useState(`

Loading ...

`) + const { isOpen, openBanner, closeBanner, bannerUUID, keepWebInlineServiceUrl } = props; + const [bannerContents, setBannerContents] = useState(`

Loading ...

`); const onConfirm = useCallback(() => { closeBanner(); - }, [closeBanner]) + }, [closeBanner]); useEffect(() => { if (!!bannerUUID && bannerUUID !== "") { - servicesProvider.getServices().collectionService.files(bannerUUID) + servicesProvider + .getServices() + .collectionService.files(bannerUUID) .then(results => { - const bannerFileData = results.find(({name}) => name === 'banner.html'); + const bannerFileData = results.find(({ name }) => name === "banner.html"); const result = localStorage.getItem(BANNER_LOCAL_STORAGE_KEY); if (result && result === JSON.stringify(bannerFileData) && !isOpen) { @@ -75,7 +73,8 @@ export const BannerComponent = (props: BannerComponentProps) => { } if (bannerFileData) { - servicesProvider.getServices() + servicesProvider + .getServices() .collectionService.getFileContents(bannerFileData) .then(data => { setBannerContents(data); @@ -88,24 +87,28 @@ export const BannerComponent = (props: BannerComponentProps) => { }, [bannerUUID, keepWebInlineServiceUrl, openBanner, isOpen]); return ( - -
+ +
-
+
- +
); -} +}; export const Banner = withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(BannerComponent)); diff --git a/src/views-components/context-menu/action-sets/api-client-authorization-action-set.ts b/src/views-components/context-menu/action-sets/api-client-authorization-action-set.ts index aeaa6a22..8e75d22f 100644 --- a/src/views-components/context-menu/action-sets/api-client-authorization-action-set.ts +++ b/src/views-components/context-menu/action-sets/api-client-authorization-action-set.ts @@ -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(openApiClientAuthorizationAttributesDialog(uuid)); - } -}, { - name: "API Details", - icon: AdvancedIcon, - execute: (dispatch, { uuid }) => { - dispatch(openAdvancedTabDialog(uuid)); - } -}, { - name: "Remove", - icon: RemoveIcon, - execute: (dispatch, { uuid }) => { - dispatch(openApiClientAuthorizationRemoveDialog(uuid)); - } -}]]; +export const apiClientAuthorizationActionSet: ContextMenuActionSet = [ + [ + { + name: "Attributes", + icon: AttributesIcon, + execute: (dispatch, resources) => { + dispatch(openApiClientAuthorizationAttributesDialog(resources[0].uuid)); + }, + }, + { + name: "API Details", + icon: AdvancedIcon, + execute: (dispatch, resources) => { + dispatch(openAdvancedTabDialog(resources[0].uuid)); + }, + }, + { + name: "Remove", + icon: RemoveIcon, + execute: (dispatch, resources) => { + dispatch(openApiClientAuthorizationRemoveDialog(resources[0].uuid)); + }, + }, + ], +]; diff --git a/src/views-components/context-menu/action-sets/collection-action-set.ts b/src/views-components/context-menu/action-sets/collection-action-set.ts index edfaa3cd..95aec9c7 100644 --- a/src/views-components/context-menu/action-sets/collection-action-set.ts +++ b/src/views-components/context-menu/action-sets/collection-action-set.ts @@ -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(toggleFavorite(resource)).then(() => { - dispatch(favoritePanelActions.REQUEST_ITEMS()); - }); - } -}; - -const commonActionSet: ContextMenuActionSet = [[ - { - icon: OpenIcon, - name: "Open in new tab", - execute: (dispatch, resource) => { - dispatch(openInNewTabAction(resource)); - } - }, - { - icon: Link, - name: "Copy to clipboard", - execute: (dispatch, resource) => { - dispatch(copyToClipboardAction(resource)); - } - }, - { - icon: CopyIcon, - name: "Make a copy", - execute: (dispatch, resource) => { - dispatch(openCollectionCopyDialog(resource)); - } - - }, - { - icon: DetailsIcon, - name: "View details", - execute: dispatch => { - dispatch(toggleDetailsPanel()); - } - }, - { - icon: AdvancedIcon, - name: "API Details", - execute: (dispatch, resource) => { - dispatch(openAdvancedTabDialog(resource.uuid)); + name: "ToggleFavoriteAction", + execute: (dispatch, resources) => { + for (const resource of [...resources]) { + dispatch(toggleFavorite(resource)).then(() => { + dispatch(favoritePanelActions.REQUEST_ITEMS()); + }); } }, -]]; +}; +const commonActionSet: ContextMenuActionSet = [ + [ + { + icon: OpenIcon, + name: "Open in new tab", + execute: (dispatch, resources) => { + dispatch(openInNewTabAction(resources[0])); + }, + }, + { + icon: Link, + name: "Copy to clipboard", + execute: (dispatch, resources) => { + dispatch(copyToClipboardAction(resources)); + }, + }, + { + icon: CopyIcon, + name: "Make a copy", + execute: (dispatch, resources) => { + if (resources[0].fromContextMenu || resources.length === 1) dispatch(openCollectionCopyDialog(resources[0])); + else dispatch(openMultiCollectionCopyDialog(resources[0])); + }, + }, + { + icon: DetailsIcon, + name: "View details", + execute: dispatch => { + dispatch(toggleDetailsPanel()); + }, + }, + { + icon: AdvancedIcon, + name: "API Details", + execute: (dispatch, resources) => { + dispatch(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(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(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(openCollectionUpdateDialog(resource)); - } + execute: (dispatch, resources) => { + dispatch(openCollectionUpdateDialog(resources[0])); + }, }, { icon: ShareIcon, name: "Share", - execute: (dispatch, { uuid }) => { - dispatch(openSharingDialog(uuid)); - } + execute: (dispatch, resources) => { + dispatch(openSharingDialog(resources[0].uuid)); + }, }, { icon: MoveToIcon, name: "Move to", - execute: (dispatch, resource) => dispatch(openMoveCollectionDialog(resource)) + execute: (dispatch, resources) => dispatch(openMoveCollectionDialog(resources[0])), }, { component: ToggleTrashAction, - name: 'ToggleTrashAction', - execute: (dispatch, resource) => { - dispatch(toggleCollectionTrashed(resource.uuid, resource.isTrashed!!)); - } + name: "ToggleTrashAction", + execute: (dispatch, resources: ContextMenuResource[]) => { + for (const resource of [...resources]) { + dispatch(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(togglePublicFavorite(resource)).then(() => { - dispatch(publicFavoritePanelActions.REQUEST_ITEMS()); - }); - } + name: "TogglePublicFavoriteAction", + execute: (dispatch, resources) => { + for (const resource of [...resources]) { + dispatch(togglePublicFavorite(resource)).then(() => { + dispatch(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(openRestoreCollectionVersionDialog(uuid)); - } + name: "Restore version", + execute: (dispatch, resources) => { + for (const resource of [...resources]) { + dispatch(openRestoreCollectionVersionDialog(resource.uuid)); + } + }, }, - ] + ], ]; diff --git a/src/views-components/context-menu/action-sets/collection-files-action-set.ts b/src/views-components/context-menu/action-sets/collection-files-action-set.ts index f34f2868..80deb37c 100644 --- a/src/views-components/context-menu/action-sets/collection-files-action-set.ts +++ b/src/views-components/context-menu/action-sets/collection-files-action-set.ts @@ -2,48 +2,114 @@ // // 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(openCollectionPartialCopyMultipleToNewCollectionDialog()); } }, { - name: "Unselect all", + name: "Copy selected into existing collection", + icon: FileCopyIcon, execute: dispatch => { - dispatch(collectionPanelFilesAction.UNSELECT_ALL_COLLECTION_FILES()); + dispatch(openCollectionPartialCopyMultipleToExistingCollectionDialog()); + } + }, +]; + +const copyActionsMultiple: ContextMenuAction[] = [ + ...copyActions, + { + name: "Copy selected into separate collections", + icon: FileCopyIcon, + execute: dispatch => { + dispatch(openCollectionPartialCopyToSeparateCollectionsDialog()); + } + } +]; + +const moveActions: ContextMenuAction[] = [ + { + name: "Move selected into new collection", + icon: FileMoveIcon, + execute: dispatch => { + dispatch(openCollectionPartialMoveMultipleToNewCollectionDialog()); } }, { - name: "Create a new collection with selected", + name: "Move selected into existing collection", + icon: FileMoveIcon, execute: dispatch => { - dispatch(openCollectionPartialCopyDialog()); + dispatch(openCollectionPartialMoveMultipleToExistingCollectionDialog()); } }, - // Disabled while addressing #18587 - // { - // name: "Copy selected into the collection", - // execute: dispatch => { - // dispatch(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(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 ]]); diff --git a/src/views-components/context-menu/action-sets/collection-files-item-action-set.ts b/src/views-components/context-menu/action-sets/collection-files-item-action-set.ts index 4cb9ebda..fb158a82 100644 --- a/src/views-components/context-menu/action-sets/collection-files-item-action-set.ts +++ b/src/views-components/context-menu/action-sets/collection-files-item-action-set.ts @@ -3,51 +3,102 @@ // 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(openCollectionPartialCopyToNewCollectionDialog(resources[0])); + }, + }, + { + name: "Copy item into existing collection", + icon: FileCopyIcon, + execute: (dispatch, resources) => { + dispatch(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(openRenameFileDialog({ - name: resource.name, - id: resource.uuid, - path: resource.uuid.split('/').slice(1).join('/') })); - } - }, - { - name: "Remove", - icon: RemoveIcon, - execute: (dispatch, resource) => { - dispatch(openFileRemoveDialog(resource.uuid)); - } - } -]]; +const writableActionSet: ContextMenuActionSet = [ + [ + { + name: "Move item into new collection", + icon: FileMoveIcon, + execute: (dispatch, resources) => { + dispatch(openCollectionPartialMoveToNewCollectionDialog(resources[0])); + }, + }, + { + name: "Move item into existing collection", + icon: FileMoveIcon, + execute: (dispatch, resources) => { + dispatch(openCollectionPartialMoveToExistingCollectionDialog(resources[0])); + }, + }, + { + name: "Rename", + icon: RenameIcon, + execute: (dispatch, resources) => { + dispatch( + 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(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); diff --git a/src/views-components/context-menu/action-sets/collection-files-not-selected-action-set.ts b/src/views-components/context-menu/action-sets/collection-files-not-selected-action-set.ts index 1ad13e74..1e31d11c 100644 --- a/src/views-components/context-menu/action-sets/collection-files-not-selected-action-set.ts +++ b/src/views-components/context-menu/action-sets/collection-files-not-selected-action-set.ts @@ -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 +}]]; diff --git a/src/views-components/context-menu/action-sets/favorite-action-set.ts b/src/views-components/context-menu/action-sets/favorite-action-set.ts index ee012fb1..bdc4b07a 100644 --- a/src/views-components/context-menu/action-sets/favorite-action-set.ts +++ b/src/views-components/context-menu/action-sets/favorite-action-set.ts @@ -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(toggleFavorite(resource)).then(() => { - dispatch(favoritePanelActions.REQUEST_ITEMS()); - }); - } -}]]; +export const favoriteActionSet: ContextMenuActionSet = [ + [ + { + component: ToggleFavoriteAction, + execute: (dispatch, resources) => { + resources.forEach((resource) => + dispatch(toggleFavorite(resource)).then(() => { + dispatch(favoritePanelActions.REQUEST_ITEMS()); + }) + ); + }, + }, + ], +]; diff --git a/src/views-components/context-menu/action-sets/group-action-set.ts b/src/views-components/context-menu/action-sets/group-action-set.ts index f573af69..816583fa 100644 --- a/src/views-components/context-menu/action-sets/group-action-set.ts +++ b/src/views-components/context-menu/action-sets/group-action-set.ts @@ -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(openGroupUpdateDialog(resource)); - } -}, { - name: "Attributes", - icon: AttributesIcon, - execute: (dispatch, { uuid }) => { - dispatch(openGroupAttributes(uuid)); - } -}, { - name: "API Details", - icon: AdvancedIcon, - execute: (dispatch, resource) => { - dispatch(openAdvancedTabDialog(resource.uuid)); - } -}, { - name: "Remove", - icon: RemoveIcon, - execute: (dispatch, { uuid }) => { - dispatch(openRemoveGroupDialog(uuid)); - } -}]]; +export const groupActionSet: ContextMenuActionSet = [ + [ + { + name: 'Rename', + icon: RenameIcon, + execute: (dispatch, resources) => { + dispatch(openGroupUpdateDialog(resources[0])) + }, + }, + { + name: 'Attributes', + icon: AttributesIcon, + execute: (dispatch, resources) => { + dispatch(openGroupAttributes(resources[0].uuid)) + }, + }, + { + name: 'API Details', + icon: AdvancedIcon, + execute: (dispatch, resources) => { + dispatch(openAdvancedTabDialog(resources[0].uuid)); + }, + }, + { + name: 'Remove', + icon: RemoveIcon, + execute: (dispatch, resources) => { + dispatch(openRemoveGroupDialog(resources[0].uuid)); + }, + }, + ], +]; diff --git a/src/views-components/context-menu/action-sets/group-member-action-set.ts b/src/views-components/context-menu/action-sets/group-member-action-set.ts index 37aa35c0..ad1ce97c 100644 --- a/src/views-components/context-menu/action-sets/group-member-action-set.ts +++ b/src/views-components/context-menu/action-sets/group-member-action-set.ts @@ -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(openGroupMemberAttributes(uuid)); - } -}, { - name: "API Details", - icon: AdvancedIcon, - execute: (dispatch, resource) => { - dispatch(openAdvancedTabDialog(resource.uuid)); - } -}, { - name: "Remove", - icon: RemoveIcon, - execute: (dispatch, { uuid }) => { - dispatch(openRemoveGroupMemberDialog(uuid)); - } -}]]; +export const groupMemberActionSet: ContextMenuActionSet = [ + [ + { + name: 'Attributes', + icon: AttributesIcon, + execute: (dispatch, resources) => { + dispatch(openGroupMemberAttributes(resources[0].uuid)); + }, + }, + { + name: 'API Details', + icon: AdvancedIcon, + execute: (dispatch, resources) => { + dispatch(openAdvancedTabDialog(resources[0].uuid)); + }, + }, + { + name: 'Remove', + icon: RemoveIcon, + execute: (dispatch, resources) => { + dispatch(openRemoveGroupMemberDialog(resources[0].uuid)); + }, + }, + ], +]; diff --git a/src/views-components/context-menu/action-sets/keep-service-action-set.ts b/src/views-components/context-menu/action-sets/keep-service-action-set.ts index 820d1978..2957f008 100644 --- a/src/views-components/context-menu/action-sets/keep-service-action-set.ts +++ b/src/views-components/context-menu/action-sets/keep-service-action-set.ts @@ -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(openKeepServiceAttributesDialog(uuid)); - } -}, { - name: "API Details", - icon: AdvancedIcon, - execute: (dispatch, { uuid }) => { - dispatch(openAdvancedTabDialog(uuid)); - } -}, { - name: "Remove", - icon: RemoveIcon, - execute: (dispatch, { uuid }) => { - dispatch(openKeepServiceRemoveDialog(uuid)); - } -}]]; +export const keepServiceActionSet: ContextMenuActionSet = [ + [ + { + name: 'Attributes', + icon: AttributesIcon, + execute: (dispatch, resources) => { + dispatch(openKeepServiceAttributesDialog(resources[0].uuid)); + }, + }, + { + name: 'API Details', + icon: AdvancedIcon, + execute: (dispatch, resources) => { + dispatch(openAdvancedTabDialog(resources[0].uuid)); + }, + }, + { + name: 'Remove', + icon: RemoveIcon, + execute: (dispatch, resources) => { + dispatch(openKeepServiceRemoveDialog(resources[0].uuid)); + }, + }, + ], +]; diff --git a/src/views-components/context-menu/action-sets/link-action-set.ts b/src/views-components/context-menu/action-sets/link-action-set.ts index 929a65a9..86458423 100644 --- a/src/views-components/context-menu/action-sets/link-action-set.ts +++ b/src/views-components/context-menu/action-sets/link-action-set.ts @@ -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(openLinkAttributesDialog(uuid)); - } -}, { - name: "API Details", - icon: AdvancedIcon, - execute: (dispatch, { uuid }) => { - dispatch(openAdvancedTabDialog(uuid)); - } -}, { - name: "Remove", - icon: RemoveIcon, - execute: (dispatch, { uuid }) => { - dispatch(openLinkRemoveDialog(uuid)); - } -}]]; +export const linkActionSet: ContextMenuActionSet = [ + [ + { + name: 'Attributes', + icon: AttributesIcon, + execute: (dispatch, resources) => { + dispatch(openLinkAttributesDialog(resources[0].uuid)); + }, + }, + { + name: 'API Details', + icon: AdvancedIcon, + execute: (dispatch, resources) => { + dispatch(openAdvancedTabDialog(resources[0].uuid)); + }, + }, + { + name: 'Remove', + icon: RemoveIcon, + execute: (dispatch, resources) => { + dispatch(openLinkRemoveDialog(resources[0].uuid)); + }, + }, + ], +]; diff --git a/src/views-components/context-menu/action-sets/permission-edit-action-set.ts b/src/views-components/context-menu/action-sets/permission-edit-action-set.ts index 8663d3c7..4b6950ee 100644 --- a/src/views-components/context-menu/action-sets/permission-edit-action-set.ts +++ b/src/views-components/context-menu/action-sets/permission-edit-action-set.ts @@ -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(editPermissionLevel(uuid, PermissionLevel.CAN_READ)); - } -}, { - name: "Write", - icon: CanWriteIcon, - execute: (dispatch, { uuid }) => { - dispatch(editPermissionLevel(uuid, PermissionLevel.CAN_WRITE)); - } -}, { - name: "Manage", - icon: CanManageIcon, - execute: (dispatch, { uuid }) => { - dispatch(editPermissionLevel(uuid, PermissionLevel.CAN_MANAGE)); - } -}]]; +export const permissionEditActionSet: ContextMenuActionSet = [ + [ + { + name: 'Read', + icon: CanReadIcon, + execute: (dispatch, resources) => { + resources.forEach((resource) => dispatch(editPermissionLevel(resource.uuid, PermissionLevel.CAN_READ))); + }, + }, + { + name: 'Write', + icon: CanWriteIcon, + execute: (dispatch, resources) => { + resources.forEach((resource) => dispatch(editPermissionLevel(resource.uuid, PermissionLevel.CAN_WRITE))); + }, + }, + { + name: 'Manage', + icon: CanManageIcon, + execute: (dispatch, resources) => { + resources.forEach((resource) => dispatch(editPermissionLevel(resource.uuid, PermissionLevel.CAN_MANAGE))); + }, + }, + ], +]; diff --git a/src/views-components/context-menu/action-sets/process-resource-action-set.ts b/src/views-components/context-menu/action-sets/process-resource-action-set.ts index 7d593ee4..2aa7faa1 100644 --- a/src/views-components/context-menu/action-sets/process-resource-action-set.ts +++ b/src/views-components/context-menu/action-sets/process-resource-action-set.ts @@ -6,114 +6,153 @@ import { ContextMenuActionSet } from "../context-menu-action-set"; import { ToggleFavoriteAction } from "../actions/favorite-action"; import { toggleFavorite } from "store/favorites/favorites-actions"; import { - RenameIcon, ShareIcon, MoveToIcon, DetailsIcon, - RemoveIcon, ReRunProcessIcon, OutputIcon, + RenameIcon, + ShareIcon, + MoveToIcon, + DetailsIcon, + RemoveIcon, + ReRunProcessIcon, + OutputIcon, AdvancedIcon, - OpenIcon + OpenIcon, + StopIcon, } from "components/icon/icon"; import { favoritePanelActions } from "store/favorite-panel/favorite-panel-action"; -import { openMoveProcessDialog } from 'store/processes/process-move-actions'; +import { openMoveProcessDialog } from "store/processes/process-move-actions"; import { openProcessUpdateDialog } from "store/processes/process-update-actions"; -import { openCopyProcessDialog } from 'store/processes/process-copy-actions'; +import { openCopyProcessDialog } from "store/processes/process-copy-actions"; import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions"; import { openRemoveProcessDialog } from "store/processes/processes-actions"; -import { toggleDetailsPanel } from 'store/details-panel/details-panel-action'; +import { toggleDetailsPanel } from "store/details-panel/details-panel-action"; import { navigateToOutput } from "store/process-panel/process-panel-actions"; import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab"; import { TogglePublicFavoriteAction } from "../actions/public-favorite-action"; import { togglePublicFavorite } from "store/public-favorites/public-favorites-actions"; import { publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action"; import { openInNewTabAction } from "store/open-in-new-tab/open-in-new-tab.actions"; +import { cancelRunningWorkflow } from "store/processes/processes-actions"; -export const readOnlyProcessResourceActionSet: ContextMenuActionSet = [[ - { - component: ToggleFavoriteAction, - execute: (dispatch, resource) => { - dispatch(toggleFavorite(resource)).then(() => { - dispatch(favoritePanelActions.REQUEST_ITEMS()); - }); - } - }, - { - icon: OpenIcon, - name: "Open in new tab", - execute: (dispatch, resource) => { - dispatch(openInNewTabAction(resource)); - } - }, - { - icon: ReRunProcessIcon, - name: "Copy and re-run process", - execute: (dispatch, resource) => { - dispatch(openCopyProcessDialog(resource)); - } - }, - { - icon: OutputIcon, - name: "Outputs", - execute: (dispatch, resource) => { - if(resource.outputUuid){ - dispatch(navigateToOutput(resource.outputUuid)); - } - } - }, - { - icon: DetailsIcon, - name: "View details", - execute: dispatch => { - dispatch(toggleDetailsPanel()); - } - }, - { - icon: AdvancedIcon, - name: "API Details", - execute: (dispatch, resource) => { - dispatch(openAdvancedTabDialog(resource.uuid)); - } - }, -]]; +export const readOnlyProcessResourceActionSet: ContextMenuActionSet = [ + [ + { + component: ToggleFavoriteAction, + execute: (dispatch, resources) => { + dispatch(toggleFavorite(resources[0])).then(() => { + dispatch(favoritePanelActions.REQUEST_ITEMS()); + }); + }, + }, + { + icon: OpenIcon, + name: "Open in new tab", + execute: (dispatch, resources) => { + dispatch(openInNewTabAction(resources[0])); + }, + }, + { + icon: ReRunProcessIcon, + name: "Copy and re-run process", + execute: (dispatch, resources) => { + dispatch(openCopyProcessDialog(resources[0])); + }, + }, + { + icon: OutputIcon, + name: "Outputs", + execute: (dispatch, resources) => { + if (resources[0]) { + dispatch(navigateToOutput(resources[0])); + } + }, + }, + { + icon: DetailsIcon, + name: "View details", + execute: dispatch => { + dispatch(toggleDetailsPanel()); + }, + }, + { + icon: AdvancedIcon, + name: "API Details", + execute: (dispatch, resources) => { + dispatch(openAdvancedTabDialog(resources[0].uuid)); + }, + }, + ], +]; -export const processResourceActionSet: ContextMenuActionSet = [[ - ...readOnlyProcessResourceActionSet.reduce((prev, next) => prev.concat(next), []), - { - icon: RenameIcon, - name: "Edit process", - execute: (dispatch, resource) => { - dispatch(openProcessUpdateDialog(resource)); - } - }, - { - icon: ShareIcon, - name: "Share", - execute: (dispatch, { uuid }) => { - dispatch(openSharingDialog(uuid)); - } - }, - { - icon: MoveToIcon, - name: "Move to", - execute: (dispatch, resource) => { - dispatch(openMoveProcessDialog(resource)); - } - }, - { - name: "Remove", - icon: RemoveIcon, - execute: (dispatch, resource) => { - dispatch(openRemoveProcessDialog(resource.uuid)); - } - } -]]; +export const processResourceActionSet: ContextMenuActionSet = [ + [ + ...readOnlyProcessResourceActionSet.reduce((prev, next) => prev.concat(next), []), + { + icon: RenameIcon, + name: "Edit process", + execute: (dispatch, resources) => { + dispatch(openProcessUpdateDialog(resources[0])); + }, + }, + { + icon: ShareIcon, + name: "Share", + execute: (dispatch, resources) => { + dispatch(openSharingDialog(resources[0].uuid)); + }, + }, + { + icon: MoveToIcon, + name: "Move to", + execute: (dispatch, resources) => { + dispatch(openMoveProcessDialog(resources[0])); + }, + }, + { + name: "Remove", + icon: RemoveIcon, + execute: (dispatch, resources) => { + dispatch(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(togglePublicFavorite(resource)).then(() => { - dispatch(publicFavoritePanelActions.REQUEST_ITEMS()); - }); - } - }, -]]; +const runningProcessOnlyActionSet: ContextMenuActionSet = [ + [ + { + name: "CANCEL", + icon: StopIcon, + execute: (dispatch, resources) => { + dispatch(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(togglePublicFavorite(resources[0])).then(() => { + dispatch(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), []), + ], +]; diff --git a/src/views-components/context-menu/action-sets/project-action-set.ts b/src/views-components/context-menu/action-sets/project-action-set.ts index 8181045c..27063151 100644 --- a/src/views-components/context-menu/action-sets/project-action-set.ts +++ b/src/views-components/context-menu/action-sets/project-action-set.ts @@ -3,19 +3,19 @@ // SPDX-License-Identifier: AGPL-3.0 import { ContextMenuActionSet } from "../context-menu-action-set"; -import { NewProjectIcon, RenameIcon, MoveToIcon, DetailsIcon, AdvancedIcon, OpenIcon, Link, FolderSharedIcon } from 'components/icon/icon'; +import { NewProjectIcon, RenameIcon, MoveToIcon, DetailsIcon, AdvancedIcon, OpenIcon, Link, FolderSharedIcon } from "components/icon/icon"; import { ToggleFavoriteAction } from "../actions/favorite-action"; import { toggleFavorite } from "store/favorites/favorites-actions"; import { favoritePanelActions } from "store/favorite-panel/favorite-panel-action"; -import { openMoveProjectDialog } from 'store/projects/project-move-actions'; -import { openProjectCreateDialog } from 'store/projects/project-create-actions'; -import { openProjectUpdateDialog } from 'store/projects/project-update-actions'; +import { openMoveProjectDialog } from "store/projects/project-move-actions"; +import { openProjectCreateDialog } from "store/projects/project-create-actions"; +import { openProjectUpdateDialog } from "store/projects/project-update-actions"; import { ToggleTrashAction } from "views-components/context-menu/actions/trash-action"; import { toggleProjectTrashed } from "store/trash/trash-actions"; -import { ShareIcon } from 'components/icon/icon'; +import { ShareIcon } from "components/icon/icon"; import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions"; import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab"; -import { toggleDetailsPanel } from 'store/details-panel/details-panel-action'; +import { toggleDetailsPanel } from "store/details-panel/details-panel-action"; import { copyToClipboardAction, openInNewTabAction } from "store/open-in-new-tab/open-in-new-tab.actions"; import { openWebDavS3InfoDialog } from "store/collections/collection-info-actions"; import { ToggleLockAction } from "../actions/lock-action"; @@ -23,28 +23,28 @@ import { freezeProject, unfreezeProject } from "store/projects/project-lock-acti export const toggleFavoriteAction = { component: ToggleFavoriteAction, - name: 'ToggleFavoriteAction', - execute: (dispatch, resource) => { - dispatch(toggleFavorite(resource)).then(() => { + name: "ToggleFavoriteAction", + execute: (dispatch, resources) => { + dispatch(toggleFavorite(resources[0])).then(() => { dispatch(favoritePanelActions.REQUEST_ITEMS()); }); - } + }, }; export const openInNewTabMenuAction = { icon: OpenIcon, name: "Open in new tab", - execute: (dispatch, resource) => { - dispatch(openInNewTabAction(resource)); - } + execute: (dispatch, resources) => { + dispatch(openInNewTabAction(resources[0])); + }, }; export const copyToClipboardMenuAction = { icon: Link, name: "Copy to clipboard", - execute: (dispatch, resource) => { - dispatch(copyToClipboardAction(resource)); - } + execute: (dispatch, resources) => { + dispatch(copyToClipboardAction(resources)); + }, }; export const viewDetailsAction = { @@ -52,121 +52,122 @@ export const viewDetailsAction = { name: "View details", execute: dispatch => { dispatch(toggleDetailsPanel()); - } -} + }, +}; export const advancedAction = { icon: AdvancedIcon, name: "API Details", - execute: (dispatch, resource) => { - dispatch(openAdvancedTabDialog(resource.uuid)); - } -} + execute: (dispatch, resources) => { + dispatch(openAdvancedTabDialog(resources[0].uuid)); + }, +}; export const openWith3rdPartyClientAction = { icon: FolderSharedIcon, name: "Open with 3rd party client", - execute: (dispatch, resource) => { - dispatch(openWebDavS3InfoDialog(resource.uuid)); - } -} + execute: (dispatch, resources) => { + dispatch(openWebDavS3InfoDialog(resources[0].uuid)); + }, +}; export const editProjectAction = { icon: RenameIcon, name: "Edit project", - execute: (dispatch, resource) => { - dispatch(openProjectUpdateDialog(resource)); - } -} + execute: (dispatch, resources) => { + dispatch(openProjectUpdateDialog(resources[0])); + }, +}; export const shareAction = { icon: ShareIcon, name: "Share", - execute: (dispatch, { uuid }) => { - dispatch(openSharingDialog(uuid)); - } -} + execute: (dispatch, resources) => { + dispatch(openSharingDialog(resources[0].uuid)); + }, +}; export const moveToAction = { icon: MoveToIcon, name: "Move to", execute: (dispatch, resource) => { - dispatch(openMoveProjectDialog(resource)); - } -} + dispatch(openMoveProjectDialog(resource[0])); + }, +}; export const toggleTrashAction = { component: ToggleTrashAction, - name: 'ToggleTrashAction', - execute: (dispatch, resource) => { - dispatch(toggleProjectTrashed(resource.uuid, resource.ownerUuid, resource.isTrashed!!)); - } -} + name: "ToggleTrashAction", + execute: (dispatch, resources) => { + dispatch(toggleProjectTrashed(resources[0].uuid, resources[0].ownerUuid, resources[0].isTrashed!!, resources.length > 1)); + }, +}; export const freezeProjectAction = { component: ToggleLockAction, - name: 'ToggleLockAction', - execute: (dispatch, resource) => { - if (resource.isFrozen) { - dispatch(unfreezeProject(resource.uuid)); + name: "ToggleLockAction", + execute: (dispatch, resources) => { + if (resources[0].isFrozen) { + dispatch(unfreezeProject(resources[0].uuid)); } else { - dispatch(freezeProject(resource.uuid)); + dispatch(freezeProject(resources[0].uuid)); } - } -} + }, +}; export const newProjectAction: any = { icon: NewProjectIcon, name: "New project", execute: (dispatch, resource): void => { dispatch(openProjectCreateDialog(resource.uuid)); - } -} - -export const readOnlyProjectActionSet: ContextMenuActionSet = [[ - toggleFavoriteAction, - openInNewTabMenuAction, - copyToClipboardMenuAction, - viewDetailsAction, - advancedAction, - openWith3rdPartyClientAction, -]]; - -export const filterGroupActionSet: ContextMenuActionSet = [[ - toggleFavoriteAction, - openInNewTabMenuAction, - copyToClipboardMenuAction, - viewDetailsAction, - advancedAction, - openWith3rdPartyClientAction, - editProjectAction, - shareAction, - moveToAction, - toggleTrashAction, -]]; - -export const frozenActionSet: ContextMenuActionSet = [[ - shareAction, - toggleFavoriteAction, - openInNewTabMenuAction, - copyToClipboardMenuAction, - viewDetailsAction, - advancedAction, - openWith3rdPartyClientAction, - freezeProjectAction -]]; - -export const projectActionSet: ContextMenuActionSet = [[ - toggleFavoriteAction, - openInNewTabMenuAction, - copyToClipboardMenuAction, - viewDetailsAction, - advancedAction, - openWith3rdPartyClientAction, - editProjectAction, - shareAction, - moveToAction, - toggleTrashAction, - newProjectAction, - freezeProjectAction, -]]; + }, +}; + +export const readOnlyProjectActionSet: ContextMenuActionSet = [ + [toggleFavoriteAction, openInNewTabMenuAction, copyToClipboardMenuAction, viewDetailsAction, advancedAction, openWith3rdPartyClientAction], +]; + +export const filterGroupActionSet: ContextMenuActionSet = [ + [ + toggleFavoriteAction, + openInNewTabMenuAction, + copyToClipboardMenuAction, + viewDetailsAction, + advancedAction, + openWith3rdPartyClientAction, + editProjectAction, + shareAction, + moveToAction, + toggleTrashAction, + ], +]; + +export const frozenActionSet: ContextMenuActionSet = [ + [ + shareAction, + toggleFavoriteAction, + openInNewTabMenuAction, + copyToClipboardMenuAction, + viewDetailsAction, + advancedAction, + openWith3rdPartyClientAction, + freezeProjectAction, + ], +]; + +export const projectActionSet: ContextMenuActionSet = [ + [ + toggleFavoriteAction, + openInNewTabMenuAction, + copyToClipboardMenuAction, + viewDetailsAction, + advancedAction, + openWith3rdPartyClientAction, + editProjectAction, + shareAction, + moveToAction, + toggleTrashAction, + newProjectAction, + freezeProjectAction, + ], +]; diff --git a/src/views-components/context-menu/action-sets/project-admin-action-set.ts b/src/views-components/context-menu/action-sets/project-admin-action-set.ts index 3faf675d..490bf3e3 100644 --- a/src/views-components/context-menu/action-sets/project-admin-action-set.ts +++ b/src/views-components/context-menu/action-sets/project-admin-action-set.ts @@ -7,56 +7,75 @@ import { TogglePublicFavoriteAction } from "views-components/context-menu/action import { togglePublicFavorite } from "store/public-favorites/public-favorites-actions"; import { publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action"; -import { shareAction, toggleFavoriteAction, openInNewTabMenuAction, copyToClipboardMenuAction, viewDetailsAction, advancedAction, openWith3rdPartyClientAction, freezeProjectAction, editProjectAction, moveToAction, toggleTrashAction, newProjectAction } from "views-components/context-menu/action-sets/project-action-set"; - -export const togglePublicFavoriteAction = { - component: TogglePublicFavoriteAction, - name: 'TogglePublicFavoriteAction', - execute: (dispatch, resource) => { - dispatch(togglePublicFavorite(resource)).then(() => { - dispatch(publicFavoritePanelActions.REQUEST_ITEMS()); - }); -}} - -export const projectAdminActionSet: ContextMenuActionSet = [[ +import { + shareAction, toggleFavoriteAction, openInNewTabMenuAction, copyToClipboardMenuAction, viewDetailsAction, advancedAction, openWith3rdPartyClientAction, + freezeProjectAction, editProjectAction, - shareAction, moveToAction, toggleTrashAction, newProjectAction, - freezeProjectAction, - togglePublicFavoriteAction -]]; +} from "views-components/context-menu/action-sets/project-action-set"; -export const filterGroupAdminActionSet: ContextMenuActionSet = [[ - toggleFavoriteAction, - openInNewTabMenuAction, - copyToClipboardMenuAction, - viewDetailsAction, - advancedAction, - openWith3rdPartyClientAction, - editProjectAction, - shareAction, - moveToAction, - toggleTrashAction, - togglePublicFavoriteAction -]]; +export const togglePublicFavoriteAction = { + component: TogglePublicFavoriteAction, + name: "TogglePublicFavoriteAction", + execute: (dispatch, resources) => { + dispatch(togglePublicFavorite(resources[0])).then(() => { + dispatch(publicFavoritePanelActions.REQUEST_ITEMS()); + }); + }, +}; +export const projectAdminActionSet: ContextMenuActionSet = [ + [ + toggleFavoriteAction, + openInNewTabMenuAction, + copyToClipboardMenuAction, + viewDetailsAction, + advancedAction, + openWith3rdPartyClientAction, + editProjectAction, + shareAction, + moveToAction, + toggleTrashAction, + newProjectAction, + freezeProjectAction, + togglePublicFavoriteAction, + ], +]; -export const frozenAdminActionSet: ContextMenuActionSet = [[ - shareAction, - togglePublicFavoriteAction, - toggleFavoriteAction, - openInNewTabMenuAction, - copyToClipboardMenuAction, - viewDetailsAction, - advancedAction, - openWith3rdPartyClientAction, - freezeProjectAction -]]; +export const filterGroupAdminActionSet: ContextMenuActionSet = [ + [ + toggleFavoriteAction, + openInNewTabMenuAction, + copyToClipboardMenuAction, + viewDetailsAction, + advancedAction, + openWith3rdPartyClientAction, + editProjectAction, + shareAction, + moveToAction, + toggleTrashAction, + togglePublicFavoriteAction, + ], +]; + +export const frozenAdminActionSet: ContextMenuActionSet = [ + [ + shareAction, + togglePublicFavoriteAction, + toggleFavoriteAction, + openInNewTabMenuAction, + copyToClipboardMenuAction, + viewDetailsAction, + advancedAction, + openWith3rdPartyClientAction, + freezeProjectAction, + ], +]; diff --git a/src/views-components/context-menu/action-sets/repository-action-set.ts b/src/views-components/context-menu/action-sets/repository-action-set.ts index 12fec7c4..cbdcd004 100644 --- a/src/views-components/context-menu/action-sets/repository-action-set.ts +++ b/src/views-components/context-menu/action-sets/repository-action-set.ts @@ -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(openRepositoryAttributes(uuid)); - } -}, { - name: "Share", - icon: ShareIcon, - execute: (dispatch, { uuid }) => { - dispatch(openSharingDialog(uuid)); - } -}, { - name: "API Details", - icon: AdvancedIcon, - execute: (dispatch, resource) => { - dispatch(openAdvancedTabDialog(resource.uuid)); - } -}, { - name: "Remove", - icon: RemoveIcon, - execute: (dispatch, { uuid }) => { - dispatch(openRemoveRepositoryDialog(uuid)); - } -}]]; +export const repositoryActionSet: ContextMenuActionSet = [ + [ + { + name: 'Attributes', + icon: AttributesIcon, + execute: (dispatch, resources) => { + dispatch(openRepositoryAttributes(resources[0].uuid)); + }, + }, + { + name: 'Share', + icon: ShareIcon, + execute: (dispatch, resources) => { + dispatch(openSharingDialog(resources[0].uuid)); + }, + }, + { + name: 'API Details', + icon: AdvancedIcon, + execute: (dispatch, resources) => { + dispatch(openAdvancedTabDialog(resources[0].uuid)); + }, + }, + { + name: 'Remove', + icon: RemoveIcon, + execute: (dispatch, resources) => { + dispatch(openRemoveRepositoryDialog(resources[0].uuid)); + }, + }, + ], +]; diff --git a/src/views-components/context-menu/action-sets/resource-action-set.ts b/src/views-components/context-menu/action-sets/resource-action-set.ts index ea8c53c5..401e9634 100644 --- a/src/views-components/context-menu/action-sets/resource-action-set.ts +++ b/src/views-components/context-menu/action-sets/resource-action-set.ts @@ -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(toggleFavorite(resource)); - } -}]]; +export const resourceActionSet: ContextMenuActionSet = [ + [ + { + component: ToggleFavoriteAction, + execute: (dispatch, resources) => { + resources.forEach((resource) => dispatch(toggleFavorite(resource))); + }, + }, + ], +]; diff --git a/src/views-components/context-menu/action-sets/root-project-action-set.ts b/src/views-components/context-menu/action-sets/root-project-action-set.ts index 9cf5bf03..a779d1eb 100644 --- a/src/views-components/context-menu/action-sets/root-project-action-set.ts +++ b/src/views-components/context-menu/action-sets/root-project-action-set.ts @@ -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(openProjectCreateDialog(resource.uuid)); - } - }, - { - icon: CollectionIcon, - name: "New Collection", - execute: (dispatch, resource) => { - dispatch(openCollectionCreateDialog(resource.uuid)); - } - } -]]; +export const rootProjectActionSet: ContextMenuActionSet = [ + [ + { + icon: NewProjectIcon, + name: 'New project', + execute: (dispatch, resources) => { + dispatch(openProjectCreateDialog(resources[0].uuid)); + }, + }, + { + icon: CollectionIcon, + name: 'New Collection', + execute: (dispatch, resources) => { + dispatch(openCollectionCreateDialog(resources[0].uuid)); + }, + }, + ], +]; diff --git a/src/views-components/context-menu/action-sets/search-results-action-set.ts b/src/views-components/context-menu/action-sets/search-results-action-set.ts index aeb6d155..dcc9eae2 100644 --- a/src/views-components/context-menu/action-sets/search-results-action-set.ts +++ b/src/views-components/context-menu/action-sets/search-results-action-set.ts @@ -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(openInNewTabAction(resource)); - } + name: 'Open in new tab', + execute: (dispatch, resources) => { + resources.forEach((resource) => dispatch(openInNewTabAction(resource))); + }, }, { icon: Link, - name: "Copy to clipboard", - execute: (dispatch, resource) => { - dispatch(copyToClipboardAction(resource)); - } + name: 'Copy to clipboard', + execute: (dispatch, resources) => { + dispatch(copyToClipboardAction(resources)); + }, }, { icon: DetailsIcon, - name: "View details", - execute: dispatch => { + name: 'View details', + execute: (dispatch) => { dispatch(toggleDetailsPanel()); - } + }, }, { icon: AdvancedIcon, - name: "API Details", - execute: (dispatch, resource) => { - dispatch(openAdvancedTabDialog(resource.uuid)); - } + name: 'API Details', + execute: (dispatch, resources) => { + dispatch(openAdvancedTabDialog(resources[0].uuid)); + }, }, - ] + ], ]; diff --git a/src/views-components/context-menu/action-sets/ssh-key-action-set.ts b/src/views-components/context-menu/action-sets/ssh-key-action-set.ts index d1a94cd3..c31e1681 100644 --- a/src/views-components/context-menu/action-sets/ssh-key-action-set.ts +++ b/src/views-components/context-menu/action-sets/ssh-key-action-set.ts @@ -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(openSshKeyAttributesDialog(uuid)); - } -}, { - name: "API Details", - icon: AdvancedIcon, - execute: (dispatch, { uuid }) => { - dispatch(openAdvancedTabDialog(uuid)); - } -}, { - name: "Remove", - icon: RemoveIcon, - execute: (dispatch, { uuid }) => { - dispatch(openSshKeyRemoveDialog(uuid)); - } -}]]; +export const sshKeyActionSet: ContextMenuActionSet = [ + [ + { + name: 'Attributes', + icon: AttributesIcon, + execute: (dispatch, resources) => { + dispatch(openSshKeyAttributesDialog(resources[0].uuid)); + }, + }, + { + name: 'API Details', + icon: AdvancedIcon, + execute: (dispatch, resources) => { + dispatch(openAdvancedTabDialog(resources[0].uuid)); + }, + }, + { + name: 'Remove', + icon: RemoveIcon, + execute: (dispatch, resources) => { + dispatch(openSshKeyRemoveDialog(resources[0].uuid)); + }, + }, + ], +]; diff --git a/src/views-components/context-menu/action-sets/trash-action-set.ts b/src/views-components/context-menu/action-sets/trash-action-set.ts index c0afd36a..82e00df6 100644 --- a/src/views-components/context-menu/action-sets/trash-action-set.ts +++ b/src/views-components/context-menu/action-sets/trash-action-set.ts @@ -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(toggleTrashed(resource.kind, resource.uuid, resource.ownerUuid, resource.isTrashed!!)); - } - }, -]]; +export const trashActionSet: ContextMenuActionSet = [ + [ + { + component: ToggleTrashAction, + execute: (dispatch, resources) => { + resources.forEach((resource) => dispatch(toggleTrashed(resource.kind, resource.uuid, resource.ownerUuid, resource.isTrashed!!))); + }, + }, + ], +]; diff --git a/src/views-components/context-menu/action-sets/trashed-collection-action-set.ts b/src/views-components/context-menu/action-sets/trashed-collection-action-set.ts index 020ff5c7..3e8f0cb6 100644 --- a/src/views-components/context-menu/action-sets/trashed-collection-action-set.ts +++ b/src/views-components/context-menu/action-sets/trashed-collection-action-set.ts @@ -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(toggleDetailsPanel()); - } - }, - { - icon: ProvenanceGraphIcon, - name: "Provenance graph", - execute: (dispatch, resource) => { - // add code - } - }, - { - icon: AdvancedIcon, - name: "API Details", - execute: (dispatch, resource) => { - dispatch(openAdvancedTabDialog(resource.uuid)); - } - }, - { - icon: RestoreFromTrashIcon, - name: "Restore", - execute: (dispatch, resource) => { - dispatch(toggleCollectionTrashed(resource.uuid, true)); - } - }, -]]; +export const trashedCollectionActionSet: ContextMenuActionSet = [ + [ + { + icon: DetailsIcon, + name: 'View details', + execute: (dispatch) => { + dispatch(toggleDetailsPanel()); + }, + }, + { + icon: ProvenanceGraphIcon, + name: 'Provenance graph', + execute: (dispatch, resource) => { + // add code + }, + }, + { + icon: AdvancedIcon, + name: 'API Details', + execute: (dispatch, resources) => { + dispatch(openAdvancedTabDialog(resources[0].uuid)); + }, + }, + { + icon: RestoreFromTrashIcon, + name: 'Restore', + execute: (dispatch, resources) => { + resources.forEach((resource) => dispatch(toggleCollectionTrashed(resource.uuid, true))); + }, + }, + ], +]; diff --git a/src/views-components/context-menu/action-sets/user-action-set.ts b/src/views-components/context-menu/action-sets/user-action-set.ts index c00b7f1f..0108ff7e 100644 --- a/src/views-components/context-menu/action-sets/user-action-set.ts +++ b/src/views-components/context-menu/action-sets/user-action-set.ts @@ -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(openUserAttributes(uuid)); - } -}, { - name: "Project", - icon: ProjectIcon, - execute: (dispatch, { uuid }) => { - dispatch(openUserProjects(uuid)); - } -}, { - name: "API Details", - icon: AdvancedIcon, - execute: (dispatch, { uuid }) => { - dispatch(openAdvancedTabDialog(uuid)); - } -}, { - name: "Account Settings", - icon: UserPanelIcon, - execute: (dispatch, { uuid }) => { - dispatch(navigateToUserProfile(uuid)); - }, - filters: [needsUserProfileLink] -}],[{ - name: "Activate User", - icon: ActiveIcon, - execute: (dispatch, { uuid }) => { - dispatch(openActivateDialog(uuid)); - }, - filters: [ - isAdmin, - canActivateUser, - ], -}, { - name: "Setup User", - icon: AdminMenuIcon, - execute: (dispatch, { uuid }) => { - dispatch(openSetupDialog(uuid)); - }, - filters: [ - isAdmin, - canSetupUser, - ], -}, { - name: "Deactivate User", - icon: DeactivateUserIcon, - execute: (dispatch, { uuid }) => { - dispatch(openDeactivateDialog(uuid)); - }, - filters: [ - isAdmin, - canDeactivateUser, +export const userActionSet: ContextMenuActionSet = [ + [ + { + name: 'Attributes', + icon: AttributesIcon, + execute: (dispatch, resources) => { + dispatch(openUserAttributes(resources[0].uuid)); + }, + }, + { + name: 'Project', + icon: ProjectIcon, + execute: (dispatch, resources) => { + dispatch(openUserProjects(resources[0].uuid)); + }, + }, + { + name: 'API Details', + icon: AdvancedIcon, + execute: (dispatch, resources) => { + dispatch(openAdvancedTabDialog(resources[0].uuid)); + }, + }, + { + name: 'Account Settings', + icon: UserPanelIcon, + execute: (dispatch, resources) => { + dispatch(navigateToUserProfile(resources[0].uuid)); + }, + filters: [needsUserProfileLink], + }, ], -}, { - name: "Login As User", - icon: LoginAsIcon, - execute: (dispatch, { uuid }) => { - dispatch(loginAs(uuid)); - }, - filters: [ - isAdmin, - isOtherUser, + [ + { + name: 'Activate User', + icon: ActiveIcon, + execute: (dispatch, resources) => { + dispatch(openActivateDialog(resources[0].uuid)); + }, + filters: [isAdmin, canActivateUser], + }, + { + name: 'Setup User', + icon: AdminMenuIcon, + execute: (dispatch, resources) => { + dispatch(openSetupDialog(resources[0].uuid)); + }, + filters: [isAdmin, canSetupUser], + }, + { + name: 'Deactivate User', + icon: DeactivateUserIcon, + execute: (dispatch, resources) => { + dispatch(openDeactivateDialog(resources[0].uuid)); + }, + filters: [isAdmin, canDeactivateUser], + }, + { + name: 'Login As User', + icon: LoginAsIcon, + execute: (dispatch, resources) => { + dispatch(loginAs(resources[0].uuid)); + }, + filters: [isAdmin, isOtherUser], + }, ], -}]]; +]; diff --git a/src/views-components/context-menu/action-sets/virtual-machine-action-set.ts b/src/views-components/context-menu/action-sets/virtual-machine-action-set.ts index be9567cd..a26cbe13 100644 --- a/src/views-components/context-menu/action-sets/virtual-machine-action-set.ts +++ b/src/views-components/context-menu/action-sets/virtual-machine-action-set.ts @@ -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(openVirtualMachineAttributes(uuid)); - } -}, { - name: "API Details", - icon: AdvancedIcon, - execute: (dispatch, { uuid }) => { - dispatch(openAdvancedTabDialog(uuid)); - } -}, { - name: "Remove", - icon: RemoveIcon, - execute: (dispatch, { uuid }) => { - dispatch(openRemoveVirtualMachineDialog(uuid)); - } -}]]; +export const virtualMachineActionSet: ContextMenuActionSet = [ + [ + { + name: 'Attributes', + icon: AttributesIcon, + execute: (dispatch, resources) => { + dispatch(openVirtualMachineAttributes(resources[0].uuid)); + }, + }, + { + name: 'API Details', + icon: AdvancedIcon, + execute: (dispatch, resources) => { + dispatch(openAdvancedTabDialog(resources[0].uuid)); + }, + }, + { + name: 'Remove', + icon: RemoveIcon, + execute: (dispatch, resources) => { + dispatch(openRemoveVirtualMachineDialog(resources[0].uuid)); + }, + }, + ], +]; diff --git a/src/views-components/context-menu/action-sets/workflow-action-set.ts b/src/views-components/context-menu/action-sets/workflow-action-set.ts index 2aa78904..4a1460bf 100644 --- a/src/views-components/context-menu/action-sets/workflow-action-set.ts +++ b/src/views-components/context-menu/action-sets/workflow-action-set.ts @@ -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(openRunProcess(resource.uuid, resource.ownerUuid, resource.name)); - } - }, -]]; +export const readOnlyWorkflowActionSet: ContextMenuActionSet = [ + [ + { + icon: OpenIcon, + name: "Open in new tab", + execute: (dispatch, resources) => { + dispatch(openInNewTabAction(resources[0])); + }, + }, + { + icon: Link, + name: "Copy to clipboard", + execute: (dispatch, resources) => { + dispatch(copyToClipboardAction(resources)); + }, + }, + { + icon: DetailsIcon, + name: "View details", + execute: dispatch => { + dispatch(toggleDetailsPanel()); + }, + }, + { + icon: AdvancedIcon, + name: "API Details", + execute: (dispatch, resources) => { + dispatch(openAdvancedTabDialog(resources[0].uuid)); + }, + }, + { + icon: StartIcon, + name: "Run Workflow", + execute: (dispatch, resources) => { + dispatch(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(deleteWorkflow(resources[0].uuid, resources[0].ownerUuid)); + }, + }, + ], +]; diff --git a/src/views-components/context-menu/context-menu-action-set.ts b/src/views-components/context-menu/context-menu-action-set.ts index abef7ec0..a953500b 100644 --- a/src/views-components/context-menu/context-menu-action-set.ts +++ b/src/views-components/context-menu/context-menu-action-set.ts @@ -5,10 +5,9 @@ import { Dispatch } from "redux"; import { ContextMenuItem } from "components/context-menu/context-menu"; import { ContextMenuResource } from "store/context-menu/context-menu-actions"; -import { RootState } from "store/store"; export interface ContextMenuAction extends ContextMenuItem { - execute(dispatch: Dispatch, resource: ContextMenuResource, state?: any): void; + execute(dispatch: Dispatch, resources: ContextMenuResource[], state?: any): void; } export type ContextMenuActionSet = Array>; diff --git a/src/views-components/context-menu/context-menu.tsx b/src/views-components/context-menu/context-menu.tsx index c659b7c5..aeb69de7 100644 --- a/src/views-components/context-menu/context-menu.tsx +++ b/src/views-components/context-menu/context-menu.tsx @@ -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 & { resource?: ContextMenuResource }; + const mapStateToProps = (state: RootState): DataProps => { const { open, position, resource } = state.contextMenu; - - const filteredItems = getMenuActionSet(resource).map((group) => (group.filter((item) => { - if (resource && item.filters) { - // Execute all filters on this item, every returns true IFF all filters return true - return item.filters.every((filter) => filter(state, resource)); - } else { - return true; - } - }))); + const filteredItems = getMenuActionSet(resource).map(group => + group.filter(item => { + if (resource && item.filters) { + // Execute all filters on this item, every returns true IFF all filters return true + return item.filters.every(filter => filter(state, resource)); + } else { + return true; + } + }) + ); return { anchorEl: resource ? createAnchorAt(position) : undefined, items: filteredItems, open, - resource + resource, }; }; @@ -40,68 +43,70 @@ const mapDispatchToProps = (dispatch: Dispatch): ActionProps => ({ onItemClick: (action: ContextMenuAction, resource?: ContextMenuResource) => { dispatch(contextMenuActions.CLOSE_CONTEXT_MENU()); if (resource) { - action.execute(dispatch, resource); + action.execute(dispatch, [resource]); } - } + }, }); const handleItemClick = memoize( - (resource: DataProps['resource'], onItemClick: ActionProps['onItemClick']): ContextMenuProps['onItemClick'] => + (resource: DataProps["resource"], onItemClick: ActionProps["onItemClick"]): ContextMenuProps["onItemClick"] => item => { - onItemClick(item, resource); + onItemClick(item, { ...resource, fromContextMenu: true } as ContextMenuResource); } ); const mergeProps = ({ resource, ...dataProps }: DataProps, actionProps: ActionProps): ContextMenuProps => ({ ...dataProps, ...actionProps, - onItemClick: handleItemClick(resource, actionProps.onItemClick) + onItemClick: handleItemClick(resource, actionProps.onItemClick), }); - export const ContextMenu = connect(mapStateToProps, mapDispatchToProps, mergeProps)(ContextMenuComponent); const menuActionSets = new Map(); export const addMenuActionSet = (name: string, itemSet: ContextMenuActionSet) => { - const sorted = itemSet.map(items => items.sort(sortByProperty('name'))); + const sorted = itemSet.map(items => items.sort(sortByProperty("name"))); menuActionSets.set(name, sorted); }; const emptyActionSet: ContextMenuActionSet = []; -const getMenuActionSet = (resource?: ContextMenuResource): ContextMenuActionSet => ( - resource ? menuActionSets.get(resource.menuKind) || emptyActionSet : emptyActionSet -); +const getMenuActionSet = (resource?: ContextMenuResource): ContextMenuActionSet => + resource ? menuActionSets.get(resource.menuKind) || emptyActionSet : emptyActionSet; export enum ContextMenuKind { API_CLIENT_AUTHORIZATION = "ApiClientAuthorization", ROOT_PROJECT = "RootProject", PROJECT = "Project", FILTER_GROUP = "FilterGroup", - READONLY_PROJECT = 'ReadOnlyProject', - FROZEN_PROJECT = 'FrozenProject', - FROZEN_PROJECT_ADMIN = 'FrozenProjectAdmin', + READONLY_PROJECT = "ReadOnlyProject", + FROZEN_PROJECT = "FrozenProject", + FROZEN_PROJECT_ADMIN = "FrozenProjectAdmin", PROJECT_ADMIN = "ProjectAdmin", FILTER_GROUP_ADMIN = "FilterGroupAdmin", RESOURCE = "Resource", FAVORITE = "Favorite", TRASH = "Trash", COLLECTION_FILES = "CollectionFiles", + COLLECTION_FILES_MULTIPLE = "CollectionFilesMultiple", READONLY_COLLECTION_FILES = "ReadOnlyCollectionFiles", + READONLY_COLLECTION_FILES_MULTIPLE = "ReadOnlyCollectionFilesMultiple", + COLLECTION_FILES_NOT_SELECTED = "CollectionFilesNotSelected", COLLECTION_FILE_ITEM = "CollectionFileItem", COLLECTION_DIRECTORY_ITEM = "CollectionDirectoryItem", READONLY_COLLECTION_FILE_ITEM = "ReadOnlyCollectionFileItem", READONLY_COLLECTION_DIRECTORY_ITEM = "ReadOnlyCollectionDirectoryItem", - COLLECTION_FILES_NOT_SELECTED = "CollectionFilesNotSelected", - COLLECTION = 'Collection', - COLLECTION_ADMIN = 'CollectionAdmin', - READONLY_COLLECTION = 'ReadOnlyCollection', - OLD_VERSION_COLLECTION = 'OldVersionCollection', - TRASHED_COLLECTION = 'TrashedCollection', + COLLECTION = "Collection", + COLLECTION_ADMIN = "CollectionAdmin", + READONLY_COLLECTION = "ReadOnlyCollection", + OLD_VERSION_COLLECTION = "OldVersionCollection", + TRASHED_COLLECTION = "TrashedCollection", PROCESS = "Process", - PROCESS_ADMIN = 'ProcessAdmin', - PROCESS_RESOURCE = 'ProcessResource', - READONLY_PROCESS_RESOURCE = 'ReadOnlyProcessResource', + RUNNING_PROCESS_ADMIN = "RunningProcessAdmin", + PROCESS_ADMIN = "ProcessAdmin", + RUNNING_PROCESS_RESOURCE = "RunningProcessResource", + PROCESS_RESOURCE = "ProcessResource", + READONLY_PROCESS_RESOURCE = "ReadOnlyProcessResource", PROCESS_LOGS = "ProcessLogs", REPOSITORY = "Repository", SSH_KEY = "SshKey", @@ -113,5 +118,6 @@ export enum ContextMenuKind { PERMISSION_EDIT = "PermissionEdit", LINK = "Link", WORKFLOW = "Workflow", - SEARCH_RESULTS = "SearchResults" + READONLY_WORKFLOW = "ReadOnlyWorkflow", + SEARCH_RESULTS = "SearchResults", } diff --git a/src/views-components/data-explorer/data-explorer.tsx b/src/views-components/data-explorer/data-explorer.tsx index 59c389ac..2e316f68 100644 --- a/src/views-components/data-explorer/data-explorer.tsx +++ b/src/views-components/data-explorer/data-explorer.tsx @@ -9,9 +9,10 @@ import { getDataExplorer } from "store/data-explorer/data-explorer-reducer"; import { Dispatch } from "redux"; import { dataExplorerActions } from "store/data-explorer/data-explorer-action"; import { DataColumn } from "components/data-table/data-column"; -import { DataColumns } from "components/data-table/data-table"; -import { DataTableFilters } from 'components/data-table-filters/data-table-filters-tree'; +import { DataColumns, TCheckedList } from "components/data-table/data-table"; +import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree"; import { LAST_REFRESH_TIMESTAMP } from "components/refresh-button/refresh-button"; +import { toggleMSToolbar, setCheckedListOnStore } from "store/multiselect/multiselect-actions"; interface Props { id: string; @@ -21,12 +22,14 @@ interface Props { extractKey?: (item: any) => React.Key; } -const mapStateToProps = (state: RootState, { id }: Props) => { - const progress = state.progressIndicator.find(p => p.id === id); - const dataExplorerState = getDataExplorer(state.dataExplorer, id); - const currentRoute = state.router.location ? state.router.location.pathname : ''; - const currentRefresh = localStorage.getItem(LAST_REFRESH_TIMESTAMP) || ''; - const currentItemUuid = currentRoute === '/workflows' ? state.properties.workflowPanelDetailsUuid : state.detailsPanel.resourceUuid; +const mapStateToProps = ({ progressIndicator, dataExplorer, router, multiselect, detailsPanel, properties}: RootState, { id }: Props) => { + const progress = progressIndicator.find(p => p.id === id); + const dataExplorerState = getDataExplorer(dataExplorer, id); + const currentRoute = router.location ? router.location.pathname : ""; + const currentRefresh = localStorage.getItem(LAST_REFRESH_TIMESTAMP) || ""; + const isDetailsResourceChecked = multiselect.checkedList[detailsPanel.resourceUuid] + const currentItemUuid = currentRoute === "/workflows" ? properties.workflowPanelDetailsUuid : isDetailsResourceChecked ? detailsPanel.resourceUuid : multiselect.selectedUuid; + const isMSToolbarVisible = multiselect.isVisible; return { ...dataExplorerState, working: !!progress?.working, @@ -34,6 +37,8 @@ const mapStateToProps = (state: RootState, { id }: Props) => { currentRoute: currentRoute, paperKey: currentRoute, currentItemUuid, + isMSToolbarVisible, + checkedList: multiselect.checkedList, }; }; @@ -71,6 +76,14 @@ const mapDispatchToProps = () => { dispatch(dataExplorerActions.SET_PAGE({ id, page })); }, + toggleMSToolbar: (isVisible: boolean) => { + dispatch(toggleMSToolbar(isVisible)); + }, + + setCheckedListOnStore: (checkedList: TCheckedList) => { + dispatch(setCheckedListOnStore(checkedList)); + }, + onRowClick, onRowDoubleClick, diff --git a/src/views-components/data-explorer/renderers.tsx b/src/views-components/data-explorer/renderers.tsx index d274157c..059aad43 100644 --- a/src/views-components/data-explorer/renderers.tsx +++ b/src/views-components/data-explorer/renderers.tsx @@ -2,18 +2,10 @@ // // SPDX-License-Identifier: AGPL-3.0 -import React from 'react'; -import { - Grid, - Typography, - withStyles, - Tooltip, - IconButton, - Checkbox, - Chip -} from '@material-ui/core'; -import { FavoriteStar, PublicFavoriteStar } from '../favorite-star/favorite-star'; -import { Resource, ResourceKind, TrashableResource } from 'models/resource'; +import React from "react"; +import { Grid, Typography, withStyles, Tooltip, IconButton, Checkbox, Chip } from "@material-ui/core"; +import { FavoriteStar, PublicFavoriteStar } from "../favorite-star/favorite-star"; +import { Resource, ResourceKind, TrashableResource } from "models/resource"; import { FreezeIcon, ProjectIcon, @@ -29,93 +21,101 @@ import { ActiveIcon, SetupIcon, InactiveIcon, -} from 'components/icon/icon'; -import { formatDate, formatFileSize, formatTime } from 'common/formatters'; -import { resourceLabel } from 'common/labels'; -import { connect, DispatchProp } from 'react-redux'; -import { RootState } from 'store/store'; -import { getResource, filterResources } from 'store/resources/resources'; -import { GroupContentsResource } from 'services/groups-service/groups-service'; -import { getProcess, Process, getProcessStatus, getProcessStatusStyles, getProcessRuntime } from 'store/processes/process'; -import { ArvadosTheme } from 'common/custom-theme'; -import { compose, Dispatch } from 'redux'; -import { WorkflowResource } from 'models/workflow'; -import { ResourceStatus as WorkflowStatus } from 'views/workflow-panel/workflow-panel-view'; -import { getUuidPrefix, openRunProcess } from 'store/workflow-panel/workflow-panel-actions'; -import { openSharingDialog } from 'store/sharing-dialog/sharing-dialog-actions'; -import { getUserFullname, getUserDisplayName, User, UserResource } from 'models/user'; -import { toggleIsAdmin } from 'store/users/users-actions'; -import { LinkClass, LinkResource } from 'models/link'; -import { navigateTo, navigateToGroupDetails, navigateToUserProfile } from 'store/navigation/navigation-action'; -import { withResourceData } from 'views-components/data-explorer/with-resources'; -import { CollectionResource } from 'models/collection'; -import { IllegalNamingWarning } from 'components/warning/warning'; -import { loadResource } from 'store/resources/resources-actions'; -import { BuiltinGroups, getBuiltinGroupUuid, GroupClass, GroupResource, isBuiltinGroup } from 'models/group'; -import { openRemoveGroupMemberDialog } from 'store/group-details-panel/group-details-panel-actions'; -import { setMemberIsHidden } from 'store/group-details-panel/group-details-panel-actions'; -import { formatPermissionLevel } from 'views-components/sharing-dialog/permission-select'; -import { PermissionLevel } from 'models/permission'; -import { openPermissionEditContextMenu } from 'store/context-menu/context-menu-actions'; -import { getUserUuid } from 'common/getuser'; -import { VirtualMachinesResource } from 'models/virtual-machines'; -import { CopyToClipboardSnackbar } from 'components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar'; -import { ProjectResource } from 'models/project'; -import { ProcessResource } from 'models/process'; - +} from "components/icon/icon"; +import { formatDate, formatFileSize, formatTime } from "common/formatters"; +import { resourceLabel } from "common/labels"; +import { connect, DispatchProp } from "react-redux"; +import { RootState } from "store/store"; +import { getResource, filterResources } from "store/resources/resources"; +import { GroupContentsResource } from "services/groups-service/groups-service"; +import { getProcess, Process, getProcessStatus, getProcessStatusStyles, getProcessRuntime } from "store/processes/process"; +import { ArvadosTheme } from "common/custom-theme"; +import { compose, Dispatch } from "redux"; +import { WorkflowResource } from "models/workflow"; +import { ResourceStatus as WorkflowStatus } from "views/workflow-panel/workflow-panel-view"; +import { getUuidPrefix, openRunProcess } from "store/workflow-panel/workflow-panel-actions"; +import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions"; +import { getUserFullname, getUserDisplayName, User, UserResource } from "models/user"; +import { toggleIsAdmin } from "store/users/users-actions"; +import { LinkClass, LinkResource } from "models/link"; +import { navigateTo, navigateToGroupDetails, navigateToUserProfile } from "store/navigation/navigation-action"; +import { withResourceData } from "views-components/data-explorer/with-resources"; +import { CollectionResource } from "models/collection"; +import { IllegalNamingWarning } from "components/warning/warning"; +import { loadResource } from "store/resources/resources-actions"; +import { BuiltinGroups, getBuiltinGroupUuid, GroupClass, GroupResource, isBuiltinGroup } from "models/group"; +import { openRemoveGroupMemberDialog } from "store/group-details-panel/group-details-panel-actions"; +import { setMemberIsHidden } from "store/group-details-panel/group-details-panel-actions"; +import { formatPermissionLevel } from "views-components/sharing-dialog/permission-select"; +import { PermissionLevel } from "models/permission"; +import { openPermissionEditContextMenu } from "store/context-menu/context-menu-actions"; +import { VirtualMachinesResource } from "models/virtual-machines"; +import { CopyToClipboardSnackbar } from "components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar"; +import { ProjectResource } from "models/project"; +import { ProcessResource } from "models/process"; const renderName = (dispatch: Dispatch, item: GroupContentsResource) => { - const navFunc = ("groupClass" in item && item.groupClass === GroupClass.ROLE ? navigateToGroupDetails : navigateTo); - return - - {renderIcon(item)} - - - dispatch(navFunc(item.uuid))}> - {item.kind === ResourceKind.PROJECT || item.kind === ResourceKind.COLLECTION - ? - : null} - {item.name} - - - - - - - { - item.kind === ResourceKind.PROJECT && - } - + const navFunc = "groupClass" in item && item.groupClass === GroupClass.ROLE ? navigateToGroupDetails : navigateTo; + return ( + + {renderIcon(item)} + + { + ev.stopPropagation() + dispatch(navFunc(item.uuid)) + }} + > + {item.kind === ResourceKind.PROJECT || item.kind === ResourceKind.COLLECTION ? : null} + {item.name} + + + + + + + {item.kind === ResourceKind.PROJECT && } + + - ; + ); }; - -const FrozenProject = (props: {item: ProjectResource}) => { +const FrozenProject = (props: { item: ProjectResource }) => { const [fullUsername, setFullusername] = React.useState(null); const getFullName = React.useCallback(() => { if (props.item.frozenByUuid) { setFullusername(); } - }, [props.item, setFullusername]) + }, [props.item, setFullusername]); if (props.item.frozenByUuid) { - - return Project was frozen by {fullUsername}}> - - ; + return ( + Project was frozen by {fullUsername}} + > + + + ); } else { return null; } -} +}; -export const ResourceName = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return resource; - })((resource: GroupContentsResource & DispatchProp) => renderName(resource.dispatch, resource)); +export const ResourceName = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + return resource; +})((resource: GroupContentsResource & DispatchProp) => renderName(resource.dispatch, resource)); - const renderIcon = (item: GroupContentsResource) => { switch (item.kind) { case ResourceKind.PROJECT: @@ -138,26 +138,39 @@ const renderIcon = (item: GroupContentsResource) => { }; const renderDate = (date?: string) => { - return {formatDate(date)}; + return ( + + {formatDate(date)} + + ); }; -const renderWorkflowName = (item: WorkflowResource) => - +const renderWorkflowName = (item: WorkflowResource) => ( + + {renderIcon(item)} - {renderIcon(item)} - - - + {item.name} - ; + +); -export const ResourceWorkflowName = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return resource; - })(renderWorkflowName); +export const ResourceWorkflowName = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + return resource; +})(renderWorkflowName); const getPublicUuid = (uuidPrefix: string) => { return `${uuidPrefix}-tpzed-anonymouspublic`; @@ -167,489 +180,517 @@ const resourceShare = (dispatch: Dispatch, uuidPrefix: string, ownerUuid?: strin const isPublic = ownerUuid === getPublicUuid(uuidPrefix); return (
- {!isPublic && uuid && + {!isPublic && uuid && ( dispatch(openSharingDialog(uuid))}> - } + )}
); }; -export const ResourceShare = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(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) => - resourceShare(props.dispatch, props.uuidPrefix, props.ownerUuid, props.uuid)); +export const ResourceShare = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(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) => + resourceShare(props.dispatch, props.uuidPrefix, props.ownerUuid, props.uuid) +); // User Resources const renderFirstName = (item: { firstName: string }) => { return {item.firstName}; }; -export const ResourceFirstName = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return resource || { firstName: '' }; - })(renderFirstName); +export const ResourceFirstName = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + return resource || { firstName: "" }; +})(renderFirstName); -const renderLastName = (item: { lastName: string }) => - {item.lastName}; +const renderLastName = (item: { lastName: string }) => {item.lastName}; -export const ResourceLastName = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return resource || { lastName: '' }; - })(renderLastName); +export const ResourceLastName = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(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 ? dispatch(navigateToUserProfile(item.uuid))}> - {displayName} - : - {displayName}; -} + return link ? ( + dispatch(navigateToUserProfile(item.uuid))} + > + {displayName} + + ) : ( + {displayName} + ); +}; -export const UserResourceFullName = connect( - (state: RootState, props: { uuid: string, link?: boolean }) => { - const resource = getResource(props.uuid)(state.resources); - return { item: resource || { uuid: '', firstName: '', lastName: '' }, link: props.link }; - })((props: { item: { uuid: string, firstName: string, lastName: string }, link?: boolean } & DispatchProp) => renderFullName(props.dispatch, props.item, props.link)); +export const UserResourceFullName = connect((state: RootState, props: { uuid: string; link?: boolean }) => { + const resource = getResource(props.uuid)(state.resources); + return { item: resource || { uuid: "", firstName: "", lastName: "" }, link: props.link }; +})((props: { item: { uuid: string; firstName: string; lastName: string }; link?: boolean } & DispatchProp) => + renderFullName(props.dispatch, props.item, props.link) +); -const renderUuid = (item: { uuid: string }) => - +const renderUuid = (item: { uuid: string }) => ( + {item.uuid} - {(item.uuid && ) || '-' } - ; + {(item.uuid && ) || "-"} + +); -const renderUuidCopyIcon = (item: { uuid: string }) => - - {(item.uuid && ) || '-' } - ; +const renderUuidCopyIcon = (item: { uuid: string }) => ( + + {(item.uuid && ) || "-"} + +); -export const ResourceUuid = connect((state: RootState, props: { uuid: string }) => ( - getResource(props.uuid)(state.resources) || { uuid: '' } -))(renderUuid); +export const ResourceUuid = connect( + (state: RootState, props: { uuid: string }) => getResource(props.uuid)(state.resources) || { uuid: "" } +)(renderUuid); -const renderEmail = (item: { email: string }) => - {item.email}; +const renderEmail = (item: { email: string }) => {item.email}; -export const ResourceEmail = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return resource || { email: '' }; - })(renderEmail); +export const ResourceEmail = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(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 }) => - +const renderAccountStatus = (props: { status: UserAccountStatus }) => ( + {(() => { switch (props.status) { case UserAccountStatus.ACTIVE: - return ; + return ; case UserAccountStatus.SETUP: - return ; + return ; case UserAccountStatus.INACTIVE: - return ; + return ; default: return <>; } })()} - - {props.status} - + {props.status} - ; + +); const getUserAccountStatus = (state: RootState, props: { uuid: string }) => { const user = getResource(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(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(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 { - e.stopPropagation(); - props.setMemberIsHidden(props.memberLinkUuid, props.permissionLinkUuid, !props.visible); - }} />; + return ( + { + e.stopPropagation(); + props.setMemberIsHidden(props.memberLinkUuid, props.permissionLinkUuid, !props.visible); + }} + /> + ); } else { return ; } -} +}; export const ResourceLinkTailIsVisible = connect( (state: RootState, props: { uuid: string }) => { const link = getResource(props.uuid)(state.resources); - const member = getResource(link?.tailUuid || '')(state.resources); - const group = getResource(link?.headUuid || '')(state.resources); + const member = getResource(link?.tailUuid || "")(state.resources); + const group = getResource(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 }) => ( { + onClick={e => { e.stopPropagation(); props.toggleIsAdmin(props.uuid); - }} />; + }} + /> +); export const ResourceIsAdmin = connect( (state: RootState, props: { uuid: string }) => { const resource = getResource(props.uuid)(state.resources); return resource || { isAdmin: false }; - }, { toggleIsAdmin } + }, + { toggleIsAdmin } )(renderIsAdmin); -const renderUsername = (item: { username: string, uuid: string }) => - {item.username || item.uuid}; +const renderUsername = (item: { username: string; uuid: string }) => {item.username || item.uuid}; -export const ResourceUsername = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return resource || { username: '', uuid: props.uuid }; - })(renderUsername); +export const ResourceUsername = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + return resource || { username: "", uuid: props.uuid }; +})(renderUsername); // Virtual machine resource -const renderHostname = (item: { hostname: string }) => - {item.hostname}; +const renderHostname = (item: { hostname: string }) => {item.hostname}; -export const VirtualMachineHostname = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return resource || { hostname: '' }; - })(renderHostname); +export const VirtualMachineHostname = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + return resource || { hostname: "" }; +})(renderHostname); -const renderVirtualMachineLogin = (login: { user: string }) => - {login.user} +const renderVirtualMachineLogin = (login: { user: string }) => {login.user}; -export const VirtualMachineLogin = connect( - (state: RootState, props: { linkUuid: string }) => { - const permission = getResource(props.linkUuid)(state.resources); - const user = getResource(permission?.tailUuid || '')(state.resources); +export const VirtualMachineLogin = connect((state: RootState, props: { linkUuid: string }) => { + const permission = getResource(props.linkUuid)(state.resources); + const user = getResource(permission?.tailUuid || "")(state.resources); - return { user: user?.username || permission?.tailUuid || '' }; - })(renderVirtualMachineLogin); + return { user: user?.username || permission?.tailUuid || "" }; +})(renderVirtualMachineLogin); // Common methods -const renderCommonData = (data: string) => - {data}; +const renderCommonData = (data: string) => {data}; -const renderCommonDate = (date: string) => - {formatDate(date)}; +const renderCommonDate = (date: string) => {formatDate(date)}; -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 {clusterId}; + 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 ( + + {clusterId} + + ); }; // Links Resources -const renderLinkName = (item: { name: string }) => - {item.name || '-'}; +const renderLinkName = (item: { name: string }) => {item.name || "-"}; -export const ResourceLinkName = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return resource || { name: '' }; - })(renderLinkName); +export const ResourceLinkName = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + return resource || { name: "" }; +})(renderLinkName); -const renderLinkClass = (item: { linkClass: string }) => - {item.linkClass}; +const renderLinkClass = (item: { linkClass: string }) => {item.linkClass}; -export const ResourceLinkClass = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return resource || { linkClass: '' }; - })(renderLinkClass); +export const ResourceLinkClass = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(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 dispatch(navigateTo(item.uuid))}> - {resourceLabel(item.kind, item && item.kind === ResourceKind.GROUP ? (item as GroupResource).groupClass || '' : '')}: {displayName || item.uuid} - ; + return ( + { + item.kind === ResourceKind.GROUP && (item as GroupResource).groupClass === "role" + ? dispatch(navigateToGroupDetails(item.uuid)) + : dispatch(navigateTo(item.uuid)); + }} + > + {resourceLabel(item.kind, item && item.kind === ResourceKind.GROUP ? (item as GroupResource).groupClass || "" : "")}:{" "} + {displayName || item.uuid} + + ); }; -export const ResourceLinkTail = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - const tailResource = getResource(resource?.tailUuid || '')(state.resources); +export const ResourceLinkTail = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + const tailResource = getResource(resource?.tailUuid || "")(state.resources); - return { - item: tailResource || { uuid: resource?.tailUuid || '', kind: resource?.tailKind || ResourceKind.NONE } - }; - })((props: { item: Resource } & DispatchProp) => - renderResourceLink(props.dispatch, props.item)); + return { + item: tailResource || { uuid: resource?.tailUuid || "", kind: resource?.tailKind || ResourceKind.NONE }, + }; +})((props: { item: Resource } & DispatchProp) => renderResourceLink(props.dispatch, props.item)); -export const ResourceLinkHead = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - const headResource = getResource(resource?.headUuid || '')(state.resources); +export const ResourceLinkHead = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + const headResource = getResource(resource?.headUuid || "")(state.resources); - return { - item: headResource || { uuid: resource?.headUuid || '', kind: resource?.headKind || ResourceKind.NONE } - }; - })((props: { item: Resource } & DispatchProp) => - renderResourceLink(props.dispatch, props.item)); + return { + item: headResource || { uuid: resource?.headUuid || "", kind: resource?.headKind || ResourceKind.NONE }, + }; +})((props: { item: Resource } & DispatchProp) => renderResourceLink(props.dispatch, props.item)); -export const ResourceLinkUuid = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return resource || { uuid: '' }; - })(renderUuid); +export const ResourceLinkUuid = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + return resource || { uuid: "" }; +})(renderUuid); -export const ResourceLinkHeadUuid = connect( - (state: RootState, props: { uuid: string }) => { - const link = getResource(props.uuid)(state.resources); - const headResource = getResource(link?.headUuid || '')(state.resources); +export const ResourceLinkHeadUuid = connect((state: RootState, props: { uuid: string }) => { + const link = getResource(props.uuid)(state.resources); + const headResource = getResource(link?.headUuid || "")(state.resources); - return headResource || { uuid: '' }; - })(renderUuid); + return headResource || { uuid: "" }; +})(renderUuid); -export const ResourceLinkTailUuid = connect( - (state: RootState, props: { uuid: string }) => { - const link = getResource(props.uuid)(state.resources); - const tailResource = getResource(link?.tailUuid || '')(state.resources); +export const ResourceLinkTailUuid = connect((state: RootState, props: { uuid: string }) => { + const link = getResource(props.uuid)(state.resources); + const tailResource = getResource(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 ? ( - dispatch(openRemoveGroupMemberDialog(item.uuid))}> + dispatch(openRemoveGroupMemberDialog(item.uuid))} + > - : + + ) : ( - + - ; + + ); } else { return ; } -} +}; -export const ResourceLinkDelete = connect( - (state: RootState, props: { uuid: string }) => { - const link = getResource(props.uuid)(state.resources); - const isBuiltin = isBuiltinGroup(link?.headUuid || '') || isBuiltinGroup(link?.tailUuid || ''); +export const ResourceLinkDelete = connect((state: RootState, props: { uuid: string }) => { + const link = getResource(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) => - 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) => renderLinkDelete(props.dispatch, props.item, props.canManage)); -export const ResourceLinkTailEmail = connect( - (state: RootState, props: { uuid: string }) => { - const link = getResource(props.uuid)(state.resources); - const resource = getResource(link?.tailUuid || '')(state.resources); +export const ResourceLinkTailEmail = connect((state: RootState, props: { uuid: string }) => { + const link = getResource(props.uuid)(state.resources); + const resource = getResource(link?.tailUuid || "")(state.resources); - return resource || { email: '' }; - })(renderEmail); + return resource || { email: "" }; +})(renderEmail); -export const ResourceLinkTailUsername = connect( - (state: RootState, props: { uuid: string }) => { - const link = getResource(props.uuid)(state.resources); - const resource = getResource(link?.tailUuid || '')(state.resources); +export const ResourceLinkTailUsername = connect((state: RootState, props: { uuid: string }) => { + const link = getResource(props.uuid)(state.resources); + const resource = getResource(link?.tailUuid || "")(state.resources); - return resource || { username: '' }; - })(renderUsername); + return resource || { username: "" }; +})(renderUsername); const renderPermissionLevel = (dispatch: Dispatch, link: LinkResource, canManage: boolean) => { - return - {formatPermissionLevel(link.name as PermissionLevel)} - {canManage ? - dispatch(openPermissionEditContextMenu(event, link))}> - - : - '' - } - ; -} + return ( + + {formatPermissionLevel(link.name as PermissionLevel)} + {canManage ? ( + dispatch(openPermissionEditContextMenu(event, link))} + > + + + ) : ( + "" + )} + + ); +}; -export const ResourceLinkHeadPermissionLevel = connect( - (state: RootState, props: { uuid: string }) => { - const link = getResource(props.uuid)(state.resources); - const isBuiltin = isBuiltinGroup(link?.headUuid || '') || isBuiltinGroup(link?.tailUuid || ''); +export const ResourceLinkHeadPermissionLevel = connect((state: RootState, props: { uuid: string }) => { + const link = getResource(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) => - 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) => renderPermissionLevel(props.dispatch, props.link, props.canManage)); -export const ResourceLinkTailPermissionLevel = connect( - (state: RootState, props: { uuid: string }) => { - const link = getResource(props.uuid)(state.resources); - const isBuiltin = isBuiltinGroup(link?.headUuid || '') || isBuiltinGroup(link?.tailUuid || ''); +export const ResourceLinkTailPermissionLevel = connect((state: RootState, props: { uuid: string }) => { + const link = getResource(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) => - 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) => renderPermissionLevel(props.dispatch, props.link, props.canManage)); const getResourceLinkCanManage = (state: RootState, link: LinkResource) => { const headResource = getResource(link.headUuid)(state.resources); - // const tailResource = getResource(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 (
- {uuid && + {uuid && ( dispatch(openRunProcess(uuid))}> - } + + )}
); }; -export const ResourceRunProcess = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return { - uuid: resource ? resource.uuid : '' - }; - })((props: { uuid: string } & DispatchProp) => - resourceRunProcess(props.dispatch, props.uuid)); +export const ResourceRunProcess = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + return { + uuid: resource ? resource.uuid : "", + }; +})((props: { uuid: string } & DispatchProp) => resourceRunProcess(props.dispatch, props.uuid)); const renderWorkflowStatus = (uuidPrefix: string, ownerUuid?: string) => { if (ownerUuid === getPublicUuid(uuidPrefix)) { @@ -659,353 +700,398 @@ const renderWorkflowStatus = (uuidPrefix: string, ownerUuid?: string) => { } }; -const renderStatus = (status: string) => - {status}; +const renderStatus = (status: string) => ( + + {status} + +); -export const ResourceWorkflowStatus = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - const uuidPrefix = getUuidPrefix(state); - return { - ownerUuid: resource ? resource.ownerUuid : '', - uuidPrefix - }; - })((props: { ownerUuid?: string, uuidPrefix: string }) => renderWorkflowStatus(props.uuidPrefix, props.ownerUuid)); - -export const ResourceContainerUuid = connect( - (state: RootState, props: { uuid: string }) => { - const process = getProcess(props.uuid)(state.resources) - return { uuid: process?.container?.uuid ? process?.container?.uuid : '' }; - })((props: { uuid: string }) => renderUuid({ uuid: props.uuid })); +export const ResourceWorkflowStatus = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + const uuidPrefix = getUuidPrefix(state); + return { + ownerUuid: resource ? resource.ownerUuid : "", + uuidPrefix, + }; +})((props: { ownerUuid?: string; uuidPrefix: string }) => renderWorkflowStatus(props.uuidPrefix, props.ownerUuid)); + +export const ResourceContainerUuid = connect((state: RootState, props: { uuid: string }) => { + const process = getProcess(props.uuid)(state.resources); + return { uuid: process?.container?.uuid ? process?.container?.uuid : "" }; +})((props: { uuid: string }) => renderUuid({ uuid: props.uuid })); enum ColumnSelection { - OUTPUT_UUID = 'outputUuid', - LOG_UUID = 'logUuid' + OUTPUT_UUID = "outputUuid", + LOG_UUID = "logUuid", } const renderUuidLinkWithCopyIcon = (dispatch: Dispatch, item: ProcessResource, column: string) => { - const selectedColumnUuid = item[column] - return - - {selectedColumnUuid ? - dispatch(navigateTo(selectedColumnUuid))}> - {selectedColumnUuid} - - : '-' } - - - {selectedColumnUuid && renderUuidCopyIcon({ uuid: selectedColumnUuid })} + const selectedColumnUuid = item[column]; + return ( + + + {selectedColumnUuid ? ( + dispatch(navigateTo(selectedColumnUuid))} + > + {selectedColumnUuid} + + ) : ( + "-" + )} + + {selectedColumnUuid && renderUuidCopyIcon({ uuid: selectedColumnUuid })} - ; + ); }; -export const ResourceOutputUuid = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return resource; - })((process: ProcessResource & DispatchProp) => renderUuidLinkWithCopyIcon(process.dispatch, process, ColumnSelection.OUTPUT_UUID)); - -export const ResourceLogUuid = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return resource; - })((process: ProcessResource & DispatchProp) => renderUuidLinkWithCopyIcon(process.dispatch, process, ColumnSelection.LOG_UUID)); +export const ResourceOutputUuid = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + return resource; +})((process: ProcessResource & DispatchProp) => renderUuidLinkWithCopyIcon(process.dispatch, process, ColumnSelection.OUTPUT_UUID)); + +export const ResourceLogUuid = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + return resource; +})((process: ProcessResource & DispatchProp) => 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(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(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(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(props.uuid)(state.resources); + return { date: resource ? resource.deleteAt : "" }; +})((props: { date: string }) => renderDate(props.date)); + +export const renderFileSize = (fileSize?: number) => ( + + {formatFileSize(fileSize)} + +); -export const ResourceParentProcess = connect( - (state: RootState, props: { uuid: string }) => { - const process = getProcess(props.uuid)(state.resources) - return { parentProcess: process?.containerRequest?.requestingContainerUuid || '' }; - })((props: { parentProcess: string }) => renderUuid({uuid: props.parentProcess})); +export const ResourceFileSize = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); -export const ResourceModifiedByUserUuid = connect( - (state: RootState, props: { uuid: string }) => { - const process = getProcess(props.uuid)(state.resources) - return { userUuid: process?.containerRequest?.modifiedByUserUuid || '' }; - })((props: { userUuid: string }) => renderUuid({uuid: props.userUuid})); + if (resource && resource.kind !== ResourceKind.COLLECTION) { + return { fileSize: "" }; + } - export const ResourceCreatedAtDate = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(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(props.uuid)(state.resources); - return { date: resource ? resource.modifiedAt : '' }; - })((props: { date: string }) => renderDate(props.date)); + return { fileSize: resource ? resource.fileSizeTotal : 0 }; +})((props: { fileSize?: number }) => renderFileSize(props.fileSize)); -export const ResourceTrashDate = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return { date: resource ? resource.trashAt : '' }; - })((props: { date: string }) => renderDate(props.date)); +const renderOwner = (owner: string) => {owner || "-"}; -export const ResourceDeleteDate = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return { date: resource ? resource.deleteAt : '' }; - })((props: { date: string }) => renderDate(props.date)); +export const ResourceOwner = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + return { owner: resource ? resource.ownerUuid : "" }; +})((props: { owner: string }) => renderOwner(props.owner)); -export const renderFileSize = (fileSize?: number) => - - {formatFileSize(fileSize)} - ; +export const ResourceOwnerName = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + const ownerNameState = state.ownerName; + const ownerName = ownerNameState.find(it => it.uuid === resource!.ownerUuid); + return { owner: ownerName ? ownerName!.name : resource!.ownerUuid }; +})((props: { owner: string }) => renderOwner(props.owner)); -export const ResourceFileSize = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); +export const ResourceUUID = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + return { uuid: resource ? resource.uuid : "" }; +})((props: { uuid: string }) => renderUuid({ uuid: props.uuid })); - if (resource && resource.kind !== ResourceKind.COLLECTION) { - return { fileSize: '' }; - } +const renderVersion = (version: number) => { + return {version ?? "-"}; +}; - return { fileSize: resource ? resource.fileSizeTotal : 0 }; - })((props: { fileSize?: number }) => renderFileSize(props.fileSize)); +export const ResourceVersion = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + return { version: resource ? resource.version : "" }; +})((props: { version: number }) => renderVersion(props.version)); -const renderOwner = (owner: string) => +const renderPortableDataHash = (portableDataHash: string | null) => ( - {owner || '-'} - ; - -export const ResourceOwner = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return { owner: resource ? resource.ownerUuid : '' }; - })((props: { owner: string }) => renderOwner(props.owner)); + {portableDataHash ? ( + <> + {portableDataHash} + + + ) : ( + "-" + )} + +); -export const ResourceOwnerName = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - const ownerNameState = state.ownerName; - const ownerName = ownerNameState.find(it => it.uuid === resource!.ownerUuid); - return { owner: ownerName ? ownerName!.name : resource!.ownerUuid }; - })((props: { owner: string }) => renderOwner(props.owner)); +export const ResourcePortableDataHash = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + return { portableDataHash: resource ? resource.portableDataHash : "" }; +})((props: { portableDataHash: string }) => renderPortableDataHash(props.portableDataHash)); -export const ResourceUUID = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return { uuid: resource ? resource.uuid : '' }; - })((props: { uuid: string }) => renderUuid({uuid: props.uuid})); +const renderFileCount = (fileCount: number) => { + return {fileCount ?? "-"}; +}; -const renderVersion = (version: number) =>{ - return {version ?? '-'} -} +export const ResourceFileCount = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + return { fileCount: resource ? resource.fileCount : "" }; +})((props: { fileCount: number }) => renderFileCount(props.fileCount)); -export const ResourceVersion = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return { version: resource ? resource.version: '' }; - })((props: { version: number }) => renderVersion(props.version)); - -const renderPortableDataHash = (portableDataHash:string | null) => - - {portableDataHash ? <>{portableDataHash} - : '-' } - - -export const ResourcePortableDataHash = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return { portableDataHash: resource ? resource.portableDataHash : '' }; - })((props: { portableDataHash: string }) => renderPortableDataHash(props.portableDataHash)); +const userFromID = connect((state: RootState, props: { uuid: string }) => { + let userFullname = ""; + const resource = getResource(props.uuid)(state.resources); + if (resource) { + userFullname = getUserFullname(resource as User) || (resource as GroupContentsResource).name; + } -const renderFileCount = (fileCount: number) =>{ - return {fileCount ?? '-'} -} + return { uuid: props.uuid, userFullname }; +}); -export const ResourceFileCount = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(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(props.uuid)(state.resources); - - if (resource) { - userFullname = getUserFullname(resource as User) || (resource as GroupContentsResource).name; - } +const ownerFromResourceId = compose( + connect((state: RootState, props: { uuid: string }) => { + const childResource = getResource(props.uuid)(state.resources); + return { uuid: childResource ? (childResource as Resource).ownerUuid : "" }; + }), + userFromID +); - return { uuid: props.uuid, userFullname }; - }); +const _resourceWithName = withStyles( + {}, + { withTheme: true } +)((props: { uuid: string; userFullname: string; dispatch: Dispatch; theme: ArvadosTheme }) => { + const { uuid, userFullname, dispatch, theme } = props; + if (userFullname === "") { + dispatch(loadResource(uuid, false)); + return ( + + {uuid} + + ); + } -const ownerFromResourceId = - compose( - connect((state: RootState, props: { uuid: string }) => { - const childResource = getResource(props.uuid)(state.resources); - return { uuid: childResource ? (childResource as Resource).ownerUuid : '' }; - }), - userFromID + return ( + + {userFullname} ({uuid}) + ); +}); +const _resourceWithNameLink = withStyles( + {}, + { withTheme: true } +)((props: { uuid: string; userFullname: string; dispatch: Dispatch; theme: ArvadosTheme }) => { + const { uuid, userFullname, dispatch, theme } = props; + if (!userFullname) { + dispatch(loadResource(uuid, false)); + } + return ( + dispatch(navigateTo(uuid))} + > + {userFullname ? userFullname : uuid} + + ) +}); - -const _resourceWithName = - withStyles({}, { withTheme: true }) - ((props: { uuid: string, userFullname: string, dispatch: Dispatch, theme: ArvadosTheme }) => { - const { uuid, userFullname, dispatch, theme } = props; - if (userFullname === '') { - dispatch(loadResource(uuid, false)); - return - {uuid} - ; - } - - return - {userFullname} ({uuid}) - ; - }); +export const ResourceOwnerWithNameLink = ownerFromResourceId(_resourceWithNameLink); export const ResourceOwnerWithName = ownerFromResourceId(_resourceWithName); export const ResourceWithName = userFromID(_resourceWithName); +export const UserNameFromID = compose(userFromID)((props: { uuid: string; displayAsText?: string; userFullname: string; dispatch: Dispatch }) => { + const { uuid, userFullname, dispatch } = props; + if (userFullname === "") { + dispatch(loadResource(uuid, false)); + } + return {userFullname ? userFullname : uuid}; +}); -export const UserNameFromID = - compose(userFromID)( - (props: { uuid: string, displayAsText?: string, userFullname: string, dispatch: Dispatch }) => { - const { uuid, userFullname, dispatch } = props; +export const ResponsiblePerson = compose( + connect((state: RootState, props: { uuid: string; parentRef: HTMLElement | null }) => { + let responsiblePersonName: string = ""; + let responsiblePersonUUID: string = ""; + let responsiblePersonProperty: string = ""; - if (userFullname === '') { - dispatch(loadResource(uuid, false)); - } - return - {userFullname ? userFullname : uuid} - ; - }); - -export const ResponsiblePerson = - compose( - connect( - (state: RootState, props: { uuid: string, parentRef: HTMLElement | null }) => { - let responsiblePersonName: string = ''; - let responsiblePersonUUID: string = ''; - let responsiblePersonProperty: string = ''; - - if (state.auth.config.clusterConfig.Collections.ManagedProperties) { - let index = 0; - const keys = Object.keys(state.auth.config.clusterConfig.Collections.ManagedProperties); - - while (!responsiblePersonProperty && keys[index]) { - const key = keys[index]; - if (state.auth.config.clusterConfig.Collections.ManagedProperties[key].Function === 'original_owner') { - responsiblePersonProperty = key; - } - index++; - } + if (state.auth.config.clusterConfig.Collections.ManagedProperties) { + let index = 0; + const keys = Object.keys(state.auth.config.clusterConfig.Collections.ManagedProperties); + + while (!responsiblePersonProperty && keys[index]) { + const key = keys[index]; + if (state.auth.config.clusterConfig.Collections.ManagedProperties[key].Function === "original_owner") { + responsiblePersonProperty = key; } + index++; + } + } - let resource: Resource | undefined = getResource(props.uuid)(state.resources); + let resource: Resource | undefined = getResource(props.uuid)(state.resources); - while (resource && resource.kind !== ResourceKind.USER && responsiblePersonProperty) { - responsiblePersonUUID = (resource as CollectionResource).properties[responsiblePersonProperty]; - resource = getResource(responsiblePersonUUID)(state.resources); - } + while (resource && resource.kind !== ResourceKind.USER && responsiblePersonProperty) { + responsiblePersonUUID = (resource as CollectionResource).properties[responsiblePersonProperty]; + resource = getResource(responsiblePersonUUID)(state.resources); + } - if (resource && resource.kind === ResourceKind.USER) { - responsiblePersonName = getUserFullname(resource as UserResource) || (resource as GroupContentsResource).name; - } + if (resource && resource.kind === ResourceKind.USER) { + responsiblePersonName = getUserFullname(resource as UserResource) || (resource as GroupContentsResource).name; + } - return { uuid: responsiblePersonUUID, responsiblePersonName, parentRef: props.parentRef }; - }), - withStyles({}, { withTheme: true })) - ((props: { uuid: string | null, responsiblePersonName: string, parentRef: HTMLElement | null, theme: ArvadosTheme }) => { - const { uuid, responsiblePersonName, parentRef, theme } = props; - - if (!uuid && parentRef) { - parentRef.style.display = 'none'; - return null; - } else if (parentRef) { - parentRef.style.display = 'block'; - } + return { uuid: responsiblePersonUUID, responsiblePersonName, parentRef: props.parentRef }; + }), + withStyles({}, { withTheme: true }) +)((props: { uuid: string | null; responsiblePersonName: string; parentRef: HTMLElement | null; theme: ArvadosTheme }) => { + const { uuid, responsiblePersonName, parentRef, theme } = props; - if (!responsiblePersonName) { - return - {uuid} - ; - } + if (!uuid && parentRef) { + parentRef.style.display = "none"; + return null; + } else if (parentRef) { + parentRef.style.display = "block"; + } - return - {responsiblePersonName} ({uuid}) - ; - }); + if (!responsiblePersonName) { + return ( + + {uuid} + + ); + } -const renderType = (type: string, subtype: string) => - - {resourceLabel(type, subtype)} - ; + return ( + + {responsiblePersonName} ({uuid}) + + ); +}); -export const ResourceType = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return { type: resource ? resource.kind : '', subtype: resource && resource.kind === ResourceKind.GROUP ? resource.groupClass : '' }; - })((props: { type: string, subtype: string }) => renderType(props.type, props.subtype)); +const renderType = (type: string, subtype: string) => {resourceLabel(type, subtype)}; + +export const ResourceType = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(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(props.uuid)(state.resources) }; })((props: { resource: GroupContentsResource }) => - (props.resource && props.resource.kind === ResourceKind.COLLECTION) - ? - : + props.resource && props.resource.kind === ResourceKind.COLLECTION ? ( + + ) : ( + + ) ); export const CollectionStatus = connect((state: RootState, props: { uuid: string }) => { return { collection: getResource(props.uuid)(state.resources) }; })((props: { collection: CollectionResource }) => - (props.collection.uuid !== props.collection.currentVersionUuid) - ? version {props.collection.version} - : head version + props.collection.uuid !== props.collection.currentVersionUuid ? ( + version {props.collection.version} + ) : ( + head version + ) ); -export const CollectionName = connect((state: RootState, props: { uuid: string, className?: string }) => { +export const CollectionName = connect((state: RootState, props: { uuid: string; className?: string }) => { return { - collection: getResource(props.uuid)(state.resources), - uuid: props.uuid, - className: props.className, - }; -})((props: { collection: CollectionResource, uuid: string, className?: string }) => - {props.collection?.name || props.uuid} -); + collection: getResource(props.uuid)(state.resources), + uuid: props.uuid, + className: props.className, + }; +})((props: { collection: CollectionResource; uuid: string; className?: string }) => ( + {props.collection?.name || props.uuid} +)); 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 - ? - : - - ); + withStyles({}, { withTheme: true }) +)((props: { process?: Process; theme: ArvadosTheme }) => + props.process ? ( + + ) : ( + - + ) +); -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) => - +export const renderRunTime = (time: number) => ( + {formatTime(time, true)} - ; + +); interface ContainerRunTimeProps { process: Process; @@ -1017,31 +1103,33 @@ interface ContainerRunTimeState { export const ContainerRunTime = connect((state: RootState, props: { uuid: string }) => { return { process: getProcess(props.uuid)(state.resources) }; -})(class extends React.Component { - private timer: any; +})( + class extends React.Component { + private timer: any; - constructor(props: ContainerRunTimeProps) { - super(props); - this.state = { runtime: this.getRuntime() }; - } + constructor(props: ContainerRunTimeProps) { + super(props); + this.state = { runtime: this.getRuntime() }; + } - getRuntime() { - return this.props.process ? getProcessRuntime(this.props.process) : 0; - } + getRuntime() { + return this.props.process ? getProcessRuntime(this.props.process) : 0; + } - updateRuntime() { - this.setState({ runtime: this.getRuntime() }); - } + updateRuntime() { + this.setState({ runtime: this.getRuntime() }); + } - componentDidMount() { - this.timer = setInterval(this.updateRuntime.bind(this), 5000); - } + componentDidMount() { + this.timer = setInterval(this.updateRuntime.bind(this), 5000); + } - componentWillUnmount() { - clearInterval(this.timer); - } + componentWillUnmount() { + clearInterval(this.timer); + } - render() { - return this.props.process ? renderRunTime(this.state.runtime) : -; + render() { + return this.props.process ? renderRunTime(this.state.runtime) : -; + } } -}); +); diff --git a/src/views-components/details-panel/details-panel.tsx b/src/views-components/details-panel/details-panel.tsx index e9175f57..2653a210 100644 --- a/src/views-components/details-panel/details-panel.tsx +++ b/src/views-components/details-panel/details-panel.tsx @@ -83,8 +83,11 @@ const getItem = (res: DetailsResource): DetailsData => { } }; -const mapStateToProps = ({ auth, detailsPanel, resources, collectionPanelFiles }: RootState) => { - const resource = getResource(detailsPanel.resourceUuid)(resources) as DetailsResource | undefined; +const mapStateToProps = ({ auth, detailsPanel, resources, collectionPanelFiles, multiselect, router }: RootState) => { + const isDetailsResourceChecked = multiselect.checkedList[detailsPanel.resourceUuid] + const currentRoute = router.location ? router.location.pathname : ""; + const currentItemUuid = isDetailsResourceChecked || currentRoute.includes('collections') ? detailsPanel.resourceUuid : multiselect.selectedUuid ? multiselect.selectedUuid : currentRoute.split('/')[2]; + const resource = getResource(currentItemUuid)(resources) as DetailsResource | undefined; const file = resource ? undefined : getNode(detailsPanel.resourceUuid)(collectionPanelFiles); diff --git a/src/views-components/details-panel/workflow-details.tsx b/src/views-components/details-panel/workflow-details.tsx index 98978dd2..ca224b1d 100644 --- a/src/views-components/details-panel/workflow-details.tsx +++ b/src/views-components/details-panel/workflow-details.tsx @@ -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(openRunProcess(wf.uuid, wf.ownerUuid, wf.name)), }); -type CssRules = 'runButton'; +type CssRules = 'runButton' | 'propertyTag'; const styles: StyleRulesCallback = (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) => { + ({ workflow, onClick, auth, classes }: WorkflowDetailsCardDataProps & AuthStateDataProps & WorkflowDetailsCardActionProps & WithStyles) => { + if (!workflow) { + return + } + + const data = getRegisteredWorkflowPanelData(workflow, auth); return - {workflow && workflow.description !== "" && - - } - + } /> + + + {Object.keys(data.gitprops).map(k => + getPropertyChip(k, data.gitprops[k], undefined, classes.propertyTag))} + ; })); diff --git a/src/views-components/dialog-copy/dialog-partial-copy-to-collection.tsx b/src/views-components/dialog-copy/dialog-collection-partial-copy-to-existing-collection.tsx similarity index 63% rename from src/views-components/dialog-copy/dialog-partial-copy-to-collection.tsx rename to src/views-components/dialog-copy/dialog-collection-partial-copy-to-existing-collection.tsx index 4e9dde6a..eb95d1f2 100644 --- a/src/views-components/dialog-copy/dialog-partial-copy-to-collection.tsx +++ b/src/views-components/dialog-copy/dialog-collection-partial-copy-to-existing-collection.tsx @@ -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 & InjectedFormProps; +type DialogCollectionPartialCopyProps = WithDialogProps & InjectedFormProps; -export const DialogCollectionPartialCopyToSelectedCollection = (props: DialogCollectionPartialCopyProps & PickerIdProp) => +export const DialogCollectionPartialCopyToExistingCollection = (props: DialogCollectionPartialCopyProps & PickerIdProp) => ; -export const CollectionPartialCopyFields = memoize( +const CollectionPartialCopyFields = memoize( (pickerId: string) => () => <> - + ); diff --git a/src/views-components/dialog-copy/dialog-collection-partial-copy.tsx b/src/views-components/dialog-copy/dialog-collection-partial-copy-to-new-collection.tsx similarity index 68% rename from src/views-components/dialog-copy/dialog-collection-partial-copy.tsx rename to src/views-components/dialog-copy/dialog-collection-partial-copy-to-new-collection.tsx index 3c584e4f..6b5a7759 100644 --- a/src/views-components/dialog-copy/dialog-collection-partial-copy.tsx +++ b/src/views-components/dialog-copy/dialog-collection-partial-copy-to-new-collection.tsx @@ -8,20 +8,20 @@ import { FormDialog } from 'components/form-dialog/form-dialog'; import { CollectionNameField, CollectionDescriptionField, CollectionProjectPickerField } from 'views-components/form-fields/collection-form-fields'; import { WithDialogProps } from 'store/dialog/with-dialog'; import { InjectedFormProps } from 'redux-form'; -import { CollectionPartialCopyFormData } from 'store/collections/collection-partial-copy-actions'; +import { CollectionPartialCopyToNewCollectionFormData } from 'store/collections/collection-partial-copy-actions'; import { PickerIdProp } from "store/tree-picker/picker-id"; -type DialogCollectionPartialCopyProps = WithDialogProps & InjectedFormProps; +type DialogCollectionPartialCopyProps = WithDialogProps & InjectedFormProps; -export const DialogCollectionPartialCopy = (props: DialogCollectionPartialCopyProps & PickerIdProp) => +export const DialogCollectionPartialCopyToNewCollection = (props: DialogCollectionPartialCopyProps & PickerIdProp) => ; -export const CollectionPartialCopyFields = memoize( +const CollectionPartialCopyFields = memoize( (pickerId: string) => () => <> diff --git a/src/views-components/dialog-copy/dialog-collection-partial-copy-to-separate-collections.tsx b/src/views-components/dialog-copy/dialog-collection-partial-copy-to-separate-collections.tsx new file mode 100644 index 00000000..32f706a2 --- /dev/null +++ b/src/views-components/dialog-copy/dialog-collection-partial-copy-to-separate-collections.tsx @@ -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 & InjectedFormProps; + +export const DialogCollectionPartialCopyToSeparateCollection = (props: DialogCollectionPartialCopyProps & PickerIdProp) => + ; + +const CollectionPartialCopyFields = memoize( + (pickerId: string) => + () => + <> + + ); diff --git a/src/views-components/dialog-copy/dialog-copy.tsx b/src/views-components/dialog-copy/dialog-copy.tsx index a3e30119..71d0dab3 100644 --- a/src/views-components/dialog-copy/dialog-copy.tsx +++ b/src/views-components/dialog-copy/dialog-copy.tsx @@ -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 & InjectedFormProps; -export const DialogCopy = (props: CopyFormDialogProps & PickerIdProp) => - ; +export const DialogCopy = (props: CopyFormDialogProps & PickerIdProp) => { + return ( + + ); +}; -const CopyDialogFields = memoize((pickerId: string) => - () => - <> - - - ); +const CopyDialogFields = memoize((pickerId: string) => () => ( + <> + + + +)); + +export const DialogMultiCopy = (props: CopyFormDialogProps & PickerIdProp) => { + return ( + + ); +}; + +const CopyMultiDialogFields = memoize((pickerId: string) => () => ( + +)); diff --git a/src/views-components/dialog-copy/dialog-process-rerun.tsx b/src/views-components/dialog-copy/dialog-process-rerun.tsx index 9f97b1ac..a5d8f3a0 100644 --- a/src/views-components/dialog-copy/dialog-process-rerun.tsx +++ b/src/views-components/dialog-copy/dialog-process-rerun.tsx @@ -2,38 +2,26 @@ // // SPDX-License-Identifier: AGPL-3.0 -import React from "react"; +import React from 'react'; import { memoize } from 'lodash/fp'; import { InjectedFormProps, Field } from 'redux-form'; import { WithDialogProps } from 'store/dialog/with-dialog'; import { FormDialog } from 'components/form-dialog/form-dialog'; import { ProjectTreePickerField } from 'views-components/projects-tree-picker/tree-picker-field'; import { COPY_NAME_VALIDATION, COPY_FILE_VALIDATION } from 'validators/validators'; -import { TextField } from "components/text-field/text-field"; +import { TextField } from 'components/text-field/text-field'; import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog'; import { PickerIdProp } from 'store/tree-picker/picker-id'; type ProcessRerunFormDialogProps = WithDialogProps & InjectedFormProps; -export const DialogProcessRerun = (props: ProcessRerunFormDialogProps & PickerIdProp) => - ; +export const DialogProcessRerun = (props: ProcessRerunFormDialogProps & PickerIdProp) => ( + +); -const CopyDialogFields = memoize((pickerId: string) => - () => - <> - - - ); +const CopyDialogFields = memoize((pickerId: string) => () => ( + <> + + + +)); diff --git a/src/views-components/dialog-forms/copy-collection-dialog.ts b/src/views-components/dialog-forms/copy-collection-dialog.ts index a1c822cf..220b5a2c 100644 --- a/src/views-components/dialog-forms/copy-collection-dialog.ts +++ b/src/views-components/dialog-forms/copy-collection-dialog.ts @@ -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({ + form: COLLECTION_MULTI_COPY_FORM_NAME, + touchOnChange: true, + onSubmit: (data, dispatch) => { + dispatch(copyCollection(data)); + }, + }), + pickerId(COLLECTION_MULTI_COPY_FORM_NAME) +)(DialogMultiCopy); diff --git a/src/views-components/dialog-forms/copy-process-dialog.ts b/src/views-components/dialog-forms/copy-process-dialog.ts index 6a79b626..8afa58dd 100644 --- a/src/views-components/dialog-forms/copy-process-dialog.ts +++ b/src/views-components/dialog-forms/copy-process-dialog.ts @@ -2,14 +2,14 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { compose } from "redux"; -import { withDialog } from "store/dialog/with-dialog"; +import { compose } from 'redux'; +import { withDialog } from 'store/dialog/with-dialog'; import { reduxForm } from 'redux-form'; import { PROCESS_COPY_FORM_NAME } from 'store/processes/process-copy-actions'; -import { DialogProcessRerun } from "views-components/dialog-copy/dialog-process-rerun"; +import { DialogProcessRerun } from 'views-components/dialog-copy/dialog-process-rerun'; import { copyProcess } from 'store/workbench/workbench-actions'; import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog'; -import { pickerId } from "store/tree-picker/picker-id"; +import { pickerId } from 'store/tree-picker/picker-id'; export const CopyProcessDialog = compose( withDialog(PROCESS_COPY_FORM_NAME), @@ -17,7 +17,7 @@ export const CopyProcessDialog = compose( form: PROCESS_COPY_FORM_NAME, onSubmit: (data, dispatch) => { dispatch(copyProcess(data)); - } + }, }), - pickerId(PROCESS_COPY_FORM_NAME), + pickerId(PROCESS_COPY_FORM_NAME) )(DialogProcessRerun); diff --git a/src/views-components/dialog-forms/move-project-dialog.ts b/src/views-components/dialog-forms/move-project-dialog.ts index 0729e29c..345040d5 100644 --- a/src/views-components/dialog-forms/move-project-dialog.ts +++ b/src/views-components/dialog-forms/move-project-dialog.ts @@ -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 index 3630ffb7..00000000 --- a/src/views-components/dialog-forms/partial-copy-collection-dialog.ts +++ /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({ - 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 index d7b33929..00000000 --- a/src/views-components/dialog-forms/partial-copy-to-collection-dialog.ts +++ /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({ - 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 index 00000000..dd0d0cb4 --- /dev/null +++ b/src/views-components/dialog-forms/partial-copy-to-existing-collection-dialog.ts @@ -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({ + 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 index 00000000..3a321def --- /dev/null +++ b/src/views-components/dialog-forms/partial-copy-to-new-collection-dialog.ts @@ -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({ + 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 index 00000000..78fdd3a1 --- /dev/null +++ b/src/views-components/dialog-forms/partial-copy-to-separate-collections-dialog.ts @@ -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({ + 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 index 00000000..e8d51f1a --- /dev/null +++ b/src/views-components/dialog-forms/partial-move-to-existing-collection-dialog.ts @@ -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({ + 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 index 00000000..103e1e19 --- /dev/null +++ b/src/views-components/dialog-forms/partial-move-to-new-collection-dialog.ts @@ -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({ + 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 index 00000000..8f7ea594 --- /dev/null +++ b/src/views-components/dialog-forms/partial-move-to-separate-collections-dialog.ts @@ -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({ + 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 index 00000000..5cd4996d --- /dev/null +++ b/src/views-components/dialog-move/dialog-collection-partial-move-to-existing-collection.tsx @@ -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 & InjectedFormProps; + +export const DialogCollectionPartialMoveToExistingCollection = (props: DialogCollectionPartialMoveProps & PickerIdProp) => + ; + +const CollectionPartialMoveFields = memoize( + (pickerId: string) => + () => + <> + + ); 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 index 00000000..a33f377c --- /dev/null +++ b/src/views-components/dialog-move/dialog-collection-partial-move-to-new-collection.tsx @@ -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 & InjectedFormProps; + +export const DialogCollectionPartialMoveToNewCollection = (props: DialogCollectionPartialMoveProps & PickerIdProp) => + ; + +const CollectionPartialMoveFields = memoize( + (pickerId: string) => + () => + <> + + + + ); 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 index 00000000..1b716628 --- /dev/null +++ b/src/views-components/dialog-move/dialog-collection-partial-move-to-separate-collections.tsx @@ -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 & InjectedFormProps; + +export const DialogCollectionPartialMoveToSeparateCollections = (props: DialogCollectionPartialMoveProps & PickerIdProp) => + ; + +const CollectionPartialMoveFields = memoize( + (pickerId: string) => + () => + <> + + ); diff --git a/src/views-components/form-fields/collection-form-fields.tsx b/src/views-components/form-fields/collection-form-fields.tsx index 7e18111a..7d5fcf80 100644 --- a/src/views-components/form-fields/collection-form-fields.tsx +++ b/src/views-components/form-fields/collection-form-fields.tsx @@ -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) => + ; + 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} />); diff --git a/src/views-components/login-form/login-form.tsx b/src/views-components/login-form/login-form.tsx index 3aa9e3f2..6c590265 100644 --- a/src/views-components/login-form/login-form.tsx +++ b/src/views-components/login-form/login-form.tsx @@ -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(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(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 ( -
- -
- - setUsername(e.target.value)} - onKeyPress={(e) => handleKeyPress(e)} - /> - setPassword(e.target.value)} - onKeyPress={(e) => handleKeyPress(e)} - /> - - - - - { isSubmitting && } -
-
-
+
+ +
+ + setUsername(e.target.value)} + onKeyPress={(e) => handleKeyPress(e)} + /> + setPassword(e.target.value)} + onKeyPress={(e) => handleKeyPress(e)} + /> + + + + + {isSubmitting && } +
+
+
); }); diff --git a/src/views-components/main-app-bar/account-menu.tsx b/src/views-components/main-app-bar/account-menu.tsx index 4b62cea2..c2cc0e2a 100644 --- a/src/views-components/main-app-bar/account-menu.tsx +++ b/src/views-components/main-app-bar/account-menu.tsx @@ -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 = () => ({ @@ -71,10 +61,6 @@ export const AccountMenuComponent = dispatch(navigateToSiteManager)}>Site Manager dispatch(navigateToMyAccount)}>My account dispatch(navigateToLinkAccount)}>Link account - - - Switch to Workbench v1 ; const reduceItemsFn: (a: React.ReactElement[], @@ -97,7 +83,7 @@ export const AccountMenuComponent = dispatch(authActions.LOGOUT({ deleteLinkData: true, preservePath: false }))}> Logout - + : null; }; diff --git a/src/views-components/main-app-bar/main-app-bar.tsx b/src/views-components/main-app-bar/main-app-bar.tsx index 60ce68e9..c57d5cd8 100644 --- a/src/views-components/main-app-bar/main-app-bar.tsx +++ b/src/views-components/main-app-bar/main-app-bar.tsx @@ -15,6 +15,7 @@ import { HelpMenu } from 'views-components/main-app-bar/help-menu'; import { ReactNode } from "react"; import { AdminMenu } from "views-components/main-app-bar/admin-menu"; import { pluginConfig } from 'plugins'; +import { sanitizeHTML } from "common/html-sanitize"; type CssRules = 'toolbar' | 'link'; @@ -47,7 +48,7 @@ export const MainAppBar = withStyles(styles)( {pluginConfig.appBarLeft || - ({props.uuidPrefix}) + ({props.uuidPrefix}) diff --git a/src/views-components/main-app-bar/notifications-menu.tsx b/src/views-components/main-app-bar/notifications-menu.tsx index ca97a612..89fd2e91 100644 --- a/src/views-components/main-app-bar/notifications-menu.tsx +++ b/src/views-components/main-app-bar/notifications-menu.tsx @@ -26,11 +26,11 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({ type NotificationsMenuProps = { isOpen: boolean; bannerUUID?: string; -} +}; type NotificationsMenuComponentProps = NotificationsMenuProps & { openBanner: any; -} +}; export const NotificationsMenuComponent = (props: NotificationsMenuComponentProps) => { const { isOpen, openBanner } = props; @@ -39,41 +39,58 @@ export const NotificationsMenuComponent = (props: NotificationsMenuComponentProp const menuItems: any[] = []; if (!isOpen && bannerResult) { - menuItems.push(Restore Banner); + menuItems.push( + + Restore Banner + + ); } const toggleTooltips = useCallback(() => { if (tooltipResult) { localStorage.removeItem(TOOLTIP_LOCAL_STORAGE_KEY); } else { - localStorage.setItem(TOOLTIP_LOCAL_STORAGE_KEY, 'true'); + localStorage.setItem(TOOLTIP_LOCAL_STORAGE_KEY, "true"); } window.location.reload(); }, [tooltipResult]); if (tooltipResult) { - menuItems.push(Enable tooltips); + menuItems.push( + + Enable tooltips + + ); } else { - menuItems.push(Disable tooltips); + menuItems.push( + + Disable tooltips + + ); } if (menuItems.length === 0) { menuItems.push(You are up to date); } - return ( - - } - id="account-menu" - title="Notifications"> - { - menuItems.map((item, i) =>
{item}
) - } -
); -} + return ( + + + + } + id="account-menu" + title="Notifications" + > + {menuItems.map((item, i) => ( +
{item}
+ ))} +
+ ); +}; export const NotificationsMenu = connect(mapStateToProps, mapDispatchToProps)(NotificationsMenuComponent); diff --git a/src/views-components/multiselect-toolbar/ms-collection-action-set.ts b/src/views-components/multiselect-toolbar/ms-collection-action-set.ts new file mode 100644 index 00000000..a8a8f457 --- /dev/null +++ b/src/views-components/multiselect-toolbar/ms-collection-action-set.ts @@ -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(openCollectionCopyDialog(resources[0])); + else dispatch(openMultiCollectionCopyDialog(resources[0])); + }, +} + +const msMoveCollection: MultiSelectMenuAction = { + name: MOVE_TO, + icon: MoveToIcon, + hasAlts: false, + isForMulti: true, + execute: (dispatch, resources) => dispatch(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(toggleCollectionTrashed(resource.uuid, resource.isTrashed!!)); + } + }, +} + +const msEditCollection: MultiSelectMenuAction = { + name: MultiSelectMenuActionNames.EDIT_COLLECTION, + icon: RenameIcon, + hasAlts: false, + isForMulti: false, + execute: (dispatch, resources) => { + dispatch(openCollectionUpdateDialog(resources[0])); + }, +} + +const msCopyToClipboardMenuAction: MultiSelectMenuAction = { + name: COPY_TO_CLIPBOARD, + icon: Link, + hasAlts: false, + isForMulti: false, + execute: (dispatch, resources) => { + dispatch(copyToClipboardAction(resources)); + }, +}; + +const msOpenWith3rdPartyClientAction: MultiSelectMenuAction = { + name: OPEN_W_3RD_PARTY_CLIENT, + icon: FolderSharedIcon, + hasAlts: false, + isForMulti: false, + execute: (dispatch, resources) => { + dispatch(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 index 00000000..91e96d9b --- /dev/null +++ b/src/views-components/multiselect-toolbar/ms-menu-actions.ts @@ -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(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(openInNewTabAction(resources[0])); + }, +}; + +const msViewDetailsAction: MultiSelectMenuAction = { + name: VIEW_DETAILS, + icon: DetailsIcon, + hasAlts: false, + isForMulti: false, + execute: (dispatch) => { + dispatch(toggleDetailsPanel()); + }, +}; + +const msAdvancedAction: MultiSelectMenuAction = { + name: API_DETAILS, + icon: AdvancedIcon, + hasAlts: false, + isForMulti: false, + execute: (dispatch, resources) => { + dispatch(openAdvancedTabDialog(resources[0].uuid)); + }, +}; + +const msShareAction: MultiSelectMenuAction = { + name: SHARE, + icon: ShareIcon, + hasAlts: false, + isForMulti: false, + execute: (dispatch, resources) => { + dispatch(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(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 index 00000000..7802ad81 --- /dev/null +++ b/src/views-components/multiselect-toolbar/ms-process-action-set.ts @@ -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(openCopyProcessDialog(resource)); + } + }, +} + +const msRemoveProcess: MultiSelectMenuAction = { + name: MultiSelectMenuActionNames.REMOVE, + icon: RemoveIcon, + hasAlts: false, + isForMulti: true, + execute: (dispatch, resources) => { + dispatch(openRemoveProcessDialog(resources[0], resources.length)); + }, +} + +const msMoveTo: MultiSelectMenuAction = { + name: MultiSelectMenuActionNames.MOVE_TO, + icon: MoveToIcon, + hasAlts: false, + isForMulti: true, + execute: (dispatch, resources) => { + dispatch(openMoveProcessDialog(resources[0])); + }, +} + +const msViewOutputs: MultiSelectMenuAction = { + name: MultiSelectMenuActionNames.OUTPUTS, + icon: OutputIcon, + hasAlts: false, + isForMulti: false, + execute: (dispatch, resources) => { + if (resources[0]) { + dispatch(msNavigateToOutput(resources[0])); + } + }, +} + +const msEditProcess: MultiSelectMenuAction = { + name: MultiSelectMenuActionNames.EDIT_PROCESS, + icon: RenameIcon, + hasAlts: false, + isForMulti: false, + execute: (dispatch, resources) => { + dispatch(openProcessUpdateDialog(resources[0])); + }, +} + +const msCancelProcess: MultiSelectMenuAction = { + name: MultiSelectMenuActionNames.CANCEL, + icon: StopIcon, + hasAlts: false, + isForMulti: false, + execute: (dispatch, resources) => { + dispatch(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 index 00000000..ee1ea1d1 --- /dev/null +++ b/src/views-components/multiselect-toolbar/ms-project-action-set.ts @@ -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(copyToClipboardAction(resources)); + }, +}; + +const msEditProjectAction: MultiSelectMenuAction = { + name: EDIT_PROJECT, + icon: RenameIcon, + hasAlts: false, + isForMulti: false, + execute: (dispatch, resources) => { + dispatch(openProjectUpdateDialog(resources[0])); + }, +}; + +const msMoveToAction: MultiSelectMenuAction = { + name: MOVE_TO, + icon: MoveToIcon, + hasAlts: false, + isForMulti: true, + execute: (dispatch, resource) => { + dispatch(openMoveProjectDialog(resource[0])); + }, +}; + +const msOpenWith3rdPartyClientAction: MultiSelectMenuAction = { + name: OPEN_W_3RD_PARTY_CLIENT, + icon: FolderSharedIcon, + hasAlts: false, + isForMulti: false, + execute: (dispatch, resources) => { + dispatch(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(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(unfreezeProject(resources[0].uuid)); + } else { + dispatch(freezeProject(resources[0].uuid)); + } + }, +}; + +const msNewProjectAction: MultiSelectMenuAction = { + name: NEW_PROJECT, + icon: NewProjectIcon, + hasAlts: false, + isForMulti: false, + execute: (dispatch, resources): void => { + dispatch(openProjectCreateDialog(resources[0].uuid)); + }, +}; + +export const msProjectActionSet: MultiSelectMenuActionSet = [ + [ + ...msCommonActionSet, + msEditProjectAction, + msMoveToAction, + msToggleTrashAction, + msNewProjectAction, + msFreezeProjectAction, + msOpenWith3rdPartyClientAction, + msCopyToClipboardMenuAction + ], +]; + +export const msCommonProjectActionFilter = new Set([ + 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([ADD_TO_FAVORITES, API_DETAILS, COPY_TO_CLIPBOARD, OPEN_IN_NEW_TAB, OPEN_W_3RD_PARTY_CLIENT, VIEW_DETAILS,]); +export const msFrozenProjectActionFilter = new Set([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([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([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([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 index 00000000..ab819df2 --- /dev/null +++ b/src/views-components/multiselect-toolbar/ms-workflow-action-set.ts @@ -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(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(deleteWorkflow(resources[0].uuid, resources[0].ownerUuid)); + }, +}; + +const msCopyToClipboardMenuAction: MultiSelectMenuAction = { + name: COPY_TO_CLIPBOARD, + icon: Link, + hasAlts: false, + isForMulti: false, + execute: (dispatch, resources) => { + dispatch(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]); diff --git a/src/views-components/projects-tree-picker/favorites-tree-picker.tsx b/src/views-components/projects-tree-picker/favorites-tree-picker.tsx index 6ab2b42d..7e63152b 100644 --- a/src/views-components/projects-tree-picker/favorites-tree-picker.tsx +++ b/src/views-components/projects-tree-picker/favorites-tree-picker.tsx @@ -11,7 +11,7 @@ import { loadFavoritesProject } from 'store/tree-picker/tree-picker-actions'; export const FavoritesTreePicker = connect(() => ({ rootItemIcon: FavoriteIcon, }), (dispatch: Dispatch): Pick => ({ - loadRootItem: (_, pickerId, includeCollections, includeFiles, options) => { - dispatch(loadFavoritesProject({ pickerId, includeCollections, includeFiles }, options)); + loadRootItem: (_, pickerId, includeCollections, includeDirectories, includeFiles, options) => { + dispatch(loadFavoritesProject({ pickerId, includeCollections, includeDirectories, includeFiles }, options)); }, -}))(ProjectsTreePicker); \ No newline at end of file +}))(ProjectsTreePicker); diff --git a/src/views-components/projects-tree-picker/generic-projects-tree-picker.tsx b/src/views-components/projects-tree-picker/generic-projects-tree-picker.tsx index 11b51caa..70797f31 100644 --- a/src/views-components/projects-tree-picker/generic-projects-tree-picker.tsx +++ b/src/views-components/projects-tree-picker/generic-projects-tree-picker.tsx @@ -21,7 +21,9 @@ import { CollectionFileType } from 'models/collection-file'; type PickedTreePickerProps = Pick, 'onContextMenu' | 'toggleItemActive' | 'toggleItemOpen' | 'toggleItemSelection'>; export interface ProjectsTreePickerDataProps { + cascadeSelection: boolean; includeCollections?: boolean; + includeDirectories?: boolean; includeFiles?: boolean; rootItemIcon: IconType; showSelection?: boolean; @@ -29,17 +31,17 @@ export interface ProjectsTreePickerDataProps { disableActivation?: string[]; options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }; loadRootItem: (item: TreeItem, 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; -const mapStateToProps = (_: any, { rootItemIcon, showSelection }: ProjectsTreePickerProps) => ({ +const mapStateToProps = (_: any, { rootItemIcon, showSelection, cascadeSelection }: ProjectsTreePickerProps) => ({ render: renderTreeItem(rootItemIcon), - showSelection: isSelectionVisible(showSelection), + showSelection: isSelectionVisible(showSelection, cascadeSelection), }); -const mapDispatchToProps = (dispatch: Dispatch, { loadRootItem, includeCollections, includeFiles, relatedTreePickers, options, ...props }: ProjectsTreePickerProps): PickedTreePickerProps => ({ +const mapDispatchToProps = (dispatch: Dispatch, { loadRootItem, includeCollections, includeDirectories, includeFiles, relatedTreePickers, options, ...props }: ProjectsTreePickerProps): PickedTreePickerProps => ({ onContextMenu: () => { return; }, toggleItemActive: (event, item, pickerId) => { @@ -59,18 +61,18 @@ const mapDispatchToProps = (dispatch: Dispatch, { loadRootItem, includeCollectio if ('kind' in data) { dispatch( data.kind === ResourceKind.COLLECTION - ? loadCollection(id, pickerId) - : loadProject({ id, pickerId, includeCollections, includeFiles, options }) + ? loadCollection(id, pickerId, includeDirectories, includeFiles) + : loadProject({ id, pickerId, includeCollections, includeDirectories, includeFiles, options }) ); } else if (!('type' in data) && loadRootItem) { - loadRootItem(item as TreeItem, pickerId, includeCollections, includeFiles, options); + loadRootItem(item as TreeItem, pickerId, includeCollections, includeDirectories, includeFiles, options); } } else if (status === TreeItemStatus.LOADED) { dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId })); } }, toggleItemSelection: (event, item, pickerId) => { - dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECTION({ id: item.id, pickerId })); + dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECTION({ id: item.id, pickerId, cascade: props.cascadeSelection })); if (props.toggleItemSelection) { props.toggleItemSelection(event, item, pickerId); } @@ -107,11 +109,14 @@ const getProjectPickerIcon = ({ data }: TreeItem, rootIc } }; -const isSelectionVisible = (shouldBeVisible?: boolean) => - ({ status, items }: TreeItem): boolean => { +const isSelectionVisible = (shouldBeVisible: boolean | undefined, cascadeSelection: boolean) => + ({ status, items, data }: TreeItem): 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; } diff --git a/src/views-components/projects-tree-picker/home-tree-picker.tsx b/src/views-components/projects-tree-picker/home-tree-picker.tsx index 3133c5db..3f71a58e 100644 --- a/src/views-components/projects-tree-picker/home-tree-picker.tsx +++ b/src/views-components/projects-tree-picker/home-tree-picker.tsx @@ -11,7 +11,7 @@ import { ProjectsIcon } from 'components/icon/icon'; export const HomeTreePicker = connect(() => ({ rootItemIcon: ProjectsIcon, }), (dispatch: Dispatch): Pick => ({ - loadRootItem: (_, pickerId, includeCollections, includeFiles, options) => { - dispatch(loadUserProject(pickerId, includeCollections, includeFiles, options)); + loadRootItem: (_, pickerId, includeCollections, includeDirectories, includeFiles, options) => { + dispatch(loadUserProject(pickerId, includeCollections, includeDirectories, includeFiles, options)); }, }))(ProjectsTreePicker); diff --git a/src/views-components/projects-tree-picker/projects-tree-picker.tsx b/src/views-components/projects-tree-picker/projects-tree-picker.tsx index 9ac0b64f..16f6cceb 100644 --- a/src/views-components/projects-tree-picker/projects-tree-picker.tsx +++ b/src/views-components/projects-tree-picker/projects-tree-picker.tsx @@ -23,8 +23,11 @@ import { withStyles, StyleRulesCallback, WithStyles } from '@material-ui/core'; import { ArvadosTheme } from 'common/custom-theme'; export interface ToplevelPickerProps { + currentUuids?: string[]; pickerId: string; + cascadeSelection: boolean; includeCollections?: boolean; + includeDirectories?: boolean; includeFiles?: boolean; showSelection?: boolean; options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }; @@ -55,6 +58,7 @@ const mapDispatchToProps = (dispatch: Dispatch, props: ToplevelPickerProps): (Pr const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(props.pickerId); const params = { includeCollections: props.includeCollections, + includeDirectories: props.includeDirectories, includeFiles: props.includeFiles, options: props.options }; @@ -104,7 +108,13 @@ export const ProjectsTreePicker = connect(mapStateToProps, mapDispatchToProps)( componentDidMount() { const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(this.props.pickerId); - this.props.dispatch(initProjectsTreePicker(this.props.pickerId)); + const preloadParams = this.props.currentUuids ? { + selectedItemUuids: this.props.currentUuids, + includeDirectories: !!this.props.includeDirectories, + includeFiles: !!this.props.includeFiles, + multi: !!this.props.showSelection, + } : undefined; + this.props.dispatch(initProjectsTreePicker(this.props.pickerId, preloadParams)); this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_PROJECT_SEARCH({ pickerId: search, projectSearchValue: "" })); this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: search, collectionFilterValue: "" })); @@ -132,7 +142,9 @@ export const ProjectsTreePicker = connect(mapStateToProps, mapDispatchToProps)( const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId); const relatedTreePickers = getRelatedTreePickers(pickerId); const p = { + cascadeSelection: this.props.cascadeSelection, includeCollections: this.props.includeCollections, + includeDirectories: this.props.includeDirectories, includeFiles: this.props.includeFiles, showSelection: this.props.showSelection, options: this.props.options, diff --git a/src/views-components/projects-tree-picker/public-favorites-tree-picker.tsx b/src/views-components/projects-tree-picker/public-favorites-tree-picker.tsx index 91551c9a..ca03f728 100644 --- a/src/views-components/projects-tree-picker/public-favorites-tree-picker.tsx +++ b/src/views-components/projects-tree-picker/public-favorites-tree-picker.tsx @@ -11,7 +11,7 @@ import { loadPublicFavoritesProject } from 'store/tree-picker/tree-picker-action export const PublicFavoritesTreePicker = connect(() => ({ rootItemIcon: PublicFavoriteIcon, }), (dispatch: Dispatch): Pick => ({ - loadRootItem: (_, pickerId, includeCollections, includeFiles, options) => { - dispatch(loadPublicFavoritesProject({ pickerId, includeCollections, includeFiles, options })); + loadRootItem: (_, pickerId, includeCollections, includeDirectories, includeFiles, options) => { + dispatch(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 index 7bad8ef7..2888050b 100644 --- a/src/views-components/projects-tree-picker/search-projects-picker.tsx +++ b/src/views-components/projects-tree-picker/search-projects-picker.tsx @@ -12,7 +12,7 @@ import { SEARCH_PROJECT_ID } from 'store/tree-picker/tree-picker-actions'; export const SearchProjectsPicker = connect(() => ({ rootItemIcon: SearchIcon, }), (dispatch: Dispatch): Pick => ({ - loadRootItem: (_, pickerId, includeCollections, includeFiles, options) => { - dispatch(loadProject({ id: SEARCH_PROJECT_ID, pickerId, includeCollections, includeFiles, searchProjects: true, options })); + loadRootItem: (_, pickerId, includeCollections, includeDirectories, includeFiles, options) => { + dispatch(loadProject({ id: SEARCH_PROJECT_ID, pickerId, includeCollections, includeDirectories, includeFiles, searchProjects: true, options })); }, }))(ProjectsTreePicker); diff --git a/src/views-components/projects-tree-picker/shared-tree-picker.tsx b/src/views-components/projects-tree-picker/shared-tree-picker.tsx index c15df6ba..1914cd9d 100644 --- a/src/views-components/projects-tree-picker/shared-tree-picker.tsx +++ b/src/views-components/projects-tree-picker/shared-tree-picker.tsx @@ -12,7 +12,7 @@ import { SHARED_PROJECT_ID } from 'store/tree-picker/tree-picker-actions'; export const SharedTreePicker = connect(() => ({ rootItemIcon: ShareMeIcon, }), (dispatch: Dispatch): Pick => ({ - loadRootItem: (_, pickerId, includeCollections, includeFiles, options) => { - dispatch(loadProject({ id: SHARED_PROJECT_ID, pickerId, includeCollections, includeFiles, loadShared: true, options })); + loadRootItem: (_, pickerId, includeCollections, includeDirectories, includeFiles, options) => { + dispatch(loadProject({ id: SHARED_PROJECT_ID, pickerId, includeCollections, includeDirectories, includeFiles, loadShared: true, options })); }, }))(ProjectsTreePicker); diff --git a/src/views-components/projects-tree-picker/tree-picker-field.tsx b/src/views-components/projects-tree-picker/tree-picker-field.tsx index 2afa606e..75cf40c6 100644 --- a/src/views-components/projects-tree-picker/tree-picker-field.tsx +++ b/src/views-components/projects-tree-picker/tree-picker-field.tsx @@ -9,6 +9,9 @@ import { WrappedFieldProps } from 'redux-form'; import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker'; import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware'; import { PickerIdProp } from 'store/tree-picker/picker-id'; +import { FileOperationLocation, getFileOperationLocation } from "store/tree-picker/tree-picker-actions"; +import { connect } from "react-redux"; +import { Dispatch } from "redux"; export const ProjectTreePickerField = (props: WrappedFieldProps & PickerIdProp) =>
@@ -16,6 +19,7 @@ export const ProjectTreePickerField = (props: WrappedFieldProps & PickerIdProp) {props.meta.dirty && props.meta.error && @@ -34,6 +38,7 @@ export const CollectionTreePickerField = (props: WrappedFieldProps & PickerIdPro {props.meta.dirty && props.meta.error && @@ -42,3 +47,42 @@ export const CollectionTreePickerField = (props: WrappedFieldProps & PickerIdPro }
; + +type ProjectsTreePickerActionProps = { + getFileOperationLocation: (item: ProjectsTreePickerItem) => Promise; +} + +const projectsTreePickerMapDispatchToProps = (dispatch: Dispatch): ProjectsTreePickerActionProps => ({ + getFileOperationLocation: (item: ProjectsTreePickerItem) => dispatch(getFileOperationLocation(item)), +}); + +type ProjectsTreePickerCombinedProps = ProjectsTreePickerActionProps & WrappedFieldProps & PickerIdProp; + +export const DirectoryTreePickerField = connect(null, projectsTreePickerMapDispatchToProps)( + class DirectoryTreePickerFieldComponent extends React.Component { + + handleDirectoryChange = (props: WrappedFieldProps) => + async (_: any, { data }: TreeItem) => { + const location = await this.props.getFileOperationLocation(data); + props.input.onChange(location || ''); + } + + render() { + return
+
+ + {this.props.meta.dirty && this.props.meta.error && + + {this.props.meta.error} + } +
+
; + } + }); diff --git a/src/views-components/search-bar/search-bar-view.tsx b/src/views-components/search-bar/search-bar-view.tsx index 28408347..eba281c9 100644 --- a/src/views-components/search-bar/search-bar-view.tsx +++ b/src/views-components/search-bar/search-bar-view.tsx @@ -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 = (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) => 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 { - debouncedSearch = debounce(() => { this.props.onSearch(this.props.searchValue); }, 1000); @@ -154,12 +144,12 @@ export const SearchBarView = compose(connectVocabulary, withStyles(styles))( handleChange = (event: React.ChangeEvent) => { this.debouncedSearch(); this.props.onChange(event); - } + }; handleSubmit = (event: React.FormEvent) => { 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 && } - {isPopoverOpen && - } - - -
+ + handleKeyDown(e, props)} startAdornment={ - + @@ -197,57 +187,69 @@ export const SearchBarView = compose(connectVocabulary, withStyles(styles))( } endAdornment={ - + handleDropdownClick(e, props)}> - } /> + } + /> -
- {isPopoverOpen && getView({ ...props })} -
-
+
{isPopoverOpen && getView({ ...props })}
+
); } - }); + } +); const getView = (props: SearchBarViewProps) => { switch (props.currentView) { case SearchView.AUTOCOMPLETE: - return ; + return ( + + ); case SearchView.ADVANCED: - return ; + return ( + + ); default: - return ; + return ( + + ); } }; -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) => -
); + zIndex: theme.zIndex.modal, + }, +}))(({ classes, ...props }: WithStyles<"backdrop"> & React.HTMLProps) => ( +
+)); diff --git a/src/views-components/sharing-dialog/participant-select.tsx b/src/views-components/sharing-dialog/participant-select.tsx index 02cdeaf2..058d7234 100644 --- a/src/views-components/sharing-dialog/participant-select.tsx +++ b/src/views-components/sharing-dialog/participant-select.tsx @@ -74,7 +74,7 @@ export const ParticipantSelect = connect()( }; render() { - const { label = 'Share' } = this.props; + const { label = 'Add people and groups' } = this.props; return ( + 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; diff --git a/src/views-components/sharing-dialog/sharing-dialog-component.test.tsx b/src/views-components/sharing-dialog/sharing-dialog-component.test.tsx index 36447a8d..2fc4d01a 100644 --- a/src/views-components/sharing-dialog/sharing-dialog-component.test.tsx +++ b/src/views-components/sharing-dialog/sharing-dialog-component.test.tsx @@ -27,6 +27,11 @@ describe("", () => { config: { keepWebServiceUrl: 'http://example.com/', keepWebInlineServiceUrl: 'http://*.collections.example.com/', + clusterConfig: { + Users: { + AnonymousUserToken: "" + } + } } } store = createStore(combineReducers({ @@ -68,4 +73,4 @@ describe("", () => { let wrapper = mount(); expect(wrapper.html()).not.toContain('Sharing URLs'); }); -}); \ No newline at end of file +}); diff --git a/src/views-components/sharing-dialog/sharing-dialog-component.tsx b/src/views-components/sharing-dialog/sharing-dialog-component.tsx index b2f31397..f83cec60 100644 --- a/src/views-components/sharing-dialog/sharing-dialog-component.tsx +++ b/src/views-components/sharing-dialog/sharing-dialog-component.tsx @@ -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) => { Sharing settings - { showTabs && - { - if (tb === SharingDialogTab.PERMISSIONS) { - refreshPermissions(); + {showTabs && + { + if (tb === SharingDialogTab.PERMISSIONS) { + refreshPermissions(); + } + setTabNr(tb) } - setTabNr(tb)} - }> - - 0 ? '('+sharingURLsNr+')' : ''}`} disabled={saveEnabled} /> - + }> + + 0 ? '(' + sharingURLsNr + ')' : ''}`} disabled={saveEnabled} /> + } - { tabNr === SharingDialogTab.PERMISSIONS && - - - - - - + {tabNr === SharingDialogTab.PERMISSIONS && + + + + + + + + + + - } - { tabNr === SharingDialogTab.URLS && - + {tabNr === SharingDialogTab.URLS && + } - { tabNr === SharingDialogTab.PERMISSIONS && - - - - } - { tabNr === SharingDialogTab.URLS && withExpiration && <> - - - - {({ date, handleChange }) => (<> - - - - - {}} - onSecondsChange={() => {}} - onHourChange={handleChange} - /> - - )} - - - - - - Maximum expiration date may be limited by the cluster configuration. - - + {tabNr === SharingDialogTab.URLS && withExpiration && <> + + + + {({ date, handleChange }) => (<> + + + + + { }} + onSecondsChange={() => { }} + onHourChange={handleChange} + /> + + )} + + + + + + Maximum expiration date may be limited by the cluster configuration. + + } - { tabNr === SharingDialogTab.PERMISSIONS && !sharingURLsDisabled && + {tabNr === SharingDialogTab.PERMISSIONS && !sharingURLsDisabled && privateAccess && sharingURLsNr > 0 && - - - Although there aren't specific permissions set, this is publicly accessible via Sharing URL(s). - - + + + Although there aren't specific permissions set, this is publicly accessible via Sharing URL(s). + + } - { tabNr === SharingDialogTab.URLS && <> - setWithExpiration(e.target.checked)} />} - label="With expiration" /> - - - - + {tabNr === SharingDialogTab.URLS && <> + setWithExpiration(e.target.checked)} />} + label="With expiration" /> + + + + } - { tabNr === SharingDialogTab.PERMISSIONS && - - - - }
diff --git a/src/views/run-process-panel/inputs/project-input.tsx b/src/views/run-process-panel/inputs/project-input.tsx index 688af4aa..438bbe8e 100644 --- a/src/views/run-process-panel/inputs/project-input.tsx +++ b/src/views/run-process-panel/inputs/project-input.tsx @@ -99,7 +99,7 @@ export const ProjectInputComponent = connect(mapStateToProps)( } } - invalid = () => (!this.state.project || this.state.project.writableBy.indexOf(this.props.userUuid) === -1); + invalid = () => (!this.state.project || !this.state.project.canWrite); renderInput() { return
diff --git a/src/views/run-process-panel/run-process-inputs-form.tsx b/src/views/run-process-panel/run-process-inputs-form.tsx index 46ab3c52..ca402ab0 100644 --- a/src/views/run-process-panel/run-process-inputs-form.tsx +++ b/src/views/run-process-panel/run-process-inputs-form.tsx @@ -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 ; - case typeof input.type === 'object' && - !(input.type instanceof Array) && - input.type.type === 'enum': + case getEnumType(input) !== null: return ; case isArrayOfType(input, CWLType.STRING): diff --git a/src/views/search-results-panel/search-results-panel.tsx b/src/views/search-results-panel/search-results-panel.tsx index 0902f15b..320e85cb 100644 --- a/src/views/search-results-panel/search-results-panel.tsx +++ b/src/views/search-results-panel/search-results-panel.tsx @@ -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(toggleOne(resourceUuid)) dispatch(loadDetailsPanel(resourceUuid)); }, onItemDoubleClick: uuid => { diff --git a/src/views/shared-with-me-panel/shared-with-me-panel.tsx b/src/views/shared-with-me-panel/shared-with-me-panel.tsx index e6cfccd2..f3f827d1 100644 --- a/src/views/shared-with-me-panel/shared-with-me-panel.tsx +++ b/src/views/shared-with-me-panel/shared-with-me-panel.tsx @@ -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 = (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 = [ + { + name: SharedWithMePanelColumnNames.NAME, + selected: true, + configurable: true, + sort: { direction: SortDirection.NONE, field: 'name' }, + filters: createTree(), + render: (uuid) => , + }, + { + name: SharedWithMePanelColumnNames.STATUS, + selected: true, + configurable: true, + mutuallyExclusiveFilters: true, + filters: getInitialProcessStatusFilters(), + render: (uuid) => , + }, + { + name: SharedWithMePanelColumnNames.TYPE, + selected: true, + configurable: true, + filters: getInitialResourceTypeFilters(), + render: (uuid) => , + }, + { + name: SharedWithMePanelColumnNames.OWNER, + selected: true, + configurable: true, + filters: createTree(), + render: (uuid) => , + }, + { + name: SharedWithMePanelColumnNames.PORTABLE_DATA_HASH, + selected: false, + configurable: true, + filters: createTree(), + render: (uuid) => , + }, + { + name: SharedWithMePanelColumnNames.FILE_SIZE, + selected: true, + configurable: true, + filters: createTree(), + render: (uuid) => , + }, + { + name: SharedWithMePanelColumnNames.FILE_COUNT, + selected: false, + configurable: true, + filters: createTree(), + render: (uuid) => , + }, + { + name: SharedWithMePanelColumnNames.UUID, + selected: false, + configurable: true, + filters: createTree(), + render: (uuid) => , + }, + { + name: SharedWithMePanelColumnNames.CONTAINER_UUID, + selected: false, + configurable: true, + filters: createTree(), + render: (uuid) => , + }, + { + name: SharedWithMePanelColumnNames.RUNTIME, + selected: false, + configurable: true, + filters: createTree(), + render: (uuid) => , + }, + { + name: SharedWithMePanelColumnNames.OUTPUT_UUID, + selected: false, + configurable: true, + filters: createTree(), + render: (uuid) => , + }, + { + name: SharedWithMePanelColumnNames.LOG_UUID, + selected: false, + configurable: true, + filters: createTree(), + render: (uuid) => , + }, + { + name: SharedWithMePanelColumnNames.PARENT_PROCESS, + selected: false, + configurable: true, + filters: createTree(), + render: (uuid) => , + }, + { + name: SharedWithMePanelColumnNames.MODIFIED_BY_USER_UUID, + selected: false, + configurable: true, + filters: createTree(), + render: (uuid) => , + }, + { + name: SharedWithMePanelColumnNames.VERSION, + selected: false, + configurable: true, + filters: createTree(), + render: (uuid) => , + }, + { + name: SharedWithMePanelColumnNames.CREATED_AT, + selected: false, + configurable: true, + sort: { direction: SortDirection.NONE, field: 'createdAt' }, + filters: createTree(), + render: (uuid) => , + }, + { + name: SharedWithMePanelColumnNames.LAST_MODIFIED, + selected: true, + configurable: true, + sort: { direction: SortDirection.DESC, field: 'modifiedAt' }, + filters: createTree(), + render: (uuid) => , + }, + { + name: SharedWithMePanelColumnNames.TRASH_AT, + selected: false, + configurable: true, + sort: { direction: SortDirection.NONE, field: 'trashAt' }, + filters: createTree(), + render: (uuid) => , + }, + { + name: SharedWithMePanelColumnNames.DELETE_AT, + selected: false, + configurable: true, + sort: { direction: SortDirection.NONE, field: 'deleteAt' }, + filters: createTree(), + render: (uuid) => , + }, +]; + + interface SharedWithMePanelDataProps { resources: ResourcesState; userUuid: string; @@ -82,6 +281,7 @@ export const SharedWithMePanel = withStyles(styles)( } handleRowClick = (uuid: string) => { + this.props.dispatch(toggleOne(uuid)) this.props.dispatch(loadDetailsPanel(uuid)); } } diff --git a/src/views/ssh-key-panel/ssh-key-panel-root.tsx b/src/views/ssh-key-panel/ssh-key-panel-root.tsx index 99ad1bff..8a266d00 100644 --- a/src/views/ssh-key-panel/ssh-key-panel-root.tsx +++ b/src/views/ssh-key-panel/ssh-key-panel-root.tsx @@ -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)( openRowOptions(event, sshKey)}> - + @@ -113,4 +113,4 @@ export const SshKeyPanelRoot = withStyles(styles)( - ); \ No newline at end of file + ); diff --git a/src/views/subprocess-panel/subprocess-panel-root.tsx b/src/views/subprocess-panel/subprocess-panel-root.tsx index 9cf1db77..65c723f6 100644 --- a/src/views/subprocess-panel/subprocess-panel-root.tsx +++ b/src/views/subprocess-panel/subprocess-panel-root.tsx @@ -20,6 +20,8 @@ import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view'; import { StyleRulesCallback, Typography, WithStyles, withStyles } from '@material-ui/core'; import { ArvadosTheme } from 'common/custom-theme'; import { ProcessResource } from 'models/process'; +import { SubprocessProgressBar } from 'components/subprocess-progress-bar/subprocess-progress-bar'; +import { Process } from 'store/processes/process'; type CssRules = 'iconHeader' | 'cardHeader'; @@ -80,11 +82,12 @@ export const subprocessPanelColumns: DataColumns = [ ]; export interface SubprocessPanelDataProps { + process: Process; resources: ResourcesState; } export interface SubprocessPanelActionProps { - onItemClick: (item: string) => void; + onRowClick: (item: string) => void; onContextMenu: (event: React.MouseEvent, item: string, resources: ResourcesState) => void; onItemDoubleClick: (item: string) => void; } @@ -111,7 +114,7 @@ const SubProcessesTitle = withStyles(styles)( export const SubprocessPanelRoot = (props: SubprocessPanelProps & MPVPanelProps) => { return props.onContextMenu(event, item, props.resources)} contextMenuColumn={true} @@ -122,5 +125,6 @@ export const SubprocessPanelRoot = (props: SubprocessPanelProps & MPVPanelProps) doUnMaximizePanel={props.doUnMaximizePanel} panelMaximized={props.panelMaximized} panelName={props.panelName} - title={} />; + title={} + progressBar={} />; }; diff --git a/src/views/subprocess-panel/subprocess-panel.tsx b/src/views/subprocess-panel/subprocess-panel.tsx index c46a1c52..684e1fd2 100644 --- a/src/views/subprocess-panel/subprocess-panel.tsx +++ b/src/views/subprocess-panel/subprocess-panel.tsx @@ -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(openProcessContextMenu(event, process)); } }, - onItemClick: (uuid: string) => { + onRowClick: (uuid: string) => { + dispatch(toggleOne(uuid)) dispatch(loadDetailsPanel(uuid)); }, onItemDoubleClick: uuid => { dispatch(navigateTo(uuid)); - } + }, }); -const mapStateToProps = (state: RootState): SubprocessPanelDataProps => ({ - resources: state.resources +const mapStateToProps = (state: RootState): Omit => ({ + resources: state.resources, }); -export const SubprocessPanel = connect(mapStateToProps, mapDispatchToProps)(SubprocessPanelRoot); \ No newline at end of file +export const SubprocessPanel = connect(mapStateToProps, mapDispatchToProps)(SubprocessPanelRoot); diff --git a/src/views/trash-panel/trash-panel.tsx b/src/views/trash-panel/trash-panel.tsx index 35020751..2a96ffe0 100644 --- a/src/views/trash-panel/trash-panel.tsx +++ b/src/views/trash-panel/trash-panel.tsx @@ -35,6 +35,7 @@ import { getTrashPanelTypeFilters } from 'store/resource-type-filters/resource-type-filters'; import { CollectionResource } from 'models/collection'; +import { toggleOne } from 'store/multiselect/multiselect-actions'; type CssRules = "toolbar" | "button" | "root"; @@ -178,6 +179,7 @@ export const TrashPanel = withStyles(styles)( } handleRowClick = (uuid: string) => { + this.props.dispatch(toggleOne(uuid)) this.props.dispatch(loadDetailsPanel(uuid)); } } diff --git a/src/views/user-profile-panel/user-profile-panel-root.tsx b/src/views/user-profile-panel/user-profile-panel-root.tsx index 6a556516..4a208371 100644 --- a/src/views/user-profile-panel/user-profile-panel-root.tsx +++ b/src/views/user-profile-panel/user-profile-panel-root.tsx @@ -27,7 +27,7 @@ import { ArvadosTheme } from 'common/custom-theme'; import { PROFILE_EMAIL_VALIDATION, PROFILE_URL_VALIDATION } from "validators/validators"; import { USER_PROFILE_PANEL_ID } from 'store/user-profile/user-profile-actions'; import { noop } from 'lodash'; -import { DetailsIcon, GroupsIcon, MoreOptionsIcon } from 'components/icon/icon'; +import { DetailsIcon, GroupsIcon, MoreVerticalIcon } from 'components/icon/icon'; import { DataColumns } from 'components/data-table/data-table'; import { ResourceLinkHeadUuid, ResourceLinkHeadPermissionLevel, ResourceLinkHead, ResourceLinkDelete, ResourceLinkTailIsVisible, UserResourceAccountStatus } from 'views-components/data-explorer/renderers'; import { createTree } from 'models/tree'; @@ -36,7 +36,7 @@ import { DefaultView } from 'components/default-view/default-view'; import { CopyToClipboardSnackbar } from 'components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar'; import { PermissionResource } from 'models/permission'; -type CssRules = 'root' | 'emptyRoot' | 'gridItem' | 'label' | 'readOnlyValue' | 'title' | 'description' | 'actions' | 'content' | 'copyIcon'; +type CssRules = 'root' | 'emptyRoot' | 'gridItem' | 'label' | 'readOnlyValue' | 'title' | 'description' | 'actions' | 'content' | 'copyIcon' | 'userProfileFormMessage'; const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ root: { @@ -81,6 +81,9 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ '& svg': { fontSize: '1rem' } + }, + userProfileFormMessage: { + fontSize: '1.1rem', } }); @@ -97,6 +100,7 @@ export interface UserProfilePanelRootDataProps { userUuid: string; resources: ResourcesState; localCluster: string; + userProfileFormMessage: string; } const RoleTypes = [ @@ -165,7 +169,7 @@ export const userProfileGroupsColumns: DataColumns = ]; const ReadOnlyField = withStyles(styles)( - (props: ({ label: string, input: {value: string} }) & WithStyles ) => ( + (props: ({ label: string, input: { value: string } }) & WithStyles) => ( {props.label} @@ -184,7 +188,7 @@ export const UserProfilePanelRoot = withStyles(styles)( }; componentDidMount() { - this.setState({ value: TABS.PROFILE}); + this.setState({ value: TABS.PROFILE }); } render() { @@ -213,14 +217,14 @@ export const UserProfilePanelRoot = withStyles(styles)( - + this.handleContextMenu(event, this.props.userUuid)}> - + @@ -261,6 +265,9 @@ export const UserProfilePanelRoot = withStyles(styles)( disabled /> + + {this.props.userProfileFormMessage} + + 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.']} />
} ; } diff --git a/src/views/user-profile-panel/user-profile-panel.tsx b/src/views/user-profile-panel/user-profile-panel.tsx index a90d44a9..040cbc6f 100644 --- a/src/views/user-profile-panel/user-profile-panel.tsx +++ b/src/views/user-profile-panel/user-profile-panel.tsx @@ -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(openUserContextMenu(event, resource)), diff --git a/src/views/virtual-machine-panel/virtual-machine-admin-panel.tsx b/src/views/virtual-machine-panel/virtual-machine-admin-panel.tsx index 864218e4..20665f17 100644 --- a/src/views/virtual-machine-panel/virtual-machine-admin-panel.tsx +++ b/src/views/virtual-machine-panel/virtual-machine-admin-panel.tsx @@ -11,7 +11,7 @@ import { compose, Dispatch } from 'redux'; import { loadVirtualMachinesAdminData, openAddVirtualMachineLoginDialog, openRemoveVirtualMachineLoginDialog, openEditVirtualMachineLoginDialog } from 'store/virtual-machines/virtual-machines-actions'; import { RootState } from 'store/store'; import { ListResults } from 'services/common-service/common-service'; -import { MoreOptionsIcon, AddUserIcon } from 'components/icon/icon'; +import { MoreVerticalIcon, AddUserIcon } from 'components/icon/icon'; import { VirtualMachineLogins, VirtualMachinesResource } from 'models/virtual-machines'; import { openVirtualMachinesContextMenu } from 'store/context-menu/context-menu-actions'; import { ResourceUuid, VirtualMachineHostname, VirtualMachineLogin } from 'views-components/data-explorer/renderers'; @@ -139,7 +139,7 @@ const virtualMachinesTable = (props: VirtualMachineProps) => props.onOptionsMenuOpen(event, machine)} className={props.classes.moreOptionsButton}> - + diff --git a/src/views/virtual-machine-panel/virtual-machine-user-panel.tsx b/src/views/virtual-machine-panel/virtual-machine-user-panel.tsx index 751ca5f1..56c92805 100644 --- a/src/views/virtual-machine-panel/virtual-machine-user-panel.tsx +++ b/src/views/virtual-machine-panel/virtual-machine-user-panel.tsx @@ -18,6 +18,7 @@ import parse from "parse-duration"; import { CopyIcon } from 'components/icon/icon'; import CopyToClipboard from 'react-copy-to-clipboard'; import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; +import { sanitizeHTML } from 'common/html-sanitize'; type CssRules = 'button' | 'codeSnippet' | 'link' | 'linkIcon' | 'rightAlign' | 'cardWithoutMachines' | 'icon' | 'chipsRoot' | 'copyIcon' | 'tableWrapper' | 'webshellButton'; @@ -269,7 +270,7 @@ const CardSSHSection = (props: VirtualMachineProps) => -
+
diff --git a/src/views/workbench/workbench.tsx b/src/views/workbench/workbench.tsx index 7103efd1..bc2396f7 100644 --- a/src/views/workbench/workbench.tsx +++ b/src/views/workbench/workbench.tsx @@ -2,132 +2,137 @@ // // SPDX-License-Identifier: AGPL-3.0 -import React from 'react'; -import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles'; +import React from "react"; +import { StyleRulesCallback, WithStyles, withStyles } from "@material-ui/core/styles"; import { Route, Switch } from "react-router"; import { ProjectPanel } from "views/project-panel/project-panel"; -import { DetailsPanel } from 'views-components/details-panel/details-panel'; -import { ArvadosTheme } from 'common/custom-theme'; +import { DetailsPanel } from "views-components/details-panel/details-panel"; +import { ArvadosTheme } from "common/custom-theme"; import { ContextMenu } from "views-components/context-menu/context-menu"; import { FavoritePanel } from "../favorite-panel/favorite-panel"; -import { TokenDialog } from 'views-components/token-dialog/token-dialog'; -import { RichTextEditorDialog } from 'views-components/rich-text-editor-dialog/rich-text-editor-dialog'; -import { Snackbar } from 'views-components/snackbar/snackbar'; -import { CollectionPanel } from '../collection-panel/collection-panel'; -import { RenameFileDialog } from 'views-components/rename-file-dialog/rename-file-dialog'; -import { FileRemoveDialog } from 'views-components/file-remove-dialog/file-remove-dialog'; -import { MultipleFilesRemoveDialog } from 'views-components/file-remove-dialog/multiple-files-remove-dialog'; -import { Routes } from 'routes/routes'; -import { SidePanel } from 'views-components/side-panel/side-panel'; -import { ProcessPanel } from 'views/process-panel/process-panel'; -import { ChangeWorkflowDialog } from 'views-components/run-process-dialog/change-workflow-dialog'; -import { CreateProjectDialog } from 'views-components/dialog-forms/create-project-dialog'; -import { CreateCollectionDialog } from 'views-components/dialog-forms/create-collection-dialog'; -import { CopyCollectionDialog } from 'views-components/dialog-forms/copy-collection-dialog'; -import { CopyProcessDialog } from 'views-components/dialog-forms/copy-process-dialog'; -import { UpdateCollectionDialog } from 'views-components/dialog-forms/update-collection-dialog'; -import { UpdateProcessDialog } from 'views-components/dialog-forms/update-process-dialog'; -import { UpdateProjectDialog } from 'views-components/dialog-forms/update-project-dialog'; -import { MoveProcessDialog } from 'views-components/dialog-forms/move-process-dialog'; -import { MoveProjectDialog } from 'views-components/dialog-forms/move-project-dialog'; -import { MoveCollectionDialog } from 'views-components/dialog-forms/move-collection-dialog'; -import { FilesUploadCollectionDialog } from 'views-components/dialog-forms/files-upload-collection-dialog'; -import { PartialCopyCollectionDialog } from 'views-components/dialog-forms/partial-copy-collection-dialog'; -import { RemoveProcessDialog } from 'views-components/process-remove-dialog/process-remove-dialog'; -import { MainContentBar } from 'views-components/main-content-bar/main-content-bar'; -import { Grid } from '@material-ui/core'; +import { TokenDialog } from "views-components/token-dialog/token-dialog"; +import { RichTextEditorDialog } from "views-components/rich-text-editor-dialog/rich-text-editor-dialog"; +import { Snackbar } from "views-components/snackbar/snackbar"; +import { CollectionPanel } from "../collection-panel/collection-panel"; +import { RenameFileDialog } from "views-components/rename-file-dialog/rename-file-dialog"; +import { FileRemoveDialog } from "views-components/file-remove-dialog/file-remove-dialog"; +import { MultipleFilesRemoveDialog } from "views-components/file-remove-dialog/multiple-files-remove-dialog"; +import { Routes } from "routes/routes"; +import { SidePanel } from "views-components/side-panel/side-panel"; +import { ProcessPanel } from "views/process-panel/process-panel"; +import { ChangeWorkflowDialog } from "views-components/run-process-dialog/change-workflow-dialog"; +import { CreateProjectDialog } from "views-components/dialog-forms/create-project-dialog"; +import { CreateCollectionDialog } from "views-components/dialog-forms/create-collection-dialog"; +import { CopyCollectionDialog, CopyMultiCollectionDialog } from "views-components/dialog-forms/copy-collection-dialog"; +import { CopyProcessDialog } from "views-components/dialog-forms/copy-process-dialog"; +import { UpdateCollectionDialog } from "views-components/dialog-forms/update-collection-dialog"; +import { UpdateProcessDialog } from "views-components/dialog-forms/update-process-dialog"; +import { UpdateProjectDialog } from "views-components/dialog-forms/update-project-dialog"; +import { MoveProcessDialog } from "views-components/dialog-forms/move-process-dialog"; +import { MoveProjectDialog } from "views-components/dialog-forms/move-project-dialog"; +import { MoveCollectionDialog } from "views-components/dialog-forms/move-collection-dialog"; +import { FilesUploadCollectionDialog } from "views-components/dialog-forms/files-upload-collection-dialog"; +import { PartialCopyToNewCollectionDialog } from "views-components/dialog-forms/partial-copy-to-new-collection-dialog"; +import { PartialCopyToExistingCollectionDialog } from "views-components/dialog-forms/partial-copy-to-existing-collection-dialog"; +import { PartialCopyToSeparateCollectionsDialog } from "views-components/dialog-forms/partial-copy-to-separate-collections-dialog"; +import { PartialMoveToNewCollectionDialog } from "views-components/dialog-forms/partial-move-to-new-collection-dialog"; +import { PartialMoveToExistingCollectionDialog } from "views-components/dialog-forms/partial-move-to-existing-collection-dialog"; +import { PartialMoveToSeparateCollectionsDialog } from "views-components/dialog-forms/partial-move-to-separate-collections-dialog"; +import { RemoveProcessDialog } from "views-components/process-remove-dialog/process-remove-dialog"; +import { MainContentBar } from "views-components/main-content-bar/main-content-bar"; +import { Grid } from "@material-ui/core"; import { TrashPanel } from "views/trash-panel/trash-panel"; -import { SharedWithMePanel } from 'views/shared-with-me-panel/shared-with-me-panel'; -import { RunProcessPanel } from 'views/run-process-panel/run-process-panel'; -import SplitterLayout from 'react-splitter-layout'; -import { WorkflowPanel } from 'views/workflow-panel/workflow-panel'; -import { SearchResultsPanel } from 'views/search-results-panel/search-results-panel'; -import { SshKeyPanel } from 'views/ssh-key-panel/ssh-key-panel'; -import { SshKeyAdminPanel } from 'views/ssh-key-panel/ssh-key-admin-panel'; +import { SharedWithMePanel } from "views/shared-with-me-panel/shared-with-me-panel"; +import { RunProcessPanel } from "views/run-process-panel/run-process-panel"; +import SplitterLayout from "react-splitter-layout"; +import { WorkflowPanel } from "views/workflow-panel/workflow-panel"; +import { RegisteredWorkflowPanel } from "views/workflow-panel/registered-workflow-panel"; +import { SearchResultsPanel } from "views/search-results-panel/search-results-panel"; +import { SshKeyPanel } from "views/ssh-key-panel/ssh-key-panel"; +import { SshKeyAdminPanel } from "views/ssh-key-panel/ssh-key-admin-panel"; import { SiteManagerPanel } from "views/site-manager-panel/site-manager-panel"; -import { UserProfilePanel } from 'views/user-profile-panel/user-profile-panel'; -import { SharingDialog } from 'views-components/sharing-dialog/sharing-dialog'; -import { NotFoundDialog } from 'views-components/not-found-dialog/not-found-dialog'; -import { AdvancedTabDialog } from 'views-components/advanced-tab-dialog/advanced-tab-dialog'; -import { ProcessInputDialog } from 'views-components/process-input-dialog/process-input-dialog'; -import { VirtualMachineUserPanel } from 'views/virtual-machine-panel/virtual-machine-user-panel'; -import { VirtualMachineAdminPanel } from 'views/virtual-machine-panel/virtual-machine-admin-panel'; -import { RepositoriesPanel } from 'views/repositories-panel/repositories-panel'; -import { KeepServicePanel } from 'views/keep-service-panel/keep-service-panel'; -import { ApiClientAuthorizationPanel } from 'views/api-client-authorization-panel/api-client-authorization-panel'; -import { LinkPanel } from 'views/link-panel/link-panel'; -import { RepositoriesSampleGitDialog } from 'views-components/repositories-sample-git-dialog/repositories-sample-git-dialog'; -import { RepositoryAttributesDialog } from 'views-components/repository-attributes-dialog/repository-attributes-dialog'; -import { CreateRepositoryDialog } from 'views-components/dialog-forms/create-repository-dialog'; -import { RemoveRepositoryDialog } from 'views-components/repository-remove-dialog/repository-remove-dialog'; -import { CreateSshKeyDialog } from 'views-components/dialog-forms/create-ssh-key-dialog'; -import { PublicKeyDialog } from 'views-components/ssh-keys-dialog/public-key-dialog'; -import { RemoveApiClientAuthorizationDialog } from 'views-components/api-client-authorizations-dialog/remove-dialog'; -import { RemoveKeepServiceDialog } from 'views-components/keep-services-dialog/remove-dialog'; -import { RemoveLinkDialog } from 'views-components/links-dialog/remove-dialog'; -import { RemoveSshKeyDialog } from 'views-components/ssh-keys-dialog/remove-dialog'; -import { VirtualMachineAttributesDialog } from 'views-components/virtual-machines-dialog/attributes-dialog'; -import { RemoveVirtualMachineDialog } from 'views-components/virtual-machines-dialog/remove-dialog'; -import { RemoveVirtualMachineLoginDialog } from 'views-components/virtual-machines-dialog/remove-login-dialog'; -import { VirtualMachineAddLoginDialog } from 'views-components/virtual-machines-dialog/add-login-dialog'; -import { AttributesApiClientAuthorizationDialog } from 'views-components/api-client-authorizations-dialog/attributes-dialog'; -import { AttributesKeepServiceDialog } from 'views-components/keep-services-dialog/attributes-dialog'; -import { AttributesLinkDialog } from 'views-components/links-dialog/attributes-dialog'; -import { AttributesSshKeyDialog } from 'views-components/ssh-keys-dialog/attributes-dialog'; -import { UserPanel } from 'views/user-panel/user-panel'; -import { UserAttributesDialog } from 'views-components/user-dialog/attributes-dialog'; -import { CreateUserDialog } from 'views-components/dialog-forms/create-user-dialog'; -import { HelpApiClientAuthorizationDialog } from 'views-components/api-client-authorizations-dialog/help-dialog'; -import { DeactivateDialog } from 'views-components/user-dialog/deactivate-dialog'; -import { ActivateDialog } from 'views-components/user-dialog/activate-dialog'; -import { SetupDialog } from 'views-components/user-dialog/setup-dialog'; -import { GroupsPanel } from 'views/groups-panel/groups-panel'; -import { RemoveGroupDialog } from 'views-components/groups-dialog/remove-dialog'; -import { GroupAttributesDialog } from 'views-components/groups-dialog/attributes-dialog'; -import { GroupDetailsPanel } from 'views/group-details-panel/group-details-panel'; -import { RemoveGroupMemberDialog } from 'views-components/groups-dialog/member-remove-dialog'; -import { GroupMemberAttributesDialog } from 'views-components/groups-dialog/member-attributes-dialog'; -import { PartialCopyToCollectionDialog } from 'views-components/dialog-forms/partial-copy-to-collection-dialog'; -import { PublicFavoritePanel } from 'views/public-favorites-panel/public-favorites-panel'; -import { LinkAccountPanel } from 'views/link-account-panel/link-account-panel'; -import { FedLogin } from './fed-login'; -import { CollectionsContentAddressPanel } from 'views/collection-content-address-panel/collection-content-address-panel'; -import { AllProcessesPanel } from '../all-processes-panel/all-processes-panel'; -import { NotFoundPanel } from '../not-found-panel/not-found-panel'; -import { AutoLogout } from 'views-components/auto-logout/auto-logout'; -import { RestoreCollectionVersionDialog } from 'views-components/collections-dialog/restore-version-dialog'; -import { WebDavS3InfoDialog } from 'views-components/webdav-s3-dialog/webdav-s3-dialog'; -import { pluginConfig } from 'plugins'; -import { ElementListReducer } from 'common/plugintypes'; -import { COLLAPSE_ICON_SIZE } from 'views-components/side-panel-toggle/side-panel-toggle' -import { Banner } from 'views-components/baner/banner'; +import { UserProfilePanel } from "views/user-profile-panel/user-profile-panel"; +import { SharingDialog } from "views-components/sharing-dialog/sharing-dialog"; +import { NotFoundDialog } from "views-components/not-found-dialog/not-found-dialog"; +import { AdvancedTabDialog } from "views-components/advanced-tab-dialog/advanced-tab-dialog"; +import { ProcessInputDialog } from "views-components/process-input-dialog/process-input-dialog"; +import { VirtualMachineUserPanel } from "views/virtual-machine-panel/virtual-machine-user-panel"; +import { VirtualMachineAdminPanel } from "views/virtual-machine-panel/virtual-machine-admin-panel"; +import { RepositoriesPanel } from "views/repositories-panel/repositories-panel"; +import { KeepServicePanel } from "views/keep-service-panel/keep-service-panel"; +import { ApiClientAuthorizationPanel } from "views/api-client-authorization-panel/api-client-authorization-panel"; +import { LinkPanel } from "views/link-panel/link-panel"; +import { RepositoriesSampleGitDialog } from "views-components/repositories-sample-git-dialog/repositories-sample-git-dialog"; +import { RepositoryAttributesDialog } from "views-components/repository-attributes-dialog/repository-attributes-dialog"; +import { CreateRepositoryDialog } from "views-components/dialog-forms/create-repository-dialog"; +import { RemoveRepositoryDialog } from "views-components/repository-remove-dialog/repository-remove-dialog"; +import { CreateSshKeyDialog } from "views-components/dialog-forms/create-ssh-key-dialog"; +import { PublicKeyDialog } from "views-components/ssh-keys-dialog/public-key-dialog"; +import { RemoveApiClientAuthorizationDialog } from "views-components/api-client-authorizations-dialog/remove-dialog"; +import { RemoveKeepServiceDialog } from "views-components/keep-services-dialog/remove-dialog"; +import { RemoveLinkDialog } from "views-components/links-dialog/remove-dialog"; +import { RemoveSshKeyDialog } from "views-components/ssh-keys-dialog/remove-dialog"; +import { VirtualMachineAttributesDialog } from "views-components/virtual-machines-dialog/attributes-dialog"; +import { RemoveVirtualMachineDialog } from "views-components/virtual-machines-dialog/remove-dialog"; +import { RemoveVirtualMachineLoginDialog } from "views-components/virtual-machines-dialog/remove-login-dialog"; +import { VirtualMachineAddLoginDialog } from "views-components/virtual-machines-dialog/add-login-dialog"; +import { AttributesApiClientAuthorizationDialog } from "views-components/api-client-authorizations-dialog/attributes-dialog"; +import { AttributesKeepServiceDialog } from "views-components/keep-services-dialog/attributes-dialog"; +import { AttributesLinkDialog } from "views-components/links-dialog/attributes-dialog"; +import { AttributesSshKeyDialog } from "views-components/ssh-keys-dialog/attributes-dialog"; +import { UserPanel } from "views/user-panel/user-panel"; +import { UserAttributesDialog } from "views-components/user-dialog/attributes-dialog"; +import { CreateUserDialog } from "views-components/dialog-forms/create-user-dialog"; +import { HelpApiClientAuthorizationDialog } from "views-components/api-client-authorizations-dialog/help-dialog"; +import { DeactivateDialog } from "views-components/user-dialog/deactivate-dialog"; +import { ActivateDialog } from "views-components/user-dialog/activate-dialog"; +import { SetupDialog } from "views-components/user-dialog/setup-dialog"; +import { GroupsPanel } from "views/groups-panel/groups-panel"; +import { RemoveGroupDialog } from "views-components/groups-dialog/remove-dialog"; +import { GroupAttributesDialog } from "views-components/groups-dialog/attributes-dialog"; +import { GroupDetailsPanel } from "views/group-details-panel/group-details-panel"; +import { RemoveGroupMemberDialog } from "views-components/groups-dialog/member-remove-dialog"; +import { GroupMemberAttributesDialog } from "views-components/groups-dialog/member-attributes-dialog"; +import { PublicFavoritePanel } from "views/public-favorites-panel/public-favorites-panel"; +import { LinkAccountPanel } from "views/link-account-panel/link-account-panel"; +import { FedLogin } from "./fed-login"; +import { CollectionsContentAddressPanel } from "views/collection-content-address-panel/collection-content-address-panel"; +import { AllProcessesPanel } from "../all-processes-panel/all-processes-panel"; +import { NotFoundPanel } from "../not-found-panel/not-found-panel"; +import { AutoLogout } from "views-components/auto-logout/auto-logout"; +import { RestoreCollectionVersionDialog } from "views-components/collections-dialog/restore-version-dialog"; +import { WebDavS3InfoDialog } from "views-components/webdav-s3-dialog/webdav-s3-dialog"; +import { pluginConfig } from "plugins"; +import { ElementListReducer } from "common/plugintypes"; +import { COLLAPSE_ICON_SIZE } from "views-components/side-panel-toggle/side-panel-toggle"; +import { Banner } from "views-components/baner/banner"; -type CssRules = 'root' | 'container' | 'splitter' | 'asidePanel' | 'contentWrapper' | 'content'; +type CssRules = "root" | "container" | "splitter" | "asidePanel" | "contentWrapper" | "content"; const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ root: { paddingTop: theme.spacing.unit * 7, - background: theme.palette.background.default + background: theme.palette.background.default, }, container: { - position: 'relative' + position: "relative", }, splitter: { - '& > .layout-splitter': { - width: '2px', + "& > .layout-splitter": { + width: "3px", + }, + "& > .layout-splitter-disabled": { + pointerEvents: "none", + cursor: "pointer", }, - '& > .layout-splitter-disabled': { - pointerEvents: 'none', - cursor: 'pointer' - } }, asidePanel: { paddingTop: theme.spacing.unit, - height: '100%' + height: "100%", }, contentWrapper: { paddingTop: theme.spacing.unit, - minWidth: 0 + minWidth: 0, }, content: { minWidth: 0, @@ -135,8 +140,8 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ paddingRight: theme.spacing.unit * 3, // Reserve vertical space for app bar + MainContentBar minHeight: `calc(100vh - ${theme.spacing.unit * 16}px)`, - display: 'flex', - } + display: "flex", + }, }); interface WorkbenchDataProps { @@ -151,84 +156,213 @@ type WorkbenchPanelProps = WithStyles & 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 = <> - - - - - - - - - - - - - - - - - - - - - - - - - - - -; +let routes = ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); -const reduceRoutesFn: (a: React.ReactElement[], - b: ElementListReducer) => React.ReactElement[] = (a, b) => b(a); +const reduceRoutesFn: (a: React.ReactElement[], b: ElementListReducer) => React.ReactElement[] = (a, b) => b(a); -routes = React.createElement(React.Fragment, null, pluginConfig.centerPanelList.reduce(reduceRoutesFn, React.Children.toArray(routes.props.children))); +routes = React.createElement( + React.Fragment, + null, + pluginConfig.centerPanelList.reduce(reduceRoutesFn, React.Children.toArray(routes.props.children)) +); -const applyCollapsedState = (isCollapsed) => { - const rightPanel: Element = document.getElementsByClassName('layout-pane')[1] - const totalWidth: number = document.getElementsByClassName('splitter-layout')[0]?.clientWidth - const rightPanelExpandedWidth = ((totalWidth-COLLAPSE_ICON_SIZE)) / (totalWidth/100) - if(rightPanel) { - rightPanel.setAttribute('style', `width: ${isCollapsed ? rightPanelExpandedWidth : getSplitterInitialSize()}%`) +const applyCollapsedState = isCollapsed => { + const rightPanel: Element = document.getElementsByClassName("layout-pane")[1]; + const totalWidth: number = document.getElementsByClassName("splitter-layout")[0]?.clientWidth; + const rightPanelExpandedWidth = (totalWidth - COLLAPSE_ICON_SIZE) / (totalWidth / 100); + if (rightPanel) { + rightPanel.setAttribute("style", `width: ${isCollapsed ? `calc(${rightPanelExpandedWidth}% - 1rem)` : `${getSplitterInitialSize()}%`}`); } - const splitter = document.getElementsByClassName('layout-splitter')[0] - isCollapsed ? splitter?.classList.add('layout-splitter-disabled') : splitter?.classList.remove('layout-splitter-disabled') - -} + const splitter = document.getElementsByClassName("layout-splitter")[0]; + isCollapsed ? splitter?.classList.add("layout-splitter-disabled") : splitter?.classList.remove("layout-splitter-disabled"); +}; -export const WorkbenchPanel = - withStyles(styles)((props: WorkbenchPanelProps) =>{ +export const WorkbenchPanel = withStyles(styles)((props: WorkbenchPanelProps) => { + //panel size will not scale automatically on window resize, so we do it manually + if (props && props.sidePanelIsCollapsed) window.addEventListener("resize", () => applyCollapsedState(props.sidePanelIsCollapsed)); + applyCollapsedState(props.sidePanelIsCollapsed); - //panel size will not scale automatically on window resize, so we do it manually - window.addEventListener('resize', ()=>applyCollapsedState(props.sidePanelIsCollapsed)) - applyCollapsedState(props.sidePanelIsCollapsed) - - return + return ( + {props.sessionIdleTimeout > 0 && } - - - {props.isUserActive && props.isNotLinking && - - } - - + + + {props.isUserActive && props.isNotLinking && ( + + + + )} + + {props.isNotLinking && } - + {routes.props.children} - + @@ -245,6 +379,7 @@ export const WorkbenchPanel = + @@ -262,8 +397,12 @@ export const WorkbenchPanel = - - + + + + + + @@ -296,5 +435,6 @@ export const WorkbenchPanel = {React.createElement(React.Fragment, null, pluginConfig.dialogs)} - } + ); +}); diff --git a/src/views/workflow-panel/registered-workflow-panel.tsx b/src/views/workflow-panel/registered-workflow-panel.tsx new file mode 100644 index 00000000..50192e54 --- /dev/null +++ b/src/views/workflow-panel/registered-workflow-panel.tsx @@ -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 = (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 + +export const RegisteredWorkflowPanel = withStyles(styles)(connect( + (state: RootState, props: RouteComponentProps<{ id: string }>) => { + const item = getResource(props.match.params.id)(state.resources); + if (item) { + return getRegisteredWorkflowPanelData(item, state.auth); + } + return { item, inputParams: [], outputParams: [], workflowCollection: "", gitprops: {} }; + })( + class extends React.Component { + render() { + const { classes, item, inputParams, outputParams, workflowCollection } = this.props; + const panelsData: MPVPanelState[] = [ + { name: "Details" }, + { name: "Inputs" }, + { name: "Outputs" }, + { name: "Files" }, + ]; + return item + ? + + + } + title={ + + + {item.name} + + + } + subheader={ + + + {item.description || '(no-description)'} + + } + action={ + + this.handleContextMenu(event)}> + + + } + + /> + + + + + + + + + + + + + + + + + + + : + + } + + handleContextMenu = (event: React.MouseEvent) => { + const { uuid, ownerUuid, name, description, + kind } = this.props.item; + const menuKind = this.props.dispatch(resourceUuidToContextMenuKind(uuid)); + const resource = { + uuid, + ownerUuid, + name, + description, + kind, + menuKind, + }; + // Avoid expanding/collapsing the panel + event.stopPropagation(); + this.props.dispatch(openContextMenu(event, resource)); + } + } + ) +); diff --git a/src/websocket/websocket.ts b/src/websocket/websocket.ts index 7c8e0171..1b74b11f 100644 --- a/src/websocket/websocket.ts +++ b/src/websocket/websocket.ts @@ -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 }>)); } }; diff --git a/tools/run-integration-tests.sh b/tools/run-integration-tests.sh index 367ccecd..132b0e53 100755 --- a/tools/run-integration-tests.sh +++ b/tools/run-integration-tests.sh @@ -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} diff --git a/tsconfig.json b/tsconfig.json index 7bce4022..08f7108e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "target": "es5", "lib": [ "es6", + "es2020", "dom" ], "sourceMap": true, diff --git a/yarn.lock b/yarn.lock index 580aa8ed..2e0c4f2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1646,6 +1646,28 @@ __metadata: languageName: node linkType: hard +"@coreui/coreui@npm:^4.3.2": + version: 4.3.2 + resolution: "@coreui/coreui@npm:4.3.2" + dependencies: + postcss-combine-duplicated-selectors: ^10.0.3 + peerDependencies: + "@popperjs/core": ^2.11.6 + checksum: 88fc70f4f681bb796e1d81ca8472a3d36bfcf92866fc7c6810ead850bc371c99bca123a94abb0fafdf2935972d130005cd62b485406631cfd9abd8f38e14be15 + languageName: node + linkType: hard + +"@coreui/react@npm:^4.11.0": + version: 4.11.0 + resolution: "@coreui/react@npm:4.11.0" + peerDependencies: + "@coreui/coreui": 4.3.0 + react: ">=17" + react-dom: ">=17" + checksum: 75c9394125e41e24fb5855b82cba93c9abeea080f9ee5bcc063ff2e581318b85c5bbef6f2c5300f5fd7a3450743488daa29b4baee6feabec38a009a452876a88 + languageName: node + linkType: hard + "@csstools/convert-colors@npm:^1.4.0": version: 1.4.0 resolution: "@csstools/convert-colors@npm:1.4.0" @@ -1765,7 +1787,7 @@ __metadata: languageName: node linkType: hard -"@gar/promisify@npm:^1.0.1": +"@gar/promisify@npm:^1.0.1, @gar/promisify@npm:^1.1.3": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" checksum: 4059f790e2d07bf3c3ff3e0fec0daa8144fe35c1f6e0111c9921bd32106adaa97a4ab096ad7dab1e28ee6a9060083c4d1a4ada42a7f5f3f7a96b8812e2b757c1 @@ -2182,6 +2204,16 @@ __metadata: languageName: node linkType: hard +"@npmcli/fs@npm:^2.1.0": + version: 2.1.2 + resolution: "@npmcli/fs@npm:2.1.2" + dependencies: + "@gar/promisify": ^1.1.3 + semver: ^7.3.5 + checksum: 405074965e72d4c9d728931b64d2d38e6ea12066d4fad651ac253d175e413c06fe4350970c783db0d749181da8fe49c42d3880bd1cbc12cd68e3a7964d820225 + languageName: node + linkType: hard + "@npmcli/move-file@npm:^1.0.1": version: 1.1.2 resolution: "@npmcli/move-file@npm:1.1.2" @@ -2192,6 +2224,16 @@ __metadata: languageName: node linkType: hard +"@npmcli/move-file@npm:^2.0.0": + version: 2.0.1 + resolution: "@npmcli/move-file@npm:2.0.1" + dependencies: + mkdirp: ^1.0.4 + rimraf: ^3.0.2 + checksum: 52dc02259d98da517fae4cb3a0a3850227bdae4939dda1980b788a7670636ca2b4a01b58df03dd5f65c1e3cb70c50fa8ce5762b582b3f499ec30ee5ce1fd9380 + languageName: node + linkType: hard + "@phenomnomnominal/tsquery@npm:^3.0.0": version: 3.0.0 resolution: "@phenomnomnominal/tsquery@npm:3.0.0" @@ -2233,6 +2275,24 @@ __metadata: languageName: node linkType: hard +"@sinonjs/commons@npm:^3.0.0": + version: 3.0.0 + resolution: "@sinonjs/commons@npm:3.0.0" + dependencies: + type-detect: 4.0.8 + checksum: b4b5b73d4df4560fb8c0c7b38c7ad4aeabedd362f3373859d804c988c725889cde33550e4bcc7cd316a30f5152a2d1d43db71b6d0c38f5feef71fd8d016763f8 + languageName: node + linkType: hard + +"@sinonjs/fake-timers@npm:^10.3.0": + version: 10.3.0 + resolution: "@sinonjs/fake-timers@npm:10.3.0" + dependencies: + "@sinonjs/commons": ^3.0.0 + checksum: 614d30cb4d5201550c940945d44c9e0b6d64a888ff2cd5b357f95ad6721070d6b8839cd10e15b76bf5e14af0bcc1d8f9ec00d49a46318f1f669a4bec1d7f3148 + languageName: node + linkType: hard + "@sinonjs/formatio@npm:^3.2.1": version: 3.2.2 resolution: "@sinonjs/formatio@npm:3.2.2" @@ -2392,6 +2452,13 @@ __metadata: languageName: node linkType: hard +"@tootallnate/once@npm:1": + version: 1.1.2 + resolution: "@tootallnate/once@npm:1.1.2" + checksum: e1fb1bbbc12089a0cb9433dc290f97bddd062deadb6178ce9bcb93bb7c1aecde5e60184bc7065aec42fe1663622a213493c48bbd4972d931aae48315f18e1be9 + languageName: node + linkType: hard + "@tootallnate/once@npm:2": version: 2.0.0 resolution: "@tootallnate/once@npm:2.0.0" @@ -2463,6 +2530,15 @@ __metadata: languageName: node linkType: hard +"@types/dompurify@npm:^3.0.3": + version: 3.0.3 + resolution: "@types/dompurify@npm:3.0.3" + dependencies: + "@types/trusted-types": "*" + checksum: ff629277db4d19d836b0d878e93efb27d876d1073db81507c39d44d509b30ee3bcdc9e951dbbf9574b1fc6c52e1eaa95abf4279fa45aca281868717f8a7298da + languageName: node + linkType: hard + "@types/enzyme-adapter-react-16@npm:1.0.3": version: 1.0.3 resolution: "@types/enzyme-adapter-react-16@npm:1.0.3" @@ -2624,6 +2700,13 @@ __metadata: languageName: node linkType: hard +"@types/minimist@npm:^1.2.0": + version: 1.2.3 + resolution: "@types/minimist@npm:1.2.3" + checksum: 666ea4f8c39dcbdfbc3171fe6b3902157c845cc9cb8cee33c10deb706cda5e0cc80f98ace2d6d29f6774b0dc21180c96cd73c592a1cbefe04777247c7ba0e84b + languageName: node + linkType: hard + "@types/node@npm:*, @types/node@npm:15.12.4": version: 15.12.4 resolution: "@types/node@npm:15.12.4" @@ -2631,6 +2714,13 @@ __metadata: languageName: node linkType: hard +"@types/normalize-package-data@npm:^2.4.0": + version: 2.4.2 + resolution: "@types/normalize-package-data@npm:2.4.2" + checksum: 2132e4054711e6118de967ae3a34f8c564e58d71fbcab678ec2c34c14659f638a86c35a0fd45237ea35a4a03079cf0a485e3f97736ffba5ed647bfb5da086b03 + languageName: node + linkType: hard + "@types/parse-json@npm:^4.0.0": version: 4.0.0 resolution: "@types/parse-json@npm:4.0.0" @@ -2859,6 +2949,13 @@ __metadata: languageName: node linkType: hard +"@types/trusted-types@npm:*": + version: 2.0.4 + resolution: "@types/trusted-types@npm:2.0.4" + checksum: 5256c4576cd1c90d33ddd9cc9cbd4f202b39c98cbe8b7f74963298f9eb2159c285ea5c25a6181b4c594d8d75641765bff85d72c2d251ad076e6529ce0eeedd1c + languageName: node + linkType: hard + "@types/uuid@npm:3.4.4": version: 3.4.4 resolution: "@types/uuid@npm:3.4.4" @@ -3309,6 +3406,15 @@ __metadata: languageName: node linkType: hard +"agentkeepalive@npm:^4.1.3": + version: 4.5.0 + resolution: "agentkeepalive@npm:4.5.0" + dependencies: + humanize-ms: ^1.2.1 + checksum: 13278cd5b125e51eddd5079f04d6fe0914ac1b8b91c1f3db2c1822f99ac1a7457869068997784342fe455d59daaff22e14fb7b8c3da4e741896e7e31faf92481 + languageName: node + linkType: hard + "agentkeepalive@npm:^4.2.1": version: 4.2.1 resolution: "agentkeepalive@npm:4.2.1" @@ -3433,27 +3539,20 @@ __metadata: linkType: hard "ansi-regex@npm:^3.0.0": - version: 3.0.0 - resolution: "ansi-regex@npm:3.0.0" - checksum: 2ad11c416f81c39f5c65eafc88cf1d71aa91d76a2f766e75e457c2a3c43e8a003aadbf2966b61c497aa6a6940a36412486c975b3270cdfc3f413b69826189ec3 + version: 3.0.1 + resolution: "ansi-regex@npm:3.0.1" + checksum: 09daf180c5f59af9850c7ac1bd7fda85ba596cc8cbeb210826e90755f06c818af86d9fa1e6e8322fab2c3b9e9b03f56c537b42241139f824dd75066a1e7257cc languageName: node linkType: hard "ansi-regex@npm:^4.0.0, ansi-regex@npm:^4.1.0": - version: 4.1.0 - resolution: "ansi-regex@npm:4.1.0" - checksum: 97aa4659538d53e5e441f5ef2949a3cffcb838e57aeaad42c4194e9d7ddb37246a6526c4ca85d3940a9d1e19b11cc2e114530b54c9d700c8baf163c31779baf8 - languageName: node - linkType: hard - -"ansi-regex@npm:^5.0.0": - version: 5.0.0 - resolution: "ansi-regex@npm:5.0.0" - checksum: b1bb4e992a5d96327bb4f72eaba9f8047f1d808d273ad19d399e266bfcc7fb19a4d1a127a32f7bc61fe46f1a94a4d04ec4c424e3fbe184929aa866323d8ed4ce + version: 4.1.1 + resolution: "ansi-regex@npm:4.1.1" + checksum: b1a6ee44cb6ecdabaa770b2ed500542714d4395d71c7e5c25baa631f680fb2ad322eb9ba697548d498a6fd366949fc8b5bfcf48d49a32803611f648005b01888 languageName: node linkType: hard -"ansi-regex@npm:^5.0.1": +"ansi-regex@npm:^5.0.0, ansi-regex@npm:^5.0.1": version: 5.0.1 resolution: "ansi-regex@npm:5.0.1" checksum: 2aa4bb54caf2d622f1afdad09441695af2a83aa3fe8b8afa581d205e57ed4261c183c4d3877cee25794443fde5876417d859c108078ab788d6af7e4fe52eb66b @@ -3519,7 +3618,7 @@ __metadata: languageName: node linkType: hard -"aproba@npm:^1.0.3, aproba@npm:^1.1.1": +"aproba@npm:^1.1.1": version: 1.2.0 resolution: "aproba@npm:1.2.0" checksum: 0fca141966559d195072ed047658b6e6c4fe92428c385dd38e288eacfc55807e7b4989322f030faff32c0f46bb0bc10f1e0ac32ec22d25315a1e5bbc0ebb76dc @@ -3533,23 +3632,23 @@ __metadata: languageName: node linkType: hard -"are-we-there-yet@npm:^3.0.0": - version: 3.0.0 - resolution: "are-we-there-yet@npm:3.0.0" +"are-we-there-yet@npm:^2.0.0": + version: 2.0.0 + resolution: "are-we-there-yet@npm:2.0.0" dependencies: delegates: ^1.0.0 readable-stream: ^3.6.0 - checksum: 348edfdd931b0b50868b55402c01c3f64df1d4c229ab6f063539a5025fd6c5f5bb8a0cab409bbed8d75d34762d22aa91b7c20b4204eb8177063158d9ba792981 + checksum: 6c80b4fd04ecee6ba6e737e0b72a4b41bdc64b7d279edfc998678567ff583c8df27e27523bc789f2c99be603ffa9eaa612803da1d886962d2086e7ff6fa90c7c languageName: node linkType: hard -"are-we-there-yet@npm:~1.1.2": - version: 1.1.5 - resolution: "are-we-there-yet@npm:1.1.5" +"are-we-there-yet@npm:^3.0.0": + version: 3.0.0 + resolution: "are-we-there-yet@npm:3.0.0" dependencies: delegates: ^1.0.0 - readable-stream: ^2.0.6 - checksum: 9a746b1dbce4122f44002b0c39fbba5b2c6f52c00e88b6ccba6fc68652323f8a1355a20e8ab94846995626d8de3bf67669a3b4a037dff0885db14607168f2b15 + readable-stream: ^3.6.0 + checksum: 348edfdd931b0b50868b55402c01c3f64df1d4c229ab6f063539a5025fd6c5f5bb8a0cab409bbed8d75d34762d22aa91b7c20b4204eb8177063158d9ba792981 languageName: node linkType: hard @@ -3723,14 +3822,18 @@ __metadata: version: 0.0.0-use.local resolution: "arvados-workbench-2@workspace:." dependencies: + "@coreui/coreui": ^4.3.2 + "@coreui/react": ^4.11.0 "@date-io/date-fns": 1 "@fortawesome/fontawesome-svg-core": 1.2.28 "@fortawesome/free-solid-svg-icons": 5.13.0 "@fortawesome/react-fontawesome": 0.1.9 "@material-ui/core": 3.9.3 "@material-ui/icons": 3.0.1 + "@sinonjs/fake-timers": ^10.3.0 "@types/classnames": 2.2.6 "@types/debounce": 3.0.0 + "@types/dompurify": ^3.0.3 "@types/enzyme": 3.1.14 "@types/enzyme-adapter-react-16": 1.0.3 "@types/file-saver": 2.0.0 @@ -3762,12 +3865,14 @@ __metadata: axios-mock-adapter: 1.17.0 babel-core: 6.26.3 babel-runtime: 6.26.0 + bootstrap: ^5.3.2 caniuse-lite: 1.0.30001299 classnames: 2.2.6 cwlts: 1.15.29 cypress: 6.3.0 date-fns: ^2.28.0 debounce: 1.2.0 + dompurify: ^3.0.6 elliptic: 6.5.4 enzyme: 3.11.0 enzyme-adapter-react-16: 1.15.6 @@ -3777,25 +3882,25 @@ __metadata: jest-localstorage-mock: 2.2.0 js-yaml: 3.13.1 jssha: 2.3.1 - jszip: 3.1.5 + jszip: ^3.10.1 lodash: ^4.17.21 - lodash-es: 4.17.14 + lodash-es: ^4.17.21 lodash.mergewith: 4.6.2 lodash.template: 4.5.0 material-ui-pickers: ^2.2.4 mem: 4.0.0 mime: ^3.0.0 - moment: 2.29.1 - node-sass: ^4.9.4 - node-sass-chokidar: 1.5.0 + moment: ^2.29.4 + node-sass: ^9.0.0 + node-sass-chokidar: ^2.0.0 parse-duration: 0.4.4 prop-types: 15.7.2 query-string: 6.9.0 - react: 16.8.6 + react: 16.14.0 react-copy-to-clipboard: 5.0.3 react-dnd: 5.0.0 react-dnd-html5-backend: 5.0.1 - react-dom: 16.8.6 + react-dom: 16.14.0 react-dropzone: 5.1.1 react-highlight-words: 0.14.0 react-idle-timer: 4.3.6 @@ -3803,7 +3908,7 @@ __metadata: react-router: 4.3.1 react-router-dom: 4.3.1 react-router-redux: 5.0.0-alpha.9 - react-rte: 0.16.3 + react-rte: ^0.16.5 react-scripts: 3.4.4 react-splitter-layout: 3.0.1 react-transition-group: 2.5.0 @@ -3811,6 +3916,7 @@ __metadata: react-window: 1.8.5 redux: 4.0.3 redux-devtools: 3.4.1 + redux-devtools-extension: ^2.13.9 redux-form: 7.4.2 redux-mock-store: 1.5.4 redux-thunk: 2.3.0 @@ -3918,18 +4024,18 @@ __metadata: linkType: hard "async@npm:^2.6.2": - version: 2.6.3 - resolution: "async@npm:2.6.3" + version: 2.6.4 + resolution: "async@npm:2.6.4" dependencies: lodash: ^4.17.14 - checksum: 5e5561ff8fca807e88738533d620488ac03a5c43fce6c937451f7e35f943d33ad06c24af3f681a48cca3d2b0002b3118faff0a128dc89438a9bf0226f712c499 + checksum: a52083fb32e1ebe1d63e5c5624038bb30be68ff07a6c8d7dfe35e47c93fc144bd8652cbec869e0ac07d57dde387aa5f1386be3559cdee799cb1f789678d88e19 languageName: node linkType: hard "async@npm:^3.2.0": - version: 3.2.0 - resolution: "async@npm:3.2.0" - checksum: 6739fae769e6c9f76b272558f118ef041d45c979c573a8fe93f8cfbc32eb9c92da032e9effe6bbcc9b1131292cde6c4a9e61a442894aa06a262addd8dd3adda1 + version: 3.2.4 + resolution: "async@npm:3.2.4" + checksum: 43d07459a4e1d09b84a20772414aa684ff4de085cbcaec6eea3c7a8f8150e8c62aa6cd4e699fe8ee93c3a5b324e777d34642531875a0817a35697522c1b02e89 languageName: node linkType: hard @@ -4015,11 +4121,11 @@ __metadata: linkType: hard "axios@npm:^0.21.1": - version: 0.21.1 - resolution: "axios@npm:0.21.1" + version: 0.21.4 + resolution: "axios@npm:0.21.4" dependencies: - follow-redirects: ^1.10.0 - checksum: c87915fa0b18c15c63350112b6b3563a3e2ae524d7707de0a73d2e065e0d30c5d3da8563037bc29d4cc1b7424b5a350cb7274fa52525c6c04a615fe561c6ab11 + follow-redirects: ^1.14.0 + checksum: 44245f24ac971e7458f3120c92f9d66d1fc695e8b97019139de5b0cc65d9b8104647db01e5f46917728edfc0cfd88eb30fc4c55e6053eef4ace76768ce95ff3c languageName: node linkType: hard @@ -4464,15 +4570,6 @@ __metadata: languageName: node linkType: hard -"block-stream@npm:*": - version: 0.0.9 - resolution: "block-stream@npm:0.0.9" - dependencies: - inherits: ~2.0.0 - checksum: 72733cbb816181b7c92449e7b650247c02122f743526ce9d948ff68afc27d8709106cd62f2c876c6d8cd3977e0204a014f38d22805974008039bd3bed35f2cbd - languageName: node - linkType: hard - "bluebird@npm:^3.5.5, bluebird@npm:^3.7.2": version: 3.7.2 resolution: "bluebird@npm:3.7.2" @@ -4533,6 +4630,15 @@ __metadata: languageName: node linkType: hard +"bootstrap@npm:^5.3.2": + version: 5.3.2 + resolution: "bootstrap@npm:5.3.2" + peerDependencies: + "@popperjs/core": ^2.11.8 + checksum: d5580b253d121ffc137388d41da58dce8d15f1ccd574e12f28d4a08e7649ca15e95db645b2b677cb8025bccd446bff04138fc0fe64f8cba0ccc5dc004a8644cf + languageName: node + linkType: hard + "brace-expansion@npm:^1.1.7": version: 1.1.11 resolution: "brace-expansion@npm:1.1.11" @@ -4543,6 +4649,15 @@ __metadata: languageName: node linkType: hard +"brace-expansion@npm:^2.0.1": + version: 2.0.1 + resolution: "brace-expansion@npm:2.0.1" + dependencies: + balanced-match: ^1.0.0 + checksum: a61e7cd2e8a8505e9f0036b3b6108ba5e926b4b55089eeb5550cd04a471fe216c96d4fe7e4c7f995c728c554ae20ddfc4244cad10aef255e72b62930afd233d1 + languageName: node + linkType: hard + "braces@npm:^2.3.1, braces@npm:^2.3.2": version: 2.3.2 resolution: "braces@npm:2.3.2" @@ -4561,7 +4676,7 @@ __metadata: languageName: node linkType: hard -"braces@npm:^3.0.1, braces@npm:~3.0.2": +"braces@npm:^3.0.2, braces@npm:~3.0.2": version: 3.0.2 resolution: "braces@npm:3.0.2" dependencies: @@ -4688,17 +4803,16 @@ __metadata: linkType: hard "browserslist@npm:^4.0.0, browserslist@npm:^4.12.0, browserslist@npm:^4.16.6, browserslist@npm:^4.6.2, browserslist@npm:^4.6.4, browserslist@npm:^4.9.1": - version: 4.16.6 - resolution: "browserslist@npm:4.16.6" + version: 4.22.1 + resolution: "browserslist@npm:4.22.1" dependencies: - caniuse-lite: ^1.0.30001219 - colorette: ^1.2.2 - electron-to-chromium: ^1.3.723 - escalade: ^3.1.1 - node-releases: ^1.1.71 + caniuse-lite: ^1.0.30001541 + electron-to-chromium: ^1.4.535 + node-releases: ^2.0.13 + update-browserslist-db: ^1.0.13 bin: browserslist: cli.js - checksum: 3dffc86892d2dcfcfc66b52519b7e5698ae070b4fc92ab047e760efc4cae0474e9e70bbe10d769c8d3491b655ef3a2a885b88e7196c83cc5dc0a46dfdba8b70c + checksum: 7e6b10c53f7dd5d83fd2b95b00518889096382539fed6403829d447e05df4744088de46a571071afb447046abc3c66ad06fbc790e70234ec2517452e32ffd862 languageName: node linkType: hard @@ -4827,7 +4941,7 @@ __metadata: languageName: node linkType: hard -"cacache@npm:^15.3.0": +"cacache@npm:^15.2.0, cacache@npm:^15.3.0": version: 15.3.0 resolution: "cacache@npm:15.3.0" dependencies: @@ -4853,6 +4967,32 @@ __metadata: languageName: node linkType: hard +"cacache@npm:^16.1.0": + version: 16.1.3 + resolution: "cacache@npm:16.1.3" + dependencies: + "@npmcli/fs": ^2.1.0 + "@npmcli/move-file": ^2.0.0 + chownr: ^2.0.0 + fs-minipass: ^2.1.0 + glob: ^8.0.1 + infer-owner: ^1.0.4 + lru-cache: ^7.7.1 + minipass: ^3.1.6 + minipass-collect: ^1.0.2 + minipass-flush: ^1.0.5 + minipass-pipeline: ^1.2.4 + mkdirp: ^1.0.4 + p-map: ^4.0.0 + promise-inflight: ^1.0.1 + rimraf: ^3.0.2 + ssri: ^9.0.0 + tar: ^6.1.11 + unique-filename: ^2.0.0 + checksum: d91409e6e57d7d9a3a25e5dcc589c84e75b178ae8ea7de05cbf6b783f77a5fae938f6e8fda6f5257ed70000be27a681e1e44829251bfffe4c10216002f8f14e6 + languageName: node + linkType: hard + "cache-base@npm:^1.0.1": version: 1.0.1 resolution: "cache-base@npm:1.0.1" @@ -4946,6 +5086,17 @@ __metadata: languageName: node linkType: hard +"camelcase-keys@npm:^6.2.2": + version: 6.2.2 + resolution: "camelcase-keys@npm:6.2.2" + dependencies: + camelcase: ^5.3.1 + map-obj: ^4.0.0 + quick-lru: ^4.0.1 + checksum: 43c9af1adf840471e54c68ab3e5fe8a62719a6b7dbf4e2e86886b7b0ff96112c945736342b837bd2529ec9d1c7d1934e5653318478d98e0cf22c475c04658e2a + languageName: node + linkType: hard + "camelcase@npm:5.3.1, camelcase@npm:^5.0.0, camelcase@npm:^5.3.1": version: 5.3.1 resolution: "camelcase@npm:5.3.1" @@ -4986,10 +5137,10 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30000981, caniuse-lite@npm:^1.0.30001035, caniuse-lite@npm:^1.0.30001109, caniuse-lite@npm:^1.0.30001219": - version: 1.0.30001414 - resolution: "caniuse-lite@npm:1.0.30001414" - checksum: 97210cfd15ded093b20c33d35bef9711a88402c3345411dad420c991a41a3e38ad17fd66721e8334c86e9b2e4aa2c1851d3631f1441afb73b92d93b2b8ca890d +"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30000981, caniuse-lite@npm:^1.0.30001035, caniuse-lite@npm:^1.0.30001109, caniuse-lite@npm:^1.0.30001541": + version: 1.0.30001561 + resolution: "caniuse-lite@npm:1.0.30001561" + checksum: 949829fe037e23346595614e01d362130245920503a12677f2506ce68e1240360113d6383febed41e8aa38cd0f5fd9c69c21b0af65a71c0246d560db489f1373 languageName: node linkType: hard @@ -5027,7 +5178,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^1.0.0, chalk@npm:^1.1.1, chalk@npm:^1.1.3": +"chalk@npm:^1.0.0, chalk@npm:^1.1.3": version: 1.1.3 resolution: "chalk@npm:1.1.3" dependencies: @@ -5040,13 +5191,13 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.0.0, chalk@npm:^4.1.0": - version: 4.1.1 - resolution: "chalk@npm:4.1.1" +"chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.2": + version: 4.1.2 + resolution: "chalk@npm:4.1.2" dependencies: ansi-styles: ^4.1.0 supports-color: ^7.1.0 - checksum: 036e973e665ba1a32c975e291d5f3d549bceeb7b1b983320d4598fb75d70fe20c5db5d62971ec0fe76cdbce83985a00ee42372416abfc3a5584465005a7855ed + checksum: fe75c9d5c76a7a98d45495b91b2172fa3b7a09e0cc9370e5c8feb1c567b85c4288e2b3fded7cfdd7359ac28d6b3844feb8b82b8686842e93d23c827c417e83fc languageName: node linkType: hard @@ -5123,8 +5274,8 @@ __metadata: linkType: hard "chokidar@npm:^3.3.0, chokidar@npm:^3.4.0, chokidar@npm:^3.4.1": - version: 3.5.2 - resolution: "chokidar@npm:3.5.2" + version: 3.5.3 + resolution: "chokidar@npm:3.5.3" dependencies: anymatch: ~3.1.2 braces: ~3.0.2 @@ -5137,7 +5288,7 @@ __metadata: dependenciesMeta: fsevents: optional: true - checksum: d1fda32fcd67d9f6170a8468ad2630a3c6194949c9db3f6a91b16478c328b2800f433fb5d2592511b6cb145a47c013ea1cce60b432b1a001ae3ee978a8bffc2d + checksum: b49fcde40176ba007ff361b198a2d35df60d9bb2a5aab228279eb810feae9294a6b4649ab15981304447afe1e6ffbf4788ad5db77235dc770ab777c6e771980c languageName: node linkType: hard @@ -5326,6 +5477,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^8.0.1": + version: 8.0.1 + resolution: "cliui@npm:8.0.1" + dependencies: + string-width: ^4.2.0 + strip-ansi: ^6.0.1 + wrap-ansi: ^7.0.0 + checksum: 79648b3b0045f2e285b76fb2e24e207c6db44323581e421c3acbd0e86454cba1b37aea976ab50195a49e7384b871e6dfb2247ad7dec53c02454ac6497394cb56 + languageName: node + linkType: hard + "clone-deep@npm:^0.2.4": version: 0.2.4 resolution: "clone-deep@npm:0.2.4" @@ -5434,7 +5596,7 @@ __metadata: languageName: node linkType: hard -"color-support@npm:^1.1.3": +"color-support@npm:^1.1.2, color-support@npm:^1.1.3": version: 1.1.3 resolution: "color-support@npm:1.1.3" bin: @@ -5453,7 +5615,7 @@ __metadata: languageName: node linkType: hard -"colorette@npm:^1.2.1, colorette@npm:^1.2.2": +"colorette@npm:^1.2.1": version: 1.2.2 resolution: "colorette@npm:1.2.2" checksum: 69fec14ddaedd0f5b00e4bae40dc4bc61f7050ebdc82983a595d6fd64e650b9dc3c033fff378775683138e992e0ddd8717ac7c7cec4d089679dcfbe3cd921b04 @@ -5591,7 +5753,7 @@ __metadata: languageName: node linkType: hard -"console-control-strings@npm:^1.0.0, console-control-strings@npm:^1.1.0, console-control-strings@npm:~1.1.0": +"console-control-strings@npm:^1.0.0, console-control-strings@npm:^1.1.0": version: 1.1.0 resolution: "console-control-strings@npm:1.1.0" checksum: 8755d76787f94e6cf79ce4666f0c5519906d7f5b02d4b884cf41e11dcd759ed69c57da0670afd9236d229a46e0f9cf519db0cd829c6dca820bb5a5c3def584ed @@ -5735,13 +5897,6 @@ __metadata: languageName: node linkType: hard -"core-js@npm:~2.3.0": - version: 2.3.0 - resolution: "core-js@npm:2.3.0" - checksum: eb2e9e82d71e646e91abc9480ee4da8a4c02606418ea83602daae5988b4ba558a233f1a29dc8d660e2e4aaa7f6e4297b6c3089b55b0e7292917eef07a3952972 - languageName: node - linkType: hard - "core-util-is@npm:1.0.2, core-util-is@npm:~1.0.0": version: 1.0.2 resolution: "core-util-is@npm:1.0.2" @@ -5812,11 +5967,11 @@ __metadata: linkType: hard "cross-fetch@npm:^3.0.4": - version: 3.1.4 - resolution: "cross-fetch@npm:3.1.4" + version: 3.1.8 + resolution: "cross-fetch@npm:3.1.8" dependencies: - node-fetch: 2.6.1 - checksum: 2107e5e633aa327bdacab036b1907c7ddd28651ede0c1d4fd14db04510944d56849a8255e2f5b8f9a1da0e061b6cee943f6819fe29ed9a130195e7fadd82a4ff + node-fetch: ^2.6.12 + checksum: 78f993fa099eaaa041122ab037fe9503ecbbcb9daef234d1d2e0b9230a983f64d645d088c464e21a247b825a08dc444a6e7064adfa93536d3a9454b4745b3632 languageName: node linkType: hard @@ -5831,16 +5986,6 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^3.0.0": - version: 3.0.1 - resolution: "cross-spawn@npm:3.0.1" - dependencies: - lru-cache: ^4.0.1 - which: ^1.2.9 - checksum: a029a5028629ce2b7773e341b57415b344b6e46b98b39b308822c3b524e8e92e15f10c4ca3384e90722b882dfce2cc8e10edc8e84ee1394afe9744c4a1082776 - languageName: node - linkType: hard - "cross-spawn@npm:^6.0.0, cross-spawn@npm:^6.0.5": version: 6.0.5 resolution: "cross-spawn@npm:6.0.5" @@ -5854,7 +5999,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.0": +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.3": version: 7.0.3 resolution: "cross-spawn@npm:7.0.3" dependencies: @@ -5977,15 +6122,15 @@ __metadata: linkType: hard "css-select@npm:^4.1.3": - version: 4.1.3 - resolution: "css-select@npm:4.1.3" + version: 4.3.0 + resolution: "css-select@npm:4.3.0" dependencies: boolbase: ^1.0.0 - css-what: ^5.0.0 - domhandler: ^4.2.0 - domutils: ^2.6.0 - nth-check: ^2.0.0 - checksum: 40928f1aa6c71faf36430e7f26bcbb8ab51d07b98b754caacb71906400a195df5e6c7020a94f2982f02e52027b9bd57c99419220cf7020968c3415f14e4be5f8 + css-what: ^6.0.1 + domhandler: ^4.3.1 + domutils: ^2.8.0 + nth-check: ^2.0.1 + checksum: d6202736839194dd7f910320032e7cfc40372f025e4bf21ca5bf6eb0a33264f322f50ba9c0adc35dadd342d3d6fae5ca244779a4873afbfa76561e343f2058e0 languageName: node linkType: hard @@ -6018,13 +6163,20 @@ __metadata: languageName: node linkType: hard -"css-what@npm:^3.2.1, css-what@npm:^5.0.0, css-what@npm:^5.0.1": +"css-what@npm:^3.2.1, css-what@npm:^5.0.1": version: 5.0.1 resolution: "css-what@npm:5.0.1" checksum: 7a3de33a1c130d32d711cce4e0fa747be7a9afe6b5f2c6f3d56bc2765f150f6034f5dd5fe263b9359a1c371c01847399602d74b55322c982742b336d998602cd languageName: node linkType: hard +"css-what@npm:^6.0.1": + version: 6.1.0 + resolution: "css-what@npm:6.1.0" + checksum: b975e547e1e90b79625918f84e67db5d33d896e6de846c9b584094e529f0c63e2ab85ee33b9daffd05bff3a146a1916bec664e18bb76dd5f66cbff9fc13b2bbe + languageName: node + linkType: hard + "css@npm:^2.0.0": version: 2.2.4 resolution: "css@npm:2.2.4" @@ -6362,7 +6514,29 @@ __metadata: languageName: node linkType: hard -"decamelize@npm:^1.1.1, decamelize@npm:^1.1.2, decamelize@npm:^1.2.0": +"debug@npm:^4.3.3": + version: 4.3.4 + resolution: "debug@npm:4.3.4" + dependencies: + ms: 2.1.2 + peerDependenciesMeta: + supports-color: + optional: true + checksum: 3dbad3f94ea64f34431a9cbf0bafb61853eda57bff2880036153438f50fb5a84f27683ba0d8e5426bf41a8c6ff03879488120cf5b3a761e77953169c0600a708 + languageName: node + linkType: hard + +"decamelize-keys@npm:^1.1.0": + version: 1.1.1 + resolution: "decamelize-keys@npm:1.1.1" + dependencies: + decamelize: ^1.1.0 + map-obj: ^1.0.0 + checksum: fc645fe20b7bda2680bbf9481a3477257a7f9304b1691036092b97ab04c0ab53e3bf9fcc2d2ae382536568e402ec41fb11e1d4c3836a9abe2d813dd9ef4311e0 + languageName: node + linkType: hard + +"decamelize@npm:^1.1.0, decamelize@npm:^1.1.1, decamelize@npm:^1.1.2, decamelize@npm:^1.2.0": version: 1.2.0 resolution: "decamelize@npm:1.2.0" checksum: ad8c51a7e7e0720c70ec2eeb1163b66da03e7616d7b98c9ef43cce2416395e84c1e9548dd94f5f6ffecfee9f8b94251fc57121a8b021f2ff2469b2bae247b8aa @@ -6370,9 +6544,9 @@ __metadata: linkType: hard "decode-uri-component@npm:^0.2.0": - version: 0.2.0 - resolution: "decode-uri-component@npm:0.2.0" - checksum: f3749344ab9305ffcfe4bfe300e2dbb61fc6359e2b736812100a3b1b6db0a5668cba31a05e4b45d4d63dbf1a18dfa354cd3ca5bb3ededddabb8cd293f4404f94 + version: 0.2.2 + resolution: "decode-uri-component@npm:0.2.2" + checksum: 95476a7d28f267292ce745eac3524a9079058bbb35767b76e3ee87d42e34cd0275d2eb19d9d08c3e167f97556e8a2872747f5e65cbebcac8b0c98d83e285f139 languageName: node linkType: hard @@ -6749,6 +6923,22 @@ __metadata: languageName: node linkType: hard +"domhandler@npm:^4.3.1": + version: 4.3.1 + resolution: "domhandler@npm:4.3.1" + dependencies: + domelementtype: ^2.2.0 + checksum: 4c665ceed016e1911bf7d1dadc09dc888090b64dee7851cccd2fcf5442747ec39c647bb1cb8c8919f8bbdd0f0c625a6bafeeed4b2d656bbecdbae893f43ffaaa + languageName: node + linkType: hard + +"dompurify@npm:^3.0.6": + version: 3.0.6 + resolution: "dompurify@npm:3.0.6" + checksum: e5c6cdc5fe972a9d0859d939f1d86320de275be00bbef7bd5591c80b1e538935f6ce236624459a1b0c84ecd7c6a1e248684aa4637512659fccc0ce7c353828a6 + languageName: node + linkType: hard + "domutils@npm:^1.7.0": version: 1.7.0 resolution: "domutils@npm:1.7.0" @@ -6759,7 +6949,7 @@ __metadata: languageName: node linkType: hard -"domutils@npm:^2.5.2, domutils@npm:^2.6.0, domutils@npm:^2.7.0": +"domutils@npm:^2.5.2, domutils@npm:^2.7.0": version: 2.7.0 resolution: "domutils@npm:2.7.0" dependencies: @@ -6770,6 +6960,17 @@ __metadata: languageName: node linkType: hard +"domutils@npm:^2.8.0": + version: 2.8.0 + resolution: "domutils@npm:2.8.0" + dependencies: + dom-serializer: ^1.0.1 + domelementtype: ^2.2.0 + domhandler: ^4.2.0 + checksum: abf7434315283e9aadc2a24bac0e00eab07ae4313b40cc239f89d84d7315ebdfd2fb1b5bf750a96bc1b4403d7237c7b2ebf60459be394d625ead4ca89b934391 + languageName: node + linkType: hard + "dot-case@npm:^3.0.4": version: 3.0.4 resolution: "dot-case@npm:3.0.4" @@ -6925,13 +7126,20 @@ __metadata: languageName: node linkType: hard -"electron-to-chromium@npm:^1.3.378, electron-to-chromium@npm:^1.3.723": +"electron-to-chromium@npm:^1.3.378": version: 1.3.758 resolution: "electron-to-chromium@npm:1.3.758" checksum: 2fec13dcdd1b24a2314d309566bd08c7f0ce383787e64ea43c14a7fc2a11c8a76fdb9a56ce7a1da6137e1ef46365f999d10c656f2fb6b9ff792ea3ae808ebb86 languageName: node linkType: hard +"electron-to-chromium@npm:^1.4.535": + version: 1.4.540 + resolution: "electron-to-chromium@npm:1.4.540" + checksum: 78a48690a5cca3f89544d4e33a11e3101adb0b220da64078f67e167b396cbcd85044853cb88a9453444796599fe157c190ca5ebd00e9daf668ed5a9df3d0bba8 + languageName: node + linkType: hard + "elegant-spinner@npm:^1.0.1": version: 1.0.1 resolution: "elegant-spinner@npm:1.0.1" @@ -6989,7 +7197,7 @@ __metadata: languageName: node linkType: hard -"encoding@npm:^0.1.11, encoding@npm:^0.1.13": +"encoding@npm:^0.1.11, encoding@npm:^0.1.12, encoding@npm:^0.1.13": version: 0.1.13 resolution: "encoding@npm:0.1.13" dependencies: @@ -7208,13 +7416,6 @@ __metadata: languageName: node linkType: hard -"es6-promise@npm:~3.0.2": - version: 3.0.2 - resolution: "es6-promise@npm:3.0.2" - checksum: f9d6cabf3fa5cff33ddd9791c190b4ae83f372489b62c81d5c19dc10afd2e59736a31e20994f80fc54151c39c00ccc493b11b5b9dfc5e605eff597f239650da5 - languageName: node - linkType: hard - "es6-symbol@npm:^3.1.1, es6-symbol@npm:~3.1.3": version: 3.1.3 resolution: "es6-symbol@npm:3.1.3" @@ -7600,11 +7801,9 @@ __metadata: linkType: hard "eventsource@npm:^1.0.7": - version: 1.1.0 - resolution: "eventsource@npm:1.1.0" - dependencies: - original: ^1.0.0 - checksum: 78338b7e75ec471cb793efb3319e0c4d2bf00fb638a2e3f888ad6d98cd1e3d4492a29f554c0921c7b2ac5130c3a732a1a0056739f6e2f548d714aec685e5da7e + version: 1.1.2 + resolution: "eventsource@npm:1.1.2" + checksum: fe8f2ac3c70b1b63ee3cef5c0a28680cb00b5747bfda1d9835695fab3ed602be41c5c799b1fc997b34b02633573fead25b12b036bdf5212f23a6aa9f59212e9b languageName: node linkType: hard @@ -7859,17 +8058,16 @@ __metadata: languageName: node linkType: hard -"fast-glob@npm:^3.1.1": - version: 3.2.5 - resolution: "fast-glob@npm:3.2.5" +"fast-glob@npm:^3.2.9": + version: 3.3.1 + resolution: "fast-glob@npm:3.3.1" dependencies: "@nodelib/fs.stat": ^2.0.2 "@nodelib/fs.walk": ^1.2.3 - glob-parent: ^5.1.0 + glob-parent: ^5.1.2 merge2: ^1.3.0 - micromatch: ^4.0.2 - picomatch: ^2.2.1 - checksum: 5d6772c9b63dbb739d60b5630851e1f2cbf9744119e0968eac44c9f8cbc2d3d5cb4f2f0c74715ccb23daa336c87bea42186ed367e6c991afee61cd3d967320eb + micromatch: ^4.0.4 + checksum: b6f3add6403e02cf3a798bfbb1183d0f6da2afd368f27456010c0bc1f9640aea308243d4cb2c0ab142f618276e65ecb8be1661d7c62a7b4e5ba774b9ce5432e5 languageName: node linkType: hard @@ -7931,8 +8129,8 @@ __metadata: linkType: hard "fbjs@npm:^0.8.1": - version: 0.8.17 - resolution: "fbjs@npm:0.8.17" + version: 0.8.18 + resolution: "fbjs@npm:0.8.18" dependencies: core-js: ^1.0.0 isomorphic-fetch: ^2.1.1 @@ -7940,8 +8138,8 @@ __metadata: object-assign: ^4.1.0 promise: ^7.1.1 setimmediate: ^1.0.5 - ua-parser-js: ^0.7.18 - checksum: e969aeb175ccf97d8818aab9907a78f253568e0cc1b8762621c5d235bf031419d7e700f16f7711e89dfd1e0fce2b87a05f8a2800f18df0a96258f0780615fd8b + ua-parser-js: ^0.7.30 + checksum: 668731b946a765908c9cbe51d5160f973abb78004b3d122587c3e930e3e1ddcc0ce2b17f2a8637dc9d733e149aa580f8d3035a35cc2d3bc78b78f1b19aab90e2 languageName: node linkType: hard @@ -8116,7 +8314,7 @@ __metadata: languageName: node linkType: hard -"find-up@npm:4.1.0, find-up@npm:^4.0.0": +"find-up@npm:4.1.0, find-up@npm:^4.0.0, find-up@npm:^4.1.0": version: 4.1.0 resolution: "find-up@npm:4.1.0" dependencies: @@ -8189,13 +8387,13 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.10.0": - version: 1.14.1 - resolution: "follow-redirects@npm:1.14.1" +"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.14.0": + version: 1.15.3 + resolution: "follow-redirects@npm:1.15.3" peerDependenciesMeta: debug: optional: true - checksum: 7381a55bdc6951c5c1ab73a8da99d9fa4c0496ce72dba92cd2ac2babe0e3ebde9b81c5bca889498ad95984bc773d713284ca2bb17f1b1e1416e5f6531e39a488 + checksum: 584da22ec5420c837bd096559ebfb8fe69d82512d5585004e36a3b4a6ef6d5905780e0c74508c7b72f907d1fa2b7bd339e613859e9c304d0dc96af2027fd0231 languageName: node linkType: hard @@ -8343,7 +8541,7 @@ __metadata: languageName: node linkType: hard -"fs-minipass@npm:^2.0.0": +"fs-minipass@npm:^2.0.0, fs-minipass@npm:^2.1.0": version: 2.1.0 resolution: "fs-minipass@npm:2.1.0" dependencies: @@ -8430,7 +8628,7 @@ __metadata: languageName: node linkType: hard -"fstream@npm:1.0.12, fstream@npm:^1.0.0, fstream@npm:^1.0.12": +"fstream@npm:1.0.12": version: 1.0.12 resolution: "fstream@npm:1.0.12" dependencies: @@ -8475,6 +8673,23 @@ __metadata: languageName: node linkType: hard +"gauge@npm:^3.0.0": + version: 3.0.2 + resolution: "gauge@npm:3.0.2" + dependencies: + aproba: ^1.0.3 || ^2.0.0 + color-support: ^1.1.2 + console-control-strings: ^1.0.0 + has-unicode: ^2.0.1 + object-assign: ^4.1.1 + signal-exit: ^3.0.0 + string-width: ^4.2.3 + strip-ansi: ^6.0.1 + wide-align: ^1.1.2 + checksum: 81296c00c7410cdd48f997800155fbead4f32e4f82109be0719c63edc8560e6579946cc8abd04205297640691ec26d21b578837fd13a4e96288ab4b40b1dc3e9 + languageName: node + linkType: hard + "gauge@npm:^4.0.0": version: 4.0.2 resolution: "gauge@npm:4.0.2" @@ -8492,22 +8707,6 @@ __metadata: languageName: node linkType: hard -"gauge@npm:~2.7.3": - version: 2.7.4 - resolution: "gauge@npm:2.7.4" - dependencies: - aproba: ^1.0.3 - console-control-strings: ^1.0.0 - has-unicode: ^2.0.0 - object-assign: ^4.1.0 - signal-exit: ^3.0.0 - string-width: ^1.0.1 - strip-ansi: ^3.0.1 - wide-align: ^1.1.0 - checksum: a89b53cee65579b46832e050b5f3a79a832cc422c190de79c6b8e2e15296ab92faddde6ddf2d376875cbba2b043efa99b9e1ed8124e7365f61b04e3cee9d40ee - languageName: node - linkType: hard - "gaze@npm:^1.0.0": version: 1.1.3 resolution: "gaze@npm:1.1.3" @@ -8616,7 +8815,7 @@ __metadata: languageName: node linkType: hard -"glob-parent@npm:^5.0.0, glob-parent@npm:^5.1.0, glob-parent@npm:~5.1.2": +"glob-parent@npm:^5.0.0, glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" dependencies: @@ -8646,6 +8845,19 @@ __metadata: languageName: node linkType: hard +"glob@npm:^8.0.1": + version: 8.1.0 + resolution: "glob@npm:8.1.0" + dependencies: + fs.realpath: ^1.0.0 + inflight: ^1.0.4 + inherits: 2 + minimatch: ^5.0.1 + once: ^1.3.0 + checksum: 92fbea3221a7d12075f26f0227abac435de868dd0736a17170663783296d0dd8d3d532a5672b4488a439bf5d7fb85cdd07c11185d6cd39184f0385cbdfb86a47 + languageName: node + linkType: hard + "global-dirs@npm:^2.0.1": version: 2.1.0 resolution: "global-dirs@npm:2.1.0" @@ -8714,16 +8926,16 @@ __metadata: linkType: hard "globby@npm:^11.0.3": - version: 11.0.4 - resolution: "globby@npm:11.0.4" + version: 11.1.0 + resolution: "globby@npm:11.1.0" dependencies: array-union: ^2.1.0 dir-glob: ^3.0.1 - fast-glob: ^3.1.1 - ignore: ^5.1.4 - merge2: ^1.3.0 + fast-glob: ^3.2.9 + ignore: ^5.2.0 + merge2: ^1.4.1 slash: ^3.0.0 - checksum: d3e02d5e459e02ffa578b45f040381c33e3c0538ed99b958f0809230c423337999867d7b0dbf752ce93c46157d3bbf154d3fff988a93ccaeb627df8e1841775b + checksum: b4be8885e0cfa018fc783792942d53926c35c50b3aefd3fdcfb9d22c627639dc26bd2327a40a0b74b074100ce95bb7187bfeae2f236856aa3de183af7a02aea6 languageName: node linkType: hard @@ -8806,6 +9018,13 @@ __metadata: languageName: node linkType: hard +"hard-rejection@npm:^2.1.0": + version: 2.1.0 + resolution: "hard-rejection@npm:2.1.0" + checksum: 7baaf80a0c7fff4ca79687b4060113f1529589852152fa935e6787a2bc96211e784ad4588fb3048136ff8ffc9dfcf3ae385314a5b24db32de20bea0d1597f9dc + languageName: node + linkType: hard + "harmony-reflect@npm:^1.4.6": version: 1.6.2 resolution: "harmony-reflect@npm:1.6.2" @@ -8850,7 +9069,7 @@ __metadata: languageName: node linkType: hard -"has-unicode@npm:^2.0.0, has-unicode@npm:^2.0.1": +"has-unicode@npm:^2.0.1": version: 2.0.1 resolution: "has-unicode@npm:2.0.1" checksum: 1eab07a7436512db0be40a710b29b5dc21fa04880b7f63c9980b706683127e3c1b57cb80ea96d47991bdae2dfe479604f6a1ba410106ee1046a41d1bd0814400 @@ -9007,6 +9226,15 @@ __metadata: languageName: node linkType: hard +"hosted-git-info@npm:^4.0.1": + version: 4.1.0 + resolution: "hosted-git-info@npm:4.1.0" + dependencies: + lru-cache: ^6.0.0 + checksum: c3f87b3c2f7eb8c2748c8f49c0c2517c9a95f35d26f4bf54b2a8cba05d2e668f3753548b6ea366b18ec8dadb4e12066e19fa382a01496b0ffa0497eb23cbe461 + languageName: node + linkType: hard + "hpack.js@npm:^2.1.6": version: 2.1.6 resolution: "hpack.js@npm:2.1.6" @@ -9112,9 +9340,9 @@ __metadata: linkType: hard "http-cache-semantics@npm:^4.1.0": - version: 4.1.0 - resolution: "http-cache-semantics@npm:4.1.0" - checksum: 974de94a81c5474be07f269f9fd8383e92ebb5a448208223bfb39e172a9dbc26feff250192ecc23b9593b3f92098e010406b0f24bd4d588d631f80214648ed42 + version: 4.1.1 + resolution: "http-cache-semantics@npm:4.1.1" + checksum: 83ac0bc60b17a3a36f9953e7be55e5c8f41acc61b22583060e8dedc9dd5e3607c823a88d0926f9150e571f90946835c7fe150732801010845c72cd8bbff1a236 languageName: node linkType: hard @@ -9170,6 +9398,17 @@ __metadata: languageName: node linkType: hard +"http-proxy-agent@npm:^4.0.1": + version: 4.0.1 + resolution: "http-proxy-agent@npm:4.0.1" + dependencies: + "@tootallnate/once": 1 + agent-base: 6 + debug: 4 + checksum: c6a5da5a1929416b6bbdf77b1aca13888013fe7eb9d59fc292e25d18e041bb154a8dfada58e223fc7b76b9b2d155a87e92e608235201f77d34aa258707963a82 + languageName: node + linkType: hard + "http-proxy-agent@npm:^5.0.0": version: 5.0.0 resolution: "http-proxy-agent@npm:5.0.0" @@ -9319,10 +9558,10 @@ __metadata: languageName: node linkType: hard -"ignore@npm:^5.1.4": - version: 5.1.8 - resolution: "ignore@npm:5.1.8" - checksum: 967abadb61e2cb0e5c5e8c4e1686ab926f91bc1a4680d994b91947d3c65d04c3ae126dcdf67f08e0feeb8ff8407d453e641aeeddcc47a3a3cca359f283cf6121 +"ignore@npm:^5.2.0": + version: 5.2.4 + resolution: "ignore@npm:5.2.4" + checksum: 3d4c309c6006e2621659311783eaea7ebcd41fe4ca1d78c91c473157ad6666a57a2df790fe0d07a12300d9aac2888204d7be8d59f9aaf665b1c7fcdb432517ef languageName: node linkType: hard @@ -9418,18 +9657,6 @@ __metadata: languageName: node linkType: hard -"in-publish@npm:^2.0.0": - version: 2.0.1 - resolution: "in-publish@npm:2.0.1" - bin: - in-install: in-install.js - in-publish: in-publish.js - not-in-install: not-in-install.js - not-in-publish: not-in-publish.js - checksum: 5efde2992a1e76550614a5a2c51f53669d9f3ee3a11d364de22b0c77c41de0b87c52c4c9b04375eaa276761b1944dd2b166323894d2344192328ffe85927ad38 - languageName: node - linkType: hard - "indefinite-observable@npm:^1.0.1": version: 1.0.2 resolution: "indefinite-observable@npm:1.0.2" @@ -9614,6 +9841,13 @@ __metadata: languageName: node linkType: hard +"ip@npm:^2.0.0": + version: 2.0.0 + resolution: "ip@npm:2.0.0" + checksum: cfcfac6b873b701996d71ec82a7dd27ba92450afdb421e356f44044ed688df04567344c36cbacea7d01b1c39a4c732dc012570ebe9bebfb06f27314bca625349 + languageName: node + linkType: hard + "ipaddr.js@npm:1.9.1, ipaddr.js@npm:^1.9.0": version: 1.9.1 resolution: "ipaddr.js@npm:1.9.1" @@ -9758,8 +9992,17 @@ __metadata: languageName: node linkType: hard -"is-data-descriptor@npm:^0.1.4": - version: 0.1.4 +"is-core-module@npm:^2.5.0": + version: 2.13.0 + resolution: "is-core-module@npm:2.13.0" + dependencies: + has: ^1.0.3 + checksum: 053ab101fb390bfeb2333360fd131387bed54e476b26860dc7f5a700bbf34a0ec4454f7c8c4d43e8a0030957e4b3db6e16d35e1890ea6fb654c833095e040355 + languageName: node + linkType: hard + +"is-data-descriptor@npm:^0.1.4": + version: 0.1.4 resolution: "is-data-descriptor@npm:0.1.4" dependencies: kind-of: ^3.0.2 @@ -10017,7 +10260,7 @@ __metadata: languageName: node linkType: hard -"is-plain-obj@npm:^1.0.0": +"is-plain-obj@npm:^1.0.0, is-plain-obj@npm:^1.1.0": version: 1.1.0 resolution: "is-plain-obj@npm:1.1.0" checksum: 0ee04807797aad50859652a7467481816cbb57e5cc97d813a7dcd8915da8195dc68c436010bf39d195226cde6a2d352f4b815f16f26b7bf486a5754290629931 @@ -10749,7 +10992,7 @@ __metadata: languageName: node linkType: hard -"js-base64@npm:^2.1.8": +"js-base64@npm:^2.1.8, js-base64@npm:^2.4.9": version: 2.6.4 resolution: "js-base64@npm:2.6.4" checksum: 5f4084078d6c46f8529741d110df84b14fac3276b903760c21fa8cc8521370d607325dfe1c1a9fbbeaae1ff8e602665aaeef1362427d8fef704f9e3659472ce8 @@ -10917,10 +11160,10 @@ __metadata: languageName: node linkType: hard -"json-schema@npm:0.2.3": - version: 0.2.3 - resolution: "json-schema@npm:0.2.3" - checksum: bbc2070988fb5f2a2266a31b956f1b5660e03ea7eaa95b33402901274f625feb586ae0c485e1df854fde40a7f0dc679f3b3ca8e5b8d31f8ea07a0d834de785c7 +"json-schema@npm:0.4.0": + version: 0.4.0 + resolution: "json-schema@npm:0.4.0" + checksum: 66389434c3469e698da0df2e7ac5a3281bcff75e797a5c127db7c5b56270e01ae13d9afa3c03344f76e32e81678337a8c912bdbb75101c62e487dc3778461d72 languageName: node linkType: hard @@ -10964,24 +11207,22 @@ __metadata: linkType: hard "json5@npm:^1.0.1": - version: 1.0.1 - resolution: "json5@npm:1.0.1" + version: 1.0.2 + resolution: "json5@npm:1.0.2" dependencies: minimist: ^1.2.0 bin: json5: lib/cli.js - checksum: e76ea23dbb8fc1348c143da628134a98adf4c5a4e8ea2adaa74a80c455fc2cdf0e2e13e6398ef819bfe92306b610ebb2002668ed9fc1af386d593691ef346fc3 + checksum: 866458a8c58a95a49bef3adba929c625e82532bcff1fe93f01d29cb02cac7c3fe1f4b79951b7792c2da9de0b32871a8401a6e3c5b36778ad852bf5b8a61165d7 languageName: node linkType: hard "json5@npm:^2.1.2": - version: 2.2.0 - resolution: "json5@npm:2.2.0" - dependencies: - minimist: ^1.2.5 + version: 2.2.3 + resolution: "json5@npm:2.2.3" bin: json5: lib/cli.js - checksum: e88fc5274bb58fc99547baa777886b069d2dd96d9cfc4490b305fd16d711dabd5979e35a4f90873cefbeb552e216b041a304fe56702bedba76e19bc7845f208d + checksum: 2a7436a93393830bce797d4626275152e37e877b265e94ca69c99e3d20c2b9dab021279146a39cdb700e71b2dd32a4cebd1514cd57cee102b1af906ce5040349 languageName: node linkType: hard @@ -11018,14 +11259,14 @@ __metadata: linkType: hard "jsprim@npm:^1.2.2": - version: 1.4.1 - resolution: "jsprim@npm:1.4.1" + version: 1.4.2 + resolution: "jsprim@npm:1.4.2" dependencies: assert-plus: 1.0.0 extsprintf: 1.3.0 - json-schema: 0.2.3 + json-schema: 0.4.0 verror: 1.10.0 - checksum: 6bcb20ec265ae18bb48e540a6da2c65f9c844f7522712d6dfcb01039527a49414816f4869000493363f1e1ea96cbad00e46188d5ecc78257a19f152467587373 + checksum: 2ad1b9fdcccae8b3d580fa6ced25de930eaa1ad154db21bbf8478a4d30bbbec7925b5f5ff29b933fba9412b16a17bd484a8da4fdb3663b5e27af95dd693bab2a languageName: node linkType: hard @@ -11117,16 +11358,15 @@ __metadata: languageName: node linkType: hard -"jszip@npm:3.1.5": - version: 3.1.5 - resolution: "jszip@npm:3.1.5" +"jszip@npm:^3.10.1": + version: 3.10.1 + resolution: "jszip@npm:3.10.1" dependencies: - core-js: ~2.3.0 - es6-promise: ~3.0.2 - lie: ~3.1.0 + lie: ~3.3.0 pako: ~1.0.2 - readable-stream: ~2.0.6 - checksum: 2d0464089d7a4604c7b7586d089b7aa39fbcfe7cc058f7c066b3c92b43f3b94f69362d1b6dd8252049f5729e1fc452a788703382cbce6d77f607d3ce1227b231 + readable-stream: ~2.3.6 + setimmediate: ^1.0.5 + checksum: abc77bfbe33e691d4d1ac9c74c8851b5761fba6a6986630864f98d876f3fcc2d36817dfc183779f32c00157b5d53a016796677298272a714ae096dfe6b1c8b60 languageName: node linkType: hard @@ -11178,7 +11418,7 @@ __metadata: languageName: node linkType: hard -"kind-of@npm:^6.0.0, kind-of@npm:^6.0.2": +"kind-of@npm:^6.0.0, kind-of@npm:^6.0.2, kind-of@npm:^6.0.3": version: 6.0.3 resolution: "kind-of@npm:6.0.3" checksum: 3ab01e7b1d440b22fe4c31f23d8d38b4d9b91d9f291df683476576493d5dfd2e03848a8b05813dd0c3f0e835bc63f433007ddeceb71f05cb25c45ae1b19c6d3b @@ -11265,12 +11505,12 @@ __metadata: languageName: node linkType: hard -"lie@npm:~3.1.0": - version: 3.1.1 - resolution: "lie@npm:3.1.1" +"lie@npm:~3.3.0": + version: 3.3.0 + resolution: "lie@npm:3.3.0" dependencies: immediate: ~3.0.5 - checksum: 6da9f2121d2dbd15f1eca44c0c7e211e66a99c7b326ec8312645f3648935bc3a658cf0e9fa7b5f10144d9e2641500b4f55bd32754607c3de945b5f443e50ddd1 + checksum: 33102302cf19766f97919a6a98d481e01393288b17a6aa1f030a3542031df42736edde8dab29ffdbf90bebeffc48c761eb1d064dc77592ca3ba3556f9fe6d2a8 languageName: node linkType: hard @@ -11401,24 +11641,24 @@ __metadata: linkType: hard "loader-utils@npm:^1.1.0, loader-utils@npm:^1.2.3, loader-utils@npm:^1.4.0": - version: 1.4.0 - resolution: "loader-utils@npm:1.4.0" + version: 1.4.2 + resolution: "loader-utils@npm:1.4.2" dependencies: big.js: ^5.2.2 emojis-list: ^3.0.0 json5: ^1.0.1 - checksum: d150b15e7a42ac47d935c8b484b79e44ff6ab4c75df7cc4cb9093350cf014ec0b17bdb60c5d6f91a37b8b218bd63b973e263c65944f58ca2573e402b9a27e717 + checksum: eb6fb622efc0ffd1abdf68a2022f9eac62bef8ec599cf8adb75e94d1d338381780be6278534170e99edc03380a6d29bc7eb1563c89ce17c5fed3a0b17f1ad804 languageName: node linkType: hard "loader-utils@npm:^2.0.0": - version: 2.0.0 - resolution: "loader-utils@npm:2.0.0" + version: 2.0.4 + resolution: "loader-utils@npm:2.0.4" dependencies: big.js: ^5.2.2 emojis-list: ^3.0.0 json5: ^2.1.2 - checksum: 6856423131b50b6f5f259da36f498cfd7fc3c3f8bb17777cf87fdd9159e797d4ba4288d9a96415fd8da62c2906960e88f74711dee72d03a9003bddcd0d364a51 + checksum: a5281f5fff1eaa310ad5e1164095689443630f3411e927f95031ab4fb83b4a98f388185bb1fe949e8ab8d4247004336a625e9255c22122b815bb9a4c5d8fc3b7 languageName: node linkType: hard @@ -11451,14 +11691,7 @@ __metadata: languageName: node linkType: hard -"lodash-es@npm:4.17.14": - version: 4.17.14 - resolution: "lodash-es@npm:4.17.14" - checksum: 56d39dc8e76ac366eae79d4e8d7c19bd2f8981b640a46942bf2d88fa871b2e083e48fe2b895c84ed139e13c0b466cac22ea27d7394be04f2ba62c518392c39be - languageName: node - linkType: hard - -"lodash-es@npm:^4.17.10, lodash-es@npm:^4.17.5, lodash-es@npm:^4.2.1": +"lodash-es@npm:^4.17.10, lodash-es@npm:^4.17.21, lodash-es@npm:^4.17.5, lodash-es@npm:^4.2.1": version: 4.17.21 resolution: "lodash-es@npm:4.17.21" checksum: 05cbffad6e2adbb331a4e16fbd826e7faee403a1a04873b82b42c0f22090f280839f85b95393f487c1303c8a3d2a010048bf06151a6cbe03eee4d388fb0a12d2 @@ -11651,16 +11884,6 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^4.0.1": - version: 4.1.5 - resolution: "lru-cache@npm:4.1.5" - dependencies: - pseudomap: ^1.0.2 - yallist: ^2.1.2 - checksum: 4bb4b58a36cd7dc4dcec74cbe6a8f766a38b7426f1ff59d4cf7d82a2aa9b9565cd1cb98f6ff60ce5cd174524868d7bc9b7b1c294371851356066ca9ac4cf135a - languageName: node - linkType: hard - "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1" @@ -11686,6 +11909,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^7.7.1": + version: 7.18.3 + resolution: "lru-cache@npm:7.18.3" + checksum: e550d772384709deea3f141af34b6d4fa392e2e418c1498c078de0ee63670f1f46f5eee746e8ef7e69e1c895af0d4224e62ee33e66a543a14763b0f2e74c1356 + languageName: node + linkType: hard + "make-dir@npm:^2.0.0, make-dir@npm:^2.1.0": version: 2.1.0 resolution: "make-dir@npm:2.1.0" @@ -11729,6 +11959,54 @@ __metadata: languageName: node linkType: hard +"make-fetch-happen@npm:^10.0.4": + version: 10.2.1 + resolution: "make-fetch-happen@npm:10.2.1" + dependencies: + agentkeepalive: ^4.2.1 + cacache: ^16.1.0 + http-cache-semantics: ^4.1.0 + http-proxy-agent: ^5.0.0 + https-proxy-agent: ^5.0.0 + is-lambda: ^1.0.1 + lru-cache: ^7.7.1 + minipass: ^3.1.6 + minipass-collect: ^1.0.2 + minipass-fetch: ^2.0.3 + minipass-flush: ^1.0.5 + minipass-pipeline: ^1.2.4 + negotiator: ^0.6.3 + promise-retry: ^2.0.1 + socks-proxy-agent: ^7.0.0 + ssri: ^9.0.0 + checksum: 2332eb9a8ec96f1ffeeea56ccefabcb4193693597b132cd110734d50f2928842e22b84cfa1508e921b8385cdfd06dda9ad68645fed62b50fff629a580f5fb72c + languageName: node + linkType: hard + +"make-fetch-happen@npm:^9.1.0": + version: 9.1.0 + resolution: "make-fetch-happen@npm:9.1.0" + dependencies: + agentkeepalive: ^4.1.3 + cacache: ^15.2.0 + http-cache-semantics: ^4.1.0 + http-proxy-agent: ^4.0.1 + https-proxy-agent: ^5.0.0 + is-lambda: ^1.0.1 + lru-cache: ^6.0.0 + minipass: ^3.1.3 + minipass-collect: ^1.0.2 + minipass-fetch: ^1.3.2 + minipass-flush: ^1.0.5 + minipass-pipeline: ^1.2.4 + negotiator: ^0.6.2 + promise-retry: ^2.0.1 + socks-proxy-agent: ^6.0.0 + ssri: ^8.0.0 + checksum: 0eb371c85fdd0b1584fcfdf3dc3c62395761b3c14658be02620c310305a9a7ecf1617a5e6fb30c1d081c5c8aaf177fa133ee225024313afabb7aa6a10f1e3d04 + languageName: node + linkType: hard + "makeerror@npm:1.0.x": version: 1.0.11 resolution: "makeerror@npm:1.0.11" @@ -11768,6 +12046,13 @@ __metadata: languageName: node linkType: hard +"map-obj@npm:^4.0.0": + version: 4.3.0 + resolution: "map-obj@npm:4.3.0" + checksum: fbc554934d1a27a1910e842bc87b177b1a556609dd803747c85ece420692380827c6ae94a95cce4407c054fa0964be3bf8226f7f2cb2e9eeee432c7c1985684e + languageName: node + linkType: hard + "map-visit@npm:^1.0.0": version: 1.0.0 resolution: "map-visit@npm:1.0.0" @@ -11891,6 +12176,26 @@ __metadata: languageName: node linkType: hard +"meow@npm:^9.0.0": + version: 9.0.0 + resolution: "meow@npm:9.0.0" + dependencies: + "@types/minimist": ^1.2.0 + camelcase-keys: ^6.2.2 + decamelize: ^1.2.0 + decamelize-keys: ^1.1.0 + hard-rejection: ^2.1.0 + minimist-options: 4.1.0 + normalize-package-data: ^3.0.0 + read-pkg-up: ^7.0.1 + redent: ^3.0.0 + trim-newlines: ^3.0.0 + type-fest: ^0.18.0 + yargs-parser: ^20.2.3 + checksum: 99799c47247f4daeee178e3124f6ef6f84bde2ba3f37652865d5d8f8b8adcf9eedfc551dd043e2455cd8206545fd848e269c0c5ab6b594680a0ad4d3617c9639 + languageName: node + linkType: hard + "merge-deep@npm:^3.0.2": version: 3.0.3 resolution: "merge-deep@npm:3.0.3" @@ -11916,7 +12221,7 @@ __metadata: languageName: node linkType: hard -"merge2@npm:^1.2.3, merge2@npm:^1.3.0": +"merge2@npm:^1.2.3, merge2@npm:^1.3.0, merge2@npm:^1.4.1": version: 1.4.1 resolution: "merge2@npm:1.4.1" checksum: 7268db63ed5169466540b6fb947aec313200bcf6d40c5ab722c22e242f651994619bcd85601602972d3c85bd2cc45a358a4c61937e9f11a061919a1da569b0c2 @@ -11958,13 +12263,13 @@ __metadata: languageName: node linkType: hard -"micromatch@npm:^4.0.2": - version: 4.0.4 - resolution: "micromatch@npm:4.0.4" +"micromatch@npm:^4.0.4": + version: 4.0.5 + resolution: "micromatch@npm:4.0.5" dependencies: - braces: ^3.0.1 - picomatch: ^2.2.3 - checksum: ef3d1c88e79e0a68b0e94a03137676f3324ac18a908c245a9e5936f838079fcc108ac7170a5fadc265a9c2596963462e402841406bda1a4bb7b68805601d631c + braces: ^3.0.2 + picomatch: ^2.3.1 + checksum: 02a17b671c06e8fefeeb6ef996119c1e597c942e632a21ef589154f23898c9c6a9858526246abb14f8bca6e77734aa9dcf65476fca47cedfb80d9577d52843fc languageName: node linkType: hard @@ -12037,6 +12342,13 @@ __metadata: languageName: node linkType: hard +"min-indent@npm:^1.0.0": + version: 1.0.1 + resolution: "min-indent@npm:1.0.1" + checksum: bfc6dd03c5eaf623a4963ebd94d087f6f4bbbfd8c41329a7f09706b0cb66969c4ddd336abeb587bc44bc6f08e13bf90f0b374f9d71f9f01e04adc2cd6f083ef1 + languageName: node + linkType: hard + "mini-css-extract-plugin@npm:0.9.0": version: 0.9.0 resolution: "mini-css-extract-plugin@npm:0.9.0" @@ -12065,7 +12377,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:3.0.4, minimatch@npm:^3.0.4, minimatch@npm:~3.0.2": +"minimatch@npm:3.0.4": version: 3.0.4 resolution: "minimatch@npm:3.0.4" dependencies: @@ -12074,10 +12386,48 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^3.0.4": + version: 3.1.2 + resolution: "minimatch@npm:3.1.2" + dependencies: + brace-expansion: ^1.1.7 + checksum: c154e566406683e7bcb746e000b84d74465b3a832c45d59912b9b55cd50dee66e5c4b1e5566dba26154040e51672f9aa450a9aef0c97cfc7336b78b7afb9540a + languageName: node + linkType: hard + +"minimatch@npm:^5.0.1": + version: 5.1.6 + resolution: "minimatch@npm:5.1.6" + dependencies: + brace-expansion: ^2.0.1 + checksum: 7564208ef81d7065a370f788d337cd80a689e981042cb9a1d0e6580b6c6a8c9279eba80010516e258835a988363f99f54a6f711a315089b8b42694f5da9d0d77 + languageName: node + linkType: hard + +"minimatch@npm:~3.0.2": + version: 3.0.8 + resolution: "minimatch@npm:3.0.8" + dependencies: + brace-expansion: ^1.1.7 + checksum: 850cca179cad715133132693e6963b0db64ab0988c4d211415b087fc23a3e46321e2c5376a01bf5623d8782aba8bdf43c571e2e902e51fdce7175c7215c29f8b + languageName: node + linkType: hard + +"minimist-options@npm:4.1.0": + version: 4.1.0 + resolution: "minimist-options@npm:4.1.0" + dependencies: + arrify: ^1.0.1 + is-plain-obj: ^1.1.0 + kind-of: ^6.0.3 + checksum: 8c040b3068811e79de1140ca2b708d3e203c8003eb9a414c1ab3cd467fc5f17c9ca02a5aef23bedc51a7f8bfbe77f87e9a7e31ec81fba304cda675b019496f4e + languageName: node + linkType: hard + "minimist@npm:^1.1.1, minimist@npm:^1.1.3, minimist@npm:^1.2.0, minimist@npm:^1.2.5": - version: 1.2.5 - resolution: "minimist@npm:1.2.5" - checksum: 86706ce5b36c16bfc35c5fe3dbb01d5acdc9a22f2b6cc810b6680656a1d2c0e44a0159c9a3ba51fb072bb5c203e49e10b51dcd0eec39c481f4c42086719bae52 + version: 1.2.8 + resolution: "minimist@npm:1.2.8" + checksum: 75a6d645fb122dad29c06a7597bddea977258957ed88d7a6df59b5cd3fe4a527e253e9bbf2e783e4b73657f9098b96a5fe96ab8a113655d4109108577ecf85b0 languageName: node linkType: hard @@ -12090,6 +12440,21 @@ __metadata: languageName: node linkType: hard +"minipass-fetch@npm:^1.3.2": + version: 1.4.1 + resolution: "minipass-fetch@npm:1.4.1" + dependencies: + encoding: ^0.1.12 + minipass: ^3.1.0 + minipass-sized: ^1.0.3 + minizlib: ^2.0.0 + dependenciesMeta: + encoding: + optional: true + checksum: ec93697bdb62129c4e6c0104138e681e30efef8c15d9429dd172f776f83898471bc76521b539ff913248cc2aa6d2b37b652c993504a51cc53282563640f29216 + languageName: node + linkType: hard + "minipass-fetch@npm:^2.0.2": version: 2.0.3 resolution: "minipass-fetch@npm:2.0.3" @@ -12105,6 +12470,21 @@ __metadata: languageName: node linkType: hard +"minipass-fetch@npm:^2.0.3": + version: 2.1.2 + resolution: "minipass-fetch@npm:2.1.2" + dependencies: + encoding: ^0.1.13 + minipass: ^3.1.6 + minipass-sized: ^1.0.3 + minizlib: ^2.1.2 + dependenciesMeta: + encoding: + optional: true + checksum: 3f216be79164e915fc91210cea1850e488793c740534985da017a4cbc7a5ff50506956d0f73bb0cb60e4fe91be08b6b61ef35101706d3ef5da2c8709b5f08f91 + languageName: node + linkType: hard + "minipass-flush@npm:^1.0.5": version: 1.0.5 resolution: "minipass-flush@npm:1.0.5" @@ -12141,6 +12521,15 @@ __metadata: languageName: node linkType: hard +"minipass@npm:^3.1.0, minipass@npm:^3.1.3": + version: 3.3.6 + resolution: "minipass@npm:3.3.6" + dependencies: + yallist: ^4.0.0 + checksum: a30d083c8054cee83cdcdc97f97e4641a3f58ae743970457b1489ce38ee1167b3aaf7d815cd39ec7a99b9c40397fd4f686e83750e73e652b21cb516f6d845e48 + languageName: node + linkType: hard + "minipass@npm:^3.1.6": version: 3.1.6 resolution: "minipass@npm:3.1.6" @@ -12150,7 +12539,14 @@ __metadata: languageName: node linkType: hard -"minizlib@npm:^2.1.1, minizlib@npm:^2.1.2": +"minipass@npm:^5.0.0": + version: 5.0.0 + resolution: "minipass@npm:5.0.0" + checksum: 425dab288738853fded43da3314a0b5c035844d6f3097a8e3b5b29b328da8f3c1af6fc70618b32c29ff906284cf6406b6841376f21caaadd0793c1d5a6a620ea + languageName: node + linkType: hard + +"minizlib@npm:^2.0.0, minizlib@npm:^2.1.1, minizlib@npm:^2.1.2": version: 2.1.2 resolution: "minizlib@npm:2.1.2" dependencies: @@ -12198,7 +12594,7 @@ __metadata: languageName: node linkType: hard -"mkdirp@npm:>=0.5 0, mkdirp@npm:^0.5.0, mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.3, mkdirp@npm:^0.5.4, mkdirp@npm:^0.5.5, mkdirp@npm:~0.5.1": +"mkdirp@npm:>=0.5 0, mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.3, mkdirp@npm:^0.5.4, mkdirp@npm:^0.5.5, mkdirp@npm:~0.5.1": version: 0.5.5 resolution: "mkdirp@npm:0.5.5" dependencies: @@ -12218,10 +12614,10 @@ __metadata: languageName: node linkType: hard -"moment@npm:2.29.1, moment@npm:^2.27.0": - version: 2.29.1 - resolution: "moment@npm:2.29.1" - checksum: 1e14d5f422a2687996be11dd2d50c8de3bd577c4a4ca79ba5d02c397242a933e5b941655de6c8cb90ac18f01cc4127e55b4a12ae3c527a6c0a274e455979345e +"moment@npm:^2.27.0, moment@npm:^2.29.4": + version: 2.29.4 + resolution: "moment@npm:2.29.4" + checksum: 0ec3f9c2bcba38dc2451b1daed5daded747f17610b92427bebe1d08d48d8b7bdd8d9197500b072d14e326dd0ccf3e326b9e3d07c5895d3d49e39b6803b76e80e languageName: node linkType: hard @@ -12309,6 +12705,15 @@ __metadata: languageName: node linkType: hard +"nan@npm:^2.17.0": + version: 2.18.0 + resolution: "nan@npm:2.18.0" + dependencies: + node-gyp: latest + checksum: 4fe42f58456504eab3105c04a5cffb72066b5f22bd45decf33523cb17e7d6abc33cca2a19829407b9000539c5cb25f410312d4dc5b30220167a3594896ea6a0a + languageName: node + linkType: hard + "nanomatch@npm:^1.2.9": version: 1.2.13 resolution: "nanomatch@npm:1.2.13" @@ -12359,7 +12764,7 @@ __metadata: languageName: node linkType: hard -"negotiator@npm:^0.6.3": +"negotiator@npm:^0.6.2, negotiator@npm:^0.6.3": version: 0.6.3 resolution: "negotiator@npm:0.6.3" checksum: b8ffeb1e262eff7968fc90a2b6767b04cfd9842582a9d0ece0af7049537266e7b2506dfb1d107a32f06dd849ab2aea834d5830f7f4d0e5cb7d36e1ae55d021d9 @@ -12410,13 +12815,6 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:2.6.1": - version: 2.6.1 - resolution: "node-fetch@npm:2.6.1" - checksum: 91075bedd57879117e310fbcc36983ad5d699e522edb1ebcdc4ee5294c982843982652925c3532729fdc86b2d64a8a827797a745f332040d91823c8752ee4d7c - languageName: node - linkType: hard - "node-fetch@npm:^1.0.1": version: 1.7.3 resolution: "node-fetch@npm:1.7.3" @@ -12427,6 +12825,20 @@ __metadata: languageName: node linkType: hard +"node-fetch@npm:^2.6.12": + version: 2.7.0 + resolution: "node-fetch@npm:2.7.0" + dependencies: + whatwg-url: ^5.0.0 + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: d76d2f5edb451a3f05b15115ec89fc6be39de37c6089f1b6368df03b91e1633fd379a7e01b7ab05089a25034b2023d959b47e59759cb38d88341b2459e89d6e5 + languageName: node + linkType: hard + "node-forge@npm:^0.10.0": version: 0.10.0 resolution: "node-forge@npm:0.10.0" @@ -12434,25 +12846,23 @@ __metadata: languageName: node linkType: hard -"node-gyp@npm:^3.8.0": - version: 3.8.0 - resolution: "node-gyp@npm:3.8.0" +"node-gyp@npm:^8.4.1": + version: 8.4.1 + resolution: "node-gyp@npm:8.4.1" dependencies: - fstream: ^1.0.0 - glob: ^7.0.3 - graceful-fs: ^4.1.2 - mkdirp: ^0.5.0 - nopt: 2 || 3 - npmlog: 0 || 1 || 2 || 3 || 4 - osenv: 0 - request: ^2.87.0 - rimraf: 2 - semver: ~5.3.0 - tar: ^2.0.0 - which: 1 + env-paths: ^2.2.0 + glob: ^7.1.4 + graceful-fs: ^4.2.6 + make-fetch-happen: ^9.1.0 + nopt: ^5.0.0 + npmlog: ^6.0.0 + rimraf: ^3.0.2 + semver: ^7.3.5 + tar: ^6.1.2 + which: ^2.0.2 bin: - node-gyp: ./bin/node-gyp.js - checksum: e99d740db6f5462cfd2f03fdfa89bae7e509e37f158d78a2fec0c858984cceb801723510656110d8f1d0ecf69cc2ceba8b477d22aac3e69ce8094db19dff6b2b + node-gyp: bin/node-gyp.js + checksum: 341710b5da39d3660e6a886b37e210d33f8282047405c2e62c277bcc744c7552c5b8b972ebc3a7d5c2813794e60cc48c3ebd142c46d6e0321db4db6c92dd0355 languageName: node linkType: hard @@ -12534,66 +12944,84 @@ __metadata: languageName: node linkType: hard -"node-releases@npm:^1.1.52, node-releases@npm:^1.1.71": +"node-releases@npm:^1.1.52": version: 1.1.73 resolution: "node-releases@npm:1.1.73" checksum: 44a6caec3330538a669c156fa84833725ae92b317585b106e08ab292c14da09f30cb913c10f1a7402180a51b10074832d4e045b6c3512d74c37d86b41a69e63b languageName: node linkType: hard -"node-sass-chokidar@npm:1.5.0": - version: 1.5.0 - resolution: "node-sass-chokidar@npm:1.5.0" +"node-releases@npm:^2.0.13": + version: 2.0.13 + resolution: "node-releases@npm:2.0.13" + checksum: 17ec8f315dba62710cae71a8dad3cd0288ba943d2ece43504b3b1aa8625bf138637798ab470b1d9035b0545996f63000a8a926e0f6d35d0996424f8b6d36dda3 + languageName: node + linkType: hard + +"node-sass-chokidar@npm:^2.0.0": + version: 2.0.0 + resolution: "node-sass-chokidar@npm:2.0.0" dependencies: async-foreach: ^0.1.3 chokidar: ^3.4.0 get-stdin: ^4.0.1 glob: ^7.0.3 meow: ^3.7.0 - node-sass: ^4.14.1 + node-sass: ^7.0.1 sass-graph: ^2.2.4 stdout-stream: ^1.4.0 bin: node-sass-chokidar: bin/node-sass-chokidar - checksum: fb3197b1dcc06b7b3c8e7d2e63ab9397745466f2e78871f8ba112f3740f7092f37f6668bc25a0d7bea82fe8a78b4d8dd009151eb0f041dc62029e76a38004e8d + checksum: 5aeffc93cddf5cc32d0e86de4999e56e3cdccb1d86b5ed211e2d661f4e579bac19c078ca791662e2aaff9752ba2e18ce87324c07de5b3222064a4c9703856d9c languageName: node linkType: hard -"node-sass@npm:^4.14.1, node-sass@npm:^4.9.4": - version: 4.14.1 - resolution: "node-sass@npm:4.14.1" +"node-sass@npm:^7.0.1": + version: 7.0.3 + resolution: "node-sass@npm:7.0.3" dependencies: async-foreach: ^0.1.3 - chalk: ^1.1.1 - cross-spawn: ^3.0.0 + chalk: ^4.1.2 + cross-spawn: ^7.0.3 gaze: ^1.0.0 get-stdin: ^4.0.1 glob: ^7.0.3 - in-publish: ^2.0.0 lodash: ^4.17.15 - meow: ^3.7.0 - mkdirp: ^0.5.1 + meow: ^9.0.0 nan: ^2.13.2 - node-gyp: ^3.8.0 - npmlog: ^4.0.0 + node-gyp: ^8.4.1 + npmlog: ^5.0.0 request: ^2.88.0 - sass-graph: 2.2.5 + sass-graph: ^4.0.1 stdout-stream: ^1.4.0 true-case-path: ^1.0.2 bin: node-sass: bin/node-sass - checksum: 6894709e7d8c4482fd0d53ce8473fd7c3ddf38ef36a109bbda96aca750e7c28777e89fcf277c9e032ca69328062f10a12be61e01a385ed0d221fbbdfd0ac7448 + checksum: 7d577d0fb68948959f367341e6cfc2858aa37abc5fadbd9e6b477ed0d192bebf7f8516d0b53c27be30ab05d5cd62d8a9bab08cc4442ef901b02cb51d864b4419 languageName: node linkType: hard -"nopt@npm:2 || 3": - version: 3.0.6 - resolution: "nopt@npm:3.0.6" +"node-sass@npm:^9.0.0": + version: 9.0.0 + resolution: "node-sass@npm:9.0.0" dependencies: - abbrev: 1 + async-foreach: ^0.1.3 + chalk: ^4.1.2 + cross-spawn: ^7.0.3 + gaze: ^1.0.0 + get-stdin: ^4.0.1 + glob: ^7.0.3 + lodash: ^4.17.15 + make-fetch-happen: ^10.0.4 + meow: ^9.0.0 + nan: ^2.17.0 + node-gyp: ^8.4.1 + sass-graph: ^4.0.1 + stdout-stream: ^1.4.0 + true-case-path: ^2.2.1 bin: - nopt: ./bin/nopt.js - checksum: 7f8579029a0d7cb3341c6b1610b31e363f708b7aaaaf3580e3ec5ae8528d1f3a79d350d8bfa331776e6c6703a5a148b72edd9b9b4c1dd55874d8e70e963d1e20 + node-sass: bin/node-sass + checksum: b15fa76b1564c37d65cde7556731e3c09b49c74a6919cd5cff6f71ddbe454bd1ad9e458f5f02f0f81f43919b8755b5f56cf657fa4e32a0a2644a48fbc07147bb languageName: node linkType: hard @@ -12608,7 +13036,7 @@ __metadata: languageName: node linkType: hard -"normalize-package-data@npm:^2.3.2, normalize-package-data@npm:^2.3.4": +"normalize-package-data@npm:^2.3.2, normalize-package-data@npm:^2.3.4, normalize-package-data@npm:^2.5.0": version: 2.5.0 resolution: "normalize-package-data@npm:2.5.0" dependencies: @@ -12620,6 +13048,18 @@ __metadata: languageName: node linkType: hard +"normalize-package-data@npm:^3.0.0": + version: 3.0.3 + resolution: "normalize-package-data@npm:3.0.3" + dependencies: + hosted-git-info: ^4.0.1 + is-core-module: ^2.5.0 + semver: ^7.3.4 + validate-npm-package-license: ^3.0.1 + checksum: bbcee00339e7c26fdbc760f9b66d429258e2ceca41a5df41f5df06cc7652de8d82e8679ff188ca095cad8eff2b6118d7d866af2b68400f74602fbcbce39c160a + languageName: node + linkType: hard + "normalize-path@npm:^2.1.1": version: 2.1.1 resolution: "normalize-path@npm:2.1.1" @@ -12687,15 +13127,15 @@ __metadata: languageName: node linkType: hard -"npmlog@npm:0 || 1 || 2 || 3 || 4, npmlog@npm:^4.0.0": - version: 4.1.2 - resolution: "npmlog@npm:4.1.2" +"npmlog@npm:^5.0.0": + version: 5.0.1 + resolution: "npmlog@npm:5.0.1" dependencies: - are-we-there-yet: ~1.1.2 - console-control-strings: ~1.1.0 - gauge: ~2.7.3 - set-blocking: ~2.0.0 - checksum: edbda9f95ec20957a892de1839afc6fb735054c3accf6fbefe767bac9a639fd5cea2baeac6bd2bcd50a85cb54924d57d9886c81c7fbc2332c2ddd19227504192 + are-we-there-yet: ^2.0.0 + console-control-strings: ^1.1.0 + gauge: ^3.0.0 + set-blocking: ^2.0.0 + checksum: 516b2663028761f062d13e8beb3f00069c5664925871a9b57989642ebe09f23ab02145bf3ab88da7866c4e112cafff72401f61a672c7c8a20edc585a7016ef5f languageName: node linkType: hard @@ -12720,12 +13160,12 @@ __metadata: languageName: node linkType: hard -"nth-check@npm:^2.0.0": - version: 2.0.0 - resolution: "nth-check@npm:2.0.0" +"nth-check@npm:^2.0.1": + version: 2.1.1 + resolution: "nth-check@npm:2.1.1" dependencies: boolbase: ^1.0.0 - checksum: a22eb19616719d46a5b517f76c32e67e4a2b6a229d67ba2f3efb296e24d79687d52b904c2298cd16510215d5d2a419f8ba671f5957a3b4b73905f62ba7aafa3b + checksum: 5afc3dafcd1573b08877ca8e6148c52abd565f1d06b1eb08caf982e3fa289a82f2cae697ffb55b5021e146d60443f1590a5d6b944844e944714a5b549675bcd3 languageName: node linkType: hard @@ -12983,15 +13423,6 @@ __metadata: languageName: node linkType: hard -"original@npm:^1.0.0": - version: 1.0.2 - resolution: "original@npm:1.0.2" - dependencies: - url-parse: ^1.4.3 - checksum: 8dca9311dab50c8953366127cb86b7c07bf547d6aa6dc6873a75964b7563825351440557e5724d9c652c5e99043b8295624f106af077f84bccf19592e421beb9 - languageName: node - linkType: hard - "os-browserify@npm:^0.3.0": version: 0.3.0 resolution: "os-browserify@npm:0.3.0" @@ -13015,23 +13446,13 @@ __metadata: languageName: node linkType: hard -"os-tmpdir@npm:^1.0.0, os-tmpdir@npm:^1.0.1, os-tmpdir@npm:~1.0.2": +"os-tmpdir@npm:^1.0.1, os-tmpdir@npm:~1.0.2": version: 1.0.2 resolution: "os-tmpdir@npm:1.0.2" checksum: 5666560f7b9f10182548bf7013883265be33620b1c1b4a4d405c25be2636f970c5488ff3e6c48de75b55d02bde037249fe5dbfbb4c0fb7714953d56aed062e6d languageName: node linkType: hard -"osenv@npm:0": - version: 0.1.5 - resolution: "osenv@npm:0.1.5" - dependencies: - os-homedir: ^1.0.0 - os-tmpdir: ^1.0.0 - checksum: 779d261920f2a13e5e18cf02446484f12747d3f2ff82280912f52b213162d43d312647a40c332373cbccd5e3fb8126915d3bfea8dde4827f70f82da76e52d359 - languageName: node - linkType: hard - "ospath@npm:^1.2.2": version: 1.2.2 resolution: "ospath@npm:1.2.2" @@ -13462,13 +13883,34 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.3": +"picocolors@npm:^0.2.1": + version: 0.2.1 + resolution: "picocolors@npm:0.2.1" + checksum: 3b0f441f0062def0c0f39e87b898ae7461c3a16ffc9f974f320b44c799418cabff17780ee647fda42b856a1dc45897e2c62047e1b546d94d6d5c6962f45427b2 + languageName: node + linkType: hard + +"picocolors@npm:^1.0.0": + version: 1.0.0 + resolution: "picocolors@npm:1.0.0" + checksum: a2e8092dd86c8396bdba9f2b5481032848525b3dc295ce9b57896f931e63fc16f79805144321f72976383fc249584672a75cc18d6777c6b757603f372f745981 + languageName: node + linkType: hard + +"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1": version: 2.3.0 resolution: "picomatch@npm:2.3.0" checksum: 16818720ea7c5872b6af110760dee856c8e4cd79aed1c7a006d076b1cc09eff3ae41ca5019966694c33fbd2e1cc6ea617ab10e4adac6df06556168f13be3fca2 languageName: node linkType: hard +"picomatch@npm:^2.3.1": + version: 2.3.1 + resolution: "picomatch@npm:2.3.1" + checksum: 050c865ce81119c4822c45d3c84f1ced46f93a0126febae20737bd05ca20589c564d6e9226977df859ed5e03dc73f02584a2b0faad36e896936238238b0446cf + languageName: node + linkType: hard + "pify@npm:^2.0.0, pify@npm:^2.2.0": version: 2.3.0 resolution: "pify@npm:2.3.0" @@ -13698,6 +14140,17 @@ __metadata: languageName: node linkType: hard +"postcss-combine-duplicated-selectors@npm:^10.0.3": + version: 10.0.3 + resolution: "postcss-combine-duplicated-selectors@npm:10.0.3" + dependencies: + postcss-selector-parser: ^6.0.4 + peerDependencies: + postcss: ^8.1.0 + checksum: 45c3dff41d0cddb510752ed92fe8c7fc66e5cf88f4988314655419d3ecdf1dc66f484a25ee73f4f292da5da851a0fdba0ec4d59bdedeee935d05b26d31d997ed + languageName: node + linkType: hard + "postcss-convert-values@npm:^4.0.1": version: 4.0.1 resolution: "postcss-convert-values@npm:4.0.1" @@ -14342,6 +14795,16 @@ __metadata: languageName: node linkType: hard +"postcss-selector-parser@npm:^6.0.4": + version: 6.0.13 + resolution: "postcss-selector-parser@npm:6.0.13" + dependencies: + cssesc: ^3.0.0 + util-deprecate: ^1.0.2 + checksum: f89163338a1ce3b8ece8e9055cd5a3165e79a15e1c408e18de5ad8f87796b61ec2d48a2902d179ae0c4b5de10fccd3a325a4e660596549b040bc5ad1b465f096 + languageName: node + linkType: hard + "postcss-svgo@npm:^4.0.3": version: 4.0.3 resolution: "postcss-svgo@npm:4.0.3" @@ -14401,13 +14864,12 @@ __metadata: linkType: hard "postcss@npm:^7, postcss@npm:^7.0.0, postcss@npm:^7.0.1, postcss@npm:^7.0.14, postcss@npm:^7.0.17, postcss@npm:^7.0.2, postcss@npm:^7.0.23, postcss@npm:^7.0.27, postcss@npm:^7.0.32, postcss@npm:^7.0.5, postcss@npm:^7.0.6": - version: 7.0.36 - resolution: "postcss@npm:7.0.36" + version: 7.0.39 + resolution: "postcss@npm:7.0.39" dependencies: - chalk: ^2.4.2 + picocolors: ^0.2.1 source-map: ^0.6.1 - supports-color: ^6.1.0 - checksum: 4cfc0989b9ad5d0e8971af80d87f9c5beac5c84cb89ff22ad69852edf73c0a2fa348e7e0a135b5897bf893edad0fe86c428769050431ad9b532f072ff530828d + checksum: 4ac793f506c23259189064bdc921260d869a115a82b5e713973c5af8e94fbb5721a5cc3e1e26840500d7e1f1fa42a209747c5b1a151918a9bc11f0d7ed9048e3 languageName: node linkType: hard @@ -14473,13 +14935,6 @@ __metadata: languageName: node linkType: hard -"process-nextick-args@npm:~1.0.6": - version: 1.0.7 - resolution: "process-nextick-args@npm:1.0.7" - checksum: 41224fbc803ac6c96907461d4dfc20942efa3ca75f2d521bcf7cf0e89f8dec127fb3fb5d76746b8fb468a232ea02d84824fae08e027aec185fd29049c66d49f8 - languageName: node - linkType: hard - "process-nextick-args@npm:~2.0.0": version: 2.0.1 resolution: "process-nextick-args@npm:2.0.1" @@ -14596,13 +15051,6 @@ __metadata: languageName: node linkType: hard -"pseudomap@npm:^1.0.2": - version: 1.0.2 - resolution: "pseudomap@npm:1.0.2" - checksum: 856c0aae0ff2ad60881168334448e898ad7a0e45fe7386d114b150084254c01e200c957cf378378025df4e052c7890c5bd933939b0e0d2ecfcc1dc2f0b2991f5 - languageName: node - linkType: hard - "psl@npm:^1.1.28": version: 1.8.0 resolution: "psl@npm:1.8.0" @@ -14691,9 +15139,9 @@ __metadata: linkType: hard "qs@npm:~6.5.2": - version: 6.5.2 - resolution: "qs@npm:6.5.2" - checksum: 24af7b9928ba2141233fba2912876ff100403dba1b08b20c3b490da9ea6c636760445ea2211a079e7dfa882a5cf8f738337b3748c8bdd0f93358fa8881d2db8f + version: 6.5.3 + resolution: "qs@npm:6.5.3" + checksum: 6f20bf08cabd90c458e50855559539a28d00b2f2e7dddcb66082b16a43188418cb3cb77cbd09268bcef6022935650f0534357b8af9eeb29bf0f27ccb17655692 languageName: node linkType: hard @@ -14746,6 +15194,13 @@ __metadata: languageName: node linkType: hard +"quick-lru@npm:^4.0.1": + version: 4.0.1 + resolution: "quick-lru@npm:4.0.1" + checksum: bea46e1abfaa07023e047d3cf1716a06172c4947886c053ede5c50321893711577cb6119360f810cc3ffcd70c4d7db4069c3cee876b358ceff8596e062bd1154 + languageName: node + linkType: hard + "raf@npm:^3.4.1": version: 3.4.1 resolution: "raf@npm:3.4.1" @@ -14903,17 +15358,17 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:16.8.6": - version: 16.8.6 - resolution: "react-dom@npm:16.8.6" +"react-dom@npm:16.14.0": + version: 16.14.0 + resolution: "react-dom@npm:16.14.0" dependencies: loose-envify: ^1.1.0 object-assign: ^4.1.1 prop-types: ^15.6.2 - scheduler: ^0.13.6 + scheduler: ^0.19.1 peerDependencies: - react: ^16.0.0 - checksum: 7f8ebd8523eb4a14a1439efa009d020abc0529da25d0de251a4f3d5b3781061f6b30d72425f5fe944317850997efc6c1d667e99b1fd70172f30a976a00008bf6 + react: ^16.14.0 + checksum: 5a5c49da0f106b2655a69f96c622c347febcd10532db391c262b26aec225b235357d9da1834103457683482ab1b229af7a50f6927a6b70e53150275e31785544 languageName: node linkType: hard @@ -15057,9 +15512,9 @@ __metadata: languageName: node linkType: hard -"react-rte@npm:0.16.3": - version: 0.16.3 - resolution: "react-rte@npm:0.16.3" +"react-rte@npm:^0.16.5": + version: 0.16.5 + resolution: "react-rte@npm:0.16.5" dependencies: babel-runtime: ^6.23.0 class-autobind: ^0.1.4 @@ -15072,9 +15527,9 @@ __metadata: draft-js-utils: ">=0.2.0" immutable: ^3.8.1 peerDependencies: - react: 0.14.x || 15.x.x || 16.x.x - react-dom: 0.14.x || 15.x.x || 16.x.x - checksum: 812ed35161bea266cbdf42da0173398834eba0166328a01ae521c86b29b573ed25107985d3a077344ecd30536804376c0d94cb7d534abecdbc1dbf4d7af8bdc4 + react: 0.14.x || 15.x.x || 16.x.x || 17.x.x + react-dom: 0.14.x || 15.x.x || 16.x.x || 17.x.x + checksum: 3af94acd7790989c44babc7b1327a0a047a1a7fd03f13d5c1ef2d276e949d7346a8b1b875b8457c2624e5c0cdcb6e3980f967280c52ff2f92d8234debec01c03 languageName: node linkType: hard @@ -15237,15 +15692,14 @@ __metadata: languageName: node linkType: hard -"react@npm:16.8.6": - version: 16.8.6 - resolution: "react@npm:16.8.6" +"react@npm:16.14.0": + version: 16.14.0 + resolution: "react@npm:16.14.0" dependencies: loose-envify: ^1.1.0 object-assign: ^4.1.1 prop-types: ^15.6.2 - scheduler: ^0.13.6 - checksum: 8dfdbec9af6999c2cfb33a9389995c6401daba732e1ee7e0a4920d28fd2e8e6b0fde99dfe4b8e2f81efc4a962c92656e3e79e221323449e55850232163f15ff4 + checksum: 8484f3ecb13414526f2a7412190575fc134da785c02695eb92bb6028c930bfe1c238d7be2a125088fec663cc7cda0a3623373c46807cf2c281f49c34b79881ac languageName: node linkType: hard @@ -15279,6 +15733,17 @@ __metadata: languageName: node linkType: hard +"read-pkg-up@npm:^7.0.1": + version: 7.0.1 + resolution: "read-pkg-up@npm:7.0.1" + dependencies: + find-up: ^4.1.0 + read-pkg: ^5.2.0 + type-fest: ^0.8.1 + checksum: e4e93ce70e5905b490ca8f883eb9e48b5d3cebc6cd4527c25a0d8f3ae2903bd4121c5ab9c5a3e217ada0141098eeb661313c86fa008524b089b8ed0b7f165e44 + languageName: node + linkType: hard + "read-pkg@npm:^1.0.0": version: 1.1.0 resolution: "read-pkg@npm:1.1.0" @@ -15312,7 +15777,19 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:1 || 2, readable-stream@npm:^2.0.0, readable-stream@npm:^2.0.1, readable-stream@npm:^2.0.2, readable-stream@npm:^2.0.6, readable-stream@npm:^2.1.5, readable-stream@npm:^2.2.2, readable-stream@npm:^2.3.3, readable-stream@npm:^2.3.6, readable-stream@npm:~2.3.6": +"read-pkg@npm:^5.2.0": + version: 5.2.0 + resolution: "read-pkg@npm:5.2.0" + dependencies: + "@types/normalize-package-data": ^2.4.0 + normalize-package-data: ^2.5.0 + parse-json: ^5.0.0 + type-fest: ^0.6.0 + checksum: eb696e60528b29aebe10e499ba93f44991908c57d70f2d26f369e46b8b9afc208ef11b4ba64f67630f31df8b6872129e0a8933c8c53b7b4daf0eace536901222 + languageName: node + linkType: hard + +"readable-stream@npm:1 || 2, readable-stream@npm:^2.0.0, readable-stream@npm:^2.0.1, readable-stream@npm:^2.0.2, readable-stream@npm:^2.1.5, readable-stream@npm:^2.2.2, readable-stream@npm:^2.3.3, readable-stream@npm:^2.3.6, readable-stream@npm:~2.3.6": version: 2.3.7 resolution: "readable-stream@npm:2.3.7" dependencies: @@ -15338,20 +15815,6 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:~2.0.6": - version: 2.0.6 - resolution: "readable-stream@npm:2.0.6" - dependencies: - core-util-is: ~1.0.0 - inherits: ~2.0.1 - isarray: ~1.0.0 - process-nextick-args: ~1.0.6 - string_decoder: ~0.10.x - util-deprecate: ~1.0.1 - checksum: 5258b248531e58cbd855dab6a67dde3f4939f78a6d7707042ce61a74fe3421a7596405bc9c8970484dc9b2d929136e6cc40985f76759b9264a0a273f6136ed3b - languageName: node - linkType: hard - "readdirp@npm:^2.2.1": version: 2.2.1 resolution: "readdirp@npm:2.2.1" @@ -15448,6 +15911,25 @@ __metadata: languageName: node linkType: hard +"redent@npm:^3.0.0": + version: 3.0.0 + resolution: "redent@npm:3.0.0" + dependencies: + indent-string: ^4.0.0 + strip-indent: ^3.0.0 + checksum: fa1ef20404a2d399235e83cc80bd55a956642e37dd197b4b612ba7327bf87fa32745aeb4a1634b2bab25467164ab4ed9c15be2c307923dd08b0fe7c52431ae6b + languageName: node + linkType: hard + +"redux-devtools-extension@npm:^2.13.9": + version: 2.13.9 + resolution: "redux-devtools-extension@npm:2.13.9" + peerDependencies: + redux: ^3.1.0 || ^4.0.0 + checksum: 603d48fd6acf3922ef373b251ab3fdbb990035e90284191047b29d25b06ea18122bc4ef01e0704ccae495acb27ab5e47b560937e98213605dd88299470025db9 + languageName: node + linkType: hard + "redux-devtools-instrument@npm:^1.0.1": version: 1.10.0 resolution: "redux-devtools-instrument@npm:1.10.0" @@ -16153,31 +16635,31 @@ __metadata: languageName: node linkType: hard -"sass-graph@npm:2.2.5": - version: 2.2.5 - resolution: "sass-graph@npm:2.2.5" +"sass-graph@npm:^2.2.4": + version: 2.2.6 + resolution: "sass-graph@npm:2.2.6" dependencies: glob: ^7.0.0 lodash: ^4.0.0 scss-tokenizer: ^0.2.3 - yargs: ^13.3.2 + yargs: ^7.0.0 bin: sassgraph: bin/sassgraph - checksum: 283b6e5a38c8b4fca77cdc4fc1da9641679120dba80e89361c82b6a3975f90d01cc78129f9f8fd148822e5a648f540c58c9a38b8c2b11ca97abc4f381613c013 + checksum: 1fb1719c659fdea00a9f55be9722c5902c3d1f1a0919d2e5ceb8a318064f2b214981d98b7d7fecaafc25f522302f919a948351e4ae1d1680b9c045d563550a93 languageName: node linkType: hard -"sass-graph@npm:^2.2.4": - version: 2.2.6 - resolution: "sass-graph@npm:2.2.6" +"sass-graph@npm:^4.0.1": + version: 4.0.1 + resolution: "sass-graph@npm:4.0.1" dependencies: glob: ^7.0.0 - lodash: ^4.0.0 - scss-tokenizer: ^0.2.3 - yargs: ^7.0.0 + lodash: ^4.17.11 + scss-tokenizer: ^0.4.3 + yargs: ^17.2.1 bin: sassgraph: bin/sassgraph - checksum: 1fb1719c659fdea00a9f55be9722c5902c3d1f1a0919d2e5ceb8a318064f2b214981d98b7d7fecaafc25f522302f919a948351e4ae1d1680b9c045d563550a93 + checksum: 896f99253bd77a429a95e483ebddee946e195b61d3f84b3e1ccf8ad843265ec0585fa40bf55fbf354c5f57eb9fd0349834a8b190cd2161ab1234cb9af10e3601 languageName: node linkType: hard @@ -16222,16 +16704,6 @@ __metadata: languageName: node linkType: hard -"scheduler@npm:^0.13.6": - version: 0.13.6 - resolution: "scheduler@npm:0.13.6" - dependencies: - loose-envify: ^1.1.0 - object-assign: ^4.1.1 - checksum: c82c705f6d0d6df87b26bf2cca33f427e91889438c0435ade3ee7f41860eda4dd7f3171ca2d93e8fe9431f3bd831ca0e267a401a0296e4b14de05e389f82d320 - languageName: node - linkType: hard - "scheduler@npm:^0.19.1": version: 0.19.1 resolution: "scheduler@npm:0.19.1" @@ -16274,6 +16746,16 @@ __metadata: languageName: node linkType: hard +"scss-tokenizer@npm:^0.4.3": + version: 0.4.3 + resolution: "scss-tokenizer@npm:0.4.3" + dependencies: + js-base64: ^2.4.9 + source-map: ^0.7.3 + checksum: f3697bb155ae23d88c7cd0275988a73231fe675fbbd250b4e56849ba66319fc249a597f3799a92f9890b12007f00f8f6a7f441283e634679e2acdb2287a341d1 + languageName: node + linkType: hard + "select-hose@npm:^2.0.0": version: 2.0.0 resolution: "select-hose@npm:2.0.0" @@ -16291,15 +16773,15 @@ __metadata: linkType: hard "semver@npm:2 || 3 || 4 || 5, semver@npm:^5.3.0, semver@npm:^5.4.1, semver@npm:^5.5.0, semver@npm:^5.5.1, semver@npm:^5.6.0, semver@npm:^5.7.0, semver@npm:^5.7.1": - version: 5.7.1 - resolution: "semver@npm:5.7.1" + version: 5.7.2 + resolution: "semver@npm:5.7.2" bin: - semver: ./bin/semver - checksum: 57fd0acfd0bac382ee87cd52cd0aaa5af086a7dc8d60379dfe65fea491fb2489b6016400813930ecd61fd0952dae75c115287a1b16c234b1550887117744dfaf + semver: bin/semver + checksum: fb4ab5e0dd1c22ce0c937ea390b4a822147a9c53dbd2a9a0132f12fe382902beef4fbf12cf51bb955248d8d15874ce8cd89532569756384f994309825f10b686 languageName: node linkType: hard -"semver@npm:6.3.0, semver@npm:^6.0.0, semver@npm:^6.1.1, semver@npm:^6.1.2, semver@npm:^6.2.0, semver@npm:^6.3.0": +"semver@npm:6.3.0": version: 6.3.0 resolution: "semver@npm:6.3.0" bin: @@ -16317,23 +16799,23 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.3.5": - version: 7.3.5 - resolution: "semver@npm:7.3.5" - dependencies: - lru-cache: ^6.0.0 +"semver@npm:^6.0.0, semver@npm:^6.1.1, semver@npm:^6.1.2, semver@npm:^6.2.0, semver@npm:^6.3.0": + version: 6.3.1 + resolution: "semver@npm:6.3.1" bin: semver: bin/semver.js - checksum: 5eafe6102bea2a7439897c1856362e31cc348ccf96efd455c8b5bc2c61e6f7e7b8250dc26b8828c1d76a56f818a7ee907a36ae9fb37a599d3d24609207001d60 + checksum: ae47d06de28836adb9d3e25f22a92943477371292d9b665fb023fae278d345d508ca1958232af086d85e0155aee22e313e100971898bbb8d5d89b8b1d4054ca2 languageName: node linkType: hard -"semver@npm:~5.3.0": - version: 5.3.0 - resolution: "semver@npm:5.3.0" +"semver@npm:^7.3.4, semver@npm:^7.3.5": + version: 7.5.4 + resolution: "semver@npm:7.5.4" + dependencies: + lru-cache: ^6.0.0 bin: - semver: ./bin/semver - checksum: 2717b14299c76a4b35aec0aafebca22a3644da2942d2a4095f26e36d77a9bbe17a9a3a5199795f83edd26323d5c22024a2d9d373a038dec4e023156fa166d314 + semver: bin/semver.js + checksum: 12d8ad952fa353b0995bf180cdac205a4068b759a140e5d3c608317098b3575ac2f1e09182206bf2eb26120e1c0ed8fb92c48c592f6099680de56bb071423ca3 languageName: node linkType: hard @@ -16394,7 +16876,7 @@ __metadata: languageName: node linkType: hard -"set-blocking@npm:^2.0.0, set-blocking@npm:~2.0.0": +"set-blocking@npm:^2.0.0": version: 2.0.0 resolution: "set-blocking@npm:2.0.0" checksum: 6e65a05f7cf7ebdf8b7c75b101e18c0b7e3dff4940d480efed8aad3a36a4005140b660fa1d804cb8bce911cac290441dc728084a30504d3516ac2ff7ad607b02 @@ -16690,6 +17172,17 @@ __metadata: languageName: node linkType: hard +"socks-proxy-agent@npm:^6.0.0": + version: 6.2.1 + resolution: "socks-proxy-agent@npm:6.2.1" + dependencies: + agent-base: ^6.0.2 + debug: ^4.3.3 + socks: ^2.6.2 + checksum: 9ca089d489e5ee84af06741135c4b0d2022977dad27ac8d649478a114cdce87849e8d82b7c22b51501a4116e231241592946fc7fae0afc93b65030ee57084f58 + languageName: node + linkType: hard + "socks-proxy-agent@npm:^6.1.1": version: 6.1.1 resolution: "socks-proxy-agent@npm:6.1.1" @@ -16701,6 +17194,17 @@ __metadata: languageName: node linkType: hard +"socks-proxy-agent@npm:^7.0.0": + version: 7.0.0 + resolution: "socks-proxy-agent@npm:7.0.0" + dependencies: + agent-base: ^6.0.2 + debug: ^4.3.3 + socks: ^2.6.2 + checksum: 720554370154cbc979e2e9ce6a6ec6ced205d02757d8f5d93fe95adae454fc187a5cbfc6b022afab850a5ce9b4c7d73e0f98e381879cf45f66317a4895953846 + languageName: node + linkType: hard + "socks@npm:^2.6.1": version: 2.6.2 resolution: "socks@npm:2.6.2" @@ -16711,6 +17215,16 @@ __metadata: languageName: node linkType: hard +"socks@npm:^2.6.2": + version: 2.7.1 + resolution: "socks@npm:2.7.1" + dependencies: + ip: ^2.0.0 + smart-buffer: ^4.2.0 + checksum: 259d9e3e8e1c9809a7f5c32238c3d4d2a36b39b83851d0f573bfde5f21c4b1288417ce1af06af1452569cd1eb0841169afd4998f0e04ba04656f6b7f0e46d748 + languageName: node + linkType: hard + "sort-keys@npm:^1.0.0": version: 1.1.2 resolution: "sort-keys@npm:1.1.2" @@ -16789,6 +17303,13 @@ __metadata: languageName: node linkType: hard +"source-map@npm:^0.7.3": + version: 0.7.4 + resolution: "source-map@npm:0.7.4" + checksum: 01cc5a74b1f0e1d626a58d36ad6898ea820567e87f18dfc9d24a9843a351aaa2ec09b87422589906d6ff1deed29693e176194dc88bcae7c9a852dc74b311dbf5 + languageName: node + linkType: hard + "spdx-correct@npm:^3.0.0": version: 3.1.1 resolution: "spdx-correct@npm:3.1.1" @@ -16913,7 +17434,7 @@ __metadata: languageName: node linkType: hard -"ssri@npm:^8.0.1": +"ssri@npm:^8.0.0, ssri@npm:^8.0.1": version: 8.0.1 resolution: "ssri@npm:8.0.1" dependencies: @@ -16922,6 +17443,15 @@ __metadata: languageName: node linkType: hard +"ssri@npm:^9.0.0": + version: 9.0.1 + resolution: "ssri@npm:9.0.1" + dependencies: + minipass: ^3.1.1 + checksum: fb58f5e46b6923ae67b87ad5ef1c5ab6d427a17db0bead84570c2df3cd50b4ceb880ebdba2d60726588272890bae842a744e1ecce5bd2a2a582fccd5068309eb + languageName: node + linkType: hard + "stable@npm:^0.1.8": version: 0.1.8 resolution: "stable@npm:0.1.8" @@ -17067,7 +17597,7 @@ __metadata: languageName: node linkType: hard -"string-width@npm:^1.0.2 || 2, string-width@npm:^2.1.1": +"string-width@npm:^2.1.1": version: 2.1.1 resolution: "string-width@npm:2.1.1" dependencies: @@ -17155,13 +17685,6 @@ __metadata: languageName: node linkType: hard -"string_decoder@npm:~0.10.x": - version: 0.10.31 - resolution: "string_decoder@npm:0.10.31" - checksum: fe00f8e303647e5db919948ccb5ce0da7dea209ab54702894dd0c664edd98e5d4df4b80d6fabf7b9e92b237359d21136c95bf068b2f7760b772ca974ba970202 - languageName: node - linkType: hard - "string_decoder@npm:~1.1.1": version: 1.1.1 resolution: "string_decoder@npm:1.1.1" @@ -17182,7 +17705,7 @@ __metadata: languageName: node linkType: hard -"strip-ansi@npm:6.0.0, strip-ansi@npm:^6.0.0": +"strip-ansi@npm:6.0.0": version: 6.0.0 resolution: "strip-ansi@npm:6.0.0" dependencies: @@ -17218,7 +17741,7 @@ __metadata: languageName: node linkType: hard -"strip-ansi@npm:^6.0.1": +"strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": version: 6.0.1 resolution: "strip-ansi@npm:6.0.1" dependencies: @@ -17278,6 +17801,15 @@ __metadata: languageName: node linkType: hard +"strip-indent@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-indent@npm:3.0.0" + dependencies: + min-indent: ^1.0.0 + checksum: 18f045d57d9d0d90cd16f72b2313d6364fd2cb4bf85b9f593523ad431c8720011a4d5f08b6591c9d580f446e78855c5334a30fb91aa1560f5d9f95ed1b4a0530 + languageName: node + linkType: hard + "strip-json-comments@npm:^3.0.1": version: 3.1.1 resolution: "strip-json-comments@npm:3.1.1" @@ -17410,28 +17942,17 @@ __metadata: languageName: node linkType: hard -"tar@npm:^2.0.0": - version: 2.2.2 - resolution: "tar@npm:2.2.2" - dependencies: - block-stream: "*" - fstream: ^1.0.12 - inherits: 2 - checksum: c0c3727d529077423cf771f9f9c06edaaff82034d05d685806d3cee69d334ee8e6f394ee8d02dbd294cdecb95bb22625703279caff24bdb90b17e59de03a4733 - languageName: node - linkType: hard - -"tar@npm:^6.0.2, tar@npm:^6.1.2": - version: 6.1.11 - resolution: "tar@npm:6.1.11" +"tar@npm:^6.0.2, tar@npm:^6.1.11, tar@npm:^6.1.2": + version: 6.2.0 + resolution: "tar@npm:6.2.0" dependencies: chownr: ^2.0.0 fs-minipass: ^2.0.0 - minipass: ^3.0.0 + minipass: ^5.0.0 minizlib: ^2.1.1 mkdirp: ^1.0.3 yallist: ^4.0.0 - checksum: a04c07bb9e2d8f46776517d4618f2406fb977a74d914ad98b264fc3db0fe8224da5bec11e5f8902c5b9bcb8ace22d95fbe3c7b36b8593b7dfc8391a25898f32f + checksum: db4d9fe74a2082c3a5016630092c54c8375ff3b280186938cfd104f2e089c4fd9bad58688ef6be9cf186a889671bf355c7cda38f09bbf60604b281715ca57f5c languageName: node linkType: hard @@ -17474,15 +17995,15 @@ __metadata: linkType: hard "terser@npm:^4.1.2, terser@npm:^4.6.12, terser@npm:^4.6.3": - version: 4.8.0 - resolution: "terser@npm:4.8.0" + version: 4.8.1 + resolution: "terser@npm:4.8.1" dependencies: commander: ^2.20.0 source-map: ~0.6.1 source-map-support: ~0.5.12 bin: terser: bin/terser - checksum: f980789097d4f856c1ef4b9a7ada37beb0bb022fb8aa3057968862b5864ad7c244253b3e269c9eb0ab7d0caf97b9521273f2d1cf1e0e942ff0016e0583859c71 + checksum: b342819bf7e82283059aaa3f22bb74deb1862d07573ba5a8947882190ad525fd9b44a15074986be083fd379c58b9a879457a330b66dcdb77b485c44267f9a55a languageName: node linkType: hard @@ -17601,9 +18122,9 @@ __metadata: linkType: hard "tmpl@npm:1.0.x": - version: 1.0.4 - resolution: "tmpl@npm:1.0.4" - checksum: 72c93335044b5b8771207d2e9cf71e8c26b110d0f0f924f6d6c06b509d89552c7c0e4086a574ce4f05110ac40c1faf6277ecba7221afeb57ebbab70d8de39cc4 + version: 1.0.5 + resolution: "tmpl@npm:1.0.5" + checksum: cd922d9b853c00fe414c5a774817be65b058d54a2d01ebb415840960406c669a0fc632f66df885e24cb022ec812739199ccbdb8d1164c3e513f85bfca5ab2873 languageName: node linkType: hard @@ -17701,7 +18222,14 @@ __metadata: languageName: node linkType: hard -"trim-newlines@npm:^1.0.0": +"tr46@npm:~0.0.3": + version: 0.0.3 + resolution: "tr46@npm:0.0.3" + checksum: 726321c5eaf41b5002e17ffbd1fb7245999a073e8979085dacd47c4b4e8068ff5777142fc6726d6ca1fd2ff16921b48788b87225cbc57c72636f6efa8efbffe3 + languageName: node + linkType: hard + +"trim-newlines@npm:^1.0.0, trim-newlines@npm:^3.0.0": version: 3.0.1 resolution: "trim-newlines@npm:3.0.1" checksum: b530f3fadf78e570cf3c761fb74fef655beff6b0f84b29209bac6c9622db75ad1417f4a7b5d54c96605dcd72734ad44526fef9f396807b90839449eb543c6206 @@ -17724,6 +18252,13 @@ __metadata: languageName: node linkType: hard +"true-case-path@npm:^2.2.1": + version: 2.2.1 + resolution: "true-case-path@npm:2.2.1" + checksum: fd5f1c2a87a122a65ffb1f84b580366be08dac7f552ea0fa4b5a6ab0a013af950b0e752beddb1c6c1652e6d6a2b293b7b3fd86a5a1706242ad365b68f1b5c6f1 + languageName: node + linkType: hard + "ts-mock-imports@npm:1.3.7": version: 1.3.7 resolution: "ts-mock-imports@npm:1.3.7" @@ -17884,6 +18419,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^0.18.0": + version: 0.18.1 + resolution: "type-fest@npm:0.18.1" + checksum: e96dcee18abe50ec82dab6cbc4751b3a82046da54c52e3b2d035b3c519732c0b3dd7a2fa9df24efd1a38d953d8d4813c50985f215f1957ee5e4f26b0fe0da395 + languageName: node + linkType: hard + "type-fest@npm:^0.21.3": version: 0.21.3 resolution: "type-fest@npm:0.21.3" @@ -17891,6 +18433,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^0.6.0": + version: 0.6.0 + resolution: "type-fest@npm:0.6.0" + checksum: b2188e6e4b21557f6e92960ec496d28a51d68658018cba8b597bd3ef757721d1db309f120ae987abeeda874511d14b776157ff809f23c6d1ce8f83b9b2b7d60f + languageName: node + linkType: hard + "type-fest@npm:^0.8.1": version: 0.8.1 resolution: "type-fest@npm:0.8.1" @@ -17949,10 +18498,10 @@ __metadata: languageName: node linkType: hard -"ua-parser-js@npm:^0.7.18": - version: 0.7.24 - resolution: "ua-parser-js@npm:0.7.24" - checksum: 722e0291fe6ad0d439cd29c4cd919f4e1b7262fe78e4c2149756180f8ad723ae04713839115eeb8738aca6d6258a743668090fb1e1417bc1fba27acc815a84e2 +"ua-parser-js@npm:^0.7.18, ua-parser-js@npm:^0.7.30": + version: 0.7.36 + resolution: "ua-parser-js@npm:0.7.36" + checksum: 04e18e7f6bf4964a10d74131ea9784c7f01d0c2d3b96f73340ac0a1f8e83d010b99fd7d425e7a2100fa40c58b72f6201408cbf4baa2df1103637f96fb59f2a30 languageName: node linkType: hard @@ -18041,6 +18590,15 @@ __metadata: languageName: node linkType: hard +"unique-filename@npm:^2.0.0": + version: 2.0.1 + resolution: "unique-filename@npm:2.0.1" + dependencies: + unique-slug: ^3.0.0 + checksum: 807acf3381aff319086b64dc7125a9a37c09c44af7620bd4f7f3247fcd5565660ac12d8b80534dcbfd067e6fe88a67e621386dd796a8af828d1337a8420a255f + languageName: node + linkType: hard + "unique-slug@npm:^2.0.0": version: 2.0.2 resolution: "unique-slug@npm:2.0.2" @@ -18050,6 +18608,15 @@ __metadata: languageName: node linkType: hard +"unique-slug@npm:^3.0.0": + version: 3.0.0 + resolution: "unique-slug@npm:3.0.0" + dependencies: + imurmurhash: ^0.1.4 + checksum: 49f8d915ba7f0101801b922062ee46b7953256c93ceca74303bd8e6413ae10aa7e8216556b54dc5382895e8221d04f1efaf75f945c2e4a515b4139f77aa6640c + languageName: node + linkType: hard + "universalify@npm:^0.1.0": version: 0.1.2 resolution: "universalify@npm:0.1.2" @@ -18102,6 +18669,20 @@ __metadata: languageName: node linkType: hard +"update-browserslist-db@npm:^1.0.13": + version: 1.0.13 + resolution: "update-browserslist-db@npm:1.0.13" + dependencies: + escalade: ^3.1.1 + picocolors: ^1.0.0 + peerDependencies: + browserslist: ">= 4.21.0" + bin: + update-browserslist-db: cli.js + checksum: 1e47d80182ab6e4ad35396ad8b61008ae2a1330221175d0abd37689658bdb61af9b705bfc41057fd16682474d79944fb2d86767c5ed5ae34b6276b9bed353322 + languageName: node + linkType: hard + "uri-js@npm:^4.2.2": version: 4.4.1 resolution: "uri-js@npm:4.4.1" @@ -18136,12 +18717,12 @@ __metadata: linkType: hard "url-parse@npm:^1.4.3": - version: 1.5.1 - resolution: "url-parse@npm:1.5.1" + version: 1.5.10 + resolution: "url-parse@npm:1.5.10" dependencies: querystringify: ^2.1.1 requires-port: ^1.0.0 - checksum: ce5c400db52d83b941944502000081e2338e46834cf16f2888961dc034ea5d49dbeb85ac8fdbe28c3fe738c09320a71a2f6d9286b748895cd464b1e208b6b991 + checksum: fbdba6b1d83336aca2216bbdc38ba658d9cfb8fc7f665eb8b17852de638ff7d1a162c198a8e4ed66001ddbf6c9888d41e4798912c62b4fd777a31657989f7bdf languageName: node linkType: hard @@ -18408,6 +18989,13 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^3.0.0": + version: 3.0.1 + resolution: "webidl-conversions@npm:3.0.1" + checksum: c92a0a6ab95314bde9c32e1d0a6dfac83b578f8fa5f21e675bc2706ed6981bc26b7eb7e6a1fab158e5ce4adf9caa4a0aee49a52505d4d13c7be545f15021b17c + languageName: node + linkType: hard + "webidl-conversions@npm:^4.0.2": version: 4.0.2 resolution: "webidl-conversions@npm:4.0.2" @@ -18595,6 +19183,16 @@ __metadata: languageName: node linkType: hard +"whatwg-url@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-url@npm:5.0.0" + dependencies: + tr46: ~0.0.3 + webidl-conversions: ^3.0.0 + checksum: b8daed4ad3356cc4899048a15b2c143a9aed0dfae1f611ebd55073310c7b910f522ad75d727346ad64203d7e6c79ef25eafd465f4d12775ca44b90fa82ed9e2c + languageName: node + linkType: hard + "whatwg-url@npm:^6.4.1": version: 6.5.0 resolution: "whatwg-url@npm:6.5.0" @@ -18644,7 +19242,7 @@ __metadata: languageName: node linkType: hard -"which@npm:1, which@npm:^1.2.9, which@npm:^1.3.0, which@npm:^1.3.1": +"which@npm:^1.2.9, which@npm:^1.3.0, which@npm:^1.3.1": version: 1.3.1 resolution: "which@npm:1.3.1" dependencies: @@ -18666,16 +19264,7 @@ __metadata: languageName: node linkType: hard -"wide-align@npm:^1.1.0": - version: 1.1.3 - resolution: "wide-align@npm:1.1.3" - dependencies: - string-width: ^1.0.2 || 2 - checksum: d09c8012652a9e6cab3e82338d1874a4d7db2ad1bd19ab43eb744acf0b9b5632ec406bdbbbb970a8f4771a7d5ef49824d038ba70aa884e7723f5b090ab87134d - languageName: node - linkType: hard - -"wide-align@npm:^1.1.5": +"wide-align@npm:^1.1.2, wide-align@npm:^1.1.5": version: 1.1.5 resolution: "wide-align@npm:1.1.5" dependencies: @@ -18685,9 +19274,9 @@ __metadata: linkType: hard "word-wrap@npm:~1.2.3": - version: 1.2.3 - resolution: "word-wrap@npm:1.2.3" - checksum: 30b48f91fcf12106ed3186ae4fa86a6a1842416df425be7b60485de14bec665a54a68e4b5156647dec3a70f25e84d270ca8bc8cd23182ed095f5c7206a938c1f + version: 1.2.5 + resolution: "word-wrap@npm:1.2.5" + checksum: f93ba3586fc181f94afdaff3a6fef27920b4b6d9eaefed0f428f8e07adea2a7f54a5f2830ce59406c8416f033f86902b91eb824072354645eea687dff3691ccb languageName: node linkType: hard @@ -19016,13 +19605,6 @@ __metadata: languageName: node linkType: hard -"yallist@npm:^2.1.2": - version: 2.1.2 - resolution: "yallist@npm:2.1.2" - checksum: 9ba99409209f485b6fcb970330908a6d41fa1c933f75e08250316cce19383179a6b70a7e0721b89672ebb6199cc377bf3e432f55100da6a7d6e11902b0a642cb - languageName: node - linkType: hard - "yallist@npm:^3.0.2": version: 3.1.1 resolution: "yallist@npm:3.1.1" @@ -19067,13 +19649,20 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:^20.2.2": +"yargs-parser@npm:^20.2.2, yargs-parser@npm:^20.2.3": version: 20.2.9 resolution: "yargs-parser@npm:20.2.9" checksum: 8bb69015f2b0ff9e17b2c8e6bfe224ab463dd00ca211eece72a4cd8a906224d2703fb8a326d36fdd0e68701e201b2a60ed7cf81ce0fd9b3799f9fe7745977ae3 languageName: node linkType: hard +"yargs-parser@npm:^21.1.1": + version: 21.1.1 + resolution: "yargs-parser@npm:21.1.1" + checksum: ed2d96a616a9e3e1cc7d204c62ecc61f7aaab633dcbfab2c6df50f7f87b393993fe6640d017759fe112d0cb1e0119f2b4150a87305cc873fd90831c6a58ccf1c + languageName: node + linkType: hard + "yargs-parser@npm:^5.0.1": version: 5.0.1 resolution: "yargs-parser@npm:5.0.1" @@ -19117,6 +19706,21 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^17.2.1": + version: 17.7.2 + resolution: "yargs@npm:17.7.2" + dependencies: + cliui: ^8.0.1 + escalade: ^3.1.1 + get-caller-file: ^2.0.5 + require-directory: ^2.1.1 + string-width: ^4.2.3 + y18n: ^5.0.5 + yargs-parser: ^21.1.1 + checksum: 73b572e863aa4a8cbef323dd911d79d193b772defd5a51aab0aca2d446655216f5002c42c5306033968193bdbf892a7a4c110b0d77954a7fdf563e653967b56a + languageName: node + linkType: hard + "yargs@npm:^7.0.0": version: 7.1.2 resolution: "yargs@npm:7.1.2"