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=e257a39cf400e4c454f71d7f02a5c483571f2907 Merge branch '21128-toolbar-context-menu' closes #21128 Arvados-DCO-1.1-Signed-off-by: Lisa Knox --- diff --git a/.gitignore b/.gitignore index 6a564a2b..7358d627 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ yarn-error.log* .idea .vscode +/public/config.json +/public/_health/ # see https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored .pnp.* diff --git a/.licenseignore b/.licenseignore index 7622a594..2d7deb73 100644 --- a/.licenseignore +++ b/.licenseignore @@ -15,6 +15,10 @@ public/* src/lib/cwl-svg/* tools/arvados_config.yml cypress/fixtures/files/5mb.bin +cypress/fixtures/files/cat.png +cypress/fixtures/files/banner.html +cypress/fixtures/files/tooltips.txt +cypress/fixtures/webdav-propfind-outputs.xml .yarn/releases/* package.json yarn.lock 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 2236f9de..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,13 +63,15 @@ clean-node-modules: clean: clean-rpm clean-deb clean-node-modules -arvados-server-install: +arvados-server-install: check-arvados-directory cd $(ARVADOS_DIRECTORY) go mod download cd cmd/arvados-server - go install + echo GOPATH is $(GOPATH) + GOFLAGS=-buildvcs=false go install cd - - ~/go/bin/arvados-server install -type test + ls -l $(GOPATH)/bin/arvados-server + $(GOPATH)/bin/arvados-server install -type test yarn-install: arvados-server-install yarn install @@ -76,12 +79,18 @@ yarn-install: arvados-server-install unit-tests: yarn-install yarn test --no-watchAll --bail --ci -integration-tests: yarn-install +integration-tests: yarn-install check-arvados-directory yarn run cypress install $(WORKSPACE)/tools/run-integration-tests.sh -a $(ARVADOS_DIRECTORY) -integration-tests-in-docker: workbench2-build-image - docker run -ti -v$(PWD):$(PWD) -w$(PWD) workbench2-build make integration-tests +integration-tests-in-docker: workbench2-build-image check-arvados-directory + docker run -ti -v$(PWD):/usr/src/workbench2 -v$(ARVADOS_DIRECTORY):/usr/src/arvados -w /usr/src/workbench2 -e ARVADOS_DIRECTORY=/usr/src/arvados workbench2-build make integration-tests + +unit-tests-in-docker: workbench2-build-image check-arvados-directory + docker run -ti -v$(PWD):/usr/src/workbench2 -v$(ARVADOS_DIRECTORY):/usr/src/arvados -w /usr/src/workbench2 -e ARVADOS_DIRECTORY=/usr/src/arvados workbench2-build make unit-tests + +tests-in-docker: workbench2-build-image check-arvados-directory + docker run -t -v$(PWD):/usr/src/workbench2 -v$(ARVADOS_DIRECTORY):/usr/src/arvados -w /usr/src/workbench2 -e ARVADOS_DIRECTORY=/usr/src/arvados -e ci="${ci}" workbench2-build make test test: unit-tests integration-tests @@ -121,16 +130,15 @@ $(RPM_FILE): build etc/arvados/workbench2/workbench2.example.json=/etc/arvados/$(APP_NAME)/workbench2.example.json copy: $(DEB_FILE) $(RPM_FILE) - for target in $(TARGETS) ; do \ - mkdir -p packages/$$target - if [[ $$target =~ ^centos ]]; then - cp -p $(RPM_FILE) packages/$$target ; \ - else - cp -p $(DEB_FILE) packages/$$target ; \ - fi - done - rm -f $(RPM_FILE) - rm -f $(DEB_FILE) + for target in $(TARGETS); do \ + mkdir -p "packages/$$target" && \ + case "$$target" in \ + centos*|rocky*) cp -p "$(RPM_FILE)" "packages/$$target" ;; \ + debian*|ubuntu*) cp -p "$(DEB_FILE)" "packages/$$target" ;; \ + *) echo "Unknown copy target $$target"; exit 1 ;; \ + esac ; \ + done ; \ + rm -f "$(DEB_FILE)" "$(RPM_FILE)" # use FPM to create DEB and RPM packages: copy @@ -143,12 +151,15 @@ packages-in-docker: check-arvados-directory workbench2-build-image docker run --env ci="true" \ --env ARVADOS_DIRECTORY=/tmp/arvados \ --env APP_NAME=${APP_NAME} \ + --env VERSION="${VERSION}" \ --env ITERATION=${ITERATION} \ --env TARGETS="${TARGETS}" \ + --env MAINTAINER="${MAINTAINER}" \ + --env DESCRIPTION="${DESCRIPTION}" \ -w="/tmp/workbench2" \ -t -v ${WORKSPACE}:/tmp/workbench2 \ -v ${ARVADOS_DIRECTORY}:/tmp/arvados workbench2-build:latest \ - make packages + sh -c 'git config --global --add safe.directory /tmp/workbench2 && make packages' workbench2-build-image: (cd docker && docker build -t workbench2-build .) diff --git a/cypress/fixtures/files/banner.html b/cypress/fixtures/files/banner.html new file mode 100644 index 00000000..34966bd9 --- /dev/null +++ b/cypress/fixtures/files/banner.html @@ -0,0 +1,5 @@ +
+

Hi there

+

This is my amazing

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

Test description

"); + + // Clear description + editProjectDescription(projName, "{selectall}{backspace}"); + + // Check description is null + verifyProjectDescription(projName, null); + + // Set description to contain whitespace + editProjectDescription(projName, "{selectall}{backspace} x"); + editProjectDescription(projName, "{backspace}"); + + // Check description is null + verifyProjectDescription(projName, null); + }); + + it('shows the appropriate buttons in the multiselect toolbar', () => { + + const msButtonTooltips = [ + 'API Details', + 'Add to Favorites', + 'Copy to clipboard', + 'Edit project', + 'Freeze Project', + 'Move to', + 'Move to trash', + 'New project', + 'Open in new tab', + 'Open with 3rd party client', + 'Share', + 'View details', + ]; + + cy.loginAs(activeUser); + const projName = `Test project (${Math.floor(999999 * Math.random())})`; + cy.get('[data-cy=side-panel-button]').click(); + cy.get('[data-cy=side-panel-new-project]').click(); + cy.get('[data-cy=form-dialog]') + .should('contain', 'New Project') + .within(() => { + cy.get('[data-cy=name-field]').within(() => { + cy.get('input').type(projName); + }); + }) + cy.get("[data-cy=form-submit-btn]").click(); + cy.waitForDom() + cy.go('back') + + cy.get('[data-cy=data-table-row]').contains(projName).should('exist').parent().parent().parent().click() + cy.get('[data-cy=multiselect-button]').should('have.length', msButtonTooltips.length) + for (let i = 0; i < msButtonTooltips.length; i++) { + cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseover'); + cy.get('body').contains(msButtonTooltips[i]).should('exist') + cy.get('[data-cy=multiselect-button]').eq(i).trigger('mouseout'); + } + }) + + it("creates new project on home project and then a subproject inside it", function () { + const createProject = function (name, parentName) { + cy.get("[data-cy=side-panel-button]").click(); + cy.get("[data-cy=side-panel-new-project]").click(); + cy.get("[data-cy=form-dialog]") + .should("contain", "New Project") .within(() => { - cy.get('[data-cy=parent-field]').within(() => { - cy.get('input').invoke('val').then((val) => { - expect(val).to.include(parentName); - }); + cy.get("[data-cy=parent-field]").within(() => { + cy.get("input") + .invoke("val") + .then(val => { + expect(val).to.include(parentName); + }); }); - cy.get('[data-cy=name-field]').within(() => { - cy.get('input').type(name); + cy.get("[data-cy=name-field]").within(() => { + cy.get("input").type(name); }); }); - cy.get('[data-cy=form-submit-btn]').click(); - } + cy.get("[data-cy=form-submit-btn]").click(); + }; cy.loginAs(activeUser); cy.goToPath(`/projects/${activeUser.user.uuid}`); - cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects'); - cy.get('[data-cy=breadcrumb-last]').should('not.exist'); + cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects"); + cy.get("[data-cy=breadcrumb-last]").should("not.exist"); // Create new project const projName = `Test project (${Math.floor(999999 * Math.random())})`; - createProject(projName, 'Home project'); + createProject(projName, "Home project"); // Confirm that the user was taken to the newly created thing - cy.get('[data-cy=form-dialog]').should('not.exist'); - cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects'); - cy.get('[data-cy=breadcrumb-last]').should('contain', projName); + cy.get("[data-cy=form-dialog]").should("not.exist"); + cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects"); + cy.get("[data-cy=breadcrumb-last]").should("contain", projName); // Create a subproject const subProjName = `Test project (${Math.floor(999999 * Math.random())})`; createProject(subProjName, projName); - cy.get('[data-cy=form-dialog]').should('not.exist'); - cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects'); - cy.get('[data-cy=breadcrumb-last]').should('contain', subProjName); + cy.get("[data-cy=form-dialog]").should("not.exist"); + cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects"); + cy.get("[data-cy=breadcrumb-last]").should("contain", subProjName); }); - it('navigates to the parent project after trashing the one being displayed', function() { + it("attempts to use a preexisting name creating a project", function () { + const name = `Test project ${Math.floor(Math.random() * 999999)}`; cy.createGroup(activeUser.token, { - name: `Test root project ${Math.floor(Math.random() * 999999)}`, - group_class: 'project', - }).as('testRootProject').then(function() { - cy.createGroup(activeUser.token, { - name : `Test subproject ${Math.floor(Math.random() * 999999)}`, - group_class: 'project', - owner_uuid: this.testRootProject.uuid, - }).as('testSubProject'); + name: name, + group_class: "project", }); - cy.getAll('@testRootProject', '@testSubProject').then(function([testRootProject, testSubProject]) { + cy.loginAs(activeUser); + cy.goToPath(`/projects/${activeUser.user.uuid}`); + + // Attempt to create new collection with a duplicate name + cy.get("[data-cy=side-panel-button]").click(); + cy.get("[data-cy=side-panel-new-project]").click(); + cy.get("[data-cy=form-dialog]") + .should("contain", "New Project") + .within(() => { + cy.get("[data-cy=name-field]").within(() => { + cy.get("input").type(name); + }); + cy.get("[data-cy=form-submit-btn]").click(); + }); + // Error message should display, allowing editing the name + cy.get("[data-cy=form-dialog]") + .should("exist") + .and("contain", "Project with the same name already exists") + .within(() => { + cy.get("[data-cy=name-field]").within(() => { + cy.get("input").type(" renamed"); + }); + cy.get("[data-cy=form-submit-btn]").click(); + }); + cy.get("[data-cy=form-dialog]").should("not.exist"); + }); + + it("navigates to the parent project after trashing the one being displayed", function () { + cy.createGroup(activeUser.token, { + name: `Test root project ${Math.floor(Math.random() * 999999)}`, + group_class: "project", + }) + .as("testRootProject") + .then(function () { + cy.createGroup(activeUser.token, { + name: `Test subproject ${Math.floor(Math.random() * 999999)}`, + group_class: "project", + owner_uuid: this.testRootProject.uuid, + }).as("testSubProject"); + }); + cy.getAll("@testRootProject", "@testSubProject").then(function ([testRootProject, testSubProject]) { cy.loginAs(activeUser); // Go to subproject and trash it. cy.goToPath(`/projects/${testSubProject.uuid}`); - cy.get('[data-cy=side-panel-tree]').should('contain', testSubProject.name); - cy.get('[data-cy=breadcrumb-last]') - .should('contain', testSubProject.name) - .rightclick(); - cy.get('[data-cy=context-menu]').contains('Move to trash').click(); + cy.get("[data-cy=side-panel-tree]").should("contain", testSubProject.name); + cy.get("[data-cy=breadcrumb-last]").should("contain", testSubProject.name).rightclick(); + cy.get("[data-cy=context-menu]").contains("Move to trash").click(); // Confirm that the parent project should be displayed. - cy.get('[data-cy=breadcrumb-last]').should('contain', testRootProject.name); - cy.url().should('contain', `/projects/${testRootProject.uuid}`); - cy.get('[data-cy=side-panel-tree]').should('not.contain', testSubProject.name); + cy.get("[data-cy=breadcrumb-last]").should("contain", testRootProject.name); + cy.url().should("contain", `/projects/${testRootProject.uuid}`); + cy.get("[data-cy=side-panel-tree]").should("not.contain", testSubProject.name); // Checks for bugfix #17637. - cy.get('[data-cy=not-found-content]').should('not.exist'); - cy.get('[data-cy=not-found-page]').should('not.exist'); + cy.get("[data-cy=not-found-content]").should("not.exist"); + cy.get("[data-cy=not-found-page]").should("not.exist"); }); }); - it('navigates to the root project after trashing the parent of the one being displayed', function() { + it("resets the search box only when navigating out of the current project", function () { + const fooProjectNameA = `Test foo project ${Math.floor(Math.random() * 999999)}`; + const fooProjectNameB = `Test foo project ${Math.floor(Math.random() * 999999)}`; + const barProjectNameA = `Test bar project ${Math.floor(Math.random() * 999999)}`; + + [fooProjectNameA, fooProjectNameB, barProjectNameA].forEach(projName => { + cy.createGroup(activeUser.token, { + name: projName, + group_class: "project", + }); + }); + + cy.loginAs(activeUser); + cy.get("[data-cy=project-panel]").should("contain", fooProjectNameA).and("contain", fooProjectNameB).and("contain", barProjectNameA); + + cy.get("[data-cy=search-input]").type("foo"); + cy.get("[data-cy=project-panel]").should("contain", fooProjectNameA).and("contain", fooProjectNameB).and("not.contain", barProjectNameA); + + // Click on the table row to select it, search should remain the same. + cy.get(`p:contains(${fooProjectNameA})`).parent().parent().parent().parent().click(); + cy.get("[data-cy=search-input] input").should("have.value", "foo"); + + // Click to navigate to the project, search should be reset + cy.get(`p:contains(${fooProjectNameA})`).click(); + cy.get("[data-cy=search-input] input").should("not.have.value", "foo"); + }); + + it("navigates to the root project after trashing the parent of the one being displayed", function () { cy.createGroup(activeUser.token, { name: `Test root project ${Math.floor(Math.random() * 999999)}`, - group_class: 'project', - }).as('testRootProject').then(function() { - cy.createGroup(activeUser.token, { - name : `Test subproject ${Math.floor(Math.random() * 999999)}`, - group_class: 'project', - owner_uuid: this.testRootProject.uuid, - }).as('testSubProject').then(function() { + group_class: "project", + }) + .as("testRootProject") + .then(function () { cy.createGroup(activeUser.token, { - name : `Test sub subproject ${Math.floor(Math.random() * 999999)}`, - group_class: 'project', - owner_uuid: this.testSubProject.uuid, - }).as('testSubSubProject'); + name: `Test subproject ${Math.floor(Math.random() * 999999)}`, + group_class: "project", + owner_uuid: this.testRootProject.uuid, + }) + .as("testSubProject") + .then(function () { + cy.createGroup(activeUser.token, { + name: `Test sub subproject ${Math.floor(Math.random() * 999999)}`, + group_class: "project", + owner_uuid: this.testSubProject.uuid, + }).as("testSubSubProject"); + }); }); - }); - cy.getAll('@testRootProject', '@testSubProject', '@testSubSubProject').then(function([testRootProject, testSubProject, testSubSubProject]) { + cy.getAll("@testRootProject", "@testSubProject", "@testSubSubProject").then(function ([testRootProject, testSubProject, testSubSubProject]) { cy.loginAs(activeUser); // Go to innermost project and trash its parent. cy.goToPath(`/projects/${testSubSubProject.uuid}`); - cy.get('[data-cy=side-panel-tree]').should('contain', testSubSubProject.name); - cy.get('[data-cy=breadcrumb-last]').should('contain', testSubSubProject.name); - cy.get('[data-cy=side-panel-tree]') - .contains(testSubProject.name) - .rightclick(); - cy.get('[data-cy=context-menu]').contains('Move to trash').click(); + cy.get("[data-cy=side-panel-tree]").should("contain", testSubSubProject.name); + cy.get("[data-cy=breadcrumb-last]").should("contain", testSubSubProject.name); + cy.get("[data-cy=side-panel-tree]").contains(testSubProject.name).rightclick(); + cy.get("[data-cy=context-menu]").contains("Move to trash").click(); // Confirm that the trashed project's parent should be displayed. - cy.get('[data-cy=breadcrumb-last]').should('contain', testRootProject.name); - cy.url().should('contain', `/projects/${testRootProject.uuid}`); - cy.get('[data-cy=side-panel-tree]').should('not.contain', testSubProject.name); - cy.get('[data-cy=side-panel-tree]').should('not.contain', testSubSubProject.name); + cy.get("[data-cy=breadcrumb-last]").should("contain", testRootProject.name); + cy.url().should("contain", `/projects/${testRootProject.uuid}`); + cy.get("[data-cy=side-panel-tree]").should("not.contain", testSubProject.name); + cy.get("[data-cy=side-panel-tree]").should("not.contain", testSubSubProject.name); // Checks for bugfix #17637. - cy.get('[data-cy=not-found-content]').should('not.exist'); - cy.get('[data-cy=not-found-page]').should('not.exist'); + cy.get("[data-cy=not-found-content]").should("not.exist"); + cy.get("[data-cy=not-found-page]").should("not.exist"); }); }); - it('shows details panel when clicking on the info icon', () => { + it("shows details panel when clicking on the info icon", () => { cy.createGroup(activeUser.token, { name: `Test root project ${Math.floor(Math.random() * 999999)}`, - group_class: 'project', - }).as('testRootProject').then(function(testRootProject) { - cy.loginAs(activeUser); + group_class: "project", + }) + .as("testRootProject") + .then(function (testRootProject) { + cy.loginAs(activeUser); - cy.get('[data-cy=side-panel-tree]').contains(testRootProject.name).click(); + cy.get("[data-cy=side-panel-tree]").contains(testRootProject.name).click(); - cy.get('[data-cy=additional-info-icon]').click(); + cy.get("[data-cy=additional-info-icon]").click(); - cy.contains(testRootProject.uuid).should('exist'); - }); + cy.contains(testRootProject.uuid).should("exist"); + }); }); - it('clears search input when changing project', () => { + it("clears search input when changing project", () => { cy.createGroup(activeUser.token, { name: `Test root project ${Math.floor(Math.random() * 999999)}`, - group_class: 'project', - }).as('testProject1').then((testProject1) => { - cy.shareWith(adminUser.token, activeUser.user.uuid, testProject1.uuid, 'can_write'); - }); + group_class: "project", + }) + .as("testProject1") + .then(testProject1 => { + cy.shareWith(adminUser.token, activeUser.user.uuid, testProject1.uuid, "can_write"); + }); - cy.getAll('@testProject1').then(function([testProject1]) { + cy.getAll("@testProject1").then(function ([testProject1]) { cy.loginAs(activeUser); - cy.get('[data-cy=side-panel-tree]').contains(testProject1.name).click(); + cy.get("[data-cy=side-panel-tree]").contains(testProject1.name).click(); - cy.get('[data-cy=search-input] input').type('test123'); + cy.get("[data-cy=search-input] input").type("test123"); - cy.get('[data-cy=side-panel-tree]').contains('Projects').click(); + cy.get("[data-cy=side-panel-tree]").contains("Projects").click(); - cy.get('[data-cy=search-input] input').should('not.have.value', 'test123'); + cy.get("[data-cy=search-input] input").should("not.have.value", "test123"); }); }); - it('opens advanced popup for project with username', () => { + it("opens advanced popup for project with username", () => { const projectName = `Test project ${Math.floor(Math.random() * 999999)}`; cy.createGroup(adminUser.token, { name: projectName, - group_class: 'project', - }).as('mainProject') + group_class: "project", + }).as("mainProject"); - cy.getAll('@mainProject') - .then(function ([mainProject]) { - cy.loginAs(adminUser); + cy.getAll("@mainProject").then(function ([mainProject]) { + cy.loginAs(adminUser); - cy.get('[data-cy=side-panel-tree]').contains('Groups').click(); + cy.get("[data-cy=side-panel-tree]").contains("Groups").click(); - cy.get('[data-cy=uuid]').eq(0).invoke('text').then(uuid => { + cy.get("[data-cy=uuid]") + .eq(0) + .invoke("text") + .then(uuid => { cy.createLink(adminUser.token, { - name: 'can_write', - link_class: 'permission', + name: "can_write", + link_class: "permission", head_uuid: mainProject.uuid, - tail_uuid: uuid + tail_uuid: uuid, }); cy.createLink(adminUser.token, { - name: 'can_write', - link_class: 'permission', + name: "can_write", + link_class: "permission", head_uuid: mainProject.uuid, - tail_uuid: activeUser.user.uuid + tail_uuid: activeUser.user.uuid, }); - cy.get('[data-cy=side-panel-tree]').contains('Projects').click(); + cy.get("[data-cy=side-panel-tree]").contains("Projects").click(); + + cy.get("main").contains(projectName).rightclick(); + + cy.get("[data-cy=context-menu]").contains("API Details").click(); + + cy.get("[role=tablist]").contains("METADATA").click(); + + cy.get("td").contains(uuid).should("exist"); + + cy.get("td").contains(activeUser.user.uuid).should("exist"); + }); + }); + }); + + describe("Frozen projects", () => { + beforeEach(() => { + cy.createGroup(activeUser.token, { + name: `Main project ${Math.floor(Math.random() * 999999)}`, + group_class: "project", + }).as("mainProject"); + + cy.createGroup(adminUser.token, { + name: `Admin project ${Math.floor(Math.random() * 999999)}`, + group_class: "project", + }) + .as("adminProject") + .then(mainProject => { + cy.shareWith(adminUser.token, activeUser.user.uuid, mainProject.uuid, "can_write"); + }); + + cy.get("@mainProject").then(mainProject => { + cy.createGroup(adminUser.token, { + name: `Sub project ${Math.floor(Math.random() * 999999)}`, + group_class: "project", + owner_uuid: mainProject.uuid, + }).as("subProject"); + + cy.createCollection(adminUser.token, { + name: `Main collection ${Math.floor(Math.random() * 999999)}`, + owner_uuid: mainProject.uuid, + manifest_text: "./subdir 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n", + }).as("mainCollection"); + }); + }); + + it("should be able to freeze own project", () => { + cy.getAll("@mainProject").then(([mainProject]) => { + cy.loginAs(activeUser); + + cy.get("[data-cy=project-panel]").contains(mainProject.name).rightclick(); + + cy.get("[data-cy=context-menu]").contains("Freeze").click(); + + cy.get("[data-cy=project-panel]").contains(mainProject.name).rightclick(); + + cy.get("[data-cy=context-menu]").contains("Freeze").should("not.exist"); + }); + }); + + it("should not be able to modify items within the frozen project", () => { + cy.getAll("@mainProject", "@mainCollection").then(([mainProject, mainCollection]) => { + cy.loginAs(activeUser); + + cy.get("[data-cy=project-panel]").contains(mainProject.name).rightclick(); + + cy.get("[data-cy=context-menu]").contains("Freeze").click(); + + cy.get("[data-cy=project-panel]").contains(mainProject.name).click(); + + cy.get("[data-cy=project-panel]").contains(mainCollection.name).rightclick(); + + cy.get("[data-cy=context-menu]").contains("Move to trash").should("not.exist"); + }); + }); + + it("should be able to freeze not owned project", () => { + cy.getAll("@adminProject").then(([adminProject]) => { + cy.loginAs(activeUser); + + cy.get("[data-cy=side-panel-tree]").contains("Shared with me").click(); + + cy.get("main").contains(adminProject.name).rightclick(); + + cy.get("[data-cy=context-menu]").contains("Freeze").should("not.exist"); + }); + }); + + it("should be able to unfreeze project if user is an admin", () => { + cy.getAll("@adminProject").then(([adminProject]) => { + cy.loginAs(adminUser); + + cy.get("main").contains(adminProject.name).rightclick(); + + cy.get("[data-cy=context-menu]").contains("Freeze").click(); - cy.get('main').contains(projectName).rightclick(); + cy.wait(1000); - cy.get('[data-cy=context-menu]').contains('Advanced').click(); + cy.get("main").contains(adminProject.name).rightclick(); - cy.get('[role=tablist]').contains('METADATA').click(); + cy.get("[data-cy=context-menu]").contains("Unfreeze").click(); - cy.get('td').contains(uuid).should('exist'); + cy.get("main").contains(adminProject.name).rightclick(); - cy.get('td').contains(activeUser.user.uuid).should('exist'); + cy.get("[data-cy=context-menu]").contains("Freeze").should("exist"); + }); + }); + }); + + it("copies project URL to clipboard", () => { + const projectName = `Test project (${Math.floor(999999 * Math.random())})`; + + cy.loginAs(activeUser); + cy.get("[data-cy=side-panel-button]").click(); + cy.get("[data-cy=side-panel-new-project]").click(); + cy.get("[data-cy=form-dialog]") + .should("contain", "New Project") + .within(() => { + cy.get("[data-cy=name-field]").within(() => { + cy.get("input").type(projectName); }); + cy.get("[data-cy=form-submit-btn]").click(); + }); + cy.get("[data-cy=form-dialog]").should("not.exist"); + cy.get("[data-cy=snackbar]").contains("created"); + cy.get("[data-cy=snackbar]").should("not.exist"); + cy.get("[data-cy=side-panel-tree]").contains("Projects").click(); + cy.waitForDom(); + cy.get("[data-cy=project-panel]").contains(projectName).should("be.visible").rightclick(); + cy.get("[data-cy=context-menu]").contains("Copy to clipboard").click(); + cy.window().then(win => + win.navigator.clipboard.readText().then(text => { + expect(text).to.match(/https\:\/\/127\.0\.0\.1\:[0-9]+\/projects\/[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}/); + }) + ); + }); + + it("sorts displayed items correctly", () => { + cy.loginAs(activeUser); + + cy.get('[data-cy=project-panel] button[title="Select columns"]').click(); + cy.get("div[role=presentation] ul > div[role=button]").contains("Date Created").click(); + cy.get("div[role=presentation] ul > div[role=button]").contains("Trash at").click(); + cy.get("div[role=presentation] ul > div[role=button]").contains("Delete at").click(); + cy.get("div[role=presentation] > div[aria-hidden=true]").click(); + + cy.intercept({ method: "GET", url: "**/arvados/v1/groups/*/contents*" }).as("filteredQuery"); + [ + { + name: "Name", + asc: "collections.name asc,container_requests.name asc,groups.name asc,container_requests.created_at desc", + desc: "collections.name desc,container_requests.name desc,groups.name desc,container_requests.created_at desc", + }, + { + name: "Last Modified", + asc: "collections.modified_at asc,container_requests.modified_at asc,groups.modified_at asc,container_requests.created_at desc", + desc: "collections.modified_at desc,container_requests.modified_at desc,groups.modified_at desc,container_requests.created_at desc", + }, + { + name: "Date Created", + asc: "collections.created_at asc,container_requests.created_at asc,groups.created_at asc,container_requests.created_at desc", + desc: "collections.created_at desc,container_requests.created_at desc,groups.created_at desc,container_requests.created_at desc", + }, + { + name: "Trash at", + asc: "collections.trash_at asc,container_requests.trash_at asc,groups.trash_at asc,container_requests.created_at desc", + desc: "collections.trash_at desc,container_requests.trash_at desc,groups.trash_at desc,container_requests.created_at desc", + }, + { + name: "Delete at", + asc: "collections.delete_at asc,container_requests.delete_at asc,groups.delete_at asc,container_requests.created_at desc", + desc: "collections.delete_at desc,container_requests.delete_at desc,groups.delete_at desc,container_requests.created_at desc", + }, + ].forEach(test => { + cy.get("[data-cy=project-panel] table thead th").contains(test.name).click(); + cy.wait("@filteredQuery").then(interception => { + const searchParams = new URLSearchParams(new URL(interception.request.url).search); + expect(searchParams.get("order")).to.eq(test.asc); + }); + cy.get("[data-cy=project-panel] table thead th").contains(test.name).click(); + cy.wait("@filteredQuery").then(interception => { + const searchParams = new URLSearchParams(new URL(interception.request.url).search); + expect(searchParams.get("order")).to.eq(test.desc); + }); }); }); }); diff --git a/cypress/integration/search.spec.js b/cypress/integration/search.spec.js index 2216c067..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,130 +95,164 @@ 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=element-path]").should("contain", `/ Projects / ${colName}`); + }); + }); + + 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}`; + + // 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("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"); + + cy.getAll("@collection1", "@collection2").then(function () { + cy.loginAs(activeUser); - cy.get('[data-cy=search-results]').contains(colName).closest('tr').click(); + cy.doSearch(colName); + cy.get("[data-cy=search-results] table tbody tr").should("have.length", 2); - cy.get('[data-cy=element-path]').should('contain', `/ Projects / ${colName}`); + 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], { @@ -223,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; }); @@ -233,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('Advanced'); - 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 7d21249c..0a06eaf3 100644 --- a/cypress/integration/user-profile.spec.js +++ b/cypress/integration/user-profile.spec.js @@ -76,7 +76,7 @@ describe('User profile tests', function() { }) { cy.get('[data-cy=user-profile-panel-options-btn]').click(); cy.get('[data-cy=context-menu]').within(() => { - cy.get('[role=button]').contains('Advanced'); + cy.get('[role=button]').contains('API Details'); cy.get('[role=button]').should(account ? 'contain' : 'not.contain', 'Account Settings'); cy.get('[role=button]').should(activate ? 'contain' : 'not.contain', 'Activate User'); @@ -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 f01a8911..92011b20 100644 --- a/cypress/integration/virtual-machine-admin.spec.js +++ b/cypress/integration/virtual-machine-admin.spec.js @@ -2,285 +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]') - .should('contain', 'Add login permission') + cy.waitForDom().get("[role=tooltip]").click(); + cy.get("[data-cy=form-dialog]") + .as("add-login-dialog") + .should("contain", "Add login permission") .within(() => { - cy.get('label') - .contains('Add groups') - .parent() - .within(() => { - cy.get('input').type('docker sudo{enter}'); - }) + cy.get("label") + .contains("Add groups") + .parent() + .within(() => { + cy.get("input").type("docker "); + // Veryfy submit enabled (form has changed) + cy.get("@add-login-dialog").within(() => { + cy.get("[data-cy=form-submit-btn]").should("be.enabled"); + }); + cy.get("input").type("sudo"); + // Veryfy submit disabled (partial input in chips) + cy.get("@add-login-dialog").within(() => { + cy.get("[data-cy=form-submit-btn]").should("be.disabled"); + }); + cy.get("input").type("{enter}"); + }); }); - cy.get('[data-cy=form-dialog]').within(() => { - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[data-cy=form-dialog]").within(() => { + cy.get("[data-cy=form-submit-btn]").click(); }); - cy.get('[data-cy=snackbar]').contains('Permission updated'); - cy.get('[data-cy=vm-admin-table]') + + cy.get("[data-cy=vm-admin-table]") .contains(vmHost) - .parents('tr') + .parents("tr") .within(() => { - cy.get('td').contains('admin'); - }); + cy.get("td").contains("admin"); + }); // Add login permission to activeUser - cy.get('[data-cy=vm-admin-table]') + cy.get("[data-cy=vm-admin-table]") .contains(vmHost) - .parents('tr') + .parents("tr") .within(() => { cy.get('button[title="Add Login Permission"]').click(); }); - cy.get('[data-cy=form-dialog]') - .should('contain', 'Add login permission') + cy.get("[data-cy=form-dialog]") + .should("contain", "Add login permission") .within(() => { - cy.get('label') - .contains('Search for user') - .parent() - .within(() => { - cy.get('input').type('VMActive user'); - }) + cy.get("label") + .contains("Search for user") + .parent() + .within(() => { + cy.get("input").type("VMActive user"); + }); }); - cy.get('[role=tooltip]').click(); - cy.get('[data-cy=form-dialog]').within(() => { - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[role=tooltip]").click(); + cy.get("[data-cy=form-dialog]").within(() => { + cy.get("[data-cy=form-submit-btn]").click(); }); - cy.get('[data-cy=snackbar]').contains('Permission updated'); - cy.get('[data-cy=vm-admin-table]') + + cy.get("[data-cy=vm-admin-table]") .contains(vmHost) - .parents('tr') + .parents("tr") .within(() => { - cy.get('td').contains('user'); - }); + cy.get("td").contains("user"); + }); // Check admin's vm page for login cy.get('header button[title="Account Management"]').click(); - cy.get('#account-menu').contains('Virtual Machines').click(); + cy.get("#account-menu").contains("Virtual Machines").click(); - cy.get('[data-cy=vm-user-table]') + cy.get("[data-cy=vm-user-table]") .contains(vmHost) - .parents('tr') + .parents("tr") .within(() => { - cy.get('td').contains('admin'); - cy.get('td').contains('docker'); - cy.get('td').contains('sudo'); - cy.get('td').contains('ssh admin@' + vmHost); - }); + cy.get("td").contains("admin"); + cy.get("td").contains("docker"); + cy.get("td").contains("sudo"); + cy.get("td").contains("ssh admin@" + vmHost); + }); // Check activeUser's vm page for login cy.loginAs(activeUser); cy.get('header button[title="Account Management"]').click(); - cy.get('#account-menu').contains('Virtual Machines').click(); + cy.get("#account-menu").contains("Virtual Machines").click(); - cy.get('[data-cy=vm-user-table]') + cy.get("[data-cy=vm-user-table]") .contains(vmHost) - .parents('tr') + .parents("tr") .within(() => { - cy.get('td').contains('user'); - cy.get('td').should('not.contain', 'docker'); - cy.get('td').should('not.contain', 'sudo'); - cy.get('td').contains('ssh user@' + vmHost); - }); + cy.get("td").contains("user"); + cy.get("td").should("not.contain", "docker"); + cy.get("td").should("not.contain", "sudo"); + cy.get("td").contains("ssh user@" + vmHost); + }); // Edit login permissions cy.loginAs(adminUser); cy.get('header button[title="Admin Panel"]').click(); - cy.get('#admin-menu').contains('Virtual Machines').click(); + cy.get("#admin-menu").contains("Virtual Machines").click(); - cy.get('[data-cy=vm-admin-table]') - .contains('admin'); // Wait for page to finish + cy.get("[data-cy=vm-admin-table]").contains("admin"); // Wait for page to finish - cy.get('[data-cy=vm-admin-table]') - .contains(vmHost) - .parents('tr') - .contains('admin') - .click(); + cy.get("[data-cy=vm-admin-table]").contains(vmHost).parents("tr").contains("admin").click(); - cy.get('[data-cy=form-dialog]') - .should('contain', 'Update login permission') + cy.get("[data-cy=form-dialog]") + .should("contain", "Update login permission") .within(() => { - cy.get('label') - .contains('Add groups') - .parent() - .as('groupInput'); + cy.get("label").contains("Add groups").parent().as("groupInput"); }); - cy.get('@groupInput').within(() => { - cy.get('div[role=button]').contains('sudo').parent().find('svg').click(); - cy.get('div[role=button]').contains('docker').parent().find('svg').click(); + cy.get("@groupInput").within(() => { + cy.get("div[role=button]").contains("sudo").parent().find("svg").click(); + cy.get("div[role=button]").contains("docker").parent().find("svg").click(); }); - cy.get('[data-cy=form-dialog]').within(() => { - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[data-cy=form-dialog]").within(() => { + cy.get("[data-cy=form-submit-btn]").click(); }); // Wait for page to finish loading - cy.get('[data-cy=snackbar]').contains('Permission updated'); - cy.get('[data-cy=vm-admin-table]') + cy.get("[data-cy=vm-admin-table]") .contains(vmHost) - .parents('tr') + .parents("tr") .within(() => { - cy.get('div[role=button]') - .parent() - .first() - .contains('admin') + cy.get("div[role=button]").parent().first().contains("admin"); }); - cy.get('[data-cy=vm-admin-table]') - .contains(vmHost) - .parents('tr') - .contains('user') - .click(); + cy.get("[data-cy=vm-admin-table]").contains(vmHost).parents("tr").contains("user").click(); - cy.get('[data-cy=form-dialog]') - .should('contain', 'Update login permission') + cy.get("[data-cy=form-dialog]") + .should("contain", "Update login permission") .within(() => { - cy.get('label') - .contains('Add groups') + cy.get("label") + .contains("Add groups") .parent() .within(() => { - cy.get('input').type('docker{enter}'); - }) + cy.get("input").type("docker{enter}"); + }); }); - cy.get('[data-cy=form-dialog]').within(() => { - cy.get('[data-cy=form-submit-btn]').click(); + cy.get("[data-cy=form-dialog]").within(() => { + cy.get("[data-cy=form-submit-btn]").click(); }); - cy.get('[data-cy=snackbar]').contains('Permission updated'); // Verify new login permissions // Check admin's vm page for login cy.get('header button[title="Account Management"]').click(); - cy.get('#account-menu').contains('Virtual Machines').click(); + cy.get("#account-menu").contains("Virtual Machines").click(); - cy.get('[data-cy=vm-user-table]') + cy.get("[data-cy=vm-user-table]") .contains(vmHost) - .parents('tr') + .parents("tr") .within(() => { - cy.get('td').contains('admin'); - cy.get('td').should('not.contain', 'docker'); - cy.get('td').should('not.contain', 'sudo'); - cy.get('td').contains('ssh admin@' + vmHost); - }); + cy.get("td").contains("admin"); + cy.get("td").should("not.contain", "docker"); + cy.get("td").should("not.contain", "sudo"); + cy.get("td").contains("ssh admin@" + vmHost); + }); // Verify new login permissions // Check activeUser's vm page for login cy.loginAs(activeUser); cy.get('header button[title="Account Management"]').click(); - cy.get('#account-menu').contains('Virtual Machines').click(); + cy.get("#account-menu").contains("Virtual Machines").click(); - cy.get('[data-cy=vm-user-table]') + cy.get("[data-cy=vm-user-table]") .contains(vmHost) - .parents('tr') + .parents("tr") .within(() => { - cy.get('td').contains('user'); - cy.get('td').contains('docker'); - cy.get('td').should('not.contain', 'sudo'); - cy.get('td').contains('ssh user@' + vmHost); - }); + cy.get("td").contains("user"); + cy.get("td").contains("docker"); + cy.get("td").should("not.contain", "sudo"); + cy.get("td").contains("ssh user@" + vmHost); + }); // Remove login permissions cy.loginAs(adminUser); cy.get('header button[title="Admin Panel"]').click(); - cy.get('#admin-menu').contains('Virtual Machines').click(); + cy.get("#admin-menu").contains("Virtual Machines").click(); - cy.get('[data-cy=vm-admin-table]') - .contains('user'); // Wait for page to finish + cy.get("[data-cy=vm-admin-table]").contains("user"); // Wait for page to finish - cy.get('[data-cy=vm-admin-table]') + cy.get("[data-cy=vm-admin-table]") .contains(vmHost) - .parents('tr') - .as('vmRow') - .contains('user') - .parents('[role=button]') - .find('svg') - .as('removeButton'); - cy.get('@removeButton').click(); - cy.get('[data-cy=confirmation-dialog-ok-btn]').click(); - - cy.get('@vmRow') - .within(() => { - cy.get('div[role=button]').should('not.contain', 'user'); - cy.get('div[role=button]').should('have.length', 1) - }); + .parents("tr") + .as("vmRow") + .contains("user") + .parents("[role=button]") + .find("svg") + .as("removeButton"); + cy.get("@removeButton").click(); + cy.get("[data-cy=confirmation-dialog-ok-btn]").click(); + + cy.get("@vmRow").within(() => { + cy.get("div[role=button]").should("not.contain", "user"); + cy.get("div[role=button]").should("have.length", 1); + }); - cy.get('@vmRow') - .find('div[role=button]') - .contains('admin') - .parents('[role=button]') - .find('svg') - .as('removeButton'); - cy.get('@removeButton').click(); - cy.get('[data-cy=confirmation-dialog-ok-btn]').click(); + cy.get("@vmRow").find("div[role=button]").contains("admin").parents("[role=button]").find("svg").as("removeButton"); + cy.get("@removeButton").click(); + cy.get("[data-cy=confirmation-dialog-ok-btn]").click(); - cy.get('[data-cy=vm-admin-table]') + cy.waitForDom() + .get("[data-cy=vm-admin-table]") .contains(vmHost) - .parents('tr') + .parents("tr") .within(() => { - cy.get('div[role=button]').should('not.contain', 'admin'); + cy.get("div[role=button]").should("not.exist"); }); // Check admin's vm page for login cy.get('header button[title="Account Management"]').click(); - cy.get('#account-menu').contains('Virtual Machines').click(); + cy.get("#account-menu").contains("Virtual Machines").click(); - cy.get('[data-cy=vm-user-panel]') - .should('not.contain', vmHost); + cy.get("[data-cy=vm-user-panel]").should("not.contain", vmHost); // Check activeUser's vm page for login cy.loginAs(activeUser); cy.get('header button[title="Account Management"]').click(); - cy.get('#account-menu').contains('Virtual Machines').click(); + cy.get("#account-menu").contains("Virtual Machines").click(); - cy.get('[data-cy=vm-user-panel]') - .should('not.contain', vmHost); + cy.get("[data-cy=vm-user-panel]").should("not.contain", vmHost); }); }); diff --git a/cypress/integration/workflow.spec.js b/cypress/integration/workflow.spec.js new file mode 100644 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 e98000fc..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,361 +53,416 @@ beforeEach(function () { }); Cypress.Commands.add( - "doRequest", (method = 'GET', path = '', data = null, qs = null, - token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => { - return cy.request({ - method: method, - url: `${controllerURL.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`, - body: data, - qs: auth ? qs : Object.assign({ api_token: token }, qs), - auth: auth ? { bearer: `${token}` } : undefined, - followRedirect: followRedirect, - failOnStatusCode: failOnStatusCode - }); -}); + "doRequest", + (method = "GET", path = "", data = null, qs = null, token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => { + return cy.request({ + method: method, + url: `${controllerURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`, + body: data, + qs: auth ? qs : Object.assign({ api_token: token }, qs), + auth: auth ? { bearer: `${token}` } : undefined, + followRedirect: followRedirect, + failOnStatusCode: failOnStatusCode, + }); + } +); Cypress.Commands.add( - "getUser", (username, first_name = '', last_name = '', is_admin = false, is_active = true) => { - // Create user if not already created - return cy.doRequest('POST', '/auth/controller/callback', { - auth_info: JSON.stringify({ - email: `${username}@example.local`, - username: username, - first_name: first_name, - last_name: last_name, - alternate_emails: [] - }), - return_to: ',https://example.local' - }, null, systemToken, true, false) // Don't follow redirects so we can catch the token - .its('headers.location').as('location') - // Get its token and set the account up as admin and/or active - .then(function () { - this.userToken = this.location.split("=")[1] - assert.isString(this.userToken) - return cy.doRequest('GET', '/arvados/v1/users', null, { - filters: `[["username", "=", "${username}"]]` - }) - .its('body.items.0').as('aUser') + "doWebDAVRequest", + (method = "GET", path = "", data = null, qs = null, token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => { + return cy.doRequest("GET", "/arvados/v1/config", null, null).then(({ body: config }) => { + return cy.request({ + method: method, + url: `${config.Services.WebDAVDownload.ExternalURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`, + body: data, + qs: auth ? qs : Object.assign({ api_token: token }, qs), + auth: auth ? { bearer: `${token}` } : undefined, + followRedirect: followRedirect, + failOnStatusCode: failOnStatusCode, + }); + }); + } +); + +Cypress.Commands.add("getUser", (username, first_name = "", last_name = "", is_admin = false, is_active = true) => { + // Create user if not already created + return ( + cy + .doRequest( + "POST", + "/auth/controller/callback", + { + auth_info: JSON.stringify({ + email: `${username}@example.local`, + username: username, + first_name: first_name, + last_name: last_name, + alternate_emails: [], + }), + return_to: ",https://controller.api.client.invalid", + }, + null, + systemToken, + true, + false + ) // Don't follow redirects so we can catch the token + .its("headers.location") + .as("location") + // Get its token and set the account up as admin and/or active .then(function () { - cy.doRequest('PUT', `/arvados/v1/users/${this.aUser.uuid}`, { - user: { - is_admin: is_admin, - is_active: is_active - } - }) - .its('body').as('theUser') - .then(function () { - cy.doRequest('GET', '/arvados/v1/api_clients', null, { - filters: `[["is_trusted", "=", false]]`, - order: `["created_at desc"]` - }) - .its('body.items').as('apiClients') - .then(function () { - if (this.apiClients.length > 0) { - cy.doRequest('PUT', `/arvados/v1/api_clients/${this.apiClients[0].uuid}`, { - api_client: { - is_trusted: true - } - }) - .its('body').as('updatedApiClient') - .then(function() { - assert(this.updatedApiClient.is_trusted); - }) - } + this.userToken = this.location.split("=")[1]; + assert.isString(this.userToken); + return cy + .doRequest("GET", "/arvados/v1/users", null, { + filters: `[["username", "=", "${username}"]]`, }) + .its("body.items.0") + .as("aUser") .then(function () { - return { user: this.theUser, token: this.userToken }; - }) - }) + cy.doRequest("PUT", `/arvados/v1/users/${this.aUser.uuid}`, { + user: { + is_admin: is_admin, + is_active: is_active, + }, + }) + .its("body") + .as("theUser") + .then(function () { + cy.doRequest("GET", "/arvados/v1/api_clients", null, { + filters: `[["is_trusted", "=", false]]`, + order: `["created_at desc"]`, + }) + .its("body.items") + .as("apiClients") + .then(function () { + if (this.apiClients.length > 0) { + cy.doRequest("PUT", `/arvados/v1/api_clients/${this.apiClients[0].uuid}`, { + api_client: { + is_trusted: true, + }, + }) + .its("body") + .as("updatedApiClient") + .then(function () { + assert(this.updatedApiClient.is_trusted); + }); + } + }) + .then(function () { + return { user: this.theUser, token: this.userToken }; + }); + }); + }); }) - }) - } -) + ); +}); -Cypress.Commands.add( - "createLink", (token, data) => { - return cy.createResource(token, 'links', { - link: JSON.stringify(data) - }) - } -) +Cypress.Commands.add("createLink", (token, data) => { + return cy.createResource(token, "links", { + link: JSON.stringify(data), + }); +}); -Cypress.Commands.add( - "createGroup", (token, data) => { - return cy.createResource(token, 'groups', { - group: JSON.stringify(data), - ensure_unique_name: true - }) - } -) +Cypress.Commands.add("createGroup", (token, data) => { + return cy.createResource(token, "groups", { + group: JSON.stringify(data), + ensure_unique_name: true, + }); +}); -Cypress.Commands.add( - "trashGroup", (token, uuid) => { - return cy.deleteResource(token, 'groups', uuid); - } -) +Cypress.Commands.add("trashGroup", (token, uuid) => { + return cy.deleteResource(token, "groups", uuid); +}); +Cypress.Commands.add("createWorkflow", (token, data) => { + return cy.createResource(token, "workflows", { + workflow: JSON.stringify(data), + ensure_unique_name: true, + }); +}); -Cypress.Commands.add( - "createWorkflow", (token, data) => { - return cy.createResource(token, 'workflows', { - workflow: JSON.stringify(data), - ensure_unique_name: true - }) - } -) +Cypress.Commands.add("createCollection", (token, data) => { + return cy.createResource(token, "collections", { + collection: JSON.stringify(data), + ensure_unique_name: true, + }); +}); -Cypress.Commands.add( - "getCollection", (token, uuid) => { - return cy.getResource(token, 'collections', uuid) - } -) +Cypress.Commands.add("getCollection", (token, uuid) => { + return cy.getResource(token, "collections", uuid); +}); -Cypress.Commands.add( - "createCollection", (token, data) => { - return cy.createResource(token, 'collections', { - collection: JSON.stringify(data), - ensure_unique_name: true - }) - } -) +Cypress.Commands.add("updateCollection", (token, uuid, data) => { + return cy.updateResource(token, "collections", uuid, { + collection: JSON.stringify(data), + }); +}); -Cypress.Commands.add( - "updateCollection", (token, uuid, data) => { - return cy.updateResource(token, 'collections', uuid, { - collection: JSON.stringify(data) - }) - } -) +Cypress.Commands.add("collectionReplaceFiles", (token, uuid, data) => { + return cy.updateResource(token, "collections", uuid, { + collection: { + preserve_version: true, + }, + replace_files: JSON.stringify(data), + }); +}); -Cypress.Commands.add( - "getContainer", (token, uuid) => { - return cy.getResource(token, 'containers', uuid) - } -) +Cypress.Commands.add("getContainer", (token, uuid) => { + return cy.getResource(token, "containers", uuid); +}); -Cypress.Commands.add( - "updateContainer", (token, uuid, data) => { - return cy.updateResource(token, 'containers', uuid, { - container: JSON.stringify(data) - }) - } -) +Cypress.Commands.add("updateContainer", (token, uuid, data) => { + return cy.updateResource(token, "containers", uuid, { + container: JSON.stringify(data), + }); +}); -Cypress.Commands.add( - 'createContainerRequest', (token, data) => { - return cy.createResource(token, 'container_requests', { - container_request: JSON.stringify(data), - ensure_unique_name: true - }) - } -) +Cypress.Commands.add("getContainerRequest", (token, uuid) => { + return cy.getResource(token, "container_requests", uuid); +}); -Cypress.Commands.add( - "updateContainerRequest", (token, uuid, data) => { - return cy.updateResource(token, 'container_requests', uuid, { - container_request: JSON.stringify(data) - }) - } -) +Cypress.Commands.add("createContainerRequest", (token, data) => { + return cy.createResource(token, "container_requests", { + container_request: JSON.stringify(data), + ensure_unique_name: true, + }); +}); -Cypress.Commands.add( - "createLog", (token, data) => { - return cy.createResource(token, 'logs', { - log: JSON.stringify(data) - }) - } -) +Cypress.Commands.add("updateContainerRequest", (token, uuid, data) => { + return cy.updateResource(token, "container_requests", uuid, { + container_request: JSON.stringify(data), + }); +}); -Cypress.Commands.add( - "logsForContainer", (token, uuid, logType, logTextArray = []) => { - let logs = []; - for (const logText of logTextArray) { - logs.push(cy.createLog(token, { - object_uuid: uuid, - event_type: logType, - properties: { - text: logText +/** + * Requires an admin token for log_uuid modification to succeed + */ +Cypress.Commands.add("appendLog", (token, crUuid, fileName, lines = []) => + cy.getContainerRequest(token, crUuid).then(containerRequest => { + if (containerRequest.log_uuid) { + cy.listContainerRequestLogs(token, crUuid).then(logFiles => { + const filePath = `${containerRequest.log_uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}/${fileName}`; + if (logFiles.find(file => file.name === fileName)) { + // File exists, fetch and append + return cy + .doWebDAVRequest("GET", `c=${filePath}`, null, null, token) + .then(({ body: contents }) => + cy.doWebDAVRequest("PUT", `c=${filePath}`, contents.split("\n").concat(lines).join("\n"), null, token) + ); + } else { + // File not exists, put new file + cy.doWebDAVRequest("PUT", `c=${filePath}`, lines.join("\n"), null, token); } - }).as('lastLogRecord')) + }); + } else { + // Create log collection + return cy + .createCollection(token, { + name: `Test log collection ${Math.floor(Math.random() * 999999)}`, + owner_uuid: containerRequest.owner_uuid, + manifest_text: "", + }) + .then(collection => { + // Update CR log_uuid to fake log collection + cy.updateContainerRequest(token, containerRequest.uuid, { + log_uuid: collection.uuid, + }).then(() => + // Create empty directory for container uuid + cy + .collectionReplaceFiles(token, collection.uuid, { + [`/${containerLogFolderPrefix}${containerRequest.container_uuid}`]: "d41d8cd98f00b204e9800998ecf8427e+0", + }) + .then(() => + // Put new log file with contents into fake log collection + cy.doWebDAVRequest( + "PUT", + `c=${collection.uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}/${fileName}`, + lines.join("\n"), + null, + token + ) + ) + ); + }); } - cy.getAll('@lastLogRecord').then(function () { - return logs; - }) - } -) - -Cypress.Commands.add( - "createVirtualMachine", (token, data) => { - return cy.createResource(token, 'virtual_machines', { - virtual_machine: JSON.stringify(data), - ensure_unique_name: true - }) - } -) - -Cypress.Commands.add( - "getResource", (token, suffix, uuid) => { - return cy.doRequest('GET', `/arvados/v1/${suffix}/${uuid}`, null, {}, token) - .its('body') - .then(function (resource) { - return resource; + }) +); + +Cypress.Commands.add("listContainerRequestLogs", (token, crUuid) => + cy.getContainerRequest(token, crUuid).then(containerRequest => + cy + .doWebDAVRequest( + "PROPFIND", + `c=${containerRequest.log_uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}`, + null, + null, + token + ) + .then(({ body: data }) => { + return extractFilesData(new DOMParser().parseFromString(data, "text/xml")); }) - } -) + ) +); -Cypress.Commands.add( - "createResource", (token, suffix, data) => { - return cy.doRequest('POST', '/arvados/v1/' + suffix, data, null, token, true) - .its('body') - .then(function (resource) { - createdResources.push({suffix, uuid: resource.uuid}); - return resource; - }) - } -) +Cypress.Commands.add("createVirtualMachine", (token, data) => { + return cy.createResource(token, "virtual_machines", { + virtual_machine: JSON.stringify(data), + ensure_unique_name: true, + }); +}); -Cypress.Commands.add( - "deleteResource", (token, suffix, uuid, failOnStatusCode = true) => { - return cy.doRequest('DELETE', '/arvados/v1/' + suffix + '/' + uuid, null, null, token, false, true, failOnStatusCode) - .its('body') - .then(function (resource) { - return resource; - }) - } -) +Cypress.Commands.add("getResource", (token, suffix, uuid) => { + return cy + .doRequest("GET", `/arvados/v1/${suffix}/${uuid}`, null, {}, token) + .its("body") + .then(function (resource) { + return resource; + }); +}); -Cypress.Commands.add( - "updateResource", (token, suffix, uuid, data) => { - return cy.doRequest('PATCH', '/arvados/v1/' + suffix + '/' + uuid, data, null, token, true) - .its('body') - .then(function (resource) { - return resource; - }) - } -) +Cypress.Commands.add("createResource", (token, suffix, data) => { + return cy + .doRequest("POST", "/arvados/v1/" + suffix, data, null, token, true) + .its("body") + .then(function (resource) { + createdResources.push({ suffix, uuid: resource.uuid }); + return resource; + }); +}); -Cypress.Commands.add( - "loginAs", (user) => { - cy.clearCookies() - cy.clearLocalStorage() - cy.visit(`/token/?api_token=${user.token}`); - cy.url({timeout: 10000}).should('contain', '/projects/'); - cy.get('div#root').should('contain', 'Arvados Workbench (zzzzz)'); - cy.get('div#root').should('not.contain', 'Your account is inactive'); - } -) +Cypress.Commands.add("deleteResource", (token, suffix, uuid, failOnStatusCode = true) => { + return cy + .doRequest("DELETE", "/arvados/v1/" + suffix + "/" + uuid, null, null, token, false, true, failOnStatusCode) + .its("body") + .then(function (resource) { + return resource; + }); +}); -Cypress.Commands.add( - "testEditProjectOrCollection", (container, oldName, newName, newDescription, isProject = true) => { - cy.get(container).contains(oldName).rightclick(); - cy.get('[data-cy=context-menu]').contains(isProject ? 'Edit project' : 'Edit collection').click(); - cy.get('[data-cy=form-dialog]').within(() => { - cy.get('input[name=name]').clear().type(newName); - cy.get(isProject ? 'div[contenteditable=true]' : 'input[name=description]').clear().type(newDescription); - cy.get('[data-cy=form-submit-btn]').click(); +Cypress.Commands.add("updateResource", (token, suffix, uuid, data) => { + return cy + .doRequest("PATCH", "/arvados/v1/" + suffix + "/" + uuid, data, null, token, true) + .its("body") + .then(function (resource) { + return resource; }); +}); - cy.get(container).contains(newName).rightclick(); - cy.get('[data-cy=context-menu]').contains(isProject ? 'Edit project' : 'Edit collection').click(); - cy.get('[data-cy=form-dialog]').within(() => { - cy.get('input[name=name]').should('have.value', newName); +Cypress.Commands.add("loginAs", user => { + cy.clearCookies(); + cy.clearLocalStorage(); + cy.visit(`/token/?api_token=${user.token}`); + cy.url({ timeout: 10000 }).should("contain", "/projects/"); + cy.get("div#root").should("contain", "Arvados Workbench (zzzzz)"); + cy.get("div#root").should("not.contain", "Your account is inactive"); +}); - if (isProject) { - cy.get('span[data-text=true]').contains(newDescription); - } else { - cy.get('input[name=description]').should('have.value', newDescription); - } +Cypress.Commands.add("testEditProjectOrCollection", (container, oldName, newName, newDescription, isProject = true) => { + cy.get(container).contains(oldName).rightclick(); + cy.get("[data-cy=context-menu]") + .contains(isProject ? "Edit project" : "Edit collection") + .click(); + cy.get("[data-cy=form-dialog]").within(() => { + cy.get("input[name=name]").clear().type(newName); + cy.get(isProject ? "div[contenteditable=true]" : "input[name=description]") + .clear() + .type(newDescription); + cy.get("[data-cy=form-submit-btn]").click(); + }); - cy.get('[data-cy=form-cancel-btn]').click(); - }); - } -) + cy.get(container).contains(newName).rightclick(); + cy.get("[data-cy=context-menu]") + .contains(isProject ? "Edit project" : "Edit collection") + .click(); + cy.get("[data-cy=form-dialog]").within(() => { + cy.get("input[name=name]").should("have.value", newName); + + if (isProject) { + cy.get("span[data-text=true]").contains(newDescription); + } else { + cy.get("input[name=description]").should("have.value", newDescription); + } -Cypress.Commands.add( - "doSearch", (searchTerm) => { - cy.get('[data-cy=searchbar-input-field]').type(`{selectall}${searchTerm}{enter}`); - } -) + cy.get("[data-cy=form-cancel-btn]").click(); + }); +}); -Cypress.Commands.add( - "goToPath", (path) => { - return cy.window().its('appHistory').invoke('push', path); - } -) +Cypress.Commands.add("doSearch", searchTerm => { + cy.get("[data-cy=searchbar-input-field]").type(`{selectall}${searchTerm}{enter}`); +}); + +Cypress.Commands.add("goToPath", path => { + return cy.window().its("appHistory").invoke("push", path); +}); -Cypress.Commands.add('getAll', (...elements) => { - const promise = cy.wrap([], { log: false }) +Cypress.Commands.add("getAll", (...elements) => { + const promise = cy.wrap([], { log: false }); for (let element of elements) { - promise.then(arr => cy.get(element).then(got => cy.wrap([...arr, got]))) + promise.then(arr => cy.get(element).then(got => cy.wrap([...arr, got]))); } - return promise -}) + return promise; +}); -Cypress.Commands.add('shareWith', (srcUserToken, targetUserUUID, itemUUID, permission = 'can_write') => { +Cypress.Commands.add("shareWith", (srcUserToken, targetUserUUID, itemUUID, permission = "can_write") => { cy.createLink(srcUserToken, { name: permission, - link_class: 'permission', + link_class: "permission", head_uuid: itemUUID, - tail_uuid: targetUserUUID + tail_uuid: targetUserUUID, }); -}) +}); -Cypress.Commands.add('addToFavorites', (userToken, userUUID, itemUUID) => { +Cypress.Commands.add("addToFavorites", (userToken, userUUID, itemUUID) => { cy.createLink(userToken, { head_uuid: itemUUID, - link_class: 'star', - name: '', + link_class: "star", + name: "", owner_uuid: userUUID, tail_uuid: userUUID, }); -}) +}); -Cypress.Commands.add('createProject', ({ - owningUser, - targetUser, - projectName, - canWrite, - addToFavorites -}) => { - const writePermission = canWrite ? 'can_write' : 'can_read'; +Cypress.Commands.add("createProject", ({ owningUser, targetUser, projectName, canWrite, addToFavorites }) => { + const writePermission = canWrite ? "can_write" : "can_read"; cy.createGroup(owningUser.token, { name: `${projectName} ${Math.floor(Math.random() * 999999)}`, - group_class: 'project', - }).as(`${projectName}`).then((project) => { - if (targetUser && targetUser !== owningUser) { - cy.shareWith(owningUser.token, targetUser.user.uuid, project.uuid, writePermission); - } - if (addToFavorites) { - const user = targetUser ? targetUser : owningUser; - cy.addToFavorites(user.token, user.user.uuid, project.uuid); - } - }); + group_class: "project", + }) + .as(`${projectName}`) + .then(project => { + if (targetUser && targetUser !== owningUser) { + cy.shareWith(owningUser.token, targetUser.user.uuid, project.uuid, writePermission); + } + if (addToFavorites) { + const user = targetUser ? targetUser : owningUser; + cy.addToFavorites(user.token, user.user.uuid, project.uuid); + } + }); }); Cypress.Commands.add( - 'upload', + "upload", { - prevSubject: 'element', + prevSubject: "element", }, - (subject, file, fileName) => { + (subject, file, fileName, binaryMode = true) => { cy.window().then(window => { - const blob = b64toBlob(file, '', 512); + const blob = binaryMode ? b64toBlob(file, "", 512) : new Blob([file], { type: "text/plain" }); const testFile = new window.File([blob], fileName); - cy.wrap(subject).trigger('drop', { + cy.wrap(subject).trigger("drop", { dataTransfer: { files: [testFile] }, }); - }) + }); } -) +); -function b64toBlob(b64Data, contentType = '', sliceSize = 512) { - const byteCharacters = atob(b64Data) - const byteArrays = [] +function b64toBlob(b64Data, contentType = "", sliceSize = 512) { + const byteCharacters = atob(b64Data); + const byteArrays = []; for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { const slice = byteCharacters.slice(offset, offset + sliceSize); @@ -417,78 +478,85 @@ function b64toBlob(b64Data, contentType = '', sliceSize = 512) { } const blob = new Blob(byteArrays, { type: contentType }); - return blob + return blob; } // From https://github.com/cypress-io/cypress/issues/7306#issuecomment-1076451070= // This command requires the async package (https://www.npmjs.com/package/async) -Cypress.Commands.add('waitForDom', () => { - cy.window().then({ - // Don't timeout before waitForDom finishes - timeout: 10000 - }, win => { - let timeElapsed = 0; - - cy.log("Waiting for DOM mutations to complete"); - - return new Cypress.Promise((resolve) => { - // set the required variables - let async = require("async"); - let observerConfig = { attributes: true, childList: true, subtree: true }; - let items = Array.apply(null, { length: 50 }).map(Number.call, Number); - win.mutationCount = 0; - win.previousMutationCount = null; - - // create an observer instance - let observer = new win.MutationObserver((mutations) => { - mutations.forEach((mutation) => { - // Only record "attributes" type mutations that are not a "class" mutation. - // If the mutation is not an "attributes" type, then we always record it. - if (mutation.type === 'attributes' && mutation.attributeName !== 'class') { - win.mutationCount += 1; - } else if (mutation.type !== 'attributes') { - win.mutationCount += 1; - } - }); - - // initialize the previousMutationCount - if (win.previousMutationCount == null) win.previousMutationCount = 0; - }); - - // watch the document body for the specified mutations - observer.observe(win.document.body, observerConfig); - - // check the DOM for mutations up to 50 times for a maximum time of 5 seconds - async.eachSeries(items, function iteratee(item, callback) { - // keep track of the elapsed time so we can log it at the end of the command - timeElapsed = timeElapsed + 100; - - // make each iteration of the loop 100ms apart - setTimeout(() => { - if (win.mutationCount === win.previousMutationCount) { - // pass an argument to the async callback to exit the loop - return callback('Resolved - DOM changes complete.'); - } else if (win.previousMutationCount != null) { - // only set the previous count if the observer has checked the DOM at least once - win.previousMutationCount = win.mutationCount; - return callback(); - } else if (win.mutationCount === 0 && win.previousMutationCount == null && item === 4) { - // this is an early exit in case nothing is changing in the DOM. That way we only - // wait 500ms instead of the full 5 seconds when no DOM changes are occurring. - return callback('Resolved - Exiting early since no DOM changes were detected.'); - } else { - // proceed to the next iteration - return callback(); - } - }, 100); - }, function done() { - // Log the total wait time so users can see it - cy.log(`DOM mutations ${timeElapsed >= 5000 ? "did not complete" : "completed"} in ${timeElapsed} ms`); - - // disconnect the observer and resolve the promise - observer.disconnect(); - resolve(); - }); - }); - }); - }); +Cypress.Commands.add("waitForDom", () => { + cy.window().then( + { + // Don't timeout before waitForDom finishes + timeout: 10000, + }, + win => { + let timeElapsed = 0; + + cy.log("Waiting for DOM mutations to complete"); + + return new Cypress.Promise(resolve => { + // set the required variables + let async = require("async"); + let observerConfig = { attributes: true, childList: true, subtree: true }; + let items = Array.apply(null, { length: 50 }).map(Number.call, Number); + win.mutationCount = 0; + win.previousMutationCount = null; + + // create an observer instance + let observer = new win.MutationObserver(mutations => { + mutations.forEach(mutation => { + // Only record "attributes" type mutations that are not a "class" mutation. + // If the mutation is not an "attributes" type, then we always record it. + if (mutation.type === "attributes" && mutation.attributeName !== "class") { + win.mutationCount += 1; + } else if (mutation.type !== "attributes") { + win.mutationCount += 1; + } + }); + + // initialize the previousMutationCount + if (win.previousMutationCount == null) win.previousMutationCount = 0; + }); + + // watch the document body for the specified mutations + observer.observe(win.document.body, observerConfig); + + // check the DOM for mutations up to 50 times for a maximum time of 5 seconds + async.eachSeries( + items, + function iteratee(item, callback) { + // keep track of the elapsed time so we can log it at the end of the command + timeElapsed = timeElapsed + 100; + + // make each iteration of the loop 100ms apart + setTimeout(() => { + if (win.mutationCount === win.previousMutationCount) { + // pass an argument to the async callback to exit the loop + return callback("Resolved - DOM changes complete."); + } else if (win.previousMutationCount != null) { + // only set the previous count if the observer has checked the DOM at least once + win.previousMutationCount = win.mutationCount; + return callback(); + } else if (win.mutationCount === 0 && win.previousMutationCount == null && item === 4) { + // this is an early exit in case nothing is changing in the DOM. That way we only + // wait 500ms instead of the full 5 seconds when no DOM changes are occurring. + return callback("Resolved - Exiting early since no DOM changes were detected."); + } else { + // proceed to the next iteration + return callback(); + } + }, 100); + }, + function done() { + // Log the total wait time so users can see it + cy.log(`DOM mutations ${timeElapsed >= 5000 ? "did not complete" : "completed"} in ${timeElapsed} ms`); + + // disconnect the observer and resolve the promise + observer.disconnect(); + resolve(); + } + ); + }); + } + ); +}); diff --git a/docker/Dockerfile b/docker/Dockerfile index b93ebd50..f529b796 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -32,3 +32,6 @@ RUN cd /usr/src/arvados && \ go run ./cmd/arvados-server install -type test && cd .. && \ rm -rf arvados && \ apt-get clean + +RUN git config --global --add safe.directory /usr/src/arvados && \ + git config --global --add safe.directory /usr/src/workbench2 \ No newline at end of file diff --git a/package.json b/package.json index 9e663ca6..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,33 +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", - "moment": "2.29.1", + "mime": "^3.0.0", + "moment": "^2.29.4", "parse-duration": "0.4.4", "prop-types": "15.7.2", "query-string": "6.9.0", - "react": "16.8.6", + "react": "16.14.0", "react-copy-to-clipboard": "5.0.3", "react-dnd": "5.0.0", "react-dnd-html5-backend": "5.0.1", - "react-dom": "16.8.6", + "react-dom": "16.14.0", "react-dropzone": "5.1.1", "react-highlight-words": "0.14.0", "react-idle-timer": "4.3.6", @@ -60,21 +66,21 @@ "react-router": "4.3.1", "react-router-dom": "4.3.1", "react-router-redux": "5.0.0-alpha.9", - "react-rte": "0.16.3", + "react-rte": "^0.16.5", "react-scripts": "3.4.4", "react-splitter-layout": "3.0.1", "react-transition-group": "2.5.0", "react-virtualized-auto-sizer": "1.0.2", "react-window": "1.8.5", "redux": "4.0.3", + "redux-devtools-extension": "^2.13.9", "redux-form": "7.4.2", "redux-thunk": "2.3.0", "reselect": "4.0.0", "set-value": "2.0.1", "shell-escape": "^0.2.0", "sinon": "7.3", - "tslint": "5.20.0", - "tslint-etc": "1.6.0", + "tippy.js": "^6.3.7", "unionize": "2.1.2", "uuid": "3.3.2" }, @@ -90,6 +96,7 @@ "watch-css": "npm run build-css && node-sass-chokidar src/ -o src/ --watch --recursive" }, "devDependencies": { + "@sinonjs/fake-timers": "^10.3.0", "@types/classnames": "2.2.6", "@types/enzyme": "3.1.14", "@types/enzyme-adapter-react-16": "1.0.3", @@ -110,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/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 2954d704..eff998ae 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -2,9 +2,10 @@ // // SPDX-License-Identifier: AGPL-3.0 -import Axios from "axios"; +import Axios from 'axios'; -export const WORKBENCH_CONFIG_URL = process.env.REACT_APP_ARVADOS_CONFIG_URL || "/config.json"; +export const WORKBENCH_CONFIG_URL = + process.env.REACT_APP_ARVADOS_CONFIG_URL || '/config.json'; interface WorkbenchConfig { API_HOST: string; @@ -13,6 +14,10 @@ interface WorkbenchConfig { } export interface ClusterConfigJSON { + API: { + UnfreezeProjectRequiresAdmin: boolean + MaxItemsPerResponse: number + }, ClusterID: string; RemoteClusters: { [key: string]: { @@ -28,26 +33,37 @@ export interface ClusterConfigJSON { }; Services: { Controller: { - ExternalURL: string - } + ExternalURL: string; + }; Workbench1: { - ExternalURL: string - } + ExternalURL: string; + }; Workbench2: { - ExternalURL: string - } + ExternalURL: string; + }; + Workbench: { + DisableSharingURLsUI: boolean; + ArvadosDocsite: string; + FileViewersConfigURL: string; + WelcomePageHTML: string; + InactivePageHTML: string; + SSHHelpPageHTML: string; + SSHHelpHostSuffix: string; + SiteName: string; + IdleTimeout: string; + }; Websocket: { - ExternalURL: string - } + ExternalURL: string; + }; WebDAV: { - ExternalURL: string - }, + ExternalURL: string; + }; WebDAVDownload: { - ExternalURL: string - }, + ExternalURL: string; + }; WebShell: { - ExternalURL: string - } + ExternalURL: string; + }; }; Workbench: { DisableSharingURLsUI: boolean; @@ -59,45 +75,51 @@ export interface ClusterConfigJSON { SSHHelpHostSuffix: string; SiteName: string; IdleTimeout: string; + BannerUUID: string; + UserProfileFormFields: {}; + UserProfileFormMessage: string; }; Login: { LoginCluster: string; Google: { Enable: boolean; - } + }; LDAP: { Enable: boolean; - } + }; OpenIDConnect: { Enable: boolean; - } + }; PAM: { Enable: boolean; - } + }; SSO: { Enable: boolean; - } + }; Test: { Enable: boolean; - } + }; }; Collections: { ForwardSlashNameSubstitution: string; ManagedProperties?: { [key: string]: { - Function: string, - Value: string, - Protected?: boolean, - } - }, - TrustAllContent: boolean + Function: string; + Value: string; + Protected?: boolean; + }; + }; + TrustAllContent: boolean; }; Volumes: { [key: string]: { StorageClasses: { [key: string]: boolean; - } - } + }; + }; + }; + Users: { + AnonymousUserToken: string; }; } @@ -106,7 +128,7 @@ export class Config { keepWebServiceUrl!: string; keepWebInlineServiceUrl!: string; remoteHosts!: { - [key: string]: string + [key: string]: string; }; rootUrl!: string; uuidPrefix!: string; @@ -129,8 +151,10 @@ export const buildConfig = (clusterConfig: ClusterConfigJSON): Config => { config.websocketUrl = clusterConfigJSON.Services.Websocket.ExternalURL; config.workbench2Url = clusterConfigJSON.Services.Workbench2.ExternalURL; config.workbenchUrl = clusterConfigJSON.Services.Workbench1.ExternalURL; - config.keepWebServiceUrl = clusterConfigJSON.Services.WebDAVDownload.ExternalURL; - config.keepWebInlineServiceUrl = clusterConfigJSON.Services.WebDAV.ExternalURL; + config.keepWebServiceUrl = + clusterConfigJSON.Services.WebDAVDownload.ExternalURL; + config.keepWebInlineServiceUrl = + clusterConfigJSON.Services.WebDAV.ExternalURL; config.loginCluster = clusterConfigJSON.Login.LoginCluster; config.clusterConfig = clusterConfigJSON; config.apiRevision = 0; @@ -141,8 +165,8 @@ export const buildConfig = (clusterConfig: ClusterConfigJSON): Config => { export const getStorageClasses = (config: Config): string[] => { const classes: Set = new Set(['default']); const volumes = config.clusterConfig.Volumes; - Object.keys(volumes).forEach(v => { - Object.keys(volumes[v].StorageClasses || {}).forEach(sc => { + Object.keys(volumes).forEach((v) => { + Object.keys(volumes[v].StorageClasses || {}).forEach((sc) => { if (volumes[v].StorageClasses[sc]) { classes.add(sc); } @@ -156,12 +180,16 @@ const getApiRevision = async (apiUrl: string) => { const dd = (await Axios.get(`${apiUrl}/${DISCOVERY_DOC_PATH}`)).data; return parseInt(dd.revision, 10) || 0; } catch { - console.warn("Unable to get API Revision number, defaulting to zero. Some features may not work properly."); + console.warn( + 'Unable to get API Revision number, defaulting to zero. Some features may not work properly.' + ); return 0; } }; -const removeTrailingSlashes = (config: ClusterConfigJSON): ClusterConfigJSON => { +const removeTrailingSlashes = ( + config: ClusterConfigJSON +): ClusterConfigJSON => { const svcs: any = {}; Object.keys(config.Services).forEach((s) => { svcs[s] = config.Services[s]; @@ -173,39 +201,53 @@ const removeTrailingSlashes = (config: ClusterConfigJSON): ClusterConfigJSON => }; export const fetchConfig = () => { - return Axios - .get(WORKBENCH_CONFIG_URL + "?nocache=" + (new Date()).getTime()) - .then(response => response.data) + 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.`); + console.warn( + `There was an exception getting the Workbench config file at ${WORKBENCH_CONFIG_URL}. Using defaults instead.` + ); return Promise.resolve(getDefaultConfig()); }) - .then(workbenchConfig => { + .then((workbenchConfig) => { if (workbenchConfig.API_HOST === undefined) { - throw new Error(`Unable to start Workbench. API_HOST is undefined in ${WORKBENCH_CONFIG_URL} or the environment.`); + throw new Error( + `Unable to start Workbench. API_HOST is undefined in ${WORKBENCH_CONFIG_URL} or the environment.` + ); } - return Axios.get(getClusterConfigURL(workbenchConfig.API_HOST)).then(async response => { - const apiRevision = await getApiRevision(response.data.Services.Controller.ExternalURL.replace(/\/+$/, '')); + 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}`); + const warnLocalConfig = (varName: string) => + console.warn( + `A value for ${varName} was found in ${WORKBENCH_CONFIG_URL}. To use the Arvados centralized configuration instead, \ +remove the entire ${varName} entry from ${WORKBENCH_CONFIG_URL}` + ); // Check if the workbench config has an entry for vocabulary and file viewer URLs // If so, use these values (even if it is an empty string), but print a console warning. // Otherwise, use the cluster config. let fileViewerConfigUrl; if (workbenchConfig.FILE_VIEWERS_CONFIG_URL !== undefined) { - warnLocalConfig("FILE_VIEWERS_CONFIG_URL"); + warnLocalConfig('FILE_VIEWERS_CONFIG_URL'); fileViewerConfigUrl = workbenchConfig.FILE_VIEWERS_CONFIG_URL; - } - else { - fileViewerConfigUrl = config.clusterConfig.Workbench.FileViewersConfigURL || "/file-viewers-example.json"; + } else { + fileViewerConfigUrl = + config.clusterConfig.Workbench.FileViewersConfigURL || + '/file-viewers-example.json'; } config.fileViewersConfigUrl = fileViewerConfigUrl; if (workbenchConfig.VOCABULARY_URL !== undefined) { - console.warn(`A value for VOCABULARY_URL was found in ${WORKBENCH_CONFIG_URL}. It will be ignored as the cluster already provides its own endpoint, you can safely remove it.`) + console.warn( + `A value for VOCABULARY_URL was found in ${WORKBENCH_CONFIG_URL}. It will be ignored as the cluster already provides its own endpoint, you can safely remove it.` + ); } config.vocabularyUrl = getVocabularyURL(workbenchConfig.API_HOST); @@ -215,37 +257,62 @@ remove the entire ${varName} entry from ${WORKBENCH_CONFIG_URL}`); }; // Maps remote cluster hosts and removes the default RemoteCluster entry -export const mapRemoteHosts = (clusterConfigJSON: ClusterConfigJSON, config: Config) => { +export const mapRemoteHosts = ( + clusterConfigJSON: ClusterConfigJSON, + config: Config +) => { config.remoteHosts = {}; - Object.keys(clusterConfigJSON.RemoteClusters).forEach(k => { config.remoteHosts[k] = clusterConfigJSON.RemoteClusters[k].Host; }); - delete config.remoteHosts["*"]; + Object.keys(clusterConfigJSON.RemoteClusters).forEach((k) => { + config.remoteHosts[k] = clusterConfigJSON.RemoteClusters[k].Host; + }); + delete config.remoteHosts['*']; }; -export const mockClusterConfigJSON = (config: Partial): ClusterConfigJSON => ({ - ClusterID: "", +export const mockClusterConfigJSON = ( + 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: "" }, + Controller: { ExternalURL: '' }, + Workbench1: { ExternalURL: '' }, + Workbench2: { ExternalURL: '' }, + Websocket: { ExternalURL: '' }, + WebDAV: { ExternalURL: '' }, + WebDAVDownload: { ExternalURL: '' }, + WebShell: { ExternalURL: '' }, + Workbench: { + DisableSharingURLsUI: false, + ArvadosDocsite: "", + FileViewersConfigURL: "", + WelcomePageHTML: "", + InactivePageHTML: "", + SSHHelpPageHTML: "", + SSHHelpHostSuffix: "", + SiteName: "", + IdleTimeout: "0s" + }, }, Workbench: { DisableSharingURLsUI: false, - ArvadosDocsite: "", - FileViewersConfigURL: "", - WelcomePageHTML: "", - InactivePageHTML: "", - SSHHelpPageHTML: "", - SSHHelpHostSuffix: "", - SiteName: "", - IdleTimeout: "0s", + ArvadosDocsite: '', + FileViewersConfigURL: '', + WelcomePageHTML: '', + InactivePageHTML: '', + SSHHelpPageHTML: '', + SSHHelpHostSuffix: '', + SiteName: '', + IdleTimeout: '0s', + BannerUUID: "", + UserProfileFormFields: {}, + UserProfileFormMessage: '', }, Login: { - LoginCluster: "", + LoginCluster: '', Google: { Enable: false, }, @@ -266,40 +333,44 @@ export const mockClusterConfigJSON = (config: Partial): Clust }, }, Collections: { - ForwardSlashNameSubstitution: "", + ForwardSlashNameSubstitution: '', TrustAllContent: false, }, Volumes: {}, - ...config + Users: { + AnonymousUserToken: "" + }, + ...config, }); export const mockConfig = (config: Partial): Config => ({ - baseUrl: "", - keepWebServiceUrl: "", - keepWebInlineServiceUrl: "", + baseUrl: '', + keepWebServiceUrl: '', + keepWebInlineServiceUrl: '', remoteHosts: {}, - rootUrl: "", - uuidPrefix: "", - websocketUrl: "", - workbenchUrl: "", - workbench2Url: "", - vocabularyUrl: "", - fileViewersConfigUrl: "", - loginCluster: "", + rootUrl: '', + uuidPrefix: '', + websocketUrl: '', + workbenchUrl: '', + workbench2Url: '', + vocabularyUrl: '', + fileViewersConfigUrl: '', + loginCluster: '', clusterConfig: mockClusterConfigJSON({}), apiRevision: 0, - ...config + ...config, }); const getDefaultConfig = (): WorkbenchConfig => { - let apiHost = ""; + let apiHost = ''; const envHost = process.env.REACT_APP_ARVADOS_API_HOST; if (envHost !== undefined) { console.warn(`Using default API host ${envHost}.`); apiHost = envHost; - } - else { - console.warn(`No API host was found in the environment. Workbench may not be able to communicate with Arvados components.`); + } else { + console.warn( + `No API host was found in the environment. Workbench may not be able to communicate with Arvados components.` + ); } return { API_HOST: apiHost, @@ -308,9 +379,11 @@ const getDefaultConfig = (): WorkbenchConfig => { }; }; -export const ARVADOS_API_PATH = "arvados/v1"; -export const CLUSTER_CONFIG_PATH = "arvados/v1/config"; -export const VOCABULARY_PATH = "arvados/v1/vocabulary"; -export const DISCOVERY_DOC_PATH = "discovery/v1/apis/arvados/v1/rest"; -export const getClusterConfigURL = (apiHost: string) => `https://${apiHost}/${CLUSTER_CONFIG_PATH}?nocache=${(new Date()).getTime()}`; -export const getVocabularyURL = (apiHost: string) => `https://${apiHost}/${VOCABULARY_PATH}?nocache=${(new Date()).getTime()}`; +export const ARVADOS_API_PATH = 'arvados/v1'; +export const CLUSTER_CONFIG_PATH = 'arvados/v1/config'; +export const VOCABULARY_PATH = 'arvados/v1/vocabulary'; +export const DISCOVERY_DOC_PATH = 'discovery/v1/apis/arvados/v1/rest'; +export const getClusterConfigURL = (apiHost: string) => + `https://${apiHost}/${CLUSTER_CONFIG_PATH}?nocache=${new Date().getTime()}`; +export const getVocabularyURL = (apiHost: string) => + `https://${apiHost}/${VOCABULARY_PATH}?nocache=${new Date().getTime()}`; diff --git a/src/common/custom-theme.ts b/src/common/custom-theme.ts index fc89a4ae..135204a0 100644 --- a/src/common/custom-theme.ts +++ b/src/common/custom-theme.ts @@ -9,7 +9,6 @@ import grey from '@material-ui/core/colors/grey'; import green from '@material-ui/core/colors/green'; import yellow from '@material-ui/core/colors/yellow'; import red from '@material-ui/core/colors/red'; -import teal from '@material-ui/core/colors/teal'; export interface ArvadosThemeOptions extends ThemeOptions { customs: any; @@ -23,21 +22,36 @@ export interface ArvadosTheme extends Theme { interface Colors { green700: string; + green800: string; yellow100: string; yellow700: string; yellow900: string; red100: string; red900: string; blue500: string; + blue700: string; grey500: string; + grey600: string; + grey700: string; + grey900: string; purple: string; - orange: string; + orange: string; + greyL: string; + greyD: string; + darkblue: string; } -const arvadosPurple = '#361336'; +/** +* arvadosGreyLight is the hex equivalent of rgba(0,0,0,0.87) on #fafafa background and arvadosGreyDark is the hex equivalent of rgab(0,0,0,0.54) on #fafafa background +*/ + +const arvadosDarkBlue = '#052a3c'; +const arvadosGreyLight = '#737373'; +const arvadosGreyDark = '#212121'; const grey500 = grey["500"]; const grey600 = grey["600"]; const grey700 = grey["700"]; +const grey800 = grey["800"]; const grey900 = grey["900"]; export const themeOptions: ArvadosThemeOptions = { @@ -47,15 +61,23 @@ export const themeOptions: ArvadosThemeOptions = { customs: { colors: { green700: green["700"], + green800: green["800"], yellow100: yellow["100"], yellow700: yellow["700"], yellow900: yellow["900"], red100: red["100"], red900: red['900'], blue500: blue['500'], + blue700: blue['700'], grey500: grey500, - purple: arvadosPurple, + grey600: grey600, + grey700: grey700, + grey800: grey800, + grey900: grey900, + darkblue: arvadosDarkBlue, orange: '#f0ad4e', + greyL: arvadosGreyLight, + greyD: arvadosGreyDark, } }, overrides: { @@ -66,7 +88,7 @@ export const themeOptions: ArvadosThemeOptions = { }, MuiAppBar: { colorPrimary: { - backgroundColor: arvadosPurple + backgroundColor: arvadosDarkBlue } }, MuiTabs: { @@ -74,14 +96,13 @@ export const themeOptions: ArvadosThemeOptions = { color: grey600 }, indicator: { - backgroundColor: arvadosPurple + backgroundColor: arvadosDarkBlue } }, MuiTab: { root: { '&$selected': { fontWeight: 700, - color: arvadosPurple } } }, @@ -97,7 +118,7 @@ export const themeOptions: ArvadosThemeOptions = { }, MuiListItemIcon: { root: { - fontSize: '1.25rem' + fontSize: '1.25rem', } }, MuiCardHeader: { @@ -106,7 +127,7 @@ export const themeOptions: ArvadosThemeOptions = { alignItems: 'center' }, title: { - color: grey700, + color: arvadosGreyDark, fontSize: '1.25rem' } }, @@ -143,7 +164,7 @@ export const themeOptions: ArvadosThemeOptions = { }, underline: { '&:after': { - borderBottomColor: arvadosPurple + borderBottomColor: arvadosDarkBlue }, '&:hover:not($disabled):not($focused):not($error):before': { borderBottom: '1px solid inherit' @@ -155,7 +176,7 @@ export const themeOptions: ArvadosThemeOptions = { fontSize: '0.875rem', "&$focused": { "&$focused:not($error)": { - color: arvadosPurple + color: arvadosDarkBlue } } } @@ -163,7 +184,7 @@ export const themeOptions: ArvadosThemeOptions = { MuiStepIcon: { root: { '&$active': { - color: arvadosPurple + color: arvadosDarkBlue }, '&$completed': { color: 'inherited' @@ -178,11 +199,12 @@ export const themeOptions: ArvadosThemeOptions = { }, palette: { primary: { - main: teal.A700, - dark: teal.A400, + main: '#017ead', + dark: '#015272', + light: '#82cffd', contrastText: '#fff' } }, }; -export const CustomTheme = createMuiTheme(themeOptions); \ No newline at end of file +export const CustomTheme = createMuiTheme(themeOptions); diff --git a/src/common/formatters.test.ts b/src/common/formatters.test.ts index 83177e22..04877972 100644 --- a/src/common/formatters.test.ts +++ b/src/common/formatters.test.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { formatUploadSpeed } from "./formatters"; +import { formatUploadSpeed, formatContainerCost } from "./formatters"; describe('formatUploadSpeed', () => { it('should show speed less than 1MB/s', () => { @@ -25,5 +25,21 @@ describe('formatUploadSpeed', () => { // then expect(result).toBe('5.23 MB/s'); - }); -}); \ No newline at end of file + }); +}); + +describe('formatContainerCost', () => { + it('should correctly round to tenth of a cent', () => { + expect(formatContainerCost(0.0)).toBe('$0'); + expect(formatContainerCost(0.125)).toBe('$0.125'); + expect(formatContainerCost(0.1254)).toBe('$0.125'); + expect(formatContainerCost(0.1255)).toBe('$0.126'); + }); + + it('should round up any smaller value to 0.001', () => { + expect(formatContainerCost(0.0)).toBe('$0'); + expect(formatContainerCost(0.001)).toBe('$0.001'); + expect(formatContainerCost(0.0001)).toBe('$0.001'); + expect(formatContainerCost(0.00001)).toBe('$0.001'); + }); +}); diff --git a/src/common/formatters.ts b/src/common/formatters.ts index 6d0a7e49..a38609a6 100644 --- a/src/common/formatters.ts +++ b/src/common/formatters.ts @@ -2,8 +2,12 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { PropertyValue } from "models/search-bar"; -import { Vocabulary, getTagKeyLabel, getTagValueLabel } from "models/vocabulary"; +import { PropertyValue } from 'models/search-bar'; +import { + Vocabulary, + getTagKeyLabel, + getTagValueLabel, +} from 'models/vocabulary'; export const formatDate = (isoDate?: string | null, utc: boolean = false) => { if (isoDate) { @@ -11,41 +15,42 @@ export const formatDate = (isoDate?: string | null, utc: boolean = false) => { let text: string; if (utc) { text = date.toUTCString(); - } - else { + } else { text = date.toLocaleString(); } - return text === 'Invalid Date' ? "(none)" : text; + return text === 'Invalid Date' ? '(none)' : text; } - return "(none)"; + return '-'; }; export const formatFileSize = (size?: number | string) => { - if (typeof size === "number") { - if (size === 0) { return "0 B"; } + if (typeof size === 'number') { + if (size === 0) { + return '0 B'; + } for (const { base, unit } of FILE_SIZES) { if (size >= base) { - return `${(size / base).toFixed()} ${unit}`; + return `${(size / base).toFixed(base === 1 ? 0 : 1)} ${unit}`; } } } - if ((typeof size === "string" && size === '') || size === undefined) { - return ''; + if ((typeof size === 'string' && size === '') || size === undefined) { + return '-'; } - return "0 B"; + return '0 B'; }; export const formatTime = (time: number, seconds?: boolean) => { - const minutes = Math.floor(time / (1000 * 60) % 60).toFixed(0); + const minutes = Math.floor((time / (1000 * 60)) % 60).toFixed(0); const hours = Math.floor(time / (1000 * 60 * 60)).toFixed(0); if (seconds) { - const seconds = Math.floor(time / (1000) % 60).toFixed(0); - return hours + "h " + minutes + "m " + seconds + "s"; + const seconds = Math.floor((time / 1000) % 60).toFixed(0); + return hours + 'h ' + minutes + 'm ' + seconds + 's'; } - return hours + "h " + minutes + "m"; + return hours + 'h ' + minutes + 'm'; }; export const getTimeDiff = (endTime: string, startTime: string) => { @@ -53,14 +58,20 @@ export const getTimeDiff = (endTime: string, startTime: string) => { }; export const formatProgress = (loaded: number, total: number) => { - const progress = loaded >= 0 && total > 0 ? loaded * 100 / total : 0; + const progress = loaded >= 0 && total > 0 ? (loaded * 100) / total : 0; return `${progress.toFixed(2)}%`; }; -export function formatUploadSpeed(prevLoaded: number, loaded: number, prevTime: number, currentTime: number) { - const speed = loaded > prevLoaded && currentTime > prevTime - ? (loaded - prevLoaded) / (currentTime - prevTime) - : 0; +export function formatUploadSpeed( + prevLoaded: number, + loaded: number, + prevTime: number, + currentTime: number +) { + const speed = + loaded > prevLoaded && currentTime > prevTime + ? (loaded - prevLoaded) / (currentTime - prevTime) + : 0; return `${(speed / 1000).toFixed(2)} MB/s`; } @@ -68,34 +79,53 @@ export function formatUploadSpeed(prevLoaded: number, loaded: number, prevTime: const FILE_SIZES = [ { base: 1099511627776, - unit: "TB" + unit: 'TiB', }, { base: 1073741824, - unit: "GB" + unit: 'GiB', }, { base: 1048576, - unit: "MB" + unit: 'MiB', }, { base: 1024, - unit: "KB" + unit: 'KiB', }, { base: 1, - unit: "B" - } + unit: 'B', + }, ]; -export const formatPropertyValue = (pv: PropertyValue, vocabulary?: Vocabulary) => { +export const formatPropertyValue = ( + pv: PropertyValue, + vocabulary?: Vocabulary +) => { if (vocabulary && pv.keyID && pv.valueID) { - return `${getTagKeyLabel(pv.keyID, vocabulary)}: ${getTagValueLabel(pv.keyID, pv.valueID!, vocabulary)}`; + return `${getTagKeyLabel(pv.keyID, vocabulary)}: ${getTagValueLabel( + pv.keyID, + pv.valueID!, + vocabulary + )}`; } if (pv.key) { - return pv.value - ? `${pv.key}: ${pv.value}` - : pv.key; + return pv.value ? `${pv.key}: ${pv.value}` : pv.key; + } + return ''; +}; + +export const formatContainerCost = (cost: number): string => { + const decimalPlaces = 3; + + const factor = Math.pow(10, decimalPlaces); + const rounded = Math.round(cost * factor) / factor; + if (cost > 0 && rounded === 0) { + // Display min value of 0.001 + return `$${1 / factor}`; + } else { + // Otherwise use rounded value to proper decimal places + return `$${rounded}`; } - return ""; }; diff --git a/src/common/frozen-resources.ts b/src/common/frozen-resources.ts new file mode 100644 index 00000000..8d227915 --- /dev/null +++ b/src/common/frozen-resources.ts @@ -0,0 +1,19 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { ProjectResource } from "models/project"; +import { getResource } from "store/resources/resources"; + +export const resourceIsFrozen = (resource: any, resources): boolean => { + let isFrozen: boolean = !!resource.frozenByUuid; + let ownerUuid: string | undefined = resource?.ownerUuid; + + while(!isFrozen && !!ownerUuid && ownerUuid.indexOf('000000000000000') === -1) { + const parentResource: ProjectResource | undefined = getResource(ownerUuid)(resources); + isFrozen = !!parentResource?.frozenByUuid; + ownerUuid = parentResource?.ownerUuid; + } + + return isFrozen; +} \ No newline at end of file diff --git a/src/common/html-sanitize.ts b/src/common/html-sanitize.ts new file mode 100644 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 d8fecde4..e71ebde7 100644 --- a/src/common/redirect-to.ts +++ b/src/common/redirect-to.ts @@ -7,6 +7,7 @@ import { Config } from './config'; export const REDIRECT_TO_DOWNLOAD_KEY = 'redirectToDownload'; export const REDIRECT_TO_PREVIEW_KEY = 'redirectToPreview'; +export const REDIRECT_TO_KEY = 'redirectTo'; const getRedirectKeyFromUrl = (href: string): string | null => { switch (true) { @@ -14,6 +15,8 @@ const getRedirectKeyFromUrl = (href: string): string | null => { return REDIRECT_TO_DOWNLOAD_KEY; case href.indexOf(REDIRECT_TO_PREVIEW_KEY) > -1: return REDIRECT_TO_PREVIEW_KEY; + case href.indexOf(`${REDIRECT_TO_KEY}=`) > -1: + return REDIRECT_TO_KEY; default: return null; } @@ -32,8 +35,11 @@ export const storeRedirects = () => { const { location: { href }, localStorage } = window; const redirectKey = getRedirectKeyFromUrl(href); - if (localStorage && redirectKey) { - localStorage.setItem(redirectKey, href.split(`${redirectKey}=`)[1]); + // Change old redirectTo -> redirectToPreview when storing redirect + const redirectStoreKey = redirectKey === REDIRECT_TO_KEY ? REDIRECT_TO_PREVIEW_KEY : redirectKey; + + if (localStorage && redirectKey && redirectStoreKey) { + localStorage.setItem(redirectStoreKey, decodeURIComponent(href.split(`${redirectKey}=`)[1])); } }; diff --git a/src/common/service-provider.ts b/src/common/service-provider.ts index 080916c5..e0504ebf 100644 --- a/src/common/service-provider.ts +++ b/src/common/service-provider.ts @@ -6,6 +6,7 @@ class ServicesProvider { private static instance: ServicesProvider; + private store; private services; private constructor() {} @@ -30,6 +31,20 @@ class ServicesProvider { } return this.services; } + + public setStore(newStore): void { + if (!this.store) { + this.store = newStore; + } + } + + public getStore() { + if (!this.store) { + throw "Please check if store has been set in the index.ts before the app is initiated"; // eslint-disable-line no-throw-literal + } + + return this.store; + } } export default ServicesProvider.getInstance(); diff --git a/src/common/use-async-interval.test.tsx b/src/common/use-async-interval.test.tsx new file mode 100644 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/common/webdav.test.ts b/src/common/webdav.test.ts index 2ab106fc..1149c451 100644 --- a/src/common/webdav.test.ts +++ b/src/common/webdav.test.ts @@ -2,7 +2,6 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { customEncodeURI } from "./url"; import { WebDAV } from "./webdav"; describe('WebDAV', () => { @@ -14,34 +13,36 @@ describe('WebDAV', () => { const request = await promise; expect(open).toHaveBeenCalledWith('PROPFIND', 'http://foo.com/foo'); expect(setRequestHeader).toHaveBeenCalledWith('Authorization', 'Basic'); + expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache'); expect(request).toBeInstanceOf(XMLHttpRequest); }); it('allows to modify defaults after instantiation', async () => { const { open, load, setRequestHeader, createRequest } = mockCreateRequest(); - const webdav = new WebDAV(undefined, createRequest); - webdav.defaults.baseURL = 'http://foo.com/'; - webdav.defaults.headers = { Authorization: 'Basic' }; + const webdav = new WebDAV({ baseURL: 'http://foo.com/' }, createRequest); + webdav.setAuthorization('Basic'); const promise = webdav.propfind('foo'); load(); const request = await promise; expect(open).toHaveBeenCalledWith('PROPFIND', 'http://foo.com/foo'); expect(setRequestHeader).toHaveBeenCalledWith('Authorization', 'Basic'); + expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache'); expect(request).toBeInstanceOf(XMLHttpRequest); }); it('PROPFIND', async () => { - const { open, load, createRequest } = mockCreateRequest(); + const { open, load, setRequestHeader, createRequest } = mockCreateRequest(); const webdav = new WebDAV(undefined, createRequest); const promise = webdav.propfind('foo'); load(); const request = await promise; expect(open).toHaveBeenCalledWith('PROPFIND', 'foo'); + expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache'); expect(request).toBeInstanceOf(XMLHttpRequest); }); it('PUT', async () => { - const { open, send, load, progress, createRequest } = mockCreateRequest(); + const { open, send, load, progress, setRequestHeader, createRequest } = mockCreateRequest(); const webdav = new WebDAV(undefined, createRequest); const promise = webdav.put('foo', 'Test data'); progress(); @@ -49,88 +50,90 @@ describe('WebDAV', () => { const request = await promise; expect(open).toHaveBeenCalledWith('PUT', 'foo'); expect(send).toHaveBeenCalledWith('Test data'); + expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache'); expect(request).toBeInstanceOf(XMLHttpRequest); }); it('COPY', async () => { const { open, setRequestHeader, load, createRequest } = mockCreateRequest(); - const webdav = new WebDAV(undefined, createRequest); - webdav.defaults.baseURL = 'http://base'; + const webdav = new WebDAV({ baseURL: 'http://base' }, createRequest); const promise = webdav.copy('foo', 'foo-copy'); load(); const request = await promise; expect(open).toHaveBeenCalledWith('COPY', 'http://base/foo'); expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-copy'); + expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache'); expect(request).toBeInstanceOf(XMLHttpRequest); }); it('COPY - adds baseURL with trailing slash to Destination header', async () => { const { open, setRequestHeader, load, createRequest } = mockCreateRequest(); - const webdav = new WebDAV(undefined, createRequest); - webdav.defaults.baseURL = 'http://base'; + const webdav = new WebDAV({ baseURL: 'http://base' }, createRequest); const promise = webdav.copy('foo', 'foo-copy'); load(); const request = await promise; expect(open).toHaveBeenCalledWith('COPY', 'http://base/foo'); expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-copy'); + expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache'); expect(request).toBeInstanceOf(XMLHttpRequest); }); it('COPY - adds baseURL without trailing slash to Destination header', async () => { const { open, setRequestHeader, load, createRequest } = mockCreateRequest(); - const webdav = new WebDAV(undefined, createRequest); - webdav.defaults.baseURL = 'http://base'; + const webdav = new WebDAV({ baseURL: 'http://base' }, createRequest); const promise = webdav.copy('foo', 'foo-copy'); load(); const request = await promise; expect(open).toHaveBeenCalledWith('COPY', 'http://base/foo'); expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-copy'); + expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache'); expect(request).toBeInstanceOf(XMLHttpRequest); }); it('MOVE', async () => { const { open, setRequestHeader, load, createRequest } = mockCreateRequest(); - const webdav = new WebDAV(undefined, createRequest); - webdav.defaults.baseURL = 'http://base'; + const webdav = new WebDAV({ baseURL: 'http://base' }, createRequest); const promise = webdav.move('foo', 'foo-moved'); load(); const request = await promise; expect(open).toHaveBeenCalledWith('MOVE', 'http://base/foo'); expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-moved'); + expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache'); expect(request).toBeInstanceOf(XMLHttpRequest); }); it('MOVE - adds baseURL with trailing slash to Destination header', async () => { const { open, setRequestHeader, load, createRequest } = mockCreateRequest(); - const webdav = new WebDAV(undefined, createRequest); - webdav.defaults.baseURL = 'http://base'; + const webdav = new WebDAV({ baseURL: 'http://base' }, createRequest); const promise = webdav.move('foo', 'foo-moved'); load(); const request = await promise; expect(open).toHaveBeenCalledWith('MOVE', 'http://base/foo'); expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-moved'); + expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache'); expect(request).toBeInstanceOf(XMLHttpRequest); }); it('MOVE - adds baseURL without trailing slash to Destination header', async () => { const { open, setRequestHeader, load, createRequest } = mockCreateRequest(); - const webdav = new WebDAV(undefined, createRequest); - webdav.defaults.baseURL = 'http://base'; + const webdav = new WebDAV({ baseURL: 'http://base' }, createRequest); const promise = webdav.move('foo', 'foo-moved'); load(); const request = await promise; expect(open).toHaveBeenCalledWith('MOVE', 'http://base/foo'); expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-moved'); + expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache'); expect(request).toBeInstanceOf(XMLHttpRequest); }); it('DELETE', async () => { - const { open, load, createRequest } = mockCreateRequest(); + const { open, load, setRequestHeader, createRequest } = mockCreateRequest(); const webdav = new WebDAV(undefined, createRequest); const promise = webdav.delete('foo'); load(); const request = await promise; expect(open).toHaveBeenCalledWith('DELETE', 'foo'); + expect(setRequestHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache'); expect(request).toBeInstanceOf(XMLHttpRequest); }); }); diff --git a/src/common/webdav.ts b/src/common/webdav.ts index 93ec21cb..1f3da0d6 100644 --- a/src/common/webdav.ts +++ b/src/common/webdav.ts @@ -6,17 +6,29 @@ import { customEncodeURI } from "./url"; export class WebDAV { - defaults: WebDAVDefaults = { + private defaults: WebDAVDefaults = { baseURL: '', - headers: {}, + headers: { + 'Cache-Control': 'no-cache' + }, }; constructor(config?: Partial, private createRequest = () => new XMLHttpRequest()) { if (config) { - this.defaults = { ...this.defaults, ...config }; + this.defaults = { + ...this.defaults, + ...config, + headers: { + ...this.defaults.headers, + ...config.headers + }, + }; } } + getBaseUrl = (): string => this.defaults.baseURL; + setAuthorization = (token?) => this.defaults.headers.Authorization = token; + propfind = (url: string, config: WebDAVRequestConfig = {}) => this.request({ ...config, url, @@ -30,6 +42,12 @@ export class WebDAV { data }) + get = (url: string, config: WebDAVRequestConfig = {}) => + this.request({ + ...config, url, + method: 'GET' + }) + upload = (url: string, files: File[], config: WebDAVRequestConfig = {}) => { return Promise.all( files.map(file => this.request({ @@ -76,7 +94,7 @@ export class WebDAV { this.defaults.baseURL = this.defaults.baseURL.replace(/\/+$/, ''); r.open(config.method, `${this.defaults.baseURL - ? this.defaults.baseURL+'/' + ? this.defaults.baseURL + '/' : ''}${customEncodeURI(config.url)}`); const headers = { ...this.defaults.headers, ...config.headers }; @@ -88,7 +106,7 @@ export class WebDAV { Object.assign(window, { cancelTokens: {} }); } - (window as any).cancelTokens[config.url] = () => { + (window as any).cancelTokens[config.url] = () => { resolve(r); r.abort(); } @@ -138,4 +156,4 @@ interface RequestConfig { headers?: { [key: string]: string }; data?: any; onUploadProgress?: (event: ProgressEvent) => void; -} \ No newline at end of file +} diff --git a/src/components/autocomplete/autocomplete.tsx b/src/components/autocomplete/autocomplete.tsx index 0044807b..17d85e85 100644 --- a/src/components/autocomplete/autocomplete.tsx +++ b/src/components/autocomplete/autocomplete.tsx @@ -8,7 +8,7 @@ import { Chip as MuiChip, Popper as MuiPopper, Paper as MuiPaper, - FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List, FormHelperText + FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List, FormHelperText, Tooltip } from '@material-ui/core'; import { PopperProps } from '@material-ui/core/Popper'; import { WithStyles } from '@material-ui/core/styles'; @@ -30,6 +30,7 @@ export interface AutocompleteProps { onDelete?: (item: Item, index: number) => void; onSelect?: (suggestion: Suggestion) => void; renderChipValue?: (item: Item) => string; + renderChipTooltip?: (item: Item) => string; renderSuggestion?: (suggestion: Suggestion) => React.ReactNode; } @@ -171,11 +172,22 @@ export class Autocomplete extends React.Component - onDelete(item, index)) : undefined} /> + (item, index) => { + const tooltip = this.props.renderChipTooltip ? this.props.renderChipTooltip(item) : ''; + if (tooltip && tooltip.length) { + return + + onDelete(item, index)) : undefined} /> + + } else { + return onDelete(item, index)) : undefined} /> + } + } ); } diff --git a/src/components/breadcrumbs/breadcrumbs.test.tsx b/src/components/breadcrumbs/breadcrumbs.test.tsx index fe3d2ab0..f17ce393 100644 --- a/src/components/breadcrumbs/breadcrumbs.test.tsx +++ b/src/components/breadcrumbs/breadcrumbs.test.tsx @@ -3,48 +3,79 @@ // SPDX-License-Identifier: AGPL-3.0 import React from "react"; -import { configure, shallow } from "enzyme"; +import { configure, mount } from "enzyme"; import Adapter from "enzyme-adapter-react-16"; import { Breadcrumbs } from "./breadcrumbs"; -import { Button } from "@material-ui/core"; +import { Button, MuiThemeProvider } from "@material-ui/core"; import ChevronRightIcon from '@material-ui/icons/ChevronRight'; +import { CustomTheme } from 'common/custom-theme'; +import { Provider } from "react-redux"; +import { combineReducers, createStore } from "redux"; configure({ adapter: new Adapter() }); describe("", () => { let onClick: () => void; - + let resources = {}; + let store; beforeEach(() => { onClick = jest.fn(); + const initialAuthState = { + config: { + clusterConfig: { + Collections: { + ForwardSlashNameSubstitution: "/" + } + } + } + } + store = createStore(combineReducers({ + auth: (state: any = initialAuthState, action: any) => state, + })); }); it("renders one item", () => { const items = [ - { label: 'breadcrumb 1' } + { label: 'breadcrumb 1', uuid: '1' } ]; - const breadcrumbs = shallow().dive(); + const breadcrumbs = mount( + + + + + ); expect(breadcrumbs.find(Button)).toHaveLength(1); expect(breadcrumbs.find(ChevronRightIcon)).toHaveLength(0); }); it("renders multiple items", () => { const items = [ - { label: 'breadcrumb 1' }, - { label: 'breadcrumb 2' } + { label: 'breadcrumb 1', uuid: '1' }, + { label: 'breadcrumb 2', uuid: '2' } ]; - const breadcrumbs = shallow().dive(); + const breadcrumbs = mount( + + + + + ); expect(breadcrumbs.find(Button)).toHaveLength(2); expect(breadcrumbs.find(ChevronRightIcon)).toHaveLength(1); }); it("calls onClick with clicked item", () => { const items = [ - { label: 'breadcrumb 1' }, - { label: 'breadcrumb 2' } + { label: 'breadcrumb 1', uuid: '1' }, + { label: 'breadcrumb 2', uuid: '2' } ]; - const breadcrumbs = shallow().dive(); + const breadcrumbs = mount( + + + + + ); breadcrumbs.find(Button).at(1).simulate('click'); expect(onClick).toBeCalledWith(items[1]); }); diff --git a/src/components/breadcrumbs/breadcrumbs.tsx b/src/components/breadcrumbs/breadcrumbs.tsx index 3d668856..baf84d1d 100644 --- a/src/components/breadcrumbs/breadcrumbs.tsx +++ b/src/components/breadcrumbs/breadcrumbs.tsx @@ -7,40 +7,64 @@ import { Button, Grid, StyleRulesCallback, WithStyles, Typography, Tooltip } fro import ChevronRightIcon from '@material-ui/icons/ChevronRight'; import { withStyles } from '@material-ui/core'; import { IllegalNamingWarning } from '../warning/warning'; -import { IconType } from 'components/icon/icon'; +import { IconType, FreezeIcon } from 'components/icon/icon'; import grey from '@material-ui/core/colors/grey'; +import { ResourcesState } from 'store/resources/resources'; +import classNames from 'classnames'; +import { ArvadosTheme } from 'common/custom-theme'; export interface Breadcrumb { label: string; icon?: IconType; + uuid: string; } -type CssRules = "item" | "currentItem" | "label" | "icon"; +type CssRules = "item" | "chevron" | "label" | "buttonLabel" | "icon" | "frozenIcon"; -const styles: StyleRulesCallback = theme => ({ +const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ item: { - opacity: 0.6 + borderRadius: '16px', + height: '32px', + minWidth: '36px', + color: theme.customs.colors.grey700, + '&.parentItem': { + color: `${theme.palette.primary.main}`, + }, }, - currentItem: { - opacity: 1 + chevron: { + color: grey["600"], }, label: { - textTransform: "none" + textTransform: "none", + paddingRight: '3px', + paddingLeft: '3px', + lineHeight: '1.4', + }, + buttonLabel: { + overflow: 'hidden', + justifyContent: 'flex-start', }, icon: { fontSize: 20, - color: grey["600"] + color: grey["600"], + marginRight: '5px', + }, + frozenIcon: { + fontSize: 20, + color: grey["600"], + marginLeft: '3px', }, }); export interface BreadcrumbsProps { items: Breadcrumb[]; + resources: ResourcesState; onClick: (breadcrumb: Breadcrumb) => void; onContextMenu: (event: React.MouseEvent, breadcrumb: Breadcrumb) => void; } export const Breadcrumbs = withStyles(styles)( - ({ classes, onClick, onContextMenu, items }: BreadcrumbsProps & WithStyles) => + ({ classes, onClick, onContextMenu, items, resources }: BreadcrumbsProps & WithStyles) => { items.map((item, index) => { @@ -58,8 +82,14 @@ export const Breadcrumbs = withStyles(styles)( : isLastItem ? 'breadcrumb-last' : false} + className={classNames( + isLastItem ? null : 'parentItem', + classes.item + )} + classes={{ + label: classes.buttonLabel + }} color="inherit" - className={isLastItem ? classes.currentItem : classes.item} onClick={() => onClick(item)} onContextMenu={event => onContextMenu(event, item)}> @@ -69,9 +99,12 @@ export const Breadcrumbs = withStyles(styles)( className={classes.label}> {item.label} + { + (resources[item.uuid] as any)?.frozenByUuid ? : null + } - {!isLastItem && } + {!isLastItem && } ); }) diff --git a/src/components/chips-input/chips-input.tsx b/src/components/chips-input/chips-input.tsx index cbb1fb12..7b9ff4a6 100644 --- a/src/components/chips-input/chips-input.tsx +++ b/src/components/chips-input/chips-input.tsx @@ -12,6 +12,7 @@ interface ChipsInputProps { values: Value[]; getLabel?: (value: Value) => string; onChange: (value: Value[]) => void; + onPartialInput?: (value: boolean) => void; handleFocus?: (e: any) => void; handleBlur?: (e: any) => void; chipsClassName?: string; @@ -54,6 +55,9 @@ export const ChipsInput = withStyles(styles)( setText = (event: React.ChangeEvent) => { this.setState({ text: event.target.value }, () => { + // Update partial input status + this.props.onPartialInput && this.props.onPartialInput(this.state.text !== ''); + // If pattern is provided, check for delimiter if (this.props.pattern) { const matches = this.state.text.match(this.props.pattern); @@ -92,6 +96,7 @@ export const ChipsInput = withStyles(styles)( this.setState({ text: '' }); this.props.onChange([...this.props.values, newValue]); } + this.props.onPartialInput && this.props.onPartialInput(false); } } diff --git a/src/components/code-snippet/code-snippet.tsx b/src/components/code-snippet/code-snippet.tsx index 83c378b8..5a5a7041 100644 --- a/src/components/code-snippet/code-snippet.tsx +++ b/src/components/code-snippet/code-snippet.tsx @@ -30,6 +30,7 @@ export interface CodeSnippetDataProps { className?: string; apiResponse?: boolean; linked?: boolean; + children?: JSX.Element; } interface CodeSnippetAuthProps { @@ -43,11 +44,12 @@ const mapStateToProps = (state: RootState): CodeSnippetAuthProps => ({ }); export const CodeSnippet = withStyles(styles)(connect(mapStateToProps)( - ({ classes, lines, linked, className, apiResponse, dispatch, auth }: CodeSnippetProps & CodeSnippetAuthProps & DispatchProp) => + ({ classes, lines, linked, className, apiResponse, dispatch, auth, children }: CodeSnippetProps & CodeSnippetAuthProps & DispatchProp) => + {children} {linked ? lines.map((line, index) => {renderLinks(auth, dispatch)(line)}{`\n`}) : lines.join('\n') diff --git a/src/components/collection-panel-files/collection-panel-files.tsx b/src/components/collection-panel-files/collection-panel-files.tsx index 06b3c507..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,518 +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: '1rem', - marginBottom: '1rem', - backgroundColor: '#fff', - boxShadow: '0px 1px 3px 0px rgb(0 0 0 / 20%), 0px 1px 1px 0px rgb(0 0 0 / 14%), 0px 2px 1px -1px rgb(0 0 0 / 12%)', + padding: "0.5rem", + marginBottom: "0.5rem", + backgroundColor: "#fff", + boxShadow: "0px 1px 3px 0px rgb(0 0 0 / 20%), 0px 1px 1px 0px rgb(0 0 0 / 14%), 0px 2px 1px -1px rgb(0 0 0 / 12%)", }, pathPanelPathWrapper: { - display: 'inline-block', + display: "inline-block", }, leftPanel: { flex: 0, - padding: '1rem', - marginRight: '1rem', - whiteSpace: 'nowrap', - position: 'relative', - backgroundColor: '#fff', - boxShadow: '0px 3px 3px 0px rgb(0 0 0 / 20%), 0px 3px 1px 0px rgb(0 0 0 / 14%), 0px 3px 1px -1px rgb(0 0 0 / 12%)', + padding: "0 1rem 1rem", + marginRight: "1rem", + whiteSpace: "nowrap", + position: "relative", + backgroundColor: "#fff", + boxShadow: "0px 3px 3px 0px rgb(0 0 0 / 20%), 0px 3px 1px 0px rgb(0 0 0 / 14%), 0px 3px 1px -1px rgb(0 0 0 / 12%)", }, leftPanelVisible: { opacity: 1, - flex: '50%', - animation: `animateVisible 1000ms ${theme.transitions.easing.easeOut}` + flex: "50%", + animation: `animateVisible 1000ms ${theme.transitions.easing.easeOut}`, }, leftPanelHidden: { opacity: 0, - flex: 'initial', - padding: '0', - marginRight: '0', + flex: "initial", + padding: "0", + marginRight: "0", }, "@keyframes animateVisible": { "0%": { opacity: 0, - flex: 'initial', + flex: "initial", }, "100%": { opacity: 1, - flex: '50%', - } + flex: "50%", + }, }, rightPanel: { - flex: '50%', - padding: '1rem', - paddingTop: '2rem', - marginTop: '-1rem', - position: 'relative', - backgroundColor: '#fff', - boxShadow: '0px 3px 3px 0px rgb(0 0 0 / 20%), 0px 3px 1px 0px rgb(0 0 0 / 14%), 0px 3px 1px -1px rgb(0 0 0 / 12%)', + flex: "50%", + padding: "1rem", + paddingTop: "0.5rem", + marginTop: "-0.5rem", + position: "relative", + backgroundColor: "#fff", + boxShadow: "0px 3px 3px 0px rgb(0 0 0 / 20%), 0px 3px 1px 0px rgb(0 0 0 / 14%), 0px 3px 1px -1px rgb(0 0 0 / 12%)", }, pathPanelItem: { - cursor: 'pointer', + cursor: "pointer", }, uploadIcon: { - transform: 'rotate(180deg)' + transform: "rotate(180deg)", }, uploadButton: { - float: 'right', + float: "right", }, moreOptionsButton: { width: theme.spacing.unit * 3, height: theme.spacing.unit * 3, marginRight: theme.spacing.unit, - marginTop: 'auto', - marginBottom: 'auto', - justifyContent: 'center', + marginTop: "auto", + marginBottom: "auto", + justifyContent: "center", }, moreOptions: { - position: 'absolute' + position: "absolute", }, }); const pathPromise = {}; -export const CollectionPanelFiles = withStyles(styles)(connect((state: RootState) => ({ - auth: state.auth, - collectionPanel: state.collectionPanel, - collectionPanelFiles: state.collectionPanelFiles, -}))((props: CollectionPanelFilesProps & WithStyles & { auth: AuthState }) => { - const { classes, onItemMenuOpen, onUploadDataClick, isWritable, dispatch, collectionPanelFiles, collectionPanel } = props; - const { apiToken, config } = props.auth; - - const webdavClient = new WebDAV(); - webdavClient.defaults.baseURL = config.keepWebServiceUrl; - webdavClient.defaults.headers = { - Authorization: `Bearer ${apiToken}` - }; - - const webDAVRequestConfig: WebDAVRequestConfig = { - headers: { - Depth: '1', - }, - }; - - const parentRef = React.useRef(null); - const [path, setPath] = React.useState([]); - 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({ ...pathData, ...newState }); - }) - .finally(() => { - setIsLoading(false); - keyArray.forEach(key => delete pathPromise[key]); - }); - }; - - React.useEffect(() => { - if (rightKey) { - fetchData(rightKey); - setLeftSearch(''); - setRightSearch(''); - } - }, [rightKey]); // eslint-disable-line react-hooks/exhaustive-deps - - const currentPDH = (collectionPanel.item || {}).portableDataHash; - React.useEffect(() => { - if (currentPDH) { - // Avoid fetching the same content level twice - if (leftKey !== rightKey) { - fetchData([leftKey, rightKey], true); - } else { - fetchData(rightKey, true); - } - } - }, [currentPDH]); // eslint-disable-line react-hooks/exhaustive-deps - - React.useEffect(() => { - if (rightData) { - const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1); - setCollectionFiles(filtered, false)(dispatch); - } - }, [rightData, dispatch, rightSearch]); - - const handleRightClick = React.useCallback( - (event) => { - event.preventDefault(); - let elem = event.target; - - while (elem && elem.dataset && !elem.dataset.item) { - elem = elem.parentNode; - } + const leftData = pathData[leftKey] || []; + const rightData = pathData[rightKey]; - if (!elem || !elem.dataset) { - return; + React.useEffect(() => { + if (props.currentItemUuid && extractUuidKind(props.currentItemUuid) === ResourceKind.COLLECTION) { + setPathData({}); + setPath([props.currentItemUuid]); } + }, [props.currentItemUuid]); - const { id } = elem.dataset; + const fetchData = (keys, ignoreCache = false) => { + const keyArray = Array.isArray(keys) ? keys : [keys]; - const item: any = { - id, - data: rightData.find((elem) => elem.id === id), - }; + Promise.all( + keyArray + .filter(key => !!key) + .map(key => { + const dataExists = !!pathData[key]; + const runningRequest = pathPromise[key]; - if (id) { - onItemMenuOpen(event, item, isWritable); - } - }, - [onItemMenuOpen, isWritable, rightData]); + if (ignoreCache || (!dataExists && !runningRequest)) { + if (!isLoading) { + setIsLoading(true); + } - React.useEffect(() => { - let node = null; + pathPromise[key] = true; - if (parentRef?.current) { - node = parentRef.current; - (node as any).addEventListener('contextmenu', handleRightClick); - } + return webdavClient.propfind(`c=${key}`, webDAVRequestConfig); + } - return () => { - if (node) { - (node as any).removeEventListener('contextmenu', handleRightClick); - } + return Promise.resolve(null); + }) + .filter(promise => !!promise) + ) + .then(requests => { + const newState = requests + .map((request, index) => { + if (request && request.responseXML != null) { + const key = keyArray[index]; + const result: any = extractFilesData(request.responseXML); + const sortedResult = sortBy(result, n => n.name).sort((n1, n2) => { + if (n1.type === "directory" && n2.type !== "directory") { + return -1; + } + if (n1.type !== "directory" && n2.type === "directory") { + return 1; + } + return 0; + }); + + return { [key]: sortedResult }; + } + return {}; + }) + .reduce((prev, next) => { + return { ...next, ...prev }; + }, {}); + setPathData(state => ({ ...state, ...newState })); + }, () => { + // Nothing to do + }) + .finally(() => { + setIsLoading(false); + keyArray.forEach(key => delete pathPromise[key]); + }); }; - }, [parentRef, handleRightClick]); - const handleClick = React.useCallback( - (event: any) => { - let isCheckbox = false; - let isMoreButton = false; - let elem = event.target; - - if (elem.type === 'checkbox') { - isCheckbox = true; - } - // The "More options" button click event could be triggered on its - // internal graphic element. - else if ((elem.dataset && elem.dataset.id === 'moreOptions') || (elem.parentNode && elem.parentNode.dataset && elem.parentNode.dataset.id === 'moreOptions')) { - isMoreButton = true; + React.useEffect(() => { + if (rightKey) { + fetchData(rightKey); + setLeftSearch(""); + setRightSearch(""); } + }, [rightKey, rightData]); // eslint-disable-line react-hooks/exhaustive-deps - while (elem && elem.dataset && !elem.dataset.item) { - elem = elem.parentNode; + const currentPDH = (collectionPanel.item || {}).portableDataHash; + React.useEffect(() => { + if (currentPDH) { + fetchData([leftKey, rightKey], true); } + }, [currentPDH]); // eslint-disable-line react-hooks/exhaustive-deps - if (elem && elem.dataset && !isCheckbox && !isMoreButton) { - const { parentPath, subfolderPath, breadcrumbPath, type } = elem.dataset; - - if (breadcrumbPath) { - const index = path.indexOf(breadcrumbPath); - setPath([...path.slice(0, index + 1)]); - } - - if (parentPath && type === 'directory') { - if (path.length > 1) { - path.pop() - } + React.useEffect(() => { + if (rightData) { + const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1); + setCollectionFiles(filtered, false)(dispatch); + } + }, [rightData, dispatch, rightSearch]); - setPath([...path, parentPath]); - } + const handleRightClick = React.useCallback( + event => { + event.preventDefault(); + let elem = event.target; - if (subfolderPath && type === 'directory') { - setPath([...path, subfolderPath]); + while (elem && elem.dataset && !elem.dataset.item) { + elem = elem.parentNode; } - if (elem.dataset.id && type === 'file') { - const item = rightData.find(({id}) => id === elem.dataset.id) || leftData.find(({ id }) => id === elem.dataset.id); - const enhancedItem = servicesProvider.getServices().collectionService.extendFileURL(item); - const fileUrl = sanitizeToken(getInlineFileUrl(enhancedItem.url, config.keepWebServiceUrl, config.keepWebInlineServiceUrl), true); - window.open(fileUrl, '_blank'); + if (!elem || !elem.dataset) { + return; } - } - if (isCheckbox) { - const { id } = elem.dataset; - const item = collectionPanelFiles[id]; - props.onSelectionToggle(event, item); - } - if (isMoreButton) { const { id } = elem.dataset; + const item: any = { id, - data: rightData.find((elem) => elem.id === id), + data: rightData.find(elem => elem.id === id), }; - onItemMenuOpen(event, item, isWritable); - } - }, - [path, setPath, collectionPanelFiles] // eslint-disable-line react-hooks/exhaustive-deps - ); - - const getItemIcon = React.useCallback( - (type: string, activeClass: string | null) => { - let Icon = DefaultIcon; - - switch (type) { - case 'directory': - Icon = DirectoryIcon; - break; - case 'file': - Icon = FileIcon; - break; - } - return ( - - - - ) - }, - [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([...path.slice(0, path.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/column-selector/column-selector.tsx b/src/components/column-selector/column-selector.tsx index 5fbef6b6..0eb1323a 100644 --- a/src/components/column-selector/column-selector.tsx +++ b/src/components/column-selector/column-selector.tsx @@ -12,17 +12,23 @@ import { DataColumns } from '../data-table/data-table'; import { ArvadosTheme } from "common/custom-theme"; interface ColumnSelectorDataProps { - columns: DataColumns; - onColumnToggle: (column: DataColumn) => void; + columns: DataColumns; + onColumnToggle: (column: DataColumn) => void; className?: string; } -type CssRules = "checkbox"; +type CssRules = "checkbox" | "listItem" | "listItemText"; const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ checkbox: { width: 24, height: 24 + }, + listItem: { + padding: 0 + }, + listItemText: { + paddingTop: '0.2rem' } }); @@ -39,13 +45,15 @@ export const ColumnSelector = withStyles(styles)( onColumnToggle(column)}> - + {column.name} 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 40617f73..27e46d58 100644 --- a/src/components/data-explorer/data-explorer.tsx +++ b/src/components/data-explorer/data-explorer.tsx @@ -2,62 +2,79 @@ // // SPDX-License-Identifier: AGPL-3.0 -import React from 'react'; -import { Grid, Paper, Toolbar, StyleRulesCallback, withStyles, WithStyles, TablePagination, IconButton, Tooltip, Button } from '@material-ui/core'; +import React from "react"; +import { Grid, Paper, Toolbar, StyleRulesCallback, withStyles, WithStyles, TablePagination, IconButton, Tooltip, Button } from "@material-ui/core"; import { ColumnSelector } from "components/column-selector/column-selector"; import { DataTable, DataColumns, DataTableFetchMode } from "components/data-table/data-table"; import { DataColumn } from "components/data-table/data-column"; -import { SearchInput } from 'components/search-input/search-input'; +import { SearchInput } from "components/search-input/search-input"; import { ArvadosTheme } from "common/custom-theme"; -import { createTree } from 'models/tree'; -import { DataTableFilters } from 'components/data-table-filters/data-table-filters-tree'; -import { CloseIcon, IconType, MaximizeIcon, MoreOptionsIcon } from 'components/icon/icon'; -import { PaperProps } from '@material-ui/core/Paper'; -import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view'; +import { MultiselectToolbar } from "components/multiselect-toolbar/MultiselectToolbar"; +import { TCheckedList } from "components/data-table/data-table"; +import { createTree } from "models/tree"; +import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree"; +import { CloseIcon, IconType, MaximizeIcon, UnMaximizeIcon, MoreVerticalIcon } from "components/icon/icon"; +import { PaperProps } from "@material-ui/core/Paper"; +import { MPVPanelProps } from "components/multi-panel-view/multi-panel-view"; -type CssRules = 'searchBox' | 'headerMenu' | "toolbar" | "footer" | "root" | 'moreOptionsButton' | 'title' | 'dataTable' | 'container'; +type CssRules = "titleWrapper" | "searchBox" | "headerMenu" | "toolbar" | "footer" | "root" | "moreOptionsButton" | "title" | 'subProcessTitle' | "dataTable" | "container"; const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ + titleWrapper: { + display: "flex", + justifyContent: "space-between", + }, searchBox: { - paddingBottom: theme.spacing.unit * 2 + paddingBottom: 0, }, toolbar: { - paddingTop: theme.spacing.unit, - paddingRight: theme.spacing.unit * 2, + paddingTop: 0, + paddingRight: theme.spacing.unit, + paddingLeft: "10px", }, footer: { - overflow: 'auto' + overflow: "auto", }, root: { - height: '100%', + height: "100%", }, moreOptionsButton: { - padding: 0 + padding: 0, }, title: { - display: 'inline-block', - paddingLeft: theme.spacing.unit * 3, - paddingTop: theme.spacing.unit * 3, - fontSize: '18px' + display: "inline-block", + paddingLeft: theme.spacing.unit * 2, + paddingTop: theme.spacing.unit * 2, + fontSize: "18px", + paddingRight: "10px", + }, + subProcessTitle: { + display: "inline-block", + paddingLeft: theme.spacing.unit * 2, + paddingTop: theme.spacing.unit * 2, + fontSize: "18px", + flexGrow: 0, + paddingRight: "10px", }, dataTable: { - height: '100%', - overflow: 'auto', + height: "100%", + overflow: "auto", }, container: { - height: '100%', + height: "100%", }, headerMenu: { - float: 'right', - display: 'inline-block' - } + marginLeft: "auto", + flexBasis: "initial", + flexGrow: 0, + }, }); interface DataExplorerDataProps { fetchMode: DataTableFetchMode; items: T[]; itemsAvailable: number; - columns: DataColumns; + columns: DataColumns; searchLabel?: string; searchValue: string; rowsPerPage: number; @@ -74,40 +91,46 @@ 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 { - onSetColumns: (columns: DataColumns) => void; + onSetColumns: (columns: DataColumns) => void; onSearch: (value: string) => void; onRowClick: (item: T) => void; onRowDoubleClick: (item: T) => void; - onColumnToggle: (column: DataColumn) => void; + onColumnToggle: (column: DataColumn) => void; onContextMenu: (event: React.MouseEvent, item: T) => void; - onSortToggle: (column: DataColumn) => void; - onFiltersChange: (filters: DataTableFilters, column: DataColumn) => void; + onSortToggle: (column: DataColumn) => void; + onFiltersChange: (filters: DataTableFilters, column: DataColumn) => void; onChangePage: (page: number) => void; onChangeRowsPerPage: (rowsPerPage: number) => void; onLoadMore: (page: number) => void; extractKey?: (item: T) => React.Key; + toggleMSToolbar: (isVisible: boolean) => void; + setCheckedListOnStore: (checkedList: TCheckedList) => void; } -type DataExplorerProps = 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, @@ -140,126 +163,251 @@ export const DataExplorer = withStyles(styles)( // Component just mounted, so we need to show the loading indicator. this.setState({ showLoading: this.props.working, - prevRefresh: this.props.currentRefresh || '', - prevRoute: this.props.currentRoute || '', + prevRefresh: this.props.currentRefresh || "", + prevRoute: this.props.currentRoute || "", }); } render() { const { - columns, onContextMenu, onFiltersChange, onSortToggle, extractKey, - rowsPerPage, rowsPerPageOptions, onColumnToggle, searchLabel, searchValue, onSearch, - items, itemsAvailable, onRowClick, onRowDoubleClick, classes, - defaultViewIcon, defaultViewMessages, hideColumnSelector, actions, paperProps, hideSearchInput, - paperKey, fetchMode, currentItemUuid, title, - doHidePanel, doMaximizePanel, panelName, panelMaximized, elementPath + columns, + onContextMenu, + onFiltersChange, + onSortToggle, + extractKey, + rowsPerPage, + rowsPerPageOptions, + onColumnToggle, + searchLabel, + searchValue, + onSearch, + items, + itemsAvailable, + onRowClick, + onRowDoubleClick, + classes, + defaultViewIcon, + defaultViewMessages, + hideColumnSelector, + actions, + paperProps, + hideSearchInput, + paperKey, + fetchMode, + currentItemUuid, + currentRoute, + title, + progressBar, + doHidePanel, + doMaximizePanel, + doUnMaximizePanel, + panelName, + panelMaximized, + elementPath, + toggleMSToolbar, + setCheckedListOnStore, + checkedList, } = this.props; - - return - -
- {title && {title}} - { - (!hideColumnSelector || !hideSearchInput || !!actions) && - - - - {!hideSearchInput &&
- {!hideSearchInput && } -
} - {actions} - {!hideColumnSelector && } + 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} - { 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} - + )} + + {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 = { + contextMenuColumn: DataColumn = { name: "Actions", selected: true, 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-column.ts b/src/components/data-table/data-column.ts index f32fea2b..35655fb7 100644 --- a/src/components/data-table/data-column.ts +++ b/src/components/data-table/data-column.ts @@ -6,7 +6,12 @@ import React from "react"; import { DataTableFilters } from "../data-table-filters/data-table-filters-tree"; import { createTree } from 'models/tree'; -export interface DataColumn { +/** + * + * @template I Type of dataexplorer item reference + * @template R Type of resource to use to restrict values of column sort.field + */ +export interface DataColumn { key?: React.Key; name: string; selected: boolean; @@ -17,9 +22,9 @@ export interface DataColumn { * radio group and only one filter can be selected at a time. */ mutuallyExclusiveFilters?: boolean; - sortDirection?: SortDirection; + sort?: {direction: SortDirection, field: keyof R}; filters: DataTableFilters; - render: (item: T) => React.ReactElement; + render: (item: I) => React.ReactElement; renderHeader?: () => React.ReactElement; } @@ -29,24 +34,23 @@ export enum SortDirection { NONE = "none" } -export const toggleSortDirection = (column: DataColumn): DataColumn => { - return column.sortDirection - ? column.sortDirection === SortDirection.ASC - ? { ...column, sortDirection: SortDirection.DESC } - : { ...column, sortDirection: SortDirection.ASC } +export const toggleSortDirection = (column: DataColumn): DataColumn => { + return column.sort + ? column.sort.direction === SortDirection.ASC + ? { ...column, sort: {...column.sort, direction: SortDirection.DESC} } + : { ...column, sort: {...column.sort, direction: SortDirection.ASC} } : column; }; -export const resetSortDirection = (column: DataColumn): DataColumn => { - return column.sortDirection ? { ...column, sortDirection: SortDirection.NONE } : column; +export const resetSortDirection = (column: DataColumn): DataColumn => { + return column.sort ? { ...column, sort: {...column.sort, direction: SortDirection.NONE} } : column; }; -export const createDataColumn = (dataColumn: Partial>): DataColumn => ({ +export const createDataColumn = (dataColumn: Partial>): DataColumn => ({ key: '', name: '', selected: true, configurable: true, - sortDirection: SortDirection.NONE, filters: createTree(), render: () => React.createElement('span'), ...dataColumn, diff --git a/src/components/data-table/data-table.test.tsx b/src/components/data-table/data-table.test.tsx index 866564ac..880868bd 100644 --- a/src/components/data-table/data-table.test.tsx +++ b/src/components/data-table/data-table.test.tsx @@ -4,210 +4,241 @@ import React from "react"; import { mount, configure } from "enzyme"; -import { pipe } from 'lodash/fp'; +import { pipe } from "lodash/fp"; import { TableHead, TableCell, Typography, TableBody, Button, TableSortLabel } from "@material-ui/core"; import Adapter from "enzyme-adapter-react-16"; import { DataTable, DataColumns } from "./data-table"; import { SortDirection, createDataColumn } from "./data-column"; -import { DataTableFiltersPopover } from 'components/data-table-filters/data-table-filters-popover'; -import { createTree, setNode, initTreeNode } from 'models/tree'; +import { DataTableFiltersPopover } from "components/data-table-filters/data-table-filters-popover"; +import { createTree, setNode, initTreeNode } from "models/tree"; import { DataTableFilterItem } from "components/data-table-filters/data-table-filters-tree"; configure({ adapter: new Adapter() }); describe("", () => { it("shows only selected columns", () => { - const columns: DataColumns = [ + const columns: DataColumns = [ createDataColumn({ 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", () => { - const columns: DataColumns = [ + const columns: DataColumns = [ createDataColumn({ 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", () => { - const columns: DataColumns = [ + const columns: DataColumns = [ createDataColumn({ name: "Column 1", 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", () => { - const columns: DataColumns = [ + const columns: DataColumns = [ createDataColumn({ name: "Column 1", 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 = [ + 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 = [ + const columns: DataColumns = [ createDataColumn({ name: "Column 1", - sortDirection: SortDirection.ASC, + 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", - sortDirection: SortDirection.ASC, - 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", - sortDirection: SortDirection.ASC, - 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 d942234d..de3e272d 100644 --- a/src/components/data-table/data-table.tsx +++ b/src/components/data-table/data-table.tsx @@ -2,186 +2,407 @@ // // SPDX-License-Identifier: AGPL-3.0 -import React from 'react'; -import { Table, TableBody, TableRow, TableCell, TableHead, TableSortLabel, StyleRulesCallback, Theme, WithStyles, withStyles, IconButton } from '@material-ui/core'; -import classnames from 'classnames'; -import { DataColumn, SortDirection } from './data-column'; -import { DataTableDefaultView } from '../data-table-default-view/data-table-default-view'; -import { DataTableFilters } from '../data-table-filters/data-table-filters-tree'; -import { DataTableFiltersPopover } from '../data-table-filters/data-table-filters-popover'; -import { countNodes, getTreeDirty } from 'models/tree'; -import { IconType, PendingIcon } from 'components/icon/icon'; -import { SvgIconProps } from '@material-ui/core/SvgIcon'; -import ArrowDownwardIcon from '@material-ui/icons/ArrowDownward'; - -export type DataColumns = Array>; +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 { - items: T[]; - columns: DataColumns; - onRowClick: (event: React.MouseEvent, item: T) => void; - onContextMenu: (event: React.MouseEvent, item: T) => void; - onRowDoubleClick: (event: React.MouseEvent, item: T) => void; - onSortToggle: (column: DataColumn) => void; - onFiltersChange: (filters: DataTableFilters, column: DataColumn) => void; - extractKey?: (item: T) => React.Key; +export interface DataTableDataProps { + items: I[]; + columns: DataColumns; + onRowClick: (event: React.MouseEvent, item: I) => void; + onContextMenu: (event: React.MouseEvent, item: I) => void; + onRowDoubleClick: (event: React.MouseEvent, item: I) => void; + onSortToggle: (column: DataColumn) => void; + onFiltersChange: (filters: DataTableFilters, column: DataColumn) => void; + extractKey?: (item: I) => React.Key; working?: boolean; defaultViewIcon?: IconType; defaultViewMessages?: string[]; currentItemUuid?: string; currentRoute?: string; + toggleMSToolbar: (isVisible: boolean) => void; + setCheckedListOnStore: (checkedList: TCheckedList) => void; + checkedList: TCheckedList; } -type CssRules = "tableBody" | "root" | "content" | "noItemsInfo" | 'tableCell' | 'arrow' | 'arrowButton' | 'tableCellWorkflows' | 'loader'; +type CssRules = + | "tableBody" + | "root" + | "content" + | "noItemsInfo" + | "checkBoxHead" + | "checkBoxCell" + | "checkBox" + | "firstTableCell" + | "tableCell" + | "arrow" + | "arrowButton" + | "tableCellWorkflows" + | "loader"; const styles: StyleRulesCallback = (theme: Theme) => ({ root: { - width: '100%', + width: "100%", }, content: { - display: 'inline-block', - width: '100%', + display: "inline-block", + width: "100%", }, tableBody: { - background: theme.palette.background.paper + background: theme.palette.background.paper, }, loader: { - left: '50%', - marginLeft: '-84px', - position: 'absolute' + left: "50%", + marginLeft: "-84px", + position: "absolute", }, noItemsInfo: { textAlign: "center", - padding: theme.spacing.unit + padding: theme.spacing.unit, + }, + checkBoxHead: { + padding: "0", + display: "flex", + }, + checkBoxCell: { + padding: "0", + paddingLeft: "10px", + }, + checkBox: { + cursor: "pointer", }, tableCell: { - wordWrap: 'break-word', - paddingRight: '24px' + wordWrap: "break-word", + paddingRight: "24px", + color: "#737373", + }, + firstTableCell: { + paddingLeft: "5px", }, tableCellWorkflows: { - '&:nth-last-child(2)': { - padding: '0px', - maxWidth: '48px' + "&:nth-last-child(2)": { + padding: "0px", + maxWidth: "48px", + }, + "&:last-child": { + padding: "0px", + paddingRight: "24px", + width: "48px", }, - '&:last-child': { - padding: '0px', - paddingRight: '24px', - width: '48px' - } }, arrow: { - margin: 0 + margin: 0, }, arrowButton: { - color: theme.palette.text.primary - } + color: theme.palette.text.primary, + }, }); +export type TCheckedList = Record; + +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 ; - } + renderNoItemsPlaceholder = (columns: DataColumns) => { + const dirty = columns.some(column => getTreeDirty("")(column.filters)); + return ( + + ); + }; - renderHeadCell = (column: DataColumn, index: number) => { - const { name, key, renderHeader, filters, sortDirection } = column; - const { onSortToggle, onFiltersChange, classes } = this.props; - return - {renderHeader ? - renderHeader() : - countNodes(filters) > 0 - ? , index: number) => { + const { name, key, renderHeader, filters, sort } = column; + const { onSortToggle, onFiltersChange, classes, checkedList } = this.props; + const { isSelected } = this.state; + return column.name === "checkBoxColumn" ? ( + +
+ + + + +
+
+ ) : ( + + {renderHeader ? ( + renderHeader() + ) : countNodes(filters) > 0 ? ( + - onFiltersChange && - onFiltersChange(filters, column)} + onChange={filters => onFiltersChange && onFiltersChange(filters, column)} filters={filters}> {name} - : sortDirection - ? - 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) => { + 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/default-view/default-view.tsx b/src/components/default-view/default-view.tsx index 014b8cc4..5acea619 100644 --- a/src/components/default-view/default-view.tsx +++ b/src/components/default-view/default-view.tsx @@ -29,7 +29,7 @@ export interface DefaultViewDataProps { messages: string[]; filtersApplied?: boolean; classMessage?: string; - icon: IconType; + icon?: IconType; classIcon?: string; } @@ -38,7 +38,7 @@ type DefaultViewProps = DefaultViewDataProps & WithStyles; export const DefaultView = withStyles(styles)( ({ classes, classRoot, messages, classMessage, icon: Icon, classIcon }: DefaultViewProps) => - + {Icon && } {messages.map((msg: string, index: number) => { return {msg}; diff --git a/src/components/details-attribute/details-attribute.tsx b/src/components/details-attribute/details-attribute.tsx index e52c487d..92d31b0b 100644 --- a/src/components/details-attribute/details-attribute.tsx +++ b/src/components/details-attribute/details-attribute.tsx @@ -24,7 +24,7 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ }, label: { boxSizing: 'border-box', - color: theme.palette.grey["500"], + color: theme.palette.grey["600"], width: '100%' }, value: { @@ -42,7 +42,7 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ }, copyIcon: { marginLeft: theme.spacing.unit, - color: theme.palette.grey["500"], + color: theme.palette.grey["600"], cursor: 'pointer', display: 'inline', '& svg': { 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/file-upload/file-upload.tsx b/src/components/file-upload/file-upload.tsx index 54d5b5db..e6c15144 100644 --- a/src/components/file-upload/file-upload.tsx +++ b/src/components/file-upload/file-upload.tsx @@ -34,7 +34,8 @@ const styles: StyleRulesCallback = theme => ({ width: "100%", height: "200px", position: "relative", - border: "1px solid rgba(0, 0, 0, 0.42)" + border: "1px solid rgba(0, 0, 0, 0.42)", + boxSizing: 'border-box', }, dropzoneBorder: { content: "", diff --git a/src/components/form-dialog/form-dialog.tsx b/src/components/form-dialog/form-dialog.tsx index 0fc799de..b50504a6 100644 --- a/src/components/form-dialog/form-dialog.tsx +++ b/src/components/form-dialog/form-dialog.tsx @@ -8,7 +8,7 @@ import { Dialog, DialogActions, DialogContent, DialogTitle } from '@material-ui/ import { Button, StyleRulesCallback, WithStyles, withStyles, CircularProgress } from '@material-ui/core'; import { WithDialogProps } from 'store/dialog/with-dialog'; -type CssRules = "button" | "lastButton" | "formContainer" | "dialogTitle" | "progressIndicator" | "dialogActions"; +type CssRules = "button" | "lastButton" | "form" | "formContainer" | "dialogTitle" | "progressIndicator" | "dialogActions"; const styles: StyleRulesCallback = theme => ({ button: { @@ -18,6 +18,12 @@ const styles: StyleRulesCallback = theme => ({ marginLeft: theme.spacing.unit, marginRight: "0", }, + form: { + display: 'flex', + overflowY: 'auto', + flexDirection: 'column', + flex: '0 1 auto', + }, formContainer: { display: "flex", flexDirection: "column", @@ -57,7 +63,7 @@ export const FormDialog = withStyles(styles)((props: DialogProjectProps) => disableEscapeKeyDown={props.submitting} fullWidth maxWidth='md'> -
+ {props.dialogTitle} diff --git a/src/components/icon/icon.tsx b/src/components/icon/icon.tsx index db603597..2dd97c16 100644 --- a/src/components/icon/icon.tsx +++ b/src/components/icon/icon.tsx @@ -2,191 +2,268 @@ // // SPDX-License-Identifier: AGPL-3.0 -import React from 'react'; -import { Badge, Tooltip } from '@material-ui/core'; -import Add from '@material-ui/icons/Add'; -import ArrowBack from '@material-ui/icons/ArrowBack'; -import ArrowDropDown from '@material-ui/icons/ArrowDropDown'; -import BubbleChart from '@material-ui/icons/BubbleChart'; -import Build from '@material-ui/icons/Build'; -import Cached from '@material-ui/icons/Cached'; -import DescriptionIcon from '@material-ui/icons/Description'; -import ChevronLeft from '@material-ui/icons/ChevronLeft'; -import CloudUpload from '@material-ui/icons/CloudUpload'; -import Code from '@material-ui/icons/Code'; -import Create from '@material-ui/icons/Create'; -import ImportContacts from '@material-ui/icons/ImportContacts'; -import ChevronRight from '@material-ui/icons/ChevronRight'; -import Close from '@material-ui/icons/Close'; -import ContentCopy from '@material-ui/icons/FileCopyOutlined'; -import CreateNewFolder from '@material-ui/icons/CreateNewFolder'; -import Delete from '@material-ui/icons/Delete'; -import DeviceHub from '@material-ui/icons/DeviceHub'; -import Edit from '@material-ui/icons/Edit'; -import ErrorRoundedIcon from '@material-ui/icons/ErrorRounded'; -import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import FlipToFront from '@material-ui/icons/FlipToFront'; -import Folder from '@material-ui/icons/Folder'; -import FolderShared from '@material-ui/icons/FolderShared'; -import Pageview from '@material-ui/icons/Pageview'; -import GetApp from '@material-ui/icons/GetApp'; -import Help from '@material-ui/icons/Help'; -import HelpOutline from '@material-ui/icons/HelpOutline'; -import History from '@material-ui/icons/History'; -import Inbox from '@material-ui/icons/Inbox'; -import Info from '@material-ui/icons/Info'; -import Input from '@material-ui/icons/Input'; -import InsertDriveFile from '@material-ui/icons/InsertDriveFile'; -import LastPage from '@material-ui/icons/LastPage'; -import LibraryBooks from '@material-ui/icons/LibraryBooks'; -import ListAlt from '@material-ui/icons/ListAlt'; -import Menu from '@material-ui/icons/Menu'; -import MoreVert from '@material-ui/icons/MoreVert'; -import Mail from '@material-ui/icons/Mail'; -import MoveToInbox from '@material-ui/icons/MoveToInbox'; -import Notifications from '@material-ui/icons/Notifications'; -import OpenInNew from '@material-ui/icons/OpenInNew'; -import People from '@material-ui/icons/People'; -import Person from '@material-ui/icons/Person'; -import PersonAdd from '@material-ui/icons/PersonAdd'; -import PlayArrow from '@material-ui/icons/PlayArrow'; -import Public from '@material-ui/icons/Public'; -import RateReview from '@material-ui/icons/RateReview'; -import RestoreFromTrash from '@material-ui/icons/History'; -import Search from '@material-ui/icons/Search'; -import SettingsApplications from '@material-ui/icons/SettingsApplications'; -import SettingsEthernet from '@material-ui/icons/SettingsEthernet'; -import Star from '@material-ui/icons/Star'; -import StarBorder from '@material-ui/icons/StarBorder'; -import Warning from '@material-ui/icons/Warning'; -import Visibility from '@material-ui/icons/Visibility'; -import VisibilityOff from '@material-ui/icons/VisibilityOff'; -import VpnKey from '@material-ui/icons/VpnKey'; -import LinkOutlined from '@material-ui/icons/LinkOutlined'; -import RemoveRedEye from '@material-ui/icons/RemoveRedEye'; -import Computer from '@material-ui/icons/Computer'; -import WrapText from '@material-ui/icons/WrapText'; -import TextIncrease from '@material-ui/icons/ZoomIn'; -import TextDecrease from '@material-ui/icons/ZoomOut'; -import CropFreeSharp from '@material-ui/icons/CropFreeSharp'; -import ExitToApp from '@material-ui/icons/ExitToApp'; -import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline'; -import RemoveCircleOutline from '@material-ui/icons/RemoveCircleOutline'; -import NotInterested from '@material-ui/icons/NotInterested'; +import React from "react"; +import { Badge, SvgIcon, Tooltip } from "@material-ui/core"; +import Add from "@material-ui/icons/Add"; +import ArrowBack from "@material-ui/icons/ArrowBack"; +import ArrowDropDown from "@material-ui/icons/ArrowDropDown"; +import Build from "@material-ui/icons/Build"; +import Cached from "@material-ui/icons/Cached"; +import DescriptionIcon from "@material-ui/icons/Description"; +import ChevronLeft from "@material-ui/icons/ChevronLeft"; +import CloudUpload from "@material-ui/icons/CloudUpload"; +import Code from "@material-ui/icons/Code"; +import Create from "@material-ui/icons/Create"; +import ImportContacts from "@material-ui/icons/ImportContacts"; +import ChevronRight from "@material-ui/icons/ChevronRight"; +import Close from "@material-ui/icons/Close"; +import ContentCopy from "@material-ui/icons/FileCopyOutlined"; +import CreateNewFolder from "@material-ui/icons/CreateNewFolder"; +import Delete from "@material-ui/icons/Delete"; +import DeviceHub from "@material-ui/icons/DeviceHub"; +import Edit from "@material-ui/icons/Edit"; +import ErrorRoundedIcon from "@material-ui/icons/ErrorRounded"; +import ExpandMoreIcon from "@material-ui/icons/ExpandMore"; +import FlipToFront from "@material-ui/icons/FlipToFront"; +import Folder from "@material-ui/icons/Folder"; +import FolderShared from "@material-ui/icons/FolderShared"; +import Pageview from "@material-ui/icons/Pageview"; +import GetApp from "@material-ui/icons/GetApp"; +import Help from "@material-ui/icons/Help"; +import HelpOutline from "@material-ui/icons/HelpOutline"; +import History from "@material-ui/icons/History"; +import Inbox from "@material-ui/icons/Inbox"; +import Memory from "@material-ui/icons/Memory"; +import MoveToInbox from "@material-ui/icons/MoveToInbox"; +import Info from "@material-ui/icons/Info"; +import Input from "@material-ui/icons/Input"; +import InsertDriveFile from "@material-ui/icons/InsertDriveFile"; +import LastPage from "@material-ui/icons/LastPage"; +import LibraryBooks from "@material-ui/icons/LibraryBooks"; +import ListAlt from "@material-ui/icons/ListAlt"; +import Menu from "@material-ui/icons/Menu"; +import MoreVert from "@material-ui/icons/MoreVert"; +import MoreHoriz from "@material-ui/icons/MoreHoriz"; +import Mail from "@material-ui/icons/Mail"; +import Notifications from "@material-ui/icons/Notifications"; +import OpenInNew from "@material-ui/icons/OpenInNew"; +import People from "@material-ui/icons/People"; +import Person from "@material-ui/icons/Person"; +import PersonAdd from "@material-ui/icons/PersonAdd"; +import PlayArrow from "@material-ui/icons/PlayArrow"; +import Public from "@material-ui/icons/Public"; +import RateReview from "@material-ui/icons/RateReview"; +import RestoreFromTrash from "@material-ui/icons/History"; +import Search from "@material-ui/icons/Search"; +import SettingsApplications from "@material-ui/icons/SettingsApplications"; +import SettingsEthernet from "@material-ui/icons/SettingsEthernet"; +import Settings from "@material-ui/icons/Settings"; +import Star from "@material-ui/icons/Star"; +import StarBorder from "@material-ui/icons/StarBorder"; +import Warning from "@material-ui/icons/Warning"; +import VpnKey from "@material-ui/icons/VpnKey"; +import LinkOutlined from "@material-ui/icons/LinkOutlined"; +import RemoveRedEye from "@material-ui/icons/RemoveRedEye"; +import Computer from "@material-ui/icons/Computer"; +import WrapText from "@material-ui/icons/WrapText"; +import TextIncrease from "@material-ui/icons/ZoomIn"; +import TextDecrease from "@material-ui/icons/ZoomOut"; +import FullscreenSharp from "@material-ui/icons/FullscreenSharp"; +import FullscreenExitSharp from "@material-ui/icons/FullscreenExitSharp"; +import ExitToApp from "@material-ui/icons/ExitToApp"; +import CheckCircleOutline from "@material-ui/icons/CheckCircleOutline"; +import RemoveCircleOutline from "@material-ui/icons/RemoveCircleOutline"; +import NotInterested from "@material-ui/icons/NotInterested"; +import Image from "@material-ui/icons/Image"; +import Stop from "@material-ui/icons/Stop"; +import FileCopy from "@material-ui/icons/FileCopy"; // Import FontAwesome icons -import { library } from '@fortawesome/fontawesome-svg-core'; -import { faPencilAlt, faSlash, faUsers, faEllipsisH } from '@fortawesome/free-solid-svg-icons'; -import { FormatAlignLeft } from '@material-ui/icons'; -library.add( - faPencilAlt, - faSlash, - faUsers, - faEllipsisH, +import { library } from "@fortawesome/fontawesome-svg-core"; +import { faPencilAlt, faSlash, faUsers, faEllipsisH } from "@fortawesome/free-solid-svg-icons"; +import { FormatAlignLeft } from "@material-ui/icons"; +library.add(faPencilAlt, faSlash, faUsers, faEllipsisH); + +export const FreezeIcon: IconType = (props: any) => ( + + + +); + +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) => ( + + }> - ; - -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 InputIcon: IconType = (props) => ; -export const KeyIcon: IconType = (props) => ; -export const LogIcon: IconType = (props) => ; -export const MailIcon: IconType = (props) => ; -export const MaximizeIcon: 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 OutputIcon: 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 VisibleIcon: IconType = (props) => ; -export const InvisibleIcon: 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) => ; + +); + +// https://materialdesignicons.com/icon/image-off +export const ImageOffIcon = (props: any) => ( + + + +); + +// https://materialdesignicons.com/icon/inbox-arrow-up +export const OutputIcon: IconType = (props: any) => ( + + + +); + +// 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/multi-panel-view/multi-panel-view.test.tsx b/src/components/multi-panel-view/multi-panel-view.test.tsx index d690e82f..3f4911c2 100644 --- a/src/components/multi-panel-view/multi-panel-view.test.tsx +++ b/src/components/multi-panel-view/multi-panel-view.test.tsx @@ -10,7 +10,7 @@ import { Button } from "@material-ui/core"; configure({ adapter: new Adapter() }); -const PanelMock = ({panelName, panelMaximized, doHidePanel, doMaximizePanel, panelIlluminated, panelRef, children, ...rest}) => +const PanelMock = ({panelName, panelMaximized, doHidePanel, doMaximizePanel, doUnMaximizePanel, panelIlluminated, panelRef, children, ...rest}) =>
{children}
; describe('', () => { diff --git a/src/components/multi-panel-view/multi-panel-view.tsx b/src/components/multi-panel-view/multi-panel-view.tsx index de824990..203748d5 100644 --- a/src/components/multi-panel-view/multi-panel-view.tsx +++ b/src/components/multi-panel-view/multi-panel-view.tsx @@ -15,13 +15,16 @@ import { import { GridProps } from '@material-ui/core/Grid'; import { isArray } from 'lodash'; import { DefaultView } from 'components/default-view/default-view'; -import { InfoIcon, InvisibleIcon, VisibleIcon } from 'components/icon/icon'; +import { InfoIcon } from 'components/icon/icon'; import { ReactNodeArray } from 'prop-types'; import classNames from 'classnames'; -type CssRules = 'button' | 'buttonIcon' | 'content'; +type CssRules = 'root' | 'button' | 'buttonIcon' | 'content'; const styles: StyleRulesCallback = theme => ({ + root: { + marginTop: '10px', + }, button: { padding: '2px 5px', marginRight: '5px', @@ -48,14 +51,15 @@ interface MPVHideablePanelDataProps { interface MPVHideablePanelActionProps { doHidePanel: () => void; doMaximizePanel: () => void; + doUnMaximizePanel: () => void; } type MPVHideablePanelProps = MPVHideablePanelDataProps & MPVHideablePanelActionProps; -const MPVHideablePanel = ({doHidePanel, doMaximizePanel, name, visible, maximized, illuminated, ...props}: MPVHideablePanelProps) => +const MPVHideablePanel = ({doHidePanel, doMaximizePanel, doUnMaximizePanel, name, visible, maximized, illuminated, ...props}: MPVHideablePanelProps) => visible ? <> - {React.cloneElement((props.children as ReactElement), { doHidePanel, doMaximizePanel, panelName: name, panelMaximized: maximized, panelIlluminated: illuminated, panelRef: props.panelRef })} + {React.cloneElement((props.children as ReactElement), { doHidePanel, doMaximizePanel, doUnMaximizePanel, panelName: name, panelMaximized: maximized, panelIlluminated: illuminated, panelRef: props.panelRef })} : null; @@ -66,11 +70,13 @@ interface MPVPanelDataProps { panelRef?: MutableRefObject; forwardProps?: boolean; maxHeight?: string; + minHeight?: string; } interface MPVPanelActionProps { doHidePanel?: () => void; doMaximizePanel?: () => void; + doUnMaximizePanel?: () => void; } // Props received by panel implementors @@ -79,24 +85,24 @@ export type MPVPanelProps = MPVPanelDataProps & MPVPanelActionProps; type MPVPanelContentProps = {children: ReactElement} & MPVPanelProps & GridProps; // Grid item compatible component for layout and MPV props passing -export const MPVPanelContent = ({doHidePanel, doMaximizePanel, panelName, - panelMaximized, panelIlluminated, panelRef, forwardProps, maxHeight, +export const MPVPanelContent = ({doHidePanel, doMaximizePanel, doUnMaximizePanel, panelName, + panelMaximized, panelIlluminated, panelRef, forwardProps, maxHeight, minHeight, ...props}: MPVPanelContentProps) => { useEffect(() => { if (panelRef && panelRef.current) { - panelRef.current.scrollIntoView({behavior: 'smooth'}); + panelRef.current.scrollIntoView({alignToTop: true}); } }, [panelRef]); - const mh = panelMaximized + const maxH = panelMaximized ? '100%' : maxHeight; - return + return {/* Element to scroll to when the panel is selected */} { forwardProps - ? React.cloneElement(props.children, { doHidePanel, doMaximizePanel, panelName, panelMaximized }) + ? React.cloneElement(props.children, { doHidePanel, doMaximizePanel, doUnMaximizePanel, panelName, panelMaximized }) : props.children } ; @@ -118,27 +124,32 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo } else if (!isArray(children)) { children = [children]; } - const visibility = (children as ReactNodeArray).map((_, idx) => + const initialVisibility = (children as ReactNodeArray).map((_, idx) => !panelStates || // if panelStates wasn't passed, default to all visible panels (panelStates[idx] && (panelStates[idx].visible || panelStates[idx].visible === undefined))); - const [panelVisibility, setPanelVisibility] = useState(visibility); - const [brightenedPanel, setBrightenedPanel] = useState(-1); + const [panelVisibility, setPanelVisibility] = useState(initialVisibility); + const [previousPanelVisibility, setPreviousPanelVisibility] = useState(initialVisibility); + const [highlightedPanel, setHighlightedPanel] = useState(-1); + const [selectedPanel, setSelectedPanel] = useState(-1); const panelRef = useRef(null); let panels: JSX.Element[] = []; - let toggles: JSX.Element[] = []; + let buttons: JSX.Element[] = []; if (isArray(children)) { for (let idx = 0; idx < children.length; idx++) { const showFn = (idx: number) => () => { + setPreviousPanelVisibility(initialVisibility); setPanelVisibility([ ...panelVisibility.slice(0, idx), true, ...panelVisibility.slice(idx+1) ]); + setSelectedPanel(idx); }; const hideFn = (idx: number) => () => { + setPreviousPanelVisibility(initialVisibility); setPanelVisibility([ ...panelVisibility.slice(0, idx), false, @@ -146,63 +157,64 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo ]) }; const maximizeFn = (idx: number) => () => { + setPreviousPanelVisibility(panelVisibility); // Maximize X == hide all but X setPanelVisibility([ ...panelVisibility.slice(0, idx).map(() => false), true, ...panelVisibility.slice(idx+1).map(() => false), - ]) + ]); }; - const toggleIcon = panelVisibility[idx] - ? - : + const unMaximizeFn = (idx: number) => () => { + setPanelVisibility(previousPanelVisibility); + setSelectedPanel(idx); + } const panelName = panelStates === undefined ? `Panel ${idx+1}` : (panelStates[idx] && panelStates[idx].name) || `Panel ${idx+1}`; - const toggleVariant = "outlined"; - const toggleTooltip = panelVisibility[idx] - ? '' - :`Show ${panelName} panel`; + const btnVariant = panelVisibility[idx] + ? "contained" + : "outlined"; + const btnTooltip = panelVisibility[idx] + ? `` + :`Open ${panelName} panel`; const panelIsMaximized = panelVisibility[idx] && panelVisibility.filter(e => e).length === 1; - let brightenerTimer: NodeJS.Timer; - toggles = [ - ...toggles, - - ]; const aPanel = + panelRef={(idx === selectedPanel) ? panelRef : undefined} + maximized={panelIsMaximized} illuminated={idx === highlightedPanel} + doHidePanel={hideFn(idx)} doMaximizePanel={maximizeFn(idx)} doUnMaximizePanel={panelIsMaximized ? unMaximizeFn(idx) : () => null}> {children[idx]} ; panels = [...panels, aPanel]; }; }; - return + return - { toggles.map((tgl, idx) => {tgl}) } + { buttons.map((tgl, idx) => {tgl}) } - + setSelectedPanel(-1)}> { panelVisibility.includes(true) ? panels : @@ -212,4 +224,4 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo ; }; -export const MPVContainer = withStyles(styles)(MPVContainerComponent); \ No newline at end of file +export const MPVContainer = withStyles(styles)(MPVContainerComponent); diff --git a/src/components/multiselect-toolbar/MultiselectToolbar.tsx b/src/components/multiselect-toolbar/MultiselectToolbar.tsx new file mode 100644 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/search-input/search-input.test.tsx b/src/components/search-input/search-input.test.tsx index c57d3608..ba70f752 100644 --- a/src/components/search-input/search-input.test.tsx +++ b/src/components/search-input/search-input.test.tsx @@ -98,11 +98,22 @@ describe("", () => { describe("on input target change", () => { it("clears the input value on selfClearProp change", () => { const searchInput = mount(); - searchInput.setProps({ selfClearProp: 'aaa' }); + + // component should clear value upon creation jest.runTimersToTime(1000); expect(onSearch).toBeCalledWith(""); expect(onSearch).toHaveBeenCalledTimes(1); + + // component should not clear on same selfClearProp + searchInput.setProps({ selfClearProp: 'abc' }); + jest.runTimersToTime(1000); + expect(onSearch).toHaveBeenCalledTimes(1); + + // component should clear on selfClearProp change + searchInput.setProps({ selfClearProp: '111' }); + jest.runTimersToTime(1000); + expect(onSearch).toBeCalledWith(""); + expect(onSearch).toHaveBeenCalledTimes(2); }); }); - }); diff --git a/src/components/search-input/search-input.tsx b/src/components/search-input/search-input.tsx index 50338f40..fbb4f599 100644 --- a/src/components/search-input/search-input.tsx +++ b/src/components/search-input/search-input.tsx @@ -2,36 +2,17 @@ // // SPDX-License-Identifier: AGPL-3.0 -import React from 'react'; -import { IconButton, StyleRulesCallback, withStyles, WithStyles, FormControl, InputLabel, Input, InputAdornment, Tooltip } from '@material-ui/core'; +import React, {useState, useEffect} from 'react'; +import { + IconButton, + FormControl, + InputLabel, + Input, + InputAdornment, + Tooltip, +} from '@material-ui/core'; import SearchIcon from '@material-ui/icons/Search'; -type CssRules = 'container' | 'input' | 'button'; - -const styles: StyleRulesCallback = theme => { - return { - container: { - position: 'relative', - width: '100%' - }, - input: { - border: 'none', - borderRadius: theme.spacing.unit / 4, - boxSizing: 'border-box', - padding: theme.spacing.unit, - paddingRight: theme.spacing.unit * 4, - width: '100%', - }, - button: { - position: 'absolute', - top: theme.spacing.unit / 2, - right: theme.spacing.unit / 2, - width: theme.spacing.unit * 3, - height: theme.spacing.unit * 3 - } - }; -}; - interface SearchInputDataProps { value: string; label?: string; @@ -43,84 +24,75 @@ interface SearchInputActionProps { debounce?: number; } -type SearchInputProps = SearchInputDataProps & SearchInputActionProps & WithStyles; - -interface SearchInputState { - value: string; - label: string; - selfClearProp: string; -} +type SearchInputProps = SearchInputDataProps & SearchInputActionProps; export const DEFAULT_SEARCH_DEBOUNCE = 1000; -export const SearchInput = withStyles(styles)( - class extends React.Component { - state: SearchInputState = { - value: "", - label: "", - selfClearProp: "" - }; +export const SearchInput = (props: SearchInputProps) => { + const [timeout, setTimeout] = useState(0); + const [value, setValue] = useState(""); + const [label, setLabel] = useState("Search"); + const [selfClearProp, setSelfClearProp] = useState(""); - timeout: number; - - render() { - return - - {this.state.label} - - - - - - - - } /> - - ; + useEffect(() => { + if (props.value) { + setValue(props.value); } - - componentDidMount() { - this.setState({ - value: this.props.value, - label: this.props.label || 'Search' - }); + if (props.label) { + setLabel(props.label); } - componentWillReceiveProps(nextProps: SearchInputProps) { - if (nextProps.value !== this.props.value) { - this.setState({ value: nextProps.value }); - } - if (this.state.value !== '' && nextProps.selfClearProp && nextProps.selfClearProp !== this.state.selfClearProp) { - this.props.onSearch(''); - this.setState({ selfClearProp: nextProps.selfClearProp }); - } - } + return () => { + setValue(""); + clearTimeout(timeout); + }; + }, [props.value, props.label]); // eslint-disable-line react-hooks/exhaustive-deps - componentWillUnmount() { - clearTimeout(this.timeout); + useEffect(() => { + if (selfClearProp !== props.selfClearProp) { + setValue(""); + setSelfClearProp(props.selfClearProp); + handleChange({ target: { value: "" } } as any); } + }, [props.selfClearProp]); // eslint-disable-line react-hooks/exhaustive-deps - handleSubmit = (event: React.FormEvent) => { - event.preventDefault(); - clearTimeout(this.timeout); - this.props.onSearch(this.state.value); - } + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + clearTimeout(timeout); + props.onSearch(value); + }; - handleChange = (event: React.ChangeEvent) => { - clearTimeout(this.timeout); - this.setState({ value: event.target.value }); - this.timeout = window.setTimeout( - () => this.props.onSearch(this.state.value), - this.props.debounce || DEFAULT_SEARCH_DEBOUNCE - ); + const handleChange = (event: React.ChangeEvent) => { + const { target: { value: eventValue } } = event; + clearTimeout(timeout); + setValue(eventValue); + + setTimeout(window.setTimeout( + () => { + props.onSearch(eventValue); + }, + props.debounce || DEFAULT_SEARCH_DEBOUNCE + )); + }; - } - } -); + return
+ + {label} + + + + + + + + } /> + +
; +}; 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/text-field/text-field.tsx b/src/components/text-field/text-field.tsx index 78e2c7fb..b2a8dd48 100644 --- a/src/components/text-field/text-field.tsx +++ b/src/components/text-field/text-field.tsx @@ -72,7 +72,11 @@ export const RichEditorTextField = withStyles(styles)( onChange = (value: any) => { this.setState({ value }); - this.props.input.onChange(value.toString('html')); + this.props.input.onChange( + !!value.getEditorState().getCurrentContent().getPlainText().trim() + ? value.toString('html') + : null + ); } render() { diff --git a/src/components/tree/tree.tsx b/src/components/tree/tree.tsx index fc9dbc74..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 } from 'components/icon/icon'; +import { CollectionIcon, DefaultIcon, DirectoryIcon, FileIcon, ProjectIcon, ProcessIcon, FilterGroupIcon, FreezeIcon } from 'components/icon/icon'; import { ReactElement } from "react"; import CircularProgress from '@material-ui/core/CircularProgress'; import classnames from "classnames"; @@ -14,6 +14,7 @@ import { ArvadosTheme } from 'common/custom-theme'; import { SidePanelRightArrowIcon } from '../icon/icon'; import { ResourceKind } from 'models/resource'; import { GroupClass } from 'models/group'; +import { SidePanelTreeCategory } from 'store/side-panel-tree/side-panel-tree-actions'; type CssRules = 'list' | 'listItem' @@ -26,7 +27,9 @@ type CssRules = 'list' | 'toggableIcon' | 'checkbox' | 'childItem' - | 'childItemIcon'; + | 'childItemIcon' + | 'frozenIcon' + | 'indentSpacer'; const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ list: { @@ -44,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 @@ -83,6 +87,14 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ active: { color: theme.palette.primary.main, }, + frozenIcon: { + fontSize: 20, + color: theme.palette.grey["600"], + marginLeft: '10px', + }, + indentSpacer: { + width: '0.25rem' + } }); export enum TreeItemStatus { @@ -93,6 +105,7 @@ export enum TreeItemStatus { export interface TreeItem { data: T; + depth?: number; id: string; open: boolean; active: boolean; @@ -102,6 +115,7 @@ export interface TreeItem { flatTree?: boolean; status: TreeItemStatus; items?: Array>; + isFrozen?: boolean; } export interface TreeProps { @@ -118,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. @@ -147,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; @@ -160,6 +179,7 @@ interface FlatTreeProps { showSelection: any; useRadioButtons?: boolean; handleCheckboxChange: Function; + selectedRef?: (node: HTMLDivElement | null) => void; } const FLAT_TREE_ACTIONS = { @@ -168,7 +188,7 @@ const FLAT_TREE_ACTIONS = { toggleActive: 'TOGGLE_ACTIVE', }; -const ItemIcon = React.memo(({ type, kind, active, groupClass, classes }: any) => { +const ItemIcon = React.memo(({ type, kind, headKind, active, groupClass, classes }: any) => { let Icon = ProjectIcon; if (groupClass === GroupClass.FILTER) { @@ -189,10 +209,14 @@ const ItemIcon = React.memo(({ type, kind, active, groupClass, classes }: any) = } if (kind) { + if(kind === ResourceKind.LINK && headKind) kind = headKind; switch (kind) { case ResourceKind.COLLECTION: Icon = CollectionIcon; break; + case ResourceKind.CONTAINER_REQUEST: + Icon = ProcessIcon; + break; default: break; } @@ -231,11 +255,14 @@ const FlatTree = (props: FlatTreeProps) => .map((item: any) =>
- - - {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} + { + !!item.data.frozenByUuid ? : null + }
) @@ -260,99 +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, @@ -360,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(); @@ -370,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.css b/src/index.css index 0172d68b..51f07761 100644 --- a/src/index.css +++ b/src/index.css @@ -5,3 +5,25 @@ body { width: 100vw; height: 100vh; } + +.app-banner { + width: calc(100% - 2rem); + height: 150px; + z-index: 11111; + position: fixed; + top: 0px; + background-color: #00bfa5; + border: 1px solid #01685a; + color: #ffffff; + margin: 1rem; + box-sizing: border-box; + cursor: pointer; +} + +.app-banner span { + font-size: 2rem; + text-align: center; + display: block; + margin: auto; + padding: 2rem; +} \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index 5d939d36..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, projectActionSet, readOnlyProjectActionSet } from "views-components/context-menu/action-sets/project-action-set"; -import { resourceActionSet } from 'views-components/context-menu/action-sets/resource-action-set'; +import { + filterGroupActionSet, + frozenActionSet, + projectActionSet, + readOnlyProjectActionSet, +} from "views-components/context-menu/action-sets/project-action-set"; +import { resourceActionSet } from "views-components/context-menu/action-sets/resource-action-set"; import { favoriteActionSet } from "views-components/context-menu/action-sets/favorite-action-set"; -import { collectionFilesActionSet, readOnlyCollectionFilesActionSet } from 'views-components/context-menu/action-sets/collection-files-action-set'; -import { collectionDirectoryItemActionSet, collectionFileItemActionSet, readOnlyCollectionDirectoryItemActionSet, readOnlyCollectionFileItemActionSet } from 'views-components/context-menu/action-sets/collection-files-item-action-set'; -import { collectionFilesNotSelectedActionSet } from 'views-components/context-menu/action-sets/collection-files-not-selected-action-set'; -import { collectionActionSet, collectionAdminActionSet, oldCollectionVersionActionSet, readOnlyCollectionActionSet } from 'views-components/context-menu/action-sets/collection-action-set'; -import { loadWorkbench } from 'store/workbench/workbench-actions'; -import { Routes } from 'routes/routes'; +import { + collectionFilesActionSet, + collectionFilesMultipleActionSet, + readOnlyCollectionFilesActionSet, + readOnlyCollectionFilesMultipleActionSet, +} from "views-components/context-menu/action-sets/collection-files-action-set"; +import { + collectionDirectoryItemActionSet, + collectionFileItemActionSet, + readOnlyCollectionDirectoryItemActionSet, + readOnlyCollectionFileItemActionSet, +} from "views-components/context-menu/action-sets/collection-files-item-action-set"; +import { collectionFilesNotSelectedActionSet } from "views-components/context-menu/action-sets/collection-files-not-selected-action-set"; +import { + collectionActionSet, + collectionAdminActionSet, + oldCollectionVersionActionSet, + readOnlyCollectionActionSet, +} from "views-components/context-menu/action-sets/collection-action-set"; +import { loadWorkbench } from "store/workbench/workbench-actions"; +import { Routes } from "routes/routes"; import { trashActionSet } from "views-components/context-menu/action-sets/trash-action-set"; -import { ServiceRepository } from 'services/services'; -import { initWebSocket } from 'websocket/websocket'; -import { Config } from 'common/config'; -import { addRouteChangeHandlers } from './routes/route-change-handlers'; -import { setTokenDialogApiHost } from 'store/token-dialog/token-dialog-actions'; +import { ServiceRepository } from "services/services"; +import { initWebSocket } from "websocket/websocket"; +import { Config } from "common/config"; +import { addRouteChangeHandlers } from "./routes/route-change-handlers"; +import { setTokenDialogApiHost } from "store/token-dialog/token-dialog-actions"; import { processResourceActionSet, + runningProcessResourceActionSet, processResourceAdminActionSet, - readOnlyProcessResourceActionSet -} from 'views-components/context-menu/action-sets/process-resource-action-set'; -import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions'; -import { trashedCollectionActionSet } from 'views-components/context-menu/action-sets/trashed-collection-action-set'; -import { setBuildInfo } from 'store/app-info/app-info-actions'; -import { getBuildInfo } from 'common/app-info'; -import { DragDropContextProvider } from 'react-dnd'; -import HTML5Backend from 'react-dnd-html5-backend'; -import { initAdvancedFormProjectsTree } from 'store/search-bar/search-bar-actions'; -import { repositoryActionSet } from 'views-components/context-menu/action-sets/repository-action-set'; -import { sshKeyActionSet } from 'views-components/context-menu/action-sets/ssh-key-action-set'; -import { keepServiceActionSet } from 'views-components/context-menu/action-sets/keep-service-action-set'; -import { loadVocabulary } from 'store/vocabulary/vocabulary-actions'; -import { virtualMachineActionSet } from 'views-components/context-menu/action-sets/virtual-machine-action-set'; -import { userActionSet } from 'views-components/context-menu/action-sets/user-action-set'; -import { apiClientAuthorizationActionSet } from 'views-components/context-menu/action-sets/api-client-authorization-action-set'; -import { groupActionSet } from 'views-components/context-menu/action-sets/group-action-set'; -import { groupMemberActionSet } from 'views-components/context-menu/action-sets/group-member-action-set'; -import { linkActionSet } from 'views-components/context-menu/action-sets/link-action-set'; -import { loadFileViewersConfig } from 'store/file-viewers/file-viewers-actions'; -import { filterGroupAdminActionSet, projectAdminActionSet } from 'views-components/context-menu/action-sets/project-admin-action-set'; -import { permissionEditActionSet } from 'views-components/context-menu/action-sets/permission-edit-action-set'; -import { workflowActionSet } from 'views-components/context-menu/action-sets/workflow-action-set'; -import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions"; -import { openNotFoundDialog } from './store/not-found-panel/not-found-panel-action'; -import { storeRedirects } from './common/redirect-to'; -import { searchResultsActionSet } from 'views-components/context-menu/action-sets/search-results-action-set'; + runningProcessResourceAdminActionSet, + readOnlyProcessResourceActionSet, +} from "views-components/context-menu/action-sets/process-resource-action-set"; +import { trashedCollectionActionSet } from "views-components/context-menu/action-sets/trashed-collection-action-set"; +import { setBuildInfo } from "store/app-info/app-info-actions"; +import { getBuildInfo } from "common/app-info"; +import { DragDropContextProvider } from "react-dnd"; +import HTML5Backend from "react-dnd-html5-backend"; +import { initAdvancedFormProjectsTree } from "store/search-bar/search-bar-actions"; +import { repositoryActionSet } from "views-components/context-menu/action-sets/repository-action-set"; +import { sshKeyActionSet } from "views-components/context-menu/action-sets/ssh-key-action-set"; +import { keepServiceActionSet } from "views-components/context-menu/action-sets/keep-service-action-set"; +import { loadVocabulary } from "store/vocabulary/vocabulary-actions"; +import { virtualMachineActionSet } from "views-components/context-menu/action-sets/virtual-machine-action-set"; +import { userActionSet } from "views-components/context-menu/action-sets/user-action-set"; +import { apiClientAuthorizationActionSet } from "views-components/context-menu/action-sets/api-client-authorization-action-set"; +import { groupActionSet } from "views-components/context-menu/action-sets/group-action-set"; +import { groupMemberActionSet } from "views-components/context-menu/action-sets/group-member-action-set"; +import { linkActionSet } from "views-components/context-menu/action-sets/link-action-set"; +import { loadFileViewersConfig } from "store/file-viewers/file-viewers-actions"; +import { + filterGroupAdminActionSet, + frozenAdminActionSet, + projectAdminActionSet, +} from "views-components/context-menu/action-sets/project-admin-action-set"; +import { permissionEditActionSet } from "views-components/context-menu/action-sets/permission-edit-action-set"; +import { workflowActionSet, readOnlyWorkflowActionSet } from "views-components/context-menu/action-sets/workflow-action-set"; +import { storeRedirects } from "./common/redirect-to"; +import { searchResultsActionSet } from "views-components/context-menu/action-sets/search-results-action-set"; + +import 'bootstrap/dist/css/bootstrap.min.css'; +import '@coreui/coreui/dist/css/coreui.min.css'; console.log(`Starting arvados [${getBuildInfo()}]`); @@ -77,7 +103,9 @@ addMenuActionSet(ContextMenuKind.FILTER_GROUP, filterGroupActionSet); addMenuActionSet(ContextMenuKind.RESOURCE, resourceActionSet); addMenuActionSet(ContextMenuKind.FAVORITE, favoriteActionSet); addMenuActionSet(ContextMenuKind.COLLECTION_FILES, collectionFilesActionSet); +addMenuActionSet(ContextMenuKind.COLLECTION_FILES_MULTIPLE, collectionFilesMultipleActionSet); addMenuActionSet(ContextMenuKind.READONLY_COLLECTION_FILES, readOnlyCollectionFilesActionSet); +addMenuActionSet(ContextMenuKind.READONLY_COLLECTION_FILES_MULTIPLE, readOnlyCollectionFilesMultipleActionSet); addMenuActionSet(ContextMenuKind.COLLECTION_FILES_NOT_SELECTED, collectionFilesNotSelectedActionSet); addMenuActionSet(ContextMenuKind.COLLECTION_DIRECTORY_ITEM, collectionDirectoryItemActionSet); addMenuActionSet(ContextMenuKind.READONLY_COLLECTION_DIRECTORY_ITEM, readOnlyCollectionDirectoryItemActionSet); @@ -88,6 +116,7 @@ addMenuActionSet(ContextMenuKind.READONLY_COLLECTION, readOnlyCollectionActionSe addMenuActionSet(ContextMenuKind.OLD_VERSION_COLLECTION, oldCollectionVersionActionSet); addMenuActionSet(ContextMenuKind.TRASHED_COLLECTION, trashedCollectionActionSet); addMenuActionSet(ContextMenuKind.PROCESS_RESOURCE, processResourceActionSet); +addMenuActionSet(ContextMenuKind.RUNNING_PROCESS_RESOURCE, runningProcessResourceActionSet); addMenuActionSet(ContextMenuKind.READONLY_PROCESS_RESOURCE, readOnlyProcessResourceActionSet); addMenuActionSet(ContextMenuKind.TRASH, trashActionSet); addMenuActionSet(ContextMenuKind.REPOSITORY, repositoryActionSet); @@ -101,89 +130,106 @@ addMenuActionSet(ContextMenuKind.GROUPS, groupActionSet); addMenuActionSet(ContextMenuKind.GROUP_MEMBER, groupMemberActionSet); addMenuActionSet(ContextMenuKind.COLLECTION_ADMIN, collectionAdminActionSet); addMenuActionSet(ContextMenuKind.PROCESS_ADMIN, processResourceAdminActionSet); +addMenuActionSet(ContextMenuKind.RUNNING_PROCESS_ADMIN, runningProcessResourceAdminActionSet); addMenuActionSet(ContextMenuKind.PROJECT_ADMIN, projectAdminActionSet); +addMenuActionSet(ContextMenuKind.FROZEN_PROJECT, frozenActionSet); +addMenuActionSet(ContextMenuKind.FROZEN_PROJECT_ADMIN, frozenAdminActionSet); addMenuActionSet(ContextMenuKind.FILTER_GROUP_ADMIN, filterGroupAdminActionSet); addMenuActionSet(ContextMenuKind.PERMISSION_EDIT, permissionEditActionSet); +addMenuActionSet(ContextMenuKind.READONLY_WORKFLOW, readOnlyWorkflowActionSet); addMenuActionSet(ContextMenuKind.WORKFLOW, workflowActionSet); addMenuActionSet(ContextMenuKind.SEARCH_RESULTS, searchResultsActionSet); storeRedirects(); -fetchConfig() - .then(({ config, apiHost }) => { - const history = createBrowserHistory(); +fetchConfig().then(({ config, apiHost }) => { + const history = createBrowserHistory(); - // Provide browser's history access to Cypress to allow programmatic - // navigation. - if ((window as any).Cypress) { - (window as any).appHistory = history; - } + // Provide browser's history access to Cypress to allow programmatic + // navigation. + if ((window as any).Cypress) { + (window as any).appHistory = history; + } - const services = createServices(config, { - progressFn: (id, working) => { - store.dispatch(progressIndicatorActions.TOGGLE_WORKING({ id, working })); - }, - errorFn: (id, error, showSnackBar: boolean) => { - if (showSnackBar) { - console.error("Backend error:", error); - - if (error.status === 404) { - store.dispatch(openNotFoundDialog()); - } else if (error.status === 401 && error.errors[0].indexOf("Not logged in") > -1) { - store.dispatch(logout()); - } else { - store.dispatch(snackbarActions.OPEN_SNACKBAR({ - message: `${error.errors - ? error.errors[0] - : error.message}`, - kind: SnackbarKind.ERROR, - hideDuration: 8000 - }) - ); - } + const services = createServices(config, { + progressFn: (id, working) => { + }, + errorFn: (id, error, showSnackBar: boolean) => { + if (showSnackBar) { + console.error("Backend error:", error); + if (error.status === 401 && error.errors[0].indexOf("Not logged in") > -1) { + // Catch auth errors when navigating and redirect to login preserving url location + store.dispatch(logout(false, true)); } } - }); - - // be sure this is initiated before the app starts - servicesProvider.setServices(services); - - const store = configureStore(history, services, config); - - store.subscribe(initListener(history, store, services, config)); - store.dispatch(initAuth(config)); - store.dispatch(setBuildInfo()); - store.dispatch(setTokenDialogApiHost(apiHost)); - store.dispatch(loadVocabulary); - store.dispatch(loadFileViewersConfig); - - const TokenComponent = (props: any) => ; - 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 99ec4cf0..d3adb03a 100644 --- a/src/models/container-request.ts +++ b/src/models/container-request.ts @@ -2,40 +2,84 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { Resource, ResourceKind, ResourceWithProperties } from "./resource"; -import { MountType } from "models/mount-types"; +import { Resource, ResourceKind, ResourceWithProperties } from './resource'; +import { MountType } from 'models/mount-types'; import { RuntimeConstraints } from './runtime-constraints'; import { SchedulingParameters } from './scheduling-parameters'; export enum ContainerRequestState { - UNCOMMITTED = "Uncommitted", - COMMITTED = "Committed", - FINAL = "Final" + UNCOMMITTED = 'Uncommitted', + COMMITTED = 'Committed', + FINAL = 'Final', } -export interface ContainerRequestResource extends Resource, ResourceWithProperties { - kind: ResourceKind.CONTAINER_REQUEST; - name: string; - description: string; - state: ContainerRequestState; - requestingContainerUuid: string | null; - containerUuid: string | null; +export interface ContainerRequestResource + extends Resource, + ResourceWithProperties { + command: string[]; containerCountMax: number; - mounts: {[path: string]: MountType}; - runtimeConstraints: RuntimeConstraints; - schedulingParameters: SchedulingParameters; + containerCount: number; containerImage: string; - environment: any; + containerUuid: string | null; + cumulativeCost: number; cwd: string; - command: string[]; - outputPath: string; + description: string; + environment: any; + expiresAt: string; + filters: string; + kind: ResourceKind.CONTAINER_REQUEST; + logUuid: string | null; + mounts: { [path: string]: MountType }; + name: string; outputName: string; + outputPath: string; + outputProperties: any; + outputStorageClasses: string[]; outputTtl: number; + outputUuid: string | null; priority: number | null; - expiresAt: string; + requestingContainerUuid: string | null; + runtimeConstraints: RuntimeConstraints; + schedulingParameters: SchedulingParameters; + state: ContainerRequestState; useExisting: boolean; - logUuid: string | null; - outputUuid: string | null; - filters: string; - containerCount: number; } + +// Until the api supports unselecting fields, we need a list of all other fields to omit mounts +export const containerRequestFieldsNoMounts = [ + "command", + "container_count_max", + "container_count", + "container_image", + "container_uuid", + "created_at", + "cumulative_cost", + "cwd", + "description", + "environment", + "etag", + "expires_at", + "filters", + "href", + "kind", + "log_uuid", + "modified_at", + "modified_by_client_uuid", + "modified_by_user_uuid", + "name", + "output_name", + "output_path", + "output_properties", + "output_storage_classes", + "output_ttl", + "output_uuid", + "owner_uuid", + "priority", + "properties", + "requesting_container_uuid", + "runtime_constraints", + "scheduling_parameters", + "state", + "use_existing", + "uuid", +]; diff --git a/src/models/container.ts b/src/models/container.ts index 127c2508..c86f11ce 100644 --- a/src/models/container.ts +++ b/src/models/container.ts @@ -25,10 +25,12 @@ export interface ContainerResource extends Resource { environment: {}; cwd: string; command: string[]; + cost: number; outputPath: string; mounts: MountType[]; runtimeConstraints: RuntimeConstraints; runtimeStatus: RuntimeStatus; + runtimeUserUuid: string; schedulingParameters: SchedulingParameters; output: string | null; containerImage: string; 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/project.ts b/src/models/project.ts index b47b426f..8dd2e716 100644 --- a/src/models/project.ts +++ b/src/models/project.ts @@ -5,6 +5,7 @@ import { GroupClass, GroupResource } from "./group"; export interface ProjectResource extends GroupResource { + frozenByUuid: null | string; groupClass: GroupClass.PROJECT | GroupClass.FILTER | GroupClass.ROLE; } diff --git a/src/models/resource.ts b/src/models/resource.ts index fd867277..2d2b9f21 100644 --- a/src/models/resource.ts +++ b/src/models/resource.ts @@ -67,6 +67,7 @@ export const RESOURCE_UUID_PATTERN = '[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}'; export const PORTABLE_DATA_HASH_PATTERN = '[a-f0-9]{32}\\+\\d+'; export const RESOURCE_UUID_REGEX = new RegExp("^" + RESOURCE_UUID_PATTERN + "$"); export const COLLECTION_PDH_REGEX = new RegExp("^" + PORTABLE_DATA_HASH_PATTERN + "$"); +export const KEEP_URL_REGEX = new RegExp("^(keep:)?" + PORTABLE_DATA_HASH_PATTERN); export const isResourceUuid = (uuid: string) => RESOURCE_UUID_REGEX.test(uuid); diff --git a/src/models/runtime-constraints.ts b/src/models/runtime-constraints.ts index 89101c6e..63982529 100644 --- a/src/models/runtime-constraints.ts +++ b/src/models/runtime-constraints.ts @@ -2,9 +2,17 @@ // // SPDX-License-Identifier: AGPL-3.0 +export interface CUDAParameters { + device_count: number; + driver_version: string; + hardware_capability: string; +} + export interface RuntimeConstraints { ram: number; vcpus: number; keep_cache_ram?: number; + keep_cache_disk?: number; API: boolean; + cuda?: CUDAParameters; } diff --git a/src/models/search-bar.ts b/src/models/search-bar.ts index b404496f..f9320a26 100644 --- a/src/models/search-bar.ts +++ b/src/models/search-bar.ts @@ -3,11 +3,13 @@ // SPDX-License-Identifier: AGPL-3.0 import { ResourceKind } from 'models/resource'; +import { GroupResource } from './group'; export type SearchBarAdvancedFormData = { type?: ResourceKind; cluster?: string; projectUuid?: string; + projectObject?: GroupResource; inTrash: boolean; pastVersions: boolean; dateFrom: string; 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 9b3d97d8..0df6eac2 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { Resource, ResourceKind } from 'models/resource'; +import { Resource, ResourceKind, RESOURCE_UUID_REGEX } from 'models/resource'; export type UserPrefs = { profile?: { @@ -24,6 +24,8 @@ export interface User { prefs: UserPrefs; isAdmin: boolean; isActive: boolean; + canWrite: boolean; + canManage: boolean; } export const getUserFullname = (user: User) => { @@ -44,8 +46,22 @@ export const getUserDisplayName = (user: User, withEmail = false, withUuid = fal return parts.join(' '); }; +export const getUserDetailsString = (user: User) => { + let parts: string[] = []; + const userCluster = getUserClusterID(user); + user.username.length && parts.push(user.username); + user.email.length && parts.push(`<${user.email}>`); + userCluster && userCluster.length && parts.push(`(${userCluster})`); + return parts.join(' '); +}; + +export const getUserClusterID = (user: User): string | undefined => { + const match = RESOURCE_UUID_REGEX.exec(user.uuid); + const parts = match ? match[0].split('-') : []; + return parts.length === 3 ? parts[0] : undefined; +}; + export interface UserResource extends Resource, User { kind: ResourceKind.USER; defaultOwnerUuid: string; - writableBy: string[]; } diff --git a/src/models/workflow.ts b/src/models/workflow.ts index 6d21dbc7..369db4c7 100644 --- a/src/models/workflow.ts +++ b/src/models/workflow.ts @@ -4,6 +4,7 @@ import { Resource, ResourceKind } from "./resource"; import { safeLoad } from 'js-yaml'; +import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter"; export interface WorkflowResource extends Resource { kind: ResourceKind.WORKFLOW; @@ -152,10 +153,21 @@ export const getWorkflowInputs = (workflowDefinition: WorkflowResourceDefinition : undefined; }; +export const getWorkflowOutputs = (workflowDefinition: WorkflowResourceDefinition) => { + if (!workflowDefinition) { return undefined; } + return getWorkflow(workflowDefinition) + ? getWorkflow(workflowDefinition)!.outputs + : undefined; +}; + export const getInputLabel = (input: CommandInputParameter) => { return `${input.label || input.id.split('/').pop()}`; }; +export const getIOParamId = (input: CommandInputParameter | CommandOutputParameter) => { + return `${input.id.split('/').pop()}`; +}; + export const isRequiredInput = ({ type }: CommandInputParameter) => { if (type instanceof Array) { for (const t of type) { @@ -173,10 +185,31 @@ export const isPrimitiveOfType = (input: GenericCommandInputParameter, : 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/api/filter-builder.ts b/src/services/api/filter-builder.ts index 4809e7a8..bb97665a 100644 --- a/src/services/api/filter-builder.ts +++ b/src/services/api/filter-builder.ts @@ -64,11 +64,27 @@ export class FilterBuilder { return this.addCondition("properties." + field, "exists", false, "", "", resourcePrefix); } - public addFullTextSearch(value: string) { - const terms = value.trim().split(/(\s+)/); + public addFullTextSearch(value: string, table?: string) { + const regex = /"[^"]*"/; + const matches: any[] = []; + + let match = value.match(regex); + + while (match) { + value = value.replace(match[0], ""); + matches.push(match[0].replace(/"/g, '')); + match = value.match(regex); + } + + let searchIn = 'any'; + if (table) { + searchIn = table + ".any"; + } + + const terms = value.trim().split(/(\s+)/).concat(matches); terms.forEach(term => { if (term !== " ") { - this.addCondition("any", "ilike", term, "%", "%"); + this.addCondition(searchIn, "ilike", term, "%", "%"); } }); return this; diff --git a/src/services/auth-service/auth-service.ts b/src/services/auth-service/auth-service.ts index 52bfa29e..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 { @@ -120,9 +122,13 @@ export class AuthService { window.location.assign(`https://${homeClusterHost}/login?${(uuidPrefix !== homeCluster && homeCluster !== loginCluster) ? "remote=" + uuidPrefix + "&" : ""}return_to=${currentUrl}`); } - public logout(expireToken: string) { - const currentUrl = `${window.location.protocol}//${window.location.host}`; - window.location.assign(`${this.baseUrl || ""}/logout?api_token=${expireToken}&return_to=${currentUrl}`); + public logout(expireToken: string, preservePath: boolean) { + const fullUrl = new URL(window.location.href); + const wbBase = `${fullUrl.protocol}//${fullUrl.host}`; + const wbPath = fullUrl.pathname + fullUrl.search; + const returnTo = `${wbBase}${preservePath ? wbPath : ''}` + + window.location.assign(`${this.baseUrl || ""}/logout?api_token=${expireToken}&return_to=${returnTo}`); } public getUserDetails = (showErrors?: boolean): Promise => { @@ -142,6 +148,8 @@ export class AuthService { isAdmin: resp.data.is_admin, isActive: resp.data.is_active, username: resp.data.username, + canWrite: resp.data.can_write, + canManage: resp.data.can_manage, prefs }; }) 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 817fdd54..3b4f423a 100644 --- a/src/services/collection-service/collection-service.test.ts +++ b/src/services/collection-service/collection-service.test.ts @@ -7,28 +7,30 @@ import MockAdapter from 'axios-mock-adapter'; import { snakeCase } from 'lodash'; import { CollectionResource, defaultCollectionSelectedFields } from 'models/collection'; import { AuthService } from '../auth-service/auth-service'; -import { CollectionService } from './collection-service'; +import { CollectionService, emptyCollectionPdh } from './collection-service'; describe('collection-service', () => { let collectionService: CollectionService; let serverApi: AxiosInstance; let axiosMock: MockAdapter; - let webdavClient: any; + let keepWebdavClient: any; let authService; let actions; beforeEach(() => { serverApi = axios.create(); axiosMock = new MockAdapter(serverApi); - webdavClient = { + keepWebdavClient = { delete: jest.fn(), upload: jest.fn(), + mkdir: jest.fn(), } as any; authService = {} as AuthService; actions = { progressFn: jest.fn(), + errorFn: jest.fn(), } as any; - collectionService = new CollectionService(serverApi, webdavClient, authService, actions); + collectionService = new CollectionService(serverApi, keepWebdavClient, authService, actions); collectionService.update = jest.fn(); }); @@ -77,7 +79,7 @@ describe('collection-service', () => { }, select: ['uuid', 'name', 'version', 'modified_at'], } - collectionService = new CollectionService(serverApi, webdavClient, authService, actions); + collectionService = new CollectionService(serverApi, keepWebdavClient, authService, actions); await collectionService.update('uuid', data); expect(serverApi.put).toHaveBeenCalledWith('/collections/uuid', expected); }); @@ -93,7 +95,7 @@ describe('collection-service', () => { await collectionService.uploadFiles(collectionUUID, files); // then - expect(webdavClient.upload).not.toHaveBeenCalled(); + expect(keepWebdavClient.upload).not.toHaveBeenCalled(); }); it('should upload files', async () => { @@ -105,11 +107,11 @@ describe('collection-service', () => { await collectionService.uploadFiles(collectionUUID, files); // then - expect(webdavClient.upload).toHaveBeenCalledTimes(1); - expect(webdavClient.upload.mock.calls[0][0]).toEqual("c=zzzzz-4zz18-0123456789abcde/test-file1"); + expect(keepWebdavClient.upload).toHaveBeenCalledTimes(1); + expect(keepWebdavClient.upload.mock.calls[0][0]).toEqual("c=zzzzz-4zz18-0123456789abcde/test-file1"); }); - it.only('should upload files with custom uplaod target', async () => { + it('should upload files with custom uplaod target', async () => { // given const files: File[] = [{name: 'test-file1'} as File]; const collectionUUID = 'zzzzz-4zz18-0123456789abcde'; @@ -119,50 +121,335 @@ describe('collection-service', () => { await collectionService.uploadFiles(collectionUUID, files, undefined, customTarget); // then - expect(webdavClient.upload).toHaveBeenCalledTimes(1); - expect(webdavClient.upload.mock.calls[0][0]).toEqual("c=zzzzz-4zz18-0123456789adddd/test-path/test-file1"); + expect(keepWebdavClient.upload).toHaveBeenCalledTimes(1); + expect(keepWebdavClient.upload.mock.calls[0][0]).toEqual("c=zzzzz-4zz18-0123456789adddd/test-path/test-file1"); }); }); describe('deleteFiles', () => { it('should remove no files', async () => { // given + serverApi.put = jest.fn(() => Promise.resolve({ data: {} })); const filePaths: string[] = []; - const collectionUUID = ''; + const collectionUUID = 'zzzzz-tpzed-5o5tg0l9a57gxxx'; // when await collectionService.deleteFiles(collectionUUID, filePaths); // then - expect(webdavClient.delete).not.toHaveBeenCalled(); + expect(serverApi.put).toHaveBeenCalledTimes(1); + expect(serverApi.put).toHaveBeenCalledWith( + `/collections/${collectionUUID}`, { + collection: { + preserve_version: true + }, + replace_files: {}, + } + ); }); it('should remove only root files', async () => { // given + serverApi.put = jest.fn(() => Promise.resolve({ data: {} })); const filePaths: string[] = ['/root/1', '/root/1/100', '/root/1/100/test.txt', '/root/2', '/root/2/200', '/root/3/300/test.txt']; - const collectionUUID = ''; + const collectionUUID = 'zzzzz-tpzed-5o5tg0l9a57gxxx'; // when await collectionService.deleteFiles(collectionUUID, filePaths); // then - expect(webdavClient.delete).toHaveBeenCalledTimes(3); - expect(webdavClient.delete).toHaveBeenCalledWith("c=/root/3/300/test.txt"); - expect(webdavClient.delete).toHaveBeenCalledWith("c=/root/2"); - expect(webdavClient.delete).toHaveBeenCalledWith("c=/root/1"); + expect(serverApi.put).toHaveBeenCalledTimes(1); + expect(serverApi.put).toHaveBeenCalledWith( + `/collections/${collectionUUID}`, { + collection: { + preserve_version: true + }, + replace_files: { + '/root/3/300/test.txt': '', + '/root/2': '', + '/root/1': '', + }, + } + ); }); - it('should remove files with uuid prefix', async () => { + it('should batch remove files', async () => { + serverApi.put = jest.fn(() => Promise.resolve({ data: {} })); // given - const filePaths: string[] = ['/root/1']; - const collectionUUID = 'zzzzz-tpzed-5o5tg0l9a57gxxx'; + const filePaths: string[] = ['/root/1', '/secondFile', 'barefile.txt']; + const collectionUUID = 'zzzzz-4zz18-5o5tg0l9a57gxxx'; // when await collectionService.deleteFiles(collectionUUID, filePaths); // then - expect(webdavClient.delete).toHaveBeenCalledTimes(1); - expect(webdavClient.delete).toHaveBeenCalledWith("c=zzzzz-tpzed-5o5tg0l9a57gxxx/root/1"); + expect(serverApi.put).toHaveBeenCalledTimes(1); + expect(serverApi.put).toHaveBeenCalledWith( + `/collections/${collectionUUID}`, { + collection: { + preserve_version: true + }, + replace_files: { + '/root/1': '', + '/secondFile': '', + '/barefile.txt': '', + }, + } + ); + }); + }); + + describe('renameFile', () => { + it('should rename file', async () => { + serverApi.put = jest.fn(() => Promise.resolve({ data: {} })); + const collectionUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq'; + const collectionPdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180'; + const oldPath = '/old/path'; + const newPath = '/new/filename'; + + await collectionService.renameFile(collectionUuid, collectionPdh, oldPath, newPath); + + expect(serverApi.put).toHaveBeenCalledTimes(1); + expect(serverApi.put).toHaveBeenCalledWith( + `/collections/${collectionUuid}`, { + collection: { + preserve_version: true + }, + replace_files: { + [newPath]: `${collectionPdh}${oldPath}`, + [oldPath]: '', + }, + } + ); }); }); -}); \ No newline at end of file + + describe('copyFiles', () => { + it('should batch copy files', async () => { + serverApi.put = jest.fn(() => Promise.resolve({ data: {} })); + const filePaths: string[] = ['/root/1', '/secondFile', 'barefile.txt']; + const sourcePdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180'; + + const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq'; + const destinationPath = '/destinationPath'; + + // when + await collectionService.copyFiles(sourcePdh, filePaths, {uuid: destinationUuid}, destinationPath); + + // then + expect(serverApi.put).toHaveBeenCalledTimes(1); + expect(serverApi.put).toHaveBeenCalledWith( + `/collections/${destinationUuid}`, { + collection: { + preserve_version: true + }, + replace_files: { + [`${destinationPath}/1`]: `${sourcePdh}/root/1`, + [`${destinationPath}/secondFile`]: `${sourcePdh}/secondFile`, + [`${destinationPath}/barefile.txt`]: `${sourcePdh}/barefile.txt`, + }, + } + ); + }); + + it('should copy files from rooth', async () => { + // Test copying from root paths + serverApi.put = jest.fn(() => Promise.resolve({ data: {} })); + const filePaths: string[] = ['/']; + const sourcePdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180'; + + const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq'; + const destinationPath = '/destinationPath'; + + await collectionService.copyFiles(sourcePdh, filePaths, {uuid: destinationUuid}, destinationPath); + + expect(serverApi.put).toHaveBeenCalledTimes(1); + expect(serverApi.put).toHaveBeenCalledWith( + `/collections/${destinationUuid}`, { + collection: { + preserve_version: true + }, + replace_files: { + [`${destinationPath}`]: `${sourcePdh}/`, + }, + } + ); + }); + + it('should copy files to root path', async () => { + // Test copying to root paths + serverApi.put = jest.fn(() => Promise.resolve({ data: {} })); + const filePaths: string[] = ['/']; + const sourcePdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180'; + + const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq'; + const destinationPath = '/'; + + await collectionService.copyFiles(sourcePdh, filePaths, {uuid: destinationUuid}, destinationPath); + + expect(serverApi.put).toHaveBeenCalledTimes(1); + expect(serverApi.put).toHaveBeenCalledWith( + `/collections/${destinationUuid}`, { + collection: { + preserve_version: true + }, + replace_files: { + "/": `${sourcePdh}/`, + }, + } + ); + }); + }); + + describe('moveFiles', () => { + it('should batch move files', async () => { + serverApi.put = jest.fn(() => Promise.resolve({ data: {} })); + // given + const filePaths: string[] = ['/rootFile', '/secondFile', '/subpath/subfile', 'barefile.txt']; + const srcCollectionUUID = 'zzzzz-4zz18-5o5tg0l9a57gxxx'; + const srcCollectionPdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180'; + + const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq'; + const destinationPath = '/destinationPath'; + + // when + await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, {uuid: destinationUuid}, destinationPath); + + // then + expect(serverApi.put).toHaveBeenCalledTimes(2); + // Verify copy + expect(serverApi.put).toHaveBeenCalledWith( + `/collections/${destinationUuid}`, { + collection: { + preserve_version: true + }, + replace_files: { + [`${destinationPath}/rootFile`]: `${srcCollectionPdh}/rootFile`, + [`${destinationPath}/secondFile`]: `${srcCollectionPdh}/secondFile`, + [`${destinationPath}/subfile`]: `${srcCollectionPdh}/subpath/subfile`, + [`${destinationPath}/barefile.txt`]: `${srcCollectionPdh}/barefile.txt`, + }, + } + ); + // Verify delete + expect(serverApi.put).toHaveBeenCalledWith( + `/collections/${srcCollectionUUID}`, { + collection: { + preserve_version: true + }, + replace_files: { + "/rootFile": "", + "/secondFile": "", + "/subpath/subfile": "", + "/barefile.txt": "", + }, + } + ); + }); + + it('should batch move files within collection', async () => { + serverApi.put = jest.fn(() => Promise.resolve({ data: {} })); + // given + const filePaths: string[] = ['/one', '/two', '/subpath/subfile', 'barefile.txt']; + const srcCollectionUUID = 'zzzzz-4zz18-5o5tg0l9a57gxxx'; + const srcCollectionPdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180'; + + const destinationPath = '/destinationPath'; + + // when + await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, {uuid: srcCollectionUUID}, destinationPath); + + // then + expect(serverApi.put).toHaveBeenCalledTimes(1); + // Verify copy + expect(serverApi.put).toHaveBeenCalledWith( + `/collections/${srcCollectionUUID}`, { + collection: { + preserve_version: true + }, + replace_files: { + [`${destinationPath}/one`]: `${srcCollectionPdh}/one`, + ['/one']: '', + [`${destinationPath}/two`]: `${srcCollectionPdh}/two`, + ['/two']: '', + [`${destinationPath}/subfile`]: `${srcCollectionPdh}/subpath/subfile`, + ['/subpath/subfile']: '', + [`${destinationPath}/barefile.txt`]: `${srcCollectionPdh}/barefile.txt`, + ['/barefile.txt']: '', + }, + } + ); + }); + + it('should abort batch move when copy fails', async () => { + // Simulate failure to copy + serverApi.put = jest.fn(() => Promise.reject({ + data: {}, + response: { + "errors": ["error getting snapshot of \"rootFile\" from \"8cd9ce1dfa21c635b620b1bfee7aaa08+180\": file does not exist"] + } + })); + // given + const filePaths: string[] = ['/rootFile', '/secondFile', '/subpath/subfile', 'barefile.txt']; + const srcCollectionUUID = 'zzzzz-4zz18-5o5tg0l9a57gxxx'; + const srcCollectionPdh = '8cd9ce1dfa21c635b620b1bfee7aaa08+180'; + + const destinationUuid = 'zzzzz-4zz18-ywq0rvhwwhkjnfq'; + const destinationPath = '/destinationPath'; + + // when + try { + await collectionService.moveFiles(srcCollectionUUID, srcCollectionPdh, filePaths, {uuid: destinationUuid}, destinationPath); + } catch {} + + // then + expect(serverApi.put).toHaveBeenCalledTimes(1); + // Verify copy + expect(serverApi.put).toHaveBeenCalledWith( + `/collections/${destinationUuid}`, { + collection: { + preserve_version: true + }, + replace_files: { + [`${destinationPath}/rootFile`]: `${srcCollectionPdh}/rootFile`, + [`${destinationPath}/secondFile`]: `${srcCollectionPdh}/secondFile`, + [`${destinationPath}/subfile`]: `${srcCollectionPdh}/subpath/subfile`, + [`${destinationPath}/barefile.txt`]: `${srcCollectionPdh}/barefile.txt`, + }, + } + ); + }); + }); + + describe('createDirectory', () => { + it('creates empty directory', async () => { + // given + const directoryNames = [ + {in: 'newDir', out: 'newDir'}, + {in: '/fooDir', out: 'fooDir'}, + {in: '/anotherPath/', out: 'anotherPath'}, + {in: 'trailingSlash/', out: 'trailingSlash'}, + ]; + const collectionUuid = 'zzzzz-tpzed-5o5tg0l9a57gxxx'; + + for (var i = 0; i < directoryNames.length; i++) { + serverApi.put = jest.fn(() => Promise.resolve({ data: {} })); + // when + await collectionService.createDirectory(collectionUuid, directoryNames[i].in); + // then + expect(serverApi.put).toHaveBeenCalledTimes(1); + expect(serverApi.put).toHaveBeenCalledWith( + `/collections/${collectionUuid}`, { + collection: { + preserve_version: true + }, + replace_files: { + ["/" + directoryNames[i].out]: emptyCollectionPdh, + }, + } + ); + } + }); + }); + +}); diff --git a/src/services/collection-service/collection-service.ts b/src/services/collection-service/collection-service.ts index 92e4dfba..e50e5ed3 100644 --- a/src/services/collection-service/collection-service.ts +++ b/src/services/collection-service/collection-service.ts @@ -10,22 +10,30 @@ import { AuthService } from "../auth-service/auth-service"; import { extractFilesData } from "./collection-service-files-response"; import { TrashableResourceService } from "services/common-service/trashable-resource-service"; import { ApiActions } from "services/api/api-actions"; -import { customEncodeURI } from "common/url"; import { Session } from "models/session"; +import { CommonService } from "services/common-service/common-service"; +import { snakeCase } from "lodash"; +import { CommonResourceServiceError } from "services/common-service/common-resource-service"; export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void; +type CollectionPartialUpdateOrCreate = + | (Partial & Pick) + | (Partial & Pick); + +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", ]); } @@ -35,48 +43,71 @@ export class CollectionService extends TrashableResourceService) { - return super.create({ ...data, preserveVersion: true }); + create(data?: Partial, showErrors?: boolean) { + return super.create({ ...data, preserveVersion: true }, showErrors); } - update(uuid: string, data: Partial) { - const select = [...Object.keys(data), 'version', 'modifiedAt']; - return super.update(uuid, { ...data, preserveVersion: true }, select); + update(uuid: string, data: Partial, showErrors?: boolean) { + const select = [...Object.keys(data), "version", "modifiedAt"]; + return super.update(uuid, { ...data, preserveVersion: true }, showErrors, select); } async files(uuid: string) { - const request = await this.webdavClient.propfind(`c=${uuid}`); - if (request.responseXML != null) { - return extractFilesData(request.responseXML); + try { + const request = await this.keepWebdavClient.propfind(`c=${uuid}`); + if (request.responseXML != null) { + return extractFilesData(request.responseXML); + } + } catch (e) { + return Promise.reject(e); } return Promise.reject(); } - async deleteFiles(collectionUuid: string, filePaths: string[]) { - const sortedUniquePaths = Array.from(new Set(filePaths)) - .sort((a, b) => a.length - b.length) - .reduce((acc, currentPath) => { - const parentPathFound = acc.find((parentPath) => currentPath.indexOf(`${parentPath}/`) > -1); - - if (!parentPathFound) { - return [...acc, currentPath]; - } - - return acc; - }, []); - - for (const path of sortedUniquePaths) { - if (path.indexOf(collectionUuid) === -1) { - await this.webdavClient.delete(`c=${collectionUuid}${path}`); + private combineFilePath(parts: string[]) { + return parts.reduce((path, part) => { + // Trim leading and trailing slashes + const trimmedPart = part.split("/").filter(Boolean).join("/"); + if (trimmedPart.length) { + const separator = path.endsWith("/") ? "" : "/"; + return `${path}${separator}${trimmedPart}`; } else { - await this.webdavClient.delete(`c=${path}`); + return path; } + }, "/"); + } + + private replaceFiles(data: CollectionPartialUpdateOrCreate, fileMap: {}, showErrors?: boolean) { + const payload = { + collection: { + preserve_version: true, + ...CommonService.mapKeys(snakeCase)(data), + // Don't send uuid in payload when creating + uuid: undefined, + }, + replace_files: fileMap, + }; + if (data.uuid) { + return CommonService.defaultResponse( + this.serverApi.put(`/${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 + ); } - await this.update(collectionUuid, { preserveVersion: true }); } - async uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress, targetLocation: string = '') { - if (collectionUuid === "" || files.length === 0) { return; } + async uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress, targetLocation: string = "") { + if (collectionUuid === "" || files.length === 0) { + return; + } // files have to be uploaded sequentially for (let idx = 0; idx < files.length; idx++) { await this.uploadFile(collectionUuid, files[idx], idx, onProgress, targetLocation); @@ -84,39 +115,140 @@ export class CollectionService extends TrashableResourceService { - const baseUrl = this.webdavClient.defaults.baseURL.endsWith('/') - ? this.webdavClient.defaults.baseURL.slice(0, -1) - : this.webdavClient.defaults.baseURL; + const baseUrl = this.keepWebdavClient.getBaseUrl().endsWith("/") + ? this.keepWebdavClient.getBaseUrl().slice(0, -1) + : this.keepWebdavClient.getBaseUrl(); const apiToken = this.authService.getApiToken(); - const encodedApiToken = apiToken ? encodeURI(apiToken) : ''; + const encodedApiToken = apiToken ? encodeURI(apiToken) : ""; const userApiToken = `/t=${encodedApiToken}/`; - const splittedPrevFileUrl = file.url.split('/'); - const url = `${baseUrl}/${splittedPrevFileUrl[1]}${userApiToken}${splittedPrevFileUrl.slice(2).join('/')}`; + const splittedPrevFileUrl = file.url.split("/"); + const url = `${baseUrl}/${splittedPrevFileUrl[1]}${userApiToken}${splittedPrevFileUrl.slice(2).join("/")}`; return { ...file, - url + url, }; + }; + + async getFileContents(file: CollectionFile) { + return (await this.keepWebdavClient.get(`c=${file.id}`)).response; } - private async uploadFile(collectionUuid: string, file: File, fileId: number, onProgress: UploadProgress = () => { return; }, targetLocation: string = '') { - const fileURL = `c=${targetLocation !== '' ? targetLocation : collectionUuid}/${file.name}`.replace('//', '/'); + private async uploadFile( + collectionUuid: string, + file: File, + fileId: number, + onProgress: UploadProgress = () => { + return; + }, + targetLocation: string = "" + ) { + const fileURL = `c=${targetLocation !== "" ? targetLocation : collectionUuid}/${file.name}`.replace("//", "/"); const requestConfig = { headers: { - 'Content-Type': 'text/octet-stream' + "Content-Type": "text/octet-stream", }, onUploadProgress: (e: ProgressEvent) => { onProgress(fileId, e.loaded, e.total, Date.now()); }, }; - return this.webdavClient.upload(fileURL, [file], requestConfig); + return this.keepWebdavClient.upload(fileURL, [file], requestConfig); + } + + deleteFiles(collectionUuid: string, files: string[], showErrors?: boolean) { + const optimizedFiles = files + .sort((a, b) => a.length - b.length) + .reduce((acc, currentPath) => { + const parentPathFound = acc.find(parentPath => currentPath.indexOf(`${parentPath}/`) > -1); + + if (!parentPathFound) { + return [...acc, currentPath]; + } + + return acc; + }, []); + + const fileMap = optimizedFiles.reduce((obj, filePath) => { + return { + ...obj, + [this.combineFilePath([filePath])]: "", + }; + }, {}); + + return this.replaceFiles({ uuid: collectionUuid }, fileMap, showErrors); + } + + copyFiles( + sourcePdh: string, + files: string[], + destinationCollection: CollectionPartialUpdateOrCreate, + destinationPath: string, + showErrors?: boolean + ) { + const fileMap = files.reduce((obj, sourceFile) => { + const fileBasename = sourceFile.split("/").filter(Boolean).slice(-1).join(""); + return { + ...obj, + [this.combineFilePath([destinationPath, fileBasename])]: `${sourcePdh}${this.combineFilePath([sourceFile])}`, + }; + }, {}); + + return this.replaceFiles(destinationCollection, fileMap, showErrors); + } + + moveFiles( + sourceUuid: string, + sourcePdh: string, + files: string[], + destinationCollection: CollectionPartialUpdateOrCreate, + destinationPath: string, + showErrors?: boolean + ) { + if (sourceUuid === destinationCollection.uuid) { + let errors: CommonResourceServiceError[] = []; + const fileMap = files.reduce((obj, sourceFile) => { + const fileBasename = sourceFile.split("/").filter(Boolean).slice(-1).join(""); + const fileDestinationPath = this.combineFilePath([destinationPath, fileBasename]); + const fileSourcePath = this.combineFilePath([sourceFile]); + const fileSourceUri = `${sourcePdh}${fileSourcePath}`; + + if (fileDestinationPath !== fileSourcePath) { + return { + ...obj, + [fileDestinationPath]: fileSourceUri, + [fileSourcePath]: "", + }; + } else { + errors.push(CommonResourceServiceError.SOURCE_DESTINATION_CANNOT_BE_SAME); + return obj; + } + }, {}); + + if (errors.length === 0) { + return this.replaceFiles({ uuid: sourceUuid }, fileMap, showErrors); + } else { + return Promise.reject({ errors }); + } + } else { + return this.copyFiles(sourcePdh, files, destinationCollection, destinationPath, showErrors).then(() => { + return this.deleteFiles(sourceUuid, files, showErrors); + }); + } + } + + createDirectory(collectionUuid: string, path: string, showErrors?: boolean) { + const fileMap = { [this.combineFilePath([path])]: emptyCollectionPdh }; + + return this.replaceFiles({ uuid: collectionUuid }, fileMap, showErrors); } } diff --git a/src/services/common-service/common-resource-service.test.ts b/src/services/common-service/common-resource-service.test.ts index b94756ae..7f47f20e 100644 --- a/src/services/common-service/common-resource-service.test.ts +++ b/src/services/common-service/common-resource-service.test.ts @@ -136,8 +136,9 @@ describe("CommonResourceService", () => { await commonResourceService.list({ filters: tooBig }); expect(axiosMock.history.get.length).toBe(0); expect(axiosMock.history.post.length).toBe(1); - expect(axiosMock.history.post[0].data.get('filters')).toBe(`[${tooBig}]`); - expect(axiosMock.history.post[0].params._method).toBe('GET'); + const postParams = new URLSearchParams(axiosMock.history.post[0].data); + expect(postParams.get('filters')).toBe(`[${tooBig}]`); + expect(postParams.get('_method')).toBe('GET'); }); it("#list using GET when query string is not too big", async () => { diff --git a/src/services/common-service/common-resource-service.ts b/src/services/common-service/common-resource-service.ts index c6306779..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,25 +24,31 @@ export class CommonResourceService extends CommonService super(serverApi, resourceType, actions, readOnlyFields.concat([ 'uuid', 'etag', - 'kind' + 'kind', + 'canWrite', + 'canManage', + 'createdAt', + 'modifiedAt', + 'modifiedByClientUuid', + 'modifiedByUserUuid' ])); } - create(data?: Partial) { + 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), }; } - return super.create(payload); + return super.create(payload, showErrors); } - update(uuid: string, data: Partial, select?: string[]) { + 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), }; @@ -48,12 +56,12 @@ export class CommonResourceService extends CommonService payload.select = ['uuid', ...select.map(field => snakeCase(field))]; }; } - return super.update(uuid, payload); + return super.update(uuid, payload, showErrors); } } export const getCommonResourceServiceError = (errorResponse: any) => { - if ('errors' in errorResponse && 'errorToken' in errorResponse) { + if (errorResponse && 'errors' in errorResponse) { const error = errorResponse.errors.join(''); switch (true) { case /UniqueViolation/.test(error): @@ -64,11 +72,13 @@ export const getCommonResourceServiceError = (errorResponse: any) => { return CommonResourceServiceError.MODIFYING_CONTAINER_REQUEST_FINAL_STATE; case /Name has already been taken/.test(error): return CommonResourceServiceError.NAME_HAS_ALREADY_BEEN_TAKEN; + case /403 Forbidden/.test(error): + return CommonResourceServiceError.PERMISSION_ERROR_FORBIDDEN; + case new RegExp(CommonResourceServiceError.SOURCE_DESTINATION_CANNOT_BE_SAME).test(error): + return CommonResourceServiceError.SOURCE_DESTINATION_CANNOT_BE_SAME; default: return CommonResourceServiceError.UNKNOWN; } } return CommonResourceServiceError.NONE; }; - - diff --git a/src/services/common-service/common-service.ts b/src/services/common-service/common-service.ts index f16a2024..8e9fe631 100644 --- a/src/services/common-service/common-service.ts +++ b/src/services/common-service/common-service.ts @@ -87,11 +87,13 @@ export class CommonService { return mapKeys ? CommonService.mapResponseKeys(response) : response.data; }) .catch(({ response }) => { - actions.progressFn(reqId, false); - const errors = CommonService.mapResponseKeys(response) as Errors; - errors.status = response.status; - actions.errorFn(reqId, errors, showErrors); - throw errors; + if (response) { + actions.progressFn(reqId, false); + const errors = CommonService.mapResponseKeys(response) as Errors; + errors.status = response.status; + actions.errorFn(reqId, errors, showErrors); + throw errors; + } }); } @@ -105,12 +107,14 @@ export class CommonService { ); } - 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 ); } @@ -152,11 +156,14 @@ export class CommonService { return CommonService.defaultResponse( this.serverApi.get(`/${this.resourceType}`, { params }), this.actions, + true, showErrors ); } else { // Using the POST special case to avoid URI length 414 errors. - const formData = new FormData(); + // We must use urlencoded post body since api doesn't support form data + // const formData = new FormData(); + const formData = new URLSearchParams(); formData.append("_method", "GET"); Object.keys(params).forEach(key => { if (params[key] !== undefined) { @@ -164,23 +171,22 @@ export class CommonService { } }); return CommonService.defaultResponse( - this.serverApi.post(`/${this.resourceType}`, formData, { - params: { - _method: 'GET' - } - }), + this.serverApi.post(`/${this.resourceType}`, formData, {}), this.actions, + true, showErrors ); } } - update(uuid: string, data: Partial) { + update(uuid: string, data: Partial, showErrors?: boolean) { this.validateUuid(uuid); return CommonService.defaultResponse( this.serverApi .put(`/${this.resourceType}/${uuid}`, data && CommonService.mapKeys(snakeCase)(data)), - this.actions + this.actions, + undefined, // 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 dc6a798c..b9f47df0 100644 --- a/src/services/groups-service/groups-service.ts +++ b/src/services/groups-service/groups-service.ts @@ -2,18 +2,22 @@ // // SPDX-License-Identifier: AGPL-3.0 +import { CancelToken } from 'axios'; import { snakeCase, camelCase } from "lodash"; import { CommonResourceService } from 'services/common-service/common-resource-service'; -import { ListResults, ListArguments } from 'services/common-service/common-service'; -import { AxiosInstance, AxiosRequestConfig } from "axios"; -import { CollectionResource } from "models/collection"; -import { ProjectResource } from "models/project"; -import { ProcessResource } from "models/process"; -import { WorkflowResource } from "models/workflow"; -import { TrashableResourceService } from "services/common-service/trashable-resource-service"; -import { ApiActions } from "services/api/api-actions"; -import { GroupResource } from "models/group"; -import { Session } from "models/session"; +import { + ListResults, + ListArguments, +} from 'services/common-service/common-service'; +import { AxiosInstance, AxiosRequestConfig } from 'axios'; +import { CollectionResource } from 'models/collection'; +import { ProjectResource } from 'models/project'; +import { ProcessResource } from 'models/process'; +import { WorkflowResource } from 'models/workflow'; +import { TrashableResourceService } from 'services/common-service/trashable-resource-service'; +import { ApiActions } from 'services/api/api-actions'; +import { GroupResource } from 'models/group'; +import { Session } from 'models/session'; export interface ContentsArguments { limit?: number; @@ -23,6 +27,7 @@ export interface ContentsArguments { recursive?: boolean; includeTrash?: boolean; excludeHomeProject?: boolean; + select?: string[]; } export interface SharedArguments extends ListArguments { @@ -30,51 +35,70 @@ export interface SharedArguments extends ListArguments { } export type GroupContentsResource = - CollectionResource | - ProjectResource | - ProcessResource | - WorkflowResource; - -export class GroupsService extends TrashableResourceService { + | CollectionResource + | ProjectResource + | ProcessResource + | WorkflowResource; +export class GroupsService< + T extends GroupResource = GroupResource + > extends TrashableResourceService { constructor(serverApi: AxiosInstance, actions: ApiActions) { - super(serverApi, "groups", actions); + super(serverApi, 'groups', actions); } - async contents(uuid: string, args: ContentsArguments = {}, session?: Session): Promise> { - const { filters, order, ...other } = args; + 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 + order: order ? order : undefined, + select: select + ? JSON.stringify(select.map(sel => { + const sp = sel.split("."); + return sp.length === 2 ? (sp[0] + "." + snakeCase(sp[1])) : snakeCase(sel); + })) + : undefined + }; + const pathUrl = (uuid !== '') ? `/${uuid}/contents` : '/contents'; + const cfg: AxiosRequestConfig = { + params: CommonResourceService.mapKeys(snakeCase)(params), }; - const pathUrl = uuid ? `/${uuid}/contents` : '/contents'; - const cfg: AxiosRequestConfig = { params: CommonResourceService.mapKeys(snakeCase)(params) }; if (session) { cfg.baseURL = session.baseUrl; - cfg.headers = { 'Authorization': 'Bearer ' + session.token }; + cfg.headers = { Authorization: 'Bearer ' + session.token }; + } + + if (cancelToken) { + cfg.cancelToken = cancelToken; } const response = await CommonResourceService.defaultResponse( - this.serverApi.get(this.resourceType + pathUrl, cfg), this.actions, false + this.serverApi.get(this.resourceType + pathUrl, cfg), + this.actions, + false ); - return { ...TrashableResourceService.mapKeys(camelCase)(response), clusterId: session && session.clusterId }; + return { + ...TrashableResourceService.mapKeys(camelCase)(response), + clusterId: session && session.clusterId, + }; } - shared(params: SharedArguments = {}): Promise> { + shared( + params: SharedArguments = {} + ): Promise> { return CommonResourceService.defaultResponse( - this.serverApi - .get(this.resourceType + '/shared', { params }), + this.serverApi.get(this.resourceType + '/shared', { params }), this.actions ); } } export enum GroupContentsResourcePrefix { - COLLECTION = "collections", - PROJECT = "groups", - PROCESS = "container_requests", - WORKFLOW = "workflows" + COLLECTION = 'collections', + PROJECT = 'groups', + PROCESS = 'container_requests', + WORKFLOW = 'workflows', } diff --git a/src/services/log-service/log-service.test.ts b/src/services/log-service/log-service.test.ts new file mode 100644 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/project-service/project-service.ts b/src/services/project-service/project-service.ts index 07b083fd..442a6ab9 100644 --- a/src/services/project-service/project-service.ts +++ b/src/services/project-service/project-service.ts @@ -9,9 +9,9 @@ import { ListArguments } from "services/common-service/common-service"; import { FilterBuilder, joinFilters } from "services/api/filter-builder"; export class ProjectService extends GroupsService { - create(data: Partial) { + create(data: Partial, showErrors?: boolean) { const projectData = { ...data, groupClass: GroupClass.PROJECT }; - return super.create(projectData); + return super.create(projectData, showErrors); } list(args: ListArguments = {}) { diff --git a/src/services/services.ts b/src/services/services.ts index 2afb843f..cd04a65f 100644 --- a/src/services/services.ts +++ b/src/services/services.ts @@ -39,14 +39,14 @@ export function setAuthorizationHeader(services: ServiceRepository, token: strin services.apiClient.defaults.headers.common = { Authorization: `Bearer ${token}` }; - services.webdavClient.defaults.headers = { - Authorization: `Bearer ${token}` - }; + services.keepWebdavClient.setAuthorization(`Bearer ${token}`); + services.apiWebdavClient.setAuthorization(`Bearer ${token}`); } export function removeAuthorizationHeader(services: ServiceRepository) { delete services.apiClient.defaults.headers.common; - delete services.webdavClient.defaults.headers.common; + services.keepWebdavClient.setAuthorization(undefined); + services.apiWebdavClient.setAuthorization(undefined); } export const createServices = (config: Config, actions: ApiActions, useApiClient?: AxiosInstance) => { @@ -57,8 +57,13 @@ export const createServices = (config: Config, actions: ApiActions, useApiClient const apiClient = useApiClient || Axios.create({ headers: {} }); apiClient.defaults.baseURL = config.baseUrl; - const webdavClient = new WebDAV(); - webdavClient.defaults.baseURL = config.keepWebServiceUrl; + const keepWebdavClient = new WebDAV({ + baseURL: config.keepWebServiceUrl + }); + + const apiWebdavClient = new WebDAV({ + baseURL: config.baseUrl + }); const apiClientAuthorizationService = new ApiClientAuthorizationService(apiClient, actions); const authorizedKeysService = new AuthorizedKeysService(apiClient, actions); @@ -67,7 +72,7 @@ export const createServices = (config: Config, actions: ApiActions, useApiClient const groupsService = new GroupsService(apiClient, actions); const keepService = new KeepService(apiClient, actions); const linkService = new LinkService(apiClient, actions); - const logService = new LogService(apiClient, actions); + const logService = new LogService(apiClient, apiWebdavClient, actions); const permissionService = new PermissionService(apiClient, actions); const projectService = new ProjectService(apiClient, actions); const repositoriesService = new RepositoriesService(apiClient, actions); @@ -76,13 +81,12 @@ export const createServices = (config: Config, actions: ApiActions, useApiClient const workflowService = new WorkflowService(apiClient, actions); const linkAccountService = new LinkAccountService(apiClient, actions); - const ancestorsService = new AncestorService(groupsService, userService); - const idleTimeout = (config && config.clusterConfig && config.clusterConfig.Workbench.IdleTimeout) || '0s'; const authService = new AuthService(apiClient, config.rootUrl, actions, (parse(idleTimeout, 's') || 0) > 0); - const collectionService = new CollectionService(apiClient, webdavClient, authService, actions); + const collectionService = new CollectionService(apiClient, keepWebdavClient, authService, actions); + const ancestorsService = new AncestorService(groupsService, userService, collectionService); const favoriteService = new FavoriteService(linkService, groupsService); const tagService = new TagService(linkService); const searchService = new SearchService(); @@ -111,7 +115,8 @@ export const createServices = (config: Config, actions: ApiActions, useApiClient tagService, userService, virtualMachineService, - webdavClient, + keepWebdavClient, + apiWebdavClient, workflowService, vocabularyService, linkAccountService 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 61fd705a..fedd5518 100644 --- a/src/store/advanced-tab/advanced-tab.tsx +++ b/src/store/advanced-tab/advanced-tab.tsx @@ -20,14 +20,16 @@ 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'; export const ADVANCED_TAB_DIALOG = 'advancedTabDialog'; -interface AdvancedTabDialogData { - apiResponse: any; +export interface AdvancedTabDialogData { + uuid: string; + apiResponse: JSX.Element; metadata: ListResults | string; user: UserResource | string; pythonHeader: string; @@ -100,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) => { @@ -266,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 })); } @@ -290,7 +314,7 @@ interface AdvancedTabData { uuid: string; metadata: ListResults | string; user: UserResource | string; - apiResponseKind: any; + apiResponseKind: (apiResponse) => JSX.Element; data: AdvanceResponseData; resourceKind: AdvanceResourceKind; resourcePrefix: AdvanceResourcePrefix; @@ -370,7 +394,7 @@ const stringify = (item: string | null | number | boolean) => const stringifyObject = (item: any) => JSON.stringify(item, null, 2) || 'null'; -const containerRequestApiResponse = (apiResponse: ContainerRequestResource) => { +const containerRequestApiResponse = (apiResponse: ContainerRequestResource): JSX.Element => { const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, description, properties, state, requestingContainerUuid, containerUuid, containerCountMax, mounts, runtimeConstraints, containerImage, environment, cwd, command, outputPath, priority, expiresAt, filters, containerCount, useExisting, schedulingParameters, outputUuid, logUuid, outputName, outputTtl } = apiResponse; @@ -409,7 +433,7 @@ const containerRequestApiResponse = (apiResponse: ContainerRequestResource) => { return {'{'} {response} {'\n'} {'}'}; }; -const collectionApiResponse = (apiResponse: CollectionResource) => { +const collectionApiResponse = (apiResponse: CollectionResource): JSX.Element => { const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, description, properties, portableDataHash, replicationDesired, replicationConfirmedAt, replicationConfirmed, deleteAt, trashAt, isTrashed, storageClassesDesired, storageClassesConfirmed, storageClassesConfirmedAt, currentVersionUuid, version, preserveVersion, fileCount, fileSizeTotal } = apiResponse; @@ -442,8 +466,10 @@ const collectionApiResponse = (apiResponse: CollectionResource) => { return {'{'} {response} {'\n'} {'}'}; }; -const groupRequestApiResponse = (apiResponse: ProjectResource) => { - const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, description, groupClass, trashAt, isTrashed, deleteAt, properties, writableBy } = apiResponse; +const groupRequestApiResponse = (apiResponse: ProjectResource): JSX.Element => { + const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, + description, groupClass, trashAt, isTrashed, deleteAt, properties, + canWrite, canManage } = apiResponse; const response = ` "uuid": "${uuid}", "owner_uuid": "${ownerUuid}", @@ -458,12 +484,13 @@ const groupRequestApiResponse = (apiResponse: ProjectResource) => { "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'} {'}'}; }; -const repositoryApiResponse = (apiResponse: RepositoryResource) => { +const repositoryApiResponse = (apiResponse: RepositoryResource): JSX.Element => { const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, cloneUrls } = apiResponse; const response = ` "uuid": "${uuid}", @@ -478,7 +505,7 @@ const repositoryApiResponse = (apiResponse: RepositoryResource) => { return {'{'} {response} {'\n'} {'}'}; }; -const sshKeyApiResponse = (apiResponse: SshKeyResource) => { +const sshKeyApiResponse = (apiResponse: SshKeyResource): JSX.Element => { const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, authorizedUserUuid, expiresAt } = apiResponse; const response = ` "uuid": "${uuid}", @@ -493,7 +520,7 @@ const sshKeyApiResponse = (apiResponse: SshKeyResource) => { return {'{'} {response} {'\n'} {'}'}; }; -const virtualMachineApiResponse = (apiResponse: VirtualMachinesResource) => { +const virtualMachineApiResponse = (apiResponse: VirtualMachinesResource): JSX.Element => { const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, hostname } = apiResponse; const response = ` "hostname": ${stringify(hostname)}, @@ -508,7 +535,7 @@ const virtualMachineApiResponse = (apiResponse: VirtualMachinesResource) => { return {'{'} {response} {'\n'} {'}'}; }; -const keepServiceApiResponse = (apiResponse: KeepServiceResource) => { +const keepServiceApiResponse = (apiResponse: KeepServiceResource): JSX.Element => { const { uuid, readOnly, serviceHost, servicePort, serviceSslFlag, serviceType, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid @@ -529,7 +556,7 @@ const keepServiceApiResponse = (apiResponse: KeepServiceResource) => { return {'{'} {response} {'\n'} {'}'}; }; -const userApiResponse = (apiResponse: UserResource) => { +const userApiResponse = (apiResponse: UserResource): JSX.Element => { const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, email, firstName, lastName, username, isActive, isAdmin, prefs, defaultOwnerUuid, @@ -554,7 +581,7 @@ const userApiResponse = (apiResponse: UserResource) => { return {'{'} {response} {'\n'} {'}'}; }; -const apiClientAuthorizationApiResponse = (apiResponse: ApiClientAuthorization) => { +const apiClientAuthorizationApiResponse = (apiResponse: ApiClientAuthorization): JSX.Element => { const { uuid, ownerUuid, apiToken, apiClientId, userId, createdByIpAddress, lastUsedByIpAddress, lastUsedAt, expiresAt, defaultOwnerUuid, scopes, updatedAt, createdAt @@ -577,7 +604,7 @@ const apiClientAuthorizationApiResponse = (apiResponse: ApiClientAuthorization) return {'{'} {response} {'\n'} {'}'}; }; -const linkApiResponse = (apiResponse: LinkResource) => { +const linkApiResponse = (apiResponse: LinkResource): JSX.Element => { const { uuid, name, headUuid, properties, headKind, tailUuid, tailKind, linkClass, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid @@ -599,3 +626,22 @@ const linkApiResponse = (apiResponse: LinkResource) => { 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-action.ts b/src/store/all-processes-panel/all-processes-panel-action.ts index 3d30eaec..c33cd823 100644 --- a/src/store/all-processes-panel/all-processes-panel-action.ts +++ b/src/store/all-processes-panel/all-processes-panel-action.ts @@ -2,9 +2,13 @@ // // SPDX-License-Identifier: AGPL-3.0 +import { Dispatch } from "redux"; import { bindDataExplorerActions } from "../data-explorer/data-explorer-action"; export const ALL_PROCESSES_PANEL_ID = "allProcessesPanel"; export const allProcessesPanelActions = bindDataExplorerActions(ALL_PROCESSES_PANEL_ID); -export const loadAllProcessesPanel = () => allProcessesPanelActions.REQUEST_ITEMS(); +export const loadAllProcessesPanel = () => (dispatch: Dispatch) => { + dispatch(allProcessesPanelActions.RESET_EXPLORER_SEARCH_VALUE()); + dispatch(allProcessesPanelActions.REQUEST_ITEMS()); +} 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 88b64e62..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 @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { DataExplorerMiddlewareService, dataExplorerToListParams, getDataExplorerColumnFilters } from "store/data-explorer/data-explorer-middleware-service"; +import { DataExplorerMiddlewareService, dataExplorerToListParams, getDataExplorerColumnFilters, getOrder } from "store/data-explorer/data-explorer-middleware-service"; import { RootState } from "../store"; import { ServiceRepository } from "services/services"; import { FilterBuilder, joinFilters } from "services/api/filter-builder"; @@ -11,7 +11,7 @@ import { Dispatch, MiddlewareAPI } from "redux"; import { resourcesActions } from "store/resources/resources-actions"; import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions'; -import { getDataExplorer, DataExplorer, getSortColumn } from "store/data-explorer/data-explorer-reducer"; +import { getDataExplorer, DataExplorer } from "store/data-explorer/data-explorer-reducer"; import { loadMissingProcessesInformation } from "store/project-panel/project-panel-middleware-service"; import { DataColumns } from "components/data-table/data-table"; import { @@ -20,26 +20,28 @@ import { serializeOnlyProcessTypeFilters } from "../resource-type-filters/resource-type-filters"; import { AllProcessesPanelColumnNames } from "views/all-processes-panel/all-processes-panel"; -import { OrderBuilder, OrderDirection } from "services/api/order-builder"; -import { ProcessResource } from "models/process"; -import { SortDirection } from "components/data-table/data-column"; +import { containerRequestFieldsNoMounts, ContainerRequestResource } from "models/container-request"; export class AllProcessesPanelMiddlewareService extends DataExplorerMiddlewareService { constructor(private services: ServiceRepository, id: string) { super(id); } - async requestItems(api: MiddlewareAPI) { + 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) }); + { + ...getParams(dataExplorer), + // Omit mounts when viewing all process panel + 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({ @@ -49,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, @@ -62,14 +64,14 @@ export class AllProcessesPanelMiddlewareService extends DataExplorerMiddlewareSe } } -const getParams = ( dataExplorer: DataExplorer ) => ({ +const getParams = (dataExplorer: DataExplorer) => ({ ...dataExplorerToListParams(dataExplorer), - order: getOrder(dataExplorer), + order: getOrder(dataExplorer), filters: getFilters(dataExplorer) }); -const getFilters = ( dataExplorer: DataExplorer ) => { - const columns = dataExplorer.columns as DataColumns; +const getFilters = (dataExplorer: DataExplorer) => { + const columns = dataExplorer.columns as DataColumns; const statusColumnFilters = getDataExplorerColumnFilters(columns, 'Status'); const activeStatusFilter = Object.keys(statusColumnFilters).find( filterName => statusColumnFilters[filterName].selected @@ -86,23 +88,6 @@ const getFilters = ( dataExplorer: DataExplorer ) => { ); }; -const getOrder = (dataExplorer: DataExplorer) => { - const sortColumn = getSortColumn(dataExplorer); - const order = new OrderBuilder(); - if (sortColumn) { - const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC - ? OrderDirection.ASC - : OrderDirection.DESC; - - const columnName = sortColumn && sortColumn.name === AllProcessesPanelColumnNames.NAME ? "name" : "createdAt"; - return order - .addOrder(sortDirection, columnName) - .getOrder(); - } else { - return order.getOrder(); - } -}; - const allProcessesPanelDataExplorerIsNotSet = () => snackbarActions.OPEN_SNACKBAR({ message: 'All Processes panel is not ready.', diff --git a/src/store/api-client-authorizations/api-client-authorizations-middleware-service.ts b/src/store/api-client-authorizations/api-client-authorizations-middleware-service.ts index d67dcbad..9ab02549 100644 --- a/src/store/api-client-authorizations/api-client-authorizations-middleware-service.ts +++ b/src/store/api-client-authorizations/api-client-authorizations-middleware-service.ts @@ -4,18 +4,14 @@ import { ServiceRepository } from 'services/services'; import { MiddlewareAPI, Dispatch } from 'redux'; -import { DataExplorerMiddlewareService, dataExplorerToListParams, listResultsToDataExplorerItemsMeta } from 'store/data-explorer/data-explorer-middleware-service'; +import { DataExplorerMiddlewareService, dataExplorerToListParams, getOrder, listResultsToDataExplorerItemsMeta } from 'store/data-explorer/data-explorer-middleware-service'; import { RootState } from 'store/store'; import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; import { DataExplorer, getDataExplorer } from 'store/data-explorer/data-explorer-reducer'; import { updateResources } from 'store/resources/resources-actions'; -import { getSortColumn } from "store/data-explorer/data-explorer-reducer"; import { apiClientAuthorizationsActions } from 'store/api-client-authorizations/api-client-authorizations-actions'; -import { OrderDirection, OrderBuilder } from 'services/api/order-builder'; import { ListResults } from 'services/common-service/common-service'; import { ApiClientAuthorization } from 'models/api-client-authorization'; -import { ApiClientAuthorizationPanelColumnNames } from 'views/api-client-authorization-panel/api-client-authorization-panel-root'; -import { SortDirection } from 'components/data-table/data-column'; export class ApiClientAuthorizationMiddlewareService extends DataExplorerMiddlewareService { constructor(private services: ServiceRepository, id: string) { @@ -37,26 +33,9 @@ export class ApiClientAuthorizationMiddlewareService extends DataExplorerMiddlew export const getParams = (dataExplorer: DataExplorer) => ({ ...dataExplorerToListParams(dataExplorer), - order: getOrder(dataExplorer) + order: getOrder(dataExplorer) }); -const getOrder = (dataExplorer: DataExplorer) => { - const sortColumn = getSortColumn(dataExplorer); - const order = new OrderBuilder(); - if (sortColumn) { - const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC - ? OrderDirection.ASC - : OrderDirection.DESC; - - const columnName = sortColumn && sortColumn.name === ApiClientAuthorizationPanelColumnNames.UUID ? "uuid" : "updatedAt"; - return order - .addOrder(sortDirection, columnName) - .getOrder(); - } else { - return order.getOrder(); - } -}; - export const setItems = (listResults: ListResults) => apiClientAuthorizationsActions.SET_ITEMS({ ...listResultsToDataExplorerItemsMeta(listResults), 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-action.ts b/src/store/auth/auth-action.ts index 7fc9df77..145a461c 100644 --- a/src/store/auth/auth-action.ts +++ b/src/store/auth/auth-action.ts @@ -20,7 +20,7 @@ import { getTokenV2 } from 'models/api-client-authorization'; export const authActions = unionize({ LOGIN: {}, - LOGOUT: ofType<{ deleteLinkData: boolean }>(), + LOGOUT: ofType<{ deleteLinkData: boolean, preservePath: boolean }>(), SET_CONFIG: ofType<{ config: Config }>(), SET_EXTRA_TOKEN: ofType<{ extraApiToken: string, extraApiTokenExpiration?: Date }>(), RESET_EXTRA_TOKEN: {}, @@ -92,6 +92,9 @@ export const saveApiToken = (token: string) => async (dispatch: Dispatch, getSta // If the token is from a LoginCluster federation, get user & token data // from the token issuing cluster. + if (!config) { + return; + } const lc = (config as Config).loginCluster const tokenCluster = tokenParts.length === 3 ? tokenParts[1].substring(0, 5) @@ -110,7 +113,7 @@ export const saveApiToken = (token: string) => async (dispatch: Dispatch, getSta const tokenLocation = await svc.authService.getStorageType(); dispatch(authActions.INIT_USER({ user, token, tokenExpiration, tokenLocation })); } catch (e) { - dispatch(authActions.LOGOUT({ deleteLinkData: false })); + dispatch(authActions.LOGOUT({ deleteLinkData: false, preservePath: false })); } }; @@ -127,7 +130,7 @@ export const getNewExtraToken = (reuseStored: boolean = false) => const client = await svc.apiClientAuthorizationService.get('current'); dispatch(authActions.SET_EXTRA_TOKEN({ extraApiToken: extraToken, - extraApiTokenExpiration: client.expiresAt ? new Date(client.expiresAt): undefined, + extraApiTokenExpiration: client.expiresAt ? new Date(client.expiresAt) : undefined, })); return extraToken; } catch (e) { @@ -145,7 +148,7 @@ export const getNewExtraToken = (reuseStored: boolean = false) => const newExtraToken = getTokenV2(client); dispatch(authActions.SET_EXTRA_TOKEN({ extraApiToken: newExtraToken, - extraApiTokenExpiration: client.expiresAt ? new Date(client.expiresAt): undefined, + extraApiTokenExpiration: client.expiresAt ? new Date(client.expiresAt) : undefined, })); return newExtraToken; } catch { @@ -160,8 +163,8 @@ export const login = (uuidPrefix: string, homeCluster: string, loginCluster: str dispatch(authActions.LOGIN()); }; -export const logout = (deleteLinkData: boolean = false) => +export const logout = (deleteLinkData: boolean = false, preservePath: boolean = false) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => - dispatch(authActions.LOGOUT({ deleteLinkData })); + dispatch(authActions.LOGOUT({ deleteLinkData, preservePath })) export type AuthAction = UnionOf; diff --git a/src/store/auth/auth-middleware.test.ts b/src/store/auth/auth-middleware.test.ts index 9ded9e67..5a0364eb 100644 --- a/src/store/auth/auth-middleware.test.ts +++ b/src/store/auth/auth-middleware.test.ts @@ -36,10 +36,10 @@ describe("AuthMiddleware", () => { window.location.assign = jest.fn(); const next = jest.fn(); const middleware = authMiddleware(services)(store)(next); - middleware(authActions.LOGOUT({deleteLinkData: false})); + middleware(authActions.LOGOUT({deleteLinkData: false, preservePath: false})); expect(window.location.assign).toBeCalledWith( `/logout?api_token=someToken&return_to=${location.protocol}//${location.host}` ); expect(localStorage.getItem(API_TOKEN_KEY)).toBeFalsy(); }); -}); \ No newline at end of file +}); diff --git a/src/store/auth/auth-middleware.ts b/src/store/auth/auth-middleware.ts index 87a1253b..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,10 +66,10 @@ export const authMiddleware = (services: ServiceRepository): Middleware => store } }, SET_CONFIG: ({ config }) => { - document.title = `Arvados Workbench (${config.uuidPrefix})`; + document.title = `Arvados (${config.uuidPrefix})`; next(action); }, - LOGOUT: ({ deleteLinkData }) => { + LOGOUT: ({ deleteLinkData, preservePath }) => { next(action); if (deleteLinkData) { services.linkAccountService.removeAccountToLink(); @@ -69,7 +79,7 @@ export const authMiddleware = (services: ServiceRepository): Middleware => store services.authService.removeSessions(); services.authService.removeUser(); removeAuthorizationHeader(services); - services.authService.logout(token || ''); + services.authService.logout(token || '', preservePath); }, default: () => next(action) }); diff --git a/src/store/banner/banner-action.ts b/src/store/banner/banner-action.ts new file mode 100644 index 00000000..808ca822 --- /dev/null +++ b/src/store/banner/banner-action.ts @@ -0,0 +1,29 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Dispatch } from "redux"; +import { RootState } from "store/store"; +import { unionize, UnionOf } from 'common/unionize'; + +export const bannerReducerActions = unionize({ + OPEN_BANNER: {}, + CLOSE_BANNER: {}, +}); + +export type BannerAction = UnionOf; + +export const openBanner = () => + async (dispatch: Dispatch, getState: () => RootState) => { + dispatch(bannerReducerActions.OPEN_BANNER()); + }; + +export const closeBanner = () => + async (dispatch: Dispatch, getState: () => RootState) => { + dispatch(bannerReducerActions.CLOSE_BANNER()); + }; + +export default { + openBanner, + closeBanner +}; diff --git a/src/store/banner/banner-reducer.ts b/src/store/banner/banner-reducer.ts new file mode 100644 index 00000000..8009f4b2 --- /dev/null +++ b/src/store/banner/banner-reducer.ts @@ -0,0 +1,26 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { BannerAction, bannerReducerActions } from "./banner-action"; + +export interface BannerState { + isOpen: boolean; +} + +const initialState = { + isOpen: false, +}; + +export const bannerReducer = (state: BannerState = initialState, action: BannerAction) => + bannerReducerActions.match(action, { + default: () => state, + OPEN_BANNER: () => ({ + ...state, + isOpen: true, + }), + CLOSE_BANNER: () => ({ + ...state, + isOpen: false, + }), + }); diff --git a/src/store/breadcrumbs/breadcrumbs-actions.ts b/src/store/breadcrumbs/breadcrumbs-actions.ts index 08e1a132..9aebeb90 100644 --- a/src/store/breadcrumbs/breadcrumbs-actions.ts +++ b/src/store/breadcrumbs/breadcrumbs-actions.ts @@ -5,10 +5,7 @@ import { Dispatch } from 'redux'; import { RootState } from 'store/store'; import { getUserUuid } from "common/getuser"; -import { Breadcrumb } from 'components/breadcrumbs/breadcrumbs'; import { getResource } from 'store/resources/resources'; -import { TreePicker } from '../tree-picker/tree-picker'; -import { getSidePanelTreeBranch, getSidePanelTreeNodeAncestorsIds } from '../side-panel-tree/side-panel-tree-actions'; import { propertiesActions } from '../properties/properties-actions'; import { getProcess } from 'store/processes/process'; import { ServiceRepository } from 'services/services'; @@ -18,46 +15,108 @@ import { ResourceKind } from 'models/resource'; import { GroupResource } from 'models/group'; import { extractUuidKind } from 'models/resource'; import { UserResource } from 'models/user'; +import { FilterBuilder } from 'services/api/filter-builder'; +import { ProcessResource } from 'models/process'; +import { OrderBuilder } from 'services/api/order-builder'; +import { Breadcrumb } from 'components/breadcrumbs/breadcrumbs'; +import { ContainerRequestResource, containerRequestFieldsNoMounts } from 'models/container-request'; +import { CollectionIcon, IconType, ProcessIcon, ProjectIcon, WorkflowIcon } from 'components/icon/icon'; +import { CollectionResource } from 'models/collection'; +import { getSidePanelIcon } from 'views-components/side-panel-tree/side-panel-tree'; +import { WorkflowResource } from 'models/workflow'; +import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions"; export const BREADCRUMBS = 'breadcrumbs'; -export interface ResourceBreadcrumb extends Breadcrumb { - uuid: string; -} - -export const setBreadcrumbs = (breadcrumbs: any, currentItem?: any) => { +export const setBreadcrumbs = (breadcrumbs: any, currentItem?: CollectionResource | ContainerRequestResource | GroupResource | WorkflowResource) => { if (currentItem) { - const addLastItem = { label: currentItem.name, uuid: currentItem.uuid }; - breadcrumbs.push(addLastItem); + breadcrumbs.push(resourceToBreadcrumb(currentItem)); } return propertiesActions.SET_PROPERTY({ key: BREADCRUMBS, value: breadcrumbs }); }; +const resourceToBreadcrumbIcon = (resource: CollectionResource | ContainerRequestResource | GroupResource | WorkflowResource): IconType | undefined => { + switch (resource.kind) { + case ResourceKind.PROJECT: + return ProjectIcon; + case ResourceKind.PROCESS: + return ProcessIcon; + case ResourceKind.COLLECTION: + return CollectionIcon; + case ResourceKind.WORKFLOW: + return WorkflowIcon; + default: + return undefined; + } +} -const getSidePanelTreeBreadcrumbs = (uuid: string) => (treePicker: TreePicker): ResourceBreadcrumb[] => { - const nodes = getSidePanelTreeBranch(uuid)(treePicker); - return nodes.map(node => - typeof node.value === 'string' - ? { label: node.value, uuid: node.id } - : { label: node.value.name, uuid: node.value.uuid }); -}; +const resourceToBreadcrumb = (resource: CollectionResource | ContainerRequestResource | GroupResource | WorkflowResource): Breadcrumb => ({ + label: resource.name, + uuid: resource.uuid, + icon: resourceToBreadcrumbIcon(resource), +}) export const setSidePanelBreadcrumbs = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const { treePicker, collectionPanel: { item } } = getState(); - const breadcrumbs = getSidePanelTreeBreadcrumbs(uuid)(treePicker); - const path = getState().router.location!.pathname; - const currentUuid = path.split('/')[2]; - const uuidKind = extractUuidKind(currentUuid); - - if (uuidKind === ResourceKind.COLLECTION) { - const collectionItem = item ? item : await services.collectionService.get(currentUuid); - dispatch(setBreadcrumbs(breadcrumbs, collectionItem)); - } else if (uuidKind === ResourceKind.PROCESS) { - const processItem = await services.containerRequestService.get(currentUuid); - dispatch(setBreadcrumbs(breadcrumbs, processItem)); + try { + dispatch(progressIndicatorActions.START_WORKING(uuid + "-breadcrumbs")); + const ancestors = await services.ancestorsService.ancestors(uuid, ''); + dispatch(updateResources(ancestors)); + + let breadcrumbs: Breadcrumb[] = []; + const { collectionPanel: { item } } = getState(); + + const path = getState().router.location!.pathname; + const currentUuid = path.split('/')[2]; + const uuidKind = extractUuidKind(currentUuid); + const rootUuid = getUserUuid(getState()); + + if (ancestors.find(ancestor => ancestor.uuid === rootUuid)) { + // Handle home project uuid root + breadcrumbs.push({ + label: SidePanelTreeCategory.PROJECTS, + uuid: SidePanelTreeCategory.PROJECTS, + icon: getSidePanelIcon(SidePanelTreeCategory.PROJECTS) + }); + } else if (Object.values(SidePanelTreeCategory).includes(uuid as SidePanelTreeCategory)) { + // Handle SidePanelTreeCategory root + breadcrumbs.push({ + label: uuid, + uuid: uuid, + icon: getSidePanelIcon(uuid) + }); + } + + breadcrumbs = ancestors.reduce((breadcrumbs, ancestor) => + ancestor.kind === ResourceKind.GROUP + ? [...breadcrumbs, resourceToBreadcrumb(ancestor)] + : breadcrumbs, + breadcrumbs); + + if (uuidKind === ResourceKind.COLLECTION) { + const collectionItem = item ? item : await services.collectionService.get(currentUuid); + const parentProcessItem = await getCollectionParent(collectionItem)(services); + if (parentProcessItem) { + const mainProcessItem = await getProcessParent(parentProcessItem)(services); + mainProcessItem && breadcrumbs.push(resourceToBreadcrumb(mainProcessItem)); + breadcrumbs.push(resourceToBreadcrumb(parentProcessItem)); + } + dispatch(setBreadcrumbs(breadcrumbs, collectionItem)); + } else if (uuidKind === ResourceKind.PROCESS) { + const processItem = await services.containerRequestService.get(currentUuid); + const parentProcessItem = await getProcessParent(processItem)(services); + if (parentProcessItem) { + breadcrumbs.push(resourceToBreadcrumb(parentProcessItem)); + } + dispatch(setBreadcrumbs(breadcrumbs, processItem)); + } else if (uuidKind === ResourceKind.WORKFLOW) { + const workflowItem = await services.workflowService.get(currentUuid); + dispatch(setBreadcrumbs(breadcrumbs, workflowItem)); + } + dispatch(setBreadcrumbs(breadcrumbs)); + } finally { + dispatch(progressIndicatorActions.STOP_WORKING(uuid + "-breadcrumbs")); } - dispatch(setBreadcrumbs(breadcrumbs)); }; export const setSharedWithMeBreadcrumbs = (uuid: string) => @@ -68,35 +127,96 @@ export const setTrashBreadcrumbs = (uuid: string) => export const setCategoryBreadcrumbs = (uuid: string, category: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const ancestors = await services.ancestorsService.ancestors(uuid, ''); - dispatch(updateResources(ancestors)); - const initialBreadcrumbs: ResourceBreadcrumb[] = [ - { label: category, uuid: category } - ]; - const { collectionPanel: { item } } = getState(); - const path = getState().router.location!.pathname; - const currentUuid = path.split('/')[2]; - const uuidKind = extractUuidKind(currentUuid); - const breadcrumbs = ancestors.reduce((breadcrumbs, ancestor) => - ancestor.kind === ResourceKind.GROUP - ? [...breadcrumbs, { label: ancestor.name, uuid: ancestor.uuid }] - : breadcrumbs, - initialBreadcrumbs); - if (uuidKind === ResourceKind.COLLECTION) { - const collectionItem = item ? item : await services.collectionService.get(currentUuid); - dispatch(setBreadcrumbs(breadcrumbs, collectionItem)); - } else if (uuidKind === ResourceKind.PROCESS) { - const processItem = await services.containerRequestService.get(currentUuid); - dispatch(setBreadcrumbs(breadcrumbs, processItem)); + try { + dispatch(progressIndicatorActions.START_WORKING(uuid + "-breadcrumbs")); + const ancestors = await services.ancestorsService.ancestors(uuid, ''); + dispatch(updateResources(ancestors)); + const initialBreadcrumbs: Breadcrumb[] = [ + { + label: category, + uuid: category, + icon: getSidePanelIcon(category) + } + ]; + const { collectionPanel: { item } } = getState(); + const path = getState().router.location!.pathname; + const currentUuid = path.split('/')[2]; + const uuidKind = extractUuidKind(currentUuid); + let breadcrumbs = ancestors.reduce((breadcrumbs, ancestor) => + ancestor.kind === ResourceKind.GROUP + ? [...breadcrumbs, resourceToBreadcrumb(ancestor)] + : breadcrumbs, + initialBreadcrumbs); + if (uuidKind === ResourceKind.COLLECTION) { + const collectionItem = item ? item : await services.collectionService.get(currentUuid); + const parentProcessItem = await getCollectionParent(collectionItem)(services); + if (parentProcessItem) { + const mainProcessItem = await getProcessParent(parentProcessItem)(services); + mainProcessItem && breadcrumbs.push(resourceToBreadcrumb(mainProcessItem)); + breadcrumbs.push(resourceToBreadcrumb(parentProcessItem)); + } + dispatch(setBreadcrumbs(breadcrumbs, collectionItem)); + } else if (uuidKind === ResourceKind.PROCESS) { + const processItem = await services.containerRequestService.get(currentUuid); + const parentProcessItem = await getProcessParent(processItem)(services); + if (parentProcessItem) { + breadcrumbs.push(resourceToBreadcrumb(parentProcessItem)); + } + dispatch(setBreadcrumbs(breadcrumbs, processItem)); + } else if (uuidKind === ResourceKind.WORKFLOW) { + const workflowItem = await services.workflowService.get(currentUuid); + dispatch(setBreadcrumbs(breadcrumbs, workflowItem)); + } + dispatch(setBreadcrumbs(breadcrumbs)); + } finally { + dispatch(progressIndicatorActions.STOP_WORKING(uuid + "-breadcrumbs")); } - dispatch(setBreadcrumbs(breadcrumbs)); }; +const getProcessParent = (childProcess: ContainerRequestResource) => + async (services: ServiceRepository): Promise => { + if (childProcess.requestingContainerUuid) { + const parentProcesses = await services.containerRequestService.list({ + order: new OrderBuilder().addAsc('createdAt').getOrder(), + filters: new FilterBuilder().addEqual('container_uuid', childProcess.requestingContainerUuid).getFilters(), + select: containerRequestFieldsNoMounts, + }); + if (parentProcesses.items.length > 0) { + return parentProcesses.items[0]; + } else { + return undefined; + } + } else { + return undefined; + } + } + +const getCollectionParent = (collection: CollectionResource) => + async (services: ServiceRepository): Promise => { + const parentOutputPromise = services.containerRequestService.list({ + order: new OrderBuilder().addAsc('createdAt').getOrder(), + filters: new FilterBuilder().addEqual('output_uuid', collection.uuid).getFilters(), + select: containerRequestFieldsNoMounts, + }); + const parentLogPromise = services.containerRequestService.list({ + order: new OrderBuilder().addAsc('createdAt').getOrder(), + filters: new FilterBuilder().addEqual('log_uuid', collection.uuid).getFilters(), + select: containerRequestFieldsNoMounts, + }); + const [parentOutput, parentLog] = await Promise.all([parentOutputPromise, parentLogPromise]); + return parentOutput.items.length > 0 ? + parentOutput.items[0] : + parentLog.items.length > 0 ? + parentLog.items[0] : + undefined; + } + + export const setProjectBreadcrumbs = (uuid: string) => - (dispatch: Dispatch, 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)); @@ -114,15 +234,23 @@ export const setProcessBreadcrumbs = (processUuid: string) => }; export const setGroupsBreadcrumbs = () => - setBreadcrumbs([{ label: SidePanelTreeCategory.GROUPS }]); + setBreadcrumbs([{ + label: SidePanelTreeCategory.GROUPS, + uuid: SidePanelTreeCategory.GROUPS, + icon: getSidePanelIcon(SidePanelTreeCategory.GROUPS) + }]); export const setGroupDetailsBreadcrumbs = (groupUuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const group = getResource(groupUuid)(getState().resources); - const breadcrumbs: ResourceBreadcrumb[] = [ - { label: SidePanelTreeCategory.GROUPS, uuid: SidePanelTreeCategory.GROUPS }, + const breadcrumbs: Breadcrumb[] = [ + { + label: SidePanelTreeCategory.GROUPS, + uuid: SidePanelTreeCategory.GROUPS, + icon: getSidePanelIcon(SidePanelTreeCategory.GROUPS) + }, { label: group ? group.name : (await services.groupsService.get(groupUuid)).name, uuid: groupUuid }, ]; @@ -139,14 +267,14 @@ export const setUserProfileBreadcrumbs = (userUuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { try { const user = getResource(userUuid)(getState().resources) - || await services.userService.get(userUuid, false); - const breadcrumbs: ResourceBreadcrumb[] = [ + || await services.userService.get(userUuid, false); + const breadcrumbs: Breadcrumb[] = [ { label: USERS_PANEL_LABEL, uuid: USERS_PANEL_LABEL }, { label: user ? user.username : userUuid, uuid: userUuid }, ]; dispatch(setBreadcrumbs(breadcrumbs)); } catch (e) { - const breadcrumbs: ResourceBreadcrumb[] = [ + const breadcrumbs: Breadcrumb[] = [ { label: USERS_PANEL_LABEL, uuid: USERS_PANEL_LABEL }, { label: userUuid, uuid: userUuid }, ]; 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-actions.ts b/src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts index 8c5e5b5a..547f1534 100644 --- a/src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts +++ b/src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts @@ -65,10 +65,11 @@ export const removeCollectionsSelectedFiles = () => export const FILE_REMOVE_DIALOG = 'fileRemoveDialog'; -export const openFileRemoveDialog = (filePath: string) => +export const openFileRemoveDialog = (fileUuid: string) => (dispatch: Dispatch, getState: () => RootState) => { - const file = getNodeValue(filePath)(getState().collectionPanelFiles); + const file = getNodeValue(fileUuid)(getState().collectionPanelFiles); if (file) { + const filePath = getFileFullPath(file); const isDirectory = file.type === CollectionFileType.DIRECTORY; const title = isDirectory ? 'Removing directory' @@ -129,7 +130,7 @@ export const renameFile = (newFullPath: string) => dispatch(startSubmit(RENAME_FILE_DIALOG)); const oldPath = getFileFullPath(file); const newPath = newFullPath; - services.collectionService.moveFile(currentCollection.uuid, oldPath, newPath).then(() => { + services.collectionService.renameFile(currentCollection.uuid, currentCollection.portableDataHash, oldPath, newPath).then(() => { dispatch(dialogActions.CLOSE_DIALOG({ id: RENAME_FILE_DIALOG })); dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'File name changed.', hideDuration: 2000 })); }).catch(e => { 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 983b309a..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 @@ -4,27 +4,24 @@ import { ServiceRepository } from 'services/services'; import { MiddlewareAPI, Dispatch } from 'redux'; -import { DataExplorerMiddlewareService } from 'store/data-explorer/data-explorer-middleware-service'; +import { DataExplorerMiddlewareService, getOrder } from 'store/data-explorer/data-explorer-middleware-service'; import { RootState } from 'store/store'; import { getUserUuid } from "common/getuser"; import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; import { getDataExplorer } from 'store/data-explorer/data-explorer-reducer'; import { resourcesActions } from 'store/resources/resources-actions'; import { FilterBuilder } from 'services/api/filter-builder'; -import { SortDirection } from 'components/data-table/data-column'; -import { OrderDirection, OrderBuilder } from 'services/api/order-builder'; -import { getSortColumn } from "store/data-explorer/data-explorer-reducer"; -import { FavoritePanelColumnNames } from 'views/favorite-panel/favorite-panel'; -import { GroupContentsResource, GroupContentsResourcePrefix } from 'services/groups-service/groups-service'; import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions'; import { collectionsContentAddressActions } from './collections-content-address-panel-actions'; -import { navigateTo } from 'store/navigation/navigation-action'; import { updateFavorites } from 'store/favorites/favorites-actions'; import { updatePublicFavorites } from 'store/public-favorites/public-favorites-actions'; import { setBreadcrumbs } from '../breadcrumbs/breadcrumbs-actions'; import { ResourceKind, extractUuidKind } from 'models/resource'; import { ownerNameActions } from 'store/owner-name/owner-name-actions'; import { getUserDisplayName } from 'models/user'; +import { CollectionResource } from 'models/collection'; +import { replace } from "react-router-redux"; +import { getNavUrl } from 'routes/routes'; export class CollectionsWithSameContentAddressMiddlewareService extends DataExplorerMiddlewareService { constructor(private services: ServiceRepository, id: string) { @@ -36,18 +33,6 @@ export class CollectionsWithSameContentAddressMiddlewareService extends DataExpl if (!dataExplorer) { api.dispatch(collectionPanelDataExplorerIsNotSet()); } else { - const sortColumn = getSortColumn(dataExplorer); - - const contentOrder = new OrderBuilder(); - - if (sortColumn && sortColumn.name === FavoritePanelColumnNames.NAME) { - const direction = sortColumn.sortDirection === SortDirection.ASC - ? OrderDirection.ASC - : OrderDirection.DESC; - - contentOrder - .addOrder(direction, "name", GroupContentsResourcePrefix.COLLECTION); - } try { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); const userUuid = getUserUuid(api.getState()); @@ -60,7 +45,8 @@ export class CollectionsWithSameContentAddressMiddlewareService extends DataExpl .addEqual('portable_data_hash', contentAddress) .addILike("name", dataExplorer.searchValue) .getFilters(), - includeOldVersions: true + includeOldVersions: true, + order: getOrder(dataExplorer) }); const userUuids = response.items.map(it => { if (extractUuidKind(it.ownerUuid) === ResourceKind.USER) { @@ -104,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-create-actions.ts b/src/store/collections/collection-create-actions.ts index 17fecc1e..f3d1fd3b 100644 --- a/src/store/collections/collection-create-actions.ts +++ b/src/store/collections/collection-create-actions.ts @@ -59,7 +59,7 @@ export const createCollection = (data: CollectionCreateFormDialogData) => let newCollection: CollectionResource | undefined; try { dispatch(progressIndicatorActions.START_WORKING(COLLECTION_CREATE_FORM_NAME)); - newCollection = await services.collectionService.create(data); + newCollection = await services.collectionService.create(data, false); await dispatch(uploadCollectionFiles(newCollection.uuid)); dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_CREATE_FORM_NAME })); dispatch(reset(COLLECTION_CREATE_FORM_NAME)); @@ -68,11 +68,14 @@ export const createCollection = (data: CollectionCreateFormDialogData) => const error = getCommonResourceServiceError(e); if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) { dispatch(stopSubmit(COLLECTION_CREATE_FORM_NAME, { name: 'Collection with the same name already exists.' } as FormErrors)); - } else if (error === CommonResourceServiceError.NONE) { + } else { dispatch(stopSubmit(COLLECTION_CREATE_FORM_NAME)); dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_CREATE_FORM_NAME })); + const errMsg = e.errors + ? e.errors.join('') + : 'There was an error while creating the collection'; dispatch(snackbarActions.OPEN_SNACKBAR({ - message: 'Collection has not been created.', + message: errMsg, hideDuration: 2000, kind: SnackbarKind.ERROR })); 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/collections/collection-update-actions.ts b/src/store/collections/collection-update-actions.ts index bf9c6449..d955c947 100644 --- a/src/store/collections/collection-update-actions.ts +++ b/src/store/collections/collection-update-actions.ts @@ -52,7 +52,7 @@ export const updateCollection = (collection: CollectionUpdateFormDialogData) => name: collection.name, storageClassesDesired: collection.storageClassesDesired, description: collection.description, - properties: collection.properties } + properties: collection.properties }, false ).then(updatedCollection => { updatedCollection = {...cachedCollection, ...updatedCollection}; dispatch(collectionPanelActions.SET_COLLECTION(updatedCollection)); @@ -72,8 +72,11 @@ export const updateCollection = (collection: CollectionUpdateFormDialogData) => dispatch(stopSubmit(COLLECTION_UPDATE_FORM_NAME, { name: 'Collection with the same name already exists.' } as FormErrors)); } else { dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_UPDATE_FORM_NAME })); + const errMsg = e.errors + ? e.errors.join('') + : 'There was an error while updating the collection'; dispatch(snackbarActions.OPEN_SNACKBAR({ - message: e.errors.join(''), + message: errMsg, hideDuration: 2000, kind: SnackbarKind.ERROR })); } 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 e00b65b3..46431487 100644 --- a/src/store/context-menu/context-menu-actions.ts +++ b/src/store/context-menu/context-menu-actions.ts @@ -2,29 +2,33 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { unionize, ofType, UnionOf } from 'common/unionize'; +import { unionize, ofType, UnionOf } from "common/unionize"; import { ContextMenuPosition } from "./context-menu-reducer"; -import { ContextMenuKind } from 'views-components/context-menu/context-menu'; -import { Dispatch } from 'redux'; -import { RootState } from 'store/store'; -import { getResource, getResourceWithEditableStatus } from '../resources/resources'; -import { UserResource } from 'models/user'; -import { isSidePanelTreeCategory } from 'store/side-panel-tree/side-panel-tree-actions'; -import { extractUuidKind, ResourceKind, EditableResource, Resource } from 'models/resource'; -import { Process } from 'store/processes/process'; -import { RepositoryResource } from 'models/repositories'; -import { SshKeyResource } from 'models/ssh-key'; -import { VirtualMachinesResource } from 'models/virtual-machines'; -import { KeepServiceResource } from 'models/keep-services'; -import { ProcessResource } from 'models/process'; -import { CollectionResource } from 'models/collection'; -import { GroupClass, GroupResource } from 'models/group'; -import { GroupContentsResource } from 'services/groups-service/groups-service'; -import { LinkResource } from 'models/link'; +import { ContextMenuKind } from "views-components/context-menu/context-menu"; +import { Dispatch } from "redux"; +import { RootState } from "store/store"; +import { getResource, getResourceWithEditableStatus } from "../resources/resources"; +import { UserResource } from "models/user"; +import { isSidePanelTreeCategory } from "store/side-panel-tree/side-panel-tree-actions"; +import { extractUuidKind, ResourceKind, EditableResource, Resource } from "models/resource"; +import { Process, isProcessCancelable } from "store/processes/process"; +import { RepositoryResource } from "models/repositories"; +import { SshKeyResource } from "models/ssh-key"; +import { VirtualMachinesResource } from "models/virtual-machines"; +import { KeepServiceResource } from "models/keep-services"; +import { ProcessResource } from "models/process"; +import { CollectionResource } from "models/collection"; +import { GroupClass, GroupResource } from "models/group"; +import { GroupContentsResource } from "services/groups-service/groups-service"; +import { LinkResource } from "models/link"; +import { resourceIsFrozen } from "common/frozen-resources"; +import { ProjectResource } from "models/project"; +import { getProcess } from "store/processes/process"; +import { filterCollectionFilesBySelection } from "store/collection-panel/collection-panel-files/collection-panel-files-state"; export const contextMenuActions = unionize({ - OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition, resource: ContextMenuResource }>(), - CLOSE_CONTEXT_MENU: ofType<{}>() + OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition; resource: ContextMenuResource }>(), + CLOSE_CONTEXT_MENU: ofType<{}>(), }); export type ContextMenuAction = UnionOf; @@ -34,250 +38,289 @@ export type ContextMenuResource = { uuid: string; ownerUuid: string; description?: string; - kind: ResourceKind, + kind: ResourceKind; menuKind: ContextMenuKind | string; isTrashed?: boolean; isEditable?: boolean; outputUuid?: string; workflowUuid?: string; + isAdmin?: boolean; + isFrozen?: boolean; storageClassesDesired?: string[]; properties?: { [key: string]: string | string[] }; + isMulti?: boolean; + fromContextMenu?: boolean; }; export const isKeyboardClick = (event: React.MouseEvent) => 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, - })); + 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; switch (kind) { case ResourceKind.PROJECT: - return (isAdminUser && !readonly) - ? (resource && resource.groupClass !== GroupClass.FILTER) + if (isFrozen) { + return isAdminUser ? ContextMenuKind.FROZEN_PROJECT_ADMIN : ContextMenuKind.FROZEN_PROJECT; + } + + return isAdminUser && !readonly + ? resource && resource.groupClass !== GroupClass.FILTER ? ContextMenuKind.PROJECT_ADMIN : ContextMenuKind.FILTER_GROUP_ADMIN : isEditable - ? (resource && resource.groupClass !== GroupClass.FILTER) - ? ContextMenuKind.PROJECT - : ContextMenuKind.FILTER_GROUP - : ContextMenuKind.READONLY_PROJECT; + ? resource && resource.groupClass !== GroupClass.FILTER + ? ContextMenuKind.PROJECT + : ContextMenuKind.FILTER_GROUP + : ContextMenuKind.READONLY_PROJECT; case ResourceKind.COLLECTION: const c = getResource(uuid)(getState().resources); - if (c === undefined) { return; } + if (c === undefined) { + return; + } const isOldVersion = c.uuid !== c.currentVersionUuid; const isTrashed = c.isTrashed; return isOldVersion ? ContextMenuKind.OLD_VERSION_COLLECTION - : (isTrashed && isEditable) - ? ContextMenuKind.TRASHED_COLLECTION - : (isAdminUser && !readonly) - ? ContextMenuKind.COLLECTION_ADMIN - : isEditable - ? ContextMenuKind.COLLECTION - : ContextMenuKind.READONLY_COLLECTION; + : isTrashed && isEditable + ? ContextMenuKind.TRASHED_COLLECTION + : isAdminUser && isEditable + ? ContextMenuKind.COLLECTION_ADMIN + : isEditable + ? ContextMenuKind.COLLECTION + : ContextMenuKind.READONLY_COLLECTION; case ResourceKind.PROCESS: - return (isAdminUser && !readonly) - ? ContextMenuKind.PROCESS_ADMIN + return isAdminUser && isEditable + ? resource && isProcessCancelable(getProcess(resource.uuid)(getState().resources) as Process) + ? ContextMenuKind.RUNNING_PROCESS_ADMIN + : ContextMenuKind.PROCESS_ADMIN : readonly - ? ContextMenuKind.READONLY_PROCESS_RESOURCE - : ContextMenuKind.PROCESS_RESOURCE; + ? ContextMenuKind.READONLY_PROCESS_RESOURCE + : resource && isProcessCancelable(getProcess(resource.uuid)(getState().resources) as Process) + ? ContextMenuKind.RUNNING_PROCESS_RESOURCE + : ContextMenuKind.PROCESS_RESOURCE; case ResourceKind.USER: return ContextMenuKind.ROOT_PROJECT; case ResourceKind.LINK: return ContextMenuKind.LINK; case ResourceKind.WORKFLOW: - return ContextMenuKind.WORKFLOW; + return isEditable ? ContextMenuKind.WORKFLOW : ContextMenuKind.READONLY_WORKFLOW; default: return; } }; -export const openSearchResultsContextMenu = (event: React.MouseEvent, 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 7ba8225d..ea050e60 100644 --- a/src/store/data-explorer/data-explorer-action.ts +++ b/src/store/data-explorer/data-explorer-action.ts @@ -4,61 +4,51 @@ import { unionize, ofType, UnionOf } from "common/unionize"; import { DataColumns, DataTableFetchMode } from "components/data-table/data-table"; -import { DataTableFilters } from 'components/data-table-filters/data-table-filters-tree'; +import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree"; export enum DataTableRequestState { IDLE, PENDING, - NEED_REFRESH + NEED_REFRESH, } export const dataExplorerActions = unionize({ CLEAR: ofType<{ id: string }>(), RESET_PAGINATION: ofType<{ id: string }>(), - REQUEST_ITEMS: ofType<{ id: string, criteriaChanged?: boolean }>(), - REQUEST_STATE: ofType<{ id: string, criteriaChanged?: boolean }>(), - SET_FETCH_MODE: ofType<({ id: string, fetchMode: DataTableFetchMode })>(), - SET_COLUMNS: ofType<{ id: string, columns: DataColumns }>(), - SET_FILTERS: ofType<{ id: string, columnName: string, filters: DataTableFilters }>(), - SET_ITEMS: ofType<{ id: string, items: any[], page: number, rowsPerPage: number, itemsAvailable: number }>(), - APPEND_ITEMS: ofType<{ id: string, items: any[], page: number, rowsPerPage: number, itemsAvailable: number }>(), - SET_PAGE: ofType<{ id: string, page: number }>(), - SET_ROWS_PER_PAGE: ofType<{ id: string, rowsPerPage: number }>(), - TOGGLE_COLUMN: ofType<{ id: string, columnName: string }>(), - TOGGLE_SORT: ofType<{ id: string, columnName: string }>(), - SET_EXPLORER_SEARCH_VALUE: ofType<{ id: string, searchValue: string }>(), - SET_REQUEST_STATE: ofType<{ id: string, requestState: DataTableRequestState }>(), + REQUEST_ITEMS: ofType<{ id: string; criteriaChanged?: boolean, background?: boolean }>(), + REQUEST_STATE: ofType<{ id: string; criteriaChanged?: boolean }>(), + SET_FETCH_MODE: ofType<{ id: string; fetchMode: DataTableFetchMode }>(), + SET_COLUMNS: ofType<{ id: string; columns: DataColumns }>(), + SET_FILTERS: ofType<{ id: string; columnName: string; filters: DataTableFilters }>(), + SET_ITEMS: ofType<{ id: string; items: any[]; page: number; rowsPerPage: number; itemsAvailable: number }>(), + APPEND_ITEMS: ofType<{ id: string; items: any[]; page: number; rowsPerPage: number; itemsAvailable: number }>(), + SET_PAGE: ofType<{ id: string; page: number }>(), + SET_ROWS_PER_PAGE: ofType<{ id: string; rowsPerPage: number }>(), + TOGGLE_COLUMN: ofType<{ id: string; columnName: string }>(), + TOGGLE_SORT: ofType<{ id: string; columnName: string }>(), + SET_EXPLORER_SEARCH_VALUE: ofType<{ id: string; searchValue: string }>(), + RESET_EXPLORER_SEARCH_VALUE: ofType<{ id: string }>(), + SET_REQUEST_STATE: ofType<{ id: string; requestState: DataTableRequestState }>(), }); export type DataExplorerAction = UnionOf; 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 }), - 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 71a6ee6a..6bb95a9a 100644 --- a/src/store/data-explorer/data-explorer-middleware-service.ts +++ b/src/store/data-explorer/data-explorer-middleware-service.ts @@ -2,13 +2,16 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { Dispatch, MiddlewareAPI } from "redux"; -import { RootState } from "../store"; -import { DataColumns } from "components/data-table/data-table"; -import { DataExplorer } from './data-explorer-reducer'; +import { Dispatch, MiddlewareAPI } from 'redux'; +import { RootState } from '../store'; +import { DataColumns } from 'components/data-table/data-table'; +import { DataExplorer, getSortColumn } from './data-explorer-reducer'; import { ListResults } from 'services/common-service/common-service'; -import { createTree } from "models/tree"; -import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree"; +import { createTree } from 'models/tree'; +import { DataTableFilters } from 'components/data-table-filters/data-table-filters-tree'; +import { OrderBuilder, OrderDirection } from 'services/api/order-builder'; +import { SortDirection } from 'components/data-table/data-column'; +import { Resource } from 'models/resource'; export abstract class DataExplorerMiddlewareService { protected readonly id: string; @@ -21,25 +24,57 @@ export abstract class DataExplorerMiddlewareService { return this.id; } - public getColumnFilters(columns: DataColumns, columnName: string): DataTableFilters { + public getColumnFilters( + columns: DataColumns, + columnName: string + ): DataTableFilters { return getDataExplorerColumnFilters(columns, columnName); } - abstract requestItems(api: MiddlewareAPI, criteriaChanged?: boolean): Promise; + abstract requestItems( + api: MiddlewareAPI, + criteriaChanged?: boolean, + background?: boolean + ): Promise; } -export const getDataExplorerColumnFilters = (columns: DataColumns, columnName: string): DataTableFilters => { - const column = columns.find(c => c.name === columnName); +export const getDataExplorerColumnFilters = ( + columns: DataColumns, + columnName: string +): DataTableFilters => { + const column = columns.find((c) => c.name === columnName); return column ? column.filters : createTree(); }; export const dataExplorerToListParams = (dataExplorer: DataExplorer) => ({ limit: dataExplorer.rowsPerPage, - offset: dataExplorer.page * dataExplorer.rowsPerPage + offset: dataExplorer.page * dataExplorer.rowsPerPage, }); -export const listResultsToDataExplorerItemsMeta = ({ itemsAvailable, offset, limit }: ListResults) => ({ +export 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; + + // Use createdAt as a secondary sort column so we break ties consistently. + return order + .addOrder(sortDirection, sortColumn.sort.field) + .addOrder(OrderDirection.DESC, "createdAt") + .getOrder(); + } else { + return order.getOrder(); + } +}; + +export const listResultsToDataExplorerItemsMeta = ({ + itemsAvailable, + offset, + limit, +}: ListResults) => ({ itemsAvailable, page: Math.floor(offset / limit), - rowsPerPage: limit + rowsPerPage: limit, }); diff --git a/src/store/data-explorer/data-explorer-middleware.test.ts b/src/store/data-explorer/data-explorer-middleware.test.ts index ef6cfe42..8bb10f0c 100644 --- a/src/store/data-explorer/data-explorer-middleware.test.ts +++ b/src/store/data-explorer/data-explorer-middleware.test.ts @@ -201,7 +201,7 @@ describe("DataExplorerMiddleware", () => { class ServiceMock extends DataExplorerMiddlewareService { constructor(private config: { id: string, - columns: DataColumns, + columns: DataColumns, requestItems: (api: MiddlewareAPI) => Promise }) { super(config.id); diff --git a/src/store/data-explorer/data-explorer-middleware.ts b/src/store/data-explorer/data-explorer-middleware.ts index efe51fe3..3404b375 100644 --- a/src/store/data-explorer/data-explorer-middleware.ts +++ b/src/store/data-explorer/data-explorer-middleware.ts @@ -1,4 +1,3 @@ - // Copyright (C) The Arvados Authors. All rights reserved. // // SPDX-License-Identifier: AGPL-3.0 @@ -6,74 +5,109 @@ import { Dispatch } from 'redux'; import { RootState } from 'store/store'; import { ServiceRepository } from 'services/services'; -import { Middleware } from "redux"; -import { dataExplorerActions, bindDataExplorerActions, DataTableRequestState } from "./data-explorer-action"; -import { getDataExplorer } from "./data-explorer-reducer"; -import { DataExplorerMiddlewareService } from "./data-explorer-middleware-service"; +import { Middleware } from 'redux'; +import { + dataExplorerActions, + bindDataExplorerActions, + DataTableRequestState, +} from './data-explorer-action'; +import { getDataExplorer } from './data-explorer-reducer'; +import { DataExplorerMiddlewareService } from './data-explorer-middleware-service'; -export const dataExplorerMiddleware = (service: DataExplorerMiddlewareService): Middleware => api => next => { - const actions = bindDataExplorerActions(service.getId()); +export const dataExplorerMiddleware = + (service: DataExplorerMiddlewareService): Middleware => + (api) => + (next) => { + const actions = bindDataExplorerActions(service.getId()); - return action => { - const handleAction = (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 })); + return (action) => { + const handleAction = + (handler: (data: T) => void) => + (data: T) => { + next(action); + if (data.id === service.getId()) { + handler(data); } - // Now check if the state is still PENDING, if it moved to NEED_REFRESH - // then we need to reissue requestItems - de = getDataExplorer(getState().dataExplorer, service.getId()); - const complete = (de.requestState === DataTableRequestState.PENDING); - dispatch(actions.SET_REQUEST_STATE({ requestState: DataTableRequestState.IDLE })); - if (complete) { - return; + }; + dataExplorerActions.match(action, { + SET_PAGE: handleAction(() => { + api.dispatch(actions.REQUEST_ITEMS(false)); + }), + SET_ROWS_PER_PAGE: handleAction(() => { + api.dispatch(actions.REQUEST_ITEMS(true)); + }), + SET_FILTERS: handleAction(() => { + api.dispatch(actions.RESET_PAGINATION()); + api.dispatch(actions.REQUEST_ITEMS(true)); + }), + TOGGLE_SORT: handleAction(() => { + api.dispatch(actions.REQUEST_ITEMS(true)); + }), + SET_EXPLORER_SEARCH_VALUE: handleAction(() => { + api.dispatch(actions.RESET_PAGINATION()); + api.dispatch(actions.REQUEST_ITEMS(true)); + }), + REQUEST_ITEMS: handleAction(({ criteriaChanged, background }) => { + api.dispatch(async ( + dispatch: Dispatch, + getState: () => RootState, + services: ServiceRepository + ) => { + while (true) { + let de = getDataExplorer( + getState().dataExplorer, + service.getId() + ); + switch (de.requestState) { + case DataTableRequestState.IDLE: + // Start a new request. + try { + dispatch( + actions.SET_REQUEST_STATE({ + requestState: DataTableRequestState.PENDING, + }) + ); + await service.requestItems(api, criteriaChanged, background); + } catch { + dispatch( + actions.SET_REQUEST_STATE({ + requestState: DataTableRequestState.NEED_REFRESH, + }) + ); + } + // Now check if the state is still PENDING, if it moved to NEED_REFRESH + // then we need to reissue requestItems + de = getDataExplorer( + getState().dataExplorer, + service.getId() + ); + const complete = + de.requestState === DataTableRequestState.PENDING; + dispatch( + actions.SET_REQUEST_STATE({ + requestState: DataTableRequestState.IDLE, + }) + ); + if (complete) { + return; + } + break; + case DataTableRequestState.PENDING: + // State is PENDING, move it to NEED_REFRESH so that when the current request finishes it starts a new one. + dispatch( + actions.SET_REQUEST_STATE({ + requestState: DataTableRequestState.NEED_REFRESH, + }) + ); + return; + case DataTableRequestState.NEED_REFRESH: + // Nothing to do right now. + return; + } } - break; - case DataTableRequestState.PENDING: - // State is PENDING, move it to NEED_REFRESH so that when the current request finishes it starts a new one. - dispatch(actions.SET_REQUEST_STATE({ requestState: DataTableRequestState.NEED_REFRESH })); - return; - case DataTableRequestState.NEED_REFRESH: - // Nothing to do right now. - return; - } - } - }); - }), - default: () => next(action) - }); - }; -}; + }); + }), + default: () => next(action), + }); + }; + }; diff --git a/src/store/data-explorer/data-explorer-reducer.test.tsx b/src/store/data-explorer/data-explorer-reducer.test.tsx index d26d768a..01aa7296 100644 --- a/src/store/data-explorer/data-explorer-reducer.test.tsx +++ b/src/store/data-explorer/data-explorer-reducer.test.tsx @@ -10,13 +10,13 @@ import { SortDirection } from "../../components/data-table/data-column"; describe('data-explorer-reducer', () => { it('should set columns', () => { - const columns: DataColumns = [{ + const columns: DataColumns = [{ name: "Column 1", filters: [], render: jest.fn(), selected: true, configurable: true, - sortDirection: SortDirection.NONE + sort: {direction: SortDirection.NONE, field: "name"} }]; const state = dataExplorerReducer(undefined, dataExplorerActions.SET_COLUMNS({ id: "Data explorer", columns })); @@ -24,12 +24,12 @@ describe('data-explorer-reducer', () => { }); it('should toggle sorting', () => { - const columns: DataColumns = [{ + const columns: DataColumns = [{ name: "Column 1", filters: [], render: jest.fn(), selected: true, - sortDirection: SortDirection.ASC, + sort: {direction: SortDirection.ASC, field: "name"}, configurable: true }, { name: "Column 2", @@ -37,22 +37,22 @@ describe('data-explorer-reducer', () => { render: jest.fn(), selected: true, configurable: true, - sortDirection: SortDirection.NONE, + sort: {direction: SortDirection.NONE, field: "name"}, }]; const state = dataExplorerReducer({ "Data explorer": { ...initialDataExplorer, columns } }, dataExplorerActions.TOGGLE_SORT({ id: "Data explorer", columnName: "Column 2" })); - expect(state["Data explorer"].columns[0].sortDirection).toEqual("none"); - expect(state["Data explorer"].columns[1].sortDirection).toEqual("asc"); + expect(state["Data explorer"].columns[0].sort.direction).toEqual("none"); + expect(state["Data explorer"].columns[1].sort.direction).toEqual("asc"); }); it('should set filters', () => { - const columns: DataColumns = [{ + const columns: DataColumns = [{ name: "Column 1", filters: [], render: jest.fn(), selected: true, configurable: true, - sortDirection: SortDirection.NONE + sort: {direction: SortDirection.NONE, field: "name"} }]; const filters: DataTableFilterItem[] = [{ diff --git a/src/store/data-explorer/data-explorer-reducer.ts b/src/store/data-explorer/data-explorer-reducer.ts index 1e5cd88f..a0a7eb64 100644 --- a/src/store/data-explorer/data-explorer-reducer.ts +++ b/src/store/data-explorer/data-explorer-reducer.ts @@ -6,15 +6,22 @@ import { DataColumn, resetSortDirection, SortDirection, - toggleSortDirection -} from "components/data-table/data-column"; -import { DataExplorerAction, dataExplorerActions, DataTableRequestState } from "./data-explorer-action"; -import { DataColumns, DataTableFetchMode } from "components/data-table/data-table"; -import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree"; + toggleSortDirection, +} from 'components/data-table/data-column'; +import { + DataExplorerAction, + dataExplorerActions, + DataTableRequestState, +} from './data-explorer-action'; +import { + DataColumns, + DataTableFetchMode, +} from 'components/data-table/data-table'; +import { DataTableFilters } from 'components/data-table-filters/data-table-filters-tree'; export interface DataExplorer { fetchMode: DataTableFetchMode; - columns: DataColumns; + columns: DataColumns; items: any[]; itemsAvailable: number; page: number; @@ -33,52 +40,78 @@ export const initialDataExplorer: DataExplorer = { page: 0, rowsPerPage: 50, rowsPerPageOptions: [10, 20, 50, 100, 200, 500], - searchValue: "", - requestState: DataTableRequestState.IDLE + searchValue: '', + requestState: DataTableRequestState.IDLE, }; export type DataExplorerState = Record; -export const dataExplorerReducer = (state: DataExplorerState = {}, action: DataExplorerAction) => - dataExplorerActions.match(action, { +export const dataExplorerReducer = ( + state: DataExplorerState = {}, + action: DataExplorerAction +) => { + return dataExplorerActions.match(action, { CLEAR: ({ id }) => - update(state, id, explorer => ({ ...explorer, page: 0, itemsAvailable: 0, items: [] })), + update(state, id, (explorer) => ({ + ...explorer, + page: 0, + itemsAvailable: 0, + items: [], + })), RESET_PAGINATION: ({ id }) => - update(state, id, explorer => ({ ...explorer, page: 0 })), + update(state, id, (explorer) => ({ ...explorer, page: 0 })), SET_FETCH_MODE: ({ id, fetchMode }) => - update(state, id, explorer => ({ ...explorer, fetchMode })), + update(state, id, (explorer) => ({ ...explorer, fetchMode })), - SET_COLUMNS: ({ id, columns }) => - update(state, id, setColumns(columns)), + SET_COLUMNS: ({ id, columns }) => update(state, id, setColumns(columns)), SET_FILTERS: ({ id, columnName, filters }) => update(state, id, mapColumns(setFilters(columnName, filters))), - SET_ITEMS: ({ id, items, itemsAvailable, page, rowsPerPage }) => - update(state, id, explorer => ({ ...explorer, items, itemsAvailable, page: page || 0, rowsPerPage })), + SET_ITEMS: ({ id, items, itemsAvailable, page, rowsPerPage }) => ( + update(state, id, (explorer) => { + // Reject updates to pages other than current, + // DataExplorer middleware should retry + const updatedPage = page || 0; + if (explorer.page === updatedPage) { + return { + ...explorer, + items, + itemsAvailable, + page: updatedPage, + rowsPerPage, + } + } else { + return explorer; + } + }) + ), APPEND_ITEMS: ({ id, items, itemsAvailable, page, rowsPerPage }) => - update(state, id, explorer => ({ + update(state, id, (explorer) => ({ ...explorer, items: state[id].items.concat(items), itemsAvailable: state[id].itemsAvailable + itemsAvailable, page, - rowsPerPage + rowsPerPage, })), SET_PAGE: ({ id, page }) => - update(state, id, explorer => ({ ...explorer, page })), + update(state, id, (explorer) => ({ ...explorer, page })), SET_ROWS_PER_PAGE: ({ id, rowsPerPage }) => - update(state, id, explorer => ({ ...explorer, rowsPerPage })), + update(state, id, (explorer) => ({ ...explorer, rowsPerPage })), SET_EXPLORER_SEARCH_VALUE: ({ id, searchValue }) => - update(state, id, explorer => ({ ...explorer, searchValue })), + update(state, id, (explorer) => ({ ...explorer, searchValue })), + + RESET_EXPLORER_SEARCH_VALUE: ({ id }) => + update(state, id, (explorer) => ({ ...explorer, searchValue: '' })), SET_REQUEST_STATE: ({ id, requestState }) => - update(state, id, explorer => ({ ...explorer, requestState })), + update(state, id, (explorer) => ({ ...explorer, requestState })), TOGGLE_SORT: ({ id, columnName }) => update(state, id, mapColumns(toggleSort(columnName))), @@ -86,19 +119,29 @@ export const dataExplorerReducer = (state: DataExplorerState = {}, action: DataE TOGGLE_COLUMN: ({ id, columnName }) => update(state, id, mapColumns(toggleColumn(columnName))), - default: () => state + default: () => state, }); +}; +export const getDataExplorer = (state: DataExplorerState, id: string) => { + const returnValue = state[id] || initialDataExplorer; + return returnValue; +}; -export const getDataExplorer = (state: DataExplorerState, id: string) => - state[id] || initialDataExplorer; - -export const getSortColumn = (dataExplorer: DataExplorer) => dataExplorer.columns.find((c: any) => - !!c.sortDirection && c.sortDirection !== SortDirection.NONE); - -const update = (state: DataExplorerState, id: string, updateFn: (dataExplorer: DataExplorer) => DataExplorer) => - ({ ...state, [id]: updateFn(getDataExplorer(state, id)) }); - -const canUpdateColumns = (prevColumns: DataColumns, nextColumns: DataColumns) => { +export const getSortColumn = (dataExplorer: DataExplorer): DataColumn | undefined => + dataExplorer.columns.find( + (c: DataColumn) => !!c.sort && c.sort.direction !== SortDirection.NONE + ); + +const update = ( + state: DataExplorerState, + id: string, + updateFn: (dataExplorer: DataExplorer) => DataExplorer +) => ({ ...state, [id]: updateFn(getDataExplorer(state, id)) }); + +const canUpdateColumns = ( + prevColumns: DataColumns, + nextColumns: DataColumns +) => { if (prevColumns.length !== nextColumns.length) { return true; } @@ -112,25 +155,32 @@ const canUpdateColumns = (prevColumns: DataColumns, nextColumns: DataColumn return false; }; -const setColumns = (columns: DataColumns) => - (dataExplorer: DataExplorer) => - ({ ...dataExplorer, columns: canUpdateColumns(dataExplorer.columns, columns) ? columns : dataExplorer.columns }); +const setColumns = + (columns: DataColumns) => (dataExplorer: DataExplorer) => ({ + ...dataExplorer, + columns: canUpdateColumns(dataExplorer.columns, columns) + ? columns + : dataExplorer.columns, + }); -const mapColumns = (mapFn: (column: DataColumn) => DataColumn) => - (dataExplorer: DataExplorer) => - ({ ...dataExplorer, columns: dataExplorer.columns.map(mapFn) }); +const mapColumns = + (mapFn: (column: DataColumn) => DataColumn) => + (dataExplorer: DataExplorer) => ({ + ...dataExplorer, + columns: dataExplorer.columns.map(mapFn), + }); -const toggleSort = (columnName: string) => - (column: DataColumn) => column.name === columnName +const toggleSort = (columnName: string) => (column: DataColumn) => + column.name === columnName ? toggleSortDirection(column) : resetSortDirection(column); -const toggleColumn = (columnName: string) => - (column: DataColumn) => column.name === columnName +const toggleColumn = (columnName: string) => (column: DataColumn) => + column.name === columnName ? { ...column, selected: !column.selected } : column; -const setFilters = (columnName: string, filters: DataTableFilters) => - (column: DataColumn) => column.name === columnName - ? { ...column, filters } - : column; +const setFilters = + (columnName: string, filters: DataTableFilters) => + (column: DataColumn) => + column.name === columnName ? { ...column, filters } : column; 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/favorite-panel/favorite-panel-action.ts b/src/store/favorite-panel/favorite-panel-action.ts index 067d5cee..85ede867 100644 --- a/src/store/favorite-panel/favorite-panel-action.ts +++ b/src/store/favorite-panel/favorite-panel-action.ts @@ -2,9 +2,13 @@ // // SPDX-License-Identifier: AGPL-3.0 +import { Dispatch } from "redux"; import { bindDataExplorerActions } from "../data-explorer/data-explorer-action"; export const FAVORITE_PANEL_ID = "favoritePanel"; export const favoritePanelActions = bindDataExplorerActions(FAVORITE_PANEL_ID); -export const loadFavoritePanel = () => favoritePanelActions.REQUEST_ITEMS(); +export const loadFavoritePanel = () => (dispatch: Dispatch) => { + dispatch(favoritePanelActions.RESET_EXPLORER_SEARCH_VALUE()); + dispatch(favoritePanelActions.REQUEST_ITEMS()); +}; \ No newline at end of file diff --git a/src/store/favorite-panel/favorite-panel-middleware-service.ts b/src/store/favorite-panel/favorite-panel-middleware-service.ts index f88f7b91..0229834c 100644 --- a/src/store/favorite-panel/favorite-panel-middleware-service.ts +++ b/src/store/favorite-panel/favorite-panel-middleware-service.ts @@ -8,24 +8,20 @@ import { RootState } from "../store"; import { getUserUuid } from "common/getuser"; import { DataColumns } from "components/data-table/data-table"; import { ServiceRepository } from "services/services"; -import { SortDirection } from "components/data-table/data-column"; import { FilterBuilder } from "services/api/filter-builder"; import { updateFavorites } from "../favorites/favorites-actions"; import { favoritePanelActions } from "./favorite-panel-action"; import { Dispatch, MiddlewareAPI } from "redux"; -import { OrderBuilder, OrderDirection } from "services/api/order-builder"; -import { LinkResource } from "models/link"; -import { GroupContentsResource, GroupContentsResourcePrefix } from "services/groups-service/groups-service"; import { resourcesActions } from "store/resources/resources-actions"; import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions'; import { getDataExplorer } from "store/data-explorer/data-explorer-reducer"; import { loadMissingProcessesInformation } from "store/project-panel/project-panel-middleware-service"; -import { getSortColumn } from "store/data-explorer/data-explorer-reducer"; import { getDataExplorerColumnFilters } from 'store/data-explorer/data-explorer-middleware-service'; import { serializeSimpleObjectTypeFilters } from '../resource-type-filters/resource-type-filters'; import { ResourceKind } from "models/resource"; import { LinkClass } from "models/link"; +import { GroupContentsResource } from "services/groups-service/groups-service"; export class FavoritePanelMiddlewareService extends DataExplorerMiddlewareService { constructor(private services: ServiceRepository, id: string) { @@ -37,25 +33,9 @@ export class FavoritePanelMiddlewareService extends DataExplorerMiddlewareServic if (!dataExplorer) { api.dispatch(favoritesPanelDataExplorerIsNotSet()); } else { - const columns = dataExplorer.columns as DataColumns; - const sortColumn = getSortColumn(dataExplorer); + const columns = dataExplorer.columns as DataColumns; const typeFilters = serializeSimpleObjectTypeFilters(getDataExplorerColumnFilters(columns, FavoritePanelColumnNames.TYPE)); - - const linkOrder = new OrderBuilder(); - const contentOrder = new OrderBuilder(); - - if (sortColumn && sortColumn.name === FavoritePanelColumnNames.NAME) { - const direction = sortColumn.sortDirection === SortDirection.ASC - ? OrderDirection.ASC - : OrderDirection.DESC; - - linkOrder.addOrder(direction, "name"); - contentOrder - .addOrder(direction, "name", GroupContentsResourcePrefix.COLLECTION) - .addOrder(direction, "name", GroupContentsResourcePrefix.PROCESS) - .addOrder(direction, "name", GroupContentsResourcePrefix.PROJECT); - } try { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); const responseLinks = await this.services.linkService.list({ 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/groups-panel/groups-panel-actions.ts b/src/store/groups-panel/groups-panel-actions.ts index 6e63702e..203bf446 100644 --- a/src/store/groups-panel/groups-panel-actions.ts +++ b/src/store/groups-panel/groups-panel-actions.ts @@ -25,7 +25,10 @@ export const GROUP_REMOVE_DIALOG = 'groupRemoveDialog'; export const GroupsPanelActions = bindDataExplorerActions(GROUPS_PANEL_ID); -export const loadGroupsPanel = () => GroupsPanelActions.REQUEST_ITEMS(); +export const loadGroupsPanel = () => (dispatch: Dispatch) => { + dispatch(GroupsPanelActions.RESET_EXPLORER_SEARCH_VALUE()); + dispatch(GroupsPanelActions.REQUEST_ITEMS()); +}; export const openCreateGroupDialog = () => (dispatch: Dispatch, getState: () => RootState) => { @@ -113,7 +116,7 @@ export const createGroup = ({ name, users = [], description }: ProjectUpdateForm } dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_CREATE_FORM_NAME })); dispatch(reset(PROJECT_CREATE_FORM_NAME)); - dispatch(loadGroupsPanel()); + dispatch(loadGroupsPanel()); dispatch(snackbarActions.OPEN_SNACKBAR({ message: `${newGroup.name} group has been created`, kind: SnackbarKind.SUCCESS diff --git a/src/store/groups-panel/groups-panel-middleware-service.ts b/src/store/groups-panel/groups-panel-middleware-service.ts index 3997e33c..7d7803f5 100644 --- a/src/store/groups-panel/groups-panel-middleware-service.ts +++ b/src/store/groups-panel/groups-panel-middleware-service.ts @@ -14,7 +14,6 @@ import { updateResources } from 'store/resources/resources-actions'; import { OrderBuilder, OrderDirection } from 'services/api/order-builder'; import { GroupResource, GroupClass } from 'models/group'; import { SortDirection } from 'components/data-table/data-column'; -import { GroupsPanelColumnNames } from 'views/groups-panel/groups-panel'; import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions"; export class GroupsPanelMiddlewareService extends DataExplorerMiddlewareService { @@ -28,14 +27,14 @@ export class GroupsPanelMiddlewareService extends DataExplorerMiddlewareService } else { try { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); + const sortColumn = getSortColumn(dataExplorer); const order = new OrderBuilder(); - const sortColumn = getSortColumn(dataExplorer); - if (sortColumn) { + if (sortColumn && sortColumn.sort) { const direction = - sortColumn.sortDirection === SortDirection.ASC && sortColumn.name === GroupsPanelColumnNames.GROUP + sortColumn.sort.direction === SortDirection.ASC ? OrderDirection.ASC : OrderDirection.DESC; - order.addOrder(direction, 'name'); + order.addOrder(direction, sortColumn.sort.field); } const filters = new FilterBuilder() .addEqual('group_class', GroupClass.ROLE) diff --git a/src/store/link-panel/link-panel-middleware-service.ts b/src/store/link-panel/link-panel-middleware-service.ts index da849a59..cc6ea8cf 100644 --- a/src/store/link-panel/link-panel-middleware-service.ts +++ b/src/store/link-panel/link-panel-middleware-service.ts @@ -4,18 +4,15 @@ import { ServiceRepository } from 'services/services'; import { MiddlewareAPI, Dispatch } from 'redux'; -import { DataExplorerMiddlewareService, dataExplorerToListParams, listResultsToDataExplorerItemsMeta } from 'store/data-explorer/data-explorer-middleware-service'; +import { DataExplorerMiddlewareService, dataExplorerToListParams, getOrder, listResultsToDataExplorerItemsMeta } from 'store/data-explorer/data-explorer-middleware-service'; import { RootState } from 'store/store'; import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; import { DataExplorer, getDataExplorer } from 'store/data-explorer/data-explorer-reducer'; import { updateResources } from 'store/resources/resources-actions'; -import { SortDirection } from 'components/data-table/data-column'; -import { OrderDirection, OrderBuilder } from 'services/api/order-builder'; import { ListResults } from 'services/common-service/common-service'; -import { getSortColumn } from "store/data-explorer/data-explorer-reducer"; import { LinkResource } from 'models/link'; import { linkPanelActions } from 'store/link-panel/link-panel-actions'; -import { LinkPanelColumnNames } from 'views/link-panel/link-panel-root'; +import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions"; export class LinkMiddlewareService extends DataExplorerMiddlewareService { constructor(private services: ServiceRepository, id: string) { @@ -26,37 +23,23 @@ export class LinkMiddlewareService extends DataExplorerMiddlewareService { const state = api.getState(); const dataExplorer = getDataExplorer(state.dataExplorer, this.getId()); try { + api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); const response = await this.services.linkService.list(getParams(dataExplorer)); api.dispatch(updateResources(response.items)); api.dispatch(setItems(response)); } catch { api.dispatch(couldNotFetchLinks()); + } finally { + api.dispatch(progressIndicatorActions.STOP_WORKING(this.getId())); } } } export const getParams = (dataExplorer: DataExplorer) => ({ ...dataExplorerToListParams(dataExplorer), - order: getOrder(dataExplorer) + order: getOrder(dataExplorer) }); -const getOrder = (dataExplorer: DataExplorer) => { - const sortColumn = getSortColumn(dataExplorer); - const order = new OrderBuilder(); - if (sortColumn) { - const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC - ? OrderDirection.ASC - : OrderDirection.DESC; - - const columnName = sortColumn && sortColumn.name === LinkPanelColumnNames.NAME ? "name" : "modifiedAt"; - return order - .addOrder(sortDirection, columnName) - .getOrder(); - } else { - return order.getOrder(); - } -}; - export const setItems = (listResults: ListResults) => linkPanelActions.SET_ITEMS({ ...listResultsToDataExplorerItemsMeta(listResults), 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 c465aae8..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,26 +2,39 @@ // // SPDX-License-Identifier: AGPL-3.0 -import copy from 'copy-to-clipboard'; -import { Dispatch } from 'redux'; -import { getNavUrl } from 'routes/routes'; -import { RootState } from 'store/store'; +import copy from "copy-to-clipboard"; +import { Dispatch } from "redux"; +import { getNavUrl } from "routes/routes"; +import { RootState } from "store/store"; +import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions"; export const openInNewTabAction = (resource: any) => (dispatch: Dispatch, getState: () => RootState) => { const url = getNavUrl(resource.uuid, getState().auth); - if (url[0] === '/') { - window.open(`${window.location.origin}${url}`, '_blank'); + if (url[0] === "/") { + window.open(`${window.location.origin}${url}`, "_blank"); } else if (url.length) { - window.open(url, '_blank'); + window.open(url, "_blank"); } }; -export const copyToClipboardAction = (resource: any) => (dispatch: Dispatch, getState: () => RootState) => { +export const copyToClipboardAction = (resources: Array) => (dispatch: Dispatch, getState: () => RootState) => { // Copy to clipboard omits token to avoid accidental sharing - const url = getNavUrl(resource.uuid, getState().auth, false); - if (url) { - copy(url); + let url = getNavUrl(resources[0].uuid, getState().auth, false); + let wasCopied; + + if (url[0] === "/") wasCopied = copy(`${window.location.origin}${url}`); + else if (url.length) { + wasCopied = copy(url); } + + if (wasCopied) + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: "Copied", + hideDuration: 2000, + kind: SnackbarKind.SUCCESS, + }) + ); }; 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 d4f5ab59..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 } 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,83 +46,289 @@ export const setProcessLogsPanelFilter = (filter: string) => export const initProcessLogsPanel = (processUuid: string) => async (dispatch: Dispatch, getState: () => RootState, { logService }: ServiceRepository) => { - dispatch(processLogsPanelActions.RESET_PROCESS_LOGS_PANEL()); - const process = getProcess(processUuid)(getState().resources); - if (process && process.container) { - const logResources = await loadContainerLogs(process.container.uuid, logService); - const initialState = createInitialLogPanelState(logResources); + let process: Process | undefined; + try { + dispatch(processLogsPanelActions.RESET_PROCESS_LOGS_PANEL()); + process = getProcess(processUuid)(getState().resources); + if (process?.containerRequest?.uuid) { + // Get log file size info + const logFiles = await loadContainerLogFileList(process.containerRequest, logService); + + // Populate lastbyte 0 for each file + const filesWithProgress = logFiles.map((file) => ({ file, lastByte: 0 })); + + // Fetch array of LogFragments + const logLines = await loadContainerLogFileContents(filesWithProgress, logService, process); + + // Populate initial state with filters + const initialState = createInitialLogPanelState(logFiles, logLines); + dispatch(processLogsPanelActions.INIT_PROCESS_LOGS_PANEL(initialState)); + } + } catch (e) { + // On error, populate empty state to allow polling to start + const initialState = createInitialLogPanelState([], []); dispatch(processLogsPanelActions.INIT_PROCESS_LOGS_PANEL(initialState)); + // Only show toast on errors other than 404 since 404 is expected when logs do not exist yet + if (e.status !== 404) { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Error loading process logs', hideDuration: 4000, kind: SnackbarKind.ERROR })); + } + if (e.status === 404 && process?.containerRequest.state === ContainerRequestState.FINAL) { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Log collection was trashed or deleted.', hideDuration: 4000, kind: SnackbarKind.WARNING })); + } } }; -export const addProcessLogsPanelItem = (message: ResourceEventMessage<{ text: string }>) => +export const pollProcessLogs = (processUuid: string) => async (dispatch: Dispatch, getState: () => RootState, { logService }: ServiceRepository) => { - if (PROCESS_PANEL_LOG_EVENT_TYPES.indexOf(message.eventType) > -1) { - const uuid = getProcessLogsPanelCurrentUuid(getState().router); - if (!uuid) { return } - const process = getProcess(uuid)(getState().resources); - if (!process) { return } - const { containerRequest, container } = process; - if (message.objectUuid === containerRequest.uuid - || (container && message.objectUuid === container.uuid)) { - dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM({ - logType: ALL_FILTER_TYPE, - log: message.properties.text - })); - dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM({ - logType: message.eventType, - log: message.properties.text - })); - if (MAIN_EVENT_TYPES.indexOf(message.eventType) > -1) { - dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM({ - logType: MAIN_FILTER_TYPE, - log: message.properties.text - })); + try { + // Get log panel state and process from store + const currentState = getState().processLogsPanel; + const process = getProcess(processUuid)(getState().resources); + + // Check if container request is present and initial logs state loaded + if (process?.containerRequest?.uuid && Object.keys(currentState.logs).length > 0) { + const logFiles = await loadContainerLogFileList(process.containerRequest, logService); + + // Determine byte to fetch from while filtering unchanged files + const filesToUpdateWithProgress = logFiles.reduce((acc, updatedFile) => { + // Fetch last byte or 0 for new log files + const currentStateLogLastByte = currentState.logs[logFileToLogType(updatedFile)]?.lastByte || 0; + + const isNew = !Object.keys(currentState.logs).find((currentStateLogName) => (updatedFile.name.startsWith(currentStateLogName))); + const isChanged = !isNew && currentStateLogLastByte < updatedFile.size; + + if (isNew || isChanged) { + return acc.concat({ file: updatedFile, lastByte: currentStateLogLastByte }); + } else { + return acc; + } + }, [] as FileWithProgress[]); + + // Perform range request(s) for each file + const logFragments = await loadContainerLogFileContents(filesToUpdateWithProgress, logService, process); + + if (logFragments.length) { + // Convert LogFragments to ProcessLogs with All/Main sorting & line-merging + const groupedLogs = groupLogs(logFiles, logFragments); + await dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM(groupedLogs)); } } + return Promise.resolve(); + } catch (e) { + // Remove log when polling error is handled in some way instead of being ignored + console.error("Error occurred in pollProcessLogs:", e); + return Promise.reject(); } }; -const loadContainerLogs = async (containerUuid: string, logService: LogService) => { - const requestFilters = new FilterBuilder() - .addEqual('object_uuid', containerUuid) - .addIn('event_type', PROCESS_PANEL_LOG_EVENT_TYPES) - .getFilters(); - const requestOrder = new OrderBuilder() - .addAsc('eventAt') - .getOrder(); - const requestParams = { - limit: MAX_AMOUNT_OF_LOGS, - filters: requestFilters, - order: requestOrder, - }; - const { items } = await logService.list(requestParams); - return items; -}; +const loadContainerLogFileList = async (containerRequest: ContainerRequestResource, logService: LogService) => { + const logCollectionContents = await logService.listLogFiles(containerRequest); -const createInitialLogPanelState = (logResources: LogResource[]) => { - const allLogs = logsToLines(logResources); - const mainLogs = logsToLines(logResources.filter( - e => MAIN_EVENT_TYPES.indexOf(e.eventType) > -1 + // Filter only root directory files matching log event types which have bytes + return logCollectionContents.filter((file): file is CollectionFile => ( + file.type === CollectionFileType.FILE && + PROCESS_PANEL_LOG_EVENT_TYPES.indexOf(logFileToLogType(file)) > -1 && + file.size > 0 )); - const groupedLogResources = groupBy(logResources, log => log.eventType); - const groupedLogs = Object - .keys(groupedLogResources) - .reduce((grouped, key) => ({ - ...grouped, - [key]: logsToLines(groupedLogResources[key]) - }), {}); - const filters = [MAIN_FILTER_TYPE, ALL_FILTER_TYPE, ...Object.keys(groupedLogs)]; - const logs = { - [MAIN_FILTER_TYPE]: mainLogs, - [ALL_FILTER_TYPE]: allLogs, - ...groupedLogs - }; +}; + +/** + * Loads the contents of each file from each file's lastByte simultaneously + * while respecting the maxLogFetchSize by requesting the start and end + * of the desired block and inserting a snipline. + * @param logFilesWithProgress CollectionFiles with the last byte previously loaded + * @param logService + * @param process + * @returns LogFragment[] containing a single LogFragment corresponding to each input file + */ +const loadContainerLogFileContents = async (logFilesWithProgress: FileWithProgress[], logService: LogService, process: Process) => ( + (await Promise.allSettled(logFilesWithProgress.filter(({ file }) => file.size > 0).map(({ file, lastByte }) => { + const requestSize = file.size - lastByte; + if (requestSize > maxLogFetchSize) { + const chunkSize = Math.floor(maxLogFetchSize / 2); + const firstChunkEnd = lastByte + chunkSize - 1; + return Promise.all([ + logService.getLogFileContents(process.containerRequest, file, lastByte, firstChunkEnd), + logService.getLogFileContents(process.containerRequest, file, file.size - chunkSize, file.size - 1) + ] as Promise<(LogFragment)>[]); + } else { + return Promise.all([logService.getLogFileContents(process.containerRequest, file, lastByte, file.size - 1)]); + } + })).then((res) => { + if (res.length && res.every(promiseResult => (promiseResult.status === 'rejected'))) { + // Since allSettled does not pass promise rejection we throw an + // error if every request failed + const error = res.find( + (promiseResult): promiseResult is PromiseRejectedResult => promiseResult.status === 'rejected' + )?.reason; + return Promise.reject(error); + } + return res.filter((promiseResult): promiseResult is PromiseFulfilledResult => ( + // Filter out log files with rejected promises + // (Promise.all rejects on any failure) + promiseResult.status === 'fulfilled' && + // Filter out files where any fragment is empty + // (prevent incorrect snipline generation or an un-resumable situation) + !!promiseResult.value.every(logFragment => logFragment.contents.length) + )).map(one => one.value) + })).map((logResponseSet) => { + // For any multi fragment response set, modify the last line of non-final chunks to include a line break and snip line + // Don't add snip line as a separate line so that sorting won't reorder it + for (let i = 1; i < logResponseSet.length; i++) { + const fragment = logResponseSet[i - 1]; + const lastLineIndex = fragment.contents.length - 1; + const lastLineContents = fragment.contents[lastLineIndex]; + const newLastLine = `${lastLineContents}\n${SNIPLINE}`; + + logResponseSet[i - 1].contents[lastLineIndex] = newLastLine; + } + + // Merge LogFragment Array (representing multiple log line arrays) into single LogLine[] / LogFragment + return logResponseSet.reduce((acc, curr: LogFragment) => ({ + logType: curr.logType, + contents: [...(acc.contents || []), ...curr.contents] + }), {} as LogFragment); + }) +); + +const createInitialLogPanelState = (logFiles: CollectionFile[], logFragments: LogFragment[]): { filters: string[], logs: ProcessLogs } => { + const logs = groupLogs(logFiles, logFragments); + const filters = Object.keys(logs); return { filters, logs }; +} + +/** + * Converts LogFragments into ProcessLogs, grouping and sorting All/Main logs + * @param logFiles + * @param logFragments + * @returns ProcessLogs for the store + */ +const groupLogs = (logFiles: CollectionFile[], logFragments: LogFragment[]): ProcessLogs => { + const sortableLogFragments = mergeMultilineLoglines(logFragments); + + const allLogs = mergeSortLogFragments(sortableLogFragments); + const mainLogs = mergeSortLogFragments(sortableLogFragments.filter((fragment) => (MAIN_EVENT_TYPES.includes(fragment.logType)))); + + const groupedLogs = logFragments.reduce((grouped, fragment) => ({ + ...grouped, + [fragment.logType as string]: { lastByte: fetchLastByteNumber(logFiles, fragment.logType), contents: fragment.contents } + }), {}); + + return { + [MAIN_FILTER_TYPE]: { lastByte: undefined, contents: mainLogs }, + [ALL_FILTER_TYPE]: { lastByte: undefined, contents: allLogs }, + ...groupedLogs, + } }; -const logsToLines = (logs: LogResource[]) => - logs.map(({ properties }) => properties.text); +/** + * Checks for non-timestamped log lines and merges them with the previous line, assumes they are multi-line logs + * If there is no previous line (first line has no timestamp), the line is deleted. + * Only used for combined logs that need sorting by timestamp after merging + * @param logFragments + * @returns Modified LogFragment[] + */ +const mergeMultilineLoglines = (logFragments: LogFragment[]) => ( + logFragments.map((fragment) => { + // Avoid altering the original fragment copy + let fragmentCopy: LogFragment = { + logType: fragment.logType, + contents: [...fragment.contents], + } + // Merge any non-timestamped lines in sortable log types with previous line + if (fragmentCopy.contents.length && !NON_SORTED_LOG_TYPES.includes(fragmentCopy.logType)) { + for (let i = 0; i < fragmentCopy.contents.length; i++) { + const lineContents = fragmentCopy.contents[i]; + if (!lineContents.match(LOG_TIMESTAMP_PATTERN)) { + // Partial line without timestamp detected + if (i > 0) { + // If not first line, copy line to previous line + const previousLineContents = fragmentCopy.contents[i - 1]; + const newPreviousLineContents = `${previousLineContents}\n${lineContents}`; + fragmentCopy.contents[i - 1] = newPreviousLineContents; + } + // Delete the current line and prevent iterating + fragmentCopy.contents.splice(i, 1); + i--; + } + } + } + return fragmentCopy; + }) +); + +/** + * Merges log lines of different types and sorts types that contain timestamps (are sortable) + * @param logFragments + * @returns string[] of merged and sorted log lines + */ +const mergeSortLogFragments = (logFragments: LogFragment[]): string[] => { + const sortableFragments = logFragments + .filter((fragment) => (!NON_SORTED_LOG_TYPES.includes(fragment.logType))); + + const nonSortableLines = fragmentsToLines(logFragments + .filter((fragment) => (NON_SORTED_LOG_TYPES.includes(fragment.logType))) + .sort((a, b) => (a.logType.localeCompare(b.logType)))); + + return [...nonSortableLines, ...sortLogFragments(sortableFragments)]; +}; + +/** + * Performs merge and sort of input log fragment lines + * @param logFragments set of sortable log fragments to be merged and sorted + * @returns A string array containing all lines, sorted by timestamp and + * preserving line ordering and type grouping when timestamps match + */ +const sortLogFragments = (logFragments: LogFragment[]): string[] => { + const linesWithType: SortableLine[] = logFragments + // Map each logFragment into an array of SortableLine + .map((fragment: LogFragment): SortableLine[] => ( + fragment.contents.map((singleLine: string) => { + const timestampMatch = singleLine.match(LOG_TIMESTAMP_PATTERN); + const timestamp = timestampMatch && timestampMatch[0] ? timestampMatch[0] : ""; + return { + logType: fragment.logType, + timestamp: timestamp, + contents: singleLine, + }; + }) + // Merge each array of SortableLine into single array + )).reduce((acc: SortableLine[], lines: SortableLine[]) => ( + [...acc, ...lines] + ), [] as SortableLine[]); + + return linesWithType + .sort(sortableLineSortFunc) + .map(lineWithType => lineWithType.contents); +}; + +/** + * Sort func to sort lines + * Preserves original ordering of lines from the same source + * Stably orders lines of differing type but same timestamp + * (produces a block of same-timestamped lines of one type before a block + * of same timestamped lines of another type for readability) + * Sorts all other lines by contents (ie by timestamp) + */ +const sortableLineSortFunc = (a: SortableLine, b: SortableLine) => { + if (a.logType === b.logType) { + return 0; + } else if (a.timestamp === b.timestamp) { + return a.logType.localeCompare(b.logType); + } else { + return a.contents.localeCompare(b.contents); + } +}; + +const fragmentsToLines = (fragments: LogFragment[]): string[] => ( + fragments.reduce((acc, fragment: LogFragment) => ( + acc.concat(...fragment.contents) + ), [] as string[]) +); + +const fetchLastByteNumber = (logFiles: CollectionFile[], key: string) => { + return logFiles.find((file) => (file.name.startsWith(key)))?.size +}; export const navigateToLogCollection = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { @@ -116,12 +336,10 @@ 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 })); } }; -const MAX_AMOUNT_OF_LOGS = 10000; - const ALL_FILTER_TYPE = 'All logs'; const MAIN_FILTER_TYPE = 'Main logs'; @@ -143,3 +361,8 @@ const PROCESS_PANEL_LOG_EVENT_TYPES = [ LogEventType.CONTAINER, LogEventType.KEEPSTORE, ]; + +const NON_SORTED_LOG_TYPES = [ + LogEventType.NODE_INFO, + LogEventType.CONTAINER, +]; 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 e77c300d..2111afdb 100644 --- a/src/store/process-panel/process-panel-actions.ts +++ b/src/store/process-panel/process-panel-actions.ts @@ -3,54 +3,165 @@ // SPDX-License-Identifier: AGPL-3.0 import { unionize, ofType, UnionOf } from "common/unionize"; -import { loadProcess } from 'store/processes/processes-actions'; -import { Dispatch } from 'redux'; -import { ProcessStatus } from 'store/processes/process'; -import { RootState } from 'store/store'; +import { getInputs, getOutputParameters, getRawInputs, getRawOutputs, loadProcess } from "store/processes/processes-actions"; +import { Dispatch } from "redux"; +import { ProcessStatus } from "store/processes/process"; +import { RootState } from "store/store"; import { ServiceRepository } from "services/services"; -import { navigateTo, navigateToWorkflows } from 'store/navigation/navigation-action'; -import { snackbarActions } from 'store/snackbar/snackbar-actions'; -import { SnackbarKind } from '../snackbar/snackbar-actions'; -import { showWorkflowDetails } from 'store/workflow-panel/workflow-panel-actions'; -import { loadSubprocessPanel } from "../subprocess-panel/subprocess-panel-actions"; +import { navigateTo } from "store/navigation/navigation-action"; +import { snackbarActions } from "store/snackbar/snackbar-actions"; +import { SnackbarKind } from "../snackbar/snackbar-actions"; +import { loadSubprocessPanel, subprocessPanelActions } from "../subprocess-panel/subprocess-panel-actions"; import { initProcessLogsPanel, processLogsPanelActions } from "store/process-logs-panel/process-logs-panel-actions"; +import { CollectionFile } from "models/collection-file"; +import { ContainerRequestResource } from "models/container-request"; +import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter"; +import { CommandInputParameter, getIOParamId, WorkflowInputsData } from "models/workflow"; +import { getIOParamDisplayValue, ProcessIOParameter } from "views/process-panel/process-io-card"; +import { OutputDetails, NodeInstanceType, NodeInfo } from "./process-panel"; +import { AuthState } from "store/auth/auth-reducer"; +import { ContextMenuResource } from "store/context-menu/context-menu-actions"; export const processPanelActions = unionize({ + RESET_PROCESS_PANEL: ofType<{}>(), SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID: ofType(), SET_PROCESS_PANEL_FILTERS: ofType(), TOGGLE_PROCESS_PANEL_FILTER: ofType(), + SET_INPUT_RAW: ofType(), + SET_INPUT_PARAMS: ofType(), + SET_OUTPUT_RAW: ofType(), + SET_OUTPUT_DEFINITIONS: ofType(), + SET_OUTPUT_PARAMS: ofType(), + SET_NODE_INFO: ofType(), }); export type ProcessPanelAction = UnionOf; export const toggleProcessPanelFilter = processPanelActions.TOGGLE_PROCESS_PANEL_FILTER; -export const loadProcessPanel = (uuid: string) => - async (dispatch: Dispatch) => { - 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 = (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) => { + 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) => { + const noOutputs = { rawOutputs: {} }; + + if (!containerRequest.outputUuid) { + dispatch(processPanelActions.SET_OUTPUT_RAW({ uuid: containerRequest.uuid, outputRaw: noOutputs })); + return; + } + try { + const propsOutputs = getRawOutputs(containerRequest); + const filesPromise = services.collectionService.files(containerRequest.outputUuid); + const collectionPromise = services.collectionService.get(containerRequest.outputUuid); + const [files, collection] = await Promise.all([filesPromise, collectionPromise]); + + // If has propsOutput, skip fetching cwl.output.json + if (propsOutputs !== undefined) { + dispatch( + processPanelActions.SET_OUTPUT_RAW({ + rawOutputs: propsOutputs, + pdh: collection.portableDataHash, + }) + ); + } else { + // Fetch outputs from keep + const outputFile = files.find(file => file.name === "cwl.output.json") as CollectionFile | undefined; + let outputData = outputFile ? await services.collectionService.getFileContents(outputFile) : undefined; + if (outputData && (outputData = JSON.parse(outputData)) && collection.portableDataHash) { + dispatch( + processPanelActions.SET_OUTPUT_RAW({ + uuid: containerRequest.uuid, + outputRaw: { rawOutputs: outputData, pdh: collection.portableDataHash }, + }) + ); + } else { + dispatch(processPanelActions.SET_OUTPUT_RAW({ uuid: containerRequest.uuid, outputRaw: noOutputs })); + } + } + } catch { + dispatch(processPanelActions.SET_OUTPUT_RAW({ uuid: containerRequest.uuid, outputRaw: noOutputs })); + } }; -export const navigateToOutput = (uuid: string) => - 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 { - await services.collectionService.get(uuid); - dispatch(navigateTo(uuid)); + const filesPromise = services.collectionService.files(containerRequest.logUuid); + const collectionPromise = services.collectionService.get(containerRequest.logUuid); + const [files] = await Promise.all([filesPromise, collectionPromise]); + + // Fetch node.json from keep + const nodeFile = files.find(file => file.name === "node.json") as CollectionFile | undefined; + let nodeData = nodeFile ? await services.collectionService.getFileContents(nodeFile) : undefined; + if (nodeData && (nodeData = JSON.parse(nodeData))) { + dispatch( + processPanelActions.SET_NODE_INFO({ + nodeInfo: nodeData as NodeInstanceType, + }) + ); + } else { + dispatch(processPanelActions.SET_NODE_INFO(noLog)); + } } catch { - dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'This collection does not exists!', hideDuration: 2000, kind: SnackbarKind.ERROR })); + dispatch(processPanelActions.SET_NODE_INFO(noLog)); } }; -export const openWorkflow = (uuid: string) => - (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - dispatch(navigateToWorkflows); - dispatch(showWorkflowDetails(uuid)); +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; + + 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(navigateTo(uuid)); +}; + export const initProcessPanelFilters = processPanelActions.SET_PROCESS_PANEL_FILTERS([ ProcessStatus.QUEUED, ProcessStatus.COMPLETED, @@ -59,5 +170,30 @@ export const initProcessPanelFilters = processPanelActions.SET_PROCESS_PANEL_FIL ProcessStatus.ONHOLD, ProcessStatus.FAILING, ProcessStatus.WARNING, - ProcessStatus.CANCELLED + ProcessStatus.CANCELLED, ]); + +export const formatInputData = (inputs: CommandInputParameter[], auth: AuthState): ProcessIOParameter[] => { + return inputs.map(input => { + return { + id: getIOParamId(input), + label: input.label || "", + value: getIOParamDisplayValue(auth, input), + }; + }); +}; + +export const formatOutputData = ( + definitions: CommandOutputParameter[], + values: any, + pdh: string | undefined, + auth: AuthState +): ProcessIOParameter[] => { + return definitions.map(output => { + return { + id: getIOParamId(output), + label: output.label || "", + value: getIOParamDisplayValue(auth, Object.assign(output, { value: values[getIOParamId(output)] || [] }), pdh), + }; + }); +}; diff --git a/src/store/process-panel/process-panel-reducer.ts b/src/store/process-panel/process-panel-reducer.ts index d26e7693..ea6de66d 100644 --- a/src/store/process-panel/process-panel-reducer.ts +++ b/src/store/process-panel/process-panel-reducer.ts @@ -2,18 +2,26 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { ProcessPanel } from 'store/process-panel/process-panel'; -import { ProcessPanelAction, processPanelActions } from 'store/process-panel/process-panel-actions'; +import { ProcessPanel } from "store/process-panel/process-panel"; +import { ProcessPanelAction, processPanelActions } from "store/process-panel/process-panel-actions"; const initialState: ProcessPanel = { containerRequestUuid: "", - filters: {} + filters: {}, + inputRaw: null, + inputParams: null, + outputRaw: null, + nodeInfo: null, + outputDefinitions: [], + outputParams: null, }; export const processPanelReducer = (state = initialState, action: ProcessPanelAction): ProcessPanel => processPanelActions.match(action, { + RESET_PROCESS_PANEL: () => initialState, SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID: containerRequestUuid => ({ - ...state, containerRequestUuid + ...state, + containerRequestUuid, }), SET_PROCESS_PANEL_FILTERS: statuses => { const filters = statuses.reduce((filters, status) => ({ ...filters, [status]: true }), {}); @@ -23,5 +31,44 @@ export const processPanelReducer = (state = initialState, action: ProcessPanelAc const filters = { ...state.filters, [status]: !state.filters[status] }; return { ...state, filters }; }, + SET_INPUT_RAW: inputRaw => { + // Since mounts can disappear and reappear, only set inputs + // if current state is null or new inputs has content + if (state.inputRaw === null || (inputRaw && Object.keys(inputRaw).length)) { + return { ...state, inputRaw }; + } else { + return state; + } + }, + SET_INPUT_PARAMS: inputParams => { + // Since mounts can disappear and reappear, only set inputs + // if current state is null or new inputs has content + if (state.inputParams === null || (inputParams && inputParams.length)) { + return { ...state, inputParams }; + } else { + return state; + } + }, + SET_OUTPUT_RAW: (data: any) => { + //never set output to {} unless initializing + if (state.outputRaw?.rawOutputs && Object.keys(state.outputRaw?.rawOutputs).length && state.containerRequestUuid === data.uuid) { + return state; + } + return { ...state, outputRaw: data.outputRaw }; + }, + SET_NODE_INFO: ({ nodeInfo }) => { + return { ...state, nodeInfo }; + }, + SET_OUTPUT_DEFINITIONS: outputDefinitions => { + // Set output definitions is only additive to avoid clearing when mounts go temporarily missing + if (outputDefinitions.length) { + return { ...state, outputDefinitions }; + } else { + return state; + } + }, + SET_OUTPUT_PARAMS: outputParams => { + return { ...state, outputParams }; + }, default: () => state, }); diff --git a/src/store/process-panel/process-panel.ts b/src/store/process-panel/process-panel.ts index 49c2691d..1ec60ff5 100644 --- a/src/store/process-panel/process-panel.ts +++ b/src/store/process-panel/process-panel.ts @@ -2,16 +2,53 @@ // // SPDX-License-Identifier: AGPL-3.0 +import { WorkflowInputsData } from 'models/workflow'; import { RouterState } from "react-router-redux"; import { matchProcessRoute } from "routes/routes"; +import { ProcessIOParameter } from "views/process-panel/process-io-card"; +import { CommandOutputParameter } from 'cwlts/mappings/v1.0/CommandOutputParameter'; + +export type OutputDetails = { + rawOutputs?: any; + pdh?: string; +} + +export interface CUDAFeatures { + DriverVersion: string; + HardwareCapability: string; + DeviceCount: number; +} + +export interface NodeInstanceType { + Name: string; + ProviderType: string; + VCPUs: number; + RAM: number; + Scratch: number; + IncludedScratch: number; + AddedScratch: number; + Price: number; + Preemptible: boolean; + CUDA: CUDAFeatures; +}; + +export interface NodeInfo { + nodeInfo: NodeInstanceType | null; +}; export interface ProcessPanel { containerRequestUuid: string; filters: { [status: string]: boolean }; + inputRaw: WorkflowInputsData | null; + inputParams: ProcessIOParameter[] | null; + outputRaw: OutputDetails | null; + outputDefinitions: CommandOutputParameter[]; + outputParams: ProcessIOParameter[] | null; + nodeInfo: NodeInstanceType | null; } export const getProcessPanelCurrentUuid = (router: RouterState) => { const pathname = router.location ? router.location.pathname : ''; const match = matchProcessRoute(pathname); return match ? match.params.id : undefined; -}; \ No newline at end of file +}; diff --git a/src/store/processes/process-copy-actions.test.ts b/src/store/processes/process-copy-actions.test.ts new file mode 100644 index 00000000..cb064ed8 --- /dev/null +++ b/src/store/processes/process-copy-actions.test.ts @@ -0,0 +1,483 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import React from 'react'; +import { configure } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import { copyProcess } from './process-copy-actions'; +import { CommonService } from 'services/common-service/common-service'; +import { snakeCase } from 'lodash'; + +configure({ adapter: new Adapter() }); + +describe('ProcessCopyAction', () => { + // let props; + let dispatch: any, getState: any, services: any; + + let sampleFailedProcess = { + command: [ + "arvados-cwl-runner", + "--api=containers", + "--local", + "--project-uuid=zzzzz-j7d0g-yr18k784zplfeza", + "/var/lib/cwl/workflow.json#main", + "/var/lib/cwl/cwl.input.json", + ], + container_count: 1, + container_count_max: 10, + container_image: "arvados/jobs", + container_uuid: "zzzzz-dz642-b9j9dtk1yikp9h0", + created_at: "2023-01-23T22:50:50.788284000Z", + cumulative_cost: 0.00120553009559028, + cwd: "/var/spool/cwl", + description: "test decsription", + environment: {}, + etag: "2es6px6q7uo0yqi2i291x8gd6", + expires_at: null, + filters: null, + href: "/container_requests/zzzzz-xvhdp-111111111111111", + kind: "arvados#containerRequest", + log_uuid: "zzzzz-4zz18-a1gxqy9o6zyrdy8", + modified_at: "2023-01-24T21:13:54.772612000Z", + modified_by_client_uuid: "zzzzz-ozdt8-q6dzdi1lcc03155", + modified_by_user_uuid: "jutro-tpzed-vllbpebicy84rd5", + mounts: { + "/var/lib/cwl/cwl.input.json": { + capacity: 0, + commit: "", + content: { + input: { + basename: "logo.ai.no.whitespace.png", + class: "File", + location: + "keep:5d3238c4db721a92c98b0305a47b0485+75/logo.ai.no.whitespace.png", + }, + reverse_sort: true, + }, + device_type: "", + exclude_from_output: false, + git_url: "", + kind: "json", + path: "", + portable_data_hash: "", + repository_name: "", + uuid: "", + writable: false, + }, + "/var/lib/cwl/workflow.json": { + capacity: 0, + commit: "", + content: { + $graph: [ + { + class: "Workflow", + doc: "Reverse the lines in a document, then sort those lines.", + id: "#main", + inputs: [ + { + default: null, + doc: "The input file to be processed.", + id: "#main/input", + type: "File", + }, + { + default: true, + doc: "If true, reverse (decending) sort", + id: "#main/reverse_sort", + type: "boolean", + }, + ], + outputs: [ + { + doc: "The output with the lines reversed and sorted.", + id: "#main/output", + outputSource: "#main/sorted/output", + type: "File", + }, + ], + steps: [ + { + id: "#main/rev", + in: [{ id: "#main/rev/input", source: "#main/input" }], + out: ["#main/rev/output"], + run: "#revtool.cwl", + }, + { + id: "#main/sorted", + in: [ + { id: "#main/sorted/input", source: "#main/rev/output" }, + { + id: "#main/sorted/reverse", + source: "#main/reverse_sort", + }, + ], + out: ["#main/sorted/output"], + run: "#sorttool.cwl", + }, + ], + }, + { + baseCommand: "rev", + class: "CommandLineTool", + doc: "Reverse each line using the `rev` command", + hints: [{ class: "ResourceRequirement", ramMin: 8 }], + id: "#revtool.cwl", + inputs: [ + { id: "#revtool.cwl/input", inputBinding: {}, type: "File" }, + ], + outputs: [ + { + id: "#revtool.cwl/output", + outputBinding: { glob: "output.txt" }, + type: "File", + }, + ], + stdout: "output.txt", + }, + { + baseCommand: "sort", + class: "CommandLineTool", + doc: "Sort lines using the `sort` command", + hints: [{ class: "ResourceRequirement", ramMin: 8 }], + id: "#sorttool.cwl", + inputs: [ + { + id: "#sorttool.cwl/reverse", + inputBinding: { position: 1, prefix: "-r" }, + type: "boolean", + }, + { + id: "#sorttool.cwl/input", + inputBinding: { position: 2 }, + type: "File", + }, + ], + outputs: [ + { + id: "#sorttool.cwl/output", + outputBinding: { glob: "output.txt" }, + type: "File", + }, + ], + stdout: "output.txt", + }, + ], + cwlVersion: "v1.0", + }, + device_type: "", + exclude_from_output: false, + git_url: "", + kind: "json", + path: "", + portable_data_hash: "", + repository_name: "", + uuid: "", + writable: false, + }, + "/var/spool/cwl": { + capacity: 0, + commit: "", + content: null, + device_type: "", + exclude_from_output: false, + git_url: "", + kind: "collection", + path: "", + portable_data_hash: "", + repository_name: "", + uuid: "", + writable: true, + }, + stdout: { + capacity: 0, + commit: "", + content: null, + device_type: "", + exclude_from_output: false, + git_url: "", + kind: "file", + path: "/var/spool/cwl/cwl.output.json", + portable_data_hash: "", + repository_name: "", + uuid: "", + writable: false, + }, + }, + name: "Copy of: Copy of: Copy of: revsort.cwl", + output_name: "Output from revsort.cwl", + output_path: "/var/spool/cwl", + output_properties: { key: "val" }, + output_storage_classes: ["default"], + output_ttl: 999999, + output_uuid: "zzzzz-4zz18-wolwlyfxmlhmgd4", + owner_uuid: "zzzzz-j7d0g-yr18k784zplfeza", + priority: 500, + properties: { + template_uuid: "zzzzz-7fd4e-7xsza0vgfe785cy", + workflowName: "revsort.cwl", + }, + requesting_container_uuid: null, + runtime_constraints: { + API: true, + cuda: { device_count: 0, driver_version: "", hardware_capability: "" }, + keep_cache_disk: 0, + keep_cache_ram: 0, + ram: 1342177280, + vcpus: 1, + }, + runtime_token: "", + scheduling_parameters: { + max_run_time: 0, + partitions: [], + preemptible: false, + }, + state: "Final", + use_existing: false, + uuid: "zzzzz-xvhdp-111111111111111", + }; + + let expectedContainerRequest = { + command: [ + "arvados-cwl-runner", + "--api=containers", + "--local", + "--project-uuid=zzzzz-j7d0g-yr18k784zplfeza", + "/var/lib/cwl/workflow.json#main", + "/var/lib/cwl/cwl.input.json", + ], + container_count_max: 10, + container_image: "arvados/jobs", + cwd: "/var/spool/cwl", + description: "test decsription", + environment: {}, + kind: "arvados#containerRequest", + mounts: { + "/var/lib/cwl/cwl.input.json": { + capacity: 0, + commit: "", + content: { + input: { + basename: "logo.ai.no.whitespace.png", + class: "File", + location: + "keep:5d3238c4db721a92c98b0305a47b0485+75/logo.ai.no.whitespace.png", + }, + reverse_sort: true, + }, + device_type: "", + exclude_from_output: false, + git_url: "", + kind: "json", + path: "", + portable_data_hash: "", + repository_name: "", + uuid: "", + writable: false, + }, + "/var/lib/cwl/workflow.json": { + capacity: 0, + commit: "", + content: { + $graph: [ + { + class: "Workflow", + doc: "Reverse the lines in a document, then sort those lines.", + id: "#main", + inputs: [ + { + default: null, + doc: "The input file to be processed.", + id: "#main/input", + type: "File", + }, + { + default: true, + doc: "If true, reverse (decending) sort", + id: "#main/reverse_sort", + type: "boolean", + }, + ], + outputs: [ + { + doc: "The output with the lines reversed and sorted.", + id: "#main/output", + outputSource: "#main/sorted/output", + type: "File", + }, + ], + steps: [ + { + id: "#main/rev", + in: [{ id: "#main/rev/input", source: "#main/input" }], + out: ["#main/rev/output"], + run: "#revtool.cwl", + }, + { + id: "#main/sorted", + in: [ + { + id: "#main/sorted/input", + source: "#main/rev/output", + }, + { + id: "#main/sorted/reverse", + source: "#main/reverse_sort", + }, + ], + out: ["#main/sorted/output"], + run: "#sorttool.cwl", + }, + ], + }, + { + baseCommand: "rev", + class: "CommandLineTool", + doc: "Reverse each line using the `rev` command", + hints: [{ class: "ResourceRequirement", ramMin: 8 }], + id: "#revtool.cwl", + inputs: [ + { + id: "#revtool.cwl/input", + inputBinding: {}, + type: "File", + }, + ], + outputs: [ + { + id: "#revtool.cwl/output", + outputBinding: { glob: "output.txt" }, + type: "File", + }, + ], + stdout: "output.txt", + }, + { + baseCommand: "sort", + class: "CommandLineTool", + doc: "Sort lines using the `sort` command", + hints: [{ class: "ResourceRequirement", ramMin: 8 }], + id: "#sorttool.cwl", + inputs: [ + { + id: "#sorttool.cwl/reverse", + inputBinding: { position: 1, prefix: "-r" }, + type: "boolean", + }, + { + id: "#sorttool.cwl/input", + inputBinding: { position: 2 }, + type: "File", + }, + ], + outputs: [ + { + id: "#sorttool.cwl/output", + outputBinding: { glob: "output.txt" }, + type: "File", + }, + ], + stdout: "output.txt", + }, + ], + cwlVersion: "v1.0", + }, + device_type: "", + exclude_from_output: false, + git_url: "", + kind: "json", + path: "", + portable_data_hash: "", + repository_name: "", + uuid: "", + writable: false, + }, + "/var/spool/cwl": { + capacity: 0, + commit: "", + content: null, + device_type: "", + exclude_from_output: false, + git_url: "", + kind: "collection", + path: "", + portable_data_hash: "", + repository_name: "", + uuid: "", + writable: true, + }, + stdout: { + capacity: 0, + commit: "", + content: null, + device_type: "", + exclude_from_output: false, + git_url: "", + kind: "file", + path: "/var/spool/cwl/cwl.output.json", + portable_data_hash: "", + repository_name: "", + uuid: "", + writable: false, + }, + }, + name: "newname.cwl", + output_name: "Output from revsort.cwl", + output_path: "/var/spool/cwl", + output_properties: { key: "val" }, + output_storage_classes: ["default"], + output_ttl: 999999, + owner_uuid: "zzzzz-j7d0g-000000000000000", + priority: 500, + properties: { + template_uuid: "zzzzz-7fd4e-7xsza0vgfe785cy", + workflowName: "revsort.cwl", + }, + runtime_constraints: { + API: true, + cuda: { + device_count: 0, + driver_version: "", + hardware_capability: "", + }, + keep_cache_disk: 0, + keep_cache_ram: 0, + ram: 1342177280, + vcpus: 1, + }, + scheduling_parameters: { + max_run_time: 0, + partitions: [], + preemptible: false, + }, + state: "Uncommitted", + use_existing: false, + }; + + beforeEach(() => { + dispatch = jest.fn(); + services = { + containerRequestService: { + get: jest.fn().mockImplementation(async () => (CommonService.mapResponseKeys({data: sampleFailedProcess}))), + create: jest.fn().mockImplementation(async (data) => (CommonService.mapKeys(snakeCase)(data))), + }, + }; + getState = () => ({ + auth: {}, + }); + }); + + it("should request the failed process and return a copy with the proper fields", async () => { + // when + const newprocess = await copyProcess({ + name: "newname.cwl", + uuid: "zzzzz-xvhdp-111111111111111", + ownerUuid: "zzzzz-j7d0g-000000000000000", + })(dispatch, getState, services); + + // then + expect(services.containerRequestService.get).toHaveBeenCalledWith("zzzzz-xvhdp-111111111111111"); + expect(newprocess).toEqual(expectedContainerRequest); + + }); +}); diff --git a/src/store/processes/process-copy-actions.ts b/src/store/processes/process-copy-actions.ts index 57e85397..36d73940 100644 --- a/src/store/processes/process-copy-actions.ts +++ b/src/store/processes/process-copy-actions.ts @@ -2,21 +2,23 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { Dispatch } from "redux"; -import { dialogActions } from "store/dialog/dialog-actions"; +import { Dispatch } from 'redux'; +import { dialogActions } from 'store/dialog/dialog-actions'; import { initialize, startSubmit } from 'redux-form'; import { resetPickerProjectTree } from 'store/project-tree-picker/project-tree-picker-actions'; import { RootState } from 'store/store'; import { ServiceRepository } from 'services/services'; import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog'; import { getProcess } from 'store/processes/process'; -import {snackbarActions, SnackbarKind} from 'store/snackbar/snackbar-actions'; +import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions'; +import { ContainerRequestState } from 'models/container-request'; export const PROCESS_COPY_FORM_NAME = 'processCopyFormName'; +export const MULTI_PROCESS_COPY_FORM_NAME = 'multiProcessCopyFormName'; -export const openCopyProcessDialog = (resource: { name: string, uuid: string }) => - (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { +export const openCopyProcessDialog = + (resource: { name: string; uuid: string }) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const process = getProcess(resource.uuid)(getState().resources); if (process) { dispatch(resetPickerProjectTree()); @@ -29,17 +31,56 @@ export const openCopyProcessDialog = (resource: { name: string, uuid: string }) } }; -export const copyProcess = (resource: CopyFormDialogData) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - dispatch(startSubmit(PROCESS_COPY_FORM_NAME)); - try { - const process = await services.containerRequestService.get(resource.uuid); - const { kind, containerImage, outputPath, outputName, containerCountMax, command, properties, requestingContainerUuid, mounts, runtimeConstraints, schedulingParameters, environment, cwd, outputTtl, priority, expiresAt, useExisting, filters } = process; - await services.containerRequestService.create({ command, containerImage, outputPath, ownerUuid: resource.ownerUuid, name: resource.name, kind, outputName, containerCountMax, properties, requestingContainerUuid, mounts, runtimeConstraints, schedulingParameters, environment, cwd, outputTtl, priority, expiresAt, useExisting, filters }); - dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME })); - return process; - } catch (e) { - dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME })); - throw new Error('Could not copy the process.'); - } - }; \ No newline at end of file +export const copyProcess = (resource: CopyFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(startSubmit(PROCESS_COPY_FORM_NAME)); + try { + const process = await services.containerRequestService.get(resource.uuid); + const { + command, + containerCountMax, + containerImage, + cwd, + description, + environment, + kind, + mounts, + outputName, + outputPath, + outputProperties, + outputStorageClasses, + outputTtl, + properties, + runtimeConstraints, + schedulingParameters, + useExisting, + } = process; + const newProcess = await services.containerRequestService.create({ + command, + containerCountMax, + containerImage, + cwd, + description, + environment, + kind, + mounts, + name: resource.name, + outputName, + outputPath, + outputProperties, + outputStorageClasses, + outputTtl, + ownerUuid: resource.ownerUuid, + priority: 500, + properties, + runtimeConstraints, + schedulingParameters, + state: ContainerRequestState.UNCOMMITTED, + useExisting, + }); + dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME })); + return newProcess; + } catch (e) { + dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME })); + throw new Error('Could not copy the process.'); + } +}; 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 19f30dd2..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 => { @@ -73,37 +75,93 @@ export const getProcessRuntime = ({ container }: Process) => { } }; -export const getProcessStatusColor = (status: string, { customs }: ArvadosTheme) => { + +export const getProcessStatusStyles = (status: string, theme: ArvadosTheme): React.CSSProperties => { + let color = theme.customs.colors.grey500; + let running = false; switch (status) { case ProcessStatus.RUNNING: - return customs.colors.blue500; + color = theme.customs.colors.green800; + running = true; + break; case ProcessStatus.COMPLETED: - return customs.colors.green700; + case ProcessStatus.REUSED: + color = theme.customs.colors.green800; + break; case ProcessStatus.WARNING: - return customs.colors.yellow700; + color = theme.customs.colors.green800; + running = true; + break; case ProcessStatus.FAILING: - return customs.colors.orange; + color = theme.customs.colors.red900; + running = true; + break; + case ProcessStatus.CANCELLING: + color = theme.customs.colors.red900; + running = true; + break; case ProcessStatus.CANCELLED: case ProcessStatus.FAILED: - return customs.colors.red900; + color = theme.customs.colors.red900; + break; + case ProcessStatus.QUEUED: + color = theme.customs.colors.grey600; + running = true; + break; default: - return customs.colors.grey500; + color = theme.customs.colors.grey600; + break; } + + // Using color and running we build the text, border, and background style properties + return { + // Set background color when not running, otherwise use white + backgroundColor: running ? theme.palette.common.white : color, + // Set text color to status color when running, else use white text for solid button + color: running ? color : theme.palette.common.white, + // Set border color when running, else omit the style entirely + ...(running ? { border: `2px solid ${color}` } : {}), + }; }; export const getProcessStatus = ({ containerRequest, container }: Process): ProcessStatus => { switch (true) { + case containerRequest.containerUuid && !container: + return ProcessStatus.UNKNOWN; + + case containerRequest.state === ContainerRequestState.UNCOMMITTED: + return ProcessStatus.DRAFT; + + case containerRequest.state === ContainerRequestState.FINAL && + container?.state === ContainerState.RUNNING: + // It is about to be completed but we haven't + // gotten the updated container record yet, + // if we don't catch this and show it as "Running" + // it will flicker "Cancelled" briefly + return ProcessStatus.RUNNING; + case containerRequest.state === ContainerRequestState.FINAL && container?.state !== ContainerState.COMPLETE: // Request was finalized before its container started (or the // container was cancelled) return ProcessStatus.CANCELLED; - case containerRequest.state === ContainerRequestState.UNCOMMITTED: - return ProcessStatus.DRAFT; - - case container?.state === ContainerState.COMPLETE: + case container && container.state === ContainerState.COMPLETE: if (container?.exitCode === 0) { + if (containerRequest && container.finishedAt) { + // don't compare on createdAt because the container can + // have a slightly earlier creation time when it is created + // in the same transaction as the container request. + // use finishedAt because most people will assume "reused" means + // no additional work needed to be done, it's possible + // to share a running container but calling it "reused" in that case + // is more likely to just be confusing. + const finishedAt = new Date(container.finishedAt).getTime(); + const createdAt = new Date(containerRequest.createdAt).getTime(); + if (finishedAt < createdAt) { + return ProcessStatus.REUSED; + } + } return ProcessStatus.COMPLETED; } return ProcessStatus.FAILED; @@ -119,6 +177,9 @@ export const getProcessStatus = ({ containerRequest, container }: Process): Proc return ProcessStatus.QUEUED; case container?.state === ContainerState.RUNNING: + if (container?.priority === 0) { + return ProcessStatus.CANCELLING; + } if (!!container?.runtimeStatus.error) { return ProcessStatus.FAILING; } @@ -132,6 +193,32 @@ export const getProcessStatus = ({ containerRequest, container }: Process): Proc } }; +export const isProcessRunning = ({ container }: Process): boolean => ( + container?.state === ContainerState.RUNNING +); + +export const isProcessRunnable = ({ containerRequest }: Process): boolean => ( + containerRequest.state === ContainerRequestState.UNCOMMITTED +); + +export const isProcessResumable = ({ containerRequest, container }: Process): boolean => ( + containerRequest.state === ContainerRequestState.COMMITTED && + containerRequest.priority === 0 && + // Don't show run button when container is present & running or cancelled + !(container && (container.state === ContainerState.RUNNING || + container.state === ContainerState.CANCELLED || + container.state === ContainerState.COMPLETE)) +); + +export const isProcessCancelable = ({ containerRequest, container }: Process): boolean => ( + containerRequest.priority !== null && + containerRequest.priority > 0 && + container !== undefined && + (container.state === ContainerState.QUEUED || + container.state === ContainerState.LOCKED || + container.state === ContainerState.RUNNING) +); + const isSubprocess = (containerUuid: string) => (resource: Resource) => resource.kind === ResourceKind.CONTAINER_REQUEST && (resource as ContainerRequestResource).requestingContainerUuid === containerUuid; diff --git a/src/store/processes/processes-actions.ts b/src/store/processes/processes-actions.ts index 213e292b..eadb05e5 100644 --- a/src/store/processes/processes-actions.ts +++ b/src/store/processes/processes-actions.ts @@ -3,62 +3,176 @@ // SPDX-License-Identifier: AGPL-3.0 import { Dispatch } from "redux"; -import { RootState } from 'store/store'; -import { ServiceRepository } from 'services/services'; -import { updateResources } from 'store/resources/resources-actions'; -import { Process } from './process'; -import { dialogActions } from 'store/dialog/dialog-actions'; -import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; -import { projectPanelActions } from 'store/project-panel/project-panel-action'; -import { navigateToRunProcess } from 'store/navigation/navigation-action'; -import { goToStep, runProcessPanelActions } from 'store/run-process-panel/run-process-panel-actions'; -import { getResource } from 'store/resources/resources'; +import { RootState } from "store/store"; +import { ServiceRepository } from "services/services"; +import { updateResources } from "store/resources/resources-actions"; +import { Process } from "./process"; +import { dialogActions } from "store/dialog/dialog-actions"; +import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions"; +import { projectPanelActions } from "store/project-panel/project-panel-action-bind"; +import { navigateToRunProcess } from "store/navigation/navigation-action"; +import { goToStep, runProcessPanelActions } from "store/run-process-panel/run-process-panel-actions"; +import { getResource } from "store/resources/resources"; import { initialize } from "redux-form"; import { RUN_PROCESS_BASIC_FORM, RunProcessBasicFormData } from "views/run-process-panel/run-process-basic-form"; import { RunProcessAdvancedFormData, RUN_PROCESS_ADVANCED_FORM } from "views/run-process-panel/run-process-advanced-form"; -import { MOUNT_PATH_CWL_WORKFLOW, MOUNT_PATH_CWL_INPUT } from 'models/process'; -import { getWorkflow, getWorkflowInputs } from "models/workflow"; +import { MOUNT_PATH_CWL_WORKFLOW, MOUNT_PATH_CWL_INPUT } from "models/process"; +import { CommandInputParameter, getWorkflow, getWorkflowInputs, getWorkflowOutputs, WorkflowInputsData } from "models/workflow"; import { ProjectResource } from "models/project"; import { UserResource } from "models/user"; +import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter"; +import { ContainerResource } from "models/container"; +import { ContainerRequestResource, ContainerRequestState } from "models/container-request"; +import { FilterBuilder } from "services/api/filter-builder"; +import { selectedToArray } from "components/multiselect-toolbar/MultiselectToolbar"; +import { Resource, ResourceKind } from "models/resource"; +import { ContextMenuResource } from "store/context-menu/context-menu-actions"; +import { CommonResourceServiceError, getCommonResourceServiceError } from "services/common-service/common-resource-service"; -export const loadProcess = (containerRequestUuid: string) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise => { - const containerRequest = await services.containerRequestService.get(containerRequestUuid); - dispatch(updateResources([containerRequest])); +export const loadProcess = + (containerRequestUuid: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise => { + let containerRequest: ContainerRequestResource | undefined = undefined; + try { + containerRequest = await services.containerRequestService.get(containerRequestUuid); + dispatch(updateResources([containerRequest])); + } catch { + return undefined; + } + + if (containerRequest.outputUuid) { + try { + const collection = await services.collectionService.get(containerRequest.outputUuid, false); + dispatch(updateResources([collection])); + } catch {} + } if (containerRequest.containerUuid) { - const container = await services.containerService.get(containerRequest.containerUuid); - dispatch(updateResources([container])); + let container: ContainerResource | undefined = undefined; + try { + container = await services.containerService.get(containerRequest.containerUuid, false); + dispatch(updateResources([container])); + } catch {} + + try { + if (container && container.runtimeUserUuid) { + const runtimeUser = await services.userService.get(container.runtimeUserUuid, false); + dispatch(updateResources([runtimeUser])); + } + } catch {} + return { containerRequest, container }; } return { containerRequest }; }; -export const loadContainers = (filters: string) => +export const loadContainers = + (containerUuids: string[], loadMounts: boolean = true) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const { items } = await services.containerService.list({ filters }); + let args: any = { + filters: new FilterBuilder().addIn("uuid", containerUuids).getFilters(), + limit: containerUuids.length, + }; + if (!loadMounts) { + args.select = containerFieldsNoMounts; + } + const { items } = await services.containerService.list(args); dispatch(updateResources(items)); return items; }; -export const cancelRunningWorkflow = (uuid: string) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - try { - const process = await services.containerRequestService.update(uuid, { priority: 0 }); - return process; - } catch (e) { - throw new Error('Could not cancel the process.'); +// Until the api supports unselecting fields, we need a list of all other fields to omit mounts +const containerFieldsNoMounts = [ + "auth_uuid", + "command", + "container_image", + "cost", + "created_at", + "cwd", + "environment", + "etag", + "exit_code", + "finished_at", + "gateway_address", + "href", + "interactive_session_started", + "kind", + "lock_count", + "locked_by_uuid", + "log", + "modified_at", + "modified_by_client_uuid", + "modified_by_user_uuid", + "output_path", + "output_properties", + "output_storage_classes", + "output", + "owner_uuid", + "priority", + "progress", + "runtime_auth_scopes", + "runtime_constraints", + "runtime_status", + "runtime_user_uuid", + "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 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 reRunProcess = (processUuid: string, workflowUuid: string) => - (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { +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) => { 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 }; @@ -72,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)); @@ -85,41 +199,145 @@ export const reRunProcess = (processUuid: string, workflowUuid: string) => } }; -const getInputs = (data: any) => { - if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) { return []; } +/* + * Fetches raw inputs from containerRequest mounts with fallback to properties + * Returns undefined if containerRequest not loaded + * Returns {} if inputs not found in mounts or props + */ +export const getRawInputs = (data: any): WorkflowInputsData | undefined => { + if (!data) { + return undefined; + } + const mountInput = data.mounts?.[MOUNT_PATH_CWL_INPUT]?.content; + const propsInput = data.properties?.cwl_input; + if (!mountInput && !propsInput) { + return {}; + } + return mountInput || propsInput; +}; + +export const getInputs = (data: any): CommandInputParameter[] => { + // Definitions from mounts are needed so we return early if missing + if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) { + return []; + } + const content = getRawInputs(data) as any; + // Only escape if content is falsy to allow displaying definitions if no inputs are present + // (Don't check raw content length) + if (!content) { + return []; + } + const inputs = getWorkflowInputs(data.mounts[MOUNT_PATH_CWL_WORKFLOW].content); - return inputs ? inputs.map( - (it: any) => ( - { - type: it.type, - id: it.id, - label: it.label, - default: data.mounts[MOUNT_PATH_CWL_INPUT].content[it.id], - doc: it.doc - } - ) - ) : []; + return inputs + ? inputs.map((it: any) => ({ + type: it.type, + id: it.id, + label: it.label, + default: content[it.id], + value: content[it.id.split("/").pop()] || [], + doc: it.doc, + })) + : []; }; -export const openRemoveProcessDialog = (uuid: string) => - (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - dispatch(dialogActions.OPEN_DIALOG({ - id: REMOVE_PROCESS_DIALOG, - data: { - title: 'Remove process permanently', - text: 'Are you sure you want to remove this process?', - confirmButtonLabel: 'Remove', - uuid - } +/* + * Fetches raw outputs from containerRequest properties + * Assumes containerRequest is loaded + */ +export const getRawOutputs = (data: any): CommandInputParameter[] | undefined => { + if (!data || !data.properties || !data.properties.cwl_output) { + return undefined; + } + return data.properties.cwl_output; +}; + +export type InputCollectionMount = { + path: string; + pdh: string; +}; + +export const getInputCollectionMounts = (data: any): InputCollectionMount[] => { + if (!data || !data.mounts) { + return []; + } + return Object.keys(data.mounts) + .map(key => ({ + ...data.mounts[key], + path: key, + })) + .filter(mount => mount.kind === "collection" && mount.portable_data_hash && mount.path) + .map(mount => ({ + path: mount.path, + pdh: mount.portable_data_hash, })); - }; +}; -export const REMOVE_PROCESS_DIALOG = 'removeProcessDialog'; +export const getOutputParameters = (data: any): CommandOutputParameter[] => { + if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) { + return []; + } + const outputs = getWorkflowOutputs(data.mounts[MOUNT_PATH_CWL_WORKFLOW].content); + return outputs + ? outputs.map((it: any) => ({ + type: it.type, + id: it.id, + label: it.label, + doc: it.doc, + })) + : []; +}; -export const removeProcessPermanently = (uuid: string) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...', kind: SnackbarKind.INFO })); - await services.containerRequestService.delete(uuid); - dispatch(projectPanelActions.REQUEST_ITEMS()); - dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS })); +export const openRemoveProcessDialog = + (resource: ContextMenuResource, numOfProcesses: Number) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const confirmationText = + numOfProcesses === 1 + ? "Are you sure you want to remove this process?" + : `Are you sure you want to remove these ${numOfProcesses} processes?`; + const titleText = numOfProcesses === 1 ? "Remove process permanently" : "Remove processes permanently"; + + dispatch( + dialogActions.OPEN_DIALOG({ + id: REMOVE_PROCESS_DIALOG, + data: { + title: titleText, + text: confirmationText, + confirmButtonLabel: "Remove", + uuid: resource.uuid, + resource, + }, + }) + ); }; + +export const REMOVE_PROCESS_DIALOG = "removeProcessDialog"; + +export const removeProcessPermanently = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const resource = getState().dialog.removeProcessDialog.data.resource; + const checkedList = getState().multiselect.checkedList; + + const uuidsToRemove: string[] = resource.fromContextMenu ? [resource.uuid] : selectedToArray(checkedList); + + //if no items in checkedlist, default to normal context menu behavior + if (!uuidsToRemove.length) uuidsToRemove.push(uuid); + + const processesToRemove = uuidsToRemove + .map(uuid => getResource(uuid)(getState().resources) as Resource) + .filter(resource => resource.kind === ResourceKind.PROCESS); + + for (const process of processesToRemove) { + try { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Removing ...", kind: SnackbarKind.INFO })); + await services.containerRequestService.delete(process.uuid, false); + dispatch(projectPanelActions.REQUEST_ITEMS()); + dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Removed.", hideDuration: 2000, kind: SnackbarKind.SUCCESS })); + } catch (e) { + const error = getCommonResourceServiceError(e); + if (error === CommonResourceServiceError.PERMISSION_ERROR_FORBIDDEN) { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: `Access denied`, hideDuration: 2000, kind: SnackbarKind.ERROR })); + } else { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: `Deletion failed`, hideDuration: 2000, kind: SnackbarKind.ERROR })); + } + } + } +}; diff --git a/src/store/project-panel/project-panel-action-bind.ts b/src/store/project-panel/project-panel-action-bind.ts new file mode 100644 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 3079e0ec..305799e8 100644 --- a/src/store/project-panel/project-panel-action.ts +++ b/src/store/project-panel/project-panel-action.ts @@ -2,24 +2,24 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { Dispatch } from 'redux'; -import { bindDataExplorerActions } from "store/data-explorer/data-explorer-action"; +import { Dispatch } from "redux"; import { propertiesActions } from "store/properties/properties-actions"; -import { RootState } from 'store/store'; +import { RootState } from "store/store"; import { getProperty } from "store/properties/properties"; +import { loadProject } from "store/workbench/workbench-actions"; +import { projectPanelActions } from "store/project-panel/project-panel-action-bind"; export const PROJECT_PANEL_ID = "projectPanel"; export const PROJECT_PANEL_CURRENT_UUID = "projectPanelCurrentUuid"; -export const IS_PROJECT_PANEL_TRASHED = 'isProjectPanelTrashed'; -export const projectPanelActions = bindDataExplorerActions(PROJECT_PANEL_ID); +export const IS_PROJECT_PANEL_TRASHED = "isProjectPanelTrashed"; -export const openProjectPanel = (projectUuid: string) => - (dispatch: Dispatch) => { - dispatch(propertiesActions.SET_PROPERTY({ key: PROJECT_PANEL_CURRENT_UUID, value: projectUuid })); - dispatch(projectPanelActions.REQUEST_ITEMS()); - }; +export const openProjectPanel = (projectUuid: string) => async (dispatch: Dispatch) => { + await dispatch(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 ccfa4fff..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,35 +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, resource.containerUuid] - : uuids - : uuids; - }, []); - if (containerUuids.length > 0) { - await dispatch(loadContainers( - new FilterBuilder().addIn('uuid', containerUuids).getFilters() - )); - } - }; +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({ @@ -104,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 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() @@ -123,31 +126,23 @@ export const getFilters = (dataExplorer: DataExplorer) => { .getFilters(); // Filter by container status - const statusFilters = buildProcessStatusFilters( - new FilterBuilder(), - activeStatusFilter || '', - GroupContentsResourcePrefix.PROCESS).getFilters(); + const statusFilters = buildProcessStatusFilters(new FilterBuilder(), activeStatusFilter || "", GroupContentsResourcePrefix.PROCESS).getFilters(); - return joinFilters( - statusFilters, - typeFilters, - nameFilters, - ); + return joinFilters(statusFilters, typeFilters, nameFilters); }; -export const getOrder = (dataExplorer: DataExplorer) => { - const sortColumn = getSortColumn(dataExplorer); +const getOrder = (dataExplorer: DataExplorer) => { + const sortColumn = getSortColumn(dataExplorer); const order = new OrderBuilder(); - if (sortColumn) { - const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC - ? OrderDirection.ASC - : OrderDirection.DESC; + if (sortColumn && sortColumn.sort) { + const sortDirection = sortColumn.sort.direction === SortDirection.ASC ? OrderDirection.ASC : OrderDirection.DESC; - const columnName = sortColumn && sortColumn.name === ProjectPanelColumnNames.NAME ? "name" : "createdAt"; + // Use createdAt as a secondary sort column so we break ties consistently. return order - .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.COLLECTION) - .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.PROCESS) - .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.PROJECT) + .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.COLLECTION) + .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROCESS) + .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROJECT) + .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS) .getOrder(); } else { return order.getOrder(); @@ -156,18 +151,18 @@ export const getOrder = (dataExplorer: DataExplorer) => { const projectPanelCurrentUuidIsNotSet = () => snackbarActions.OPEN_SNACKBAR({ - message: 'Project panel is not opened.', - kind: SnackbarKind.ERROR + message: "Project panel is not opened.", + kind: SnackbarKind.ERROR, }); const couldNotFetchProjectContents = () => snackbarActions.OPEN_SNACKBAR({ - message: 'Could not fetch project contents.', - kind: SnackbarKind.ERROR + message: "Could not fetch project contents.", + kind: SnackbarKind.ERROR, }); const projectPanelDataExplorerIsNotSet = () => snackbarActions.OPEN_SNACKBAR({ - message: 'Project panel is not ready.', - kind: SnackbarKind.ERROR + message: "Project panel is not ready.", + kind: SnackbarKind.ERROR, }); diff --git a/src/store/projects/project-create-actions.ts b/src/store/projects/project-create-actions.ts index 23eaf7a4..c15c3748 100644 --- a/src/store/projects/project-create-actions.ts +++ b/src/store/projects/project-create-actions.ts @@ -20,6 +20,8 @@ import { ServiceRepository } from 'services/services'; import { matchProjectRoute, matchRunProcessRoute } from 'routes/routes'; import { RouterState } from "react-router-redux"; import { GroupClass } from "models/group"; +import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions"; +import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions"; export interface ProjectCreateFormDialogData { ownerUuid: string; @@ -65,7 +67,8 @@ export const createProject = (project: Partial) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { dispatch(startSubmit(PROJECT_CREATE_FORM_NAME)); try { - const newProject = await services.projectService.create(project); + dispatch(progressIndicatorActions.START_WORKING(PROJECT_CREATE_FORM_NAME)); + const newProject = await services.projectService.create(project, false); dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_CREATE_FORM_NAME })); dispatch(reset(PROJECT_CREATE_FORM_NAME)); return newProject; @@ -73,7 +76,20 @@ export const createProject = (project: Partial) => const error = getCommonResourceServiceError(e); if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) { dispatch(stopSubmit(PROJECT_CREATE_FORM_NAME, { name: 'Project with the same name already exists.' } as FormErrors)); + } else { + dispatch(stopSubmit(PROJECT_CREATE_FORM_NAME)); + dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_CREATE_FORM_NAME })); + const errMsg = e.errors + ? e.errors.join('') + : 'There was an error while creating the collection'; + dispatch(snackbarActions.OPEN_SNACKBAR({ + message: errMsg, + hideDuration: 2000, + kind: SnackbarKind.ERROR + })); } return undefined; + } finally { + dispatch(progressIndicatorActions.STOP_WORKING(PROJECT_CREATE_FORM_NAME)); } }; diff --git a/src/store/projects/project-lock-actions.ts b/src/store/projects/project-lock-actions.ts new file mode 100644 index 00000000..28e934d1 --- /dev/null +++ b/src/store/projects/project-lock-actions.ts @@ -0,0 +1,37 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Dispatch } from "redux"; +import { ServiceRepository } from "services/services"; +import { projectPanelActions } from "store/project-panel/project-panel-action-bind"; +import { loadResource } from "store/resources/resources-actions"; +import { RootState } from "store/store"; +import { MultiSelectMenuActionNames } from "views-components/multiselect-toolbar/ms-menu-actions"; +import { addDisabledButton, removeDisabledButton } from "store/multiselect/multiselect-actions"; + +export const freezeProject = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(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; +}; + +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)); + 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 a6e67485..81249031 100644 --- a/src/store/projects/project-update-actions.ts +++ b/src/store/projects/project-update-actions.ts @@ -3,27 +3,18 @@ // SPDX-License-Identifier: AGPL-3.0 import { Dispatch } from "redux"; -import { - FormErrors, - formValueSelector, - initialize, - reset, - startSubmit, - stopSubmit -} from 'redux-form'; +import { FormErrors, formValueSelector, initialize, reset, startSubmit, stopSubmit } from "redux-form"; import { RootState } from "store/store"; import { dialogActions } from "store/dialog/dialog-actions"; -import { - getCommonResourceServiceError, - CommonResourceServiceError -} from "services/common-service/common-resource-service"; +import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service"; import { ServiceRepository } from "services/services"; -import { projectPanelActions } from 'store/project-panel/project-panel-action'; +import { projectPanelActions } from "store/project-panel/project-panel-action-bind"; import { GroupClass } from "models/group"; import { Participant } from "views-components/sharing-dialog/participant-select"; import { ProjectProperties } from "./project-create-actions"; import { getResource } from "store/resources/resources"; import { ProjectResource } from "models/project"; +import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions"; export interface ProjectUpdateFormDialogData { uuid: string; @@ -33,26 +24,27 @@ export interface ProjectUpdateFormDialogData { properties?: ProjectProperties; } -export const PROJECT_UPDATE_FORM_NAME = 'projectUpdateFormName'; -export const PROJECT_UPDATE_PROPERTIES_FORM_NAME = 'projectUpdatePropertiesFormName'; +export const PROJECT_UPDATE_FORM_NAME = "projectUpdateFormName"; +export const PROJECT_UPDATE_PROPERTIES_FORM_NAME = "projectUpdatePropertiesFormName"; export const PROJECT_UPDATE_FORM_SELECTOR = formValueSelector(PROJECT_UPDATE_FORM_NAME); -export const openProjectUpdateDialog = (resource: ProjectUpdateFormDialogData) => - (dispatch: Dispatch, getState: () => RootState) => { - // Get complete project resource from store to handle consumers passing in partial resources - const project = getResource(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( @@ -61,7 +53,9 @@ export const updateProject = (project: ProjectUpdateFormDialogData) => name: project.name, description: project.description, properties: project.properties, - }); + }, + false + ); dispatch(projectPanelActions.REQUEST_ITEMS()); dispatch(reset(PROJECT_UPDATE_FORM_NAME)); dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_UPDATE_FORM_NAME })); @@ -69,8 +63,18 @@ export const updateProject = (project: ProjectUpdateFormDialogData) => } catch (e) { const error = getCommonResourceServiceError(e); if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) { - dispatch(stopSubmit(PROJECT_UPDATE_FORM_NAME, { name: 'Project with the same name already exists.' } as FormErrors)); + dispatch(stopSubmit(PROJECT_UPDATE_FORM_NAME, { name: "Project with the same name already exists." } as FormErrors)); + } else { + dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_UPDATE_FORM_NAME })); + const errMsg = e.errors ? e.errors.join("") : "There was an error while updating the project"; + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: errMsg, + hideDuration: 2000, + kind: SnackbarKind.ERROR, + }) + ); } - return ; + return; } }; diff --git a/src/store/public-favorites-panel/public-favorites-action.ts b/src/store/public-favorites-panel/public-favorites-action.ts index bc0fc329..6e36e1f8 100644 --- a/src/store/public-favorites-panel/public-favorites-action.ts +++ b/src/store/public-favorites-panel/public-favorites-action.ts @@ -2,9 +2,13 @@ // // SPDX-License-Identifier: AGPL-3.0 +import { Dispatch } from "redux"; import { bindDataExplorerActions } from "store/data-explorer/data-explorer-action"; export const PUBLIC_FAVORITE_PANEL_ID = "publicFavoritePanel"; export const publicFavoritePanelActions = bindDataExplorerActions(PUBLIC_FAVORITE_PANEL_ID); -export const loadPublicFavoritePanel = () => publicFavoritePanelActions.REQUEST_ITEMS(); \ No newline at end of file +export const loadPublicFavoritePanel = () => (dispatch: Dispatch) => { + dispatch(publicFavoritePanelActions.RESET_EXPLORER_SEARCH_VALUE()); + dispatch(publicFavoritePanelActions.REQUEST_ITEMS()); +}; \ No newline at end of file diff --git a/src/store/public-favorites-panel/public-favorites-middleware-service.ts b/src/store/public-favorites-panel/public-favorites-middleware-service.ts index dd21a380..48d27be5 100644 --- a/src/store/public-favorites-panel/public-favorites-middleware-service.ts +++ b/src/store/public-favorites-panel/public-favorites-middleware-service.ts @@ -10,17 +10,14 @@ import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; import { getDataExplorer } from 'store/data-explorer/data-explorer-reducer'; import { resourcesActions } from 'store/resources/resources-actions'; import { FilterBuilder } from 'services/api/filter-builder'; -import { SortDirection } from 'components/data-table/data-column'; -import { OrderDirection, OrderBuilder } from 'services/api/order-builder'; -import { getSortColumn } from "store/data-explorer/data-explorer-reducer"; import { FavoritePanelColumnNames } from 'views/favorite-panel/favorite-panel'; import { publicFavoritePanelActions } from 'store/public-favorites-panel/public-favorites-action'; import { DataColumns } from 'components/data-table/data-table'; import { serializeSimpleObjectTypeFilters } from '../resource-type-filters/resource-type-filters'; -import { LinkResource, LinkClass } from 'models/link'; -import { GroupContentsResource, GroupContentsResourcePrefix } from 'services/groups-service/groups-service'; +import { LinkClass } from 'models/link'; import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions'; import { updatePublicFavorites } from 'store/public-favorites/public-favorites-actions'; +import { GroupContentsResource } from 'services/groups-service/groups-service'; export class PublicFavoritesMiddlewareService extends DataExplorerMiddlewareService { constructor(private services: ServiceRepository, id: string) { @@ -32,25 +29,9 @@ export class PublicFavoritesMiddlewareService extends DataExplorerMiddlewareServ if (!dataExplorer) { api.dispatch(favoritesPanelDataExplorerIsNotSet()); } else { - const columns = dataExplorer.columns as DataColumns; - const sortColumn = getSortColumn(dataExplorer); + const columns = dataExplorer.columns as DataColumns; const typeFilters = serializeSimpleObjectTypeFilters(getDataExplorerColumnFilters(columns, FavoritePanelColumnNames.TYPE)); - - const linkOrder = new OrderBuilder(); - const contentOrder = new OrderBuilder(); - - if (sortColumn && sortColumn.name === FavoritePanelColumnNames.NAME) { - const direction = sortColumn.sortDirection === SortDirection.ASC - ? OrderDirection.ASC - : OrderDirection.DESC; - - linkOrder.addOrder(direction, "name"); - contentOrder - .addOrder(direction, "name", GroupContentsResourcePrefix.COLLECTION) - .addOrder(direction, "name", GroupContentsResourcePrefix.PROCESS) - .addOrder(direction, "name", GroupContentsResourcePrefix.PROJECT); - } try { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); const uuidPrefix = api.getState().auth.config.uuidPrefix; 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-bar/search-bar-actions.ts b/src/store/search-bar/search-bar-actions.ts index 7d76ec69..af40e86a 100644 --- a/src/store/search-bar/search-bar-actions.ts +++ b/src/store/search-bar/search-bar-actions.ts @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0 +import axios from "axios"; import { ofType, unionize, UnionOf } from "common/unionize"; import { GroupContentsResource, GroupContentsResourcePrefix } from 'services/groups-service/groups-service'; import { Dispatch } from 'redux'; @@ -62,13 +63,13 @@ export const loadRecentQueries = () => return recentQueries; }; -export const searchData = (searchValue: string) => +export const searchData = (searchValue: string, useCancel = false) => async (dispatch: Dispatch, getState: () => RootState) => { const currentView = getState().searchBar.currentView; dispatch(searchResultsPanelActions.CLEAR()); dispatch(searchBarActions.SET_SEARCH_VALUE(searchValue)); if (searchValue.length > 0) { - dispatch(searchGroups(searchValue, 5)); + dispatch(searchGroups(searchValue, 5, useCancel)); if (currentView === SearchView.BASIC) { dispatch(searchBarActions.CLOSE_SEARCH_VIEW()); dispatch(navigateToSearchResults(searchValue)); @@ -88,6 +89,9 @@ export const searchAdvancedData = (data: SearchBarAdvancedFormData) => export const setSearchValueFromAdvancedData = (data: SearchBarAdvancedFormData, prevData?: SearchBarAdvancedFormData) => (dispatch: Dispatch, getState: () => RootState) => { + if (data.projectObject) { + data.projectUuid = data.projectObject.uuid; + } const searchValue = getState().searchBar.searchValue; const value = getQueryFromAdvancedData({ ...data, @@ -97,8 +101,11 @@ export const setSearchValueFromAdvancedData = (data: SearchBarAdvancedFormData, }; export const setAdvancedDataFromSearchValue = (search: string, vocabulary: Vocabulary) => - async (dispatch: Dispatch) => { + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const data = getAdvancedDataFromQuery(search, vocabulary); + if (data.projectUuid) { + data.projectObject = await services.projectService.get(data.projectUuid); + } dispatch(initialize(SEARCH_BAR_ADVANCED_FORM_NAME, data)); if (data.projectUuid) { await dispatch(activateSearchBarProject(data.projectUuid)); @@ -203,26 +210,41 @@ export const submitData = (event: React.FormEvent) => } }; - -const searchGroups = (searchValue: string, limit: number) => +let cancelTokens: any[] = []; +const searchGroups = (searchValue: string, limit: number, useCancel = false) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const currentView = getState().searchBar.currentView; - if (searchValue || currentView === SearchView.ADVANCED) { - const { cluster: clusterId } = getAdvancedDataFromQuery(searchValue); - const sessions = getSearchSessions(clusterId, getState().auth.sessions); - const lists: ListResults[] = await Promise.all(sessions.map(session => { - const filters = queryToFilters(searchValue, session.apiRevision); - return services.groupsService.contents('', { - filters, - limit, - recursive: true - }, session); - })); - - const items = lists.reduce((items, list) => items.concat(list.items), [] as GroupContentsResource[]); - dispatch(searchBarActions.SET_SEARCH_RESULTS(items)); + if (cancelTokens.length > 0 && useCancel) { + cancelTokens.forEach(cancelToken => (cancelToken as any).cancel('New search request triggered.')); + cancelTokens = []; } + + setTimeout(async () => { + if (searchValue || currentView === SearchView.ADVANCED) { + const { cluster: clusterId } = getAdvancedDataFromQuery(searchValue); + const sessions = getSearchSessions(clusterId, getState().auth.sessions); + const lists: ListResults[] = await Promise.all(sessions.map((session, index) => { + cancelTokens.push(axios.CancelToken.source()); + const filters = queryToFilters(searchValue, session.apiRevision); + return services.groupsService.contents('', { + filters, + limit, + recursive: true + }, session, cancelTokens[index].token); + })); + + cancelTokens = []; + + const items = lists.reduce((items, list) => items.concat(list.items), [] as GroupContentsResource[]); + + if (lists.filter(list => !!(list as any).items).length !== lists.length) { + dispatch(searchBarActions.SET_SEARCH_RESULTS([])); + } else { + dispatch(searchBarActions.SET_SEARCH_RESULTS(items)); + } + } + }, 10); }; const buildQueryFromKeyMap = (data: any, keyMap: string[][]) => { @@ -274,7 +296,7 @@ export const getQueryFromAdvancedData = (data: SearchBarAdvancedFormData, prevDa }; (data.properties || []).forEach(p => fo[`prop-"${p.keyID || p.key}":"${p.valueID || p.value}"`] = `"${p.valueID || p.value}"` - ); + ); return fo; }; @@ -292,9 +314,9 @@ export const getQueryFromAdvancedData = (data: SearchBarAdvancedFormData, prevDa [`has:"${p.keyID || p.key}"`, `prop-"${p.keyID || p.key}":"${p.valueID || p.value}"`] )); - const modified = getModifiedKeysValues(flatData(data), prevData ? flatData(prevData):{}); + const modified = getModifiedKeysValues(flatData(data), prevData ? flatData(prevData) : {}); value = buildQueryFromKeyMap( - {searchValue: data.searchValue, ...modified} as SearchBarAdvancedFormData, keyMap); + { searchValue: data.searchValue, ...modified } as SearchBarAdvancedFormData, keyMap); value = value.trim(); return value; diff --git a/src/store/search-bar/search-bar-tree-actions.ts b/src/store/search-bar/search-bar-tree-actions.ts index 6ab25d6e..b0bad2f3 100644 --- a/src/store/search-bar/search-bar-tree-actions.ts +++ b/src/store/search-bar/search-bar-tree-actions.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0 import { getTreePicker, TreePicker } from "store/tree-picker/tree-picker"; -import { getNode, getNodeAncestorsIds, initTreeNode, TreeNodeStatus } from "models/tree"; +import { getNode, getNodeAncestorsIds, initTreeNode } from "models/tree"; import { Dispatch } from "redux"; import { RootState } from "store/store"; import { getUserUuid } from "common/getuser"; @@ -66,8 +66,10 @@ export const expandSearchBarTreeItem = (id: string) => }; export const activateSearchBarProject = (id: string) => - async (dispatch: Dispatch, getState: () => RootState) => { - const { treePicker } = getState(); + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + + + /*const { treePicker } = getState(); const node = getSearchBarTreeNode(id)(treePicker); if (node && node.status !== TreeNodeStatus.LOADED) { await dispatch(loadSearchBarTreeProjects(id)); @@ -78,7 +80,7 @@ export const activateSearchBarProject = (id: string) => ids: getSearchBarTreeNodeAncestorsIds(id)(treePicker), pickerId: SEARCH_BAR_ADVANCED_FORM_PICKER_ID })); - dispatch(expandSearchBarTreeItem(id)); + dispatch(expandSearchBarTreeItem(id));*/ }; 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 78ba6c38..00a69cd2 100644 --- a/src/store/search-results-panel/search-results-middleware-service.ts +++ b/src/store/search-results-panel/search-results-middleware-service.ts @@ -20,11 +20,12 @@ import { getAdvancedDataFromQuery } from 'store/search-bar/search-bar-actions'; import { getSortColumn } from "store/data-explorer/data-explorer-reducer"; -import { joinFilters } from 'services/api/filter-builder'; +import { FilterBuilder, joinFilters } from 'services/api/filter-builder'; import { DataColumns } from 'components/data-table/data-table'; import { serializeResourceTypeFilters } from 'store//resource-type-filters/resource-type-filters'; import { ProjectPanelColumnNames } from 'views/project-panel/project-panel'; -import { Resource } from 'models/resource'; +import { ResourceKind } from 'models/resource'; +import { ContainerRequestResource } from 'models/container-request'; export class SearchResultsMiddlewareService extends DataExplorerMiddlewareService { constructor(private services: ServiceRepository, id: string) { @@ -60,15 +61,27 @@ export class SearchResultsMiddlewareService extends DataExplorerMiddlewareServic .then((response) => { api.dispatch(updateResources(response.items)); api.dispatch(appendItems(response)); + // Request all containers for process status to be available + const containerRequests = response.items.filter((item) => item.kind === ResourceKind.CONTAINER_REQUEST) as ContainerRequestResource[]; + const containerUuids = containerRequests.map(container => container.containerUuid).filter(uuid => uuid !== null) as string[]; + containerUuids.length && this.services.containerService + .list({ + filters: new FilterBuilder() + .addIn('uuid', containerUuids) + .getFilters() + }, false) + .then((containers) => { + api.dispatch(updateResources(containers.items)); + }); }).catch(() => { api.dispatch(couldNotFetchSearchResults(session.clusterId)); }); - } + } ); } } -const typeFilters = (columns: DataColumns) => serializeResourceTypeFilters(getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.TYPE)); +const typeFilters = (columns: DataColumns) => serializeResourceTypeFilters(getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.TYPE)); export const getParams = (dataExplorer: DataExplorer, query: string, apiRevision: number) => ({ ...dataExplorerToListParams(dataExplorer), @@ -82,17 +95,19 @@ export const getParams = (dataExplorer: DataExplorer, query: string, apiRevision }); const getOrder = (dataExplorer: DataExplorer) => { - const sortColumn = getSortColumn(dataExplorer); + const sortColumn = getSortColumn(dataExplorer); const order = new OrderBuilder(); - if (sortColumn) { - const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC + if (sortColumn && sortColumn.sort) { + const sortDirection = sortColumn.sort.direction === SortDirection.ASC ? OrderDirection.ASC : OrderDirection.DESC; + // Use createdAt as a secondary sort column so we break ties consistently. return order - .addOrder(sortDirection, sortColumn.name as keyof Resource, GroupContentsResourcePrefix.COLLECTION) - .addOrder(sortDirection, sortColumn.name as keyof Resource, GroupContentsResourcePrefix.PROCESS) - .addOrder(sortDirection, sortColumn.name as keyof Resource, GroupContentsResourcePrefix.PROJECT) + .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.COLLECTION) + .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROCESS) + .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROJECT) + .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS) .getOrder(); } else { return order.getOrder(); 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 5f92637c..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 @@ -17,11 +17,11 @@ import { GroupContentsResource, GroupContentsResourcePrefix } from 'services/gro import { SortDirection } from 'components/data-table/data-column'; import { OrderBuilder, OrderDirection } from 'services/api/order-builder'; import { ProjectResource } from 'models/project'; -import { ProjectPanelColumnNames } from 'views/project-panel/project-panel'; import { getSortColumn } from "store/data-explorer/data-explorer-reducer"; import { updatePublicFavorites } from 'store/public-favorites/public-favorites-actions'; -import { FilterBuilder } from 'services/api/filter-builder'; +import { FilterBuilder, joinFilters } from 'services/api/filter-builder'; import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions'; +import { AuthState } from 'store/auth/auth-reducer'; export class SharedWithMeMiddlewareService extends DataExplorerMiddlewareService { constructor(private services: ServiceRepository, id: string) { @@ -34,11 +34,7 @@ export class SharedWithMeMiddlewareService extends DataExplorerMiddlewareService try { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); const response = await this.services.groupsService - .contents('', { - ...getParams(dataExplorer), - excludeHomeProject: true, - filters: new FilterBuilder().addDistinct('uuid', `${state.auth.config.uuidPrefix}-j7d0g-publicfavorites`).getFilters() - }); + .contents('', getParams(dataExplorer, state.auth)); api.dispatch(updateFavorites(response.items.map(item => item.uuid))); api.dispatch(updatePublicFavorites(response.items.map(item => item.uuid))); api.dispatch(updateResources(response.items)); @@ -52,31 +48,31 @@ export class SharedWithMeMiddlewareService extends DataExplorerMiddlewareService } } -export const getParams = (dataExplorer: DataExplorer) => ({ +export const getParams = (dataExplorer: DataExplorer, authState: AuthState) => ({ ...dataExplorerToListParams(dataExplorer), order: getOrder(dataExplorer), - filters: getFilters(dataExplorer), + filters: joinFilters( + getFilters(dataExplorer), + new FilterBuilder().addDistinct('uuid', `${authState.config.uuidPrefix}-j7d0g-publicfavorites`).getFilters(), + ), + excludeHomeProject: true, }); -export const getOrder = (dataExplorer: DataExplorer) => { - const sortColumn = getSortColumn(dataExplorer); +const getOrder = (dataExplorer: DataExplorer) => { + const sortColumn = getSortColumn(dataExplorer); const order = new OrderBuilder(); - if (sortColumn) { - const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC + if (sortColumn && sortColumn.sort) { + const sortDirection = sortColumn.sort.direction === SortDirection.ASC ? OrderDirection.ASC : OrderDirection.DESC; - const columnName = sortColumn && sortColumn.name === ProjectPanelColumnNames.NAME ? "name" : "createdAt"; - if (columnName === 'name') { - return order - .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.COLLECTION) - .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.PROCESS) - .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.PROJECT) - .getOrder(); - } else { - return order - .addOrder(sortDirection, columnName) - .getOrder(); - } + + // Use createdAt as a secondary sort column so we break ties consistently. + return order + .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.COLLECTION) + .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROCESS) + .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROJECT) + .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS) + .getOrder(); } else { return order.getOrder(); } diff --git a/src/store/shared-with-me-panel/shared-with-me-panel-actions.ts b/src/store/shared-with-me-panel/shared-with-me-panel-actions.ts index c8731ae6..616bd005 100644 --- a/src/store/shared-with-me-panel/shared-with-me-panel-actions.ts +++ b/src/store/shared-with-me-panel/shared-with-me-panel-actions.ts @@ -2,8 +2,12 @@ // // SPDX-License-Identifier: AGPL-3.0 +import { Dispatch } from "redux"; import { bindDataExplorerActions } from "../data-explorer/data-explorer-action"; export const SHARED_WITH_ME_PANEL_ID = "sharedWithMePanel"; export const sharedWithMePanelActions = bindDataExplorerActions(SHARED_WITH_ME_PANEL_ID); -export const loadSharedWithMePanel = () => sharedWithMePanelActions.REQUEST_ITEMS(); +export const loadSharedWithMePanel = () => (dispatch: Dispatch) => { + dispatch(sharedWithMePanelActions.RESET_EXPLORER_SEARCH_VALUE()); + dispatch(sharedWithMePanelActions.REQUEST_ITEMS()); +}; \ No newline at end of file diff --git a/src/store/sharing-dialog/sharing-dialog-actions.ts b/src/store/sharing-dialog/sharing-dialog-actions.ts index cdc6c0c7..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', permissionLinks.map(({ tailUuid }) => tailUuid)) - .getFilters(); - - const { items: users } = await userService.list({ filters, count: "none" }); - const { items: groups } = await groupsService.list({ filters, count: "none" }); + const dialog = getDialog(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 dd56b428..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 = 'Projects', - SHARED_WITH_ME = 'Shared with me', - PUBLIC_FAVORITES = 'Public Favorites', + PROJECTS = 'Home Projects', FAVORITES = 'My Favorites', - TRASH = 'Trash', + PUBLIC_FAVORITES = 'Public Favorites', + SHARED_WITH_ME = 'Shared with me', ALL_PROCESSES = 'All Processes', + SHELL_ACCESS = 'Shell Access', GROUPS = 'Groups', + TRASH = 'Trash', } export const SIDE_PANEL_TREE = 'sidePanelTree'; +const SIDEPANEL_TREE_NODE_LIMIT = 50 export const getSidePanelTree = (treePicker: TreePicker) => getTreePicker(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/side-panel/side-panel-action.ts b/src/store/side-panel/side-panel-action.ts index 9e8da283..e4f53cea 100644 --- a/src/store/side-panel/side-panel-action.ts +++ b/src/store/side-panel/side-panel-action.ts @@ -5,7 +5,17 @@ import { Dispatch } from 'redux'; import { navigateTo } from 'store/navigation/navigation-action'; +export const sidePanelActions = { + TOGGLE_COLLAPSE: 'TOGGLE_COLLAPSE' +} + export const navigateFromSidePanel = (id: string) => (dispatch: Dispatch) => { dispatch(navigateTo(id)); }; + +export const toggleSidePanel = (collapsedState: boolean) => { + return (dispatch) => { + dispatch({type: sidePanelActions.TOGGLE_COLLAPSE, payload: !collapsedState}) + } +} \ No newline at end of file diff --git a/src/store/side-panel/side-panel-reducer.tsx b/src/store/side-panel/side-panel-reducer.tsx new file mode 100644 index 00000000..a6ed03b6 --- /dev/null +++ b/src/store/side-panel/side-panel-reducer.tsx @@ -0,0 +1,18 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { sidePanelActions } from "./side-panel-action" + +interface SidePanelState { + collapsedState: boolean +} + +const sidePanelInitialState = { + collapsedState: false +} + +export const sidePanelReducer = (state: SidePanelState = sidePanelInitialState, action)=>{ + if(action.type === sidePanelActions.TOGGLE_COLLAPSE) return {...state, collapsedState: action.payload} + return state +} \ No newline at end of file diff --git a/src/store/snackbar/snackbar-actions.ts b/src/store/snackbar/snackbar-actions.ts index c0437154..7b6f2efd 100644 --- a/src/store/snackbar/snackbar-actions.ts +++ b/src/store/snackbar/snackbar-actions.ts @@ -20,7 +20,7 @@ export enum SnackbarKind { export const snackbarActions = unionize({ OPEN_SNACKBAR: ofType<{message: string; hideDuration?: number, kind?: SnackbarKind, link?: string}>(), - CLOSE_SNACKBAR: ofType<{}>(), + CLOSE_SNACKBAR: ofType<{}|null>(), SHIFT_MESSAGES: ofType<{}>() }); diff --git a/src/store/snackbar/snackbar-reducer.ts b/src/store/snackbar/snackbar-reducer.ts index fa1717c7..c3fcfb07 100644 --- a/src/store/snackbar/snackbar-reducer.ts +++ b/src/store/snackbar/snackbar-reducer.ts @@ -29,10 +29,21 @@ export const snackbarReducer = (state = initialState, action: SnackbarAction) => }) }; }, - CLOSE_SNACKBAR: () => ({ - ...state, - open: false - }), + CLOSE_SNACKBAR: (payload) => { + let newMessages: any = [...state.messages];// state.messages.filter(({ message }) => message !== payload); + + if (payload === undefined || JSON.stringify(payload) === '{}') { + newMessages.pop(); + } else { + newMessages = state.messages.filter((message, index) => index !== payload); + } + + return { + ...state, + messages: newMessages, + open: newMessages.length > 0 + } + }, SHIFT_MESSAGES: () => { const messages = state.messages.filter((m, idx) => idx > 0); return { diff --git a/src/store/store.ts b/src/store/store.ts index 94f110a0..daa9812e 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -2,89 +2,90 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { createStore, applyMiddleware, compose, Middleware, combineReducers, Store, Action, Dispatch } from 'redux'; +import { createStore, applyMiddleware, compose, Middleware, combineReducers, Store, Action, Dispatch } from "redux"; import { routerMiddleware, routerReducer } from "react-router-redux"; -import thunkMiddleware from 'redux-thunk'; +import thunkMiddleware from "redux-thunk"; import { History } from "history"; -import { handleRedirects } from '../common/redirect-to'; +import { handleRedirects } from "../common/redirect-to"; import { authReducer } from "./auth/auth-reducer"; import { authMiddleware } from "./auth/auth-middleware"; -import { dataExplorerReducer } from './data-explorer/data-explorer-reducer'; -import { detailsPanelReducer } from './details-panel/details-panel-reducer'; -import { contextMenuReducer } from './context-menu/context-menu-reducer'; -import { reducer as formReducer } from 'redux-form'; -import { favoritesReducer } from './favorites/favorites-reducer'; -import { snackbarReducer } from './snackbar/snackbar-reducer'; -import { collectionPanelFilesReducer } from './collection-panel/collection-panel-files/collection-panel-files-reducer'; +import { dataExplorerReducer } from "./data-explorer/data-explorer-reducer"; +import { detailsPanelReducer } from "./details-panel/details-panel-reducer"; +import { contextMenuReducer } from "./context-menu/context-menu-reducer"; +import { reducer as formReducer } from "redux-form"; +import { favoritesReducer } from "./favorites/favorites-reducer"; +import { snackbarReducer } from "./snackbar/snackbar-reducer"; +import { collectionPanelFilesReducer } from "./collection-panel/collection-panel-files/collection-panel-files-reducer"; import { dataExplorerMiddleware } from "./data-explorer/data-explorer-middleware"; import { FAVORITE_PANEL_ID } from "./favorite-panel/favorite-panel-action"; import { PROJECT_PANEL_ID } from "./project-panel/project-panel-action"; import { ProjectPanelMiddlewareService } from "./project-panel/project-panel-middleware-service"; import { FavoritePanelMiddlewareService } from "./favorite-panel/favorite-panel-middleware-service"; import { AllProcessesPanelMiddlewareService } from "./all-processes-panel/all-processes-panel-middleware-service"; -import { collectionPanelReducer } from './collection-panel/collection-panel-reducer'; -import { dialogReducer } from './dialog/dialog-reducer'; +import { collectionPanelReducer } from "./collection-panel/collection-panel-reducer"; +import { dialogReducer } from "./dialog/dialog-reducer"; import { ServiceRepository } from "services/services"; -import { treePickerReducer } from './tree-picker/tree-picker-reducer'; -import { resourcesReducer } from 'store/resources/resources-reducer'; -import { propertiesReducer } from './properties/properties-reducer'; -import { fileUploaderReducer } from './file-uploader/file-uploader-reducer'; +import { treePickerReducer, treePickerSearchReducer } from "./tree-picker/tree-picker-reducer"; +import { treePickerSearchMiddleware } from "./tree-picker/tree-picker-middleware"; +import { resourcesReducer } from "store/resources/resources-reducer"; +import { propertiesReducer } from "./properties/properties-reducer"; +import { fileUploaderReducer } from "./file-uploader/file-uploader-reducer"; import { TrashPanelMiddlewareService } from "store/trash-panel/trash-panel-middleware-service"; import { TRASH_PANEL_ID } from "store/trash-panel/trash-panel-action"; -import { processLogsPanelReducer } from './process-logs-panel/process-logs-panel-reducer'; -import { processPanelReducer } from 'store/process-panel/process-panel-reducer'; -import { SHARED_WITH_ME_PANEL_ID } from 'store/shared-with-me-panel/shared-with-me-panel-actions'; -import { SharedWithMeMiddlewareService } from './shared-with-me-panel/shared-with-me-middleware-service'; -import { progressIndicatorReducer } from './progress-indicator/progress-indicator-reducer'; -import { runProcessPanelReducer } from 'store/run-process-panel/run-process-panel-reducer'; -import { WorkflowMiddlewareService } from './workflow-panel/workflow-middleware-service'; -import { WORKFLOW_PANEL_ID } from './workflow-panel/workflow-panel-actions'; -import { appInfoReducer } from 'store/app-info/app-info-reducer'; -import { searchBarReducer } from './search-bar/search-bar-reducer'; -import { SEARCH_RESULTS_PANEL_ID } from 'store/search-results-panel/search-results-panel-actions'; -import { SearchResultsMiddlewareService } from './search-results-panel/search-results-middleware-service'; +import { processLogsPanelReducer } from "./process-logs-panel/process-logs-panel-reducer"; +import { processPanelReducer } from "store/process-panel/process-panel-reducer"; +import { SHARED_WITH_ME_PANEL_ID } from "store/shared-with-me-panel/shared-with-me-panel-actions"; +import { SharedWithMeMiddlewareService } from "./shared-with-me-panel/shared-with-me-middleware-service"; +import { progressIndicatorReducer } from "./progress-indicator/progress-indicator-reducer"; +import { runProcessPanelReducer } from "store/run-process-panel/run-process-panel-reducer"; +import { WorkflowMiddlewareService } from "./workflow-panel/workflow-middleware-service"; +import { WORKFLOW_PANEL_ID } from "./workflow-panel/workflow-panel-actions"; +import { appInfoReducer } from "store/app-info/app-info-reducer"; +import { searchBarReducer } from "./search-bar/search-bar-reducer"; +import { SEARCH_RESULTS_PANEL_ID } from "store/search-results-panel/search-results-panel-actions"; +import { SearchResultsMiddlewareService } from "./search-results-panel/search-results-middleware-service"; import { virtualMachinesReducer } from "store/virtual-machines/virtual-machines-reducer"; -import { repositoriesReducer } from 'store/repositories/repositories-reducer'; -import { keepServicesReducer } from 'store/keep-services/keep-services-reducer'; -import { UserMiddlewareService } from 'store/users/user-panel-middleware-service'; -import { USERS_PANEL_ID } from 'store/users/users-actions'; -import { UserProfileGroupsMiddlewareService } from 'store/user-profile/user-profile-groups-middleware-service'; -import { USER_PROFILE_PANEL_ID } from 'store/user-profile/user-profile-actions' -import { GroupsPanelMiddlewareService } from 'store/groups-panel/groups-panel-middleware-service'; -import { GROUPS_PANEL_ID } from 'store/groups-panel/groups-panel-actions'; -import { GroupDetailsPanelMembersMiddlewareService } from 'store/group-details-panel/group-details-panel-members-middleware-service'; -import { GroupDetailsPanelPermissionsMiddlewareService } from 'store/group-details-panel/group-details-panel-permissions-middleware-service'; -import { GROUP_DETAILS_MEMBERS_PANEL_ID, GROUP_DETAILS_PERMISSIONS_PANEL_ID } from 'store/group-details-panel/group-details-panel-actions'; -import { LINK_PANEL_ID } from 'store/link-panel/link-panel-actions'; -import { LinkMiddlewareService } from 'store/link-panel/link-panel-middleware-service'; -import { API_CLIENT_AUTHORIZATION_PANEL_ID } from 'store/api-client-authorizations/api-client-authorizations-actions'; -import { ApiClientAuthorizationMiddlewareService } from 'store/api-client-authorizations/api-client-authorizations-middleware-service'; -import { PublicFavoritesMiddlewareService } from 'store/public-favorites-panel/public-favorites-middleware-service'; -import { PUBLIC_FAVORITE_PANEL_ID } from 'store/public-favorites-panel/public-favorites-action'; -import { publicFavoritesReducer } from 'store/public-favorites/public-favorites-reducer'; -import { linkAccountPanelReducer } from './link-account-panel/link-account-panel-reducer'; -import { CollectionsWithSameContentAddressMiddlewareService } from 'store/collections-content-address-panel/collections-content-address-middleware-service'; -import { COLLECTIONS_CONTENT_ADDRESS_PANEL_ID } from 'store/collections-content-address-panel/collections-content-address-panel-actions'; -import { ownerNameReducer } from 'store/owner-name/owner-name-reducer'; -import { SubprocessMiddlewareService } from 'store/subprocess-panel/subprocess-panel-middleware-service'; -import { SUBPROCESS_PANEL_ID } from 'store/subprocess-panel/subprocess-panel-actions'; -import { ALL_PROCESSES_PANEL_ID } from './all-processes-panel/all-processes-panel-action'; -import { Config } from 'common/config'; -import { pluginConfig } from 'plugins'; -import { MiddlewareListReducer } from 'common/plugintypes'; +import { repositoriesReducer } from "store/repositories/repositories-reducer"; +import { keepServicesReducer } from "store/keep-services/keep-services-reducer"; +import { UserMiddlewareService } from "store/users/user-panel-middleware-service"; +import { USERS_PANEL_ID } from "store/users/users-actions"; +import { UserProfileGroupsMiddlewareService } from "store/user-profile/user-profile-groups-middleware-service"; +import { USER_PROFILE_PANEL_ID } from "store/user-profile/user-profile-actions"; +import { GroupsPanelMiddlewareService } from "store/groups-panel/groups-panel-middleware-service"; +import { GROUPS_PANEL_ID } from "store/groups-panel/groups-panel-actions"; +import { GroupDetailsPanelMembersMiddlewareService } from "store/group-details-panel/group-details-panel-members-middleware-service"; +import { GroupDetailsPanelPermissionsMiddlewareService } from "store/group-details-panel/group-details-panel-permissions-middleware-service"; +import { GROUP_DETAILS_MEMBERS_PANEL_ID, GROUP_DETAILS_PERMISSIONS_PANEL_ID } from "store/group-details-panel/group-details-panel-actions"; +import { LINK_PANEL_ID } from "store/link-panel/link-panel-actions"; +import { LinkMiddlewareService } from "store/link-panel/link-panel-middleware-service"; +import { API_CLIENT_AUTHORIZATION_PANEL_ID } from "store/api-client-authorizations/api-client-authorizations-actions"; +import { ApiClientAuthorizationMiddlewareService } from "store/api-client-authorizations/api-client-authorizations-middleware-service"; +import { PublicFavoritesMiddlewareService } from "store/public-favorites-panel/public-favorites-middleware-service"; +import { PUBLIC_FAVORITE_PANEL_ID } from "store/public-favorites-panel/public-favorites-action"; +import { publicFavoritesReducer } from "store/public-favorites/public-favorites-reducer"; +import { linkAccountPanelReducer } from "./link-account-panel/link-account-panel-reducer"; +import { CollectionsWithSameContentAddressMiddlewareService } from "store/collections-content-address-panel/collections-content-address-middleware-service"; +import { COLLECTIONS_CONTENT_ADDRESS_PANEL_ID } from "store/collections-content-address-panel/collections-content-address-panel-actions"; +import { ownerNameReducer } from "store/owner-name/owner-name-reducer"; +import { SubprocessMiddlewareService } from "store/subprocess-panel/subprocess-panel-middleware-service"; +import { SUBPROCESS_PANEL_ID } from "store/subprocess-panel/subprocess-panel-actions"; +import { ALL_PROCESSES_PANEL_ID } from "./all-processes-panel/all-processes-panel-action"; +import { Config } from "common/config"; +import { pluginConfig } from "plugins"; +import { MiddlewareListReducer } from "common/plugintypes"; +import { tooltipsMiddleware } from "./tooltips/tooltips-middleware"; +import { sidePanelReducer } from "./side-panel/side-panel-reducer"; +import { bannerReducer } from "./banner/banner-reducer"; +import { multiselectReducer } from "./multiselect/multiselect-reducer"; +import { composeWithDevTools } from "redux-devtools-extension"; declare global { interface Window { - __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: typeof compose; + __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: typeof compose; } } -const composeEnhancers = - (process.env.NODE_ENV === 'development' && - window && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || - compose; - export type RootState = ReturnType>; export type RootStore = Store & { dispatch: Dispatch }; @@ -92,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(); @@ -157,6 +133,7 @@ export function configureStore(history: History, services: ServiceRepository, co routerMiddleware(history), thunkMiddleware.withExtraArgument(services), authMiddleware(services), + tooltipsMiddleware(services), projectPanelMiddleware, favoritePanelMiddleware, allProcessessPanelMiddleware, @@ -174,43 +151,50 @@ export function configureStore(history: History, services: ServiceRepository, co publicFavoritesMiddleware, collectionsContentAddress, subprocessMiddleware, + treePickerSearchMiddleware, ]; - const reduceMiddlewaresFn: (a: Middleware[], - b: MiddlewareListReducer) => Middleware[] = (a, b) => b(a, services); + const reduceMiddlewaresFn: (a: Middleware[], b: MiddlewareListReducer) => Middleware[] = (a, b) => b(a, services); middlewares = pluginConfig.middlewares.reduce(reduceMiddlewaresFn, middlewares); - const enhancer = composeEnhancers(applyMiddleware(redirectToMiddleware, ...middlewares)); + const enhancer = composeWithDevTools({ + /* options */ + })(applyMiddleware(redirectToMiddleware, ...middlewares)); return createStore(rootReducer, enhancer); } -const createRootReducer = (services: ServiceRepository) => combineReducers({ - auth: authReducer(services), - collectionPanel: collectionPanelReducer, - collectionPanelFiles: collectionPanelFilesReducer, - contextMenu: contextMenuReducer, - dataExplorer: dataExplorerReducer, - detailsPanel: detailsPanelReducer, - dialog: dialogReducer, - favorites: favoritesReducer, - ownerName: ownerNameReducer, - publicFavorites: publicFavoritesReducer, - form: formReducer, - processLogsPanel: processLogsPanelReducer, - properties: propertiesReducer, - resources: resourcesReducer, - router: routerReducer, - snackbar: snackbarReducer, - treePicker: treePickerReducer, - fileUploader: fileUploaderReducer, - processPanel: processPanelReducer, - progressIndicator: progressIndicatorReducer, - runProcessPanel: runProcessPanelReducer, - appInfo: appInfoReducer, - searchBar: searchBarReducer, - virtualMachines: virtualMachinesReducer, - repositories: repositoriesReducer, - keepServices: keepServicesReducer, - linkAccountPanel: linkAccountPanelReducer -}); +const createRootReducer = (services: ServiceRepository) => + combineReducers({ + auth: authReducer(services), + banner: bannerReducer, + collectionPanel: collectionPanelReducer, + collectionPanelFiles: collectionPanelFilesReducer, + contextMenu: contextMenuReducer, + dataExplorer: dataExplorerReducer, + detailsPanel: detailsPanelReducer, + dialog: dialogReducer, + favorites: favoritesReducer, + ownerName: ownerNameReducer, + publicFavorites: publicFavoritesReducer, + form: formReducer, + processLogsPanel: processLogsPanelReducer, + properties: propertiesReducer, + resources: resourcesReducer, + router: routerReducer, + snackbar: snackbarReducer, + treePicker: treePickerReducer, + treePickerSearch: treePickerSearchReducer, + fileUploader: fileUploaderReducer, + processPanel: processPanelReducer, + progressIndicator: progressIndicatorReducer, + runProcessPanel: runProcessPanelReducer, + appInfo: appInfoReducer, + searchBar: searchBarReducer, + virtualMachines: virtualMachinesReducer, + repositories: repositoriesReducer, + keepServices: keepServicesReducer, + linkAccountPanel: linkAccountPanelReducer, + sidePanel: sidePanelReducer, + multiselect: multiselectReducer, + }); 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 fb9b0e8b..5124c834 100644 --- a/src/store/subprocess-panel/subprocess-panel-middleware-service.ts +++ b/src/store/subprocess-panel/subprocess-panel-middleware-service.ts @@ -5,23 +5,19 @@ import { ServiceRepository } from 'services/services'; import { MiddlewareAPI, Dispatch } from 'redux'; import { - DataExplorerMiddlewareService, dataExplorerToListParams, listResultsToDataExplorerItemsMeta, getDataExplorerColumnFilters + DataExplorerMiddlewareService, dataExplorerToListParams, listResultsToDataExplorerItemsMeta, getDataExplorerColumnFilters, getOrder } from 'store/data-explorer/data-explorer-middleware-service'; import { RootState } from 'store/store'; import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; import { DataExplorer, getDataExplorer } from 'store/data-explorer/data-explorer-reducer'; import { updateResources } from 'store/resources/resources-actions'; -import { SortDirection } from 'components/data-table/data-column'; -import { OrderDirection, OrderBuilder } from 'services/api/order-builder'; import { ListResults } from 'services/common-service/common-service'; -import { getSortColumn } from "store/data-explorer/data-explorer-reducer"; import { ProcessResource } from 'models/process'; -import { SubprocessPanelColumnNames } from 'views/subprocess-panel/subprocess-panel-root'; import { FilterBuilder, joinFilters } from 'services/api/filter-builder'; import { subprocessPanelActions } from './subprocess-panel-actions'; import { DataColumns } from 'components/data-table/data-table'; import { ProcessStatusFilter, buildProcessStatusFilters } from '../resource-type-filters/resource-type-filters'; -import { ContainerRequestResource } from 'models/container-request'; +import { ContainerRequestResource, containerRequestFieldsNoMounts } from 'models/container-request'; import { progressIndicatorActions } from '../progress-indicator/progress-indicator-actions'; import { loadMissingProcessesInformation } from '../project-panel/project-panel-middleware-service'; @@ -30,25 +26,29 @@ 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); - const containerRequests = await this.services.containerRequestService.list( - { ...getParams(dataExplorer, parentContainerRequest) }); - - api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); - api.dispatch(updateResources(containerRequests.items)); - await api.dispatch(loadMissingProcessesInformation(containerRequests.items)); - // Populate the actual user view - api.dispatch(setItems(containerRequests)); + if (parentContainerRequest.containerUuid) { + const containerRequests = await this.services.containerRequestService.list( + { + ...getParams(dataExplorer, parentContainerRequest), + select: containerRequestFieldsNoMounts + }); + api.dispatch(updateResources(containerRequests.items)); + await api.dispatch(loadMissingProcessesInformation(containerRequests.items)); + // Populate the actual user view + api.dispatch(setItems(containerRequests)); + } + if (!background) { api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); } } catch { - api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); + if (!background) { api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); } api.dispatch(couldNotFetchSubprocesses()); } } @@ -58,51 +58,34 @@ export const getParams = ( dataExplorer: DataExplorer, parentContainerRequest: ContainerRequestResource) => ({ ...dataExplorerToListParams(dataExplorer), - order: getOrder(dataExplorer), + order: getOrder(dataExplorer), filters: getFilters(dataExplorer, parentContainerRequest) }); -const getOrder = (dataExplorer: DataExplorer) => { - const sortColumn = getSortColumn(dataExplorer); - const order = new OrderBuilder(); - if (sortColumn) { - const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC - ? OrderDirection.ASC - : OrderDirection.DESC; - - const columnName = sortColumn && sortColumn.name === SubprocessPanelColumnNames.NAME ? "name" : "modifiedAt"; - return order - .addOrder(sortDirection, columnName) - .getOrder(); - } else { - return order.getOrder(); - } -}; - export const getFilters = ( dataExplorer: DataExplorer, parentContainerRequest: ContainerRequestResource) => { - const columns = dataExplorer.columns as DataColumns; - 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/tooltips/tooltips-middleware.ts b/src/store/tooltips/tooltips-middleware.ts new file mode 100644 index 00000000..d4ea41ee --- /dev/null +++ b/src/store/tooltips/tooltips-middleware.ts @@ -0,0 +1,84 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { CollectionDirectory, CollectionFile } from "models/collection-file"; +import { Middleware, Store } from "redux"; +import { ServiceRepository } from "services/services"; +import { RootState } from "store/store"; +import tippy, { createSingleton } from 'tippy.js'; +import 'tippy.js/dist/tippy.css'; + +let running = false; +let tooltipsContents = null; +let tooltipsFetchFailed = false; +export const TOOLTIP_LOCAL_STORAGE_KEY = "TOOLTIP_LOCAL_STORAGE_KEY"; + +const tippySingleton = createSingleton([], {delay: 10}); + +export const tooltipsMiddleware = (services: ServiceRepository): Middleware => (store: Store) => next => action => { + const state: RootState = store.getState(); + + if (state && state.auth && state.auth.config && state.auth.config.clusterConfig && state.auth.config.clusterConfig.Workbench) { + const hideTooltip = localStorage.getItem(TOOLTIP_LOCAL_STORAGE_KEY); + const { BannerUUID: bannerUUID } = state.auth.config.clusterConfig.Workbench; + + if (bannerUUID && !tooltipsContents && !hideTooltip && !tooltipsFetchFailed && !running) { + running = true; + fetchTooltips(services, bannerUUID); + } else if (tooltipsContents && !hideTooltip && !tooltipsFetchFailed) { + applyTooltips(); + } + } + + return next(action); +}; + +const fetchTooltips = (services, bannerUUID) => { + services.collectionService.files(bannerUUID) + .then(results => { + const tooltipsFile: CollectionDirectory | CollectionFile | undefined = results.find(({ name }) => name === 'tooltips.json'); + + if (tooltipsFile) { + running = true; + services.collectionService.getFileContents(tooltipsFile as CollectionFile) + .then(data => { + tooltipsContents = JSON.parse(data); + applyTooltips(); + }) + .catch(() => {}) + .finally(() => { + running = false; + }); + } else { + tooltipsFetchFailed = true; + } + }) + .catch(() => {}) + .finally(() => { + running = false; + }); +}; + +const applyTooltips = () => { + const tippyInstances: any[] = Object.keys(tooltipsContents as any) + .map((key) => { + const content = (tooltipsContents as any)[key] + const element = document.querySelector(key); + + if (element) { + const hasTippyAttatched = !!(element as any)._tippy; + + if (!hasTippyAttatched && tooltipsContents) { + return tippy(element as any, { content }); + } + } + + return null; + }) + .filter(data => !!data); + + if (tippyInstances.length > 0) { + tippySingleton.setInstances(tippyInstances); + } +}; \ No newline at end of file diff --git a/src/store/trash-panel/trash-panel-action.ts b/src/store/trash-panel/trash-panel-action.ts index 80321b04..78b1a972 100644 --- a/src/store/trash-panel/trash-panel-action.ts +++ b/src/store/trash-panel/trash-panel-action.ts @@ -2,9 +2,13 @@ // // SPDX-License-Identifier: AGPL-3.0 +import { Dispatch } from "redux"; import { bindDataExplorerActions } from "store/data-explorer/data-explorer-action"; export const TRASH_PANEL_ID = "trashPanel"; export const trashPanelActions = bindDataExplorerActions(TRASH_PANEL_ID); -export const loadTrashPanel = () => trashPanelActions.REQUEST_ITEMS(); +export const loadTrashPanel = () => (dispatch: Dispatch) => { + dispatch(trashPanelActions.RESET_EXPLORER_SEARCH_VALUE()); + dispatch(trashPanelActions.REQUEST_ITEMS()); +}; \ No newline at end of file diff --git a/src/store/trash-panel/trash-panel-middleware-service.ts b/src/store/trash-panel/trash-panel-middleware-service.ts index 0319f729..c822cece 100644 --- a/src/store/trash-panel/trash-panel-middleware-service.ts +++ b/src/store/trash-panel/trash-panel-middleware-service.ts @@ -15,19 +15,20 @@ import { FilterBuilder } from "services/api/filter-builder"; import { trashPanelActions } from "./trash-panel-action"; import { Dispatch, MiddlewareAPI } from "redux"; import { OrderBuilder, OrderDirection } from "services/api/order-builder"; -import { GroupContentsResourcePrefix } from "services/groups-service/groups-service"; -import { ProjectResource } from "models/project"; +import { GroupContentsResource, GroupContentsResourcePrefix } from "services/groups-service/groups-service"; import { ProjectPanelColumnNames } from "views/project-panel/project-panel"; import { updateFavorites } from "store/favorites/favorites-actions"; import { updatePublicFavorites } from 'store/public-favorites/public-favorites-actions'; import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions"; import { updateResources } from "store/resources/resources-actions"; import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions"; -import { getSortColumn } from "store/data-explorer/data-explorer-reducer"; +import { DataExplorer, getSortColumn } from "store/data-explorer/data-explorer-reducer"; import { serializeResourceTypeFilters } from 'store//resource-type-filters/resource-type-filters'; import { getDataExplorerColumnFilters } from 'store/data-explorer/data-explorer-middleware-service'; import { joinFilters } from 'services/api/filter-builder'; - +import { CollectionResource } from "models/collection"; +import { MultiSelectMenuActionNames } from "views-components/multiselect-toolbar/ms-menu-actions"; +import { removeDisabledButton } from "store/multiselect/multiselect-actions"; export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService { constructor(private services: ServiceRepository, id: string) { super(id); @@ -35,8 +36,7 @@ export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService { async requestItems(api: MiddlewareAPI) { const dataExplorer = api.getState().dataExplorer[this.getId()]; - const columns = dataExplorer.columns as DataColumns; - const sortColumn = getSortColumn(dataExplorer); + const columns = dataExplorer.columns as DataColumns; const typeFilters = serializeResourceTypeFilters(getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.TYPE)); @@ -52,27 +52,14 @@ export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService { otherFilters, ); - const order = new OrderBuilder(); - - if (sortColumn) { - const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC - ? OrderDirection.ASC - : OrderDirection.DESC; - - const columnName = sortColumn && sortColumn.name === ProjectPanelColumnNames.NAME ? "name" : "createdAt"; - order - .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.COLLECTION) - .addOrder(sortDirection, columnName, GroupContentsResourcePrefix.PROJECT); - } - const userUuid = getUserUuid(api.getState()); if (!userUuid) { return; } try { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); const listResults = await this.services.groupsService - .contents(userUuid, { + .contents('', { ...dataExplorerToListParams(dataExplorer), - order: order.getOrder(), + order: getOrder(dataExplorer), filters, recursive: true, includeTrash: true @@ -98,9 +85,29 @@ export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService { })); api.dispatch(couldNotFetchTrashContents()); } + api.dispatch(removeDisabledButton(MultiSelectMenuActionNames.MOVE_TO_TRASH)) } } +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; + + // Use createdAt as a secondary sort column so we break ties consistently. + return order + .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.COLLECTION) + .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROJECT) + .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS) + .getOrder(); + } else { + return order.getOrder(); + } +}; + const couldNotFetchTrashContents = () => snackbarActions.OPEN_SNACKBAR({ message: 'Could not fetch trash contents.', 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 06abe39f..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"; @@ -14,7 +14,7 @@ import { pipe, values } from 'lodash/fp'; import { ResourceKind } from 'models/resource'; import { GroupContentsResource } from 'services/groups-service/groups-service'; import { getTreePicker, TreePicker } from './tree-picker'; -import { ProjectsTreePickerItem } from 'views-components/projects-tree-picker/generic-projects-tree-picker'; +import { ProjectsTreePickerItem } from './tree-picker-middleware'; import { OrderBuilder } from 'services/api/order-builder'; import { ProjectResource } from 'models/project'; import { mapTree } from '../../models/tree'; @@ -22,28 +22,52 @@ import { LinkResource, LinkClass } from "models/link"; import { mapTreeValues } from "models/tree"; import { sortFilesTree } from "services/collection-service/collection-service-files-response"; import { GroupClass, GroupResource } from "models/group"; +import { CollectionResource } from "models/collection"; +import { getResource } from "store/resources/resources"; +import { updateResources } from "store/resources/resources-actions"; +import { SnackbarKind, snackbarActions } from "store/snackbar/snackbar-actions"; export const treePickerActions = unionize({ LOAD_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(), LOAD_TREE_PICKER_NODE_SUCCESS: ofType<{ id: string, nodes: Array>, pickerId: string }>(), 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 }>() }); export type TreePickerAction = UnionOf; +export interface LoadProjectParams { + includeCollections?: boolean; + includeDirectories?: boolean; + includeFiles?: boolean; + includeFilterGroups?: boolean; + options?: { showOnlyOwned: boolean; showOnlyWritable: boolean; }; +} + +export const treePickerSearchActions = unionize({ + SET_TREE_PICKER_PROJECT_SEARCH: ofType<{ pickerId: string, projectSearchValue: string }>(), + SET_TREE_PICKER_COLLECTION_FILTER: ofType<{ pickerId: string, collectionFilterValue: string }>(), + SET_TREE_PICKER_LOAD_PARAMS: ofType<{ pickerId: string, params: LoadProjectParams }>(), + REFRESH_TREE_PICKER: ofType<{ pickerId: string }>(), +}); + +export type TreePickerSearchAction = UnionOf; + export const getProjectsTreePickerIds = (pickerId: string) => ({ home: `${pickerId}_home`, shared: `${pickerId}_shared`, favorites: `${pickerId}_favorites`, - publicFavorites: `${pickerId}_publicFavorites` + publicFavorites: `${pickerId}_publicFavorites`, + search: `${pickerId}_search`, }); export const getAllNodes = (pickerId: string, filter = (node: TreeNode) => true) => (state: TreePicker) => @@ -69,13 +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) => { - const { home, shared, favorites, publicFavorites } = getProjectsTreePickerIds(pickerId); +interface TreePickerPreloadParams { + selectedItemUuids: string[]; + includeDirectories: boolean; + includeFiles: boolean; + multi: boolean; +} + +export const initProjectsTreePicker = (pickerId: string, preloadParams?: TreePickerPreloadParams) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId); dispatch(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 { @@ -93,54 +135,117 @@ export const receiveTreePickerData = (params: ReceiveTreePickerDataParams) nodes: data.map(item => initTreeNode(extractNodeData(item))), pickerId, })); - dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId })); + dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE({ id, pickerId })); }; -interface LoadProjectParams { +interface LoadProjectParamsWithId extends LoadProjectParams { id: string; pickerId: string; - includeCollections?: boolean; - includeFiles?: boolean; - includeFilterGroups?: boolean; loadShared?: boolean; + searchProjects?: boolean; } -export const loadProject = (params: LoadProjectParams) => - async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => { - const { id, pickerId, includeCollections = false, includeFiles = false, includeFilterGroups = false, loadShared = false } = params; + +/** + * loadProject is used to load or refresh a project node in a tree picker + * Errors are caught and a toast is shown if the project fails to load + */ +export const loadProject = (params: LoadProjectParamsWithId) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const { + id, + pickerId, + includeCollections = false, + includeDirectories = false, + includeFiles = false, + includeFilterGroups = false, + loadShared = false, + options, + searchProjects = false + } = params; dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId })); - const filters = pipe( - (fb: FilterBuilder) => includeCollections - ? fb.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION]) - : fb.addIsA('uuid', [ResourceKind.PROJECT]), - fb => fb.getFilters(), - )(new FilterBuilder()); + let filterB = new FilterBuilder(); - const { items } = await services.groupsService.contents(loadShared ? '' : id, { filters, excludeHomeProject: loadShared || undefined }); + filterB = (includeCollections && !searchProjects) + ? filterB.addIsA('uuid', [ResourceKind.PROJECT, ResourceKind.COLLECTION]) + : filterB.addIsA('uuid', [ResourceKind.PROJECT]); - dispatch(receiveTreePickerData({ - id, - pickerId, - data: items.filter((item) => { + const state = getState(); + + if (state.treePickerSearch.collectionFilterValues[pickerId]) { + filterB = filterB.addFullTextSearch(state.treePickerSearch.collectionFilterValues[pickerId], 'collections'); + } else { + filterB = filterB.addNotIn("collections.properties.type", ["intermediate", "log"]); + } + + if (searchProjects && state.treePickerSearch.projectSearchValues[pickerId]) { + filterB = filterB.addFullTextSearch(state.treePickerSearch.projectSearchValues[pickerId], 'groups'); + } + + const filters = filterB.getFilters(); + + const itemLimit = 200; + + try { + const { items, itemsAvailable } = await services.groupsService.contents((loadShared || searchProjects) ? '' : id, { filters, excludeHomeProject: loadShared || undefined, limit: itemLimit }); + dispatch(updateResources(items)); + + if (itemsAvailable > itemLimit) { + items.push({ + uuid: "more-items-available", + kind: ResourceKind.WORKFLOW, + name: `*** Not all items listed (${items.length} out of ${itemsAvailable}), reduce item count with search or filter ***`, + description: "", + definition: "", + ownerUuid: "", + createdAt: "", + modifiedByClientUuid: "", + modifiedByUserUuid: "", + modifiedAt: "", + href: "", + etag: "" + }); + } + + dispatch(receiveTreePickerData({ + id, + pickerId, + data: items.filter((item) => { if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) { return false; } + + if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) { + return false; + } + return true; }), - extractNodeData: item => ({ - id: item.uuid, - value: item, - status: item.kind === ResourceKind.PROJECT - ? TreeNodeStatus.INITIAL - : includeFiles - ? TreeNodeStatus.INITIAL - : TreeNodeStatus.LOADED - }), - })); + extractNodeData: item => ( + item.uuid === "more-items-available" ? + { + id: item.uuid, + value: item, + status: TreeNodeStatus.LOADED + } + : { + id: item.uuid, + value: item, + status: item.kind === ResourceKind.PROJECT + ? TreeNodeStatus.INITIAL + : includeDirectories || includeFiles + ? TreeNodeStatus.INITIAL + : TreeNodeStatus.LOADED + }), + })); + } catch(e) { + console.error("Failed to load project into tree picker:", e);; + dispatch(snackbarActions.OPEN_SNACKBAR({ message: `Failed to load project`, kind: SnackbarKind.ERROR })); + } }; -export const loadCollection = (id: string, pickerId: string) => +export const loadCollection = (id: string, pickerId: string, includeDirectories?: boolean, includeFiles?: boolean) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId })); @@ -149,24 +254,30 @@ export const loadCollection = (id: string, pickerId: string) => const node = getNode(id)(picker); if (node && 'kind' in node.value && node.value.kind === ResourceKind.COLLECTION) { - const files = await services.collectionService.files(node.value.portableDataHash); + const files = (await services.collectionService.files(node.value.uuid)) + .filter((file) => ( + (includeFiles) || + (includeDirectories && file.type === CollectionFileType.DIRECTORY) + )); const tree = createCollectionFilesTree(files); const sorted = sortFilesTree(tree); const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted); - dispatch( + // await tree modifications so that consumers can guarantee node presence + await dispatch( treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({ id, pickerId, subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree) })); + // Expand collection root node dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId })); } } }; - +export const HOME_PROJECT_ID = 'Home Projects'; export const initUserProject = (pickerId: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const uuid = getUserUuid(getState()); @@ -174,7 +285,7 @@ export const initUserProject = (pickerId: string) => dispatch(receiveTreePickerData({ id: '', pickerId, - data: [{ uuid, name: 'Projects' }], + data: [{ uuid, name: HOME_PROJECT_ID }], extractNodeData: value => ({ id: value.uuid, status: TreeNodeStatus.INITIAL, @@ -183,11 +294,11 @@ export const initUserProject = (pickerId: string) => })); } }; -export const loadUserProject = (pickerId: string, includeCollections = false, includeFiles = false) => +export const loadUserProject = (pickerId: string, includeCollections = false, includeDirectories = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const uuid = getUserUuid(getState()); if (uuid) { - dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeFiles })); + dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeDirectories, includeFiles, options })); } }; @@ -206,6 +317,134 @@ export const initSharedProject = (pickerId: string) => })); }; +type PickerItemPreloadData = { + itemId: string; + mainItemUuid: string; + ancestors: (GroupResource | CollectionResource)[]; + isHomeProjectItem: boolean; +} + +type PickerTreePreloadData = { + tree: Tree; + 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) => { @@ -236,16 +475,34 @@ export const initPublicFavoritesProject = (pickerId: string) => })); }; +export const SEARCH_PROJECT_ID = 'Search all Projects'; +export const initSearchProject = (pickerId: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(receiveTreePickerData({ + id: '', + pickerId, + data: [{ uuid: SEARCH_PROJECT_ID, name: SEARCH_PROJECT_ID }], + extractNodeData: value => ({ + id: value.uuid, + status: TreeNodeStatus.INITIAL, + value, + }), + })); + }; + + interface LoadFavoritesProjectParams { pickerId: string; includeCollections?: boolean; + includeDirectories?: boolean; includeFiles?: boolean; + options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }; } export const loadFavoritesProject = (params: LoadFavoritesProjectParams, options: { showOnlyOwned: boolean, showOnlyWritable: boolean } = { showOnlyOwned: true, showOnlyWritable: false }) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const { pickerId, includeCollections = false, includeFiles = false } = params; + const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params; const uuid = getUserUuid(getState()); if (uuid) { const filters = pipe( @@ -261,7 +518,11 @@ export const loadFavoritesProject = (params: LoadFavoritesProjectParams, id: 'Favorites', pickerId, data: items.filter((item) => { - if (options.showOnlyWritable && (item as GroupResource).writableBy && (item as GroupResource).writableBy.indexOf(uuid) === -1) { + if (options.showOnlyWritable && !(item as GroupResource).canWrite) { + return false; + } + + if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) { return false; } @@ -272,7 +533,7 @@ export const loadFavoritesProject = (params: LoadFavoritesProjectParams, value: item, status: item.kind === ResourceKind.PROJECT ? TreeNodeStatus.INITIAL - : includeFiles + : includeDirectories || includeFiles ? TreeNodeStatus.INITIAL : TreeNodeStatus.LOADED }), @@ -282,7 +543,7 @@ export const loadFavoritesProject = (params: LoadFavoritesProjectParams, export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const { pickerId, includeCollections = false, includeFiles = false } = params; + const { pickerId, includeCollections = false, includeDirectories = false, includeFiles = false } = params; const uuidPrefix = getState().auth.config.uuidPrefix; const publicProjectUuid = `${uuidPrefix}-j7d0g-publicfavorites`; @@ -301,13 +562,19 @@ export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) = dispatch(receiveTreePickerData({ id: 'Public Favorites', pickerId, - data: items, + data: items.filter(item => { + if (params.options && params.options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) { + return false; + } + + return true; + }), extractNodeData: item => ({ id: item.headUuid, value: item, status: item.headKind === ResourceKind.PROJECT ? TreeNodeStatus.INITIAL - : includeFiles + : includeDirectories || includeFiles ? TreeNodeStatus.INITIAL : TreeNodeStatus.LOADED }), @@ -378,3 +645,74 @@ const buildParams = (ownerUuid: string) => { .getOrder() }; }; + +/** + * Given a tree picker item, return collection uuid and path + * if the item represents a valid target/destination location + */ +export type FileOperationLocation = { + name: string; + uuid: string; + pdh?: string; + subpath: string; +} +export const getFileOperationLocation = (item: ProjectsTreePickerItem) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise => { + 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 new file mode 100644 index 00000000..6f748a99 --- /dev/null +++ b/src/store/tree-picker/tree-picker-middleware.ts @@ -0,0 +1,122 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Dispatch, MiddlewareAPI } from 'redux'; +import { RootState } from 'store/store'; +import { ServiceRepository } from 'services/services'; +import { Middleware } from "redux"; +import { getNode, getNodeDescendantsIds, TreeNodeStatus } from 'models/tree'; +import { getTreePicker } from './tree-picker'; +import { + treePickerSearchActions, loadProject, loadFavoritesProject, loadPublicFavoritesProject, + SHARED_PROJECT_ID, FAVORITES_PROJECT_ID, PUBLIC_FAVORITES_PROJECT_ID, SEARCH_PROJECT_ID +} from "./tree-picker-actions"; +import { LinkResource } from "models/link"; +import { GroupContentsResource } from 'services/groups-service/groups-service'; +import { CollectionDirectory, CollectionFile } from 'models/collection-file'; + +export interface ProjectsTreePickerRootItem { + id: string; + name: string; +} + +export type ProjectsTreePickerItem = ProjectsTreePickerRootItem | GroupContentsResource | CollectionDirectory | CollectionFile | LinkResource; + +export const treePickerSearchMiddleware: Middleware = store => next => action => { + let isSearchAction = false; + let searchChanged = false; + + treePickerSearchActions.match(action, { + SET_TREE_PICKER_PROJECT_SEARCH: ({ pickerId, projectSearchValue }) => { + isSearchAction = true; + searchChanged = store.getState().treePickerSearch.projectSearchValues[pickerId] !== projectSearchValue; + }, + + SET_TREE_PICKER_COLLECTION_FILTER: ({ pickerId, collectionFilterValue }) => { + isSearchAction = true; + searchChanged = store.getState().treePickerSearch.collectionFilterValues[pickerId] !== collectionFilterValue; + }, + + REFRESH_TREE_PICKER: refreshPickers(store), + default: () => { } + }); + + if (isSearchAction && !searchChanged) { + return; + } + + // pass it on to the reducer + const r = next(action); + + treePickerSearchActions.match(action, { + SET_TREE_PICKER_PROJECT_SEARCH: ({ pickerId }) => + store.dispatch((dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const picker = getTreePicker(pickerId)(getState().treePicker); + if (picker) { + const loadParams = getState().treePickerSearch.loadProjectParams[pickerId]; + dispatch(loadProject({ + ...loadParams, + id: SEARCH_PROJECT_ID, + pickerId: pickerId, + searchProjects: true + })); + } + }), + + SET_TREE_PICKER_COLLECTION_FILTER: refreshPickers(store), + default: () => { } + }); + + return r; +} + +const refreshPickers = (store: MiddlewareAPI) => ({ pickerId }) => + store.dispatch((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 349240ea..84d5ed0c 100644 --- a/src/store/tree-picker/tree-picker-reducer.ts +++ b/src/store/tree-picker/tree-picker-reducer.ts @@ -2,13 +2,15 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { createTree, TreeNode, setNode, Tree, TreeNodeStatus, setNodeStatus, expandNode, deactivateNode, selectNodes, deselectNodes } from 'models/tree'; +import { + createTree, TreeNode, setNode, Tree, TreeNodeStatus, setNodeStatus, + expandNode, deactivateNode, selectNodes, deselectNodes, + activateNode, getNode, toggleNodeCollapse, toggleNodeSelection, appendSubtree, expandNodeAncestors +} from 'models/tree'; import { TreePicker } from "./tree-picker"; -import { treePickerActions, TreePickerAction } from "./tree-picker-actions"; +import { treePickerActions, treePickerSearchActions, TreePickerAction, TreePickerSearchAction, LoadProjectParams } from "./tree-picker-actions"; import { compose } from "redux"; -import { activateNode, getNode, toggleNodeCollapse, toggleNodeSelection } from 'models/tree'; import { pipe } from 'lodash/fp'; -import { appendSubtree } from 'models/tree'; export const treePickerReducer = (state: TreePicker = {}, action: TreePickerAction) => treePickerActions.match(action, { @@ -18,12 +20,18 @@ export const treePickerReducer = (state: TreePicker = {}, action: TreePickerActi LOAD_TREE_PICKER_NODE_SUCCESS: ({ id, nodes, pickerId }) => updateOrCreatePicker(state, pickerId, compose(receiveNodes(nodes)(id), setNodeStatus(id)(TreeNodeStatus.LOADED))), - APPEND_TREE_PICKER_NODE_SUBTREE: ({ id, subtree, pickerId}) => + APPEND_TREE_PICKER_NODE_SUBTREE: ({ id, subtree, pickerId }) => updateOrCreatePicker(state, pickerId, compose(appendSubtree(id, subtree), setNodeStatus(id)(TreeNodeStatus.LOADED))), TOGGLE_TREE_PICKER_NODE_COLLAPSE: ({ id, pickerId }) => updateOrCreatePicker(state, pickerId, toggleNodeCollapse(id)), + EXPAND_TREE_PICKER_NODE: ({ id, pickerId }) => + updateOrCreatePicker(state, pickerId, expandNode(id)), + + EXPAND_TREE_PICKER_NODE_ANCESTORS: ({ id, pickerId }) => + updateOrCreatePicker(state, pickerId, expandNodeAncestors(id)), + ACTIVATE_TREE_PICKER_NODE: ({ id, pickerId, relatedTreePickers = [] }) => pipe( () => relatedTreePickers.reduce( @@ -36,14 +44,14 @@ export const treePickerReducer = (state: TreePicker = {}, action: TreePickerActi DEACTIVATE_TREE_PICKER_NODE: ({ pickerId }) => updateOrCreatePicker(state, pickerId, deactivateNode), - TOGGLE_TREE_PICKER_NODE_SELECTION: ({ id, pickerId }) => - updateOrCreatePicker(state, pickerId, toggleNodeSelection(id)), + TOGGLE_TREE_PICKER_NODE_SELECTION: ({ id, pickerId, cascade }) => + updateOrCreatePicker(state, pickerId, toggleNodeSelection(id, cascade)), - SELECT_TREE_PICKER_NODE: ({ id, pickerId }) => - updateOrCreatePicker(state, pickerId, selectNodes(id)), + SELECT_TREE_PICKER_NODE: ({ id, pickerId, cascade }) => + updateOrCreatePicker(state, pickerId, selectNodes(id, cascade)), - DESELECT_TREE_PICKER_NODE: ({ id, pickerId }) => - updateOrCreatePicker(state, pickerId, deselectNodes(id)), + DESELECT_TREE_PICKER_NODE: ({ id, pickerId, cascade }) => + updateOrCreatePicker(state, pickerId, deselectNodes(id, cascade)), RESET_TREE_PICKER: ({ pickerId }) => updateOrCreatePicker(state, pickerId, createTree), @@ -67,6 +75,33 @@ const receiveNodes = (nodes: Array>) => (parent: string) => (stat newState = setNode({ ...parentNode, children: [] })(state); } return nodes.reduce((tree, node) => { + const preexistingNode = getNode(node.id)(state); + if (preexistingNode) { + node = { ...preexistingNode, value: node.value }; + } return setNode({ ...node, parent })(tree); }, newState); }; + +interface TreePickerSearch { + projectSearchValues: { [pickerId: string]: string }; + collectionFilterValues: { [pickerId: string]: string }; + loadProjectParams: { [pickerId: string]: LoadProjectParams }; +} + +export const treePickerSearchReducer = (state: TreePickerSearch = { projectSearchValues: {}, collectionFilterValues: {}, loadProjectParams: {} }, action: TreePickerSearchAction) => + treePickerSearchActions.match(action, { + SET_TREE_PICKER_PROJECT_SEARCH: ({ pickerId, projectSearchValue }) => ({ + ...state, projectSearchValues: { ...state.projectSearchValues, [pickerId]: projectSearchValue } + }), + + SET_TREE_PICKER_COLLECTION_FILTER: ({ pickerId, collectionFilterValue }) => ({ + ...state, collectionFilterValues: { ...state.collectionFilterValues, [pickerId]: collectionFilterValue } + }), + + SET_TREE_PICKER_LOAD_PARAMS: ({ pickerId, params }) => ({ + ...state, loadProjectParams: { ...state.loadProjectParams, [pickerId]: params } + }), + + default: () => state + }); 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 c0589a60..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())); } } } @@ -56,28 +60,23 @@ const getParams = (dataExplorer: DataExplorer) => ({ .getFilters() }); -export const getOrder = (dataExplorer: DataExplorer) => { - const sortColumn = getSortColumn(dataExplorer); +const getOrder = (dataExplorer: DataExplorer) => { + const sortColumn = getSortColumn(dataExplorer); const order = new OrderBuilder(); - if (sortColumn) { - const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC + if (sortColumn && sortColumn.sort) { + const sortDirection = sortColumn.sort.direction === SortDirection.ASC ? OrderDirection.ASC : OrderDirection.DESC; - switch (sortColumn.name) { - case UserPanelColumnNames.NAME: - order.addOrder(sortDirection, "firstName") - .addOrder(sortDirection, "lastName"); - break; - case UserPanelColumnNames.UUID: - order.addOrder(sortDirection, "uuid"); - break; - case UserPanelColumnNames.EMAIL: - order.addOrder(sortDirection, "email"); - break; - case UserPanelColumnNames.USERNAME: - order.addOrder(sortDirection, "username"); - break; + + if (sortColumn.name === UserPanelColumnNames.NAME) { + order.addOrder(sortDirection, "firstName") + .addOrder(sortDirection, "lastName"); + } else { + order.addOrder(sortDirection, sortColumn.sort.field); } + + // Use createdAt as a secondary sort column so we break ties consistently. + order.addOrder(OrderDirection.DESC, "createdAt"); } return order.getOrder(); }; diff --git a/src/store/users/users-actions.ts b/src/store/users/users-actions.ts index b553b324..4c789dbe 100644 --- a/src/store/users/users-actions.ts +++ b/src/store/users/users-actions.ts @@ -148,6 +148,7 @@ export const toggleIsAdmin = (uuid: string) => export const loadUsersPanel = () => (dispatch: Dispatch) => { + dispatch(userBindedActions.RESET_EXPLORER_SEARCH_VALUE()); dispatch(userBindedActions.REQUEST_ITEMS()); }; diff --git a/src/store/virtual-machines/virtual-machines-actions.ts b/src/store/virtual-machines/virtual-machines-actions.ts index e4b17ea0..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,46 +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() - }); - dispatch(updateResources(logins.items)); - dispatch(virtualMachinesActions.SET_LINKS(logins)); - - const users = await services.userService.list({ - filters: new FilterBuilder() - .addIn('uuid', logins.items.map(item => item.tailUuid)) - .getFilters(), - count: "none" - }); - dispatch(updateResources(users.items)); - - const getAllLogins = await services.virtualMachineService.getAllLogins(); - dispatch(virtualMachinesActions.SET_LOGINS(getAllLogins)); + try { + dispatch(progressIndicatorActions.START_WORKING("virtual-machines-admin")); + dispatch(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 virtualMachines = await services.virtualMachineService.list(); - const virtualMachinesUuids = virtualMachines.items.map(it => it.uuid); - const links = await services.linkService.list({ - filters: new FilterBuilder() - .addIn("head_uuid", virtualMachinesUuids) - .getFilters() - }); - dispatch(virtualMachinesActions.SET_VIRTUAL_MACHINES(virtualMachines)); - dispatch(virtualMachinesActions.SET_LINKS(links)); + try { + dispatch(progressIndicatorActions.START_WORKING("virtual-machines-user")); + + dispatch(loadRequestedDate()); + const user = getState().auth.user; + const virtualMachines = await services.virtualMachineService.list(); + const virtualMachinesUuids = virtualMachines.items.map(it => it.uuid); + const links = await services.linkService.list({ + filters: new FilterBuilder() + .addIn("head_uuid", virtualMachinesUuids) + .addEqual("tail_uuid", user?.uuid) + .getFilters() + }); + dispatch(virtualMachinesActions.SET_VIRTUAL_MACHINES(virtualMachines)); + dispatch(virtualMachinesActions.SET_LINKS(links)); + } finally { + dispatch(progressIndicatorActions.STOP_WORKING("virtual-machines-user")); + } }; export const openAddVirtualMachineLoginDialog = (vmUuid: string) => @@ -121,17 +137,17 @@ export const openAddVirtualMachineLoginDialog = (vmUuid: string) => dispatch(updateResources(virtualMachines.items)); const logins = await services.permissionService.list({ filters: new FilterBuilder() - .addIn('head_uuid', virtualMachines.items.map(item => item.uuid)) - .addEqual('name', PermissionLevel.CAN_LOGIN) - .getFilters() + .addIn('head_uuid', virtualMachines.items.map(item => item.uuid)) + .addEqual('name', PermissionLevel.CAN_LOGIN) + .getFilters() }); dispatch(updateResources(logins.items)); dispatch(initialize(VIRTUAL_MACHINE_ADD_LOGIN_FORM, { - [VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD]: vmUuid, - [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: [], - })); - dispatch(dialogActions.OPEN_DIALOG( {id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: {excludedParticipants: logins.items.map(it => it.tailUuid)}} )); + [VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD]: vmUuid, + [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: [], + })); + dispatch(dialogActions.OPEN_DIALOG({ id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: { excludedParticipants: logins.items.map(it => it.tailUuid) } })); } export const openEditVirtualMachineLoginDialog = (permissionUuid: string) => @@ -139,11 +155,11 @@ export const openEditVirtualMachineLoginDialog = (permissionUuid: string) => const login = await services.permissionService.get(permissionUuid); const user = await services.userService.get(login.tailUuid); dispatch(initialize(VIRTUAL_MACHINE_ADD_LOGIN_FORM, { - [VIRTUAL_MACHINE_UPDATE_LOGIN_UUID_FIELD]: permissionUuid, - [VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD]: {name: getUserDisplayName(user, true, true), uuid: login.tailUuid}, - [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: login.properties.groups, - })); - dispatch(dialogActions.OPEN_DIALOG( {id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: {updating: true}} )); + [VIRTUAL_MACHINE_UPDATE_LOGIN_UUID_FIELD]: permissionUuid, + [VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD]: { name: getUserDisplayName(user, true, true), uuid: login.tailUuid }, + [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: login.properties.groups, + })); + dispatch(dialogActions.OPEN_DIALOG({ id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: { updating: true } })); } export interface AddLoginFormData { @@ -154,15 +170,15 @@ export interface AddLoginFormData { } -export const addUpdateVirtualMachineLogin = ({uuid, vmUuid, user, groups}: AddLoginFormData) => +export const addUpdateVirtualMachineLogin = ({ uuid, vmUuid, user, groups }: AddLoginFormData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { let userResource: UserResource | undefined = undefined; try { // Get user userResource = await services.userService.get(user.uuid, false); } catch (e) { - dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Failed to get user details.", hideDuration: 2000, kind: SnackbarKind.ERROR })); - return; + dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Failed to get user details.", hideDuration: 2000, kind: SnackbarKind.ERROR })); + return; } try { if (uuid) { diff --git a/src/store/workbench/workbench-actions.ts b/src/store/workbench/workbench-actions.ts index 0a348431..188dba05 100644 --- a/src/store/workbench/workbench-actions.ts +++ b/src/store/workbench/workbench-actions.ts @@ -2,28 +2,24 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { Dispatch } from 'redux'; +import { Dispatch } from "redux"; import { RootState } from "store/store"; import { getUserUuid } from "common/getuser"; -import { loadDetailsPanel } from 'store/details-panel/details-panel-action'; -import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; -import { favoritePanelActions, loadFavoritePanel } from 'store/favorite-panel/favorite-panel-action'; -import { - getProjectPanelCurrentUuid, - openProjectPanel, - projectPanelActions, - setIsProjectPanelTrashed -} from 'store/project-panel/project-panel-action'; +import { loadDetailsPanel } from "store/details-panel/details-panel-action"; +import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions"; +import { favoritePanelActions, loadFavoritePanel } from "store/favorite-panel/favorite-panel-action"; +import { getProjectPanelCurrentUuid, setIsProjectPanelTrashed } from "store/project-panel/project-panel-action"; +import { projectPanelActions } from "store/project-panel/project-panel-action-bind"; import { activateSidePanelTreeItem, initSidePanelTree, loadSidePanelTreeProjects, - SidePanelTreeCategory -} from 'store/side-panel-tree/side-panel-tree-actions'; -import { updateResources } from 'store/resources/resources-actions'; -import { projectPanelColumns } from 'views/project-panel/project-panel'; -import { favoritePanelColumns } from 'views/favorite-panel/favorite-panel'; -import { matchRootRoute } from 'routes/routes'; + SidePanelTreeCategory, +} from "store/side-panel-tree/side-panel-tree-actions"; +import { updateResources } from "store/resources/resources-actions"; +import { projectPanelColumns } from "views/project-panel/project-panel"; +import { favoritePanelColumns } from "views/favorite-panel/favorite-panel"; +import { matchRootRoute } from "routes/routes"; import { setBreadcrumbs, setGroupDetailsBreadcrumbs, @@ -35,177 +31,222 @@ import { setUsersBreadcrumbs, setMyAccountBreadcrumbs, setUserProfileBreadcrumbs, -} from 'store/breadcrumbs/breadcrumbs-actions'; -import { navigateTo, navigateToRootProject } from 'store/navigation/navigation-action'; -import { MoveToFormDialogData } from 'store/move-to-dialog/move-to-dialog'; -import { ServiceRepository } from 'services/services'; -import { getResource } from 'store/resources/resources'; -import * as projectCreateActions from 'store/projects/project-create-actions'; -import * as projectMoveActions from 'store/projects/project-move-actions'; -import * as projectUpdateActions from 'store/projects/project-update-actions'; -import * as collectionCreateActions from 'store/collections/collection-create-actions'; -import * as collectionCopyActions from 'store/collections/collection-copy-actions'; -import * as collectionMoveActions from 'store/collections/collection-move-actions'; -import * as processesActions from 'store/processes/processes-actions'; -import * as processMoveActions from 'store/processes/process-move-actions'; -import * as processUpdateActions from 'store/processes/process-update-actions'; -import * as processCopyActions from 'store/processes/process-copy-actions'; +} from "store/breadcrumbs/breadcrumbs-actions"; +import { navigateTo, navigateToRootProject } from "store/navigation/navigation-action"; +import { MoveToFormDialogData } from "store/move-to-dialog/move-to-dialog"; +import { ServiceRepository } from "services/services"; +import { getResource } from "store/resources/resources"; +import * as projectCreateActions from "store/projects/project-create-actions"; +import * as projectMoveActions from "store/projects/project-move-actions"; +import * as projectUpdateActions from "store/projects/project-update-actions"; +import * as collectionCreateActions from "store/collections/collection-create-actions"; +import * as collectionCopyActions from "store/collections/collection-copy-actions"; +import * as collectionMoveActions from "store/collections/collection-move-actions"; +import * as processesActions from "store/processes/processes-actions"; +import * as processMoveActions from "store/processes/process-move-actions"; +import * as processUpdateActions from "store/processes/process-update-actions"; +import * as processCopyActions from "store/processes/process-copy-actions"; import { trashPanelColumns } from "views/trash-panel/trash-panel"; import { loadTrashPanel, trashPanelActions } from "store/trash-panel/trash-panel-action"; -import { loadProcessPanel } from 'store/process-panel/process-panel-actions'; -import { - loadSharedWithMePanel, - sharedWithMePanelActions -} from 'store/shared-with-me-panel/shared-with-me-panel-actions'; -import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog'; -import { workflowPanelActions } from 'store/workflow-panel/workflow-panel-actions'; -import { loadSshKeysPanel } from 'store/auth/auth-action-ssh'; -import { loadLinkAccountPanel, linkAccountPanelActions } from 'store/link-account-panel/link-account-panel-actions'; -import { loadSiteManagerPanel } from 'store/auth/auth-action-session'; -import { workflowPanelColumns } from 'views/workflow-panel/workflow-panel-view'; -import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions'; -import { getProgressIndicator } from 'store/progress-indicator/progress-indicator-reducer'; -import { extractUuidKind, ResourceKind } from 'models/resource'; -import { FilterBuilder } from 'services/api/filter-builder'; -import { GroupContentsResource } from 'services/groups-service/groups-service'; -import { MatchCases, ofType, unionize, UnionOf } from 'common/unionize'; -import { loadRunProcessPanel } from 'store/run-process-panel/run-process-panel-actions'; +import { loadProcessPanel } from "store/process-panel/process-panel-actions"; +import { loadSharedWithMePanel, sharedWithMePanelActions } from "store/shared-with-me-panel/shared-with-me-panel-actions"; +import { sharedWithMePanelColumns } from "views/shared-with-me-panel/shared-with-me-panel"; +import { CopyFormDialogData } from "store/copy-dialog/copy-dialog"; +import { workflowPanelActions } from "store/workflow-panel/workflow-panel-actions"; +import { loadSshKeysPanel } from "store/auth/auth-action-ssh"; +import { loadLinkAccountPanel, linkAccountPanelActions } from "store/link-account-panel/link-account-panel-actions"; +import { loadSiteManagerPanel } from "store/auth/auth-action-session"; +import { workflowPanelColumns } from "views/workflow-panel/workflow-panel-view"; +import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions"; +import { getProgressIndicator } from "store/progress-indicator/progress-indicator-reducer"; +import { extractUuidKind, Resource, ResourceKind } from "models/resource"; +import { FilterBuilder } from "services/api/filter-builder"; +import { GroupContentsResource } from "services/groups-service/groups-service"; +import { MatchCases, ofType, unionize, UnionOf } from "common/unionize"; +import { loadRunProcessPanel } from "store/run-process-panel/run-process-panel-actions"; import { collectionPanelActions, loadCollectionPanel } from "store/collection-panel/collection-panel-action"; import { CollectionResource } from "models/collection"; -import { - loadSearchResultsPanel, - searchResultsPanelActions -} from 'store/search-results-panel/search-results-panel-actions'; -import { searchResultsPanelColumns } from 'views/search-results-panel/search-results-panel-view'; -import { loadVirtualMachinesPanel } from 'store/virtual-machines/virtual-machines-actions'; -import { loadRepositoriesPanel } from 'store/repositories/repositories-actions'; -import { loadKeepServicesPanel } from 'store/keep-services/keep-services-actions'; -import { loadUsersPanel, userBindedActions } from 'store/users/users-actions'; -import * as userProfilePanelActions from 'store/user-profile/user-profile-actions'; -import { linkPanelActions, loadLinkPanel } from 'store/link-panel/link-panel-actions'; -import { linkPanelColumns } from 'views/link-panel/link-panel-root'; -import { userPanelColumns } from 'views/user-panel/user-panel'; -import { loadApiClientAuthorizationsPanel, apiClientAuthorizationsActions } from 'store/api-client-authorizations/api-client-authorizations-actions'; -import { apiClientAuthorizationPanelColumns } from 'views/api-client-authorization-panel/api-client-authorization-panel-root'; -import * as groupPanelActions from 'store/groups-panel/groups-panel-actions'; -import { groupsPanelColumns } from 'views/groups-panel/groups-panel'; -import * as groupDetailsPanelActions from 'store/group-details-panel/group-details-panel-actions'; -import { groupDetailsMembersPanelColumns, groupDetailsPermissionsPanelColumns } from 'views/group-details-panel/group-details-panel'; +import { WorkflowResource } from "models/workflow"; +import { loadSearchResultsPanel, searchResultsPanelActions } from "store/search-results-panel/search-results-panel-actions"; +import { searchResultsPanelColumns } from "views/search-results-panel/search-results-panel-view"; +import { loadVirtualMachinesPanel } from "store/virtual-machines/virtual-machines-actions"; +import { loadRepositoriesPanel } from "store/repositories/repositories-actions"; +import { loadKeepServicesPanel } from "store/keep-services/keep-services-actions"; +import { loadUsersPanel, userBindedActions } from "store/users/users-actions"; +import * as userProfilePanelActions from "store/user-profile/user-profile-actions"; +import { linkPanelActions, loadLinkPanel } from "store/link-panel/link-panel-actions"; +import { linkPanelColumns } from "views/link-panel/link-panel-root"; +import { userPanelColumns } from "views/user-panel/user-panel"; +import { loadApiClientAuthorizationsPanel, apiClientAuthorizationsActions } from "store/api-client-authorizations/api-client-authorizations-actions"; +import { apiClientAuthorizationPanelColumns } from "views/api-client-authorization-panel/api-client-authorization-panel-root"; +import * as groupPanelActions from "store/groups-panel/groups-panel-actions"; +import { groupsPanelColumns } from "views/groups-panel/groups-panel"; +import * as groupDetailsPanelActions from "store/group-details-panel/group-details-panel-actions"; +import { groupDetailsMembersPanelColumns, groupDetailsPermissionsPanelColumns } from "views/group-details-panel/group-details-panel"; import { DataTableFetchMode } from "components/data-table/data-table"; -import { loadPublicFavoritePanel, publicFavoritePanelActions } from 'store/public-favorites-panel/public-favorites-action'; -import { publicFavoritePanelColumns } from 'views/public-favorites-panel/public-favorites-panel'; -import { loadCollectionsContentAddressPanel, collectionsContentAddressActions } from 'store/collections-content-address-panel/collections-content-address-panel-actions'; -import { collectionContentAddressPanelColumns } from 'views/collection-content-address-panel/collection-content-address-panel'; -import { subprocessPanelActions } from 'store/subprocess-panel/subprocess-panel-actions'; -import { subprocessPanelColumns } from 'views/subprocess-panel/subprocess-panel-root'; -import { loadAllProcessesPanel, allProcessesPanelActions } from '../all-processes-panel/all-processes-panel-action'; -import { allProcessesPanelColumns } from 'views/all-processes-panel/all-processes-panel'; -import { AdminMenuIcon } from 'components/icon/icon'; -import { userProfileGroupsColumns } from 'views/user-profile-panel/user-profile-panel-root'; - -export const WORKBENCH_LOADING_SCREEN = 'workbenchLoadingScreen'; +import { loadPublicFavoritePanel, publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action"; +import { publicFavoritePanelColumns } from "views/public-favorites-panel/public-favorites-panel"; +import { + loadCollectionsContentAddressPanel, + collectionsContentAddressActions, +} from "store/collections-content-address-panel/collections-content-address-panel-actions"; +import { collectionContentAddressPanelColumns } from "views/collection-content-address-panel/collection-content-address-panel"; +import { subprocessPanelActions } from "store/subprocess-panel/subprocess-panel-actions"; +import { subprocessPanelColumns } from "views/subprocess-panel/subprocess-panel-root"; +import { loadAllProcessesPanel, allProcessesPanelActions } from "../all-processes-panel/all-processes-panel-action"; +import { allProcessesPanelColumns } from "views/all-processes-panel/all-processes-panel"; +import { AdminMenuIcon } from "components/icon/icon"; +import { userProfileGroupsColumns } from "views/user-profile-panel/user-profile-panel-root"; +import { selectedToArray, selectedToKindSet } from "components/multiselect-toolbar/MultiselectToolbar"; +import { multiselectActions } from "store/multiselect/multiselect-actions"; + +export const WORKBENCH_LOADING_SCREEN = "workbenchLoadingScreen"; export const isWorkbenchLoading = (state: RootState) => { const progress = getProgressIndicator(WORKBENCH_LOADING_SCREEN)(state.progressIndicator); return progress ? progress.working : false; }; -export const handleFirstTimeLoad = (action: any) => - async (dispatch: Dispatch, 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 })); - - if (services.linkAccountService.getAccountToLink()) { - dispatch(linkAccountPanelActions.HAS_SESSION_DATA()); - } +export const loadWorkbench = () => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN)); + const { auth, router } = getState(); + const { user } = auth; + if (user) { + dispatch(projectPanelActions.SET_COLUMNS({ columns: projectPanelColumns })); + dispatch(favoritePanelActions.SET_COLUMNS({ columns: favoritePanelColumns })); + dispatch( + allProcessesPanelActions.SET_COLUMNS({ + columns: allProcessesPanelColumns, + }) + ); + dispatch( + publicFavoritePanelActions.SET_COLUMNS({ + columns: publicFavoritePanelColumns, + }) + ); + dispatch(trashPanelActions.SET_COLUMNS({ columns: trashPanelColumns })); + dispatch(sharedWithMePanelActions.SET_COLUMNS({ columns: sharedWithMePanelColumns })); + dispatch(workflowPanelActions.SET_COLUMNS({ columns: workflowPanelColumns })); + dispatch( + searchResultsPanelActions.SET_FETCH_MODE({ + fetchMode: DataTableFetchMode.INFINITE, + }) + ); + dispatch( + searchResultsPanelActions.SET_COLUMNS({ + columns: searchResultsPanelColumns, + }) + ); + dispatch(userBindedActions.SET_COLUMNS({ columns: userPanelColumns })); + dispatch( + groupPanelActions.GroupsPanelActions.SET_COLUMNS({ + columns: groupsPanelColumns, + }) + ); + dispatch( + groupDetailsPanelActions.GroupMembersPanelActions.SET_COLUMNS({ + columns: groupDetailsMembersPanelColumns, + }) + ); + dispatch( + groupDetailsPanelActions.GroupPermissionsPanelActions.SET_COLUMNS({ + columns: groupDetailsPermissionsPanelColumns, + }) + ); + dispatch( + userProfilePanelActions.UserProfileGroupsActions.SET_COLUMNS({ + columns: userProfileGroupsColumns, + }) + ); + dispatch(linkPanelActions.SET_COLUMNS({ columns: linkPanelColumns })); + dispatch( + apiClientAuthorizationsActions.SET_COLUMNS({ + columns: apiClientAuthorizationPanelColumns, + }) + ); + dispatch( + collectionsContentAddressActions.SET_COLUMNS({ + columns: collectionContentAddressPanelColumns, + }) + ); + dispatch(subprocessPanelActions.SET_COLUMNS({ columns: subprocessPanelColumns })); + + if (services.linkAccountService.getAccountToLink()) { + dispatch(linkAccountPanelActions.HAS_SESSION_DATA()); + } - dispatch(initSidePanelTree()); - if (router.location) { - const match = matchRootRoute(router.location.pathname); - if (match) { - dispatch(navigateToRootProject); - } + dispatch(initSidePanelTree()); + if (router.location) { + const match = matchRootRoute(router.location.pathname); + if (match) { + dispatch(navigateToRootProject); } - } else { - dispatch(userIsNotAuthenticated); } - }; + } else { + dispatch(userIsNotAuthenticated); + } +}; export const loadFavorites = () => - handleFirstTimeLoad( - (dispatch: Dispatch) => { - dispatch(activateSidePanelTreeItem(SidePanelTreeCategory.FAVORITES)); - dispatch(loadFavoritePanel()); - dispatch(setSidePanelBreadcrumbs(SidePanelTreeCategory.FAVORITES)); - }); - -export const loadCollectionContentAddress = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadCollectionsContentAddressPanel()); + handleFirstTimeLoad((dispatch: Dispatch) => { + dispatch(activateSidePanelTreeItem(SidePanelTreeCategory.FAVORITES)); + dispatch(loadFavoritePanel()); + dispatch(setSidePanelBreadcrumbs(SidePanelTreeCategory.FAVORITES)); }); +export const loadCollectionContentAddress = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadCollectionsContentAddressPanel()); +}); + export const loadTrash = () => - handleFirstTimeLoad( - (dispatch: Dispatch) => { - dispatch(activateSidePanelTreeItem(SidePanelTreeCategory.TRASH)); - dispatch(loadTrashPanel()); - dispatch(setSidePanelBreadcrumbs(SidePanelTreeCategory.TRASH)); - }); + handleFirstTimeLoad((dispatch: Dispatch) => { + dispatch(activateSidePanelTreeItem(SidePanelTreeCategory.TRASH)); + dispatch(loadTrashPanel()); + dispatch(setSidePanelBreadcrumbs(SidePanelTreeCategory.TRASH)); + }); export const loadAllProcesses = () => - handleFirstTimeLoad( - (dispatch: Dispatch) => { - dispatch(activateSidePanelTreeItem(SidePanelTreeCategory.ALL_PROCESSES)); - dispatch(loadAllProcessesPanel()); - dispatch(setSidePanelBreadcrumbs(SidePanelTreeCategory.ALL_PROCESSES)); - } - ); + handleFirstTimeLoad((dispatch: Dispatch) => { + 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)); } else if (userUuid !== uuid) { await dispatch(finishLoadingProject(uuid)); - const match = await loadGroupContentsResource({ uuid, userUuid, services }); + const match = await loadGroupContentsResource({ + uuid, + userUuid, + services, + }); match({ OWNED: async () => { await dispatch(activateSidePanelTreeItem(uuid)); @@ -219,222 +260,455 @@ export const loadProject = (uuid: string) => await dispatch(activateSidePanelTreeItem(SidePanelTreeCategory.TRASH)); dispatch(setTrashBreadcrumbs(uuid)); dispatch(setIsProjectPanelTrashed(true)); - } + }, }); } else { await dispatch(finishLoadingProject(userUuid)); 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({ +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)); - } - }; + 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) { - dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Project has been moved', hideDuration: 2000, kind: SnackbarKind.SUCCESS })); - if (oldProject) { - await dispatch(loadSidePanelTreeProjects(oldProject.ownerUuid)); - } - dispatch(reloadProjectMatchingUuid([oldOwnerUuid, movedProject.ownerUuid, movedProject.uuid])); +export const moveProject = + (data: MoveToFormDialogData, isSecondaryMove = false) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const checkedList = getState().multiselect.checkedList; + const uuidsToMove: string[] = data.fromContextMenu ? [data.uuid] : selectedToArray(checkedList); + + //if no items in checkedlist default to normal context menu behavior + if (!isSecondaryMove && !uuidsToMove.length) uuidsToMove.push(data.uuid); + + const sourceUuid = getResource(data.uuid)(getState().resources)?.ownerUuid; + const destinationUuid = data.ownerUuid; + + const projectsToMove: MoveableResource[] = uuidsToMove + .map(uuid => getResource(uuid)(getState().resources) as MoveableResource) + .filter(resource => resource.kind === ResourceKind.PROJECT); + + for (const project of projectsToMove) { + await moveSingleProject(project); } - } catch (e) { - dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR })); - } - }; -export const updateProject = (data: projectUpdateActions.ProjectUpdateFormDialogData) => - async (dispatch: Dispatch) => { - const updatedProject = await dispatch(projectUpdateActions.updateProject(data)); - if (updatedProject) { - dispatch(snackbarActions.OPEN_SNACKBAR({ + //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 as any).frozenByUuid ? 'Could not move frozen project.' : 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])); - } - }; + 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({ +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])); - } - }; + 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 }); + 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({ +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)); - } - }; + 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); -export const copyCollection = (data: CopyFormDialogData) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + //if no items in checkedlist && no items passed in, default to normal context menu behavior + if (!uuidsToCopy.length) uuidsToCopy.push(data.uuid); + + const collectionsToCopy: CollectionCopyResource[] = uuidsToCopy + .map(uuid => getResource(uuid)(getState().resources) as CollectionCopyResource) + .filter(resource => resource.kind === ResourceKind.COLLECTION); + + for (const collection of collectionsToCopy) { + await copySingleCollection({ ...collection, ownerUuid: data.ownerUuid } as CollectionCopyResource); + } + + async function copySingleCollection(copyToProject: CollectionCopyResource) { + const newName = data.fromContextMenu || collectionsToCopy.length === 1 ? data.name : `Copy of: ${copyToProject.name}`; try { - const copyToProject = getResource(data.ownerUuid)(getState().resources); - const collection = await dispatch(collectionCopyActions.copyCollection(data)); + const collection = await dispatch( + collectionCopyActions.copyCollection({ + ...copyToProject, + name: newName, + fromContextMenu: collectionsToCopy.length === 1 ? true : data.fromContextMenu, + }) + ); if (copyToProject && collection) { - dispatch(reloadProjectMatchingUuid([copyToProject.uuid])); - dispatch(snackbarActions.OPEN_SNACKBAR({ - message: 'Collection has been copied.', - hideDuration: 3000, - kind: SnackbarKind.SUCCESS, - link: collection.ownerUuid - })); + await dispatch(reloadProjectMatchingUuid([copyToProject.uuid])); + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: "Collection has been copied.", + hideDuration: 3000, + kind: SnackbarKind.SUCCESS, + link: collection.ownerUuid, + }) + ); + dispatch(multiselectActions.deselectOne(copyToProject.uuid)); } } catch (e) { - dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR })); + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: e.message, + hideDuration: 2000, + kind: SnackbarKind.ERROR, + }) + ); } - }; + } + dispatch(projectPanelActions.REQUEST_ITEMS()); +}; -export const moveCollection = (data: MoveToFormDialogData) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - try { - const collection = await dispatch(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 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 moved.", + hideDuration: 2000, + kind: SnackbarKind.SUCCESS, + }) + ); + } catch (e) { + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: e.message, + hideDuration: 2000, + kind: SnackbarKind.ERROR, + }) + ); + } + } + }; export const loadProcess = (uuid: string) => - handleFirstTimeLoad( - async (dispatch: Dispatch, getState: () => RootState) => { + handleFirstTimeLoad(async (dispatch: Dispatch, getState: () => RootState) => { + try { + dispatch(progressIndicatorActions.START_WORKING(uuid)); dispatch(loadProcessPanel(uuid)); const process = await dispatch(processesActions.loadProcess(uuid)); - await dispatch(activateSidePanelTreeItem(process.containerRequest.ownerUuid)); - dispatch(setProcessBreadcrumbs(uuid)); - dispatch(loadDetailsPanel(uuid)); - }); - -export const updateProcess = (data: processUpdateActions.ProcessUpdateFormDialogData) => - async (dispatch: Dispatch) => { - try { - const process = await dispatch(processUpdateActions.updateProcess(data)); if (process) { - dispatch(snackbarActions.OPEN_SNACKBAR({ - message: "Process has been successfully updated.", - hideDuration: 2000, - kind: SnackbarKind.SUCCESS - })); - dispatch(updateResources([process])); - dispatch(reloadProjectMatchingUuid([process.ownerUuid])); + await dispatch(finishLoadingProject(process.containerRequest.ownerUuid)); + await dispatch(activateSidePanelTreeItem(process.containerRequest.ownerUuid)); + dispatch(setProcessBreadcrumbs(uuid)); + dispatch(loadDetailsPanel(uuid)); } - } catch (e) { - dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR })); + } finally { + dispatch(progressIndicatorActions.STOP_WORKING(uuid)); } - }; + }); -export const moveProcess = (data: MoveToFormDialogData) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - try { - const process = await dispatch(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 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 copyProcess = (data: CopyFormDialogData) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - try { - const process = await dispatch(processCopyActions.copyProcess(data)); +export const updateProcess = (data: processUpdateActions.ProcessUpdateFormDialogData) => async (dispatch: Dispatch) => { + try { + const process = await dispatch(processUpdateActions.updateProcess(data)); + if (process) { + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: "Process has been successfully updated.", + hideDuration: 2000, + kind: SnackbarKind.SUCCESS, + }) + ); dispatch(updateResources([process])); dispatch(reloadProjectMatchingUuid([process.ownerUuid])); - dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Process has been copied.', hideDuration: 2000, kind: SnackbarKind.SUCCESS })); - } catch (e) { - dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR })); } - }; + } catch (e) { + dispatch( + snackbarActions.OPEN_SNACKBAR({ + message: e.message, + hideDuration: 2000, + kind: SnackbarKind.ERROR, + }) + ); + } +}; + +export const moveProcess = + (data: MoveToFormDialogData, isSecondaryMove = false) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const checkedList = getState().multiselect.checkedList; + const uuidsToMove: string[] = data.fromContextMenu ? [data.uuid] : selectedToArray(checkedList); + + //if no items in checkedlist && no items passed in, default to normal context menu behavior + if (!isSecondaryMove && !uuidsToMove.length) uuidsToMove.push(data.uuid); + + const processesToMove: MoveableResource[] = uuidsToMove + .map(uuid => getResource(uuid)(getState().resources) as MoveableResource) + .filter(resource => resource.kind === ResourceKind.PROCESS); + + for (const process of processesToMove) { + await moveSingleProcess(process); + } + + //omly propagate if this call is the original + if (!isSecondaryMove) { + const kindsToMove: Set = 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 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 resourceIsNotLoaded = (uuid: string) => snackbarActions.OPEN_SNACKBAR({ message: `Resource identified by ${uuid} is not loaded.`, - kind: SnackbarKind.ERROR + kind: SnackbarKind.ERROR, }); export const userIsNotAuthenticated = snackbarActions.OPEN_SNACKBAR({ - message: 'User is not authenticated', - kind: SnackbarKind.ERROR + message: "User is not authenticated", + kind: SnackbarKind.ERROR, }); export const couldNotLoadUser = snackbarActions.OPEN_SNACKBAR({ - message: 'Could not load user', - kind: SnackbarKind.ERROR + message: "Could not load user", + kind: SnackbarKind.ERROR, }); -export const reloadProjectMatchingUuid = (matchingUuids: string[]) => - async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { +export const reloadProjectMatchingUuid = + (matchingUuids: string[]) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const currentProjectPanelUuid = getProjectPanelCurrentUuid(getState()); if (currentProjectPanelUuid && matchingUuids.some(uuid => uuid === currentProjectPanelUuid)) { dispatch(loadProject(currentProjectPanelUuid)); @@ -447,124 +721,97 @@ export const loadSharedWithMe = handleFirstTimeLoad(async (dispatch: Dispatch) = 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(loadPublicFavoritePanel()); - dispatch(setSidePanelBreadcrumbs(SidePanelTreeCategory.PUBLIC_FAVORITES)); - }); - -export const loadSearchResults = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadSearchResultsPanel()); + handleFirstTimeLoad((dispatch: Dispatch) => { + dispatch(activateSidePanelTreeItem(SidePanelTreeCategory.PUBLIC_FAVORITES)); + dispatch(loadPublicFavoritePanel()); + dispatch(setSidePanelBreadcrumbs(SidePanelTreeCategory.PUBLIC_FAVORITES)); }); -export const loadLinks = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadLinkPanel()); - }); +export const loadSearchResults = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadSearchResultsPanel()); +}); -export const loadVirtualMachines = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadVirtualMachinesPanel()); - dispatch(setBreadcrumbs([{ label: 'Virtual Machines' }])); - }); +export const loadLinks = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadLinkPanel()); +}); -export const loadVirtualMachinesAdmin = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadVirtualMachinesPanel()); - dispatch(setBreadcrumbs([{ label: 'Virtual Machines Admin', icon: AdminMenuIcon }])); - }); +export const loadVirtualMachines = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadVirtualMachinesPanel()); + dispatch(setBreadcrumbs([{ label: "Virtual Machines" }])); +}); -export const loadRepositories = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadRepositoriesPanel()); - dispatch(setBreadcrumbs([{ label: 'Repositories' }])); - }); +export const loadVirtualMachinesAdmin = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadVirtualMachinesPanel()); + dispatch(setBreadcrumbs([{ label: "Virtual Machines Admin", icon: AdminMenuIcon }])); +}); -export const loadSshKeys = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadSshKeysPanel()); - }); +export const loadRepositories = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadRepositoriesPanel()); + dispatch(setBreadcrumbs([{ label: "Repositories" }])); +}); -export const loadSiteManager = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadSiteManagerPanel()); - }); +export const loadSshKeys = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadSshKeysPanel()); +}); + +export const loadSiteManager = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadSiteManagerPanel()); +}); export const loadUserProfile = (userUuid?: string) => - handleFirstTimeLoad( - (dispatch: Dispatch) => { - if (userUuid) { - dispatch(setUserProfileBreadcrumbs(userUuid)); - dispatch(userProfilePanelActions.loadUserProfilePanel(userUuid)); - } else { - dispatch(setMyAccountBreadcrumbs()); - dispatch(userProfilePanelActions.loadUserProfilePanel()); - } + handleFirstTimeLoad((dispatch: Dispatch) => { + if (userUuid) { + dispatch(setUserProfileBreadcrumbs(userUuid)); + dispatch(userProfilePanelActions.loadUserProfilePanel(userUuid)); + } else { + dispatch(setMyAccountBreadcrumbs()); + dispatch(userProfilePanelActions.loadUserProfilePanel()); } - ); - -export const loadLinkAccount = handleFirstTimeLoad( - (dispatch: Dispatch) => { - dispatch(loadLinkAccountPanel()); }); -export const loadKeepServices = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadKeepServicesPanel()); - }); +export const loadLinkAccount = handleFirstTimeLoad((dispatch: Dispatch) => { + dispatch(loadLinkAccountPanel()); +}); -export const loadUsers = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadUsersPanel()); - dispatch(setUsersBreadcrumbs()); - }); +export const loadKeepServices = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadKeepServicesPanel()); +}); -export const loadApiClientAuthorizations = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadApiClientAuthorizationsPanel()); - }); +export const loadUsers = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadUsersPanel()); + dispatch(setUsersBreadcrumbs()); +}); -export const loadGroupsPanel = handleFirstTimeLoad( - (dispatch: Dispatch) => { - dispatch(setGroupsBreadcrumbs()); - dispatch(groupPanelActions.loadGroupsPanel()); - }); +export const loadApiClientAuthorizations = handleFirstTimeLoad(async (dispatch: Dispatch) => { + await dispatch(loadApiClientAuthorizationsPanel()); +}); +export const loadGroupsPanel = handleFirstTimeLoad((dispatch: Dispatch) => { + dispatch(setGroupsBreadcrumbs()); + dispatch(groupPanelActions.loadGroupsPanel()); +}); export const loadGroupDetailsPanel = (groupUuid: string) => - handleFirstTimeLoad( - (dispatch: Dispatch) => { - dispatch(setGroupDetailsBreadcrumbs(groupUuid)); - 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])); - } - }; + handleFirstTimeLoad((dispatch: Dispatch) => { + dispatch(setGroupDetailsBreadcrumbs(groupUuid)); + dispatch(groupDetailsPanelActions.loadGroupDetailsPanel(groupUuid)); + }); -const loadGroupContentsResource = async (params: { - uuid: string, - userUuid: string, - services: ServiceRepository -}) => { - const filters = new FilterBuilder() - .addEqual('uuid', params.uuid) - .getFilters(); +const finishLoadingProject = (project: GroupContentsResource | string) => async (dispatch: Dispatch) => { + const uuid = typeof project === "string" ? project : project.uuid; + dispatch(loadDetailsPanel(uuid)); + if (typeof project !== "string") { + dispatch(updateResources([project])); + } +}; + +const loadGroupContentsResource = async (params: { uuid: string; userUuid: string; services: ServiceRepository }) => { + const filters = new FilterBuilder().addEqual("uuid", params.uuid).getFilters(); const { items } = await params.services.groupsService.contents(params.userUuid, { filters, recursive: true, @@ -573,9 +820,10 @@ const loadGroupContentsResource = async (params: { const resource = items.shift(); let handler: GroupContentsHandler; if (resource) { - handler = (resource.kind === ResourceKind.COLLECTION || resource.kind === ResourceKind.PROJECT) && resource.isTrashed - ? groupContentsHandlers.TRASHED(resource) - : groupContentsHandlers.OWNED(resource); + handler = + (resource.kind === ResourceKind.COLLECTION || resource.kind === ResourceKind.PROJECT) && resource.isTrashed + ? groupContentsHandlers.TRASHED(resource) + : groupContentsHandlers.OWNED(resource); } else { const kind = extractUuidKind(params.uuid); let resource: GroupContentsResource; @@ -583,14 +831,16 @@ const loadGroupContentsResource = async (params: { resource = await params.services.collectionService.get(params.uuid); } else if (kind === ResourceKind.PROJECT) { resource = await params.services.projectService.get(params.uuid); - } else { + } else if (kind === ResourceKind.WORKFLOW) { + resource = await params.services.workflowService.get(params.uuid); + } else if (kind === ResourceKind.CONTAINER_REQUEST) { resource = await params.services.containerRequestService.get(params.uuid); + } else { + throw new Error("loadGroupContentsResource unsupported kind " + kind); } handler = groupContentsHandlers.SHARED(resource); } - return (cases: MatchCases) => - groupContentsHandlers.match(handler, cases); - + return (cases: MatchCases) => groupContentsHandlers.match(handler, cases); }; const groupContentsHandlersRecord = { @@ -602,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-middleware-service.ts b/src/store/workflow-panel/workflow-middleware-service.ts index d3a1d055..587f0224 100644 --- a/src/store/workflow-panel/workflow-middleware-service.ts +++ b/src/store/workflow-panel/workflow-middleware-service.ts @@ -4,19 +4,15 @@ import { ServiceRepository } from 'services/services'; import { MiddlewareAPI, Dispatch } from 'redux'; -import { DataExplorerMiddlewareService, dataExplorerToListParams, listResultsToDataExplorerItemsMeta } from 'store/data-explorer/data-explorer-middleware-service'; +import { DataExplorerMiddlewareService, dataExplorerToListParams, getOrder, listResultsToDataExplorerItemsMeta } from 'store/data-explorer/data-explorer-middleware-service'; import { RootState } from 'store/store'; import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; import { DataExplorer, getDataExplorer } from 'store/data-explorer/data-explorer-reducer'; import { updateResources } from 'store/resources/resources-actions'; import { FilterBuilder } from 'services/api/filter-builder'; -import { SortDirection } from 'components/data-table/data-column'; -import { WorkflowPanelColumnNames } from 'views/workflow-panel/workflow-panel-view'; -import { OrderDirection, OrderBuilder } from 'services/api/order-builder'; import { WorkflowResource } from 'models/workflow'; import { ListResults } from 'services/common-service/common-service'; import { workflowPanelActions } from 'store/workflow-panel/workflow-panel-actions'; -import { getSortColumn } from "store/data-explorer/data-explorer-reducer"; export class WorkflowMiddlewareService extends DataExplorerMiddlewareService { constructor(private services: ServiceRepository, id: string) { @@ -38,7 +34,7 @@ export class WorkflowMiddlewareService extends DataExplorerMiddlewareService { export const getParams = (dataExplorer: DataExplorer) => ({ ...dataExplorerToListParams(dataExplorer), - order: getOrder(dataExplorer), + order: getOrder(dataExplorer), filters: getFilters(dataExplorer) }); @@ -49,22 +45,6 @@ export const getFilters = (dataExplorer: DataExplorer) => { return filters; }; -export const getOrder = (dataExplorer: DataExplorer) => { - const sortColumn = getSortColumn(dataExplorer); - const order = new OrderBuilder(); - if (sortColumn) { - const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC - ? OrderDirection.ASC - : OrderDirection.DESC; - const columnName = sortColumn && sortColumn.name === WorkflowPanelColumnNames.NAME ? "name" : "modifiedAt"; - return order - .addOrder(sortDirection, columnName) - .getOrder(); - } else { - return order.getOrder(); - } -}; - export const setItems = (listResults: ListResults) => workflowPanelActions.SET_ITEMS({ ...listResultsToDataExplorerItemsMeta(listResults), @@ -75,4 +55,4 @@ const couldNotFetchWorkflows = () => snackbarActions.OPEN_SNACKBAR({ message: 'Could not fetch workflows.', kind: SnackbarKind.ERROR - }); \ No newline at end of file + }); diff --git a/src/store/workflow-panel/workflow-panel-actions.ts b/src/store/workflow-panel/workflow-panel-actions.ts index 912f7630..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,7 @@ import { RUN_PROCESS_ADVANCED_FORM } from 'views/run-process-panel/run-process-a import { getResource } from 'store/resources/resources'; import { ProjectResource } from 'models/project'; import { UserResource } from 'models/user'; -import { getUserUuid } from "common/getuser"; +import { getWorkflowInputs, parseWorkflowDefinition } from 'models/workflow'; export const WORKFLOW_PANEL_ID = "workflowPanel"; const UUID_PREFIX_PROPERTY_NAME = 'uuidPrefix'; @@ -62,9 +62,8 @@ export const openRunProcess = (workflowUuid: string, ownerUuid?: string, name?: let owner; if (ownerUuid) { // Must be writable. - const userUuid = getUserUuid(getState()); owner = getResource(ownerUuid)(getState().resources); - if (!owner || !userUuid || owner.writableBy.indexOf(userUuid) === -1) { + if (!owner || !owner.canWrite) { owner = undefined; } } @@ -74,6 +73,18 @@ export const openRunProcess = (workflowUuid: string, ownerUuid?: string, name?: dispatch(initialize(RUN_PROCESS_BASIC_FORM, { name, owner })); + const definition = parseWorkflowDefinition(workflow); + if (definition) { + const inputs = getWorkflowInputs(definition); + if (inputs) { + const values = inputs.reduce((values, input) => ({ + ...values, + [input.id]: input.default, + }), {}); + dispatch(initialize(RUN_PROCESS_INPUTS_FORM, values)); + } + } + if (inputObj) { dispatch(initialize(RUN_PROCESS_INPUTS_FORM, inputObj)); } @@ -90,6 +101,10 @@ export const getPublicGroupUuid = (state: RootState) => { const prefix = state.auth.localCluster; return `${prefix}-j7d0g-anonymouspublic`; }; +export const getAllUsersGroupUuid = (state: RootState) => { + const prefix = state.auth.localCluster; + return `${prefix}-j7d0g-fffffffffffffff`; +}; export const showWorkflowDetails = (uuid: string) => propertiesActions.SET_PROPERTY({ key: WORKFLOW_PANEL_DETAILS_UUID, value: uuid }); @@ -100,3 +115,11 @@ export const getWorkflowDetails = (state: RootState) => { const workflow = workflows.find(workflow => workflow.uuid === uuid); return workflow || undefined; }; + +export const deleteWorkflow = (workflowUuid: string, ownerUuid: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(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/validators/validators.tsx b/src/validators/validators.tsx index 6e72ef68..87a4c1f5 100644 --- a/src/validators/validators.tsx +++ b/src/validators/validators.tsx @@ -8,8 +8,8 @@ import { isRsaKey } from './is-rsa-key'; import { isRemoteHost } from "./is-remote-host"; import { validFilePath, validName, validNameAllowSlash } from "./valid-name"; -export const TAG_KEY_VALIDATION = [require, maxLength(255)]; -export const TAG_VALUE_VALIDATION = [require, maxLength(255)]; +export const TAG_KEY_VALIDATION = [maxLength(255)]; +export const TAG_VALUE_VALIDATION = [maxLength(255)]; export const PROJECT_NAME_VALIDATION = [require, validName, maxLength(255)]; export const PROJECT_NAME_VALIDATION_ALLOW_SLASH = [require, validNameAllowSlash, maxLength(255)]; 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 f493df33..3505faed 100644 --- a/src/views-components/advanced-tab-dialog/advanced-tab-dialog.tsx +++ b/src/views-components/advanced-tab-dialog/advanced-tab-dialog.tsx @@ -7,9 +7,11 @@ import { Dialog, DialogActions, Button, StyleRulesCallback, WithStyles, withStyl import { WithDialogProps } from 'store/dialog/with-dialog'; import { withDialog } from "store/dialog/with-dialog"; import { compose } from 'redux'; -import { ADVANCED_TAB_DIALOG } from "store/advanced-tab/advanced-tab"; +import { AdvancedTabDialogData, ADVANCED_TAB_DIALOG } from "store/advanced-tab/advanced-tab"; import { DefaultCodeSnippet } from "components/default-code-snippet/default-code-snippet"; import { MetadataTab } from 'views-components/advanced-tab-dialog/metadataTab'; +import { LinkResource } from "models/link"; +import { ListResults } from "services/common-service/common-service"; type CssRules = 'content' | 'codeSnippet' | 'spacing'; @@ -34,7 +36,7 @@ export const AdvancedTabDialog = compose( withDialog(ADVANCED_TAB_DIALOG), withStyles(styles), )( - class extends React.Component & WithStyles>{ + class extends React.Component & WithStyles>{ state = { value: 0, }; @@ -67,7 +69,7 @@ export const AdvancedTabDialog = compose( maxWidth="lg" onClose={closeDialog} onExit={() => this.setState({ value: 0 })} > - Advanced + API Details @@ -78,8 +80,8 @@ export const AdvancedTabDialog = compose( {value === 0 &&

{dialogContentExample(apiResponse, classes)}
} {value === 1 &&
- {metadata !== '' && metadata.items.length > 0 ? - + {metadata !== '' && (metadata as ListResults).items.length > 0 ? + ).items} uuid={uuid} /> : dialogContentHeader('(No metadata links found)')}
} {value === 2 && dialogContent(pythonHeader, pythonExample, classes)} @@ -110,8 +112,14 @@ const dialogContentHeader = (header: string) => {header} ; -const dialogContentExample = (example: string, classes: any) => - { + // Pass string to lines param or JSX to child props + const stringData = example && (example as string).length ? (example as string) : undefined; + return ; \ No newline at end of file + lines={stringData ? [stringData] : []} + > + {React.isValidElement(example) ? (example as JSX.Element) : undefined} + ; +} diff --git a/src/views-components/auto-logout/auto-logout.tsx b/src/views-components/auto-logout/auto-logout.tsx index a2d71d08..b4bef2b5 100644 --- a/src/views-components/auto-logout/auto-logout.tsx +++ b/src/views-components/auto-logout/auto-logout.tsx @@ -30,7 +30,7 @@ const mapStateToProps = (state: RootState, ownProps: any): AutoLogoutDataProps = }); const mapDispatchToProps = (dispatch: Dispatch): AutoLogoutActionProps => ({ - doLogout: () => dispatch(logout(true)), + doLogout: () => dispatch(logout(true, true)), doWarn: (message: string, duration: number) => dispatch(snackbarActions.OPEN_SNACKBAR({ message, hideDuration: duration, kind: SnackbarKind.WARNING })), diff --git a/src/views-components/baner/banner.test.tsx b/src/views-components/baner/banner.test.tsx new file mode 100644 index 00000000..1e820089 --- /dev/null +++ b/src/views-components/baner/banner.test.tsx @@ -0,0 +1,63 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import React from 'react'; +import { configure, shallow, mount } from "enzyme"; +import { BannerComponent } from './banner'; +import { Button } from "@material-ui/core"; +import Adapter from "enzyme-adapter-react-16"; +import servicesProvider from '../../common/service-provider'; + +configure({ adapter: new Adapter() }); + +jest.mock('../../common/service-provider', () => ({ + getServices: jest.fn(), +})); + +describe('', () => { + + let props; + + beforeEach(() => { + props = { + isOpen: false, + bannerUUID: undefined, + keepWebInlineServiceUrl: '', + openBanner: jest.fn(), + closeBanner: jest.fn(), + classes: {} as any, + } + }); + + it('renders without crashing', () => { + // when + const banner = shallow(); + + // then + expect(banner.find(Button)).toHaveLength(1); + }); + + it('calls collectionService', () => { + // given + props.isOpen = true; + props.bannerUUID = '123'; + const mocks = { + collectionService: { + files: jest.fn(() => ({ then: (callback) => callback([{ name: 'banner.html' }]) })), + getFileContents: jest.fn(() => ({ then: (callback) => callback('

Test

') })) + } + }; + (servicesProvider.getServices as any).mockImplementation(() => mocks); + + // when + const banner = mount(); + + // then + expect(servicesProvider.getServices).toHaveBeenCalled(); + expect(mocks.collectionService.files).toHaveBeenCalled(); + expect(mocks.collectionService.getFileContents).toHaveBeenCalled(); + expect(banner.html()).toContain('

Test

'); + }); +}); + diff --git a/src/views-components/baner/banner.tsx b/src/views-components/baner/banner.tsx new file mode 100644 index 00000000..ac5b8943 --- /dev/null +++ b/src/views-components/baner/banner.tsx @@ -0,0 +1,114 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import React, { useState, useCallback, useEffect } from "react"; +import { Dialog, DialogContent, DialogActions, Button, StyleRulesCallback, withStyles, WithStyles } from "@material-ui/core"; +import { connect } from "react-redux"; +import { RootState } from "store/store"; +import bannerActions from "store/banner/banner-action"; +import { ArvadosTheme } from "common/custom-theme"; +import servicesProvider from "common/service-provider"; +import { Dispatch } from "redux"; +import { sanitizeHTML } from "common/html-sanitize"; + +type CssRules = "dialogContent" | "dialogContentIframe"; + +const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ + dialogContent: { + minWidth: "550px", + minHeight: "500px", + display: "block", + }, + dialogContentIframe: { + minWidth: "550px", + minHeight: "500px", + }, +}); + +interface BannerProps { + isOpen: boolean; + bannerUUID?: string; + keepWebInlineServiceUrl: string; +} + +type BannerComponentProps = BannerProps & + WithStyles & { + openBanner: Function; + closeBanner: Function; + }; + +const mapStateToProps = (state: RootState): BannerProps => ({ + isOpen: state.banner.isOpen, + bannerUUID: state.auth.config.clusterConfig.Workbench.BannerUUID, + keepWebInlineServiceUrl: state.auth.config.keepWebInlineServiceUrl, +}); + +const mapDispatchToProps = (dispatch: Dispatch) => ({ + openBanner: () => dispatch(bannerActions.openBanner()), + closeBanner: () => dispatch(bannerActions.closeBanner()), +}); + +export const BANNER_LOCAL_STORAGE_KEY = "bannerFileData"; + +export const BannerComponent = (props: BannerComponentProps) => { + const { isOpen, openBanner, closeBanner, bannerUUID, keepWebInlineServiceUrl } = props; + const [bannerContents, setBannerContents] = useState(`

Loading ...

`); + + const onConfirm = useCallback(() => { + closeBanner(); + }, [closeBanner]); + + useEffect(() => { + if (!!bannerUUID && bannerUUID !== "") { + servicesProvider + .getServices() + .collectionService.files(bannerUUID) + .then(results => { + const bannerFileData = results.find(({ name }) => name === "banner.html"); + const result = localStorage.getItem(BANNER_LOCAL_STORAGE_KEY); + + if (result && result === JSON.stringify(bannerFileData) && !isOpen) { + return; + } + + if (bannerFileData) { + servicesProvider + .getServices() + .collectionService.getFileContents(bannerFileData) + .then(data => { + setBannerContents(data); + openBanner(); + localStorage.setItem(BANNER_LOCAL_STORAGE_KEY, JSON.stringify(bannerFileData)); + }); + } + }); + } + }, [bannerUUID, keepWebInlineServiceUrl, openBanner, isOpen]); + + return ( + +
+ +
+
+ + + +
+
+ ); +}; + +export const Banner = withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(BannerComponent)); diff --git a/src/views-components/breadcrumbs/breadcrumbs.ts b/src/views-components/breadcrumbs/breadcrumbs.ts index cb48b38f..0334097d 100644 --- a/src/views-components/breadcrumbs/breadcrumbs.ts +++ b/src/views-components/breadcrumbs/breadcrumbs.ts @@ -3,28 +3,29 @@ // SPDX-License-Identifier: AGPL-3.0 import { connect } from "react-redux"; -import { Breadcrumbs as BreadcrumbsComponent, BreadcrumbsProps } from 'components/breadcrumbs/breadcrumbs'; +import { Breadcrumb, Breadcrumbs as BreadcrumbsComponent, BreadcrumbsProps } from 'components/breadcrumbs/breadcrumbs'; import { RootState } from 'store/store'; import { Dispatch } from 'redux'; import { navigateTo } from 'store/navigation/navigation-action'; import { getProperty } from '../../store/properties/properties'; -import { ResourceBreadcrumb, BREADCRUMBS } from '../../store/breadcrumbs/breadcrumbs-actions'; +import { BREADCRUMBS } from '../../store/breadcrumbs/breadcrumbs-actions'; import { openSidePanelContextMenu } from 'store/context-menu/context-menu-actions'; -type BreadcrumbsDataProps = Pick; +type BreadcrumbsDataProps = Pick; type BreadcrumbsActionProps = Pick; -const mapStateToProps = () => ({ properties }: RootState): BreadcrumbsDataProps => ({ - items: getProperty(BREADCRUMBS)(properties) || [] +const mapStateToProps = () => ({ properties, resources }: RootState): BreadcrumbsDataProps => ({ + items: (getProperty(BREADCRUMBS)(properties) || []), + resources, }); const mapDispatchToProps = (dispatch: Dispatch): BreadcrumbsActionProps => ({ - onClick: ({ uuid }: ResourceBreadcrumb) => { + onClick: ({ uuid }: Breadcrumb) => { dispatch(navigateTo(uuid)); }, - onContextMenu: (event, breadcrumb: ResourceBreadcrumb) => { + onContextMenu: (event, breadcrumb: Breadcrumb) => { dispatch(openSidePanelContextMenu(event, breadcrumb.uuid)); } }); -export const Breadcrumbs = connect(mapStateToProps(), mapDispatchToProps)(BreadcrumbsComponent); \ No newline at end of file +export const Breadcrumbs = connect(mapStateToProps(), mapDispatchToProps)(BreadcrumbsComponent); 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 3394b211..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: "Advanced", - 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 9b0efac0..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: "Advanced", - 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 874a601b..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: "Advanced", - 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 b7215c70..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: "Advanced", - icon: AdvancedIcon, - execute: (dispatch, resource) => { - dispatch(openAdvancedTabDialog(resource.uuid)); - } -}, { - name: "Remove", - icon: RemoveIcon, - execute: (dispatch, { uuid }) => { - dispatch(openRemoveGroupMemberDialog(uuid)); - } -}]]; \ No newline at end of file +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 b2d30baf..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: "Advanced", - 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 0b70ba9b..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: "Advanced", - 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 a7a75901..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,125 +6,153 @@ import { ContextMenuActionSet } from "../context-menu-action-set"; import { ToggleFavoriteAction } from "../actions/favorite-action"; import { toggleFavorite } from "store/favorites/favorites-actions"; import { - RenameIcon, ShareIcon, MoveToIcon, CopyIcon, DetailsIcon, - RemoveIcon, ReRunProcessIcon, InputIcon, OutputIcon, - AdvancedIcon + RenameIcon, + ShareIcon, + MoveToIcon, + DetailsIcon, + RemoveIcon, + ReRunProcessIcon, + OutputIcon, + AdvancedIcon, + OpenIcon, + StopIcon, } from "components/icon/icon"; import { favoritePanelActions } from "store/favorite-panel/favorite-panel-action"; -import { openMoveProcessDialog } from 'store/processes/process-move-actions'; +import { openMoveProcessDialog } from "store/processes/process-move-actions"; import { openProcessUpdateDialog } from "store/processes/process-update-actions"; -import { openCopyProcessDialog } from 'store/processes/process-copy-actions'; +import { openCopyProcessDialog } from "store/processes/process-copy-actions"; import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions"; -import { openRemoveProcessDialog, reRunProcess } from "store/processes/processes-actions"; -import { toggleDetailsPanel } from 'store/details-panel/details-panel-action'; -import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions"; -import { openProcessInputDialog } from "store/processes/process-input-actions"; +import { openRemoveProcessDialog } from "store/processes/processes-actions"; +import { toggleDetailsPanel } from "store/details-panel/details-panel-action"; import { navigateToOutput } from "store/process-panel/process-panel-actions"; import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab"; import { TogglePublicFavoriteAction } from "../actions/public-favorite-action"; import { togglePublicFavorite } from "store/public-favorites/public-favorites-actions"; import { publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action"; +import { openInNewTabAction } from "store/open-in-new-tab/open-in-new-tab.actions"; +import { cancelRunningWorkflow } from "store/processes/processes-actions"; -export const readOnlyProcessResourceActionSet: ContextMenuActionSet = [[ - { - component: ToggleFavoriteAction, - execute: (dispatch, resource) => { - dispatch(toggleFavorite(resource)).then(() => { - dispatch(favoritePanelActions.REQUEST_ITEMS()); - }); - } - }, - { - icon: CopyIcon, - name: "Copy to project", - execute: (dispatch, resource) => { - dispatch(openCopyProcessDialog(resource)); - } - }, - { - icon: ReRunProcessIcon, - name: "Re-run process", - execute: (dispatch, resource) => { - if(resource.workflowUuid) { - dispatch(reRunProcess(resource.uuid, resource.workflowUuid)); - } else { - dispatch(snackbarActions.OPEN_SNACKBAR({ message: `You can't re-run this process`, hideDuration: 2000, kind: SnackbarKind.ERROR })); - } - } - }, - { - icon: InputIcon, - name: "Inputs", - execute: (dispatch, resource) => { - dispatch(openProcessInputDialog(resource.uuid)); - } - }, - { - 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: "Advanced", - 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 a079bf4f..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,112 +3,171 @@ // SPDX-License-Identifier: AGPL-3.0 import { ContextMenuActionSet } from "../context-menu-action-set"; -import { NewProjectIcon, RenameIcon, MoveToIcon, DetailsIcon, AdvancedIcon, OpenIcon, Link, FolderSharedIcon } from 'components/icon/icon'; +import { NewProjectIcon, RenameIcon, MoveToIcon, DetailsIcon, AdvancedIcon, OpenIcon, Link, FolderSharedIcon } from "components/icon/icon"; import { ToggleFavoriteAction } from "../actions/favorite-action"; import { toggleFavorite } from "store/favorites/favorites-actions"; import { favoritePanelActions } from "store/favorite-panel/favorite-panel-action"; -import { openMoveProjectDialog } from 'store/projects/project-move-actions'; -import { openProjectCreateDialog } from 'store/projects/project-create-actions'; -import { openProjectUpdateDialog } from 'store/projects/project-update-actions'; +import { openMoveProjectDialog } from "store/projects/project-move-actions"; +import { openProjectCreateDialog } from "store/projects/project-create-actions"; +import { openProjectUpdateDialog } from "store/projects/project-update-actions"; import { ToggleTrashAction } from "views-components/context-menu/actions/trash-action"; import { toggleProjectTrashed } from "store/trash/trash-actions"; -import { ShareIcon } from 'components/icon/icon'; +import { ShareIcon } from "components/icon/icon"; import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions"; import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab"; -import { toggleDetailsPanel } from 'store/details-panel/details-panel-action'; +import { toggleDetailsPanel } from "store/details-panel/details-panel-action"; import { copyToClipboardAction, openInNewTabAction } from "store/open-in-new-tab/open-in-new-tab.actions"; import { openWebDavS3InfoDialog } from "store/collections/collection-info-actions"; +import { ToggleLockAction } from "../actions/lock-action"; +import { freezeProject, unfreezeProject } from "store/projects/project-lock-actions"; -export const readOnlyProjectActionSet: ContextMenuActionSet = [[ - { - component: ToggleFavoriteAction, - name: 'ToggleFavoriteAction', - execute: (dispatch, resource) => { - dispatch(toggleFavorite(resource)).then(() => { - dispatch(favoritePanelActions.REQUEST_ITEMS()); - }); - } +export const toggleFavoriteAction = { + component: ToggleFavoriteAction, + name: "ToggleFavoriteAction", + execute: (dispatch, resources) => { + dispatch(toggleFavorite(resources[0])).then(() => { + dispatch(favoritePanelActions.REQUEST_ITEMS()); + }); }, - { - icon: OpenIcon, - name: "Open in new tab", - execute: (dispatch, resource) => { - dispatch(openInNewTabAction(resource)); - } +}; + +export const openInNewTabMenuAction = { + icon: OpenIcon, + name: "Open in new tab", + execute: (dispatch, resources) => { + dispatch(openInNewTabAction(resources[0])); }, - { - icon: Link, - name: "Copy to clipboard", - execute: (dispatch, resource) => { - dispatch(copyToClipboardAction(resource)); - } +}; + +export const copyToClipboardMenuAction = { + icon: Link, + name: "Copy to clipboard", + execute: (dispatch, resources) => { + dispatch(copyToClipboardAction(resources)); }, - { - icon: DetailsIcon, - name: "View details", - execute: dispatch => { - dispatch(toggleDetailsPanel()); - } +}; + +export const viewDetailsAction = { + icon: DetailsIcon, + name: "View details", + execute: dispatch => { + dispatch(toggleDetailsPanel()); }, - { - icon: AdvancedIcon, - name: "Advanced", - execute: (dispatch, resource) => { - dispatch(openAdvancedTabDialog(resource.uuid)); - } +}; + +export const advancedAction = { + icon: AdvancedIcon, + name: "API Details", + execute: (dispatch, resources) => { + dispatch(openAdvancedTabDialog(resources[0].uuid)); + }, +}; + +export const openWith3rdPartyClientAction = { + icon: FolderSharedIcon, + name: "Open with 3rd party client", + execute: (dispatch, resources) => { + dispatch(openWebDavS3InfoDialog(resources[0].uuid)); }, - { - icon: FolderSharedIcon, - name: "Open with 3rd party client", - execute: (dispatch, resource) => { - dispatch(openWebDavS3InfoDialog(resource.uuid)); +}; + +export const editProjectAction = { + icon: RenameIcon, + name: "Edit project", + execute: (dispatch, resources) => { + dispatch(openProjectUpdateDialog(resources[0])); + }, +}; + +export const shareAction = { + icon: ShareIcon, + name: "Share", + execute: (dispatch, resources) => { + dispatch(openSharingDialog(resources[0].uuid)); + }, +}; + +export const moveToAction = { + icon: MoveToIcon, + name: "Move to", + execute: (dispatch, resource) => { + dispatch(openMoveProjectDialog(resource[0])); + }, +}; + +export const toggleTrashAction = { + component: ToggleTrashAction, + name: "ToggleTrashAction", + execute: (dispatch, resources) => { + dispatch(toggleProjectTrashed(resources[0].uuid, resources[0].ownerUuid, resources[0].isTrashed!!, resources.length > 1)); + }, +}; + +export const freezeProjectAction = { + component: ToggleLockAction, + name: "ToggleLockAction", + execute: (dispatch, resources) => { + if (resources[0].isFrozen) { + dispatch(unfreezeProject(resources[0].uuid)); + } else { + dispatch(freezeProject(resources[0].uuid)); } }, -]]; +}; + +export const newProjectAction: any = { + icon: NewProjectIcon, + name: "New project", + execute: (dispatch, resource): void => { + dispatch(openProjectCreateDialog(resource.uuid)); + }, +}; + +export const readOnlyProjectActionSet: ContextMenuActionSet = [ + [toggleFavoriteAction, openInNewTabMenuAction, copyToClipboardMenuAction, viewDetailsAction, advancedAction, openWith3rdPartyClientAction], +]; export const filterGroupActionSet: ContextMenuActionSet = [ [ - ...readOnlyProjectActionSet.reduce((prev, next) => prev.concat(next), []), - { - icon: RenameIcon, - name: "Edit project", - execute: (dispatch, resource) => { - dispatch(openProjectUpdateDialog(resource)); - } - }, - { - icon: ShareIcon, - name: "Share", - execute: (dispatch, { uuid }) => { - dispatch(openSharingDialog(uuid)); - } - }, - { - icon: MoveToIcon, - name: "Move to", - execute: (dispatch, resource) => { - dispatch(openMoveProjectDialog(resource)); - } - }, - { - component: ToggleTrashAction, - name: 'ToggleTrashAction', - execute: (dispatch, resource) => { - dispatch(toggleProjectTrashed(resource.uuid, resource.ownerUuid, resource.isTrashed!!)); - } - }, - ] + toggleFavoriteAction, + openInNewTabMenuAction, + copyToClipboardMenuAction, + viewDetailsAction, + advancedAction, + openWith3rdPartyClientAction, + editProjectAction, + shareAction, + moveToAction, + toggleTrashAction, + ], +]; + +export const frozenActionSet: ContextMenuActionSet = [ + [ + shareAction, + toggleFavoriteAction, + openInNewTabMenuAction, + copyToClipboardMenuAction, + viewDetailsAction, + advancedAction, + openWith3rdPartyClientAction, + freezeProjectAction, + ], ]; export const projectActionSet: ContextMenuActionSet = [ [ - ...filterGroupActionSet.reduce((prev, next) => prev.concat(next), []), - { - icon: NewProjectIcon, - name: "New project", - execute: (dispatch, resource) => { - dispatch(openProjectCreateDialog(resource.uuid)); - } - }, - ] + 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 ebf827aa..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,30 +7,75 @@ import { TogglePublicFavoriteAction } from "views-components/context-menu/action import { togglePublicFavorite } from "store/public-favorites/public-favorites-actions"; import { publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action"; -import { projectActionSet, filterGroupActionSet } from "views-components/context-menu/action-sets/project-action-set"; +import { + shareAction, + toggleFavoriteAction, + openInNewTabMenuAction, + copyToClipboardMenuAction, + viewDetailsAction, + advancedAction, + openWith3rdPartyClientAction, + freezeProjectAction, + editProjectAction, + moveToAction, + toggleTrashAction, + newProjectAction, +} from "views-components/context-menu/action-sets/project-action-set"; -export const projectAdminActionSet: ContextMenuActionSet = [[ - ...projectActionSet.reduce((prev, next) => prev.concat(next), []), - { - component: TogglePublicFavoriteAction, - name: 'TogglePublicFavoriteAction', - execute: (dispatch, resource) => { - dispatch(togglePublicFavorite(resource)).then(() => { - dispatch(publicFavoritePanelActions.REQUEST_ITEMS()); - }); - } - } -]]; +export const togglePublicFavoriteAction = { + component: TogglePublicFavoriteAction, + name: "TogglePublicFavoriteAction", + execute: (dispatch, resources) => { + dispatch(togglePublicFavorite(resources[0])).then(() => { + dispatch(publicFavoritePanelActions.REQUEST_ITEMS()); + }); + }, +}; -export const filterGroupAdminActionSet: ContextMenuActionSet = [[ - ...filterGroupActionSet.reduce((prev, next) => prev.concat(next), []), - { - component: TogglePublicFavoriteAction, - name: 'TogglePublicFavoriteAction', - execute: (dispatch, resource) => { - dispatch(togglePublicFavorite(resource)).then(() => { - dispatch(publicFavoritePanelActions.REQUEST_ITEMS()); - }); - } - } -]]; +export const projectAdminActionSet: ContextMenuActionSet = [ + [ + toggleFavoriteAction, + openInNewTabMenuAction, + copyToClipboardMenuAction, + viewDetailsAction, + advancedAction, + openWith3rdPartyClientAction, + editProjectAction, + shareAction, + moveToAction, + toggleTrashAction, + newProjectAction, + freezeProjectAction, + togglePublicFavoriteAction, + ], +]; + +export const filterGroupAdminActionSet: ContextMenuActionSet = [ + [ + toggleFavoriteAction, + openInNewTabMenuAction, + copyToClipboardMenuAction, + viewDetailsAction, + advancedAction, + openWith3rdPartyClientAction, + editProjectAction, + shareAction, + moveToAction, + toggleTrashAction, + togglePublicFavoriteAction, + ], +]; + +export const frozenAdminActionSet: ContextMenuActionSet = [ + [ + shareAction, + togglePublicFavoriteAction, + toggleFavoriteAction, + openInNewTabMenuAction, + copyToClipboardMenuAction, + viewDetailsAction, + advancedAction, + openWith3rdPartyClientAction, + freezeProjectAction, + ], +]; 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 fdd9d288..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: "Advanced", - 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 e916a105..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: "Advanced", - 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 587a05bc..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: "Advanced", - 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 b3d893b4..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: "Advanced", - 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 c298e1ab..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: "Advanced", - 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 721a6a2f..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: "Advanced", - 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/actions/lock-action.tsx b/src/views-components/context-menu/actions/lock-action.tsx new file mode 100644 index 00000000..99eb756d --- /dev/null +++ b/src/views-components/context-menu/actions/lock-action.tsx @@ -0,0 +1,45 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import React from "react"; +import { ListItemIcon, ListItemText, ListItem } from "@material-ui/core"; +import { FreezeIcon, UnfreezeIcon } from "components/icon/icon"; +import { connect } from "react-redux"; +import { RootState } from "store/store"; +import { ProjectResource } from "models/project"; +import { withRouter, RouteComponentProps } from "react-router"; +import { resourceIsFrozen } from "common/frozen-resources"; + +const mapStateToProps = (state: RootState, props: { onClick: () => {} }) => ({ + isAdmin: !!state.auth.user?.isAdmin, + isLocked: !!(state.resources[state.contextMenu.resource!.uuid] as ProjectResource).frozenByUuid, + canManage: (state.resources[state.contextMenu.resource!.uuid] as ProjectResource).canManage, + canUnfreeze: !state.auth.remoteHostsConfig[state.auth.homeCluster]?.clusterConfig?.API?.UnfreezeProjectRequiresAdmin, + resource: state.contextMenu.resource, + resources: state.resources, + onClick: props.onClick +}); + +export const ToggleLockAction = withRouter(connect(mapStateToProps)((props: { + resource: any, + resources: any, + onClick: () => void, + state: RootState, isAdmin: boolean, isLocked: boolean, canManage: boolean, canUnfreeze: boolean, +} & RouteComponentProps) => + (props.canManage && !props.isLocked) || (props.isLocked && props.canManage && (props.canUnfreeze || props.isAdmin)) ? + resourceIsFrozen(props.resource, props.resources) ? null : + + + {props.isLocked + ? + : } + + + {props.isLocked + ? <>Unfreeze project + : <>Freeze project} + + : null)); 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 1c9eb99b..a953500b 100644 --- a/src/views-components/context-menu/context-menu-action-set.ts +++ b/src/views-components/context-menu/context-menu-action-set.ts @@ -7,7 +7,7 @@ import { ContextMenuItem } from "components/context-menu/context-menu"; import { ContextMenuResource } from "store/context-menu/context-menu-actions"; export interface ContextMenuAction extends ContextMenuItem { - execute(dispatch: Dispatch, resource: ContextMenuResource): void; + execute(dispatch: Dispatch, resources: ContextMenuResource[], state?: any): void; } export type ContextMenuActionSet = Array>; diff --git a/src/views-components/context-menu/context-menu.tsx b/src/views-components/context-menu/context-menu.tsx index a8e7fd02..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,66 +43,70 @@ const mapDispatchToProps = (dispatch: Dispatch): ActionProps => ({ onItemClick: (action: ContextMenuAction, resource?: ContextMenuResource) => { dispatch(contextMenuActions.CLOSE_CONTEXT_MENU()); if (resource) { - action.execute(dispatch, resource); + action.execute(dispatch, [resource]); } - } + }, }); const handleItemClick = memoize( - (resource: DataProps['resource'], onItemClick: ActionProps['onItemClick']): ContextMenuProps['onItemClick'] => + (resource: DataProps["resource"], onItemClick: ActionProps["onItemClick"]): ContextMenuProps["onItemClick"] => item => { - onItemClick(item, resource); + onItemClick(item, { ...resource, fromContextMenu: true } as ContextMenuResource); } ); const mergeProps = ({ resource, ...dataProps }: DataProps, actionProps: ActionProps): ContextMenuProps => ({ ...dataProps, ...actionProps, - onItemClick: handleItemClick(resource, actionProps.onItemClick) + onItemClick: handleItemClick(resource, actionProps.onItemClick), }); - export const ContextMenu = connect(mapStateToProps, mapDispatchToProps, mergeProps)(ContextMenuComponent); const menuActionSets = new Map(); export const addMenuActionSet = (name: string, itemSet: ContextMenuActionSet) => { - const sorted = itemSet.map(items => items.sort(sortByProperty('name'))); + const sorted = itemSet.map(items => items.sort(sortByProperty("name"))); menuActionSets.set(name, sorted); }; const emptyActionSet: ContextMenuActionSet = []; -const getMenuActionSet = (resource?: ContextMenuResource): ContextMenuActionSet => ( - resource ? menuActionSets.get(resource.menuKind) || emptyActionSet : emptyActionSet -); +const getMenuActionSet = (resource?: ContextMenuResource): ContextMenuActionSet => + resource ? menuActionSets.get(resource.menuKind) || emptyActionSet : emptyActionSet; export enum ContextMenuKind { API_CLIENT_AUTHORIZATION = "ApiClientAuthorization", ROOT_PROJECT = "RootProject", PROJECT = "Project", FILTER_GROUP = "FilterGroup", - READONLY_PROJECT = 'ReadOnlyProject', + READONLY_PROJECT = "ReadOnlyProject", + FROZEN_PROJECT = "FrozenProject", + FROZEN_PROJECT_ADMIN = "FrozenProjectAdmin", PROJECT_ADMIN = "ProjectAdmin", FILTER_GROUP_ADMIN = "FilterGroupAdmin", RESOURCE = "Resource", FAVORITE = "Favorite", TRASH = "Trash", COLLECTION_FILES = "CollectionFiles", + COLLECTION_FILES_MULTIPLE = "CollectionFilesMultiple", READONLY_COLLECTION_FILES = "ReadOnlyCollectionFiles", + READONLY_COLLECTION_FILES_MULTIPLE = "ReadOnlyCollectionFilesMultiple", + COLLECTION_FILES_NOT_SELECTED = "CollectionFilesNotSelected", COLLECTION_FILE_ITEM = "CollectionFileItem", COLLECTION_DIRECTORY_ITEM = "CollectionDirectoryItem", READONLY_COLLECTION_FILE_ITEM = "ReadOnlyCollectionFileItem", READONLY_COLLECTION_DIRECTORY_ITEM = "ReadOnlyCollectionDirectoryItem", - COLLECTION_FILES_NOT_SELECTED = "CollectionFilesNotSelected", - COLLECTION = 'Collection', - COLLECTION_ADMIN = 'CollectionAdmin', - READONLY_COLLECTION = 'ReadOnlyCollection', - OLD_VERSION_COLLECTION = 'OldVersionCollection', - TRASHED_COLLECTION = 'TrashedCollection', + COLLECTION = "Collection", + COLLECTION_ADMIN = "CollectionAdmin", + READONLY_COLLECTION = "ReadOnlyCollection", + OLD_VERSION_COLLECTION = "OldVersionCollection", + TRASHED_COLLECTION = "TrashedCollection", PROCESS = "Process", - PROCESS_ADMIN = 'ProcessAdmin', - PROCESS_RESOURCE = 'ProcessResource', - READONLY_PROCESS_RESOURCE = 'ReadOnlyProcessResource', + RUNNING_PROCESS_ADMIN = "RunningProcessAdmin", + PROCESS_ADMIN = "ProcessAdmin", + RUNNING_PROCESS_RESOURCE = "RunningProcessResource", + PROCESS_RESOURCE = "ProcessResource", + READONLY_PROCESS_RESOURCE = "ReadOnlyProcessResource", PROCESS_LOGS = "ProcessLogs", REPOSITORY = "Repository", SSH_KEY = "SshKey", @@ -111,5 +118,6 @@ export enum ContextMenuKind { PERMISSION_EDIT = "PermissionEdit", LINK = "Link", WORKFLOW = "Workflow", - SEARCH_RESULTS = "SearchResults" + READONLY_WORKFLOW = "ReadOnlyWorkflow", + SEARCH_RESULTS = "SearchResults", } diff --git a/src/views-components/data-explorer/data-explorer.tsx b/src/views-components/data-explorer/data-explorer.tsx index 06d97038..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,26 +22,29 @@ interface Props { extractKey?: (item: any) => React.Key; } -const mapStateToProps = (state: RootState, { id }: Props) => { - const progress = state.progressIndicator.find(p => p.id === id); - const dataExplorerState = getDataExplorer(state.dataExplorer, id); - const currentRoute = state.router.location ? state.router.location.pathname : ''; - const currentRefresh = localStorage.getItem(LAST_REFRESH_TIMESTAMP) || ''; - const currentItemUuid = currentRoute === '/workflows' ? state.properties.workflowPanelDetailsUuid : state.detailsPanel.resourceUuid; - +const mapStateToProps = ({ progressIndicator, dataExplorer, router, multiselect, detailsPanel, properties}: RootState, { id }: Props) => { + const progress = progressIndicator.find(p => p.id === id); + const dataExplorerState = getDataExplorer(dataExplorer, id); + const currentRoute = router.location ? router.location.pathname : ""; + const currentRefresh = localStorage.getItem(LAST_REFRESH_TIMESTAMP) || ""; + const isDetailsResourceChecked = multiselect.checkedList[detailsPanel.resourceUuid] + const currentItemUuid = currentRoute === "/workflows" ? properties.workflowPanelDetailsUuid : isDetailsResourceChecked ? detailsPanel.resourceUuid : multiselect.selectedUuid; + const isMSToolbarVisible = multiselect.isVisible; return { ...dataExplorerState, working: !!progress?.working, currentRefresh: currentRefresh, currentRoute: currentRoute, paperKey: currentRoute, - currentItemUuid + currentItemUuid, + isMSToolbarVisible, + checkedList: multiselect.checkedList, }; }; const mapDispatchToProps = () => { return (dispatch: Dispatch, { id, onRowClick, onRowDoubleClick, onContextMenu }: Props) => ({ - onSetColumns: (columns: DataColumns) => { + onSetColumns: (columns: DataColumns) => { dispatch(dataExplorerActions.SET_COLUMNS({ id, columns })); }, @@ -48,15 +52,15 @@ const mapDispatchToProps = () => { dispatch(dataExplorerActions.SET_EXPLORER_SEARCH_VALUE({ id, searchValue })); }, - onColumnToggle: (column: DataColumn) => { + onColumnToggle: (column: DataColumn) => { dispatch(dataExplorerActions.TOGGLE_COLUMN({ id, columnName: column.name })); }, - onSortToggle: (column: DataColumn) => { + onSortToggle: (column: DataColumn) => { dispatch(dataExplorerActions.TOGGLE_SORT({ id, columnName: column.name })); }, - onFiltersChange: (filters: DataTableFilters, column: DataColumn) => { + onFiltersChange: (filters: DataTableFilters, column: DataColumn) => { dispatch(dataExplorerActions.SET_FILTERS({ id, columnName: column.name, filters })); }, @@ -72,6 +76,14 @@ const mapDispatchToProps = () => { dispatch(dataExplorerActions.SET_PAGE({ id, page })); }, + toggleMSToolbar: (isVisible: boolean) => { + dispatch(toggleMSToolbar(isVisible)); + }, + + setCheckedListOnStore: (checkedList: TCheckedList) => { + dispatch(setCheckedListOnStore(checkedList)); + }, + onRowClick, onRowDoubleClick, @@ -81,4 +93,3 @@ const mapDispatchToProps = () => { }; export const DataExplorer = connect(mapStateToProps, mapDispatchToProps)(DataExplorerComponent); - diff --git a/src/views-components/data-explorer/renderers.test.tsx b/src/views-components/data-explorer/renderers.test.tsx index 5bc123df..ac8729aa 100644 --- a/src/views-components/data-explorer/renderers.test.tsx +++ b/src/views-components/data-explorer/renderers.test.tsx @@ -29,12 +29,10 @@ describe('renderers', () => { colors: { // Color values are arbitrary, but they should be // representative of the colors used in the UI. - blue500: 'rgb(0, 0, 255)', - green700: 'rgb(0, 255, 0)', - yellow700: 'rgb(255, 255, 0)', + green800: 'rgb(0, 255, 0)', red900: 'rgb(255, 0, 0)', orange: 'rgb(240, 173, 78)', - grey500: 'rgb(128, 128, 128)', + grey600: 'rgb(128, 128, 128)', } }, spacing: { @@ -49,42 +47,44 @@ describe('renderers', () => { }; [ - // CR Status ; Priority ; C Status ; Exit Code ; C RuntimeStatus ; Expected label ; Expected Color - [CR.COMMITTED, 1, C.RUNNING, null, {}, PS.RUNNING, props.theme.customs.colors.blue500], - [CR.COMMITTED, 1, C.RUNNING, null, {error: 'whoops'}, PS.FAILING, props.theme.customs.colors.orange], - [CR.COMMITTED, 1, C.RUNNING, null, {warning: 'watch out!'}, PS.WARNING, props.theme.customs.colors.yellow700], - [CR.FINAL, 1, C.CANCELLED, null, {}, PS.CANCELLED, props.theme.customs.colors.red900], - [CR.FINAL, 1, C.COMPLETE, 137, {}, PS.FAILED, props.theme.customs.colors.red900], - [CR.FINAL, 1, C.COMPLETE, 0, {}, PS.COMPLETED, props.theme.customs.colors.green700], - [CR.COMMITTED, 0, C.LOCKED, null, {}, PS.ONHOLD, props.theme.customs.colors.grey500], - [CR.COMMITTED, 0, C.QUEUED, null, {}, PS.ONHOLD, props.theme.customs.colors.grey500], - [CR.COMMITTED, 1, C.LOCKED, null, {}, PS.QUEUED, props.theme.customs.colors.grey500], - [CR.COMMITTED, 1, C.QUEUED, null, {}, PS.QUEUED, props.theme.customs.colors.grey500], - ].forEach(([crState, crPrio, cState, exitCode, rs, eLabel, eColor]) => { + // CR Status ; Priority ; C Status ; Exit Code ; C RuntimeStatus ; Expected label ; Expected bg color ; Expected fg color + [CR.COMMITTED, 1, C.RUNNING, null, {}, PS.RUNNING, props.theme.palette.common.white, props.theme.customs.colors.green800], + [CR.COMMITTED, 1, C.RUNNING, null, { error: 'whoops' }, PS.FAILING, props.theme.palette.common.white, props.theme.customs.colors.red900], + [CR.COMMITTED, 1, C.RUNNING, null, { warning: 'watch out!' }, PS.WARNING, props.theme.palette.common.white, props.theme.customs.colors.green800], + [CR.FINAL, 1, C.CANCELLED, null, {}, PS.CANCELLED, props.theme.customs.colors.red900, props.theme.palette.common.white], + [CR.FINAL, 1, C.COMPLETE, 137, {}, PS.FAILED, props.theme.customs.colors.red900, props.theme.palette.common.white], + [CR.FINAL, 1, C.COMPLETE, 0, {}, PS.COMPLETED, props.theme.customs.colors.green800, props.theme.palette.common.white], + [CR.COMMITTED, 0, C.LOCKED, null, {}, PS.ONHOLD, props.theme.customs.colors.grey600, props.theme.palette.common.white], + [CR.COMMITTED, 0, C.QUEUED, null, {}, PS.ONHOLD, props.theme.customs.colors.grey600, props.theme.palette.common.white], + [CR.COMMITTED, 1, C.LOCKED, null, {}, PS.QUEUED, props.theme.palette.common.white, props.theme.customs.colors.grey600], + [CR.COMMITTED, 1, C.QUEUED, null, {}, PS.QUEUED, props.theme.palette.common.white, props.theme.customs.colors.grey600], + ].forEach(([crState, crPrio, cState, exitCode, rs, eLabel, eColor, tColor]) => { it(`should render the state label '${eLabel}' and color '${eColor}' for CR state=${crState}, priority=${crPrio}, C state=${cState}, exitCode=${exitCode} and RuntimeStatus=${JSON.stringify(rs)}`, () => { const containerUuid = 'zzzzz-dz642-zzzzzzzzzzzzzzz'; - const store = mockStore({ resources: { - [props.uuid]: { - kind: ResourceKind.CONTAINER_REQUEST, - state: crState, - containerUuid: containerUuid, - priority: crPrio, - }, - [containerUuid]: { - kind: ResourceKind.CONTAINER, - state: cState, - runtimeStatus: rs, - exitCode: exitCode, - }, - }}); + const store = mockStore({ + resources: { + [props.uuid]: { + kind: ResourceKind.CONTAINER_REQUEST, + state: crState, + containerUuid: containerUuid, + priority: crPrio, + }, + [containerUuid]: { + kind: ResourceKind.CONTAINER, + state: cState, + runtimeStatus: rs, + exitCode: exitCode, + }, + } + }); const wrapper = mount( - - ); + + ); expect(wrapper.text()).toEqual(eLabel); expect(getComputedStyle(wrapper.getDOMNode()) - .getPropertyValue('color')).toEqual(props.theme.palette.common.white); + .getPropertyValue('color')).toEqual(tColor); expect(getComputedStyle(wrapper.getDOMNode()) .getPropertyValue('background-color')).toEqual(eColor); }); @@ -100,12 +100,14 @@ describe('renderers', () => { it('should render collection fileSizeTotal', () => { // given - const store = mockStore({ resources: { - [props.uuid]: { - kind: ResourceKind.COLLECTION, - fileSizeTotal: 100, + const store = mockStore({ + resources: { + [props.uuid]: { + kind: ResourceKind.COLLECTION, + fileSizeTotal: 100, + } } - }}); + }); // when const wrapper = mount( @@ -131,16 +133,20 @@ describe('renderers', () => { it('should render empty string for non collection resource', () => { // given - const store1 = mockStore({ resources: { - [props.uuid]: { - kind: ResourceKind.PROJECT, + const store1 = mockStore({ + resources: { + [props.uuid]: { + kind: ResourceKind.PROJECT, + } } - }}); - const store2 = mockStore({ resources: { - [props.uuid]: { - kind: ResourceKind.PROJECT, + }); + const store2 = mockStore({ + resources: { + [props.uuid]: { + kind: ResourceKind.PROJECT, + } } - }}); + }); // when const wrapper1 = mount( @@ -155,4 +161,4 @@ describe('renderers', () => { expect(wrapper2.text()).toContain(''); }); }); -}); \ No newline at end of file +}); diff --git a/src/views-components/data-explorer/renderers.tsx b/src/views-components/data-explorer/renderers.tsx index 7822bdc6..059aad43 100644 --- a/src/views-components/data-explorer/renderers.tsx +++ b/src/views-components/data-explorer/renderers.tsx @@ -2,19 +2,12 @@ // // SPDX-License-Identifier: AGPL-3.0 -import React from 'react'; -import { - Grid, - Typography, - withStyles, - Tooltip, - IconButton, - Checkbox, - Chip -} from '@material-ui/core'; -import { FavoriteStar, PublicFavoriteStar } from '../favorite-star/favorite-star'; -import { Resource, ResourceKind, TrashableResource } from 'models/resource'; +import React from "react"; +import { Grid, Typography, withStyles, Tooltip, IconButton, Checkbox, Chip } from "@material-ui/core"; +import { FavoriteStar, PublicFavoriteStar } from "../favorite-star/favorite-star"; +import { Resource, ResourceKind, TrashableResource } from "models/resource"; import { + FreezeIcon, ProjectIcon, FilterGroupIcon, CollectionIcon, @@ -28,67 +21,100 @@ import { ActiveIcon, SetupIcon, InactiveIcon, -} from 'components/icon/icon'; -import { formatDate, formatFileSize, formatTime } from 'common/formatters'; -import { resourceLabel } from 'common/labels'; -import { connect, DispatchProp } from 'react-redux'; -import { RootState } from 'store/store'; -import { getResource, filterResources } from 'store/resources/resources'; -import { GroupContentsResource } from 'services/groups-service/groups-service'; -import { getProcess, Process, getProcessStatus, getProcessStatusColor, getProcessRuntime } from 'store/processes/process'; -import { ArvadosTheme } from 'common/custom-theme'; -import { compose, Dispatch } from 'redux'; -import { WorkflowResource } from 'models/workflow'; -import { ResourceStatus as WorkflowStatus } from 'views/workflow-panel/workflow-panel-view'; -import { getUuidPrefix, openRunProcess } from 'store/workflow-panel/workflow-panel-actions'; -import { openSharingDialog } from 'store/sharing-dialog/sharing-dialog-actions'; -import { getUserFullname, getUserDisplayName, User, UserResource } from 'models/user'; -import { toggleIsAdmin } from 'store/users/users-actions'; -import { LinkClass, LinkResource } from 'models/link'; -import { navigateTo, navigateToGroupDetails, navigateToUserProfile } from 'store/navigation/navigation-action'; -import { withResourceData } from 'views-components/data-explorer/with-resources'; -import { CollectionResource } from 'models/collection'; -import { IllegalNamingWarning } from 'components/warning/warning'; -import { loadResource } from 'store/resources/resources-actions'; -import { BuiltinGroups, getBuiltinGroupUuid, GroupClass, GroupResource, isBuiltinGroup } from 'models/group'; -import { openRemoveGroupMemberDialog } from 'store/group-details-panel/group-details-panel-actions'; -import { setMemberIsHidden } from 'store/group-details-panel/group-details-panel-actions'; -import { formatPermissionLevel } from 'views-components/sharing-dialog/permission-select'; -import { PermissionLevel } from 'models/permission'; -import { openPermissionEditContextMenu } from 'store/context-menu/context-menu-actions'; -import { getUserUuid } from 'common/getuser'; -import { VirtualMachinesResource } from 'models/virtual-machines'; -import { CopyToClipboardSnackbar } from 'components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar'; +} from "components/icon/icon"; +import { formatDate, formatFileSize, formatTime } from "common/formatters"; +import { resourceLabel } from "common/labels"; +import { connect, DispatchProp } from "react-redux"; +import { RootState } from "store/store"; +import { getResource, filterResources } from "store/resources/resources"; +import { GroupContentsResource } from "services/groups-service/groups-service"; +import { getProcess, Process, getProcessStatus, getProcessStatusStyles, getProcessRuntime } from "store/processes/process"; +import { ArvadosTheme } from "common/custom-theme"; +import { compose, Dispatch } from "redux"; +import { WorkflowResource } from "models/workflow"; +import { ResourceStatus as WorkflowStatus } from "views/workflow-panel/workflow-panel-view"; +import { getUuidPrefix, openRunProcess } from "store/workflow-panel/workflow-panel-actions"; +import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions"; +import { getUserFullname, getUserDisplayName, User, UserResource } from "models/user"; +import { toggleIsAdmin } from "store/users/users-actions"; +import { LinkClass, LinkResource } from "models/link"; +import { navigateTo, navigateToGroupDetails, navigateToUserProfile } from "store/navigation/navigation-action"; +import { withResourceData } from "views-components/data-explorer/with-resources"; +import { CollectionResource } from "models/collection"; +import { IllegalNamingWarning } from "components/warning/warning"; +import { loadResource } from "store/resources/resources-actions"; +import { BuiltinGroups, getBuiltinGroupUuid, GroupClass, GroupResource, isBuiltinGroup } from "models/group"; +import { openRemoveGroupMemberDialog } from "store/group-details-panel/group-details-panel-actions"; +import { setMemberIsHidden } from "store/group-details-panel/group-details-panel-actions"; +import { formatPermissionLevel } from "views-components/sharing-dialog/permission-select"; +import { PermissionLevel } from "models/permission"; +import { openPermissionEditContextMenu } from "store/context-menu/context-menu-actions"; +import { VirtualMachinesResource } from "models/virtual-machines"; +import { CopyToClipboardSnackbar } from "components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar"; +import { ProjectResource } from "models/project"; +import { ProcessResource } from "models/process"; const renderName = (dispatch: Dispatch, item: GroupContentsResource) => { - - const navFunc = ("groupClass" in item && item.groupClass === GroupClass.ROLE ? navigateToGroupDetails : navigateTo); - return - - {renderIcon(item)} - - - dispatch(navFunc(item.uuid))}> - {item.kind === ResourceKind.PROJECT || item.kind === ResourceKind.COLLECTION - ? - : null} - {item.name} - - - - - - - + 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 && } + + - ; + ); }; -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 FrozenProject = (props: { item: ProjectResource }) => { + const [fullUsername, setFullusername] = React.useState(null); + const getFullName = React.useCallback(() => { + if (props.item.frozenByUuid) { + setFullusername(); + } + }, [props.item, setFullusername]); + + if (props.item.frozenByUuid) { + 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)); const renderIcon = (item: GroupContentsResource) => { switch (item.kind) { @@ -112,26 +138,39 @@ const renderIcon = (item: GroupContentsResource) => { }; const renderDate = (date?: string) => { - return {formatDate(date)}; + return ( + + {formatDate(date)} + + ); }; -const renderWorkflowName = (item: WorkflowResource) => - - - {renderIcon(item)} - +const renderWorkflowName = (item: WorkflowResource) => ( + + {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`; @@ -141,484 +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 && ) || "-"} + +); + +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 || '(none)'}; +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)) { @@ -628,248 +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 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 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", +} -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)); +const renderUuidLinkWithCopyIcon = (dispatch: Dispatch, item: ProcessResource, column: string) => { + const selectedColumnUuid = item[column]; + return ( + + + {selectedColumnUuid ? ( + dispatch(navigateTo(selectedColumnUuid))} + > + {selectedColumnUuid} + + ) : ( + "-" + )} + + {selectedColumnUuid && renderUuidCopyIcon({ uuid: selectedColumnUuid })} + + ); +}; -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 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 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 ResourceFileSize = connect((state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); -export const renderFileSize = (fileSize?: number) => - - {formatFileSize(fileSize)} - ; + if (resource && resource.kind !== ResourceKind.COLLECTION) { + return { fileSize: "" }; + } -export const ResourceFileSize = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); + return { fileSize: resource ? resource.fileSizeTotal : 0 }; +})((props: { fileSize?: number }) => renderFileSize(props.fileSize)); - if (resource && resource.kind !== ResourceKind.COLLECTION) { - return { fileSize: '' }; - } +const renderOwner = (owner: string) => {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)); + +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 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 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} - ; + {portableDataHash ? ( + <> + {portableDataHash} + + + ) : ( + "-" + )} + +); -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 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 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)); - -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 ?? "-"}; +}; + +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; + } + + return { uuid: props.uuid, userFullname }; +}); - return { uuid: props.uuid, userFullname }; - }); +const ownerFromResourceId = compose( + connect((state: RootState, props: { uuid: string }) => { + const childResource = getResource(props.uuid)(state.resources); + return { uuid: childResource ? (childResource as Resource).ownerUuid : "" }; + }), + userFromID +); + +const _resourceWithName = withStyles( + {}, + { withTheme: true } +)((props: { uuid: string; userFullname: string; dispatch: Dispatch; theme: ArvadosTheme }) => { + const { uuid, userFullname, dispatch, theme } = props; + if (userFullname === "") { + dispatch(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, userFullname: string, dispatch: Dispatch }) => { - const { uuid, userFullname, dispatch } = props; +export const UserNameFromID = compose(userFromID)((props: { uuid: string; displayAsText?: string; userFullname: string; dispatch: Dispatch }) => { + const { uuid, userFullname, dispatch } = props; - if (userFullname === '') { - dispatch(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 (userFullname === "") { + dispatch(loadResource(uuid, false)); + } + return {userFullname ? userFullname : uuid}; +}); - let resource: Resource | undefined = getResource(props.uuid)(state.resources); +export const ResponsiblePerson = compose( + connect((state: RootState, props: { uuid: string; parentRef: HTMLElement | null }) => { + let responsiblePersonName: string = ""; + let responsiblePersonUUID: string = ""; + let responsiblePersonProperty: string = ""; - while (resource && resource.kind !== ResourceKind.USER && responsiblePersonProperty) { - responsiblePersonUUID = (resource as CollectionResource).properties[responsiblePersonProperty]; - resource = getResource(responsiblePersonUUID)(state.resources); - } + if (state.auth.config.clusterConfig.Collections.ManagedProperties) { + let index = 0; + const keys = Object.keys(state.auth.config.clusterConfig.Collections.ManagedProperties); - if (resource && resource.kind === ResourceKind.USER) { - responsiblePersonName = getUserFullname(resource as UserResource) || (resource as GroupContentsResource).name; + while (!responsiblePersonProperty && keys[index]) { + const key = keys[index]; + if (state.auth.config.clusterConfig.Collections.ManagedProperties[key].Function === "original_owner") { + responsiblePersonProperty = key; } - - return { uuid: responsiblePersonUUID, responsiblePersonName, parentRef: props.parentRef }; - }), - withStyles({}, { withTheme: true })) - ((props: { uuid: string | null, responsiblePersonName: string, parentRef: HTMLElement | null, theme: ArvadosTheme }) => { - const { uuid, responsiblePersonName, parentRef, theme } = props; - - if (!uuid && parentRef) { - parentRef.style.display = 'none'; - return null; - } else if (parentRef) { - parentRef.style.display = 'block'; + index++; } + } - if (!responsiblePersonName) { - return - {uuid} - ; - } + 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); + } - return - {responsiblePersonName} ({uuid}) - ; - }); + if (resource && resource.kind === ResourceKind.USER) { + responsiblePersonName = getUserFullname(resource as UserResource) || (resource as GroupContentsResource).name; + } -const renderType = (type: string, subtype: string) => - - {resourceLabel(type, subtype)} - ; + return { uuid: responsiblePersonUUID, responsiblePersonName, parentRef: props.parentRef }; + }), + withStyles({}, { withTheme: true }) +)((props: { uuid: string | null; responsiblePersonName: string; parentRef: HTMLElement | null; theme: ArvadosTheme }) => { + const { uuid, responsiblePersonName, parentRef, theme } = props; + + if (!uuid && parentRef) { + parentRef.style.display = "none"; + return null; + } else if (parentRef) { + parentRef.style.display = "block"; + } -export const ResourceType = connect( - (state: RootState, props: { uuid: string }) => { - const resource = getResource(props.uuid)(state.resources); - return { type: resource ? resource.kind : '', subtype: resource && resource.kind === ResourceKind.GROUP ? resource.groupClass : '' }; - })((props: { type: string, subtype: string }) => renderType(props.type, props.subtype)); + if (!responsiblePersonName) { + return ( + + {uuid} + + ); + } + + return ( + + {responsiblePersonName} ({uuid}) + + ); +}); + +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 }) => { + 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} +)); + 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; @@ -881,31 +1103,33 @@ interface ContainerRunTimeState { export const ContainerRunTime = connect((state: RootState, props: { uuid: string }) => { return { process: getProcess(props.uuid)(state.resources) }; -})(class extends React.Component { - 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 renderRunTime(this.state.runtime); + render() { + return this.props.process ? renderRunTime(this.state.runtime) : -; + } } -}); +); diff --git a/src/views-components/details-panel/collection-details.tsx b/src/views-components/details-panel/collection-details.tsx index 5edfbc37..4431465b 100644 --- a/src/views-components/details-panel/collection-details.tsx +++ b/src/views-components/details-panel/collection-details.tsx @@ -8,7 +8,7 @@ import { CollectionResource } from 'models/collection'; import { DetailsData } from "./details-data"; import { CollectionDetailsAttributes } from 'views/collection-panel/collection-panel'; import { RootState } from 'store/store'; -import { filterResources, getResource } from 'store/resources/resources'; +import { filterResources, getResource, ResourcesState } from 'store/resources/resources'; import { connect } from 'react-redux'; import { Button, Grid, ListItem, StyleRulesCallback, Typography, withStyles, WithStyles } from '@material-ui/core'; import { formatDate, formatFileSize } from 'common/formatters'; @@ -17,6 +17,7 @@ import { Dispatch } from 'redux'; import { navigateTo } from 'store/navigation/navigation-action'; import { openContextMenu, resourceUuidToContextMenuKind } from 'store/context-menu/context-menu-actions'; import { openCollectionUpdateDialog } from 'store/collections/collection-update-actions'; +import { resourceIsFrozen } from 'common/frozen-resources'; export type CssRules = 'versionBrowserHeader' | 'versionBrowserItem' @@ -82,6 +83,7 @@ export class CollectionDetails extends DetailsData { } interface CollectionInfoDataProps { + resources: ResourcesState; currentCollection: CollectionResource | undefined; } @@ -91,6 +93,7 @@ interface CollectionInfoDispatchProps { const ciMapStateToProps = (state: RootState): CollectionInfoDataProps => { return { + resources: state.resources, currentCollection: getResource(state.detailsPanel.resourceUuid)(state.resources), }; }; @@ -110,10 +113,11 @@ type CollectionInfoProps = CollectionInfoDataProps & CollectionInfoDispatchProps const CollectionInfo = withStyles(styles)( connect(ciMapStateToProps, ciMapDispatchToProps)( - ({ currentCollection, editCollection, classes }: CollectionInfoProps) => + ({ currentCollection, resources, editCollection, classes }: CollectionInfoProps) => currentCollection !== undefined ?
- {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 60% rename from src/views-components/dialog-copy/dialog-partial-copy-to-collection.tsx rename to src/views-components/dialog-copy/dialog-collection-partial-copy-to-existing-collection.tsx index a79ed0bc..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 66% rename from src/views-components/dialog-copy/dialog-collection-partial-copy.tsx rename to src/views-components/dialog-copy/dialog-collection-partial-copy-to-new-collection.tsx index 7a3c5fdd..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,24 +8,24 @@ import { FormDialog } from 'components/form-dialog/form-dialog'; import { CollectionNameField, CollectionDescriptionField, CollectionProjectPickerField } from 'views-components/form-fields/collection-form-fields'; import { WithDialogProps } from 'store/dialog/with-dialog'; import { InjectedFormProps } from 'redux-form'; -import { CollectionPartialCopyFormData } from 'store/collections/collection-partial-copy-actions'; +import { CollectionPartialCopyToNewCollectionFormData } from 'store/collections/collection-partial-copy-actions'; import { PickerIdProp } from "store/tree-picker/picker-id"; -type DialogCollectionPartialCopyProps = WithDialogProps & 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 5605e6ca..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 new file mode 100644 index 00000000..a5d8f3a0 --- /dev/null +++ b/src/views-components/dialog-copy/dialog-process-rerun.tsx @@ -0,0 +1,27 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import React from 'react'; +import { memoize } from 'lodash/fp'; +import { InjectedFormProps, Field } from 'redux-form'; +import { WithDialogProps } from 'store/dialog/with-dialog'; +import { FormDialog } from 'components/form-dialog/form-dialog'; +import { ProjectTreePickerField } from 'views-components/projects-tree-picker/tree-picker-field'; +import { COPY_NAME_VALIDATION, COPY_FILE_VALIDATION } from 'validators/validators'; +import { TextField } from 'components/text-field/text-field'; +import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog'; +import { PickerIdProp } from 'store/tree-picker/picker-id'; + +type ProcessRerunFormDialogProps = WithDialogProps & InjectedFormProps; + +export const DialogProcessRerun = (props: ProcessRerunFormDialogProps & PickerIdProp) => ( + +); + +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 c8f33642..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 { DialogCopy } from "views-components/dialog-copy/dialog-copy"; +import { DialogProcessRerun } from 'views-components/dialog-copy/dialog-process-rerun'; import { copyProcess } from 'store/workbench/workbench-actions'; import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog'; -import { pickerId } from "store/tree-picker/picker-id"; +import { pickerId } from 'store/tree-picker/picker-id'; export const CopyProcessDialog = compose( withDialog(PROCESS_COPY_FORM_NAME), @@ -17,7 +17,7 @@ export const CopyProcessDialog = compose( form: PROCESS_COPY_FORM_NAME, onSubmit: (data, dispatch) => { dispatch(copyProcess(data)); - } + }, }), - pickerId(PROCESS_COPY_FORM_NAME), -)(DialogCopy); \ No newline at end of file + pickerId(PROCESS_COPY_FORM_NAME) +)(DialogProcessRerun); 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/file-uploader/file-uploader.tsx b/src/views-components/file-uploader/file-uploader.tsx index cde286c4..be592617 100644 --- a/src/views-components/file-uploader/file-uploader.tsx +++ b/src/views-components/file-uploader/file-uploader.tsx @@ -36,7 +36,7 @@ const mapDispatchToProps = (dispatch: Dispatch, { onDrop }: FileUploaderProps): export const FileUploader = connect(mapStateToProps, mapDispatchToProps)(FileUpload); export const FileUploaderField = (props: WrappedFieldProps & { label?: string }) => -
+ <> {props.label} -
; + ; 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/form-fields/search-bar-form-fields.tsx b/src/views-components/form-fields/search-bar-form-fields.tsx index 777fa824..47633a0b 100644 --- a/src/views-components/form-fields/search-bar-form-fields.tsx +++ b/src/views-components/form-fields/search-bar-form-fields.tsx @@ -3,20 +3,17 @@ // SPDX-License-Identifier: AGPL-3.0 import React from "react"; -import { Field, WrappedFieldProps, FieldArray } from 'redux-form'; +import { Field, FieldArray } from 'redux-form'; import { TextField, DateTextField } from "components/text-field/text-field"; import { CheckboxField } from 'components/checkbox-field/checkbox-field'; import { NativeSelectField } from 'components/select-field/select-field'; import { ResourceKind } from 'models/resource'; -import { HomeTreePicker } from 'views-components/projects-tree-picker/home-tree-picker'; -import { SEARCH_BAR_ADVANCED_FORM_PICKER_ID } from 'store/search-bar/search-bar-actions'; import { SearchBarAdvancedPropertiesView } from 'views-components/search-bar/search-bar-advanced-properties-view'; -import { TreeItem } from "components/tree/tree"; -import { ProjectsTreePickerItem } from "views-components/projects-tree-picker/generic-projects-tree-picker"; import { PropertyKeyField, } from 'views-components/resource-properties-form/property-key-field'; import { PropertyValueField } from 'views-components/resource-properties-form/property-value-field'; import { connect } from "react-redux"; import { RootState } from "store/store"; +import { ProjectInput, ProjectCommandInputParameter } from 'views/run-process-panel/inputs/project-input'; export const SearchBarTypeField = () => ({ - clusters: [{key: '', value: 'Any'}].concat( + clusters: [{ key: '', value: 'Any' }].concat( state.auth.sessions .filter(s => s.loggedIn) .map(s => ({ @@ -46,24 +43,15 @@ export const SearchBarClusterField = connect( }))((props: SearchBarClusterFieldProps) => + items={props.clusters} /> ); export const SearchBarProjectField = () => - ; - -const ProjectsPicker = (props: WrappedFieldProps) => -
- ) => { - props.input.onChange(id); - } - } /> -
; + export const SearchBarTrashField = () => { - 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.test.tsx b/src/views-components/main-app-bar/account-menu.test.tsx index f0316e34..1d7b77ac 100644 --- a/src/views-components/main-app-bar/account-menu.test.tsx +++ b/src/views-components/main-app-bar/account-menu.test.tsx @@ -43,7 +43,7 @@ describe('', () => { it('should dispatch a logout action when clicked', () => { wrapper.find('[data-cy="logout-menuitem"]').simulate('click'); expect(props.dispatch).toHaveBeenCalledWith({ - payload: {deleteLinkData: true}, + payload: {deleteLinkData: true, preservePath: false}, type: 'LOGOUT', }); }); diff --git a/src/views-components/main-app-bar/account-menu.tsx b/src/views-components/main-app-bar/account-menu.tsx index 7faf27c2..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[], @@ -95,9 +81,9 @@ export const AccountMenuComponent = {user.isActive && accountMenuItems} dispatch(authActions.LOGOUT({ deleteLinkData: true }))}> + onClick={() => 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 442b9034..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'; @@ -24,7 +25,7 @@ const styles: StyleRulesCallback = () => ({ color: 'inherit' }, toolbar: { - height: '56px' + height: '56px', } }); @@ -34,6 +35,7 @@ interface MainAppBarDataProps { children?: ReactNode; uuidPrefix: string; siteBanner: string; + sidePanelIsCollapsed: boolean; } export type MainAppBarProps = MainAppBarDataProps & WithStyles; @@ -46,10 +48,12 @@ export const MainAppBar = withStyles(styles)( {pluginConfig.appBarLeft || - ({props.uuidPrefix}) + ({props.uuidPrefix}) - {props.buildInfo} + + + {props.buildInfo} } +const mapStateToProps = (state: RootState): NotificationsMenuProps => ({ + isOpen: state.banner.isOpen, + bannerUUID: state.auth.config.clusterConfig.Workbench.BannerUUID, +}); + +const mapDispatchToProps = (dispatch: Dispatch) => ({ + openBanner: () => dispatch(bannerActions.openBanner()), +}); + +type NotificationsMenuProps = { + isOpen: boolean; + bannerUUID?: string; +}; + +type NotificationsMenuComponentProps = NotificationsMenuProps & { + openBanner: any; +}; + +export const NotificationsMenuComponent = (props: NotificationsMenuComponentProps) => { + const { isOpen, openBanner } = props; + const bannerResult = localStorage.getItem(BANNER_LOCAL_STORAGE_KEY); + const tooltipResult = localStorage.getItem(TOOLTIP_LOCAL_STORAGE_KEY); + const menuItems: any[] = []; + + if (!isOpen && bannerResult) { + menuItems.push( + + Restore Banner + + ); + } + + const toggleTooltips = useCallback(() => { + if (tooltipResult) { + localStorage.removeItem(TOOLTIP_LOCAL_STORAGE_KEY); + } else { + localStorage.setItem(TOOLTIP_LOCAL_STORAGE_KEY, "true"); + } + window.location.reload(); + }, [tooltipResult]); + + if (tooltipResult) { + menuItems.push( + + Enable tooltips + + ); + } else { + menuItems.push( + + Disable tooltips + + ); + } + + if (menuItems.length === 0) { + menuItems.push(You are up to date); + } + + return ( + color="primary" + > - } + + } id="account-menu" - title="Notifications"> - You are up to date - ; + title="Notifications" + > + {menuItems.map((item, i) => ( +
{item}
+ ))} + + ); +}; +export const NotificationsMenu = connect(mapStateToProps, mapDispatchToProps)(NotificationsMenuComponent); diff --git a/src/views-components/main-content-bar/main-content-bar.tsx b/src/views-components/main-content-bar/main-content-bar.tsx index 271d77c1..3f4de301 100644 --- a/src/views-components/main-content-bar/main-content-bar.tsx +++ b/src/views-components/main-content-bar/main-content-bar.tsx @@ -15,9 +15,15 @@ import RefreshButton from "components/refresh-button/refresh-button"; import { loadSidePanelTreeProjects } from "store/side-panel-tree/side-panel-tree-actions"; import { Dispatch } from "redux"; -type CssRules = "infoTooltip"; +type CssRules = 'mainBar' | 'breadcrumbContainer' | 'infoTooltip'; const styles: StyleRulesCallback = theme => ({ + mainBar: { + flexWrap: 'nowrap', + }, + breadcrumbContainer: { + overflow: 'hidden', + }, infoTooltip: { marginTop: '-10px', marginLeft: '10px', @@ -61,8 +67,8 @@ const mapDispatchToProps = () => (dispatch: Dispatch) => ({ export const MainContentBar = connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)( (props: MainContentBarProps & WithStyles & any) => - - + + 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/process-runtime-status/process-runtime-status.tsx b/src/views-components/process-runtime-status/process-runtime-status.tsx index 3b5fae36..4761e12f 100644 --- a/src/views-components/process-runtime-status/process-runtime-status.tsx +++ b/src/views-components/process-runtime-status/process-runtime-status.tsx @@ -55,7 +55,7 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ whiteSpace: 'pre-line', }, errorColor: { - color: theme.customs.colors.red900, + color: theme.customs.colors.grey700, }, error: { backgroundColor: theme.customs.colors.red100, @@ -65,7 +65,7 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ backgroundColor: theme.customs.colors.yellow100, }, warningColor: { - color: theme.customs.colors.yellow900, + color: theme.customs.colors.grey700, }, paperRoot: { minHeight: theme.spacing.unit * 6, 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 aa9fb60b..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 @@ -10,24 +10,20 @@ import { TreeItem, TreeItemStatus } from 'components/tree/tree'; import { ProjectResource } from "models/project"; import { treePickerActions } from "store/tree-picker/tree-picker-actions"; import { ListItemTextIcon } from "components/list-item-text-icon/list-item-text-icon"; -import { ProjectIcon, InputIcon, IconType, CollectionIcon } from 'components/icon/icon'; +import { ProjectIcon, FileInputIcon, IconType, CollectionIcon } from 'components/icon/icon'; import { loadProject, loadCollection } from 'store/tree-picker/tree-picker-actions'; -import { GroupContentsResource } from 'services/groups-service/groups-service'; -import { CollectionDirectory, CollectionFile, CollectionFileType } from 'models/collection-file'; +import { ProjectsTreePickerItem, ProjectsTreePickerRootItem } from 'store/tree-picker/tree-picker-middleware'; import { ResourceKind } from 'models/resource'; import { TreePickerProps, TreePicker } from "views-components/tree-picker/tree-picker"; -import { LinkResource } from "models/link"; +import { CollectionFileType } from 'models/collection-file'; -export interface ProjectsTreePickerRootItem { - id: string; - name: string; -} -export type ProjectsTreePickerItem = ProjectsTreePickerRootItem | GroupContentsResource | CollectionDirectory | CollectionFile | LinkResource; type PickedTreePickerProps = Pick, 'onContextMenu' | 'toggleItemActive' | 'toggleItemOpen' | 'toggleItemSelection'>; export interface ProjectsTreePickerDataProps { + cascadeSelection: boolean; includeCollections?: boolean; + includeDirectories?: boolean; includeFiles?: boolean; rootItemIcon: IconType; showSelection?: boolean; @@ -35,17 +31,17 @@ export interface ProjectsTreePickerDataProps { disableActivation?: string[]; options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }; loadRootItem: (item: TreeItem, 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) => { @@ -65,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 }) + ? 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); } @@ -104,7 +100,7 @@ const getProjectPickerIcon = ({ data }: TreeItem, rootIc } else if ('type' in data) { switch (data.type) { case CollectionFileType.FILE: - return InputIcon; + return FileInputIcon; default: return ProjectIcon; } @@ -113,11 +109,14 @@ const getProjectPickerIcon = ({ data }: TreeItem, 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 df5fa9c2..3f71a58e 100644 --- a/src/views-components/projects-tree-picker/home-tree-picker.tsx +++ b/src/views-components/projects-tree-picker/home-tree-picker.tsx @@ -6,12 +6,12 @@ import { connect } from 'react-redux'; import { ProjectsTreePicker, ProjectsTreePickerProps } from 'views-components/projects-tree-picker/generic-projects-tree-picker'; import { Dispatch } from 'redux'; import { loadUserProject } from 'store/tree-picker/tree-picker-actions'; -import { ProjectIcon } from 'components/icon/icon'; +import { ProjectsIcon } from 'components/icon/icon'; export const HomeTreePicker = connect(() => ({ - rootItemIcon: ProjectIcon, + rootItemIcon: ProjectsIcon, }), (dispatch: Dispatch): Pick => ({ - loadRootItem: (_, pickerId, includeCollections, includeFiles) => { - dispatch(loadUserProject(pickerId, includeCollections, includeFiles)); + loadRootItem: (_, pickerId, includeCollections, includeDirectories, includeFiles, options) => { + dispatch(loadUserProject(pickerId, includeCollections, includeDirectories, includeFiles, options)); }, -}))(ProjectsTreePicker); \ No newline at end of file +}))(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 ee8ce1d5..16f6cceb 100644 --- a/src/views-components/projects-tree-picker/projects-tree-picker.tsx +++ b/src/views-components/projects-tree-picker/projects-tree-picker.tsx @@ -3,18 +3,31 @@ // SPDX-License-Identifier: AGPL-3.0 import React from 'react'; -import { values, memoize, pipe } from 'lodash/fp'; +import { Dispatch } from 'redux'; +import { connect, DispatchProp } from 'react-redux'; +import { RootState } from 'store/store'; +import { values, pipe } from 'lodash/fp'; import { HomeTreePicker } from 'views-components/projects-tree-picker/home-tree-picker'; import { SharedTreePicker } from 'views-components/projects-tree-picker/shared-tree-picker'; import { FavoritesTreePicker } from 'views-components/projects-tree-picker/favorites-tree-picker'; -import { getProjectsTreePickerIds, SHARED_PROJECT_ID, FAVORITES_PROJECT_ID } from 'store/tree-picker/tree-picker-actions'; +import { SearchProjectsPicker } from 'views-components/projects-tree-picker/search-projects-picker'; +import { + getProjectsTreePickerIds, treePickerActions, treePickerSearchActions, initProjectsTreePicker, + SHARED_PROJECT_ID, FAVORITES_PROJECT_ID +} from 'store/tree-picker/tree-picker-actions'; import { TreeItem } from 'components/tree/tree'; -import { ProjectsTreePickerItem } from './generic-projects-tree-picker'; +import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware'; import { PublicFavoritesTreePicker } from './public-favorites-tree-picker'; +import { SearchInput } from 'components/search-input/search-input'; +import { withStyles, StyleRulesCallback, WithStyles } from '@material-ui/core'; +import { ArvadosTheme } from 'common/custom-theme'; -export interface ProjectsTreePickerProps { +export interface ToplevelPickerProps { + currentUuids?: string[]; pickerId: string; + cascadeSelection: boolean; includeCollections?: boolean; + includeDirectories?: boolean; includeFiles?: boolean; showSelection?: boolean; options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }; @@ -22,29 +35,155 @@ export interface ProjectsTreePickerProps { toggleItemSelection?: (event: React.MouseEvent, item: TreeItem, pickerId: string) => void; } -export const ProjectsTreePicker = ({ pickerId, ...props }: ProjectsTreePickerProps) => { - const { home, shared, favorites, publicFavorites } = getProjectsTreePickerIds(pickerId); - const relatedTreePickers = getRelatedTreePickers(pickerId); - const p = { +interface ProjectsTreePickerSearchProps { + projectSearch: string; + collectionFilter: string; +} + +interface ProjectsTreePickerActionProps { + onProjectSearch: (value: string) => void; + onCollectionFilter: (value: string) => void; +} + +const mapStateToProps = (state: RootState, props: ToplevelPickerProps): ProjectsTreePickerSearchProps => { + const { search } = getProjectsTreePickerIds(props.pickerId); + return { ...props, - relatedTreePickers, - disableActivation + projectSearch: state.treePickerSearch.projectSearchValues[search], + collectionFilter: state.treePickerSearch.collectionFilterValues[search], + }; +}; + +const mapDispatchToProps = (dispatch: Dispatch, props: ToplevelPickerProps): (ProjectsTreePickerActionProps & DispatchProp) => { + const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(props.pickerId); + const params = { + includeCollections: props.includeCollections, + includeDirectories: props.includeDirectories, + includeFiles: props.includeFiles, + options: props.options }; - return
-
- -
-
- -
-
- -
-
- -
-
; + dispatch(treePickerSearchActions.SET_TREE_PICKER_LOAD_PARAMS({ pickerId: home, params })); + dispatch(treePickerSearchActions.SET_TREE_PICKER_LOAD_PARAMS({ pickerId: shared, params })); + dispatch(treePickerSearchActions.SET_TREE_PICKER_LOAD_PARAMS({ pickerId: favorites, params })); + dispatch(treePickerSearchActions.SET_TREE_PICKER_LOAD_PARAMS({ pickerId: publicFavorites, params })); + dispatch(treePickerSearchActions.SET_TREE_PICKER_LOAD_PARAMS({ pickerId: search, params })); + + return { + onProjectSearch: (projectSearchValue: string) => dispatch(treePickerSearchActions.SET_TREE_PICKER_PROJECT_SEARCH({ pickerId: search, projectSearchValue })), + onCollectionFilter: (collectionFilterValue: string) => { + dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: home, collectionFilterValue })); + dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: shared, collectionFilterValue })); + dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: favorites, collectionFilterValue })); + dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: publicFavorites, collectionFilterValue })); + dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: search, collectionFilterValue })); + }, + dispatch + } }; -const getRelatedTreePickers = memoize(pipe(getProjectsTreePickerIds, values)); +type CssRules = 'pickerHeight' | 'searchFlex' | 'scrolledBox'; + +const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ + pickerHeight: { + height: "100%", + display: "flex", + flexDirection: "column", + }, + searchFlex: { + display: "flex", + justifyContent: "space-around", + paddingBottom: "1em" + }, + scrolledBox: { + overflow: "scroll" + } +}); + +type ProjectsTreePickerCombinedProps = ToplevelPickerProps & ProjectsTreePickerSearchProps & ProjectsTreePickerActionProps & DispatchProp & WithStyles; + +export const ProjectsTreePicker = connect(mapStateToProps, mapDispatchToProps)( + withStyles(styles)( + class FileInputComponent extends React.Component { + + componentDidMount() { + const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(this.props.pickerId); + + const preloadParams = this.props.currentUuids ? { + selectedItemUuids: this.props.currentUuids, + includeDirectories: !!this.props.includeDirectories, + includeFiles: !!this.props.includeFiles, + multi: !!this.props.showSelection, + } : undefined; + this.props.dispatch(initProjectsTreePicker(this.props.pickerId, preloadParams)); + + this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_PROJECT_SEARCH({ pickerId: search, projectSearchValue: "" })); + this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: search, collectionFilterValue: "" })); + this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: home, collectionFilterValue: "" })); + this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: shared, collectionFilterValue: "" })); + this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: favorites, collectionFilterValue: "" })); + this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: publicFavorites, collectionFilterValue: "" })); + } + + componentWillUnmount() { + const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(this.props.pickerId); + // Release all the state, we don't need it to hang around forever. + this.props.dispatch(treePickerActions.RESET_TREE_PICKER({ pickerId: search })); + this.props.dispatch(treePickerActions.RESET_TREE_PICKER({ pickerId: home })); + this.props.dispatch(treePickerActions.RESET_TREE_PICKER({ pickerId: shared })); + this.props.dispatch(treePickerActions.RESET_TREE_PICKER({ pickerId: favorites })); + this.props.dispatch(treePickerActions.RESET_TREE_PICKER({ pickerId: publicFavorites })); + } + + render() { + const pickerId = this.props.pickerId; + const onProjectSearch = this.props.onProjectSearch; + const onCollectionFilter = this.props.onCollectionFilter; + + const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId); + const relatedTreePickers = getRelatedTreePickers(pickerId); + const p = { + cascadeSelection: this.props.cascadeSelection, + includeCollections: this.props.includeCollections, + includeDirectories: this.props.includeDirectories, + includeFiles: this.props.includeFiles, + showSelection: this.props.showSelection, + options: this.props.options, + toggleItemActive: this.props.toggleItemActive, + toggleItemSelection: this.props.toggleItemSelection, + relatedTreePickers, + disableActivation, + }; + return
+ + + {this.props.includeCollections && + } + + +
+ {this.props.projectSearch ? +
+ +
+ : + <> +
+ +
+
+ +
+
+ +
+
+ +
+ } +
+
; + } + })); + +const getRelatedTreePickers = pipe(getProjectsTreePickerIds, values); const disableActivation = [SHARED_PROJECT_ID, FAVORITES_PROJECT_ID]; 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 d2037af4..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) => { - dispatch(loadPublicFavoritesProject({ pickerId, includeCollections, includeFiles })); + 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 new file mode 100644 index 00000000..2888050b --- /dev/null +++ b/src/views-components/projects-tree-picker/search-projects-picker.tsx @@ -0,0 +1,18 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { connect } from 'react-redux'; +import { ProjectsTreePicker, ProjectsTreePickerProps } from 'views-components/projects-tree-picker/generic-projects-tree-picker'; +import { Dispatch } from 'redux'; +import { SearchIcon } from 'components/icon/icon'; +import { loadProject } from 'store/tree-picker/tree-picker-actions'; +import { SEARCH_PROJECT_ID } from 'store/tree-picker/tree-picker-actions'; + +export const SearchProjectsPicker = connect(() => ({ + rootItemIcon: SearchIcon, +}), (dispatch: Dispatch): Pick => ({ + 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 d6a59bea..1914cd9d 100644 --- a/src/views-components/projects-tree-picker/shared-tree-picker.tsx +++ b/src/views-components/projects-tree-picker/shared-tree-picker.tsx @@ -7,11 +7,12 @@ import { ProjectsTreePicker, ProjectsTreePickerProps } from 'views-components/pr import { Dispatch } from 'redux'; import { ShareMeIcon } from 'components/icon/icon'; import { loadProject } from 'store/tree-picker/tree-picker-actions'; +import { SHARED_PROJECT_ID } from 'store/tree-picker/tree-picker-actions'; export const SharedTreePicker = connect(() => ({ rootItemIcon: ShareMeIcon, }), (dispatch: Dispatch): Pick => ({ - loadRootItem: (_, pickerId, includeCollections, includeFiles) => { - dispatch(loadProject({ id: 'Shared with me', pickerId, includeCollections, includeFiles, loadShared: true })); + loadRootItem: (_, pickerId, includeCollections, includeDirectories, includeFiles, options) => { + dispatch(loadProject({ id: SHARED_PROJECT_ID, pickerId, includeCollections, includeDirectories, includeFiles, loadShared: true, options })); }, -}))(ProjectsTreePicker); \ No newline at end of file +}))(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 e5fecf97..75cf40c6 100644 --- a/src/views-components/projects-tree-picker/tree-picker-field.tsx +++ b/src/views-components/projects-tree-picker/tree-picker-field.tsx @@ -7,19 +7,25 @@ import { Typography } from "@material-ui/core"; import { TreeItem } from "components/tree/tree"; import { WrappedFieldProps } from 'redux-form'; import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker'; -import { ProjectsTreePickerItem } from 'views-components/projects-tree-picker/generic-projects-tree-picker'; +import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware'; import { PickerIdProp } from 'store/tree-picker/picker-id'; +import { FileOperationLocation, getFileOperationLocation } from "store/tree-picker/tree-picker-actions"; +import { connect } from "react-redux"; +import { Dispatch } from "redux"; export const ProjectTreePickerField = (props: WrappedFieldProps & PickerIdProp) => -
- - {props.meta.dirty && props.meta.error && - - {props.meta.error} - } +
+
+ + {props.meta.dirty && props.meta.error && + + {props.meta.error} + } +
; const handleChange = (props: WrappedFieldProps) => @@ -27,14 +33,56 @@ const handleChange = (props: WrappedFieldProps) => props.input.onChange(id); export const CollectionTreePickerField = (props: WrappedFieldProps & PickerIdProp) => -
- - {props.meta.dirty && props.meta.error && - - {props.meta.error} - } -
; \ No newline at end of file +
+
+ + {props.meta.dirty && props.meta.error && + + {props.meta.error} + } +
+
; + +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/resource-properties-form/property-value-field.tsx b/src/views-components/resource-properties-form/property-value-field.tsx index b8e525bf..8941d441 100644 --- a/src/views-components/resource-properties-form/property-value-field.tsx +++ b/src/views-components/resource-properties-form/property-value-field.tsx @@ -89,7 +89,7 @@ const getValidation = (props: PropertyValueFieldProps) => const matchTagValues = ({ vocabulary, propertyKeyId }: PropertyValueFieldProps) => (value: string) => - getTagValues(propertyKeyId, vocabulary).find(v => v.label === value) + getTagValues(propertyKeyId, vocabulary).find(v => !value || v.label === value) ? undefined : 'Incorrect value'; diff --git a/src/views-components/resource-properties-form/resource-properties-form.tsx b/src/views-components/resource-properties-form/resource-properties-form.tsx index 25d0f2bb..01473129 100644 --- a/src/views-components/resource-properties-form/resource-properties-form.tsx +++ b/src/views-components/resource-properties-form/resource-properties-form.tsx @@ -3,13 +3,29 @@ // SPDX-License-Identifier: AGPL-3.0 import React from 'react'; -import { InjectedFormProps } from 'redux-form'; +import { RootState } from 'store/store'; +import { connect } from 'react-redux'; +import { formValueSelector, InjectedFormProps } from 'redux-form'; import { Grid, withStyles, WithStyles } from '@material-ui/core'; import { PropertyKeyField, PROPERTY_KEY_FIELD_NAME, PROPERTY_KEY_FIELD_ID } from './property-key-field'; import { PropertyValueField, PROPERTY_VALUE_FIELD_NAME, PROPERTY_VALUE_FIELD_ID } from './property-value-field'; import { ProgressButton } from 'components/progress-button/progress-button'; import { GridClassKey } from '@material-ui/core/Grid'; +const AddButton = withStyles(theme => ({ + root: { marginTop: theme.spacing.unit } +}))(ProgressButton); + +const mapStateToProps = (state: RootState) => { + return { + applySelector: (selector) => selector(state, 'key', 'value', 'keyID', 'valueID') + } +} + +interface ApplySelector { + applySelector: (selector) => any; +} + export interface ResourcePropertiesFormData { uuid: string; [PROPERTY_KEY_FIELD_NAME]: string; @@ -19,10 +35,11 @@ export interface ResourcePropertiesFormData { clearPropertyKeyOnSelect?: boolean; } -export type ResourcePropertiesFormProps = {uuid: string; clearPropertyKeyOnSelect?: boolean } & InjectedFormProps & WithStyles; +type ResourcePropertiesFormProps = {uuid: string; clearPropertyKeyOnSelect?: boolean } & InjectedFormProps & WithStyles & ApplySelector; -export const ResourcePropertiesForm = ({ handleSubmit, change, submitting, invalid, classes, uuid, clearPropertyKeyOnSelect }: ResourcePropertiesFormProps ) => { +export const ResourcePropertiesForm = connect(mapStateToProps)(({ handleSubmit, change, submitting, invalid, classes, uuid, clearPropertyKeyOnSelect, applySelector, ...props }: ResourcePropertiesFormProps ) => { change('uuid', uuid); // Sets the uuid field to the uuid of the resource. + const propertyValue = applySelector(formValueSelector(props.form)); return
@@ -32,19 +49,16 @@ export const ResourcePropertiesForm = ({ handleSubmit, change, submitting, inval - + -
}; - -export const Button = withStyles(theme => ({ - root: { marginTop: theme.spacing.unit } -}))(ProgressButton); + } +); \ No newline at end of file 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/search-bar/search-bar.tsx b/src/views-components/search-bar/search-bar.tsx index 327644ed..6a4d2a62 100644 --- a/src/views-components/search-bar/search-bar.tsx +++ b/src/views-components/search-bar/search-bar.tsx @@ -38,7 +38,7 @@ const mapStateToProps = ({ searchBar, form }: RootState): SearchBarDataProps => }; const mapDispatchToProps = (dispatch: Dispatch): SearchBarActionProps => ({ - onSearch: (valueSearch: string) => dispatch(searchData(valueSearch)), + onSearch: (valueSearch: string) => dispatch(searchData(valueSearch, true)), onChange: (event: React.ChangeEvent) => dispatch(changeData(event.target.value)), onSetView: (currentView: string) => dispatch(goToView(currentView)), onSubmit: (event: React.FormEvent) => dispatch(submitData(event)), diff --git a/src/views-components/sharing-dialog/participant-select.tsx b/src/views-components/sharing-dialog/participant-select.tsx index a826fcd5..058d7234 100644 --- a/src/views-components/sharing-dialog/participant-select.tsx +++ b/src/views-components/sharing-dialog/participant-select.tsx @@ -11,12 +11,13 @@ import { debounce } from 'debounce'; import { ListItemText, Typography } from '@material-ui/core'; import { noop } from 'lodash/fp'; import { GroupClass, GroupResource } from 'models/group'; -import { getUserDisplayName, UserResource } from 'models/user'; +import { getUserDetailsString, getUserDisplayName, UserResource } from 'models/user'; import { Resource, ResourceKind } from 'models/resource'; import { ListResults } from 'services/common-service/common-service'; export interface Participant { name: string; + tooltip: string; uuid: string; } @@ -43,10 +44,21 @@ interface ParticipantSelectState { suggestions: ParticipantResource[]; } -const getDisplayName = (item: GroupResource | UserResource) => { +const getDisplayName = (item: GroupResource | UserResource, detailed: boolean) => { switch (item.kind) { case ResourceKind.USER: - return getUserDisplayName(item, true, true); + return getUserDisplayName(item, detailed, detailed); + case ResourceKind.GROUP: + return item.name + `(${`(${(item as Resource).uuid})`})`; + default: + return (item as Resource).uuid; + } +}; + +const getDisplayTooltip = (item: GroupResource | UserResource) => { + switch (item.kind) { + case ResourceKind.USER: + return getUserDetailsString(item); case ResourceKind.GROUP: return item.name + `(${`(${(item as Resource).uuid})`})`; default: @@ -62,7 +74,7 @@ export const ParticipantSelect = connect()( }; render() { - const { label = 'Share' } = this.props; + const { label = 'Add people and groups' } = this.props; return ( + disabled={this.props.disabled} /> ); } + onBlur = (e) => { + if (this.props.onBlur) { + this.props.onBlur(e); + } + setTimeout(() => this.setState({ value: '', suggestions: [] }), 200); + } + renderChipValue(chipValue: Participant) { const { name, uuid } = chipValue; return name || uuid; } + renderChipTooltip(item: Participant) { + return item.tooltip; + } + renderSuggestion(item: ParticipantResource) { return ( - {getDisplayName(item)} + {getDisplayName(item, true)} ); } @@ -107,6 +131,7 @@ export const ParticipantSelect = connect()( this.setState({ value: '', suggestions: [] }); onCreate({ name: '', + tooltip: '', uuid: this.state.value, }); } @@ -117,7 +142,8 @@ export const ParticipantSelect = connect()( const { onSelect = noop } = this.props; this.setState({ value: '', suggestions: [] }); onSelect({ - name: getDisplayName(selection), + name: getDisplayName(selection, false), + tooltip: getDisplayTooltip(selection), uuid, }); } 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 && - - - - } - - - - dialogContentStyles: StyleRulesCallback = ({ spacing }) => ({ root: { display: 'flex', flexDirection: 'column', - height: `${spacing.unit * 8}vh`, + }, + pickerWrapper: { + display: 'flex', + flexDirection: 'column', + flexBasis: `${spacing.unit * 8}vh`, + flexShrink: 1, + minHeight: 0, }, tree: { flex: 3, @@ -253,31 +243,52 @@ const FileArrayInputComponent = connect(mapStateToProps)( }, }) - dialogContent = withStyles(this.dialogContentStyles)( + + dialog = withStyles(this.dialogContentStyles)( ({ classes }: WithStyles) => -
-
- -
- -
- Selected files ({this.state.files.length}): - file.name} /> -
-
+ + Choose files + +
+
+ +
+ +
+ Selected files ({this.state.files.length}): + file.name} /> +
+
+ +
+ + + + +
); }); -type DialogContentCssRules = 'root' | 'tree' | 'divider' | 'chips'; +type DialogContentCssRules = 'root' | 'pickerWrapper' | 'tree' | 'divider' | 'chips'; diff --git a/src/views/run-process-panel/inputs/file-input.tsx b/src/views/run-process-panel/inputs/file-input.tsx index c2e17c95..6970e2a5 100644 --- a/src/views/run-process-panel/inputs/file-input.tsx +++ b/src/views/run-process-panel/inputs/file-input.tsx @@ -12,19 +12,22 @@ import { } from 'models/workflow'; import { Field } from 'redux-form'; import { ERROR_MESSAGE } from 'validators/require'; -import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@material-ui/core'; +import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button, StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core'; import { GenericInputProps, GenericInput } from './generic-input'; import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker'; import { connect, DispatchProp } from 'react-redux'; import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions'; import { TreeItem } from 'components/tree/tree'; -import { ProjectsTreePickerItem } from 'views-components/projects-tree-picker/generic-projects-tree-picker'; +import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware'; import { CollectionFile, CollectionFileType } from 'models/collection-file'; export interface FileInputProps { input: FileCommandInputParameter; options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }; } + +type DialogContentCssRules = 'root' | 'pickerWrapper'; + export const FileInput = ({ input, options }: FileInputProps) => {this.renderInput()} - {this.renderDialog()} + ; } openDialog = () => { + this.componentDidMount(); this.setState({ open: true }); } @@ -112,33 +116,47 @@ const FileInputComponent = connect()( {...this.props} />; } - renderDialog() { - return - Choose a file - - - - - - - - ; - } - + dialogContentStyles: StyleRulesCallback = ({ spacing }) => ({ + root: { + display: 'flex', + flexDirection: 'column', + }, + pickerWrapper: { + flexBasis: `${spacing.unit * 8}vh`, + flexShrink: 1, + minHeight: 0, + }, + }); + + dialog = withStyles(this.dialogContentStyles)( + ({ classes }: WithStyles) => + + Choose a file + +
+ +
+
+ + + + +
+ ); }); - - diff --git a/src/views/run-process-panel/inputs/generic-input.tsx b/src/views/run-process-panel/inputs/generic-input.tsx index 8ca4ec89..963998f1 100644 --- a/src/views/run-process-panel/inputs/generic-input.tsx +++ b/src/views/run-process-panel/inputs/generic-input.tsx @@ -13,12 +13,13 @@ export type GenericInputProps = WrappedFieldProps & { type GenericInputContainerProps = GenericInputProps & { component: React.ComponentType; + required?: boolean; }; export const GenericInput = ({ component: Component, ...props }: GenericInputContainerProps) => { return {getInputLabel(props.commandInput)} @@ -31,4 +32,4 @@ export const GenericInput = ({ component: Component, ...props }: GenericInputCon } ; -}; \ No newline at end of file +}; diff --git a/src/views/run-process-panel/inputs/project-input.tsx b/src/views/run-process-panel/inputs/project-input.tsx index 7b45a6d1..438bbe8e 100644 --- a/src/views/run-process-panel/inputs/project-input.tsx +++ b/src/views/run-process-panel/inputs/project-input.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { connect, DispatchProp } from 'react-redux'; import { Field } from 'redux-form'; -import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@material-ui/core'; +import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button, withStyles, WithStyles, StyleRulesCallback } from '@material-ui/core'; import { GenericCommandInputParameter } from 'models/workflow'; @@ -13,7 +13,7 @@ import { GenericInput, GenericInputProps } from './generic-input'; import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker'; import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions'; import { TreeItem } from 'components/tree/tree'; -import { ProjectsTreePickerItem } from 'views-components/projects-tree-picker/generic-projects-tree-picker'; +import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware'; import { ProjectResource } from 'models/project'; import { ResourceKind } from 'models/resource'; import { RootState } from 'store/store'; @@ -24,18 +24,23 @@ export type ProjectCommandInputParameter = GenericCommandInputParameter (value === undefined); export interface ProjectInputProps { + required: boolean; input: ProjectCommandInputParameter; options?: { showOnlyOwned: boolean, showOnlyWritable: boolean }; } -export const ProjectInput = ({ input, options }: ProjectInputProps) => + +type DialogContentCssRules = 'root' | 'pickerWrapper'; + +export const ProjectInput = ({ required, input, options }: ProjectInputProps) => ; const format = (value?: ProjectResource) => value ? value.name : ''; @@ -54,6 +59,7 @@ const mapStateToProps = (state: RootState) => ({ userUuid: getUserUuid(state) }) export const ProjectInputComponent = connect(mapStateToProps)( class ProjectInputComponent extends React.Component { state: ProjectInputComponentState = { open: false, @@ -67,11 +73,12 @@ export const ProjectInputComponent = connect(mapStateToProps)( render() { return <> {this.renderInput()} - {this.renderDialog()} + ; } openDialog = () => { + this.componentDidMount(); this.setState({ open: true }); } @@ -92,7 +99,7 @@ export const ProjectInputComponent = connect(mapStateToProps)( } } - invalid = () => (!this.state.project || this.state.project.writableBy.indexOf(this.props.userUuid) === -1); + invalid = () => (!this.state.project || !this.state.project.canWrite); renderInput() { return ; } - renderDialog() { - return - Choose a project - - - - - - - - ; - } + dialogContentStyles: StyleRulesCallback = ({ spacing }) => ({ + root: { + display: 'flex', + flexDirection: 'column', + }, + pickerWrapper: { + flexBasis: `${spacing.unit * 8}vh`, + flexShrink: 1, + minHeight: 0, + }, + }); + + dialog = withStyles(this.dialogContentStyles)( + ({ classes }: WithStyles) => + this.state.open ? + Choose a project + +
+ +
+
+ + + + +
: null + ); }); diff --git a/src/views/run-process-panel/run-process-basic-form.tsx b/src/views/run-process-panel/run-process-basic-form.tsx index 32a126a4..a6f7a706 100644 --- a/src/views/run-process-panel/run-process-basic-form.tsx +++ b/src/views/run-process-panel/run-process-basic-form.tsx @@ -40,7 +40,7 @@ export const RunProcessBasicForm = label="Optional description of this workflow run" /> - { 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-view.tsx b/src/views/search-results-panel/search-results-panel-view.tsx index e281035c..d9b9002e 100644 --- a/src/views/search-results-panel/search-results-panel-view.tsx +++ b/src/views/search-results-panel/search-results-panel-view.tsx @@ -29,6 +29,7 @@ import { StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core'; import { ArvadosTheme } from 'common/custom-theme'; import { getSearchSessions } from 'store/search-bar/search-bar-actions'; import { camelCase } from 'lodash'; +import { GroupContentsResource } from 'services/groups-service/groups-service'; export enum SearchResultsPanelColumnNames { CLUSTER = "Cluster", @@ -56,7 +57,7 @@ export interface WorkflowPanelFilter extends DataTableFilterItem { type: ResourceKind | ContainerRequestState; } -export const searchResultsPanelColumns: DataColumns = [ +export const searchResultsPanelColumns: DataColumns = [ { name: SearchResultsPanelColumnNames.CLUSTER, selected: true, @@ -68,7 +69,7 @@ export const searchResultsPanelColumns: DataColumns = [ name: SearchResultsPanelColumnNames.NAME, selected: true, configurable: true, - sortDirection: SortDirection.NONE, + sort: {direction: SortDirection.NONE, field: "name"}, filters: createTree(), render: (uuid: string) => }, @@ -104,7 +105,7 @@ export const searchResultsPanelColumns: DataColumns = [ name: SearchResultsPanelColumnNames.LAST_MODIFIED, selected: true, configurable: true, - sortDirection: SortDirection.DESC, + sort: {direction: SortDirection.DESC, field: "modifiedAt"}, filters: createTree(), render: uuid => } 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 d4ccae9c..65c723f6 100644 --- a/src/views/subprocess-panel/subprocess-panel-root.tsx +++ b/src/views/subprocess-panel/subprocess-panel-root.tsx @@ -17,6 +17,24 @@ import { createTree } from 'models/tree'; import { getInitialProcessStatusFilters } from 'store/resource-type-filters/resource-type-filters'; import { ResourcesState } from 'store/resources/resources'; import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view'; +import { StyleRulesCallback, Typography, WithStyles, withStyles } from '@material-ui/core'; +import { ArvadosTheme } from 'common/custom-theme'; +import { ProcessResource } from 'models/process'; +import { SubprocessProgressBar } from 'components/subprocess-progress-bar/subprocess-progress-bar'; +import { Process } from 'store/processes/process'; + +type CssRules = 'iconHeader' | 'cardHeader'; + +const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ + iconHeader: { + fontSize: '1.875rem', + color: theme.customs.colors.greyL, + marginRight: theme.spacing.unit * 2, + }, + cardHeader: { + display: 'flex', + }, +}); export enum SubprocessPanelColumnNames { NAME = "Name", @@ -29,12 +47,12 @@ export interface SubprocessPanelFilter extends DataTableFilterItem { type: ResourceKind | ContainerRequestState; } -export const subprocessPanelColumns: DataColumns = [ +export const subprocessPanelColumns: DataColumns = [ { name: SubprocessPanelColumnNames.NAME, selected: true, configurable: true, - sortDirection: SortDirection.NONE, + sort: {direction: SortDirection.NONE, field: "name"}, filters: createTree(), render: uuid => }, @@ -50,7 +68,7 @@ export const subprocessPanelColumns: DataColumns = [ name: SubprocessPanelColumnNames.CREATED_AT, selected: true, configurable: true, - sortDirection: SortDirection.DESC, + sort: {direction: SortDirection.DESC, field: "createdAt"}, filters: createTree(), render: uuid => }, @@ -64,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; } @@ -80,10 +99,22 @@ const DEFAULT_VIEW_MESSAGES = [ 'The current process may not have any or none matches current filtering.' ]; +type SubProcessesTitleProps = WithStyles; + +const SubProcessesTitle = withStyles(styles)( + ({classes}: SubProcessesTitleProps) => +
+ + + Subprocesses + +
+); + export const SubprocessPanelRoot = (props: SubprocessPanelProps & MPVPanelProps) => { return props.onContextMenu(event, item, props.resources)} contextMenuColumn={true} @@ -91,6 +122,9 @@ export const SubprocessPanelRoot = (props: SubprocessPanelProps & MPVPanelProps) defaultViewMessages={DEFAULT_VIEW_MESSAGES} doHidePanel={props.doHidePanel} doMaximizePanel={props.doMaximizePanel} + doUnMaximizePanel={props.doUnMaximizePanel} panelMaximized={props.panelMaximized} - panelName={props.panelName} />; + panelName={props.panelName} + title={} + 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 67326829..2a96ffe0 100644 --- a/src/views/trash-panel/trash-panel.tsx +++ b/src/views/trash-panel/trash-panel.tsx @@ -34,6 +34,8 @@ import { createTree } from 'models/tree'; import { getTrashPanelTypeFilters } from 'store/resource-type-filters/resource-type-filters'; +import { CollectionResource } from 'models/collection'; +import { toggleOne } from 'store/multiselect/multiselect-actions'; type CssRules = "toolbar" | "button" | "root"; @@ -83,12 +85,12 @@ export const ResourceRestore = ); -export const trashPanelColumns: DataColumns = [ +export const trashPanelColumns: DataColumns = [ { name: TrashPanelColumnNames.NAME, selected: true, configurable: true, - sortDirection: SortDirection.NONE, + sort: {direction: SortDirection.NONE, field: "name"}, filters: createTree(), render: uuid => }, @@ -96,7 +98,6 @@ export const trashPanelColumns: DataColumns = [ name: TrashPanelColumnNames.TYPE, selected: true, configurable: true, - sortDirection: SortDirection.NONE, filters: getTrashPanelTypeFilters(), render: uuid => , }, @@ -104,7 +105,7 @@ export const trashPanelColumns: DataColumns = [ name: TrashPanelColumnNames.FILE_SIZE, selected: true, configurable: true, - sortDirection: SortDirection.NONE, + sort: {direction: SortDirection.NONE, field: "fileSizeTotal"}, filters: createTree(), render: uuid => }, @@ -112,7 +113,7 @@ export const trashPanelColumns: DataColumns = [ name: TrashPanelColumnNames.TRASHED_DATE, selected: true, configurable: true, - sortDirection: SortDirection.DESC, + sort: {direction: SortDirection.DESC, field: "trashAt"}, filters: createTree(), render: uuid => }, @@ -120,7 +121,7 @@ export const trashPanelColumns: DataColumns = [ name: TrashPanelColumnNames.TO_BE_DELETED, selected: true, configurable: true, - sortDirection: SortDirection.NONE, + sort: {direction: SortDirection.NONE, field: "deleteAt"}, filters: createTree(), render: uuid => }, @@ -128,7 +129,6 @@ export const trashPanelColumns: DataColumns = [ name: '', selected: true, configurable: false, - sortDirection: SortDirection.NONE, filters: createTree(), render: uuid => } @@ -179,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-panel/user-panel.tsx b/src/views/user-panel/user-panel.tsx index f2491dc2..950262d8 100644 --- a/src/views/user-panel/user-panel.tsx +++ b/src/views/user-panel/user-panel.tsx @@ -3,7 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0 import React from 'react'; -import { WithStyles, withStyles, Paper, Button, Grid } from '@material-ui/core'; +import { WithStyles, withStyles, Paper, Typography } from '@material-ui/core'; import { DataExplorer } from "views-components/data-explorer/data-explorer"; import { connect, DispatchProp } from 'react-redux'; import { DataColumns } from 'components/data-table/data-table'; @@ -23,7 +23,7 @@ import { navigateToUserProfile } from "store/navigation/navigation-action"; import { createTree } from 'models/tree'; import { compose, Dispatch } from 'redux'; import { UserResource } from 'models/user'; -import { ShareMeIcon, AddIcon } from 'components/icon/icon'; +import { ShareMeIcon } from 'components/icon/icon'; import { USERS_PANEL_ID, openUserCreateDialog } from 'store/users/users-actions'; import { noop } from 'lodash'; @@ -51,12 +51,12 @@ export enum UserPanelColumnNames { USERNAME = "Username" } -export const userPanelColumns: DataColumns = [ +export const userPanelColumns: DataColumns = [ { name: UserPanelColumnNames.NAME, selected: true, configurable: true, - sortDirection: SortDirection.NONE, + sort: {direction: SortDirection.NONE, field: "firstName"}, filters: createTree(), render: uuid => }, @@ -64,7 +64,7 @@ export const userPanelColumns: DataColumns = [ name: UserPanelColumnNames.UUID, selected: true, configurable: true, - sortDirection: SortDirection.NONE, + sort: {direction: SortDirection.NONE, field: "uuid"}, filters: createTree(), render: uuid => }, @@ -72,7 +72,7 @@ export const userPanelColumns: DataColumns = [ name: UserPanelColumnNames.EMAIL, selected: true, configurable: true, - sortDirection: SortDirection.NONE, + sort: {direction: SortDirection.NONE, field: "email"}, filters: createTree(), render: uuid => }, @@ -94,7 +94,7 @@ export const userPanelColumns: DataColumns = [ name: UserPanelColumnNames.USERNAME, selected: true, configurable: false, - sortDirection: SortDirection.NONE, + sort: {direction: SortDirection.NONE, field: "username"}, filters: createTree(), render: uuid => } @@ -132,18 +132,20 @@ export const UserPanel = compose( return + + User records are created automatically on first log in. + + + To add a new user, add them to your configured log in provider. + + } onRowClick={noop} onRowDoubleClick={noop} onContextMenu={this.handleContextMenu} contextMenuColumn={true} hideColumnSelector - actions={ - - - - } paperProps={{ elevation: 0, }} 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 53c0799f..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,15 +27,16 @@ import { ArvadosTheme } from 'common/custom-theme'; import { PROFILE_EMAIL_VALIDATION, PROFILE_URL_VALIDATION } from "validators/validators"; import { USER_PROFILE_PANEL_ID } from 'store/user-profile/user-profile-actions'; import { noop } from 'lodash'; -import { DetailsIcon, GroupsIcon, MoreOptionsIcon } from 'components/icon/icon'; +import { DetailsIcon, GroupsIcon, MoreVerticalIcon } from 'components/icon/icon'; import { DataColumns } from 'components/data-table/data-table'; import { ResourceLinkHeadUuid, ResourceLinkHeadPermissionLevel, ResourceLinkHead, ResourceLinkDelete, ResourceLinkTailIsVisible, UserResourceAccountStatus } from 'views-components/data-explorer/renderers'; import { createTree } from 'models/tree'; import { getResource, ResourcesState } from 'store/resources/resources'; import { DefaultView } from 'components/default-view/default-view'; import { CopyToClipboardSnackbar } from 'components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar'; +import { PermissionResource } from 'models/permission'; -type CssRules = 'root' | 'emptyRoot' | 'gridItem' | 'label' | 'readOnlyValue' | 'title' | 'description' | 'actions' | 'content' | 'copyIcon'; +type CssRules = 'root' | 'emptyRoot' | 'gridItem' | 'label' | 'readOnlyValue' | 'title' | 'description' | 'actions' | 'content' | 'copyIcon' | 'userProfileFormMessage'; const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ root: { @@ -80,6 +81,9 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ '& svg': { fontSize: '1rem' } + }, + userProfileFormMessage: { + fontSize: '1.1rem', } }); @@ -96,6 +100,7 @@ export interface UserProfilePanelRootDataProps { userUuid: string; resources: ResourcesState; localCluster: string; + userProfileFormMessage: string; } const RoleTypes = [ @@ -125,7 +130,7 @@ enum TABS { } -export const userProfileGroupsColumns: DataColumns = [ +export const userProfileGroupsColumns: DataColumns = [ { name: UserProfileGroupsColumnNames.NAME, selected: true, @@ -164,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} @@ -183,7 +188,7 @@ export const UserProfilePanelRoot = withStyles(styles)( }; componentDidMount() { - this.setState({ value: TABS.PROFILE}); + this.setState({ value: TABS.PROFILE }); } render() { @@ -212,14 +217,14 @@ export const UserProfilePanelRoot = withStyles(styles)( - + this.handleContextMenu(event, this.props.userUuid)}> - + @@ -260,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 468ef35a..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,12 +11,12 @@ import { compose, Dispatch } from 'redux'; import { loadVirtualMachinesAdminData, openAddVirtualMachineLoginDialog, openRemoveVirtualMachineLoginDialog, openEditVirtualMachineLoginDialog } from 'store/virtual-machines/virtual-machines-actions'; import { RootState } from 'store/store'; import { ListResults } from 'services/common-service/common-service'; -import { MoreOptionsIcon, AddUserIcon } from 'components/icon/icon'; +import { MoreVerticalIcon, AddUserIcon } from 'components/icon/icon'; import { VirtualMachineLogins, VirtualMachinesResource } from 'models/virtual-machines'; import { openVirtualMachinesContextMenu } from 'store/context-menu/context-menu-actions'; import { ResourceUuid, VirtualMachineHostname, VirtualMachineLogin } from 'views-components/data-explorer/renderers'; -type CssRules = 'moreOptionsButton' | 'moreOptions' | 'chipsRoot'; +type CssRules = 'moreOptionsButton' | 'moreOptions' | 'chipsRoot' | 'vmTableWrapper'; const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ moreOptionsButton: { @@ -31,6 +31,9 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ chipsRoot: { margin: `0px -${theme.spacing.unit / 2}px`, }, + vmTableWrapper: { + overflowX: 'auto', + }, }); const mapStateToProps = (state: RootState) => { @@ -95,7 +98,7 @@ export const VirtualMachineAdminPanel = compose( const CardContentWithVirtualMachines = (props: VirtualMachineProps) => - + {virtualMachinesTable(props)} @@ -136,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 70f97daf..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,8 +18,9 @@ import parse from "parse-duration"; import { CopyIcon } from 'components/icon/icon'; import CopyToClipboard from 'react-copy-to-clipboard'; import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; +import { sanitizeHTML } from 'common/html-sanitize'; -type CssRules = 'button' | 'codeSnippet' | 'link' | 'linkIcon' | 'rightAlign' | 'cardWithoutMachines' | 'icon' | 'chipsRoot' | 'copyIcon' | 'webshellButton'; +type CssRules = 'button' | 'codeSnippet' | 'link' | 'linkIcon' | 'rightAlign' | 'cardWithoutMachines' | 'icon' | 'chipsRoot' | 'copyIcon' | 'tableWrapper' | 'webshellButton'; const EXTRA_TOKEN = "exraToken"; @@ -72,6 +73,9 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ fontSize: '1rem' } }, + tableWrapper: { + overflowX: 'auto', + }, webshellButton: { textTransform: "initial", }, @@ -176,7 +180,9 @@ const CardContentWithVirtualMachines = (props: VirtualMachineProps) =>
- {virtualMachinesTable(props)} +
+ {virtualMachinesTable(props)} +
@@ -264,7 +270,7 @@ const CardSSHSection = (props: VirtualMachineProps) => -
+
diff --git a/src/views/workbench/workbench.test.tsx b/src/views/workbench/workbench.test.tsx index 471ecc40..fe5dff8a 100644 --- a/src/views/workbench/workbench.test.tsx +++ b/src/views/workbench/workbench.test.tsx @@ -14,6 +14,8 @@ import { CustomTheme } from 'common/custom-theme'; import { createServices } from "services/services"; import 'jest-localstorage-mock'; +jest.mock('views-components/baner/banner', () => ({ Banner: () => 'Banner' })) + const history = createBrowserHistory(); it('renders without crashing', () => { diff --git a/src/views/workbench/workbench.tsx b/src/views/workbench/workbench.tsx index a6c49e34..bc2396f7 100644 --- a/src/views/workbench/workbench.tsx +++ b/src/views/workbench/workbench.tsx @@ -2,126 +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 { 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", + }, }, asidePanel: { paddingTop: theme.spacing.unit, - height: '100%' + height: "100%", }, contentWrapper: { paddingTop: theme.spacing.unit, - minWidth: 0 + minWidth: 0, }, content: { minWidth: 0, @@ -129,14 +140,15 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ paddingRight: theme.spacing.unit * 3, // Reserve vertical space for app bar + MainContentBar minHeight: `calc(100vh - ${theme.spacing.unit * 16}px)`, - display: 'flex', - } + display: "flex", + }, }); interface WorkbenchDataProps { isUserActive: boolean; isNotLinking: boolean; sessionIdleTimeout: number; + sidePanelIsCollapsed: boolean; } type WorkbenchPanelProps = WithStyles & WorkbenchDataProps; @@ -144,67 +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)) +); -export const WorkbenchPanel = - withStyles(styles)((props: WorkbenchPanelProps) => - +const applyCollapsedState = isCollapsed => { + const rightPanel: Element = document.getElementsByClassName("layout-pane")[1]; + const totalWidth: number = document.getElementsByClassName("splitter-layout")[0]?.clientWidth; + const rightPanelExpandedWidth = (totalWidth - COLLAPSE_ICON_SIZE) / (totalWidth / 100); + if (rightPanel) { + rightPanel.setAttribute("style", `width: ${isCollapsed ? `calc(${rightPanelExpandedWidth}% - 1rem)` : `${getSplitterInitialSize()}%`}`); + } + const splitter = document.getElementsByClassName("layout-splitter")[0]; + isCollapsed ? splitter?.classList.add("layout-splitter-disabled") : splitter?.classList.remove("layout-splitter-disabled"); +}; + +export const WorkbenchPanel = withStyles(styles)((props: WorkbenchPanelProps) => { + //panel size will not scale automatically on window resize, so we do it manually + if (props && props.sidePanelIsCollapsed) window.addEventListener("resize", () => applyCollapsedState(props.sidePanelIsCollapsed)); + applyCollapsedState(props.sidePanelIsCollapsed); + + return ( + {props.sessionIdleTimeout > 0 && } - - - {props.isUserActive && props.isNotLinking && - - } - - + + + {props.isUserActive && props.isNotLinking && ( + + + + )} + + {props.isNotLinking && } - + {routes.props.children} - + @@ -221,6 +379,7 @@ export const WorkbenchPanel = + @@ -238,8 +397,12 @@ export const WorkbenchPanel = - - + + + + + + @@ -270,6 +433,8 @@ 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/views/workflow-panel/workflow-panel-view.tsx b/src/views/workflow-panel/workflow-panel-view.tsx index 44e14fd3..7d9d746d 100644 --- a/src/views/workflow-panel/workflow-panel-view.tsx +++ b/src/views/workflow-panel/workflow-panel-view.tsx @@ -63,12 +63,12 @@ export enum ResourceStatus { // } // }; -export const workflowPanelColumns: DataColumns = [ +export const workflowPanelColumns: DataColumns = [ { name: WorkflowPanelColumnNames.NAME, selected: true, configurable: true, - sortDirection: SortDirection.ASC, + sort: {direction: SortDirection.ASC, field: "name"}, filters: createTree(), render: (uuid: string) => }, @@ -101,7 +101,7 @@ export const workflowPanelColumns: DataColumns = [ name: WorkflowPanelColumnNames.LAST_MODIFIED, selected: true, configurable: true, - sortDirection: SortDirection.NONE, + sort: {direction: SortDirection.NONE, field: "modifiedAt"}, filters: createTree(), render: (uuid: string) => }, 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/arvados_config.yml b/tools/arvados_config.yml index 3b2ecd8d..1ef77b86 100644 --- a/tools/arvados_config.yml +++ b/tools/arvados_config.yml @@ -12,13 +12,12 @@ Clusters: CollectionVersioning: true PreserveVersionIfIdle: -1s BlobSigningKey: zfhgfenhffzltr9dixws36j1yhksjoll2grmku38mi7yxd66h5j4q9w4jzanezacp8s6q0ro3hxakfye02152hncy6zml2ed0uc - TrustAllContent: false + TrustAllContent: true ForwardSlashNameSubstitution: / ManagedProperties: original_owner_uuid: {Function: original_owner, Protected: true} - WebDAVCache: - UUIDTTL: 0s Login: + TrustPrivateNetworks: true PAM: Enable: true StorageClasses: 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 6dfb5b18..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" @@ -2203,6 +2245,13 @@ __metadata: languageName: node linkType: hard +"@popperjs/core@npm:^2.9.0": + version: 2.11.6 + resolution: "@popperjs/core@npm:2.11.6" + checksum: 47fb328cec1924559d759b48235c78574f2d71a8a6c4c03edb6de5d7074078371633b91e39bbf3f901b32aa8af9b9d8f82834856d2f5737a23475036b16817f0 + languageName: node + linkType: hard + "@samverschueren/stream-to-observable@npm:^0.3.0": version: 0.3.1 resolution: "@samverschueren/stream-to-observable@npm:0.3.1" @@ -2226,6 +2275,24 @@ __metadata: languageName: node linkType: hard +"@sinonjs/commons@npm:^3.0.0": + version: 3.0.0 + resolution: "@sinonjs/commons@npm:3.0.0" + dependencies: + type-detect: 4.0.8 + checksum: b4b5b73d4df4560fb8c0c7b38c7ad4aeabedd362f3373859d804c988c725889cde33550e4bcc7cd316a30f5152a2d1d43db71b6d0c38f5feef71fd8d016763f8 + languageName: node + linkType: hard + +"@sinonjs/fake-timers@npm:^10.3.0": + version: 10.3.0 + resolution: "@sinonjs/fake-timers@npm:10.3.0" + dependencies: + "@sinonjs/commons": ^3.0.0 + checksum: 614d30cb4d5201550c940945d44c9e0b6d64a888ff2cd5b357f95ad6721070d6b8839cd10e15b76bf5e14af0bcc1d8f9ec00d49a46318f1f669a4bec1d7f3148 + languageName: node + linkType: hard + "@sinonjs/formatio@npm:^3.2.1": version: 3.2.2 resolution: "@sinonjs/formatio@npm:3.2.2" @@ -2385,6 +2452,13 @@ __metadata: languageName: node linkType: hard +"@tootallnate/once@npm:1": + version: 1.1.2 + resolution: "@tootallnate/once@npm:1.1.2" + checksum: e1fb1bbbc12089a0cb9433dc290f97bddd062deadb6178ce9bcb93bb7c1aecde5e60184bc7065aec42fe1663622a213493c48bbd4972d931aae48315f18e1be9 + languageName: node + linkType: hard + "@tootallnate/once@npm:2": version: 2.0.0 resolution: "@tootallnate/once@npm:2.0.0" @@ -2456,6 +2530,15 @@ __metadata: languageName: node linkType: hard +"@types/dompurify@npm:^3.0.3": + version: 3.0.3 + resolution: "@types/dompurify@npm:3.0.3" + dependencies: + "@types/trusted-types": "*" + checksum: ff629277db4d19d836b0d878e93efb27d876d1073db81507c39d44d509b30ee3bcdc9e951dbbf9574b1fc6c52e1eaa95abf4279fa45aca281868717f8a7298da + languageName: node + linkType: hard + "@types/enzyme-adapter-react-16@npm:1.0.3": version: 1.0.3 resolution: "@types/enzyme-adapter-react-16@npm:1.0.3" @@ -2617,6 +2700,13 @@ __metadata: languageName: node linkType: hard +"@types/minimist@npm:^1.2.0": + version: 1.2.3 + resolution: "@types/minimist@npm:1.2.3" + checksum: 666ea4f8c39dcbdfbc3171fe6b3902157c845cc9cb8cee33c10deb706cda5e0cc80f98ace2d6d29f6774b0dc21180c96cd73c592a1cbefe04777247c7ba0e84b + languageName: node + linkType: hard + "@types/node@npm:*, @types/node@npm:15.12.4": version: 15.12.4 resolution: "@types/node@npm:15.12.4" @@ -2624,6 +2714,13 @@ __metadata: languageName: node linkType: hard +"@types/normalize-package-data@npm:^2.4.0": + version: 2.4.2 + resolution: "@types/normalize-package-data@npm:2.4.2" + checksum: 2132e4054711e6118de967ae3a34f8c564e58d71fbcab678ec2c34c14659f638a86c35a0fd45237ea35a4a03079cf0a485e3f97736ffba5ed647bfb5da086b03 + languageName: node + linkType: hard + "@types/parse-json@npm:^4.0.0": version: 4.0.0 resolution: "@types/parse-json@npm:4.0.0" @@ -2852,6 +2949,13 @@ __metadata: languageName: node linkType: hard +"@types/trusted-types@npm:*": + version: 2.0.4 + resolution: "@types/trusted-types@npm:2.0.4" + checksum: 5256c4576cd1c90d33ddd9cc9cbd4f202b39c98cbe8b7f74963298f9eb2159c285ea5c25a6181b4c594d8d75641765bff85d72c2d251ad076e6529ce0eeedd1c + languageName: node + linkType: hard + "@types/uuid@npm:3.4.4": version: 3.4.4 resolution: "@types/uuid@npm:3.4.4" @@ -3302,6 +3406,15 @@ __metadata: languageName: node linkType: hard +"agentkeepalive@npm:^4.1.3": + version: 4.5.0 + resolution: "agentkeepalive@npm:4.5.0" + dependencies: + humanize-ms: ^1.2.1 + checksum: 13278cd5b125e51eddd5079f04d6fe0914ac1b8b91c1f3db2c1822f99ac1a7457869068997784342fe455d59daaff22e14fb7b8c3da4e741896e7e31faf92481 + languageName: node + linkType: hard + "agentkeepalive@npm:^4.2.1": version: 4.2.1 resolution: "agentkeepalive@npm:4.2.1" @@ -3426,27 +3539,20 @@ __metadata: linkType: hard "ansi-regex@npm:^3.0.0": - version: 3.0.0 - resolution: "ansi-regex@npm:3.0.0" - checksum: 2ad11c416f81c39f5c65eafc88cf1d71aa91d76a2f766e75e457c2a3c43e8a003aadbf2966b61c497aa6a6940a36412486c975b3270cdfc3f413b69826189ec3 + version: 3.0.1 + resolution: "ansi-regex@npm:3.0.1" + checksum: 09daf180c5f59af9850c7ac1bd7fda85ba596cc8cbeb210826e90755f06c818af86d9fa1e6e8322fab2c3b9e9b03f56c537b42241139f824dd75066a1e7257cc languageName: node linkType: hard "ansi-regex@npm:^4.0.0, ansi-regex@npm:^4.1.0": - version: 4.1.0 - resolution: "ansi-regex@npm:4.1.0" - checksum: 97aa4659538d53e5e441f5ef2949a3cffcb838e57aeaad42c4194e9d7ddb37246a6526c4ca85d3940a9d1e19b11cc2e114530b54c9d700c8baf163c31779baf8 - languageName: node - linkType: hard - -"ansi-regex@npm:^5.0.0": - version: 5.0.0 - resolution: "ansi-regex@npm:5.0.0" - checksum: b1bb4e992a5d96327bb4f72eaba9f8047f1d808d273ad19d399e266bfcc7fb19a4d1a127a32f7bc61fe46f1a94a4d04ec4c424e3fbe184929aa866323d8ed4ce + version: 4.1.1 + resolution: "ansi-regex@npm:4.1.1" + checksum: b1a6ee44cb6ecdabaa770b2ed500542714d4395d71c7e5c25baa631f680fb2ad322eb9ba697548d498a6fd366949fc8b5bfcf48d49a32803611f648005b01888 languageName: node linkType: hard -"ansi-regex@npm:^5.0.1": +"ansi-regex@npm:^5.0.0, ansi-regex@npm:^5.0.1": version: 5.0.1 resolution: "ansi-regex@npm:5.0.1" checksum: 2aa4bb54caf2d622f1afdad09441695af2a83aa3fe8b8afa581d205e57ed4261c183c4d3877cee25794443fde5876417d859c108078ab788d6af7e4fe52eb66b @@ -3512,7 +3618,7 @@ __metadata: languageName: node linkType: hard -"aproba@npm:^1.0.3, aproba@npm:^1.1.1": +"aproba@npm:^1.1.1": version: 1.2.0 resolution: "aproba@npm:1.2.0" checksum: 0fca141966559d195072ed047658b6e6c4fe92428c385dd38e288eacfc55807e7b4989322f030faff32c0f46bb0bc10f1e0ac32ec22d25315a1e5bbc0ebb76dc @@ -3526,23 +3632,23 @@ __metadata: languageName: node linkType: hard -"are-we-there-yet@npm:^3.0.0": - version: 3.0.0 - resolution: "are-we-there-yet@npm:3.0.0" +"are-we-there-yet@npm:^2.0.0": + version: 2.0.0 + resolution: "are-we-there-yet@npm:2.0.0" dependencies: delegates: ^1.0.0 readable-stream: ^3.6.0 - checksum: 348edfdd931b0b50868b55402c01c3f64df1d4c229ab6f063539a5025fd6c5f5bb8a0cab409bbed8d75d34762d22aa91b7c20b4204eb8177063158d9ba792981 + checksum: 6c80b4fd04ecee6ba6e737e0b72a4b41bdc64b7d279edfc998678567ff583c8df27e27523bc789f2c99be603ffa9eaa612803da1d886962d2086e7ff6fa90c7c languageName: node linkType: hard -"are-we-there-yet@npm:~1.1.2": - version: 1.1.5 - resolution: "are-we-there-yet@npm:1.1.5" +"are-we-there-yet@npm:^3.0.0": + version: 3.0.0 + resolution: "are-we-there-yet@npm:3.0.0" dependencies: delegates: ^1.0.0 - readable-stream: ^2.0.6 - checksum: 9a746b1dbce4122f44002b0c39fbba5b2c6f52c00e88b6ccba6fc68652323f8a1355a20e8ab94846995626d8de3bf67669a3b4a037dff0885db14607168f2b15 + readable-stream: ^3.6.0 + checksum: 348edfdd931b0b50868b55402c01c3f64df1d4c229ab6f063539a5025fd6c5f5bb8a0cab409bbed8d75d34762d22aa91b7c20b4204eb8177063158d9ba792981 languageName: node linkType: hard @@ -3716,14 +3822,18 @@ __metadata: version: 0.0.0-use.local resolution: "arvados-workbench-2@workspace:." dependencies: + "@coreui/coreui": ^4.3.2 + "@coreui/react": ^4.11.0 "@date-io/date-fns": 1 "@fortawesome/fontawesome-svg-core": 1.2.28 "@fortawesome/free-solid-svg-icons": 5.13.0 "@fortawesome/react-fontawesome": 0.1.9 "@material-ui/core": 3.9.3 "@material-ui/icons": 3.0.1 + "@sinonjs/fake-timers": ^10.3.0 "@types/classnames": 2.2.6 "@types/debounce": 3.0.0 + "@types/dompurify": ^3.0.3 "@types/enzyme": 3.1.14 "@types/enzyme-adapter-react-16": 1.0.3 "@types/file-saver": 2.0.0 @@ -3755,12 +3865,14 @@ __metadata: axios-mock-adapter: 1.17.0 babel-core: 6.26.3 babel-runtime: 6.26.0 + bootstrap: ^5.3.2 caniuse-lite: 1.0.30001299 classnames: 2.2.6 cwlts: 1.15.29 cypress: 6.3.0 date-fns: ^2.28.0 debounce: 1.2.0 + dompurify: ^3.0.6 elliptic: 6.5.4 enzyme: 3.11.0 enzyme-adapter-react-16: 1.15.6 @@ -3770,24 +3882,25 @@ __metadata: jest-localstorage-mock: 2.2.0 js-yaml: 3.13.1 jssha: 2.3.1 - jszip: 3.1.5 + jszip: ^3.10.1 lodash: ^4.17.21 - lodash-es: 4.17.14 + lodash-es: ^4.17.21 lodash.mergewith: 4.6.2 lodash.template: 4.5.0 material-ui-pickers: ^2.2.4 mem: 4.0.0 - moment: 2.29.1 - node-sass: ^4.9.4 - node-sass-chokidar: 1.5.0 + mime: ^3.0.0 + moment: ^2.29.4 + node-sass: ^9.0.0 + node-sass-chokidar: ^2.0.0 parse-duration: 0.4.4 prop-types: 15.7.2 query-string: 6.9.0 - react: 16.8.6 + react: 16.14.0 react-copy-to-clipboard: 5.0.3 react-dnd: 5.0.0 react-dnd-html5-backend: 5.0.1 - react-dom: 16.8.6 + react-dom: 16.14.0 react-dropzone: 5.1.1 react-highlight-words: 0.14.0 react-idle-timer: 4.3.6 @@ -3795,7 +3908,7 @@ __metadata: react-router: 4.3.1 react-router-dom: 4.3.1 react-router-redux: 5.0.0-alpha.9 - react-rte: 0.16.3 + react-rte: ^0.16.5 react-scripts: 3.4.4 react-splitter-layout: 3.0.1 react-transition-group: 2.5.0 @@ -3803,6 +3916,7 @@ __metadata: react-window: 1.8.5 redux: 4.0.3 redux-devtools: 3.4.1 + redux-devtools-extension: ^2.13.9 redux-form: 7.4.2 redux-mock-store: 1.5.4 redux-thunk: 2.3.0 @@ -3810,6 +3924,7 @@ __metadata: set-value: 2.0.1 shell-escape: ^0.2.0 sinon: 7.3 + tippy.js: ^6.3.7 ts-mock-imports: 1.3.7 tslint: 5.20.0 tslint-etc: 1.6.0 @@ -3909,18 +4024,18 @@ __metadata: linkType: hard "async@npm:^2.6.2": - version: 2.6.3 - resolution: "async@npm:2.6.3" + version: 2.6.4 + resolution: "async@npm:2.6.4" dependencies: lodash: ^4.17.14 - checksum: 5e5561ff8fca807e88738533d620488ac03a5c43fce6c937451f7e35f943d33ad06c24af3f681a48cca3d2b0002b3118faff0a128dc89438a9bf0226f712c499 + checksum: a52083fb32e1ebe1d63e5c5624038bb30be68ff07a6c8d7dfe35e47c93fc144bd8652cbec869e0ac07d57dde387aa5f1386be3559cdee799cb1f789678d88e19 languageName: node linkType: hard "async@npm:^3.2.0": - version: 3.2.0 - resolution: "async@npm:3.2.0" - checksum: 6739fae769e6c9f76b272558f118ef041d45c979c573a8fe93f8cfbc32eb9c92da032e9effe6bbcc9b1131292cde6c4a9e61a442894aa06a262addd8dd3adda1 + version: 3.2.4 + resolution: "async@npm:3.2.4" + checksum: 43d07459a4e1d09b84a20772414aa684ff4de085cbcaec6eea3c7a8f8150e8c62aa6cd4e699fe8ee93c3a5b324e777d34642531875a0817a35697522c1b02e89 languageName: node linkType: hard @@ -4006,11 +4121,11 @@ __metadata: linkType: hard "axios@npm:^0.21.1": - version: 0.21.1 - resolution: "axios@npm:0.21.1" + version: 0.21.4 + resolution: "axios@npm:0.21.4" dependencies: - follow-redirects: ^1.10.0 - checksum: c87915fa0b18c15c63350112b6b3563a3e2ae524d7707de0a73d2e065e0d30c5d3da8563037bc29d4cc1b7424b5a350cb7274fa52525c6c04a615fe561c6ab11 + follow-redirects: ^1.14.0 + checksum: 44245f24ac971e7458f3120c92f9d66d1fc695e8b97019139de5b0cc65d9b8104647db01e5f46917728edfc0cfd88eb30fc4c55e6053eef4ace76768ce95ff3c languageName: node linkType: hard @@ -4455,15 +4570,6 @@ __metadata: languageName: node linkType: hard -"block-stream@npm:*": - version: 0.0.9 - resolution: "block-stream@npm:0.0.9" - dependencies: - inherits: ~2.0.0 - checksum: 72733cbb816181b7c92449e7b650247c02122f743526ce9d948ff68afc27d8709106cd62f2c876c6d8cd3977e0204a014f38d22805974008039bd3bed35f2cbd - languageName: node - linkType: hard - "bluebird@npm:^3.5.5, bluebird@npm:^3.7.2": version: 3.7.2 resolution: "bluebird@npm:3.7.2" @@ -4524,6 +4630,15 @@ __metadata: languageName: node linkType: hard +"bootstrap@npm:^5.3.2": + version: 5.3.2 + resolution: "bootstrap@npm:5.3.2" + peerDependencies: + "@popperjs/core": ^2.11.8 + checksum: d5580b253d121ffc137388d41da58dce8d15f1ccd574e12f28d4a08e7649ca15e95db645b2b677cb8025bccd446bff04138fc0fe64f8cba0ccc5dc004a8644cf + languageName: node + linkType: hard + "brace-expansion@npm:^1.1.7": version: 1.1.11 resolution: "brace-expansion@npm:1.1.11" @@ -4534,6 +4649,15 @@ __metadata: languageName: node linkType: hard +"brace-expansion@npm:^2.0.1": + version: 2.0.1 + resolution: "brace-expansion@npm:2.0.1" + dependencies: + balanced-match: ^1.0.0 + checksum: a61e7cd2e8a8505e9f0036b3b6108ba5e926b4b55089eeb5550cd04a471fe216c96d4fe7e4c7f995c728c554ae20ddfc4244cad10aef255e72b62930afd233d1 + languageName: node + linkType: hard + "braces@npm:^2.3.1, braces@npm:^2.3.2": version: 2.3.2 resolution: "braces@npm:2.3.2" @@ -4552,7 +4676,7 @@ __metadata: languageName: node linkType: hard -"braces@npm:^3.0.1, braces@npm:~3.0.2": +"braces@npm:^3.0.2, braces@npm:~3.0.2": version: 3.0.2 resolution: "braces@npm:3.0.2" dependencies: @@ -4679,17 +4803,16 @@ __metadata: linkType: hard "browserslist@npm:^4.0.0, browserslist@npm:^4.12.0, browserslist@npm:^4.16.6, browserslist@npm:^4.6.2, browserslist@npm:^4.6.4, browserslist@npm:^4.9.1": - version: 4.16.6 - resolution: "browserslist@npm:4.16.6" + version: 4.22.1 + resolution: "browserslist@npm:4.22.1" dependencies: - caniuse-lite: ^1.0.30001219 - colorette: ^1.2.2 - electron-to-chromium: ^1.3.723 - escalade: ^3.1.1 - node-releases: ^1.1.71 + caniuse-lite: ^1.0.30001541 + electron-to-chromium: ^1.4.535 + node-releases: ^2.0.13 + update-browserslist-db: ^1.0.13 bin: browserslist: cli.js - checksum: 3dffc86892d2dcfcfc66b52519b7e5698ae070b4fc92ab047e760efc4cae0474e9e70bbe10d769c8d3491b655ef3a2a885b88e7196c83cc5dc0a46dfdba8b70c + checksum: 7e6b10c53f7dd5d83fd2b95b00518889096382539fed6403829d447e05df4744088de46a571071afb447046abc3c66ad06fbc790e70234ec2517452e32ffd862 languageName: node linkType: hard @@ -4818,7 +4941,7 @@ __metadata: languageName: node linkType: hard -"cacache@npm:^15.3.0": +"cacache@npm:^15.2.0, cacache@npm:^15.3.0": version: 15.3.0 resolution: "cacache@npm:15.3.0" dependencies: @@ -4844,6 +4967,32 @@ __metadata: languageName: node linkType: hard +"cacache@npm:^16.1.0": + version: 16.1.3 + resolution: "cacache@npm:16.1.3" + dependencies: + "@npmcli/fs": ^2.1.0 + "@npmcli/move-file": ^2.0.0 + chownr: ^2.0.0 + fs-minipass: ^2.1.0 + glob: ^8.0.1 + infer-owner: ^1.0.4 + lru-cache: ^7.7.1 + minipass: ^3.1.6 + minipass-collect: ^1.0.2 + minipass-flush: ^1.0.5 + minipass-pipeline: ^1.2.4 + mkdirp: ^1.0.4 + p-map: ^4.0.0 + promise-inflight: ^1.0.1 + rimraf: ^3.0.2 + ssri: ^9.0.0 + tar: ^6.1.11 + unique-filename: ^2.0.0 + checksum: d91409e6e57d7d9a3a25e5dcc589c84e75b178ae8ea7de05cbf6b783f77a5fae938f6e8fda6f5257ed70000be27a681e1e44829251bfffe4c10216002f8f14e6 + languageName: node + linkType: hard + "cache-base@npm:^1.0.1": version: 1.0.1 resolution: "cache-base@npm:1.0.1" @@ -4937,6 +5086,17 @@ __metadata: languageName: node linkType: hard +"camelcase-keys@npm:^6.2.2": + version: 6.2.2 + resolution: "camelcase-keys@npm:6.2.2" + dependencies: + camelcase: ^5.3.1 + map-obj: ^4.0.0 + quick-lru: ^4.0.1 + checksum: 43c9af1adf840471e54c68ab3e5fe8a62719a6b7dbf4e2e86886b7b0ff96112c945736342b837bd2529ec9d1c7d1934e5653318478d98e0cf22c475c04658e2a + languageName: node + linkType: hard + "camelcase@npm:5.3.1, camelcase@npm:^5.0.0, camelcase@npm:^5.3.1": version: 5.3.1 resolution: "camelcase@npm:5.3.1" @@ -4970,13 +5130,20 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:1.0.30001299, caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30000981, caniuse-lite@npm:^1.0.30001035, caniuse-lite@npm:^1.0.30001109, caniuse-lite@npm:^1.0.30001219": +"caniuse-lite@npm:1.0.30001299": version: 1.0.30001299 resolution: "caniuse-lite@npm:1.0.30001299" checksum: c770f60ebf3e0cc8043ba4db0ebec12d7a595a6b50cb4437c3c5c55b04de9d2413f711f2828be761e8c37bb46b927a8abe6b199b8f0ffc1a34af0ebdee84be27 languageName: node linkType: hard +"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30000981, caniuse-lite@npm:^1.0.30001035, caniuse-lite@npm:^1.0.30001109, caniuse-lite@npm:^1.0.30001541": + version: 1.0.30001561 + resolution: "caniuse-lite@npm:1.0.30001561" + checksum: 949829fe037e23346595614e01d362130245920503a12677f2506ce68e1240360113d6383febed41e8aa38cd0f5fd9c69c21b0af65a71c0246d560db489f1373 + languageName: node + linkType: hard + "capture-exit@npm:^2.0.0": version: 2.0.0 resolution: "capture-exit@npm:2.0.0" @@ -5011,7 +5178,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^1.0.0, chalk@npm:^1.1.1, chalk@npm:^1.1.3": +"chalk@npm:^1.0.0, chalk@npm:^1.1.3": version: 1.1.3 resolution: "chalk@npm:1.1.3" dependencies: @@ -5024,13 +5191,13 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.0.0, chalk@npm:^4.1.0": - version: 4.1.1 - resolution: "chalk@npm:4.1.1" +"chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.2": + version: 4.1.2 + resolution: "chalk@npm:4.1.2" dependencies: ansi-styles: ^4.1.0 supports-color: ^7.1.0 - checksum: 036e973e665ba1a32c975e291d5f3d549bceeb7b1b983320d4598fb75d70fe20c5db5d62971ec0fe76cdbce83985a00ee42372416abfc3a5584465005a7855ed + checksum: fe75c9d5c76a7a98d45495b91b2172fa3b7a09e0cc9370e5c8feb1c567b85c4288e2b3fded7cfdd7359ac28d6b3844feb8b82b8686842e93d23c827c417e83fc languageName: node linkType: hard @@ -5107,8 +5274,8 @@ __metadata: linkType: hard "chokidar@npm:^3.3.0, chokidar@npm:^3.4.0, chokidar@npm:^3.4.1": - version: 3.5.2 - resolution: "chokidar@npm:3.5.2" + version: 3.5.3 + resolution: "chokidar@npm:3.5.3" dependencies: anymatch: ~3.1.2 braces: ~3.0.2 @@ -5121,7 +5288,7 @@ __metadata: dependenciesMeta: fsevents: optional: true - checksum: d1fda32fcd67d9f6170a8468ad2630a3c6194949c9db3f6a91b16478c328b2800f433fb5d2592511b6cb145a47c013ea1cce60b432b1a001ae3ee978a8bffc2d + checksum: b49fcde40176ba007ff361b198a2d35df60d9bb2a5aab228279eb810feae9294a6b4649ab15981304447afe1e6ffbf4788ad5db77235dc770ab777c6e771980c languageName: node linkType: hard @@ -5310,6 +5477,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^8.0.1": + version: 8.0.1 + resolution: "cliui@npm:8.0.1" + dependencies: + string-width: ^4.2.0 + strip-ansi: ^6.0.1 + wrap-ansi: ^7.0.0 + checksum: 79648b3b0045f2e285b76fb2e24e207c6db44323581e421c3acbd0e86454cba1b37aea976ab50195a49e7384b871e6dfb2247ad7dec53c02454ac6497394cb56 + languageName: node + linkType: hard + "clone-deep@npm:^0.2.4": version: 0.2.4 resolution: "clone-deep@npm:0.2.4" @@ -5418,7 +5596,7 @@ __metadata: languageName: node linkType: hard -"color-support@npm:^1.1.3": +"color-support@npm:^1.1.2, color-support@npm:^1.1.3": version: 1.1.3 resolution: "color-support@npm:1.1.3" bin: @@ -5437,7 +5615,7 @@ __metadata: languageName: node linkType: hard -"colorette@npm:^1.2.1, colorette@npm:^1.2.2": +"colorette@npm:^1.2.1": version: 1.2.2 resolution: "colorette@npm:1.2.2" checksum: 69fec14ddaedd0f5b00e4bae40dc4bc61f7050ebdc82983a595d6fd64e650b9dc3c033fff378775683138e992e0ddd8717ac7c7cec4d089679dcfbe3cd921b04 @@ -5575,7 +5753,7 @@ __metadata: languageName: node linkType: hard -"console-control-strings@npm:^1.0.0, console-control-strings@npm:^1.1.0, console-control-strings@npm:~1.1.0": +"console-control-strings@npm:^1.0.0, console-control-strings@npm:^1.1.0": version: 1.1.0 resolution: "console-control-strings@npm:1.1.0" checksum: 8755d76787f94e6cf79ce4666f0c5519906d7f5b02d4b884cf41e11dcd759ed69c57da0670afd9236d229a46e0f9cf519db0cd829c6dca820bb5a5c3def584ed @@ -5719,13 +5897,6 @@ __metadata: languageName: node linkType: hard -"core-js@npm:~2.3.0": - version: 2.3.0 - resolution: "core-js@npm:2.3.0" - checksum: eb2e9e82d71e646e91abc9480ee4da8a4c02606418ea83602daae5988b4ba558a233f1a29dc8d660e2e4aaa7f6e4297b6c3089b55b0e7292917eef07a3952972 - languageName: node - linkType: hard - "core-util-is@npm:1.0.2, core-util-is@npm:~1.0.0": version: 1.0.2 resolution: "core-util-is@npm:1.0.2" @@ -5796,11 +5967,11 @@ __metadata: linkType: hard "cross-fetch@npm:^3.0.4": - version: 3.1.4 - resolution: "cross-fetch@npm:3.1.4" + version: 3.1.8 + resolution: "cross-fetch@npm:3.1.8" dependencies: - node-fetch: 2.6.1 - checksum: 2107e5e633aa327bdacab036b1907c7ddd28651ede0c1d4fd14db04510944d56849a8255e2f5b8f9a1da0e061b6cee943f6819fe29ed9a130195e7fadd82a4ff + node-fetch: ^2.6.12 + checksum: 78f993fa099eaaa041122ab037fe9503ecbbcb9daef234d1d2e0b9230a983f64d645d088c464e21a247b825a08dc444a6e7064adfa93536d3a9454b4745b3632 languageName: node linkType: hard @@ -5815,16 +5986,6 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^3.0.0": - version: 3.0.1 - resolution: "cross-spawn@npm:3.0.1" - dependencies: - lru-cache: ^4.0.1 - which: ^1.2.9 - checksum: a029a5028629ce2b7773e341b57415b344b6e46b98b39b308822c3b524e8e92e15f10c4ca3384e90722b882dfce2cc8e10edc8e84ee1394afe9744c4a1082776 - languageName: node - linkType: hard - "cross-spawn@npm:^6.0.0, cross-spawn@npm:^6.0.5": version: 6.0.5 resolution: "cross-spawn@npm:6.0.5" @@ -5838,7 +5999,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.0": +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.3": version: 7.0.3 resolution: "cross-spawn@npm:7.0.3" dependencies: @@ -5961,15 +6122,15 @@ __metadata: linkType: hard "css-select@npm:^4.1.3": - version: 4.1.3 - resolution: "css-select@npm:4.1.3" + version: 4.3.0 + resolution: "css-select@npm:4.3.0" dependencies: boolbase: ^1.0.0 - css-what: ^5.0.0 - domhandler: ^4.2.0 - domutils: ^2.6.0 - nth-check: ^2.0.0 - checksum: 40928f1aa6c71faf36430e7f26bcbb8ab51d07b98b754caacb71906400a195df5e6c7020a94f2982f02e52027b9bd57c99419220cf7020968c3415f14e4be5f8 + css-what: ^6.0.1 + domhandler: ^4.3.1 + domutils: ^2.8.0 + nth-check: ^2.0.1 + checksum: d6202736839194dd7f910320032e7cfc40372f025e4bf21ca5bf6eb0a33264f322f50ba9c0adc35dadd342d3d6fae5ca244779a4873afbfa76561e343f2058e0 languageName: node linkType: hard @@ -6002,13 +6163,20 @@ __metadata: languageName: node linkType: hard -"css-what@npm:^3.2.1, css-what@npm:^5.0.0, css-what@npm:^5.0.1": +"css-what@npm:^3.2.1, css-what@npm:^5.0.1": version: 5.0.1 resolution: "css-what@npm:5.0.1" checksum: 7a3de33a1c130d32d711cce4e0fa747be7a9afe6b5f2c6f3d56bc2765f150f6034f5dd5fe263b9359a1c371c01847399602d74b55322c982742b336d998602cd languageName: node linkType: hard +"css-what@npm:^6.0.1": + version: 6.1.0 + resolution: "css-what@npm:6.1.0" + checksum: b975e547e1e90b79625918f84e67db5d33d896e6de846c9b584094e529f0c63e2ab85ee33b9daffd05bff3a146a1916bec664e18bb76dd5f66cbff9fc13b2bbe + languageName: node + linkType: hard + "css@npm:^2.0.0": version: 2.2.4 resolution: "css@npm:2.2.4" @@ -6346,7 +6514,29 @@ __metadata: languageName: node linkType: hard -"decamelize@npm:^1.1.1, decamelize@npm:^1.1.2, decamelize@npm:^1.2.0": +"debug@npm:^4.3.3": + version: 4.3.4 + resolution: "debug@npm:4.3.4" + dependencies: + ms: 2.1.2 + peerDependenciesMeta: + supports-color: + optional: true + checksum: 3dbad3f94ea64f34431a9cbf0bafb61853eda57bff2880036153438f50fb5a84f27683ba0d8e5426bf41a8c6ff03879488120cf5b3a761e77953169c0600a708 + languageName: node + linkType: hard + +"decamelize-keys@npm:^1.1.0": + version: 1.1.1 + resolution: "decamelize-keys@npm:1.1.1" + dependencies: + decamelize: ^1.1.0 + map-obj: ^1.0.0 + checksum: fc645fe20b7bda2680bbf9481a3477257a7f9304b1691036092b97ab04c0ab53e3bf9fcc2d2ae382536568e402ec41fb11e1d4c3836a9abe2d813dd9ef4311e0 + languageName: node + linkType: hard + +"decamelize@npm:^1.1.0, decamelize@npm:^1.1.1, decamelize@npm:^1.1.2, decamelize@npm:^1.2.0": version: 1.2.0 resolution: "decamelize@npm:1.2.0" checksum: ad8c51a7e7e0720c70ec2eeb1163b66da03e7616d7b98c9ef43cce2416395e84c1e9548dd94f5f6ffecfee9f8b94251fc57121a8b021f2ff2469b2bae247b8aa @@ -6354,9 +6544,9 @@ __metadata: linkType: hard "decode-uri-component@npm:^0.2.0": - version: 0.2.0 - resolution: "decode-uri-component@npm:0.2.0" - checksum: f3749344ab9305ffcfe4bfe300e2dbb61fc6359e2b736812100a3b1b6db0a5668cba31a05e4b45d4d63dbf1a18dfa354cd3ca5bb3ededddabb8cd293f4404f94 + version: 0.2.2 + resolution: "decode-uri-component@npm:0.2.2" + checksum: 95476a7d28f267292ce745eac3524a9079058bbb35767b76e3ee87d42e34cd0275d2eb19d9d08c3e167f97556e8a2872747f5e65cbebcac8b0c98d83e285f139 languageName: node linkType: hard @@ -6733,6 +6923,22 @@ __metadata: languageName: node linkType: hard +"domhandler@npm:^4.3.1": + version: 4.3.1 + resolution: "domhandler@npm:4.3.1" + dependencies: + domelementtype: ^2.2.0 + checksum: 4c665ceed016e1911bf7d1dadc09dc888090b64dee7851cccd2fcf5442747ec39c647bb1cb8c8919f8bbdd0f0c625a6bafeeed4b2d656bbecdbae893f43ffaaa + languageName: node + linkType: hard + +"dompurify@npm:^3.0.6": + version: 3.0.6 + resolution: "dompurify@npm:3.0.6" + checksum: e5c6cdc5fe972a9d0859d939f1d86320de275be00bbef7bd5591c80b1e538935f6ce236624459a1b0c84ecd7c6a1e248684aa4637512659fccc0ce7c353828a6 + languageName: node + linkType: hard + "domutils@npm:^1.7.0": version: 1.7.0 resolution: "domutils@npm:1.7.0" @@ -6743,7 +6949,7 @@ __metadata: languageName: node linkType: hard -"domutils@npm:^2.5.2, domutils@npm:^2.6.0, domutils@npm:^2.7.0": +"domutils@npm:^2.5.2, domutils@npm:^2.7.0": version: 2.7.0 resolution: "domutils@npm:2.7.0" dependencies: @@ -6754,6 +6960,17 @@ __metadata: languageName: node linkType: hard +"domutils@npm:^2.8.0": + version: 2.8.0 + resolution: "domutils@npm:2.8.0" + dependencies: + dom-serializer: ^1.0.1 + domelementtype: ^2.2.0 + domhandler: ^4.2.0 + checksum: abf7434315283e9aadc2a24bac0e00eab07ae4313b40cc239f89d84d7315ebdfd2fb1b5bf750a96bc1b4403d7237c7b2ebf60459be394d625ead4ca89b934391 + languageName: node + linkType: hard + "dot-case@npm:^3.0.4": version: 3.0.4 resolution: "dot-case@npm:3.0.4" @@ -6909,13 +7126,20 @@ __metadata: languageName: node linkType: hard -"electron-to-chromium@npm:^1.3.378, electron-to-chromium@npm:^1.3.723": +"electron-to-chromium@npm:^1.3.378": version: 1.3.758 resolution: "electron-to-chromium@npm:1.3.758" checksum: 2fec13dcdd1b24a2314d309566bd08c7f0ce383787e64ea43c14a7fc2a11c8a76fdb9a56ce7a1da6137e1ef46365f999d10c656f2fb6b9ff792ea3ae808ebb86 languageName: node linkType: hard +"electron-to-chromium@npm:^1.4.535": + version: 1.4.540 + resolution: "electron-to-chromium@npm:1.4.540" + checksum: 78a48690a5cca3f89544d4e33a11e3101adb0b220da64078f67e167b396cbcd85044853cb88a9453444796599fe157c190ca5ebd00e9daf668ed5a9df3d0bba8 + languageName: node + linkType: hard + "elegant-spinner@npm:^1.0.1": version: 1.0.1 resolution: "elegant-spinner@npm:1.0.1" @@ -6973,7 +7197,7 @@ __metadata: languageName: node linkType: hard -"encoding@npm:^0.1.11, encoding@npm:^0.1.13": +"encoding@npm:^0.1.11, encoding@npm:^0.1.12, encoding@npm:^0.1.13": version: 0.1.13 resolution: "encoding@npm:0.1.13" dependencies: @@ -7192,13 +7416,6 @@ __metadata: languageName: node linkType: hard -"es6-promise@npm:~3.0.2": - version: 3.0.2 - resolution: "es6-promise@npm:3.0.2" - checksum: f9d6cabf3fa5cff33ddd9791c190b4ae83f372489b62c81d5c19dc10afd2e59736a31e20994f80fc54151c39c00ccc493b11b5b9dfc5e605eff597f239650da5 - languageName: node - linkType: hard - "es6-symbol@npm:^3.1.1, es6-symbol@npm:~3.1.3": version: 3.1.3 resolution: "es6-symbol@npm:3.1.3" @@ -7584,11 +7801,9 @@ __metadata: linkType: hard "eventsource@npm:^1.0.7": - version: 1.1.0 - resolution: "eventsource@npm:1.1.0" - dependencies: - original: ^1.0.0 - checksum: 78338b7e75ec471cb793efb3319e0c4d2bf00fb638a2e3f888ad6d98cd1e3d4492a29f554c0921c7b2ac5130c3a732a1a0056739f6e2f548d714aec685e5da7e + version: 1.1.2 + resolution: "eventsource@npm:1.1.2" + checksum: fe8f2ac3c70b1b63ee3cef5c0a28680cb00b5747bfda1d9835695fab3ed602be41c5c799b1fc997b34b02633573fead25b12b036bdf5212f23a6aa9f59212e9b languageName: node linkType: hard @@ -7843,17 +8058,16 @@ __metadata: languageName: node linkType: hard -"fast-glob@npm:^3.1.1": - version: 3.2.5 - resolution: "fast-glob@npm:3.2.5" +"fast-glob@npm:^3.2.9": + version: 3.3.1 + resolution: "fast-glob@npm:3.3.1" dependencies: "@nodelib/fs.stat": ^2.0.2 "@nodelib/fs.walk": ^1.2.3 - glob-parent: ^5.1.0 + glob-parent: ^5.1.2 merge2: ^1.3.0 - micromatch: ^4.0.2 - picomatch: ^2.2.1 - checksum: 5d6772c9b63dbb739d60b5630851e1f2cbf9744119e0968eac44c9f8cbc2d3d5cb4f2f0c74715ccb23daa336c87bea42186ed367e6c991afee61cd3d967320eb + micromatch: ^4.0.4 + checksum: b6f3add6403e02cf3a798bfbb1183d0f6da2afd368f27456010c0bc1f9640aea308243d4cb2c0ab142f618276e65ecb8be1661d7c62a7b4e5ba774b9ce5432e5 languageName: node linkType: hard @@ -7915,8 +8129,8 @@ __metadata: linkType: hard "fbjs@npm:^0.8.1": - version: 0.8.17 - resolution: "fbjs@npm:0.8.17" + version: 0.8.18 + resolution: "fbjs@npm:0.8.18" dependencies: core-js: ^1.0.0 isomorphic-fetch: ^2.1.1 @@ -7924,8 +8138,8 @@ __metadata: object-assign: ^4.1.0 promise: ^7.1.1 setimmediate: ^1.0.5 - ua-parser-js: ^0.7.18 - checksum: e969aeb175ccf97d8818aab9907a78f253568e0cc1b8762621c5d235bf031419d7e700f16f7711e89dfd1e0fce2b87a05f8a2800f18df0a96258f0780615fd8b + ua-parser-js: ^0.7.30 + checksum: 668731b946a765908c9cbe51d5160f973abb78004b3d122587c3e930e3e1ddcc0ce2b17f2a8637dc9d733e149aa580f8d3035a35cc2d3bc78b78f1b19aab90e2 languageName: node linkType: hard @@ -8100,7 +8314,7 @@ __metadata: languageName: node linkType: hard -"find-up@npm:4.1.0, find-up@npm:^4.0.0": +"find-up@npm:4.1.0, find-up@npm:^4.0.0, find-up@npm:^4.1.0": version: 4.1.0 resolution: "find-up@npm:4.1.0" dependencies: @@ -8173,13 +8387,13 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.10.0": - version: 1.14.1 - resolution: "follow-redirects@npm:1.14.1" +"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.14.0": + version: 1.15.3 + resolution: "follow-redirects@npm:1.15.3" peerDependenciesMeta: debug: optional: true - checksum: 7381a55bdc6951c5c1ab73a8da99d9fa4c0496ce72dba92cd2ac2babe0e3ebde9b81c5bca889498ad95984bc773d713284ca2bb17f1b1e1416e5f6531e39a488 + checksum: 584da22ec5420c837bd096559ebfb8fe69d82512d5585004e36a3b4a6ef6d5905780e0c74508c7b72f907d1fa2b7bd339e613859e9c304d0dc96af2027fd0231 languageName: node linkType: hard @@ -8327,7 +8541,7 @@ __metadata: languageName: node linkType: hard -"fs-minipass@npm:^2.0.0": +"fs-minipass@npm:^2.0.0, fs-minipass@npm:^2.1.0": version: 2.1.0 resolution: "fs-minipass@npm:2.1.0" dependencies: @@ -8414,7 +8628,7 @@ __metadata: languageName: node linkType: hard -"fstream@npm:1.0.12, fstream@npm:^1.0.0, fstream@npm:^1.0.12": +"fstream@npm:1.0.12": version: 1.0.12 resolution: "fstream@npm:1.0.12" dependencies: @@ -8459,6 +8673,23 @@ __metadata: languageName: node linkType: hard +"gauge@npm:^3.0.0": + version: 3.0.2 + resolution: "gauge@npm:3.0.2" + dependencies: + aproba: ^1.0.3 || ^2.0.0 + color-support: ^1.1.2 + console-control-strings: ^1.0.0 + has-unicode: ^2.0.1 + object-assign: ^4.1.1 + signal-exit: ^3.0.0 + string-width: ^4.2.3 + strip-ansi: ^6.0.1 + wide-align: ^1.1.2 + checksum: 81296c00c7410cdd48f997800155fbead4f32e4f82109be0719c63edc8560e6579946cc8abd04205297640691ec26d21b578837fd13a4e96288ab4b40b1dc3e9 + languageName: node + linkType: hard + "gauge@npm:^4.0.0": version: 4.0.2 resolution: "gauge@npm:4.0.2" @@ -8476,22 +8707,6 @@ __metadata: languageName: node linkType: hard -"gauge@npm:~2.7.3": - version: 2.7.4 - resolution: "gauge@npm:2.7.4" - dependencies: - aproba: ^1.0.3 - console-control-strings: ^1.0.0 - has-unicode: ^2.0.0 - object-assign: ^4.1.0 - signal-exit: ^3.0.0 - string-width: ^1.0.1 - strip-ansi: ^3.0.1 - wide-align: ^1.1.0 - checksum: a89b53cee65579b46832e050b5f3a79a832cc422c190de79c6b8e2e15296ab92faddde6ddf2d376875cbba2b043efa99b9e1ed8124e7365f61b04e3cee9d40ee - languageName: node - linkType: hard - "gaze@npm:^1.0.0": version: 1.1.3 resolution: "gaze@npm:1.1.3" @@ -8600,7 +8815,7 @@ __metadata: languageName: node linkType: hard -"glob-parent@npm:^5.0.0, glob-parent@npm:^5.1.0, glob-parent@npm:~5.1.2": +"glob-parent@npm:^5.0.0, glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" dependencies: @@ -8630,6 +8845,19 @@ __metadata: languageName: node linkType: hard +"glob@npm:^8.0.1": + version: 8.1.0 + resolution: "glob@npm:8.1.0" + dependencies: + fs.realpath: ^1.0.0 + inflight: ^1.0.4 + inherits: 2 + minimatch: ^5.0.1 + once: ^1.3.0 + checksum: 92fbea3221a7d12075f26f0227abac435de868dd0736a17170663783296d0dd8d3d532a5672b4488a439bf5d7fb85cdd07c11185d6cd39184f0385cbdfb86a47 + languageName: node + linkType: hard + "global-dirs@npm:^2.0.1": version: 2.1.0 resolution: "global-dirs@npm:2.1.0" @@ -8698,16 +8926,16 @@ __metadata: linkType: hard "globby@npm:^11.0.3": - version: 11.0.4 - resolution: "globby@npm:11.0.4" + version: 11.1.0 + resolution: "globby@npm:11.1.0" dependencies: array-union: ^2.1.0 dir-glob: ^3.0.1 - fast-glob: ^3.1.1 - ignore: ^5.1.4 - merge2: ^1.3.0 + fast-glob: ^3.2.9 + ignore: ^5.2.0 + merge2: ^1.4.1 slash: ^3.0.0 - checksum: d3e02d5e459e02ffa578b45f040381c33e3c0538ed99b958f0809230c423337999867d7b0dbf752ce93c46157d3bbf154d3fff988a93ccaeb627df8e1841775b + checksum: b4be8885e0cfa018fc783792942d53926c35c50b3aefd3fdcfb9d22c627639dc26bd2327a40a0b74b074100ce95bb7187bfeae2f236856aa3de183af7a02aea6 languageName: node linkType: hard @@ -8790,6 +9018,13 @@ __metadata: languageName: node linkType: hard +"hard-rejection@npm:^2.1.0": + version: 2.1.0 + resolution: "hard-rejection@npm:2.1.0" + checksum: 7baaf80a0c7fff4ca79687b4060113f1529589852152fa935e6787a2bc96211e784ad4588fb3048136ff8ffc9dfcf3ae385314a5b24db32de20bea0d1597f9dc + languageName: node + linkType: hard + "harmony-reflect@npm:^1.4.6": version: 1.6.2 resolution: "harmony-reflect@npm:1.6.2" @@ -8834,7 +9069,7 @@ __metadata: languageName: node linkType: hard -"has-unicode@npm:^2.0.0, has-unicode@npm:^2.0.1": +"has-unicode@npm:^2.0.1": version: 2.0.1 resolution: "has-unicode@npm:2.0.1" checksum: 1eab07a7436512db0be40a710b29b5dc21fa04880b7f63c9980b706683127e3c1b57cb80ea96d47991bdae2dfe479604f6a1ba410106ee1046a41d1bd0814400 @@ -8991,6 +9226,15 @@ __metadata: languageName: node linkType: hard +"hosted-git-info@npm:^4.0.1": + version: 4.1.0 + resolution: "hosted-git-info@npm:4.1.0" + dependencies: + lru-cache: ^6.0.0 + checksum: c3f87b3c2f7eb8c2748c8f49c0c2517c9a95f35d26f4bf54b2a8cba05d2e668f3753548b6ea366b18ec8dadb4e12066e19fa382a01496b0ffa0497eb23cbe461 + languageName: node + linkType: hard + "hpack.js@npm:^2.1.6": version: 2.1.6 resolution: "hpack.js@npm:2.1.6" @@ -9096,9 +9340,9 @@ __metadata: linkType: hard "http-cache-semantics@npm:^4.1.0": - version: 4.1.0 - resolution: "http-cache-semantics@npm:4.1.0" - checksum: 974de94a81c5474be07f269f9fd8383e92ebb5a448208223bfb39e172a9dbc26feff250192ecc23b9593b3f92098e010406b0f24bd4d588d631f80214648ed42 + version: 4.1.1 + resolution: "http-cache-semantics@npm:4.1.1" + checksum: 83ac0bc60b17a3a36f9953e7be55e5c8f41acc61b22583060e8dedc9dd5e3607c823a88d0926f9150e571f90946835c7fe150732801010845c72cd8bbff1a236 languageName: node linkType: hard @@ -9154,6 +9398,17 @@ __metadata: languageName: node linkType: hard +"http-proxy-agent@npm:^4.0.1": + version: 4.0.1 + resolution: "http-proxy-agent@npm:4.0.1" + dependencies: + "@tootallnate/once": 1 + agent-base: 6 + debug: 4 + checksum: c6a5da5a1929416b6bbdf77b1aca13888013fe7eb9d59fc292e25d18e041bb154a8dfada58e223fc7b76b9b2d155a87e92e608235201f77d34aa258707963a82 + languageName: node + linkType: hard + "http-proxy-agent@npm:^5.0.0": version: 5.0.0 resolution: "http-proxy-agent@npm:5.0.0" @@ -9303,10 +9558,10 @@ __metadata: languageName: node linkType: hard -"ignore@npm:^5.1.4": - version: 5.1.8 - resolution: "ignore@npm:5.1.8" - checksum: 967abadb61e2cb0e5c5e8c4e1686ab926f91bc1a4680d994b91947d3c65d04c3ae126dcdf67f08e0feeb8ff8407d453e641aeeddcc47a3a3cca359f283cf6121 +"ignore@npm:^5.2.0": + version: 5.2.4 + resolution: "ignore@npm:5.2.4" + checksum: 3d4c309c6006e2621659311783eaea7ebcd41fe4ca1d78c91c473157ad6666a57a2df790fe0d07a12300d9aac2888204d7be8d59f9aaf665b1c7fcdb432517ef languageName: node linkType: hard @@ -9402,18 +9657,6 @@ __metadata: languageName: node linkType: hard -"in-publish@npm:^2.0.0": - version: 2.0.1 - resolution: "in-publish@npm:2.0.1" - bin: - in-install: in-install.js - in-publish: in-publish.js - not-in-install: not-in-install.js - not-in-publish: not-in-publish.js - checksum: 5efde2992a1e76550614a5a2c51f53669d9f3ee3a11d364de22b0c77c41de0b87c52c4c9b04375eaa276761b1944dd2b166323894d2344192328ffe85927ad38 - languageName: node - linkType: hard - "indefinite-observable@npm:^1.0.1": version: 1.0.2 resolution: "indefinite-observable@npm:1.0.2" @@ -9598,6 +9841,13 @@ __metadata: languageName: node linkType: hard +"ip@npm:^2.0.0": + version: 2.0.0 + resolution: "ip@npm:2.0.0" + checksum: cfcfac6b873b701996d71ec82a7dd27ba92450afdb421e356f44044ed688df04567344c36cbacea7d01b1c39a4c732dc012570ebe9bebfb06f27314bca625349 + languageName: node + linkType: hard + "ipaddr.js@npm:1.9.1, ipaddr.js@npm:^1.9.0": version: 1.9.1 resolution: "ipaddr.js@npm:1.9.1" @@ -9742,6 +9992,15 @@ __metadata: languageName: node linkType: hard +"is-core-module@npm:^2.5.0": + version: 2.13.0 + resolution: "is-core-module@npm:2.13.0" + dependencies: + has: ^1.0.3 + checksum: 053ab101fb390bfeb2333360fd131387bed54e476b26860dc7f5a700bbf34a0ec4454f7c8c4d43e8a0030957e4b3db6e16d35e1890ea6fb654c833095e040355 + languageName: node + linkType: hard + "is-data-descriptor@npm:^0.1.4": version: 0.1.4 resolution: "is-data-descriptor@npm:0.1.4" @@ -10001,7 +10260,7 @@ __metadata: languageName: node linkType: hard -"is-plain-obj@npm:^1.0.0": +"is-plain-obj@npm:^1.0.0, is-plain-obj@npm:^1.1.0": version: 1.1.0 resolution: "is-plain-obj@npm:1.1.0" checksum: 0ee04807797aad50859652a7467481816cbb57e5cc97d813a7dcd8915da8195dc68c436010bf39d195226cde6a2d352f4b815f16f26b7bf486a5754290629931 @@ -10733,7 +10992,7 @@ __metadata: languageName: node linkType: hard -"js-base64@npm:^2.1.8": +"js-base64@npm:^2.1.8, js-base64@npm:^2.4.9": version: 2.6.4 resolution: "js-base64@npm:2.6.4" checksum: 5f4084078d6c46f8529741d110df84b14fac3276b903760c21fa8cc8521370d607325dfe1c1a9fbbeaae1ff8e602665aaeef1362427d8fef704f9e3659472ce8 @@ -10901,10 +11160,10 @@ __metadata: languageName: node linkType: hard -"json-schema@npm:0.2.3": - version: 0.2.3 - resolution: "json-schema@npm:0.2.3" - checksum: bbc2070988fb5f2a2266a31b956f1b5660e03ea7eaa95b33402901274f625feb586ae0c485e1df854fde40a7f0dc679f3b3ca8e5b8d31f8ea07a0d834de785c7 +"json-schema@npm:0.4.0": + version: 0.4.0 + resolution: "json-schema@npm:0.4.0" + checksum: 66389434c3469e698da0df2e7ac5a3281bcff75e797a5c127db7c5b56270e01ae13d9afa3c03344f76e32e81678337a8c912bdbb75101c62e487dc3778461d72 languageName: node linkType: hard @@ -10948,24 +11207,22 @@ __metadata: linkType: hard "json5@npm:^1.0.1": - version: 1.0.1 - resolution: "json5@npm:1.0.1" + version: 1.0.2 + resolution: "json5@npm:1.0.2" dependencies: minimist: ^1.2.0 bin: json5: lib/cli.js - checksum: e76ea23dbb8fc1348c143da628134a98adf4c5a4e8ea2adaa74a80c455fc2cdf0e2e13e6398ef819bfe92306b610ebb2002668ed9fc1af386d593691ef346fc3 + checksum: 866458a8c58a95a49bef3adba929c625e82532bcff1fe93f01d29cb02cac7c3fe1f4b79951b7792c2da9de0b32871a8401a6e3c5b36778ad852bf5b8a61165d7 languageName: node linkType: hard "json5@npm:^2.1.2": - version: 2.2.0 - resolution: "json5@npm:2.2.0" - dependencies: - minimist: ^1.2.5 + version: 2.2.3 + resolution: "json5@npm:2.2.3" bin: json5: lib/cli.js - checksum: e88fc5274bb58fc99547baa777886b069d2dd96d9cfc4490b305fd16d711dabd5979e35a4f90873cefbeb552e216b041a304fe56702bedba76e19bc7845f208d + checksum: 2a7436a93393830bce797d4626275152e37e877b265e94ca69c99e3d20c2b9dab021279146a39cdb700e71b2dd32a4cebd1514cd57cee102b1af906ce5040349 languageName: node linkType: hard @@ -11002,14 +11259,14 @@ __metadata: linkType: hard "jsprim@npm:^1.2.2": - version: 1.4.1 - resolution: "jsprim@npm:1.4.1" + version: 1.4.2 + resolution: "jsprim@npm:1.4.2" dependencies: assert-plus: 1.0.0 extsprintf: 1.3.0 - json-schema: 0.2.3 + json-schema: 0.4.0 verror: 1.10.0 - checksum: 6bcb20ec265ae18bb48e540a6da2c65f9c844f7522712d6dfcb01039527a49414816f4869000493363f1e1ea96cbad00e46188d5ecc78257a19f152467587373 + checksum: 2ad1b9fdcccae8b3d580fa6ced25de930eaa1ad154db21bbf8478a4d30bbbec7925b5f5ff29b933fba9412b16a17bd484a8da4fdb3663b5e27af95dd693bab2a languageName: node linkType: hard @@ -11101,16 +11358,15 @@ __metadata: languageName: node linkType: hard -"jszip@npm:3.1.5": - version: 3.1.5 - resolution: "jszip@npm:3.1.5" +"jszip@npm:^3.10.1": + version: 3.10.1 + resolution: "jszip@npm:3.10.1" dependencies: - core-js: ~2.3.0 - es6-promise: ~3.0.2 - lie: ~3.1.0 + lie: ~3.3.0 pako: ~1.0.2 - readable-stream: ~2.0.6 - checksum: 2d0464089d7a4604c7b7586d089b7aa39fbcfe7cc058f7c066b3c92b43f3b94f69362d1b6dd8252049f5729e1fc452a788703382cbce6d77f607d3ce1227b231 + readable-stream: ~2.3.6 + setimmediate: ^1.0.5 + checksum: abc77bfbe33e691d4d1ac9c74c8851b5761fba6a6986630864f98d876f3fcc2d36817dfc183779f32c00157b5d53a016796677298272a714ae096dfe6b1c8b60 languageName: node linkType: hard @@ -11162,7 +11418,7 @@ __metadata: languageName: node linkType: hard -"kind-of@npm:^6.0.0, kind-of@npm:^6.0.2": +"kind-of@npm:^6.0.0, kind-of@npm:^6.0.2, kind-of@npm:^6.0.3": version: 6.0.3 resolution: "kind-of@npm:6.0.3" checksum: 3ab01e7b1d440b22fe4c31f23d8d38b4d9b91d9f291df683476576493d5dfd2e03848a8b05813dd0c3f0e835bc63f433007ddeceb71f05cb25c45ae1b19c6d3b @@ -11249,12 +11505,12 @@ __metadata: languageName: node linkType: hard -"lie@npm:~3.1.0": - version: 3.1.1 - resolution: "lie@npm:3.1.1" +"lie@npm:~3.3.0": + version: 3.3.0 + resolution: "lie@npm:3.3.0" dependencies: immediate: ~3.0.5 - checksum: 6da9f2121d2dbd15f1eca44c0c7e211e66a99c7b326ec8312645f3648935bc3a658cf0e9fa7b5f10144d9e2641500b4f55bd32754607c3de945b5f443e50ddd1 + checksum: 33102302cf19766f97919a6a98d481e01393288b17a6aa1f030a3542031df42736edde8dab29ffdbf90bebeffc48c761eb1d064dc77592ca3ba3556f9fe6d2a8 languageName: node linkType: hard @@ -11385,24 +11641,24 @@ __metadata: linkType: hard "loader-utils@npm:^1.1.0, loader-utils@npm:^1.2.3, loader-utils@npm:^1.4.0": - version: 1.4.0 - resolution: "loader-utils@npm:1.4.0" + version: 1.4.2 + resolution: "loader-utils@npm:1.4.2" dependencies: big.js: ^5.2.2 emojis-list: ^3.0.0 json5: ^1.0.1 - checksum: d150b15e7a42ac47d935c8b484b79e44ff6ab4c75df7cc4cb9093350cf014ec0b17bdb60c5d6f91a37b8b218bd63b973e263c65944f58ca2573e402b9a27e717 + checksum: eb6fb622efc0ffd1abdf68a2022f9eac62bef8ec599cf8adb75e94d1d338381780be6278534170e99edc03380a6d29bc7eb1563c89ce17c5fed3a0b17f1ad804 languageName: node linkType: hard "loader-utils@npm:^2.0.0": - version: 2.0.0 - resolution: "loader-utils@npm:2.0.0" + version: 2.0.4 + resolution: "loader-utils@npm:2.0.4" dependencies: big.js: ^5.2.2 emojis-list: ^3.0.0 json5: ^2.1.2 - checksum: 6856423131b50b6f5f259da36f498cfd7fc3c3f8bb17777cf87fdd9159e797d4ba4288d9a96415fd8da62c2906960e88f74711dee72d03a9003bddcd0d364a51 + checksum: a5281f5fff1eaa310ad5e1164095689443630f3411e927f95031ab4fb83b4a98f388185bb1fe949e8ab8d4247004336a625e9255c22122b815bb9a4c5d8fc3b7 languageName: node linkType: hard @@ -11435,14 +11691,7 @@ __metadata: languageName: node linkType: hard -"lodash-es@npm:4.17.14": - version: 4.17.14 - resolution: "lodash-es@npm:4.17.14" - checksum: 56d39dc8e76ac366eae79d4e8d7c19bd2f8981b640a46942bf2d88fa871b2e083e48fe2b895c84ed139e13c0b466cac22ea27d7394be04f2ba62c518392c39be - languageName: node - linkType: hard - -"lodash-es@npm:^4.17.10, lodash-es@npm:^4.17.5, lodash-es@npm:^4.2.1": +"lodash-es@npm:^4.17.10, lodash-es@npm:^4.17.21, lodash-es@npm:^4.17.5, lodash-es@npm:^4.2.1": version: 4.17.21 resolution: "lodash-es@npm:4.17.21" checksum: 05cbffad6e2adbb331a4e16fbd826e7faee403a1a04873b82b42c0f22090f280839f85b95393f487c1303c8a3d2a010048bf06151a6cbe03eee4d388fb0a12d2 @@ -11635,16 +11884,6 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^4.0.1": - version: 4.1.5 - resolution: "lru-cache@npm:4.1.5" - dependencies: - pseudomap: ^1.0.2 - yallist: ^2.1.2 - checksum: 4bb4b58a36cd7dc4dcec74cbe6a8f766a38b7426f1ff59d4cf7d82a2aa9b9565cd1cb98f6ff60ce5cd174524868d7bc9b7b1c294371851356066ca9ac4cf135a - languageName: node - linkType: hard - "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1" @@ -11670,6 +11909,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^7.7.1": + version: 7.18.3 + resolution: "lru-cache@npm:7.18.3" + checksum: e550d772384709deea3f141af34b6d4fa392e2e418c1498c078de0ee63670f1f46f5eee746e8ef7e69e1c895af0d4224e62ee33e66a543a14763b0f2e74c1356 + languageName: node + linkType: hard + "make-dir@npm:^2.0.0, make-dir@npm:^2.1.0": version: 2.1.0 resolution: "make-dir@npm:2.1.0" @@ -11713,6 +11959,54 @@ __metadata: languageName: node linkType: hard +"make-fetch-happen@npm:^10.0.4": + version: 10.2.1 + resolution: "make-fetch-happen@npm:10.2.1" + dependencies: + agentkeepalive: ^4.2.1 + cacache: ^16.1.0 + http-cache-semantics: ^4.1.0 + http-proxy-agent: ^5.0.0 + https-proxy-agent: ^5.0.0 + is-lambda: ^1.0.1 + lru-cache: ^7.7.1 + minipass: ^3.1.6 + minipass-collect: ^1.0.2 + minipass-fetch: ^2.0.3 + minipass-flush: ^1.0.5 + minipass-pipeline: ^1.2.4 + negotiator: ^0.6.3 + promise-retry: ^2.0.1 + socks-proxy-agent: ^7.0.0 + ssri: ^9.0.0 + checksum: 2332eb9a8ec96f1ffeeea56ccefabcb4193693597b132cd110734d50f2928842e22b84cfa1508e921b8385cdfd06dda9ad68645fed62b50fff629a580f5fb72c + languageName: node + linkType: hard + +"make-fetch-happen@npm:^9.1.0": + version: 9.1.0 + resolution: "make-fetch-happen@npm:9.1.0" + dependencies: + agentkeepalive: ^4.1.3 + cacache: ^15.2.0 + http-cache-semantics: ^4.1.0 + http-proxy-agent: ^4.0.1 + https-proxy-agent: ^5.0.0 + is-lambda: ^1.0.1 + lru-cache: ^6.0.0 + minipass: ^3.1.3 + minipass-collect: ^1.0.2 + minipass-fetch: ^1.3.2 + minipass-flush: ^1.0.5 + minipass-pipeline: ^1.2.4 + negotiator: ^0.6.2 + promise-retry: ^2.0.1 + socks-proxy-agent: ^6.0.0 + ssri: ^8.0.0 + checksum: 0eb371c85fdd0b1584fcfdf3dc3c62395761b3c14658be02620c310305a9a7ecf1617a5e6fb30c1d081c5c8aaf177fa133ee225024313afabb7aa6a10f1e3d04 + languageName: node + linkType: hard + "makeerror@npm:1.0.x": version: 1.0.11 resolution: "makeerror@npm:1.0.11" @@ -11752,6 +12046,13 @@ __metadata: languageName: node linkType: hard +"map-obj@npm:^4.0.0": + version: 4.3.0 + resolution: "map-obj@npm:4.3.0" + checksum: fbc554934d1a27a1910e842bc87b177b1a556609dd803747c85ece420692380827c6ae94a95cce4407c054fa0964be3bf8226f7f2cb2e9eeee432c7c1985684e + languageName: node + linkType: hard + "map-visit@npm:^1.0.0": version: 1.0.0 resolution: "map-visit@npm:1.0.0" @@ -11875,6 +12176,26 @@ __metadata: languageName: node linkType: hard +"meow@npm:^9.0.0": + version: 9.0.0 + resolution: "meow@npm:9.0.0" + dependencies: + "@types/minimist": ^1.2.0 + camelcase-keys: ^6.2.2 + decamelize: ^1.2.0 + decamelize-keys: ^1.1.0 + hard-rejection: ^2.1.0 + minimist-options: 4.1.0 + normalize-package-data: ^3.0.0 + read-pkg-up: ^7.0.1 + redent: ^3.0.0 + trim-newlines: ^3.0.0 + type-fest: ^0.18.0 + yargs-parser: ^20.2.3 + checksum: 99799c47247f4daeee178e3124f6ef6f84bde2ba3f37652865d5d8f8b8adcf9eedfc551dd043e2455cd8206545fd848e269c0c5ab6b594680a0ad4d3617c9639 + languageName: node + linkType: hard + "merge-deep@npm:^3.0.2": version: 3.0.3 resolution: "merge-deep@npm:3.0.3" @@ -11900,7 +12221,7 @@ __metadata: languageName: node linkType: hard -"merge2@npm:^1.2.3, merge2@npm:^1.3.0": +"merge2@npm:^1.2.3, merge2@npm:^1.3.0, merge2@npm:^1.4.1": version: 1.4.1 resolution: "merge2@npm:1.4.1" checksum: 7268db63ed5169466540b6fb947aec313200bcf6d40c5ab722c22e242f651994619bcd85601602972d3c85bd2cc45a358a4c61937e9f11a061919a1da569b0c2 @@ -11942,13 +12263,13 @@ __metadata: languageName: node linkType: hard -"micromatch@npm:^4.0.2": - version: 4.0.4 - resolution: "micromatch@npm:4.0.4" +"micromatch@npm:^4.0.4": + version: 4.0.5 + resolution: "micromatch@npm:4.0.5" dependencies: - braces: ^3.0.1 - picomatch: ^2.2.3 - checksum: ef3d1c88e79e0a68b0e94a03137676f3324ac18a908c245a9e5936f838079fcc108ac7170a5fadc265a9c2596963462e402841406bda1a4bb7b68805601d631c + braces: ^3.0.2 + picomatch: ^2.3.1 + checksum: 02a17b671c06e8fefeeb6ef996119c1e597c942e632a21ef589154f23898c9c6a9858526246abb14f8bca6e77734aa9dcf65476fca47cedfb80d9577d52843fc languageName: node linkType: hard @@ -11998,6 +12319,15 @@ __metadata: languageName: node linkType: hard +"mime@npm:^3.0.0": + version: 3.0.0 + resolution: "mime@npm:3.0.0" + bin: + mime: cli.js + checksum: f43f9b7bfa64534e6b05bd6062961681aeb406a5b53673b53b683f27fcc4e739989941836a355eef831f4478923651ecc739f4a5f6e20a76487b432bfd4db928 + languageName: node + linkType: hard + "mimic-fn@npm:^1.0.0": version: 1.2.0 resolution: "mimic-fn@npm:1.2.0" @@ -12012,6 +12342,13 @@ __metadata: languageName: node linkType: hard +"min-indent@npm:^1.0.0": + version: 1.0.1 + resolution: "min-indent@npm:1.0.1" + checksum: bfc6dd03c5eaf623a4963ebd94d087f6f4bbbfd8c41329a7f09706b0cb66969c4ddd336abeb587bc44bc6f08e13bf90f0b374f9d71f9f01e04adc2cd6f083ef1 + languageName: node + linkType: hard + "mini-css-extract-plugin@npm:0.9.0": version: 0.9.0 resolution: "mini-css-extract-plugin@npm:0.9.0" @@ -12040,7 +12377,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:3.0.4, minimatch@npm:^3.0.4, minimatch@npm:~3.0.2": +"minimatch@npm:3.0.4": version: 3.0.4 resolution: "minimatch@npm:3.0.4" dependencies: @@ -12049,10 +12386,48 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^3.0.4": + version: 3.1.2 + resolution: "minimatch@npm:3.1.2" + dependencies: + brace-expansion: ^1.1.7 + checksum: c154e566406683e7bcb746e000b84d74465b3a832c45d59912b9b55cd50dee66e5c4b1e5566dba26154040e51672f9aa450a9aef0c97cfc7336b78b7afb9540a + languageName: node + linkType: hard + +"minimatch@npm:^5.0.1": + version: 5.1.6 + resolution: "minimatch@npm:5.1.6" + dependencies: + brace-expansion: ^2.0.1 + checksum: 7564208ef81d7065a370f788d337cd80a689e981042cb9a1d0e6580b6c6a8c9279eba80010516e258835a988363f99f54a6f711a315089b8b42694f5da9d0d77 + languageName: node + linkType: hard + +"minimatch@npm:~3.0.2": + version: 3.0.8 + resolution: "minimatch@npm:3.0.8" + dependencies: + brace-expansion: ^1.1.7 + checksum: 850cca179cad715133132693e6963b0db64ab0988c4d211415b087fc23a3e46321e2c5376a01bf5623d8782aba8bdf43c571e2e902e51fdce7175c7215c29f8b + languageName: node + linkType: hard + +"minimist-options@npm:4.1.0": + version: 4.1.0 + resolution: "minimist-options@npm:4.1.0" + dependencies: + arrify: ^1.0.1 + is-plain-obj: ^1.1.0 + kind-of: ^6.0.3 + checksum: 8c040b3068811e79de1140ca2b708d3e203c8003eb9a414c1ab3cd467fc5f17c9ca02a5aef23bedc51a7f8bfbe77f87e9a7e31ec81fba304cda675b019496f4e + languageName: node + linkType: hard + "minimist@npm:^1.1.1, minimist@npm:^1.1.3, minimist@npm:^1.2.0, minimist@npm:^1.2.5": - version: 1.2.5 - resolution: "minimist@npm:1.2.5" - checksum: 86706ce5b36c16bfc35c5fe3dbb01d5acdc9a22f2b6cc810b6680656a1d2c0e44a0159c9a3ba51fb072bb5c203e49e10b51dcd0eec39c481f4c42086719bae52 + version: 1.2.8 + resolution: "minimist@npm:1.2.8" + checksum: 75a6d645fb122dad29c06a7597bddea977258957ed88d7a6df59b5cd3fe4a527e253e9bbf2e783e4b73657f9098b96a5fe96ab8a113655d4109108577ecf85b0 languageName: node linkType: hard @@ -12065,6 +12440,21 @@ __metadata: languageName: node linkType: hard +"minipass-fetch@npm:^1.3.2": + version: 1.4.1 + resolution: "minipass-fetch@npm:1.4.1" + dependencies: + encoding: ^0.1.12 + minipass: ^3.1.0 + minipass-sized: ^1.0.3 + minizlib: ^2.0.0 + dependenciesMeta: + encoding: + optional: true + checksum: ec93697bdb62129c4e6c0104138e681e30efef8c15d9429dd172f776f83898471bc76521b539ff913248cc2aa6d2b37b652c993504a51cc53282563640f29216 + languageName: node + linkType: hard + "minipass-fetch@npm:^2.0.2": version: 2.0.3 resolution: "minipass-fetch@npm:2.0.3" @@ -12080,6 +12470,21 @@ __metadata: languageName: node linkType: hard +"minipass-fetch@npm:^2.0.3": + version: 2.1.2 + resolution: "minipass-fetch@npm:2.1.2" + dependencies: + encoding: ^0.1.13 + minipass: ^3.1.6 + minipass-sized: ^1.0.3 + minizlib: ^2.1.2 + dependenciesMeta: + encoding: + optional: true + checksum: 3f216be79164e915fc91210cea1850e488793c740534985da017a4cbc7a5ff50506956d0f73bb0cb60e4fe91be08b6b61ef35101706d3ef5da2c8709b5f08f91 + languageName: node + linkType: hard + "minipass-flush@npm:^1.0.5": version: 1.0.5 resolution: "minipass-flush@npm:1.0.5" @@ -12116,6 +12521,15 @@ __metadata: languageName: node linkType: hard +"minipass@npm:^3.1.0, minipass@npm:^3.1.3": + version: 3.3.6 + resolution: "minipass@npm:3.3.6" + dependencies: + yallist: ^4.0.0 + checksum: a30d083c8054cee83cdcdc97f97e4641a3f58ae743970457b1489ce38ee1167b3aaf7d815cd39ec7a99b9c40397fd4f686e83750e73e652b21cb516f6d845e48 + languageName: node + linkType: hard + "minipass@npm:^3.1.6": version: 3.1.6 resolution: "minipass@npm:3.1.6" @@ -12125,7 +12539,14 @@ __metadata: languageName: node linkType: hard -"minizlib@npm:^2.1.1, minizlib@npm:^2.1.2": +"minipass@npm:^5.0.0": + version: 5.0.0 + resolution: "minipass@npm:5.0.0" + checksum: 425dab288738853fded43da3314a0b5c035844d6f3097a8e3b5b29b328da8f3c1af6fc70618b32c29ff906284cf6406b6841376f21caaadd0793c1d5a6a620ea + languageName: node + linkType: hard + +"minizlib@npm:^2.0.0, minizlib@npm:^2.1.1, minizlib@npm:^2.1.2": version: 2.1.2 resolution: "minizlib@npm:2.1.2" dependencies: @@ -12173,7 +12594,7 @@ __metadata: languageName: node linkType: hard -"mkdirp@npm:>=0.5 0, mkdirp@npm:^0.5.0, mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.3, mkdirp@npm:^0.5.4, mkdirp@npm:^0.5.5, mkdirp@npm:~0.5.1": +"mkdirp@npm:>=0.5 0, mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.3, mkdirp@npm:^0.5.4, mkdirp@npm:^0.5.5, mkdirp@npm:~0.5.1": version: 0.5.5 resolution: "mkdirp@npm:0.5.5" dependencies: @@ -12193,10 +12614,10 @@ __metadata: languageName: node linkType: hard -"moment@npm:2.29.1, moment@npm:^2.27.0": - version: 2.29.1 - resolution: "moment@npm:2.29.1" - checksum: 1e14d5f422a2687996be11dd2d50c8de3bd577c4a4ca79ba5d02c397242a933e5b941655de6c8cb90ac18f01cc4127e55b4a12ae3c527a6c0a274e455979345e +"moment@npm:^2.27.0, moment@npm:^2.29.4": + version: 2.29.4 + resolution: "moment@npm:2.29.4" + checksum: 0ec3f9c2bcba38dc2451b1daed5daded747f17610b92427bebe1d08d48d8b7bdd8d9197500b072d14e326dd0ccf3e326b9e3d07c5895d3d49e39b6803b76e80e languageName: node linkType: hard @@ -12284,6 +12705,15 @@ __metadata: languageName: node linkType: hard +"nan@npm:^2.17.0": + version: 2.18.0 + resolution: "nan@npm:2.18.0" + dependencies: + node-gyp: latest + checksum: 4fe42f58456504eab3105c04a5cffb72066b5f22bd45decf33523cb17e7d6abc33cca2a19829407b9000539c5cb25f410312d4dc5b30220167a3594896ea6a0a + languageName: node + linkType: hard + "nanomatch@npm:^1.2.9": version: 1.2.13 resolution: "nanomatch@npm:1.2.13" @@ -12334,7 +12764,7 @@ __metadata: languageName: node linkType: hard -"negotiator@npm:^0.6.3": +"negotiator@npm:^0.6.2, negotiator@npm:^0.6.3": version: 0.6.3 resolution: "negotiator@npm:0.6.3" checksum: b8ffeb1e262eff7968fc90a2b6767b04cfd9842582a9d0ece0af7049537266e7b2506dfb1d107a32f06dd849ab2aea834d5830f7f4d0e5cb7d36e1ae55d021d9 @@ -12385,13 +12815,6 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:2.6.1": - version: 2.6.1 - resolution: "node-fetch@npm:2.6.1" - checksum: 91075bedd57879117e310fbcc36983ad5d699e522edb1ebcdc4ee5294c982843982652925c3532729fdc86b2d64a8a827797a745f332040d91823c8752ee4d7c - languageName: node - linkType: hard - "node-fetch@npm:^1.0.1": version: 1.7.3 resolution: "node-fetch@npm:1.7.3" @@ -12402,6 +12825,20 @@ __metadata: languageName: node linkType: hard +"node-fetch@npm:^2.6.12": + version: 2.7.0 + resolution: "node-fetch@npm:2.7.0" + dependencies: + whatwg-url: ^5.0.0 + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: d76d2f5edb451a3f05b15115ec89fc6be39de37c6089f1b6368df03b91e1633fd379a7e01b7ab05089a25034b2023d959b47e59759cb38d88341b2459e89d6e5 + languageName: node + linkType: hard + "node-forge@npm:^0.10.0": version: 0.10.0 resolution: "node-forge@npm:0.10.0" @@ -12409,25 +12846,23 @@ __metadata: languageName: node linkType: hard -"node-gyp@npm:^3.8.0": - version: 3.8.0 - resolution: "node-gyp@npm:3.8.0" +"node-gyp@npm:^8.4.1": + version: 8.4.1 + resolution: "node-gyp@npm:8.4.1" dependencies: - fstream: ^1.0.0 - glob: ^7.0.3 - graceful-fs: ^4.1.2 - mkdirp: ^0.5.0 - nopt: 2 || 3 - npmlog: 0 || 1 || 2 || 3 || 4 - osenv: 0 - request: ^2.87.0 - rimraf: 2 - semver: ~5.3.0 - tar: ^2.0.0 - which: 1 + env-paths: ^2.2.0 + glob: ^7.1.4 + graceful-fs: ^4.2.6 + make-fetch-happen: ^9.1.0 + nopt: ^5.0.0 + npmlog: ^6.0.0 + rimraf: ^3.0.2 + semver: ^7.3.5 + tar: ^6.1.2 + which: ^2.0.2 bin: - node-gyp: ./bin/node-gyp.js - checksum: e99d740db6f5462cfd2f03fdfa89bae7e509e37f158d78a2fec0c858984cceb801723510656110d8f1d0ecf69cc2ceba8b477d22aac3e69ce8094db19dff6b2b + node-gyp: bin/node-gyp.js + checksum: 341710b5da39d3660e6a886b37e210d33f8282047405c2e62c277bcc744c7552c5b8b972ebc3a7d5c2813794e60cc48c3ebd142c46d6e0321db4db6c92dd0355 languageName: node linkType: hard @@ -12509,66 +12944,84 @@ __metadata: languageName: node linkType: hard -"node-releases@npm:^1.1.52, node-releases@npm:^1.1.71": +"node-releases@npm:^1.1.52": version: 1.1.73 resolution: "node-releases@npm:1.1.73" checksum: 44a6caec3330538a669c156fa84833725ae92b317585b106e08ab292c14da09f30cb913c10f1a7402180a51b10074832d4e045b6c3512d74c37d86b41a69e63b languageName: node linkType: hard -"node-sass-chokidar@npm:1.5.0": - version: 1.5.0 - resolution: "node-sass-chokidar@npm:1.5.0" +"node-releases@npm:^2.0.13": + version: 2.0.13 + resolution: "node-releases@npm:2.0.13" + checksum: 17ec8f315dba62710cae71a8dad3cd0288ba943d2ece43504b3b1aa8625bf138637798ab470b1d9035b0545996f63000a8a926e0f6d35d0996424f8b6d36dda3 + languageName: node + linkType: hard + +"node-sass-chokidar@npm:^2.0.0": + version: 2.0.0 + resolution: "node-sass-chokidar@npm:2.0.0" dependencies: async-foreach: ^0.1.3 chokidar: ^3.4.0 get-stdin: ^4.0.1 glob: ^7.0.3 meow: ^3.7.0 - node-sass: ^4.14.1 + node-sass: ^7.0.1 sass-graph: ^2.2.4 stdout-stream: ^1.4.0 bin: node-sass-chokidar: bin/node-sass-chokidar - checksum: fb3197b1dcc06b7b3c8e7d2e63ab9397745466f2e78871f8ba112f3740f7092f37f6668bc25a0d7bea82fe8a78b4d8dd009151eb0f041dc62029e76a38004e8d + checksum: 5aeffc93cddf5cc32d0e86de4999e56e3cdccb1d86b5ed211e2d661f4e579bac19c078ca791662e2aaff9752ba2e18ce87324c07de5b3222064a4c9703856d9c languageName: node linkType: hard -"node-sass@npm:^4.14.1, node-sass@npm:^4.9.4": - version: 4.14.1 - resolution: "node-sass@npm:4.14.1" +"node-sass@npm:^7.0.1": + version: 7.0.3 + resolution: "node-sass@npm:7.0.3" dependencies: async-foreach: ^0.1.3 - chalk: ^1.1.1 - cross-spawn: ^3.0.0 + chalk: ^4.1.2 + cross-spawn: ^7.0.3 gaze: ^1.0.0 get-stdin: ^4.0.1 glob: ^7.0.3 - in-publish: ^2.0.0 lodash: ^4.17.15 - meow: ^3.7.0 - mkdirp: ^0.5.1 + meow: ^9.0.0 nan: ^2.13.2 - node-gyp: ^3.8.0 - npmlog: ^4.0.0 + node-gyp: ^8.4.1 + npmlog: ^5.0.0 request: ^2.88.0 - sass-graph: 2.2.5 + sass-graph: ^4.0.1 stdout-stream: ^1.4.0 true-case-path: ^1.0.2 bin: node-sass: bin/node-sass - checksum: 6894709e7d8c4482fd0d53ce8473fd7c3ddf38ef36a109bbda96aca750e7c28777e89fcf277c9e032ca69328062f10a12be61e01a385ed0d221fbbdfd0ac7448 + checksum: 7d577d0fb68948959f367341e6cfc2858aa37abc5fadbd9e6b477ed0d192bebf7f8516d0b53c27be30ab05d5cd62d8a9bab08cc4442ef901b02cb51d864b4419 languageName: node linkType: hard -"nopt@npm:2 || 3": - version: 3.0.6 - resolution: "nopt@npm:3.0.6" +"node-sass@npm:^9.0.0": + version: 9.0.0 + resolution: "node-sass@npm:9.0.0" dependencies: - abbrev: 1 + async-foreach: ^0.1.3 + chalk: ^4.1.2 + cross-spawn: ^7.0.3 + gaze: ^1.0.0 + get-stdin: ^4.0.1 + glob: ^7.0.3 + lodash: ^4.17.15 + make-fetch-happen: ^10.0.4 + meow: ^9.0.0 + nan: ^2.17.0 + node-gyp: ^8.4.1 + sass-graph: ^4.0.1 + stdout-stream: ^1.4.0 + true-case-path: ^2.2.1 bin: - nopt: ./bin/nopt.js - checksum: 7f8579029a0d7cb3341c6b1610b31e363f708b7aaaaf3580e3ec5ae8528d1f3a79d350d8bfa331776e6c6703a5a148b72edd9b9b4c1dd55874d8e70e963d1e20 + node-sass: bin/node-sass + checksum: b15fa76b1564c37d65cde7556731e3c09b49c74a6919cd5cff6f71ddbe454bd1ad9e458f5f02f0f81f43919b8755b5f56cf657fa4e32a0a2644a48fbc07147bb languageName: node linkType: hard @@ -12583,7 +13036,7 @@ __metadata: languageName: node linkType: hard -"normalize-package-data@npm:^2.3.2, normalize-package-data@npm:^2.3.4": +"normalize-package-data@npm:^2.3.2, normalize-package-data@npm:^2.3.4, normalize-package-data@npm:^2.5.0": version: 2.5.0 resolution: "normalize-package-data@npm:2.5.0" dependencies: @@ -12595,6 +13048,18 @@ __metadata: languageName: node linkType: hard +"normalize-package-data@npm:^3.0.0": + version: 3.0.3 + resolution: "normalize-package-data@npm:3.0.3" + dependencies: + hosted-git-info: ^4.0.1 + is-core-module: ^2.5.0 + semver: ^7.3.4 + validate-npm-package-license: ^3.0.1 + checksum: bbcee00339e7c26fdbc760f9b66d429258e2ceca41a5df41f5df06cc7652de8d82e8679ff188ca095cad8eff2b6118d7d866af2b68400f74602fbcbce39c160a + languageName: node + linkType: hard + "normalize-path@npm:^2.1.1": version: 2.1.1 resolution: "normalize-path@npm:2.1.1" @@ -12662,15 +13127,15 @@ __metadata: languageName: node linkType: hard -"npmlog@npm:0 || 1 || 2 || 3 || 4, npmlog@npm:^4.0.0": - version: 4.1.2 - resolution: "npmlog@npm:4.1.2" +"npmlog@npm:^5.0.0": + version: 5.0.1 + resolution: "npmlog@npm:5.0.1" dependencies: - are-we-there-yet: ~1.1.2 - console-control-strings: ~1.1.0 - gauge: ~2.7.3 - set-blocking: ~2.0.0 - checksum: edbda9f95ec20957a892de1839afc6fb735054c3accf6fbefe767bac9a639fd5cea2baeac6bd2bcd50a85cb54924d57d9886c81c7fbc2332c2ddd19227504192 + are-we-there-yet: ^2.0.0 + console-control-strings: ^1.1.0 + gauge: ^3.0.0 + set-blocking: ^2.0.0 + checksum: 516b2663028761f062d13e8beb3f00069c5664925871a9b57989642ebe09f23ab02145bf3ab88da7866c4e112cafff72401f61a672c7c8a20edc585a7016ef5f languageName: node linkType: hard @@ -12695,12 +13160,12 @@ __metadata: languageName: node linkType: hard -"nth-check@npm:^2.0.0": - version: 2.0.0 - resolution: "nth-check@npm:2.0.0" +"nth-check@npm:^2.0.1": + version: 2.1.1 + resolution: "nth-check@npm:2.1.1" dependencies: boolbase: ^1.0.0 - checksum: a22eb19616719d46a5b517f76c32e67e4a2b6a229d67ba2f3efb296e24d79687d52b904c2298cd16510215d5d2a419f8ba671f5957a3b4b73905f62ba7aafa3b + checksum: 5afc3dafcd1573b08877ca8e6148c52abd565f1d06b1eb08caf982e3fa289a82f2cae697ffb55b5021e146d60443f1590a5d6b944844e944714a5b549675bcd3 languageName: node linkType: hard @@ -12958,15 +13423,6 @@ __metadata: languageName: node linkType: hard -"original@npm:^1.0.0": - version: 1.0.2 - resolution: "original@npm:1.0.2" - dependencies: - url-parse: ^1.4.3 - checksum: 8dca9311dab50c8953366127cb86b7c07bf547d6aa6dc6873a75964b7563825351440557e5724d9c652c5e99043b8295624f106af077f84bccf19592e421beb9 - languageName: node - linkType: hard - "os-browserify@npm:^0.3.0": version: 0.3.0 resolution: "os-browserify@npm:0.3.0" @@ -12990,23 +13446,13 @@ __metadata: languageName: node linkType: hard -"os-tmpdir@npm:^1.0.0, os-tmpdir@npm:^1.0.1, os-tmpdir@npm:~1.0.2": +"os-tmpdir@npm:^1.0.1, os-tmpdir@npm:~1.0.2": version: 1.0.2 resolution: "os-tmpdir@npm:1.0.2" checksum: 5666560f7b9f10182548bf7013883265be33620b1c1b4a4d405c25be2636f970c5488ff3e6c48de75b55d02bde037249fe5dbfbb4c0fb7714953d56aed062e6d languageName: node linkType: hard -"osenv@npm:0": - version: 0.1.5 - resolution: "osenv@npm:0.1.5" - dependencies: - os-homedir: ^1.0.0 - os-tmpdir: ^1.0.0 - checksum: 779d261920f2a13e5e18cf02446484f12747d3f2ff82280912f52b213162d43d312647a40c332373cbccd5e3fb8126915d3bfea8dde4827f70f82da76e52d359 - languageName: node - linkType: hard - "ospath@npm:^1.2.2": version: 1.2.2 resolution: "ospath@npm:1.2.2" @@ -13437,13 +13883,34 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.3": +"picocolors@npm:^0.2.1": + version: 0.2.1 + resolution: "picocolors@npm:0.2.1" + checksum: 3b0f441f0062def0c0f39e87b898ae7461c3a16ffc9f974f320b44c799418cabff17780ee647fda42b856a1dc45897e2c62047e1b546d94d6d5c6962f45427b2 + languageName: node + linkType: hard + +"picocolors@npm:^1.0.0": + version: 1.0.0 + resolution: "picocolors@npm:1.0.0" + checksum: a2e8092dd86c8396bdba9f2b5481032848525b3dc295ce9b57896f931e63fc16f79805144321f72976383fc249584672a75cc18d6777c6b757603f372f745981 + languageName: node + linkType: hard + +"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1": version: 2.3.0 resolution: "picomatch@npm:2.3.0" checksum: 16818720ea7c5872b6af110760dee856c8e4cd79aed1c7a006d076b1cc09eff3ae41ca5019966694c33fbd2e1cc6ea617ab10e4adac6df06556168f13be3fca2 languageName: node linkType: hard +"picomatch@npm:^2.3.1": + version: 2.3.1 + resolution: "picomatch@npm:2.3.1" + checksum: 050c865ce81119c4822c45d3c84f1ced46f93a0126febae20737bd05ca20589c564d6e9226977df859ed5e03dc73f02584a2b0faad36e896936238238b0446cf + languageName: node + linkType: hard + "pify@npm:^2.0.0, pify@npm:^2.2.0": version: 2.3.0 resolution: "pify@npm:2.3.0" @@ -13673,6 +14140,17 @@ __metadata: languageName: node linkType: hard +"postcss-combine-duplicated-selectors@npm:^10.0.3": + version: 10.0.3 + resolution: "postcss-combine-duplicated-selectors@npm:10.0.3" + dependencies: + postcss-selector-parser: ^6.0.4 + peerDependencies: + postcss: ^8.1.0 + checksum: 45c3dff41d0cddb510752ed92fe8c7fc66e5cf88f4988314655419d3ecdf1dc66f484a25ee73f4f292da5da851a0fdba0ec4d59bdedeee935d05b26d31d997ed + languageName: node + linkType: hard + "postcss-convert-values@npm:^4.0.1": version: 4.0.1 resolution: "postcss-convert-values@npm:4.0.1" @@ -14317,6 +14795,16 @@ __metadata: languageName: node linkType: hard +"postcss-selector-parser@npm:^6.0.4": + version: 6.0.13 + resolution: "postcss-selector-parser@npm:6.0.13" + dependencies: + cssesc: ^3.0.0 + util-deprecate: ^1.0.2 + checksum: f89163338a1ce3b8ece8e9055cd5a3165e79a15e1c408e18de5ad8f87796b61ec2d48a2902d179ae0c4b5de10fccd3a325a4e660596549b040bc5ad1b465f096 + languageName: node + linkType: hard + "postcss-svgo@npm:^4.0.3": version: 4.0.3 resolution: "postcss-svgo@npm:4.0.3" @@ -14376,13 +14864,12 @@ __metadata: linkType: hard "postcss@npm:^7, postcss@npm:^7.0.0, postcss@npm:^7.0.1, postcss@npm:^7.0.14, postcss@npm:^7.0.17, postcss@npm:^7.0.2, postcss@npm:^7.0.23, postcss@npm:^7.0.27, postcss@npm:^7.0.32, postcss@npm:^7.0.5, postcss@npm:^7.0.6": - version: 7.0.36 - resolution: "postcss@npm:7.0.36" + version: 7.0.39 + resolution: "postcss@npm:7.0.39" dependencies: - chalk: ^2.4.2 + picocolors: ^0.2.1 source-map: ^0.6.1 - supports-color: ^6.1.0 - checksum: 4cfc0989b9ad5d0e8971af80d87f9c5beac5c84cb89ff22ad69852edf73c0a2fa348e7e0a135b5897bf893edad0fe86c428769050431ad9b532f072ff530828d + checksum: 4ac793f506c23259189064bdc921260d869a115a82b5e713973c5af8e94fbb5721a5cc3e1e26840500d7e1f1fa42a209747c5b1a151918a9bc11f0d7ed9048e3 languageName: node linkType: hard @@ -14448,13 +14935,6 @@ __metadata: languageName: node linkType: hard -"process-nextick-args@npm:~1.0.6": - version: 1.0.7 - resolution: "process-nextick-args@npm:1.0.7" - checksum: 41224fbc803ac6c96907461d4dfc20942efa3ca75f2d521bcf7cf0e89f8dec127fb3fb5d76746b8fb468a232ea02d84824fae08e027aec185fd29049c66d49f8 - languageName: node - linkType: hard - "process-nextick-args@npm:~2.0.0": version: 2.0.1 resolution: "process-nextick-args@npm:2.0.1" @@ -14571,13 +15051,6 @@ __metadata: languageName: node linkType: hard -"pseudomap@npm:^1.0.2": - version: 1.0.2 - resolution: "pseudomap@npm:1.0.2" - checksum: 856c0aae0ff2ad60881168334448e898ad7a0e45fe7386d114b150084254c01e200c957cf378378025df4e052c7890c5bd933939b0e0d2ecfcc1dc2f0b2991f5 - languageName: node - linkType: hard - "psl@npm:^1.1.28": version: 1.8.0 resolution: "psl@npm:1.8.0" @@ -14666,9 +15139,9 @@ __metadata: linkType: hard "qs@npm:~6.5.2": - version: 6.5.2 - resolution: "qs@npm:6.5.2" - checksum: 24af7b9928ba2141233fba2912876ff100403dba1b08b20c3b490da9ea6c636760445ea2211a079e7dfa882a5cf8f738337b3748c8bdd0f93358fa8881d2db8f + version: 6.5.3 + resolution: "qs@npm:6.5.3" + checksum: 6f20bf08cabd90c458e50855559539a28d00b2f2e7dddcb66082b16a43188418cb3cb77cbd09268bcef6022935650f0534357b8af9eeb29bf0f27ccb17655692 languageName: node linkType: hard @@ -14721,6 +15194,13 @@ __metadata: languageName: node linkType: hard +"quick-lru@npm:^4.0.1": + version: 4.0.1 + resolution: "quick-lru@npm:4.0.1" + checksum: bea46e1abfaa07023e047d3cf1716a06172c4947886c053ede5c50321893711577cb6119360f810cc3ffcd70c4d7db4069c3cee876b358ceff8596e062bd1154 + languageName: node + linkType: hard + "raf@npm:^3.4.1": version: 3.4.1 resolution: "raf@npm:3.4.1" @@ -14878,17 +15358,17 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:16.8.6": - version: 16.8.6 - resolution: "react-dom@npm:16.8.6" +"react-dom@npm:16.14.0": + version: 16.14.0 + resolution: "react-dom@npm:16.14.0" dependencies: loose-envify: ^1.1.0 object-assign: ^4.1.1 prop-types: ^15.6.2 - scheduler: ^0.13.6 + scheduler: ^0.19.1 peerDependencies: - react: ^16.0.0 - checksum: 7f8ebd8523eb4a14a1439efa009d020abc0529da25d0de251a4f3d5b3781061f6b30d72425f5fe944317850997efc6c1d667e99b1fd70172f30a976a00008bf6 + react: ^16.14.0 + checksum: 5a5c49da0f106b2655a69f96c622c347febcd10532db391c262b26aec225b235357d9da1834103457683482ab1b229af7a50f6927a6b70e53150275e31785544 languageName: node linkType: hard @@ -15032,9 +15512,9 @@ __metadata: languageName: node linkType: hard -"react-rte@npm:0.16.3": - version: 0.16.3 - resolution: "react-rte@npm:0.16.3" +"react-rte@npm:^0.16.5": + version: 0.16.5 + resolution: "react-rte@npm:0.16.5" dependencies: babel-runtime: ^6.23.0 class-autobind: ^0.1.4 @@ -15047,9 +15527,9 @@ __metadata: draft-js-utils: ">=0.2.0" immutable: ^3.8.1 peerDependencies: - react: 0.14.x || 15.x.x || 16.x.x - react-dom: 0.14.x || 15.x.x || 16.x.x - checksum: 812ed35161bea266cbdf42da0173398834eba0166328a01ae521c86b29b573ed25107985d3a077344ecd30536804376c0d94cb7d534abecdbc1dbf4d7af8bdc4 + react: 0.14.x || 15.x.x || 16.x.x || 17.x.x + react-dom: 0.14.x || 15.x.x || 16.x.x || 17.x.x + checksum: 3af94acd7790989c44babc7b1327a0a047a1a7fd03f13d5c1ef2d276e949d7346a8b1b875b8457c2624e5c0cdcb6e3980f967280c52ff2f92d8234debec01c03 languageName: node linkType: hard @@ -15212,15 +15692,14 @@ __metadata: languageName: node linkType: hard -"react@npm:16.8.6": - version: 16.8.6 - resolution: "react@npm:16.8.6" +"react@npm:16.14.0": + version: 16.14.0 + resolution: "react@npm:16.14.0" dependencies: loose-envify: ^1.1.0 object-assign: ^4.1.1 prop-types: ^15.6.2 - scheduler: ^0.13.6 - checksum: 8dfdbec9af6999c2cfb33a9389995c6401daba732e1ee7e0a4920d28fd2e8e6b0fde99dfe4b8e2f81efc4a962c92656e3e79e221323449e55850232163f15ff4 + checksum: 8484f3ecb13414526f2a7412190575fc134da785c02695eb92bb6028c930bfe1c238d7be2a125088fec663cc7cda0a3623373c46807cf2c281f49c34b79881ac languageName: node linkType: hard @@ -15254,6 +15733,17 @@ __metadata: languageName: node linkType: hard +"read-pkg-up@npm:^7.0.1": + version: 7.0.1 + resolution: "read-pkg-up@npm:7.0.1" + dependencies: + find-up: ^4.1.0 + read-pkg: ^5.2.0 + type-fest: ^0.8.1 + checksum: e4e93ce70e5905b490ca8f883eb9e48b5d3cebc6cd4527c25a0d8f3ae2903bd4121c5ab9c5a3e217ada0141098eeb661313c86fa008524b089b8ed0b7f165e44 + languageName: node + linkType: hard + "read-pkg@npm:^1.0.0": version: 1.1.0 resolution: "read-pkg@npm:1.1.0" @@ -15287,7 +15777,19 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:1 || 2, readable-stream@npm:^2.0.0, readable-stream@npm:^2.0.1, readable-stream@npm:^2.0.2, readable-stream@npm:^2.0.6, readable-stream@npm:^2.1.5, readable-stream@npm:^2.2.2, readable-stream@npm:^2.3.3, readable-stream@npm:^2.3.6, readable-stream@npm:~2.3.6": +"read-pkg@npm:^5.2.0": + version: 5.2.0 + resolution: "read-pkg@npm:5.2.0" + dependencies: + "@types/normalize-package-data": ^2.4.0 + normalize-package-data: ^2.5.0 + parse-json: ^5.0.0 + type-fest: ^0.6.0 + checksum: eb696e60528b29aebe10e499ba93f44991908c57d70f2d26f369e46b8b9afc208ef11b4ba64f67630f31df8b6872129e0a8933c8c53b7b4daf0eace536901222 + languageName: node + linkType: hard + +"readable-stream@npm:1 || 2, readable-stream@npm:^2.0.0, readable-stream@npm:^2.0.1, readable-stream@npm:^2.0.2, readable-stream@npm:^2.1.5, readable-stream@npm:^2.2.2, readable-stream@npm:^2.3.3, readable-stream@npm:^2.3.6, readable-stream@npm:~2.3.6": version: 2.3.7 resolution: "readable-stream@npm:2.3.7" dependencies: @@ -15313,20 +15815,6 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:~2.0.6": - version: 2.0.6 - resolution: "readable-stream@npm:2.0.6" - dependencies: - core-util-is: ~1.0.0 - inherits: ~2.0.1 - isarray: ~1.0.0 - process-nextick-args: ~1.0.6 - string_decoder: ~0.10.x - util-deprecate: ~1.0.1 - checksum: 5258b248531e58cbd855dab6a67dde3f4939f78a6d7707042ce61a74fe3421a7596405bc9c8970484dc9b2d929136e6cc40985f76759b9264a0a273f6136ed3b - languageName: node - linkType: hard - "readdirp@npm:^2.2.1": version: 2.2.1 resolution: "readdirp@npm:2.2.1" @@ -15423,6 +15911,25 @@ __metadata: languageName: node linkType: hard +"redent@npm:^3.0.0": + version: 3.0.0 + resolution: "redent@npm:3.0.0" + dependencies: + indent-string: ^4.0.0 + strip-indent: ^3.0.0 + checksum: fa1ef20404a2d399235e83cc80bd55a956642e37dd197b4b612ba7327bf87fa32745aeb4a1634b2bab25467164ab4ed9c15be2c307923dd08b0fe7c52431ae6b + languageName: node + linkType: hard + +"redux-devtools-extension@npm:^2.13.9": + version: 2.13.9 + resolution: "redux-devtools-extension@npm:2.13.9" + peerDependencies: + redux: ^3.1.0 || ^4.0.0 + checksum: 603d48fd6acf3922ef373b251ab3fdbb990035e90284191047b29d25b06ea18122bc4ef01e0704ccae495acb27ab5e47b560937e98213605dd88299470025db9 + languageName: node + linkType: hard + "redux-devtools-instrument@npm:^1.0.1": version: 1.10.0 resolution: "redux-devtools-instrument@npm:1.10.0" @@ -16128,31 +16635,31 @@ __metadata: languageName: node linkType: hard -"sass-graph@npm:2.2.5": - version: 2.2.5 - resolution: "sass-graph@npm:2.2.5" +"sass-graph@npm:^2.2.4": + version: 2.2.6 + resolution: "sass-graph@npm:2.2.6" dependencies: glob: ^7.0.0 lodash: ^4.0.0 scss-tokenizer: ^0.2.3 - yargs: ^13.3.2 + yargs: ^7.0.0 bin: sassgraph: bin/sassgraph - checksum: 283b6e5a38c8b4fca77cdc4fc1da9641679120dba80e89361c82b6a3975f90d01cc78129f9f8fd148822e5a648f540c58c9a38b8c2b11ca97abc4f381613c013 + checksum: 1fb1719c659fdea00a9f55be9722c5902c3d1f1a0919d2e5ceb8a318064f2b214981d98b7d7fecaafc25f522302f919a948351e4ae1d1680b9c045d563550a93 languageName: node linkType: hard -"sass-graph@npm:^2.2.4": - version: 2.2.6 - resolution: "sass-graph@npm:2.2.6" +"sass-graph@npm:^4.0.1": + version: 4.0.1 + resolution: "sass-graph@npm:4.0.1" dependencies: glob: ^7.0.0 - lodash: ^4.0.0 - scss-tokenizer: ^0.2.3 - yargs: ^7.0.0 + lodash: ^4.17.11 + scss-tokenizer: ^0.4.3 + yargs: ^17.2.1 bin: sassgraph: bin/sassgraph - checksum: 1fb1719c659fdea00a9f55be9722c5902c3d1f1a0919d2e5ceb8a318064f2b214981d98b7d7fecaafc25f522302f919a948351e4ae1d1680b9c045d563550a93 + checksum: 896f99253bd77a429a95e483ebddee946e195b61d3f84b3e1ccf8ad843265ec0585fa40bf55fbf354c5f57eb9fd0349834a8b190cd2161ab1234cb9af10e3601 languageName: node linkType: hard @@ -16197,16 +16704,6 @@ __metadata: languageName: node linkType: hard -"scheduler@npm:^0.13.6": - version: 0.13.6 - resolution: "scheduler@npm:0.13.6" - dependencies: - loose-envify: ^1.1.0 - object-assign: ^4.1.1 - checksum: c82c705f6d0d6df87b26bf2cca33f427e91889438c0435ade3ee7f41860eda4dd7f3171ca2d93e8fe9431f3bd831ca0e267a401a0296e4b14de05e389f82d320 - languageName: node - linkType: hard - "scheduler@npm:^0.19.1": version: 0.19.1 resolution: "scheduler@npm:0.19.1" @@ -16249,6 +16746,16 @@ __metadata: languageName: node linkType: hard +"scss-tokenizer@npm:^0.4.3": + version: 0.4.3 + resolution: "scss-tokenizer@npm:0.4.3" + dependencies: + js-base64: ^2.4.9 + source-map: ^0.7.3 + checksum: f3697bb155ae23d88c7cd0275988a73231fe675fbbd250b4e56849ba66319fc249a597f3799a92f9890b12007f00f8f6a7f441283e634679e2acdb2287a341d1 + languageName: node + linkType: hard + "select-hose@npm:^2.0.0": version: 2.0.0 resolution: "select-hose@npm:2.0.0" @@ -16266,15 +16773,15 @@ __metadata: linkType: hard "semver@npm:2 || 3 || 4 || 5, semver@npm:^5.3.0, semver@npm:^5.4.1, semver@npm:^5.5.0, semver@npm:^5.5.1, semver@npm:^5.6.0, semver@npm:^5.7.0, semver@npm:^5.7.1": - version: 5.7.1 - resolution: "semver@npm:5.7.1" + version: 5.7.2 + resolution: "semver@npm:5.7.2" bin: - semver: ./bin/semver - checksum: 57fd0acfd0bac382ee87cd52cd0aaa5af086a7dc8d60379dfe65fea491fb2489b6016400813930ecd61fd0952dae75c115287a1b16c234b1550887117744dfaf + semver: bin/semver + checksum: fb4ab5e0dd1c22ce0c937ea390b4a822147a9c53dbd2a9a0132f12fe382902beef4fbf12cf51bb955248d8d15874ce8cd89532569756384f994309825f10b686 languageName: node linkType: hard -"semver@npm:6.3.0, semver@npm:^6.0.0, semver@npm:^6.1.1, semver@npm:^6.1.2, semver@npm:^6.2.0, semver@npm:^6.3.0": +"semver@npm:6.3.0": version: 6.3.0 resolution: "semver@npm:6.3.0" bin: @@ -16292,23 +16799,23 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.3.5": - version: 7.3.5 - resolution: "semver@npm:7.3.5" - dependencies: - lru-cache: ^6.0.0 +"semver@npm:^6.0.0, semver@npm:^6.1.1, semver@npm:^6.1.2, semver@npm:^6.2.0, semver@npm:^6.3.0": + version: 6.3.1 + resolution: "semver@npm:6.3.1" bin: semver: bin/semver.js - checksum: 5eafe6102bea2a7439897c1856362e31cc348ccf96efd455c8b5bc2c61e6f7e7b8250dc26b8828c1d76a56f818a7ee907a36ae9fb37a599d3d24609207001d60 + checksum: ae47d06de28836adb9d3e25f22a92943477371292d9b665fb023fae278d345d508ca1958232af086d85e0155aee22e313e100971898bbb8d5d89b8b1d4054ca2 languageName: node linkType: hard -"semver@npm:~5.3.0": - version: 5.3.0 - resolution: "semver@npm:5.3.0" +"semver@npm:^7.3.4, semver@npm:^7.3.5": + version: 7.5.4 + resolution: "semver@npm:7.5.4" + dependencies: + lru-cache: ^6.0.0 bin: - semver: ./bin/semver - checksum: 2717b14299c76a4b35aec0aafebca22a3644da2942d2a4095f26e36d77a9bbe17a9a3a5199795f83edd26323d5c22024a2d9d373a038dec4e023156fa166d314 + semver: bin/semver.js + checksum: 12d8ad952fa353b0995bf180cdac205a4068b759a140e5d3c608317098b3575ac2f1e09182206bf2eb26120e1c0ed8fb92c48c592f6099680de56bb071423ca3 languageName: node linkType: hard @@ -16369,7 +16876,7 @@ __metadata: languageName: node linkType: hard -"set-blocking@npm:^2.0.0, set-blocking@npm:~2.0.0": +"set-blocking@npm:^2.0.0": version: 2.0.0 resolution: "set-blocking@npm:2.0.0" checksum: 6e65a05f7cf7ebdf8b7c75b101e18c0b7e3dff4940d480efed8aad3a36a4005140b660fa1d804cb8bce911cac290441dc728084a30504d3516ac2ff7ad607b02 @@ -16665,6 +17172,17 @@ __metadata: languageName: node linkType: hard +"socks-proxy-agent@npm:^6.0.0": + version: 6.2.1 + resolution: "socks-proxy-agent@npm:6.2.1" + dependencies: + agent-base: ^6.0.2 + debug: ^4.3.3 + socks: ^2.6.2 + checksum: 9ca089d489e5ee84af06741135c4b0d2022977dad27ac8d649478a114cdce87849e8d82b7c22b51501a4116e231241592946fc7fae0afc93b65030ee57084f58 + languageName: node + linkType: hard + "socks-proxy-agent@npm:^6.1.1": version: 6.1.1 resolution: "socks-proxy-agent@npm:6.1.1" @@ -16676,6 +17194,17 @@ __metadata: languageName: node linkType: hard +"socks-proxy-agent@npm:^7.0.0": + version: 7.0.0 + resolution: "socks-proxy-agent@npm:7.0.0" + dependencies: + agent-base: ^6.0.2 + debug: ^4.3.3 + socks: ^2.6.2 + checksum: 720554370154cbc979e2e9ce6a6ec6ced205d02757d8f5d93fe95adae454fc187a5cbfc6b022afab850a5ce9b4c7d73e0f98e381879cf45f66317a4895953846 + languageName: node + linkType: hard + "socks@npm:^2.6.1": version: 2.6.2 resolution: "socks@npm:2.6.2" @@ -16686,6 +17215,16 @@ __metadata: languageName: node linkType: hard +"socks@npm:^2.6.2": + version: 2.7.1 + resolution: "socks@npm:2.7.1" + dependencies: + ip: ^2.0.0 + smart-buffer: ^4.2.0 + checksum: 259d9e3e8e1c9809a7f5c32238c3d4d2a36b39b83851d0f573bfde5f21c4b1288417ce1af06af1452569cd1eb0841169afd4998f0e04ba04656f6b7f0e46d748 + languageName: node + linkType: hard + "sort-keys@npm:^1.0.0": version: 1.1.2 resolution: "sort-keys@npm:1.1.2" @@ -16764,6 +17303,13 @@ __metadata: languageName: node linkType: hard +"source-map@npm:^0.7.3": + version: 0.7.4 + resolution: "source-map@npm:0.7.4" + checksum: 01cc5a74b1f0e1d626a58d36ad6898ea820567e87f18dfc9d24a9843a351aaa2ec09b87422589906d6ff1deed29693e176194dc88bcae7c9a852dc74b311dbf5 + languageName: node + linkType: hard + "spdx-correct@npm:^3.0.0": version: 3.1.1 resolution: "spdx-correct@npm:3.1.1" @@ -16888,7 +17434,7 @@ __metadata: languageName: node linkType: hard -"ssri@npm:^8.0.1": +"ssri@npm:^8.0.0, ssri@npm:^8.0.1": version: 8.0.1 resolution: "ssri@npm:8.0.1" dependencies: @@ -16897,6 +17443,15 @@ __metadata: languageName: node linkType: hard +"ssri@npm:^9.0.0": + version: 9.0.1 + resolution: "ssri@npm:9.0.1" + dependencies: + minipass: ^3.1.1 + checksum: fb58f5e46b6923ae67b87ad5ef1c5ab6d427a17db0bead84570c2df3cd50b4ceb880ebdba2d60726588272890bae842a744e1ecce5bd2a2a582fccd5068309eb + languageName: node + linkType: hard + "stable@npm:^0.1.8": version: 0.1.8 resolution: "stable@npm:0.1.8" @@ -17042,7 +17597,7 @@ __metadata: languageName: node linkType: hard -"string-width@npm:^1.0.2 || 2, string-width@npm:^2.1.1": +"string-width@npm:^2.1.1": version: 2.1.1 resolution: "string-width@npm:2.1.1" dependencies: @@ -17130,13 +17685,6 @@ __metadata: languageName: node linkType: hard -"string_decoder@npm:~0.10.x": - version: 0.10.31 - resolution: "string_decoder@npm:0.10.31" - checksum: fe00f8e303647e5db919948ccb5ce0da7dea209ab54702894dd0c664edd98e5d4df4b80d6fabf7b9e92b237359d21136c95bf068b2f7760b772ca974ba970202 - languageName: node - linkType: hard - "string_decoder@npm:~1.1.1": version: 1.1.1 resolution: "string_decoder@npm:1.1.1" @@ -17157,7 +17705,7 @@ __metadata: languageName: node linkType: hard -"strip-ansi@npm:6.0.0, strip-ansi@npm:^6.0.0": +"strip-ansi@npm:6.0.0": version: 6.0.0 resolution: "strip-ansi@npm:6.0.0" dependencies: @@ -17193,7 +17741,7 @@ __metadata: languageName: node linkType: hard -"strip-ansi@npm:^6.0.1": +"strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": version: 6.0.1 resolution: "strip-ansi@npm:6.0.1" dependencies: @@ -17253,6 +17801,15 @@ __metadata: languageName: node linkType: hard +"strip-indent@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-indent@npm:3.0.0" + dependencies: + min-indent: ^1.0.0 + checksum: 18f045d57d9d0d90cd16f72b2313d6364fd2cb4bf85b9f593523ad431c8720011a4d5f08b6591c9d580f446e78855c5334a30fb91aa1560f5d9f95ed1b4a0530 + languageName: node + linkType: hard + "strip-json-comments@npm:^3.0.1": version: 3.1.1 resolution: "strip-json-comments@npm:3.1.1" @@ -17385,28 +17942,17 @@ __metadata: languageName: node linkType: hard -"tar@npm:^2.0.0": - version: 2.2.2 - resolution: "tar@npm:2.2.2" - dependencies: - block-stream: "*" - fstream: ^1.0.12 - inherits: 2 - checksum: c0c3727d529077423cf771f9f9c06edaaff82034d05d685806d3cee69d334ee8e6f394ee8d02dbd294cdecb95bb22625703279caff24bdb90b17e59de03a4733 - languageName: node - linkType: hard - -"tar@npm:^6.0.2, tar@npm:^6.1.2": - version: 6.1.11 - resolution: "tar@npm:6.1.11" +"tar@npm:^6.0.2, tar@npm:^6.1.11, tar@npm:^6.1.2": + version: 6.2.0 + resolution: "tar@npm:6.2.0" dependencies: chownr: ^2.0.0 fs-minipass: ^2.0.0 - minipass: ^3.0.0 + minipass: ^5.0.0 minizlib: ^2.1.1 mkdirp: ^1.0.3 yallist: ^4.0.0 - checksum: a04c07bb9e2d8f46776517d4618f2406fb977a74d914ad98b264fc3db0fe8224da5bec11e5f8902c5b9bcb8ace22d95fbe3c7b36b8593b7dfc8391a25898f32f + checksum: db4d9fe74a2082c3a5016630092c54c8375ff3b280186938cfd104f2e089c4fd9bad58688ef6be9cf186a889671bf355c7cda38f09bbf60604b281715ca57f5c languageName: node linkType: hard @@ -17449,15 +17995,15 @@ __metadata: linkType: hard "terser@npm:^4.1.2, terser@npm:^4.6.12, terser@npm:^4.6.3": - version: 4.8.0 - resolution: "terser@npm:4.8.0" + version: 4.8.1 + resolution: "terser@npm:4.8.1" dependencies: commander: ^2.20.0 source-map: ~0.6.1 source-map-support: ~0.5.12 bin: terser: bin/terser - checksum: f980789097d4f856c1ef4b9a7ada37beb0bb022fb8aa3057968862b5864ad7c244253b3e269c9eb0ab7d0caf97b9521273f2d1cf1e0e942ff0016e0583859c71 + checksum: b342819bf7e82283059aaa3f22bb74deb1862d07573ba5a8947882190ad525fd9b44a15074986be083fd379c58b9a879457a330b66dcdb77b485c44267f9a55a languageName: node linkType: hard @@ -17548,6 +18094,15 @@ __metadata: languageName: node linkType: hard +"tippy.js@npm:^6.3.7": + version: 6.3.7 + resolution: "tippy.js@npm:6.3.7" + dependencies: + "@popperjs/core": ^2.9.0 + checksum: cac955318a65288e8d2dca05059878b003c6e66f92c94f7810f5bc5448eb6646abdf7dacc9bd00020e2611592598d0aae3a28ec9a45349a159603c3fdddce5fb + languageName: node + linkType: hard + "tmp@npm:^0.0.33": version: 0.0.33 resolution: "tmp@npm:0.0.33" @@ -17567,9 +18122,9 @@ __metadata: linkType: hard "tmpl@npm:1.0.x": - version: 1.0.4 - resolution: "tmpl@npm:1.0.4" - checksum: 72c93335044b5b8771207d2e9cf71e8c26b110d0f0f924f6d6c06b509d89552c7c0e4086a574ce4f05110ac40c1faf6277ecba7221afeb57ebbab70d8de39cc4 + version: 1.0.5 + resolution: "tmpl@npm:1.0.5" + checksum: cd922d9b853c00fe414c5a774817be65b058d54a2d01ebb415840960406c669a0fc632f66df885e24cb022ec812739199ccbdb8d1164c3e513f85bfca5ab2873 languageName: node linkType: hard @@ -17667,7 +18222,14 @@ __metadata: languageName: node linkType: hard -"trim-newlines@npm:^1.0.0": +"tr46@npm:~0.0.3": + version: 0.0.3 + resolution: "tr46@npm:0.0.3" + checksum: 726321c5eaf41b5002e17ffbd1fb7245999a073e8979085dacd47c4b4e8068ff5777142fc6726d6ca1fd2ff16921b48788b87225cbc57c72636f6efa8efbffe3 + languageName: node + linkType: hard + +"trim-newlines@npm:^1.0.0, trim-newlines@npm:^3.0.0": version: 3.0.1 resolution: "trim-newlines@npm:3.0.1" checksum: b530f3fadf78e570cf3c761fb74fef655beff6b0f84b29209bac6c9622db75ad1417f4a7b5d54c96605dcd72734ad44526fef9f396807b90839449eb543c6206 @@ -17690,6 +18252,13 @@ __metadata: languageName: node linkType: hard +"true-case-path@npm:^2.2.1": + version: 2.2.1 + resolution: "true-case-path@npm:2.2.1" + checksum: fd5f1c2a87a122a65ffb1f84b580366be08dac7f552ea0fa4b5a6ab0a013af950b0e752beddb1c6c1652e6d6a2b293b7b3fd86a5a1706242ad365b68f1b5c6f1 + languageName: node + linkType: hard + "ts-mock-imports@npm:1.3.7": version: 1.3.7 resolution: "ts-mock-imports@npm:1.3.7" @@ -17850,6 +18419,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^0.18.0": + version: 0.18.1 + resolution: "type-fest@npm:0.18.1" + checksum: e96dcee18abe50ec82dab6cbc4751b3a82046da54c52e3b2d035b3c519732c0b3dd7a2fa9df24efd1a38d953d8d4813c50985f215f1957ee5e4f26b0fe0da395 + languageName: node + linkType: hard + "type-fest@npm:^0.21.3": version: 0.21.3 resolution: "type-fest@npm:0.21.3" @@ -17857,6 +18433,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^0.6.0": + version: 0.6.0 + resolution: "type-fest@npm:0.6.0" + checksum: b2188e6e4b21557f6e92960ec496d28a51d68658018cba8b597bd3ef757721d1db309f120ae987abeeda874511d14b776157ff809f23c6d1ce8f83b9b2b7d60f + languageName: node + linkType: hard + "type-fest@npm:^0.8.1": version: 0.8.1 resolution: "type-fest@npm:0.8.1" @@ -17915,10 +18498,10 @@ __metadata: languageName: node linkType: hard -"ua-parser-js@npm:^0.7.18": - version: 0.7.24 - resolution: "ua-parser-js@npm:0.7.24" - checksum: 722e0291fe6ad0d439cd29c4cd919f4e1b7262fe78e4c2149756180f8ad723ae04713839115eeb8738aca6d6258a743668090fb1e1417bc1fba27acc815a84e2 +"ua-parser-js@npm:^0.7.18, ua-parser-js@npm:^0.7.30": + version: 0.7.36 + resolution: "ua-parser-js@npm:0.7.36" + checksum: 04e18e7f6bf4964a10d74131ea9784c7f01d0c2d3b96f73340ac0a1f8e83d010b99fd7d425e7a2100fa40c58b72f6201408cbf4baa2df1103637f96fb59f2a30 languageName: node linkType: hard @@ -18007,6 +18590,15 @@ __metadata: languageName: node linkType: hard +"unique-filename@npm:^2.0.0": + version: 2.0.1 + resolution: "unique-filename@npm:2.0.1" + dependencies: + unique-slug: ^3.0.0 + checksum: 807acf3381aff319086b64dc7125a9a37c09c44af7620bd4f7f3247fcd5565660ac12d8b80534dcbfd067e6fe88a67e621386dd796a8af828d1337a8420a255f + languageName: node + linkType: hard + "unique-slug@npm:^2.0.0": version: 2.0.2 resolution: "unique-slug@npm:2.0.2" @@ -18016,6 +18608,15 @@ __metadata: languageName: node linkType: hard +"unique-slug@npm:^3.0.0": + version: 3.0.0 + resolution: "unique-slug@npm:3.0.0" + dependencies: + imurmurhash: ^0.1.4 + checksum: 49f8d915ba7f0101801b922062ee46b7953256c93ceca74303bd8e6413ae10aa7e8216556b54dc5382895e8221d04f1efaf75f945c2e4a515b4139f77aa6640c + languageName: node + linkType: hard + "universalify@npm:^0.1.0": version: 0.1.2 resolution: "universalify@npm:0.1.2" @@ -18068,6 +18669,20 @@ __metadata: languageName: node linkType: hard +"update-browserslist-db@npm:^1.0.13": + version: 1.0.13 + resolution: "update-browserslist-db@npm:1.0.13" + dependencies: + escalade: ^3.1.1 + picocolors: ^1.0.0 + peerDependencies: + browserslist: ">= 4.21.0" + bin: + update-browserslist-db: cli.js + checksum: 1e47d80182ab6e4ad35396ad8b61008ae2a1330221175d0abd37689658bdb61af9b705bfc41057fd16682474d79944fb2d86767c5ed5ae34b6276b9bed353322 + languageName: node + linkType: hard + "uri-js@npm:^4.2.2": version: 4.4.1 resolution: "uri-js@npm:4.4.1" @@ -18102,12 +18717,12 @@ __metadata: linkType: hard "url-parse@npm:^1.4.3": - version: 1.5.1 - resolution: "url-parse@npm:1.5.1" + version: 1.5.10 + resolution: "url-parse@npm:1.5.10" dependencies: querystringify: ^2.1.1 requires-port: ^1.0.0 - checksum: ce5c400db52d83b941944502000081e2338e46834cf16f2888961dc034ea5d49dbeb85ac8fdbe28c3fe738c09320a71a2f6d9286b748895cd464b1e208b6b991 + checksum: fbdba6b1d83336aca2216bbdc38ba658d9cfb8fc7f665eb8b17852de638ff7d1a162c198a8e4ed66001ddbf6c9888d41e4798912c62b4fd777a31657989f7bdf languageName: node linkType: hard @@ -18374,6 +18989,13 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^3.0.0": + version: 3.0.1 + resolution: "webidl-conversions@npm:3.0.1" + checksum: c92a0a6ab95314bde9c32e1d0a6dfac83b578f8fa5f21e675bc2706ed6981bc26b7eb7e6a1fab158e5ce4adf9caa4a0aee49a52505d4d13c7be545f15021b17c + languageName: node + linkType: hard + "webidl-conversions@npm:^4.0.2": version: 4.0.2 resolution: "webidl-conversions@npm:4.0.2" @@ -18561,6 +19183,16 @@ __metadata: languageName: node linkType: hard +"whatwg-url@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-url@npm:5.0.0" + dependencies: + tr46: ~0.0.3 + webidl-conversions: ^3.0.0 + checksum: b8daed4ad3356cc4899048a15b2c143a9aed0dfae1f611ebd55073310c7b910f522ad75d727346ad64203d7e6c79ef25eafd465f4d12775ca44b90fa82ed9e2c + languageName: node + linkType: hard + "whatwg-url@npm:^6.4.1": version: 6.5.0 resolution: "whatwg-url@npm:6.5.0" @@ -18610,7 +19242,7 @@ __metadata: languageName: node linkType: hard -"which@npm:1, which@npm:^1.2.9, which@npm:^1.3.0, which@npm:^1.3.1": +"which@npm:^1.2.9, which@npm:^1.3.0, which@npm:^1.3.1": version: 1.3.1 resolution: "which@npm:1.3.1" dependencies: @@ -18632,16 +19264,7 @@ __metadata: languageName: node linkType: hard -"wide-align@npm:^1.1.0": - version: 1.1.3 - resolution: "wide-align@npm:1.1.3" - dependencies: - string-width: ^1.0.2 || 2 - checksum: d09c8012652a9e6cab3e82338d1874a4d7db2ad1bd19ab43eb744acf0b9b5632ec406bdbbbb970a8f4771a7d5ef49824d038ba70aa884e7723f5b090ab87134d - languageName: node - linkType: hard - -"wide-align@npm:^1.1.5": +"wide-align@npm:^1.1.2, wide-align@npm:^1.1.5": version: 1.1.5 resolution: "wide-align@npm:1.1.5" dependencies: @@ -18651,9 +19274,9 @@ __metadata: linkType: hard "word-wrap@npm:~1.2.3": - version: 1.2.3 - resolution: "word-wrap@npm:1.2.3" - checksum: 30b48f91fcf12106ed3186ae4fa86a6a1842416df425be7b60485de14bec665a54a68e4b5156647dec3a70f25e84d270ca8bc8cd23182ed095f5c7206a938c1f + version: 1.2.5 + resolution: "word-wrap@npm:1.2.5" + checksum: f93ba3586fc181f94afdaff3a6fef27920b4b6d9eaefed0f428f8e07adea2a7f54a5f2830ce59406c8416f033f86902b91eb824072354645eea687dff3691ccb languageName: node linkType: hard @@ -18982,13 +19605,6 @@ __metadata: languageName: node linkType: hard -"yallist@npm:^2.1.2": - version: 2.1.2 - resolution: "yallist@npm:2.1.2" - checksum: 9ba99409209f485b6fcb970330908a6d41fa1c933f75e08250316cce19383179a6b70a7e0721b89672ebb6199cc377bf3e432f55100da6a7d6e11902b0a642cb - languageName: node - linkType: hard - "yallist@npm:^3.0.2": version: 3.1.1 resolution: "yallist@npm:3.1.1" @@ -19033,13 +19649,20 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:^20.2.2": +"yargs-parser@npm:^20.2.2, yargs-parser@npm:^20.2.3": version: 20.2.9 resolution: "yargs-parser@npm:20.2.9" checksum: 8bb69015f2b0ff9e17b2c8e6bfe224ab463dd00ca211eece72a4cd8a906224d2703fb8a326d36fdd0e68701e201b2a60ed7cf81ce0fd9b3799f9fe7745977ae3 languageName: node linkType: hard +"yargs-parser@npm:^21.1.1": + version: 21.1.1 + resolution: "yargs-parser@npm:21.1.1" + checksum: ed2d96a616a9e3e1cc7d204c62ecc61f7aaab633dcbfab2c6df50f7f87b393993fe6640d017759fe112d0cb1e0119f2b4150a87305cc873fd90831c6a58ccf1c + languageName: node + linkType: hard + "yargs-parser@npm:^5.0.1": version: 5.0.1 resolution: "yargs-parser@npm:5.0.1" @@ -19083,6 +19706,21 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^17.2.1": + version: 17.7.2 + resolution: "yargs@npm:17.7.2" + dependencies: + cliui: ^8.0.1 + escalade: ^3.1.1 + get-caller-file: ^2.0.5 + require-directory: ^2.1.1 + string-width: ^4.2.3 + y18n: ^5.0.5 + yargs-parser: ^21.1.1 + checksum: 73b572e863aa4a8cbef323dd911d79d193b772defd5a51aab0aca2d446655216f5002c42c5306033968193bdbf892a7a4c110b0d77954a7fdf563e653967b56a + languageName: node + linkType: hard + "yargs@npm:^7.0.0": version: 7.1.2 resolution: "yargs@npm:7.1.2"