diff --git a/Makefile b/Makefile index 82a94aa7..78a54a04 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ help: ## display a help message shell: ## run a shell on the studio-frontend container docker exec -it dahlia.studio-frontend /bin/bash -attach: +attach: ## attach local standard input, output, and error streams to studio-frontend container docker attach --sig-proxy=false dahlia.studio-frontend up: ## bring up studio-frontend container @@ -34,11 +34,11 @@ from-scratch: ## start development environment from scratch docker build -t edxops/studio-frontend:latest --no-cache . make up -restart: +restart: ## bring container down and back up make down make up -restart-detached: +restart-detached: ## bring container down and back up in detached mode make down make up-detached diff --git a/package-lock.json b/package-lock.json index 73bf093a..648f63d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@edx/studio-frontend", - "version": "1.4.1", + "version": "1.5.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -4474,7 +4474,7 @@ "fsevents": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.1.3.tgz", - "integrity": "sha512-WIr7iDkdmdbxu/Gh6eKEZJL6KPE74/5MEsf2whTOFNxbIoIixogroLdKYqB6FDav4Wavh/lZdzzd3b2KxIXC5Q==", + "integrity": "sha1-EfgjGPX+e7LNIpZaEI6TBiCCFtg=", "dev": true, "optional": true, "requires": { @@ -4484,13 +4484,15 @@ "dependencies": { "abbrev": { "version": "1.1.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.0.tgz", + "integrity": "sha1-0FVMIlZjbi9W58LlrRg/hZQo2B8=", "dev": true, "optional": true }, "ajv": { "version": "4.11.8", - "bundled": true, + "resolved": "https://registry.npmjs.org/ajv/-/ajv-4.11.8.tgz", + "integrity": "sha1-gv+wKynmYq5TvcIK8VlHcGc5xTY=", "dev": true, "optional": true, "requires": { @@ -4500,18 +4502,21 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", "dev": true }, "aproba": { "version": "1.1.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.1.1.tgz", + "integrity": "sha1-ldNgDwdxCqDpKYxyatXs8urLq6s=", "dev": true, "optional": true }, "are-we-there-yet": { "version": "1.1.4", - "bundled": true, + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.4.tgz", + "integrity": "sha1-u13KOCu5TwXhUZQ3PRb9O6HKEQ0=", "dev": true, "optional": true, "requires": { @@ -4521,42 +4526,49 @@ }, "asn1": { "version": "0.2.3", - "bundled": true, + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", + "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=", "dev": true, "optional": true }, "assert-plus": { "version": "0.2.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz", + "integrity": "sha1-104bh+ev/A24qttwIfP+SBAasjQ=", "dev": true, "optional": true }, "asynckit": { "version": "0.4.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", "dev": true, "optional": true }, "aws-sign2": { "version": "0.6.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.6.0.tgz", + "integrity": "sha1-FDQt0428yU0OW4fXY81jYSwOeU8=", "dev": true, "optional": true }, "aws4": { "version": "1.6.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.6.0.tgz", + "integrity": "sha1-g+9cqGCysy5KDe7e6MdxudtXRx4=", "dev": true, "optional": true }, "balanced-match": { "version": "0.4.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz", + "integrity": "sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg=", "dev": true }, "bcrypt-pbkdf": { "version": "1.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz", + "integrity": "sha1-Y7xdy2EzG5K8Bf1SiVPDNGKgb40=", "dev": true, "optional": true, "requires": { @@ -4565,7 +4577,8 @@ }, "block-stream": { "version": "0.0.9", - "bundled": true, + "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", + "integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=", "dev": true, "requires": { "inherits": "2.0.3" @@ -4573,7 +4586,8 @@ }, "boom": { "version": "2.10.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz", + "integrity": "sha1-OciRjO/1eZ+D+UkqhI9iWt0Mdm8=", "dev": true, "requires": { "hoek": "2.16.3" @@ -4581,7 +4595,8 @@ }, "brace-expansion": { "version": "1.1.7", - "bundled": true, + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.7.tgz", + "integrity": "sha1-Pv/DxQ4ABTH7cg6v+A8K6O8jz1k=", "dev": true, "requires": { "balanced-match": "0.4.2", @@ -4590,29 +4605,34 @@ }, "buffer-shims": { "version": "1.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz", + "integrity": "sha1-mXjOMXOIxkmth5MCjDR37wRKi1E=", "dev": true }, "caseless": { "version": "0.12.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", "dev": true, "optional": true }, "co": { "version": "4.6.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", "dev": true, "optional": true }, "code-point-at": { "version": "1.1.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", "dev": true }, "combined-stream": { "version": "1.0.5", - "bundled": true, + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.5.tgz", + "integrity": "sha1-k4NwpXtKUd6ix3wV1cX9+JUWQAk=", "dev": true, "requires": { "delayed-stream": "1.0.0" @@ -4620,22 +4640,26 @@ }, "concat-map": { "version": "0.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", "dev": true }, "core-util-is": { "version": "1.0.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true }, "cryptiles": { "version": "2.0.5", - "bundled": true, + "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz", + "integrity": "sha1-O9/s3GCBR8HGcgL6KR59ylnqo7g=", "dev": true, "requires": { "boom": "2.10.1" @@ -4643,7 +4667,8 @@ }, "dashdash": { "version": "1.14.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", "dev": true, "optional": true, "requires": { @@ -4652,7 +4677,8 @@ "dependencies": { "assert-plus": { "version": "1.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", "dev": true, "optional": true } @@ -4660,7 +4686,8 @@ }, "debug": { "version": "2.6.8", - "bundled": true, + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.8.tgz", + "integrity": "sha1-5zFTHKLt4n0YgiJCfaF4IdaP9Pw=", "dev": true, "optional": true, "requires": { @@ -4669,30 +4696,35 @@ }, "deep-extend": { "version": "0.4.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.4.2.tgz", + "integrity": "sha1-SLaZwn4zS/ifEIkr5DL25MfTSn8=", "dev": true, "optional": true }, "delayed-stream": { "version": "1.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", "dev": true }, "delegates": { "version": "1.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", "dev": true, "optional": true }, "detect-libc": { "version": "1.0.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.2.tgz", + "integrity": "sha1-ca1dIEvxempsqPRQxhRUBm70YeE=", "dev": true, "optional": true }, "ecc-jsbn": { "version": "0.1.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", + "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=", "dev": true, "optional": true, "requires": { @@ -4701,24 +4733,28 @@ }, "extend": { "version": "3.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz", + "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=", "dev": true, "optional": true }, "extsprintf": { "version": "1.0.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.0.2.tgz", + "integrity": "sha1-4QgOBljjALBilJkMxw4VAiNf1VA=", "dev": true }, "forever-agent": { "version": "0.6.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", "dev": true, "optional": true }, "form-data": { "version": "2.1.4", - "bundled": true, + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.1.4.tgz", + "integrity": "sha1-M8GDrPGTJ27KqYFDpp6Uv+4XUNE=", "dev": true, "optional": true, "requires": { @@ -4729,12 +4765,14 @@ }, "fs.realpath": { "version": "1.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true }, "fstream": { "version": "1.0.11", - "bundled": true, + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.11.tgz", + "integrity": "sha1-XB+x8RdHcRTwYyoOtLcbPLD9MXE=", "dev": true, "requires": { "graceful-fs": "4.1.11", @@ -4745,7 +4783,8 @@ }, "fstream-ignore": { "version": "1.0.5", - "bundled": true, + "resolved": "https://registry.npmjs.org/fstream-ignore/-/fstream-ignore-1.0.5.tgz", + "integrity": "sha1-nDHa40dnAY/h0kmyTa2mfQktoQU=", "dev": true, "optional": true, "requires": { @@ -4756,7 +4795,8 @@ }, "gauge": { "version": "2.7.4", - "bundled": true, + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", "dev": true, "optional": true, "requires": { @@ -4772,7 +4812,8 @@ }, "getpass": { "version": "0.1.7", - "bundled": true, + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", "dev": true, "optional": true, "requires": { @@ -4781,7 +4822,8 @@ "dependencies": { "assert-plus": { "version": "1.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", "dev": true, "optional": true } @@ -4789,7 +4831,8 @@ }, "glob": { "version": "7.1.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", "dev": true, "requires": { "fs.realpath": "1.0.0", @@ -4802,18 +4845,21 @@ }, "graceful-fs": { "version": "4.1.11", - "bundled": true, + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", + "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=", "dev": true }, "har-schema": { "version": "1.0.5", - "bundled": true, + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-1.0.5.tgz", + "integrity": "sha1-0mMTX0MwfALGAq/I/pWXDAFRNp4=", "dev": true, "optional": true }, "har-validator": { "version": "4.2.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-4.2.1.tgz", + "integrity": "sha1-M0gdDxu/9gDdID11gSpqX7oALio=", "dev": true, "optional": true, "requires": { @@ -4823,13 +4869,15 @@ }, "has-unicode": { "version": "2.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", "dev": true, "optional": true }, "hawk": { "version": "3.1.3", - "bundled": true, + "resolved": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz", + "integrity": "sha1-B4REvXwWQLD+VA0sm3PVlnjo4cQ=", "dev": true, "requires": { "boom": "2.10.1", @@ -4840,12 +4888,14 @@ }, "hoek": { "version": "2.16.3", - "bundled": true, + "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz", + "integrity": "sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0=", "dev": true }, "http-signature": { "version": "1.1.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz", + "integrity": "sha1-33LiZwZs0Kxn+3at+OE0qPvPkb8=", "dev": true, "optional": true, "requires": { @@ -4856,7 +4906,8 @@ }, "inflight": { "version": "1.0.6", - "bundled": true, + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "dev": true, "requires": { "once": "1.4.0", @@ -4865,18 +4916,21 @@ }, "inherits": { "version": "2.0.3", - "bundled": true, + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", "dev": true }, "ini": { "version": "1.3.4", - "bundled": true, + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.4.tgz", + "integrity": "sha1-BTfLedr1m1mhpRff9wbIbsA5Fi4=", "dev": true, "optional": true }, "is-fullwidth-code-point": { "version": "1.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", "dev": true, "requires": { "number-is-nan": "1.0.1" @@ -4884,24 +4938,28 @@ }, "is-typedarray": { "version": "1.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", "dev": true, "optional": true }, "isarray": { "version": "1.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", "dev": true }, "isstream": { "version": "0.1.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", "dev": true, "optional": true }, "jodid25519": { "version": "1.0.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/jodid25519/-/jodid25519-1.0.2.tgz", + "integrity": "sha1-BtSRIlUJNBlHfUJWM2BuDpB4KWc=", "dev": true, "optional": true, "requires": { @@ -4910,19 +4968,22 @@ }, "jsbn": { "version": "0.1.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", "dev": true, "optional": true }, "json-schema": { "version": "0.2.3", - "bundled": true, + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", "dev": true, "optional": true }, "json-stable-stringify": { "version": "1.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz", + "integrity": "sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8=", "dev": true, "optional": true, "requires": { @@ -4931,19 +4992,22 @@ }, "json-stringify-safe": { "version": "5.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", "dev": true, "optional": true }, "jsonify": { "version": "0.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", + "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=", "dev": true, "optional": true }, "jsprim": { "version": "1.4.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.0.tgz", + "integrity": "sha1-o7h+QCmNjDgFUtjMdiigu5WiKRg=", "dev": true, "optional": true, "requires": { @@ -4955,7 +5019,8 @@ "dependencies": { "assert-plus": { "version": "1.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", "dev": true, "optional": true } @@ -4963,12 +5028,14 @@ }, "mime-db": { "version": "1.27.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.27.0.tgz", + "integrity": "sha1-gg9XIpa70g7CXtVeW13oaeVDbrE=", "dev": true }, "mime-types": { "version": "2.1.15", - "bundled": true, + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.15.tgz", + "integrity": "sha1-pOv1BkCUVpI3uM9wBGd20J/JKu0=", "dev": true, "requires": { "mime-db": "1.27.0" @@ -4976,7 +5043,8 @@ }, "minimatch": { "version": "3.0.4", - "bundled": true, + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "dev": true, "requires": { "brace-expansion": "1.1.7" @@ -4984,12 +5052,14 @@ }, "minimist": { "version": "0.0.8", - "bundled": true, + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", "dev": true }, "mkdirp": { "version": "0.5.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "dev": true, "requires": { "minimist": "0.0.8" @@ -4997,13 +5067,15 @@ }, "ms": { "version": "2.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", "dev": true, "optional": true }, "node-pre-gyp": { "version": "0.6.39", - "bundled": true, + "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.6.39.tgz", + "integrity": "sha512-OsJV74qxnvz/AMGgcfZoDaeDXKD3oY3QVIbBmwszTFkRisTSXbMQyn4UWzUMOtA5SVhrBZOTp0wcoSBgfMfMmQ==", "dev": true, "optional": true, "requires": { @@ -5022,7 +5094,8 @@ }, "nopt": { "version": "4.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz", + "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", "dev": true, "optional": true, "requires": { @@ -5032,7 +5105,8 @@ }, "npmlog": { "version": "4.1.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.0.tgz", + "integrity": "sha512-ocolIkZYZt8UveuiDS0yAkkIjid1o7lPG8cYm05yNYzBn8ykQtaiPMEGp8fY9tKdDgm8okpdKzkvu1y9hUYugA==", "dev": true, "optional": true, "requires": { @@ -5044,24 +5118,28 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", "dev": true }, "oauth-sign": { "version": "0.8.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", + "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=", "dev": true, "optional": true }, "object-assign": { "version": "4.1.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", "dev": true, "optional": true }, "once": { "version": "1.4.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, "requires": { "wrappy": "1.0.2" @@ -5069,19 +5147,22 @@ }, "os-homedir": { "version": "1.0.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", "dev": true, "optional": true }, "os-tmpdir": { "version": "1.0.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", "dev": true, "optional": true }, "osenv": { "version": "0.1.4", - "bundled": true, + "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.4.tgz", + "integrity": "sha1-Qv5tWVPfBsgGS+bxdsPQWqqjRkQ=", "dev": true, "optional": true, "requires": { @@ -5091,35 +5172,41 @@ }, "path-is-absolute": { "version": "1.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true }, "performance-now": { "version": "0.2.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-0.2.0.tgz", + "integrity": "sha1-M+8wxcd9TqIcWlOGnZG1bY8lVeU=", "dev": true, "optional": true }, "process-nextick-args": { "version": "1.0.7", - "bundled": true, + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", + "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=", "dev": true }, "punycode": { "version": "1.4.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", "dev": true, "optional": true }, "qs": { "version": "6.4.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/qs/-/qs-6.4.0.tgz", + "integrity": "sha1-E+JtKK1rD/qpExLNO/cI7TUecjM=", "dev": true, "optional": true }, "rc": { "version": "1.2.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.1.tgz", + "integrity": "sha1-LgPo5C7kULjLPc5lvhv4l04d/ZU=", "dev": true, "optional": true, "requires": { @@ -5131,7 +5218,8 @@ "dependencies": { "minimist": { "version": "1.2.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true, "optional": true } @@ -5139,7 +5227,8 @@ }, "readable-stream": { "version": "2.2.9", - "bundled": true, + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.9.tgz", + "integrity": "sha1-z3jsb0ptHrQ9JkiMrJfwQudLf8g=", "dev": true, "requires": { "buffer-shims": "1.0.0", @@ -5153,7 +5242,8 @@ }, "request": { "version": "2.81.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/request/-/request-2.81.0.tgz", + "integrity": "sha1-xpKJRqDgbF+Nb4qTM0af/aRimKA=", "dev": true, "optional": true, "requires": { @@ -5183,7 +5273,8 @@ }, "rimraf": { "version": "2.6.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.1.tgz", + "integrity": "sha1-wjOOxkPfeht/5cVPqG9XQopV8z0=", "dev": true, "requires": { "glob": "7.1.2" @@ -5191,30 +5282,35 @@ }, "safe-buffer": { "version": "5.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.0.1.tgz", + "integrity": "sha1-0mPKVGls2KMGtcplUekt5XkY++c=", "dev": true }, "semver": { "version": "5.3.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=", "dev": true, "optional": true }, "set-blocking": { "version": "2.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", "dev": true, "optional": true }, "signal-exit": { "version": "3.0.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "dev": true, "optional": true }, "sntp": { "version": "1.0.9", - "bundled": true, + "resolved": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz", + "integrity": "sha1-ZUEYTMkK7qbG57NeJlkIJEPGYZg=", "dev": true, "requires": { "hoek": "2.16.3" @@ -5222,7 +5318,8 @@ }, "sshpk": { "version": "1.13.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.13.0.tgz", + "integrity": "sha1-/yo+T9BEl1Vf7Zezmg/YL6+zozw=", "dev": true, "optional": true, "requires": { @@ -5239,7 +5336,8 @@ "dependencies": { "assert-plus": { "version": "1.0.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", "dev": true, "optional": true } @@ -5247,7 +5345,8 @@ }, "string-width": { "version": "1.0.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "dev": true, "requires": { "code-point-at": "1.1.0", @@ -5257,7 +5356,8 @@ }, "string_decoder": { "version": "1.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.1.tgz", + "integrity": "sha1-YuIA8DmVWmgQ2N8KM//A8BNmLZg=", "dev": true, "requires": { "safe-buffer": "5.0.1" @@ -5265,13 +5365,15 @@ }, "stringstream": { "version": "0.0.5", - "bundled": true, + "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz", + "integrity": "sha1-TkhM1N5aC7vuGORjB3EKioFiGHg=", "dev": true, "optional": true }, "strip-ansi": { "version": "3.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "dev": true, "requires": { "ansi-regex": "2.1.1" @@ -5279,13 +5381,15 @@ }, "strip-json-comments": { "version": "2.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", "dev": true, "optional": true }, "tar": { "version": "2.2.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz", + "integrity": "sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE=", "dev": true, "requires": { "block-stream": "0.0.9", @@ -5295,7 +5399,8 @@ }, "tar-pack": { "version": "3.4.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/tar-pack/-/tar-pack-3.4.0.tgz", + "integrity": "sha1-I74tf2cagzk3bL2wuP4/3r8xeYQ=", "dev": true, "optional": true, "requires": { @@ -5311,7 +5416,8 @@ }, "tough-cookie": { "version": "2.3.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.2.tgz", + "integrity": "sha1-8IH3bkyFcg5sN6X6ztc3FQ2EByo=", "dev": true, "optional": true, "requires": { @@ -5320,7 +5426,8 @@ }, "tunnel-agent": { "version": "0.6.0", - "bundled": true, + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", "dev": true, "optional": true, "requires": { @@ -5329,30 +5436,35 @@ }, "tweetnacl": { "version": "0.14.5", - "bundled": true, + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", "dev": true, "optional": true }, "uid-number": { "version": "0.0.6", - "bundled": true, + "resolved": "https://registry.npmjs.org/uid-number/-/uid-number-0.0.6.tgz", + "integrity": "sha1-DqEOgDXo61uOREnwbaHHMGY7qoE=", "dev": true, "optional": true }, "util-deprecate": { "version": "1.0.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true }, "uuid": { "version": "3.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.0.1.tgz", + "integrity": "sha1-ZUS7ot/ajBzxfmKaOjBeK7H+5sE=", "dev": true, "optional": true }, "verror": { "version": "1.3.6", - "bundled": true, + "resolved": "https://registry.npmjs.org/verror/-/verror-1.3.6.tgz", + "integrity": "sha1-z/XfEpRtKX0rqu+qJoniW+AcAFw=", "dev": true, "optional": true, "requires": { @@ -5361,7 +5473,8 @@ }, "wide-align": { "version": "1.1.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.2.tgz", + "integrity": "sha512-ijDLlyQ7s6x1JgCLur53osjm/UXUYD9+0PbYKrBsYisYXzCxN+HC3mYDNy/dWdmf3AwqwU3CXwDCvsNgGK1S0w==", "dev": true, "optional": true, "requires": { @@ -5370,7 +5483,8 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true, + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true } } diff --git a/package.json b/package.json index 492279cd..82c229fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@edx/studio-frontend", - "version": "1.4.1", + "version": "1.5.0", "description": "The frontend for the Open edX platform", "repository": "edx/studio-frontend", "scripts": { diff --git a/src/components/AssetsPage/AssetsPage.test.jsx b/src/components/AssetsPage/AssetsPage.test.jsx index db9a6397..935edd9c 100644 --- a/src/components/AssetsPage/AssetsPage.test.jsx +++ b/src/components/AssetsPage/AssetsPage.test.jsx @@ -1,8 +1,8 @@ import React from 'react'; import AssetsPage, { types } from './index'; -import { assetActions } from '../../data/constants/actionTypes'; -import courseDetails from '../../utils/testConstants'; +import { assetActions } from '../../data/constants//actionTypes'; +import courseDetails, { testAssetsList } from '../../utils/testConstants'; import { shallowWithIntl } from '../../utils/i18n/enzymeHelper'; import WrappedMessage from '../../utils/i18n/formattedMessageWrapper'; import messages from './displayMessages'; @@ -11,7 +11,9 @@ let wrapper; const defaultProps = { assetsList: [], + assetsStatus: {}, courseDetails, + deletedAsset: {}, filtersMetadata: { assetTypes: { edX: false, @@ -27,7 +29,7 @@ const defaultProps = { searchSettings: { enabled: true, }, - + clearAssetDeletion: () => {}, getAssets: () => {}, }; @@ -388,4 +390,55 @@ describe('', () => { normalAssetsPageRenderTest(); }); + describe('onDeleteStatusAlertClose', () => { + it('calls clearAssetDeletion prop', () => { + const clearAssetDeletionSpy = jest.fn(); + + // run test with no assets to avoid triggering a focus change + wrapper.setProps({ + clearAssetDeletion: clearAssetDeletionSpy, + deletedAsset: testAssetsList[0], + deletedAssetIndex: 0, + }); + + wrapper.instance().onDeleteStatusAlertClose(); + expect(clearAssetDeletionSpy).toHaveBeenCalledTimes(1); + }); + }); + describe('getNextFocusElementOnDelete', () => { + const deleteButtonRefs = testAssetsList + .map(asset => asset.id) + .reduce( + // eslint-disable-next-line no-param-reassign + ((memo, id) => { memo[id] = id; return memo; }), + {}, + ); + + const testData = [ + { deletedAssetIndex: 0 }, + { deletedAssetIndex: testAssetsList.length - 1 }, + { deletedAssetIndex: 2 }, + ]; + + testData.forEach((test) => { + it(`returns the correct element to focus on when element at index ${test.deletedAssetIndex} deleted`, () => { + const deletedAssetIndex = test.deletedAssetIndex; + + wrapper.setProps({ + assetsList: testAssetsList, + assetsStatus: { + type: assetActions.delete.DELETE_ASSET_SUCCESS, + }, + deletedAssetIndex, + deleteAsset: testAssetsList[deletedAssetIndex], + }); + + wrapper.instance().deleteButtonRefs = deleteButtonRefs; + + const nextFocusElementOnDelete = wrapper.instance().getNextFocusElementOnDelete(); + + expect(nextFocusElementOnDelete).toEqual(testAssetsList[deletedAssetIndex].id); + }); + }); + }); }); diff --git a/src/components/AssetsPage/container.jsx b/src/components/AssetsPage/container.jsx index ecb77231..4d896f29 100644 --- a/src/components/AssetsPage/container.jsx +++ b/src/components/AssetsPage/container.jsx @@ -1,20 +1,25 @@ import { connect } from 'react-redux'; import AssetsPage from '.'; -import { getAssets } from '../../data/actions/assets'; +import { clearAssetDeletion, getAssets } from '../../data/actions/assets'; const mapStateToProps = state => ({ assetsList: state.assets, + assetsStatus: state.metadata.status, + assetToDelete: state.metadata.deletion.assetToDelete, courseDetails: state.studioDetails.course, - uploadSettings: state.studioDetails.upload_settings, - status: state.metadata.status, + deletedAsset: state.metadata.deletion.deletedAsset, + deletedAssetIndex: state.metadata.deletion.deletedAssetIndex, filtersMetadata: state.metadata.filters, + uploadSettings: state.studioDetails.upload_settings, searchMetadata: state.metadata.search, searchSettings: state.studioDetails.search_settings, + status: state.metadata.status, }); const mapDispatchToProps = dispatch => ({ + clearAssetDeletion: () => dispatch(clearAssetDeletion()), getAssets: (request, courseDetails) => dispatch(getAssets(request, courseDetails)), }); diff --git a/src/components/AssetsPage/index.jsx b/src/components/AssetsPage/index.jsx index 142147e0..0d1c9944 100644 --- a/src/components/AssetsPage/index.jsx +++ b/src/components/AssetsPage/index.jsx @@ -10,6 +10,7 @@ import WrappedAssetsTable from '../AssetsTable/container'; import WrappedAssetsFilters from '../AssetsFilters/container'; import WrappedPagination from '../Pagination/container'; import WrappedAssetsSearch from '../AssetsSearch/container'; +import WrappedAssetsStatusAlert from '../AssetsStatusAlert/container'; import WrappedAssetsResultsCount from '../AssetsResultsCount/container'; import WrappedAssetsClearFiltersButton from '../AssetsClearFiltersButton/container'; import WrappedMessage from '../../utils/i18n/formattedMessageWrapper'; @@ -29,10 +30,18 @@ export default class AssetsPage extends React.Component { this.state = { pageType: types.SKELETON, }; + + this.statusAlertRef = null; + this.deleteButtonRefs = {}; + + this.getNextFocusElementOnDelete = this.getNextFocusElementOnDelete.bind(this); + this.onDeleteStatusAlertClose = this.onDeleteStatusAlertClose.bind(this); } componentDidMount() { - this.props.getAssets({}, this.props.courseDetails); + if (this.props.assetsList.length === 0) { + this.props.getAssets({}, this.props.courseDetails); + } } componentWillReceiveProps(nextProps) { @@ -41,6 +50,24 @@ export default class AssetsPage extends React.Component { }); } + onDeleteStatusAlertClose = () => { + this.props.clearAssetDeletion(); + + // do not attempt to focus if there are no assets delete buttons to focus on + // TO-DO: determine where the focus should go when the last asset is deleted + if (this.props.assetsList.length > 0) { + const focusElement = this.getNextFocusElementOnDelete(); + focusElement.focus(); + } + } + + getNextFocusElementOnDelete() { + const { deletedAssetIndex, assetsList } = this.props; + + const focusAsset = assetsList[deletedAssetIndex]; + return this.deleteButtonRefs[focusAsset.id]; + } + getPageType = (props) => { const numberOfAssets = props.assetsList.length; const filters = props.filtersMetadata.assetTypes; @@ -101,7 +128,9 @@ export default class AssetsPage extends React.Component {
- + { this.deleteButtonRefs[asset.id] = button; }} + />
@@ -162,6 +191,15 @@ export default class AssetsPage extends React.Component { return (
+
+
+ { this.statusAlertRef = input; }} + onDeleteStatusAlertClose={this.onDeleteStatusAlertClose} + onClose={this.onStatusAlertClose} + /> +
+
{this.props.searchSettings.enabled &&
@@ -193,6 +231,9 @@ AssetsPage.propTypes = { id: PropTypes.string, revision: PropTypes.string, }).isRequired, + deletedAssetIndex: PropTypes.oneOfType([ + PropTypes.number, + ]), // eslint-disable-next-line react/no-unused-prop-types filtersMetadata: PropTypes.shape({ assetTypes: PropTypes.object, @@ -213,4 +254,9 @@ AssetsPage.propTypes = { searchSettings: PropTypes.shape({ enabled: PropTypes.bool, }).isRequired, + clearAssetDeletion: PropTypes.func.isRequired, +}; + +AssetsPage.defaultProps = { + deletedAssetIndex: null, }; diff --git a/src/components/AssetsStatusAlert/AssetsStatusAlert.test.jsx b/src/components/AssetsStatusAlert/AssetsStatusAlert.test.jsx new file mode 100644 index 00000000..2603e76c --- /dev/null +++ b/src/components/AssetsStatusAlert/AssetsStatusAlert.test.jsx @@ -0,0 +1,355 @@ +import React from 'react'; +import { StatusAlert } from '@edx/paragon'; + +import AssestStatusAlert from './index'; +import messages from './displayMessages'; +import WrappedMessage from '../../utils/i18n/formattedMessageWrapper'; +import { assetActions } from '../../data/constants/actionTypes'; +import { mountWithIntl } from '../../utils/i18n/enzymeHelper'; +import { testAssetsList } from '../../utils/testConstants'; + +const statusAlertIsClosed = (wrapper) => { + expect(wrapper.state('statusAlertFields').alertDialog).toEqual(''); + expect(wrapper.state('statusAlertFields').alertType).toEqual('info'); + expect(wrapper.state('statusAlertOpen')).toEqual(false); + expect(wrapper.state('uploadSuccessCount')).toEqual(1); +}; + +const statusAlertIsOpen = (wrapper) => { + expect(wrapper.state('statusAlertOpen')).toEqual(true); + expect(wrapper.find(StatusAlert).exists()).toEqual(true); +}; + +const statusAlertIsCorrectType = (wrapper, alertType) => { + const statusAlert = wrapper.find(StatusAlert); + const statusAlertType = statusAlert.prop('alertType'); + + expect(statusAlertType).toEqual(alertType); + expect(statusAlert.find('div').first().hasClass(`alert-${alertType}`)).toEqual(true); +}; + +const statusAlertHasCorrectMessage = (wrapper, message) => { + const statusAlert = wrapper.find(StatusAlert); + const statusAlertMessage = statusAlert.find(WrappedMessage); + + expect(statusAlertMessage.prop('message')).toEqual(message); +}; + +const triggerOpenStatusAlertWithType = (wrapper, type, extraProps = {}) => { + wrapper.setProps({ + assetsStatus: { + type, + ...extraProps, + }, + }); +}; + +const assetName = testAssetsList[0].display_name; + +const testData = [ + { + actionType: assetActions.delete.DELETE_ASSET_FAILURE, + alertType: 'danger', + message: messages.assetsStatusAlertCantDelete, + extraProps: { + assetName, + }, + }, + { + actionType: assetActions.delete.DELETE_ASSET_SUCCESS, + alertType: 'success', + message: messages.assetsStatusAlertDeleteSuccess, + extraProps: { + assetName, + }, + }, + { + actionType: assetActions.upload.UPLOAD_ASSET_SUCCESS, + alertType: 'success', + message: messages.assetsStatusAlertUploadSuccess, + extraProps: {}, + }, + { + actionType: assetActions.upload.UPLOADING_ASSETS, + alertType: 'info', + message: messages.assetsStatusAlertUploadInProgress, + extraProps: { + count: 1, + }, + }, + { + actionType: assetActions.upload.UPLOAD_EXCEED_MAX_COUNT_ERROR, + alertType: 'danger', + message: messages.assetsStatusAlertTooManyFiles, + extraProps: { + maxFileCount: 1, + }, + }, + { + actionType: assetActions.upload.UPLOAD_EXCEED_MAX_SIZE_ERROR, + alertType: 'danger', + message: messages.assetsStatusAlertTooMuchData, + extraProps: { + maxFileSizeMB: 1, + }, + }, + { + actionType: assetActions.upload.UPLOAD_ASSET_FAILURE, + alertType: 'danger', + message: messages.assetsStatusAlertGenericError, + extraProps: { + asset: { name: assetName }, + }, + }, + { + actionType: assetActions.lock.TOGGLING_LOCK_ASSET_FAILURE, + alertType: 'danger', + message: messages.assetsStatusAlertFailedLock, + extraProps: { + asset: { name: assetName }, + }, + }, + { + actionType: assetActions.clear.CLEAR_FILTERS_FAILURE, + alertType: 'danger', + message: messages.assetsStatusAlertGenericUpdateError, + extraProps: {}, + }, + { + actionType: assetActions.filter.FILTER_UPDATE_FAILURE, + alertType: 'danger', + message: messages.assetsStatusAlertGenericUpdateError, + extraProps: {}, + }, + + { + actionType: assetActions.paginate.PAGE_UPDATE_FAILURE, + alertType: 'danger', + message: messages.assetsStatusAlertGenericUpdateError, + extraProps: {}, + }, + + { + actionType: assetActions.sort.SORT_UPDATE_FAILURE, + alertType: 'danger', + message: messages.assetsStatusAlertGenericUpdateError, + extraProps: {}, + }, +]; + +const defaultProps = { + assetsStatus: {}, + clearAssetsStatus: () => { }, + deletedAsset: {}, + onDeleteStatusAlertClose: () => { }, +}; + +let wrapper; + +describe('AssetsStatusAlert', () => { + describe('renders', () => { + testData.forEach((test) => { + it(`on ${test.actionType}`, () => { + wrapper = mountWithIntl( + , + ); + + wrapper.setProps({ + assetsStatus: { + type: test.actionType, + ...test.extraProps, + }, + }); + + statusAlertIsOpen(wrapper); + statusAlertIsCorrectType(wrapper, test.alertType); + statusAlertHasCorrectMessage(wrapper, test.message); + }); + }); + + it('closed by the default', () => { + wrapper = mountWithIntl( + , + ); + + statusAlertIsClosed(wrapper); + }); + + it('calls statusAlertRef prop', () => { + const statusAlertRefSpy = jest.fn(); + + wrapper = mountWithIntl( + , + ); + + expect(statusAlertRefSpy).toHaveBeenCalledTimes(1); + }); + }); + describe('behaves', () => { + it('opening status alert focuses on the close button', () => { + wrapper = mountWithIntl( + , + ); + + triggerOpenStatusAlertWithType(wrapper, assetActions.sort.SORT_UPDATE_FAILURE); + + const statusAlert = wrapper.find(StatusAlert); + const closeStatusAlertButton = statusAlert.find('button').filterWhere(button => button.text() === '×'); + + expect(closeStatusAlertButton.html()).toEqual(document.activeElement.outerHTML); + }); + + it('clicking close button closes the status alert', () => { + wrapper = mountWithIntl( + , + ); + + triggerOpenStatusAlertWithType(wrapper, assetActions.sort.SORT_UPDATE_FAILURE); + + const statusAlert = wrapper.find(StatusAlert); + const closeStatusAlertButton = statusAlert.find('button').filterWhere(button => button.text() === '×'); + + closeStatusAlertButton.simulate('click'); + statusAlertIsClosed(wrapper); + }); + + it('clicking close button calls clearAssetsStatusProp', () => { + const clearAssetsStatusSpy = jest.fn(); + + wrapper = mountWithIntl( + , + ); + + triggerOpenStatusAlertWithType(wrapper, assetActions.sort.SORT_UPDATE_FAILURE); + + const statusAlert = wrapper.find(StatusAlert); + const closeStatusAlertButton = statusAlert.find('button').filterWhere(button => button.text() === '×'); + + closeStatusAlertButton.simulate('click'); + expect(clearAssetsStatusSpy).toHaveBeenCalledTimes(1); + }); + + it('clicking close button on delete status alert closes the status alert', () => { + wrapper = mountWithIntl( + , + ); + + const extraProps = { + asset: { name: assetName }, + }; + + triggerOpenStatusAlertWithType(wrapper, assetActions.delete.DELETE_ASSET_SUCCESS, extraProps); + + const statusAlert = wrapper.find(StatusAlert); + const closeStatusAlertButton = statusAlert.find('button').filterWhere(button => button.text() === '×'); + + closeStatusAlertButton.simulate('click'); + statusAlertIsClosed(wrapper); + }); + + it('clicking close button calls clearAssetsStatusProp', () => { + const clearAssetsStatusSpy = jest.fn(); + + wrapper = mountWithIntl( + , + ); + + const extraProps = { + asset: { name: assetName }, + }; + + triggerOpenStatusAlertWithType(wrapper, assetActions.delete.DELETE_ASSET_SUCCESS, extraProps); + + const statusAlert = wrapper.find(StatusAlert); + const closeStatusAlertButton = statusAlert.find('button').filterWhere(button => button.text() === '×'); + + closeStatusAlertButton.simulate('click'); + expect(clearAssetsStatusSpy).toHaveBeenCalledTimes(1); + }); + + it('clicking close button calls onDeleteStatusAlertClose', () => { + const onDeleteStatusAlertCloseSpy = jest.fn(); + + wrapper = mountWithIntl( + , + ); + + const extraProps = { + asset: { name: assetName }, + }; + + triggerOpenStatusAlertWithType(wrapper, assetActions.delete.DELETE_ASSET_SUCCESS, extraProps); + + const statusAlert = wrapper.find(StatusAlert); + const closeStatusAlertButton = statusAlert.find('button').filterWhere(button => button.text() === '×'); + + closeStatusAlertButton.simulate('click'); + expect(onDeleteStatusAlertCloseSpy).toHaveBeenCalledTimes(1); + }); + + it('increments uploadSuccessCount on UPLOAD_ASSET_SUCCESS', () => { + wrapper = mountWithIntl( + , + ); + + const initialUploadSuccessCount = wrapper.state('uploadSuccessCount'); + + triggerOpenStatusAlertWithType(wrapper, assetActions.upload.UPLOAD_ASSET_SUCCESS); + + expect(wrapper.state('uploadSuccessCount')).toEqual(initialUploadSuccessCount + 1); + }); + + it('default updateStatusAlertFields condition does not modify status alert from default status alert state', () => { + wrapper = mountWithIntl( + , + ); + + // SORT_UPDATE will hit the default case of the switch statement in updateStatusAlertFields + triggerOpenStatusAlertWithType(wrapper, assetActions.sort.SORT_UPDATE); + + statusAlertIsClosed(wrapper); + }); + + it('default updateStatusAlertFields condition does not modify status alert from open status alert', () => { + wrapper = mountWithIntl( + , + ); + + triggerOpenStatusAlertWithType(wrapper, assetActions.sort.SORT_UPDATE_FAILURE); + + const initialState = wrapper.state(); + + triggerOpenStatusAlertWithType(wrapper, assetActions.sort.SORT_UPDATE); + + expect(initialState).toEqual(wrapper.state()); + }); + }); +}); diff --git a/src/components/AssetsStatusAlert/container.jsx b/src/components/AssetsStatusAlert/container.jsx new file mode 100644 index 00000000..025ba749 --- /dev/null +++ b/src/components/AssetsStatusAlert/container.jsx @@ -0,0 +1,20 @@ +import { connect } from 'react-redux'; + +import { clearAssetsStatus } from '../../data/actions/assets'; +import AssetsStatusAlert from '.'; + +const mapStateToProps = state => ({ + assetsStatus: state.metadata.status, + deletedAsset: state.metadata.deletion.deletedAsset, +}); + +const mapDispatchToProps = dispatch => ({ + clearAssetsStatus: () => dispatch(clearAssetsStatus()), +}); + +const WrappedAssetsStatusAlert = connect( + mapStateToProps, + mapDispatchToProps, +)(AssetsStatusAlert); + +export default WrappedAssetsStatusAlert; diff --git a/src/components/AssetsStatusAlert/displayMessages.jsx b/src/components/AssetsStatusAlert/displayMessages.jsx new file mode 100644 index 00000000..f468541b --- /dev/null +++ b/src/components/AssetsStatusAlert/displayMessages.jsx @@ -0,0 +1,56 @@ +import { defineMessages } from 'react-intl'; + +const messages = defineMessages({ + assetsStatusAlertGenericUpdateError: { + id: 'assetsTableGenericUpdateError', + defaultMessage: 'The action could not be completed. Refresh the page, and then try the action again.', + description: 'States that the action could not be completed and asks the user to refresh the page and try the action again', + }, + assetsStatusAlertCantDelete: { + id: 'assetsTableCantDelete', + defaultMessage: 'Unable to delete {assetName}.', + description: 'States that an item could not be deleted', + }, + assetsStatusAlertDeleteSuccess: { + id: 'assetsTableDeleteSuccess', + defaultMessage: '{assetName} has been deleted.', + description: 'States that an item was successfully deleted', + }, + assetsStatusAlertUploadSuccess: { + id: 'assetsTableUploadSuccess', + defaultMessage: '{uploaded_count} files successfully uploaded.', + description: 'States that files were successfully uploaded', + }, + assetsStatusAlertUploadInProgress: { + id: 'assetsTableUploadInProgress', + defaultMessage: '{uploading_count} files uploading.', + description: 'States that the file upload operation is in progress', + }, + assetsStatusAlertTooManyFiles: { + id: 'assetsTableTooManyFiles', + defaultMessage: 'The maximum number of files for an upload is {max_count}. No files were uploaded.', + description: 'Error message shown when too many files are selected for upload', + }, + assetsStatusAlertTooMuchData: { + id: 'assetsTableTooMuchData', + defaultMessage: 'The maximum size for an upload is {max_size} MB. No files were uploaded.', + description: 'Error message shown when too much data is being uploaded', + }, + assetsStatusAlertGenericError: { + id: 'assetsTableGenericError', + defaultMessage: 'Error uploading {assetName}. Try again.', + description: 'Generic error message while uploading files', + }, + assetsStatusAlertFailedLock: { + id: 'assetsTableFailedLock', + defaultMessage: 'Failed to toggle lock for {assetName}.', + description: 'States that there was a failure toggling an item\'s lock status', + }, + assetsStatusAlertLoadingStatus: { + id: 'assetsTableLoadingStatus', + defaultMessage: 'Loading', + description: 'Indicates something is loading', + }, +}); + +export default messages; diff --git a/src/components/AssetsStatusAlert/index.jsx b/src/components/AssetsStatusAlert/index.jsx new file mode 100644 index 00000000..50421cb5 --- /dev/null +++ b/src/components/AssetsStatusAlert/index.jsx @@ -0,0 +1,211 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { StatusAlert } from '@edx/paragon'; +import { FormattedNumber } from 'react-intl'; + +import { assetActions } from '../../data/constants/actionTypes'; +import WrappedMessage from '../../utils/i18n/formattedMessageWrapper'; +import messages from './displayMessages'; + +const defaultState = { + statusAlertFields: { + alertDialog: '', + alertType: 'info', + }, + statusAlertOpen: false, + uploadSuccessCount: 1, +}; + +export default class AssetsStatusAlert extends React.Component { + constructor(props) { + super(props); + this.state = defaultState; + + this.statusAlertRef = {}; + + this.closeStatusAlert = this.closeStatusAlert.bind(this); + this.updateStatusAlertFields = this.updateStatusAlertFields.bind(this); + this.updateUploadSuccessCount = this.updateUploadSuccessCount.bind(this); + + this.closeDeleteStatus = this.closeDeleteStatus.bind(this); + } + + componentWillReceiveProps(nextProps) { + const { assetsStatus, deletedAsset } = nextProps; + this.updateStatusAlertFields(assetsStatus, deletedAsset); + } + + updateStatusAlertFields(assetsStatus, deletedAsset) { + const assetName = deletedAsset.display_name; + let alertDialog; + let alertType; + + const genericUpdateError = ( + + ); + + switch (assetsStatus.type) { + case assetActions.delete.DELETE_ASSET_FAILURE: + alertDialog = ( + + ); + alertType = 'danger'; + break; + case assetActions.delete.DELETE_ASSET_SUCCESS: + alertDialog = ( + + ); + alertType = 'success'; + break; + case assetActions.upload.UPLOAD_ASSET_SUCCESS: + this.updateUploadSuccessCount(); + alertDialog = ( + ) }} + /> + ); + alertType = 'success'; + break; + case assetActions.upload.UPLOADING_ASSETS: + this.closeStatusAlert(); + alertDialog = ( + ) }} + /> + ); + alertType = 'info'; + break; + case assetActions.upload.UPLOAD_EXCEED_MAX_COUNT_ERROR: + alertDialog = ( + ) }} + /> + ); + alertType = 'danger'; + break; + case assetActions.upload.UPLOAD_EXCEED_MAX_SIZE_ERROR: + alertDialog = ( + ) }} + /> + ); + alertType = 'danger'; + break; + case assetActions.upload.UPLOAD_ASSET_FAILURE: + alertDialog = ( + + ); + alertType = 'danger'; + break; + case assetActions.lock.TOGGLING_LOCK_ASSET_FAILURE: + alertDialog = ( + + ); + alertType = 'danger'; + break; + case assetActions.clear.CLEAR_FILTERS_FAILURE: + case assetActions.filter.FILTER_UPDATE_FAILURE: + case assetActions.paginate.PAGE_UPDATE_FAILURE: + case assetActions.sort.SORT_UPDATE_FAILURE: + alertDialog = genericUpdateError; + alertType = 'danger'; + break; + default: + return; + } + + this.setState({ + statusAlertOpen: true, + statusAlertFields: { + alertDialog, + alertType, + }, + }); + this.statusAlertRef.focus(); + } + + closeDeleteStatus = () => { + this.closeStatusAlert(); + this.props.onDeleteStatusAlertClose(); + } + + closeStatusAlert() { + this.props.clearAssetsStatus(); + + // clear out all status related state + this.setState(defaultState); + } + + updateUploadSuccessCount() { + this.setState({ + uploadSuccessCount: this.state.uploadSuccessCount + 1, + }); + } + + render() { + const { assetsStatus, statusAlertRef } = this.props; + const { statusAlertFields, statusAlertOpen } = this.state; + + let onClose = this.closeStatusAlert; + if (Object.prototype.hasOwnProperty.call(assetActions.delete, assetsStatus.type)) { + onClose = this.closeDeleteStatus; + } + + return ( + { + this.statusAlertRef = input; + statusAlertRef(input); + }} + /> + ); + } +} + +AssetsStatusAlert.propTypes = { + assetsStatus: PropTypes.shape({ + response: PropTypes.object, + type: PropTypes.string, + }).isRequired, + clearAssetsStatus: PropTypes.func.isRequired, + deletedAsset: PropTypes.shape({ + display_name: PropTypes.string, + content_type: PropTypes.string, + url: PropTypes.string, + date_added: PropTypes.string, + id: PropTypes.string, + portable_url: PropTypes.string, + thumbnail: PropTypes.string, + external_url: PropTypes.string, + }), + onDeleteStatusAlertClose: PropTypes.func.isRequired, + statusAlertRef: PropTypes.func, +}; + +AssetsStatusAlert.defaultProps = { + assetsStatus: {}, + deletedAsset: {}, + statusAlertRef: () => {}, +}; diff --git a/src/components/AssetsTable/AssetsTable.test.jsx b/src/components/AssetsTable/AssetsTable.test.jsx index 79cfe913..66935168 100644 --- a/src/components/AssetsTable/AssetsTable.test.jsx +++ b/src/components/AssetsTable/AssetsTable.test.jsx @@ -1,59 +1,32 @@ import React from 'react'; -import { StatusAlert } from '@edx/paragon'; import AssetsTable from './index'; - -import { mountWithIntl } from '../../utils/i18n/enzymeHelper'; +import courseDetails, { testAssetsList } from '../../utils/testConstants'; import { assetActions } from '../../data/constants/actionTypes'; import { assetLoading } from '../../data/constants/loadingTypes'; -import courseDetails from '../../utils/testConstants'; +import { mountWithIntl } from '../../utils/i18n/enzymeHelper'; +import AssetsStatusAlert from '../AssetsStatusAlert/index'; -const thumbnail = '/animal'; -const copyUrl = 'animal'; +const getStatusAlert = () => ( + {}} + onDeleteStatusAlertClose={() => {}} + />); const defaultProps = { - assetsList: [ - { - display_name: 'cat.jpg', - id: 'cat.jpg', - thumbnail, - locked: false, - portable_url: copyUrl, - external_url: copyUrl, - }, - { - display_name: 'dog.png', - id: 'dog.png', - thumbnail, - locked: true, - portable_url: null, - external_url: null, - }, - { - display_name: 'bird.json', - id: 'bird.json', - thumbnail: null, - locked: false, - portable_url: null, - external_url: copyUrl, - }, - { - display_name: 'fish.doc', - id: 'fish.doc', - thumbnail: null, - locked: false, - portable_url: copyUrl, - external_url: null, - }, - ], + assetsList: testAssetsList, assetsSortMetadata: {}, assetsStatus: {}, + assetToDelete: {}, courseDetails, courseFilesDocs: 'testUrl', clearAssetsStatus: () => {}, deleteAsset: () => {}, - updateSort: () => {}, + stageAssetDeletion: () => {}, + statusAlertRef: getStatusAlert(), toggleLockAsset: () => {}, + unstageAssetDeletion: () => {}, + updateSort: () => {}, }; const defaultColumns = [ @@ -90,19 +63,6 @@ const defaultColumns = [ }, ]; -const clearStatus = (wrapper) => { - wrapper.setProps({ assetsStatus: {} }); -}; - -const getMockForDeleteAsset = (wrapper, assetToDeleteId) => ( - jest.fn(() => { - wrapper.setProps({ - assetsList: defaultProps.assetsList.filter(asset => asset.id !== assetToDeleteId), - assetsStatus: { type: assetActions.delete.DELETE_ASSET_SUCCESS }, - }); - }) -); - const getMockForTogglingLockAsset = (wrapper, assetToToggle) => ( jest.fn(() => { wrapper.setProps({ @@ -353,6 +313,21 @@ describe('', () => { expect(wrapper.state('modalOpen')).toEqual(false); }); + it('calls unstageAssetDeletion when Cancel button clicked', () => { + const unstageAssetDeletionSpy = jest.fn(); + + wrapper.setProps({ + unstageAssetDeletion: unstageAssetDeletionSpy, + }); + + const trashButtons = wrapper.find('button').filterWhere(button => button.hasClass('fa-trash')); + trashButtons.at(0).simulate('click'); + + const closeButton = modal.find('button').filterWhere(button => button.text() === 'Cancel'); + closeButton.simulate('click'); + + expect(unstageAssetDeletionSpy).toHaveBeenCalledTimes(1); + }); }); describe('deleteAsset', () => { it('calls deleteAsset function prop correctly', () => { @@ -362,6 +337,7 @@ describe('', () => { , ); @@ -373,11 +349,11 @@ describe('', () => { expect(deleteAssetSpy).toHaveBeenCalledTimes(1); expect(deleteAssetSpy).toHaveBeenCalledWith( - defaultProps.assetsList[0].id, + defaultProps.assetsList[0], defaultProps.courseDetails, ); }); - it('closes on deleteAsset call', () => { + it('closes modal on deleteAsset call', () => { wrapper = mountWithIntl( ', () => { trashButtons.at(0).simulate('click'); expect(wrapper.state('modalOpen')).toEqual(true); }); - it('sets assetToDelete correctly', () => { - const trashButtonAriaLabel = `Delete ${defaultProps.assetsList[0].display_name}`; - expect(trashButtons.at(0).prop('aria-label')).toEqual(trashButtonAriaLabel); + it('calls stageAssetDeletion prop', () => { + const stageAssetDeletionSpy = jest.fn(); + + wrapper.setProps({ + stageAssetDeletion: stageAssetDeletionSpy, + }); trashButtons.at(0).simulate('click'); - expect(wrapper.state('assetToDelete')).toEqual(defaultProps.assetsList[0]); + + expect(stageAssetDeletionSpy).toHaveBeenCalledTimes(1); + expect(stageAssetDeletionSpy).toHaveBeenCalledWith(defaultProps.assetsList[0], 0); }); it('sets elementToFocusOnModalClose correctly', () => { trashButtons.at(0).simulate('click'); @@ -427,7 +408,6 @@ describe('', () => { let closeButton; let modal; let trashButtons; - let mockDeleteAsset; beforeEach(() => { wrapper = mountWithIntl( @@ -451,109 +431,14 @@ describe('', () => { expect(trashButtons.at(0).html()).toEqual(document.activeElement.outerHTML); }); - - it('moves from modal to status alert on asset delete', () => { - const deleteButton = wrapper.find('[role="dialog"] button').filterWhere(button => button.hasClass('btn-primary') && button.text() === 'Permanently delete'); - trashButtons.at(0).simulate('click'); - expect(closeButton.html()).toEqual(document.activeElement.outerHTML); - - mockDeleteAsset = getMockForDeleteAsset(wrapper, 0); - wrapper.setProps({ deleteAsset: mockDeleteAsset }); - deleteButton.simulate('click'); - const statusAlert = wrapper.find(StatusAlert); - const closeStatusAlertButton = statusAlert.find('button').filterWhere(button => button.text() === '×'); - expect(closeStatusAlertButton.html()).toEqual(document.activeElement.outerHTML); - }); - - const testData = [ - { - assetToDeleteIndex: 0, - trashButtonIndex: 0, - newFocusIndex: 0, - }, - { - assetToDeleteIndex: 2, - trashButtonIndex: 2, - newFocusIndex: 1, - }, - ]; - - testData.forEach((test) => { - it(`moves to correct asset trashcan icon after asset ${test.assetToDeleteIndex} deleted`, () => { - const assetToDeleteId = defaultProps.assetsList[test.assetToDeleteIndex].id; - - mockDeleteAsset = getMockForDeleteAsset(wrapper, assetToDeleteId); - - wrapper.setProps({ deleteAsset: mockDeleteAsset }); - - const deleteButton = wrapper.find('[role="dialog"] button').filterWhere(button => button.hasClass('btn-primary') && button.text() === 'Permanently delete'); - - trashButtons.at(test.trashButtonIndex).simulate('click'); - - deleteButton.simulate('click'); - expect(mockDeleteAsset).toHaveBeenCalledTimes(1); - - const statusAlert = wrapper.find(StatusAlert); - const closeStatusAlertButton = statusAlert.find('button').filterWhere(button => button.text() === '×'); - wrapper.setProps({ - clearAssetsStatus: () => clearStatus(wrapper), - }); - closeStatusAlertButton.simulate('click'); - - expect(mockDeleteAsset).toHaveBeenCalledTimes(1); - expect(mockDeleteAsset).toHaveBeenCalledWith( - assetToDeleteId, - defaultProps.courseDetails, - ); - - // This gets the new trashcans after the asset delete. - trashButtons = wrapper.find('button').filterWhere(button => button.hasClass('fa-trash')); - - expect(trashButtons.at(test.newFocusIndex).html()) - .toEqual(document.activeElement.outerHTML); - }); - }); - }); - describe('status alert', () => { - describe('renders', () => { - const assetsStatuses = [ - assetActions.clear.CLEAR_FILTERS_FAILURE, - assetActions.filter.FILTER_UPDATE_FAILURE, - assetActions.paginate.PAGE_UPDATE_FAILURE, - assetActions.sort.SORT_UPDATE_FAILURE, - ]; - - beforeEach(() => { - wrapper = mountWithIntl( - , - ); - }); - - assetsStatuses.forEach((statusType) => { - it(`on ${statusType}`, () => { - const assetsStatus = { - type: statusType, - }; - - wrapper.setProps({ - assetsStatus, - }); - - const statusAlert = wrapper.find(StatusAlert); - expect(statusAlert.prop('alertType')).toEqual('danger'); - expect(statusAlert.find('.alert-dialog').text()).toEqual('The action could not be completed. Refresh the page, and then try the action again.'); - }); - }); - }); }); }); -describe('Lock asset', () => { +describe('Lock Asset', () => { const getLockedButtons = () => wrapper.find('button .fa-lock'); const getUnlockedButtons = () => wrapper.find('button .fa-unlock'); const getLockingButtons = () => wrapper.find('button > .fa-spinner'); + beforeEach(() => { wrapper = mountWithIntl( { />, ); }); + it('renders icons and buttons', () => { const lockedButtons = getLockedButtons(); expect(lockedButtons).toHaveLength(1); @@ -568,6 +454,7 @@ describe('Lock asset', () => { const unlockedButtons = getUnlockedButtons(); expect(unlockedButtons).toHaveLength(3); }); + it('can toggle state', () => { const assetToToggle = 'dog.png'; const mockToggle = getMockForTogglingLockAsset(wrapper, assetToToggle); @@ -600,172 +487,4 @@ describe('Lock asset', () => { expect(getUnlockedButtons()).toHaveLength(4); expect(getLockedButtons()).toHaveLength(0); }); - it('displays locking errors', () => { - wrapper.setProps({ - assetsStatus: { - response: {}, - type: assetActions.lock.TOGGLING_LOCK_ASSET_FAILURE, - asset: { name: 'marmoset.png' }, - }, - }); - const statusAlert = wrapper.find(StatusAlert); - expect(statusAlert.prop('alertType')).toEqual('danger'); - expect(statusAlert.find('.alert-dialog').text()).toEqual('Failed to toggle lock for marmoset.png.'); - }); -}); - -describe('displays status alert properly', () => { - it('renders success alert on success', () => { - wrapper = mountWithIntl( - , - ); - - wrapper.setProps({ - assetsStatus: { - response: {}, - type: assetActions.delete.DELETE_ASSET_SUCCESS, - }, - }); - const statusAlert = wrapper.find(StatusAlert); - const statusAlertType = statusAlert.prop('alertType'); - - expect(statusAlertType).toEqual('success'); - expect(statusAlert.find('div').first().hasClass('alert-success')).toEqual(true); - }); - - it('renders danger alert on error', () => { - wrapper = mountWithIntl( - , - ); - - wrapper.setProps({ - assetsStatus: { - response: {}, - type: assetActions.delete.DELETE_ASSET_FAILURE, - }, - }); - const statusAlert = wrapper.find(StatusAlert); - const statusAlertType = statusAlert.prop('alertType'); - - expect(statusAlertType).toEqual('danger'); - expect(statusAlert.find('div').first().hasClass('alert-danger')).toEqual(true); - }); - - it('hides the alert when state cleared', () => { - wrapper = mountWithIntl( - , - ); - let statusAlert = wrapper.find(StatusAlert); - - statusAlert = wrapper.find(StatusAlert); - expect(statusAlert).toBeDefined(); - - wrapper.setState({ assetsStatus: {} }); - statusAlert = wrapper.find(StatusAlert); - expect(statusAlert.find('.alert-dialog').text()).toEqual(''); - }); - - it('clears uploading assets on keyDown', () => { - wrapper = mountWithIntl( - , - ); - wrapper.setProps({ - clearAssetsStatus: () => clearStatus(wrapper), - }); - wrapper.setProps({ assetsStatus: { type: assetActions.upload.UPLOADING_ASSETS, count: 77 } }); - - let statusAlert = wrapper.find(StatusAlert); - expect(statusAlert.find('.alert-dialog').text()).toEqual('77 files uploading.'); - - statusAlert.find('button').at(0).simulate('keyDown', { key: 'Enter' }); - expect(wrapper.props().assetsStatus).toEqual({}); - - statusAlert = wrapper.find(StatusAlert); - expect(statusAlert.find('.alert-dialog').text()).toEqual(''); - }); -}); - -describe('Upload statuses', () => { - beforeEach(() => { - wrapper = mountWithIntl( - , - ); - }); - - it('renders uploading', () => { - wrapper.setProps({ - assetsStatus: { - type: assetActions.upload.UPLOADING_ASSETS, - count: 885, - }, - }); - const statusAlert = wrapper.find(StatusAlert); - expect(statusAlert.find('.alert-dialog').text()).toEqual('885 files uploading.'); - }); - - it('renders upload success', () => { - wrapper.setState({ uploadSuccessCount: 5 }); - wrapper.setProps({ - assetsStatus: { - type: assetActions.upload.UPLOAD_ASSET_SUCCESS, - }, - }); - - const statusAlert = wrapper.find(StatusAlert); - expect(statusAlert.find('.alert-dialog').text()).toEqual('5 files successfully uploaded.'); - }); - - it('renders upload success', () => { - wrapper.setProps({ - assetsStatus: { - type: assetActions.upload.UPLOAD_EXCEED_MAX_COUNT_ERROR, - maxFileCount: 110, - }, - }); - const statusAlert = wrapper.find(StatusAlert); - expect(statusAlert.find('.alert-dialog').text()).toEqual('The maximum number of files for an upload is 110. No files were uploaded.'); - }); - - it('renders upload exceeds maximum file count', () => { - wrapper.setProps({ - assetsStatus: { - type: assetActions.upload.UPLOAD_EXCEED_MAX_COUNT_ERROR, - maxFileCount: 110, - }, - }); - const statusAlert = wrapper.find(StatusAlert); - expect(statusAlert.find('.alert-dialog').text()).toEqual('The maximum number of files for an upload is 110. No files were uploaded.'); - }); - - it('renders upload exceeds maximum file size', () => { - wrapper.setProps({ - assetsStatus: { - type: assetActions.upload.UPLOAD_EXCEED_MAX_SIZE_ERROR, - maxFileSizeMB: 9, - }, - }); - const statusAlert = wrapper.find(StatusAlert); - expect(statusAlert.find('.alert-dialog').text()).toEqual('The maximum size for an upload is 9 MB. No files were uploaded.'); - }); - - it('renders upload failed', () => { - wrapper.setProps({ - assetsStatus: { - type: assetActions.upload.UPLOAD_ASSET_FAILURE, - file: { name: 'quail.png' }, - }, - }); - const statusAlert = wrapper.find(StatusAlert); - expect(statusAlert.find('.alert-dialog').text()).toEqual('Error uploading quail.png. Try again.'); - }); }); diff --git a/src/components/AssetsTable/container.jsx b/src/components/AssetsTable/container.jsx index 1f679f06..9b1a0d2c 100644 --- a/src/components/AssetsTable/container.jsx +++ b/src/components/AssetsTable/container.jsx @@ -1,10 +1,11 @@ import { connect } from 'react-redux'; import AssetsTable from '.'; -import { clearAssetsStatus, deleteAsset, sortUpdate, toggleLockAsset } from '../../data/actions/assets'; +import { clearAssetsStatus, deleteAsset, sortUpdate, stageAssetDeletion, toggleLockAsset, unstageAssetDeletion } from '../../data/actions/assets'; const mapStateToProps = state => ({ assetsList: state.assets, + assetToDelete: state.metadata.deletion.assetToDelete, assetsSortMetadata: state.metadata.sort, assetsStatus: state.metadata.status, courseDetails: state.studioDetails.course, @@ -14,10 +15,12 @@ const mapStateToProps = state => ({ const mapDispatchToProps = dispatch => ({ clearAssetsStatus: () => dispatch(clearAssetsStatus()), - deleteAsset: (assetId, courseDetails) => dispatch(deleteAsset(assetId, courseDetails)), + deleteAsset: (asset, courseDetails) => dispatch(deleteAsset(asset, courseDetails)), + stageAssetDeletion: (asset, index) => dispatch(stageAssetDeletion(asset, index)), + toggleLockAsset: (asset, courseDetails) => dispatch(toggleLockAsset(asset, courseDetails)), updateSort: (sortKey, sortDirection, courseDetails) => dispatch(sortUpdate(sortKey, sortDirection, courseDetails)), - toggleLockAsset: (asset, courseDetails) => dispatch(toggleLockAsset(asset, courseDetails)), + unstageAssetDeletion: () => dispatch(unstageAssetDeletion()), }); const WrappedAssetsTable = connect( diff --git a/src/components/AssetsTable/displayMessages.jsx b/src/components/AssetsTable/displayMessages.jsx index c0d5cbba..500b1ee7 100644 --- a/src/components/AssetsTable/displayMessages.jsx +++ b/src/components/AssetsTable/displayMessages.jsx @@ -61,51 +61,6 @@ const messages = defineMessages({ defaultMessage: 'Updating lock status for {assetName}.', description: 'States that the lock status of an item is updating', }, - assetsTableFailedLock: { - id: 'assetsTableFailedLock', - defaultMessage: 'Failed to toggle lock for {assetName}.', - description: 'States that there was a failure toggling an item\'s lock status', - }, - assetsTableCantDelete: { - id: 'assetsTableCantDelete', - defaultMessage: 'Unable to delete {assetName}.', - description: 'States that an item could not be deleted', - }, - assetsTableDeleteSuccess: { - id: 'assetsTableDeleteSuccess', - defaultMessage: '{assetName} has been deleted.', - description: 'States that an item was successfully deleted', - }, - assetsTableUploadSuccess: { - id: 'assetsTableUploadSuccess', - defaultMessage: '{uploaded_count} files successfully uploaded.', - description: 'States that files were successfully uploaded', - }, - assetsTableUploadInProgress: { - id: 'assetsTableUploadInProgress', - defaultMessage: '{uploading_count} files uploading.', - description: 'States that the file upload operation is in progress', - }, - assetsTableTooManyFiles: { - id: 'assetsTableTooManyFiles', - defaultMessage: 'The maximum number of files for an upload is {max_count}. No files were uploaded.', - description: 'Error message shown when too many files are selected for upload', - }, - assetsTableTooMuchData: { - id: 'assetsTableTooMuchData', - defaultMessage: 'The maximum size for an upload is {max_size} MB. No files were uploaded.', - description: 'Error message shown when too much data is being uploaded', - }, - assetsTableGenericError: { - id: 'assetsTableGenericError', - defaultMessage: 'Error uploading {assetName}. Try again.', - description: 'Generic error message while uploading files', - }, - assetsTableGenericUpdateError: { - id: 'assetsTableGenericUpdateError', - defaultMessage: 'The action could not be completed. Refresh the page, and then try the action again.', - description: 'States that the action could not be completed and asks the user to refresh the page and try the action again', - }, assetsTableStudioLink: { id: 'assetsTableStudioLink', defaultMessage: 'Studio', @@ -156,11 +111,6 @@ const messages = defineMessages({ defaultMessage: 'Any links or references to this file will no longer work. {link}', description: 'Warns of the consequences of deleting an item', }, - assetsTableLoadingStatus: { - id: 'assetsTableLoadingStatus', - defaultMessage: 'Loading', - description: 'Indicates something is loading', - }, }); export default messages; diff --git a/src/components/AssetsTable/index.jsx b/src/components/AssetsTable/index.jsx index da44f64d..91d52fe6 100644 --- a/src/components/AssetsTable/index.jsx +++ b/src/components/AssetsTable/index.jsx @@ -1,12 +1,10 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Table, Button, Modal, StatusAlert, Variant } from '@edx/paragon'; +import { Table, Button, Modal, Variant } from '@edx/paragon'; import classNames from 'classnames'; -import { FormattedNumber } from 'react-intl'; import FontAwesomeStyles from 'font-awesome/css/font-awesome.min.css'; import styles from './AssetsTable.scss'; -import { assetActions } from '../../data/constants/actionTypes'; import { assetLoading } from '../../data/constants/loadingTypes'; import CopyButton from '../CopyButton'; import WrappedMessage from '../../utils/i18n/formattedMessageWrapper'; @@ -16,18 +14,9 @@ export default class AssetsTable extends React.Component { constructor(props) { super(props); this.state = { - assetToDelete: {}, copyButtonIsClicked: false, - deletedAsset: {}, - deletedAssetIndex: null, elementToFocusOnModalClose: {}, modalOpen: false, - statusAlertFields: { - alertDialog: '', - alertType: 'info', - }, - statusAlertOpen: false, - uploadSuccessCount: 1, }; this.columns = { @@ -72,22 +61,13 @@ export default class AssetsTable extends React.Component { }; this.trashcanRefs = {}; - this.statusAlertRef = {}; this.addSupplementalTableElements = this.addSupplementalTableElements.bind(this); this.closeModal = this.closeModal.bind(this); - this.closeStatusAlert = this.closeStatusAlert.bind(this); this.deleteAsset = this.deleteAsset.bind(this); + this.getAssetDeleteButtonRef = this.getAssetDeleteButtonRef.bind(this); this.onCopyButtonClick = this.onCopyButtonClick.bind(this); this.onDeleteClick = this.onDeleteClick.bind(this); - this.renderStatusAlert = this.renderStatusAlert.bind(this); - this.updateUploadSuccessCount = this.updateUploadSuccessCount.bind(this); - } - - - componentWillReceiveProps(nextProps) { - const { assetsStatus } = nextProps; - this.updateStatusAlertFields(assetsStatus); } onSortClick(columnKey) { @@ -106,9 +86,9 @@ export default class AssetsTable extends React.Component { onDeleteClick(index) { const assetToDelete = this.props.assetsList[index]; + this.props.stageAssetDeletion(assetToDelete, index); + this.setState({ - assetToDelete, - deletedAssetIndex: index, elementToFocusOnModalClose: this.trashcanRefs[assetToDelete.id], modalOpen: true, }); @@ -168,26 +148,6 @@ export default class AssetsTable extends React.Component { ); } - getNextFocusElementOnDelete() { - const { assetsStatus } = this.props; - - let deletedIndex = this.state.deletedAssetIndex; - let focusAsset = this.state.deletedAsset; - - switch (assetsStatus.type) { - case assetActions.delete.DELETE_ASSET_SUCCESS: - if (deletedIndex > 0) { - deletedIndex -= 1; - } - focusAsset = this.props.assetsList[deletedIndex]; - break; - default: - break; - } - - return this.trashcanRefs[focusAsset.id]; - } - getLoadingLockButton(asset) { // spinner classes are applied to the span to keep the whole button from spinning const spinnerClasses = [FontAwesomeStyles.fa, FontAwesomeStyles['fa-spinner'], FontAwesomeStyles['fa-spin']]; @@ -266,127 +226,9 @@ export default class AssetsTable extends React.Component { ); } - updateStatusAlertFields(assetsStatus) { - const assetName = this.state.deletedAsset.display_name; - let alertDialog; - let alertType; - - const genericUpdateError = ( - - ); - - switch (assetsStatus.type) { - case assetActions.delete.DELETE_ASSET_FAILURE: - alertDialog = ( - - ); - alertType = 'danger'; - break; - case assetActions.delete.DELETE_ASSET_SUCCESS: - alertDialog = ( - - ); - alertType = 'success'; - break; - case assetActions.upload.UPLOAD_ASSET_SUCCESS: - this.updateUploadSuccessCount(); - alertDialog = ( - ) }} - /> - ); - alertType = 'success'; - break; - case assetActions.upload.UPLOADING_ASSETS: - this.closeStatusAlert(); - alertDialog = ( - ) }} - /> - ); - alertType = 'info'; - break; - case assetActions.upload.UPLOAD_EXCEED_MAX_COUNT_ERROR: - alertDialog = ( - ) }} - /> - ); - alertType = 'danger'; - break; - case assetActions.upload.UPLOAD_EXCEED_MAX_SIZE_ERROR: - alertDialog = ( - ) }} - /> - ); - alertType = 'danger'; - break; - case assetActions.upload.UPLOAD_ASSET_FAILURE: - alertDialog = ( - - ); - alertType = 'danger'; - break; - case assetActions.lock.TOGGLING_LOCK_ASSET_FAILURE: - alertDialog = ( - - ); - alertType = 'danger'; - break; - case assetActions.clear.CLEAR_FILTERS_FAILURE: - alertDialog = genericUpdateError; - alertType = 'danger'; - break; - case assetActions.filter.FILTER_UPDATE_FAILURE: - alertDialog = genericUpdateError; - alertType = 'danger'; - break; - case assetActions.paginate.PAGE_UPDATE_FAILURE: - alertDialog = genericUpdateError; - alertType = 'danger'; - break; - case assetActions.sort.SORT_UPDATE_FAILURE: - alertDialog = genericUpdateError; - alertType = 'danger'; - break; - default: - return; - } - - this.setState({ - statusAlertOpen: true, - statusAlertFields: { - alertDialog, - alertType, - }, - }); - this.statusAlertRef.focus(); - } - - updateUploadSuccessCount() { - const uploadSuccessCount = this.state.uploadSuccessCount + 1; - this.setState({ - uploadSuccessCount, - }); + getAssetDeleteButtonRef(ref, currentAsset) { + this.trashcanRefs[currentAsset.id] = ref; + this.props.deleteButtonRefs(ref, currentAsset); } addSupplementalTableElements() { @@ -405,7 +247,7 @@ export default class AssetsTable extends React.Component { label={''} aria-label={displayText} onClick={() => { this.onDeleteClick(index); }} - inputRef={(ref) => { this.trashcanRefs[currentAsset.id] = ref; }} + inputRef={ref => this.getAssetDeleteButtonRef(ref, currentAsset)} data-identifier="asset-delete-button" />) } @@ -453,45 +295,19 @@ export default class AssetsTable extends React.Component { closeModal() { this.state.elementToFocusOnModalClose.focus(); + this.props.unstageAssetDeletion(); this.setState({ - assetToDelete: {}, - deletedAssetIndex: null, elementToFocusOnModalClose: {}, modalOpen: false, }); } - closeDeleteStatus = () => { - this.getNextFocusElementOnDelete().focus(); - this.closeStatusAlert(); - } - - closeStatusAlert() { - this.props.clearAssetsStatus(); - - // clear out all status related state - this.setState({ - deletedAsset: {}, - deletedAssetIndex: null, - statusAlertOpen: false, - statusAlertFields: { - alertDialog: '', - alertType: 'info', - }, - uploadSuccessCount: 1, - }); - } - deleteAsset() { - const deletedAsset = { ...this.state.assetToDelete }; - - this.props.deleteAsset(this.state.assetToDelete.id, this.props.courseDetails); + this.props.deleteAsset(this.props.assetToDelete, this.props.courseDetails); this.setState({ - assetToDelete: {}, - deletedAsset, - elementToFocusOnModalClose: this.statusAlertRef, + elementToFocusOnModalClose: {}, modalOpen: false, }); } @@ -506,7 +322,7 @@ export default class AssetsTable extends React.Component { title={()} body={this.renderModalBody()} @@ -551,7 +367,7 @@ export default class AssetsTable extends React.Component { message={messages.assetsTableDeleteWarning} tagName="p" values={{ - displayName: {this.state.assetToDelete.display_name}, + displayName: {this.props.assetToDelete.display_name}, }} /> { this.statusAlertRef = input; }} - /> - ); - - return statusAlert; - } - render() { return ( - {this.renderStatusAlert()} {}, }; diff --git a/src/data/actions/assets.js b/src/data/actions/assets.js index 77bcfee7..4165cd75 100644 --- a/src/data/actions/assets.js +++ b/src/data/actions/assets.js @@ -57,7 +57,6 @@ export const getAssets = (parameters, courseDetails) => }; dispatch(updateRequest(requestParameters)); - return clientApi.requestAssets(courseDetails.id, { ...requestParameters }) .then((response) => { if (response.ok) { @@ -198,28 +197,42 @@ export const pageUpdate = (page, courseDetails) => }); }; -export const deleteAssetSuccess = assetId => ({ +export const clearAssetDeletion = () => ({ + type: assetActions.delete.CLEAR_DELETE, +}); + +export const deleteAssetSuccess = asset => ({ type: assetActions.delete.DELETE_ASSET_SUCCESS, - assetId, + asset, }); -export const deleteAssetFailure = assetId => ({ +export const deleteAssetFailure = asset => ({ type: assetActions.delete.DELETE_ASSET_FAILURE, - assetId, + asset, +}); + +export const stageAssetDeletion = (asset, index) => ({ + type: assetActions.delete.STAGE_ASSET_DELETION, + asset, + index, +}); + +export const unstageAssetDeletion = () => ({ + type: assetActions.delete.UNSTAGE_ASSET_DELETION, }); -export const deleteAsset = (assetId, courseDetails) => +export const deleteAsset = (asset, courseDetails) => dispatch => - clientApi.requestDeleteAsset(courseDetails.id, assetId) + clientApi.requestDeleteAsset(courseDetails.id, asset.id) // since the API returns 204 on success and 404 on failure, neither of which have // content, we don't json-ify the response .then((response) => { if (response.ok) { return dispatch(getAssets({}, courseDetails)).then(() => ( - dispatch(deleteAssetSuccess(assetId)) + dispatch(deleteAssetSuccess(asset)) )); } - return dispatch(deleteAssetFailure(assetId)); + return dispatch(deleteAssetFailure(asset)); }); export const togglingLockAsset = asset => ({ diff --git a/src/data/actions/assets.test.jsx b/src/data/actions/assets.test.jsx index a93fb80c..2b8b032a 100644 --- a/src/data/actions/assets.test.jsx +++ b/src/data/actions/assets.test.jsx @@ -2,11 +2,13 @@ import configureStore from 'redux-mock-store'; import fetchMock from 'fetch-mock'; import thunk from 'redux-thunk'; +import deepCopy from './utils'; import endpoints from '../api/endpoints'; -import * as actionCreators from './assets'; -import { filtersInitial, paginationInitial, sortInitial, searchInitial, requestInitial } from '../reducers/assets'; import { assetActions } from '../../data/constants/actionTypes'; -import deepCopy from './utils'; +import { deletionInitial, filtersInitial, paginationInitial, sortInitial, searchInitial, requestInitial } from '../reducers/assets'; +import { testAssetsList } from '../../utils/testConstants'; +import * as actionCreators from './assets'; + const initialState = { assets: [], @@ -16,6 +18,7 @@ const initialState = { request: { ...requestInitial }, sort: { ...sortInitial }, search: { ...searchInitial }, + deletion: { ...deletionInitial }, }, }; @@ -23,8 +26,6 @@ const courseDetails = { id: 'edX', }; -const assetId = 'asset'; - const assetsEndpoint = endpoints.assets; const middlewares = [thunk]; const mockStore = configureStore(middlewares); @@ -70,8 +71,9 @@ describe('Assets Action Creators', () => { expect(store.dispatch(actionCreators.requestAssetsSuccess('response'))).toEqual(expectedAction); }); it('returns expected state from assetDeleteFailure', () => { - const expectedAction = { assetId, type: assetActions.delete.DELETE_ASSET_FAILURE }; - expect(store.dispatch(actionCreators.deleteAssetFailure(assetId))).toEqual(expectedAction); + const asset = testAssetsList[0]; + const expectedAction = { asset, type: assetActions.delete.DELETE_ASSET_FAILURE }; + expect(store.dispatch(actionCreators.deleteAssetFailure(asset))).toEqual(expectedAction); }); it('returns expected state from getAssets success', () => { const requestParameters = {}; @@ -506,6 +508,8 @@ describe('Assets Action Creators', () => { body: {}, }; + const asset = testAssetsList[0]; + fetchMock.deleteOnce(`begin:${assetsEndpoint}`, {}); fetchMock.getOnce(`begin:${assetsEndpoint}`, response); @@ -513,10 +517,10 @@ describe('Assets Action Creators', () => { { type: assetActions.request.REQUESTING_ASSETS }, { type: assetActions.request.UPDATE_REQUEST, newRequest: requestInitial }, { type: assetActions.request.REQUEST_ASSETS_SUCCESS, response: response.body }, - { type: assetActions.delete.DELETE_ASSET_SUCCESS, assetId }, + { type: assetActions.delete.DELETE_ASSET_SUCCESS, asset }, ]; - return store.dispatch(actionCreators.deleteAsset(assetId, courseDetails)).then(() => { + return store.dispatch(actionCreators.deleteAsset(asset, courseDetails)).then(() => { // return of async actions expect(store.getActions()).toEqual(expectedActions); }); @@ -526,17 +530,29 @@ describe('Assets Action Creators', () => { status: 400, }; + const asset = testAssetsList[0]; + fetchMock.once(`begin:${assetsEndpoint}`, response); const expectedActions = [ - { type: assetActions.delete.DELETE_ASSET_FAILURE, assetId }, + { type: assetActions.delete.DELETE_ASSET_FAILURE, asset }, ]; - return store.dispatch(actionCreators.deleteAsset(assetId, courseDetails)).then(() => { + return store.dispatch(actionCreators.deleteAsset(asset, courseDetails)).then(() => { // return of async actions expect(store.getActions()).toEqual(expectedActions); }); }); + it('returns expected state from stageAssetDeletion', () => { + const index = 0; + const asset = testAssetsList[index]; + const expectedAction = { type: assetActions.delete.STAGE_ASSET_DELETION, asset, index }; + expect(store.dispatch(actionCreators.stageAssetDeletion(asset, index))).toEqual(expectedAction); + }); + it('returns expected state from unstageAssetDeletion', () => { + const expectedAction = { type: assetActions.delete.UNSTAGE_ASSET_DELETION }; + expect(store.dispatch(actionCreators.unstageAssetDeletion())).toEqual(expectedAction); + }); it('returns expected state from clearAssetsStatus', () => { const expectedAction = { type: assetActions.clear.CLEAR_ASSETS_STATUS }; expect(store.dispatch(actionCreators.clearAssetsStatus())).toEqual(expectedAction); @@ -546,14 +562,16 @@ describe('Assets Action Creators', () => { status: 200, }; + const asset = testAssetsList[0]; + fetchMock.once(`begin:${assetsEndpoint}`, response); const expectedActions = [ - { type: assetActions.lock.TOGGLING_LOCK_ASSET_SUCCESS, asset: assetId }, - { type: assetActions.lock.TOGGLE_LOCK_ASSET_SUCCESS, asset: assetId }, + { type: assetActions.lock.TOGGLING_LOCK_ASSET_SUCCESS, asset: asset.id }, + { type: assetActions.lock.TOGGLE_LOCK_ASSET_SUCCESS, asset: asset.id }, ]; - return store.dispatch(actionCreators.toggleLockAsset(assetId, courseDetails)).then(() => { + return store.dispatch(actionCreators.toggleLockAsset(asset.id, courseDetails)).then(() => { // return of async actions expect(store.getActions()).toEqual(expectedActions); }); @@ -564,15 +582,16 @@ describe('Assets Action Creators', () => { }; const error = new Error(response); + const asset = testAssetsList[0]; fetchMock.once(`begin:${assetsEndpoint}`, response); const expectedActions = [ - { type: assetActions.lock.TOGGLING_LOCK_ASSET_SUCCESS, asset: assetId }, - { type: assetActions.lock.TOGGLING_LOCK_ASSET_FAILURE, asset: assetId, response: error }, + { type: assetActions.lock.TOGGLING_LOCK_ASSET_SUCCESS, asset: asset.id }, + { type: assetActions.lock.TOGGLING_LOCK_ASSET_FAILURE, asset: asset.id, response: error }, ]; - return store.dispatch(actionCreators.toggleLockAsset(assetId, courseDetails)).then(() => { + return store.dispatch(actionCreators.toggleLockAsset(asset.id, courseDetails)).then(() => { // return of async actions expect(store.getActions()).toEqual(expectedActions); }); diff --git a/src/data/constants/actionTypes.js b/src/data/constants/actionTypes.js index 22632323..2a319228 100644 --- a/src/data/constants/actionTypes.js +++ b/src/data/constants/actionTypes.js @@ -6,8 +6,12 @@ export const assetActions = { CLEAR_FILTERS_FAILURE: 'CLEAR_FILTERS_FAILURE', }, delete: { + CLEAR_DELETE: 'CLEAR_DELETE', DELETE_ASSET_SUCCESS: 'DELETE_ASSET_SUCCESS', DELETE_ASSET_FAILURE: 'DELETE_ASSET_FAILURE', + DELETING_ASSET: 'DELETING_ASSET', + STAGE_ASSET_DELETION: 'STAGE_ASSET_DELETION', + UNSTAGE_ASSET_DELETION: 'UNSTAGE_ASSET_DELETION', }, filter: { FILTER_UPDATED: 'FILTER_UPDATED', diff --git a/src/data/i18n/default/src/components/AssetsStatusAlert/displayMessages.json b/src/data/i18n/default/src/components/AssetsStatusAlert/displayMessages.json new file mode 100644 index 00000000..04259f8f --- /dev/null +++ b/src/data/i18n/default/src/components/AssetsStatusAlert/displayMessages.json @@ -0,0 +1,52 @@ +[ + { + "id": "assetsTableGenericUpdateError", + "description": "States that the action could not be completed and asks the user to refresh the page and try the action again", + "defaultMessage": "The action could not be completed. Refresh the page, and then try the action again." + }, + { + "id": "assetsTableCantDelete", + "description": "States that an item could not be deleted", + "defaultMessage": "Unable to delete {assetName}." + }, + { + "id": "assetsTableDeleteSuccess", + "description": "States that an item was successfully deleted", + "defaultMessage": "{assetName} has been deleted." + }, + { + "id": "assetsTableUploadSuccess", + "description": "States that files were successfully uploaded", + "defaultMessage": "{uploaded_count} files successfully uploaded." + }, + { + "id": "assetsTableUploadInProgress", + "description": "States that the file upload operation is in progress", + "defaultMessage": "{uploading_count} files uploading." + }, + { + "id": "assetsTableTooManyFiles", + "description": "Error message shown when too many files are selected for upload", + "defaultMessage": "The maximum number of files for an upload is {max_count}. No files were uploaded." + }, + { + "id": "assetsTableTooMuchData", + "description": "Error message shown when too much data is being uploaded", + "defaultMessage": "The maximum size for an upload is {max_size} MB. No files were uploaded." + }, + { + "id": "assetsTableGenericError", + "description": "Generic error message while uploading files", + "defaultMessage": "Error uploading {assetName}. Try again." + }, + { + "id": "assetsTableFailedLock", + "description": "States that there was a failure toggling an item's lock status", + "defaultMessage": "Failed to toggle lock for {assetName}." + }, + { + "id": "assetsTableLoadingStatus", + "description": "Indicates something is loading", + "defaultMessage": "Loading" + } +] \ No newline at end of file diff --git a/src/data/i18n/default/transifex_input.json b/src/data/i18n/default/transifex_input.json index e12d4a58..14352907 100644 --- a/src/data/i18n/default/transifex_input.json +++ b/src/data/i18n/default/transifex_input.json @@ -53,6 +53,16 @@ "assetsResultsCountTotal": "Showing {start}-{end} out of {total} total files.", "assetsSearchInputLabel": "Search", "assetsSearchSubmitLabel": "Submit search", + "assetsTableGenericUpdateError": "The action could not be completed. Refresh the page, and then try the action again.", + "assetsTableCantDelete": "Unable to delete {assetName}.", + "assetsTableDeleteSuccess": "{assetName} has been deleted.", + "assetsTableUploadSuccess": "{uploaded_count} files successfully uploaded.", + "assetsTableUploadInProgress": "{uploading_count} files uploading.", + "assetsTableTooManyFiles": "The maximum number of files for an upload is {max_count}. No files were uploaded.", + "assetsTableTooMuchData": "The maximum size for an upload is {max_size} MB. No files were uploaded.", + "assetsTableGenericError": "Error uploading {assetName}. Try again.", + "assetsTableFailedLock": "Failed to toggle lock for {assetName}.", + "assetsTableLoadingStatus": "Loading", "assetsTablePreviewLabel": "Image Preview", "assetsTableNameLabel": "Name", "assetsTableTypeLable": "Type", @@ -65,15 +75,6 @@ "assetsTableLockedObject": "Locked {object}", "assetsTableUnlockedObject": "Unlocked {object}", "assetsTableUpdateLock": "Updating lock status for {assetName}.", - "assetsTableFailedLock": "Failed to toggle lock for {assetName}.", - "assetsTableCantDelete": "Unable to delete {assetName}.", - "assetsTableDeleteSuccess": "{assetName} has been deleted.", - "assetsTableUploadSuccess": "{uploaded_count} files successfully uploaded.", - "assetsTableUploadInProgress": "{uploading_count} files uploading.", - "assetsTableTooManyFiles": "The maximum number of files for an upload is {max_count}. No files were uploaded.", - "assetsTableTooMuchData": "The maximum size for an upload is {max_size} MB. No files were uploaded.", - "assetsTableGenericError": "Error uploading {assetName}. Try again.", - "assetsTableGenericUpdateError": "The action could not be completed. Refresh the page, and then try the action again.", "assetsTableStudioLink": "Studio", "assetsTableWebLink": "Web", "assetsTableCopiedStatus": "Copied", @@ -84,7 +85,6 @@ "assetsTableLearnMore": "Learn more.", "assetsTableDeleteWarning": "Deleting {displayName} cannot be undone.", "assetsTableDeleteConsequences": "Any links or references to this file will no longer work. {link}", - "assetsTableLoadingStatus": "Loading", "paginationButtonDisabled": "button is disabled", "paginationNext": "next", "paginationPrevious": "previous" diff --git a/src/data/reducers/assets.js b/src/data/reducers/assets.js index 12dd12b7..07128cdf 100644 --- a/src/data/reducers/assets.js +++ b/src/data/reducers/assets.js @@ -9,6 +9,12 @@ import { getAssetAPIAttributeFromDatabaseAttribute } from '../../utils/getAssets const defaultAssetTypeFilters = getDefaultFilterState(); +export const deletionInitial = { + assetToDelete: {}, + deletedAsset: {}, + deletedAssetIndex: null, +}; + export const filtersInitial = { assetTypes: { ...defaultAssetTypeFilters }, }; @@ -210,6 +216,31 @@ export const request = (state = requestInitial, action) => { } }; +export const deletion = (state = deletionInitial, action) => { + switch (action.type) { + case assetActions.delete.DELETE_ASSET_SUCCESS: + return { + ...state, + assetToDelete: {}, + deletedAsset: action.asset, + }; + case assetActions.delete.STAGE_ASSET_DELETION: + return { + ...state, + assetToDelete: action.asset, + deletedAssetIndex: action.index, + }; + case assetActions.delete.UNSTAGE_ASSET_DELETION: + return { + ...state, + assetToDelete: {}, + deletedAssetIndex: null, + }; + default: + return state; + } +}; + export const metadata = combineReducers({ filters, pagination, @@ -217,4 +248,5 @@ export const metadata = combineReducers({ sort, status, search, + deletion, }); diff --git a/src/data/reducers/assets.test.jsx b/src/data/reducers/assets.test.jsx index a8b88c46..6e3e49b4 100644 --- a/src/data/reducers/assets.test.jsx +++ b/src/data/reducers/assets.test.jsx @@ -485,4 +485,68 @@ describe('Assets Reducers', () => { }); }); }); + describe('deletion reducer', () => { + const asset = { id: 'asset1' }; + const index = 2; + + beforeEach(() => { + defaultState = reducers.deletionInitial; + }); + it('returns correct state on DELETE_ASSET_SUCCESS action', () => { + const defaultStateWithStagedAsset = { ...defaultState }; + defaultStateWithStagedAsset.assetToDelete = asset; + defaultStateWithStagedAsset.deletedAssetIndex = index; + + action = { + asset, + type: assetActions.delete.DELETE_ASSET_SUCCESS, + }; + + state = reducers.deletion(defaultStateWithStagedAsset, action); + + expect(state).toEqual({ + ...defaultState, + deletedAsset: action.asset, + assetToDelete: {}, + deletedAssetIndex: defaultStateWithStagedAsset.deletedAssetIndex, + }); + }); + it('returns correct state on STAGE_ASSET_DELETION action', () => { + action = { + asset, + index, + type: assetActions.delete.STAGE_ASSET_DELETION, + }; + + state = reducers.deletion(defaultState, action); + + expect(state).toEqual({ + ...defaultState, + assetToDelete: asset, + deletedAssetIndex: index, + }); + }); + it('returns correct state on UNSTAGE_ASSET_DELETION action', () => { + const stageAssetDeletionAction = { + asset, + index, + type: assetActions.delete.STAGE_ASSET_DELETION, + }; + + action = { + type: assetActions.delete.UNSTAGE_ASSET_DELETION, + }; + + // stage an asset first + reducers.deletion(defaultState, stageAssetDeletionAction); + + state = reducers.deletion(defaultState, action); + + expect(state).toEqual({ + ...defaultState, + assetToDelete: {}, + deletedAssetIndex: null, + }); + }); + }); }); diff --git a/src/data/reducers/index.js b/src/data/reducers/index.js index 0af67404..06d96092 100644 --- a/src/data/reducers/index.js +++ b/src/data/reducers/index.js @@ -16,3 +16,4 @@ const rootReducer = combineReducers({ }); export default rootReducer; + diff --git a/src/utils/testConstants.jsx b/src/utils/testConstants.jsx index 61fd7c3a..b11e624f 100644 --- a/src/utils/testConstants.jsx +++ b/src/utils/testConstants.jsx @@ -11,3 +11,41 @@ const courseDetails = { }; export default courseDetails; + +const thumbnail = '/animal'; +const copyUrl = 'animal'; + +export const testAssetsList = [ + { + display_name: 'cat.jpg', + id: 'cat.jpg', + thumbnail, + locked: false, + portable_url: copyUrl, + external_url: copyUrl, + }, + { + display_name: 'dog.png', + id: 'dog.png', + thumbnail, + locked: true, + portable_url: null, + external_url: null, + }, + { + display_name: 'bird.json', + id: 'bird.json', + thumbnail: null, + locked: false, + portable_url: null, + external_url: copyUrl, + }, + { + display_name: 'fish.doc', + id: 'fish.doc', + thumbnail: null, + locked: false, + portable_url: copyUrl, + external_url: null, + }, +];