diff --git a/package-lock.json b/package-lock.json index a6109891e..b73ae471c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,8 @@ "codemirror": "^6.0.0", "fast-xml-parser": "^4.0.10", "lodash-es": "^4.17.21", + "moment": "^2.29.4", + "moment-shortformat": "^2.1.0", "react-redux": "^7.2.8", "react-responsive": "8.2.0", "react-transition-group": "4.4.2", @@ -41,6 +43,7 @@ "@edx/paragon": "^20.28.0", "@edx/reactifex": "^2.1.1", "@testing-library/dom": "^8.13.0", + "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.1", "@testing-library/user-event": "^13.5.0", "codecov": "3.8.3", @@ -66,6 +69,12 @@ "react-dom": "^16.14.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.2.0.tgz", + "integrity": "sha512-E09FiIft46CmH5Qnjb0wsW54/YQd69LsxeKUOWawmws1XWvyFGURnAChH0mlr7YPFR1ofwvUQfcL0J3lMxXqPA==", + "dev": true + }, "node_modules/@babel/cli": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.16.0.tgz", @@ -5017,6 +5026,47 @@ "node": ">=8" } }, + "node_modules/@jest/expect-utils": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.5.0.tgz", + "integrity": "sha512-fmKzsidoXQT2KwnrwE0SQq3uj8Z763vzR8LnLBwC2qYWEFpjX8daRsk6rHUM1QvNlEW/UJXNXm59ztmJJWs2Mg==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.4.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/schemas": { + "version": "29.4.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.4.3.tgz", + "integrity": "sha512-VLYKXQmtmuEz6IxJsrZwzG9NvtkQsWNnWMsKxqWNu3+CnfzJQhp0WDDKWLVV9hLKr0l3SLLFRqcYHjhtyuDVxg==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.25.16" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.5.0.tgz", + "integrity": "sha512-qbu7kN6czmVRc3xWFQcAN03RAUamgppVUdXrvl1Wr3jlNF93o9mJbGcDWrwGB6ht44u7efB1qCFgVQmca24Uog==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.4.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@lezer/common": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.0.2.tgz", @@ -5416,6 +5466,12 @@ "@babel/runtime": "^7.9.2" } }, + "node_modules/@sinclair/typebox": { + "version": "0.25.24", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz", + "integrity": "sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==", + "dev": true + }, "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-6.5.0.tgz", @@ -6404,6 +6460,41 @@ "node": ">=12" } }, + "node_modules/@testing-library/jest-dom": { + "version": "5.16.5", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz", + "integrity": "sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA==", + "dev": true, + "dependencies": { + "@adobe/css-tools": "^4.0.1", + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=8", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@testing-library/react": { "version": "12.1.1", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.1.tgz", @@ -6543,6 +6634,57 @@ "@types/istanbul-lib-coverage": "*" } }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", + "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.1", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.1.tgz", + "integrity": "sha512-tEuVcHrpaixS36w7hpsfLBLpjtMRJUE09/MHXn923LOVojDwyC14cWcfc0rDs0VEfUyYmt/+iX1kxxp+gZMcaQ==", + "dev": true, + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/jest/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.5.0.tgz", + "integrity": "sha512-V2mGkI31qdttvTFX7Mt4efOqHXqJWMu4/r66Xh3Z3BwZaPfPJgp6/gbwoujRpPUtfEF6AUUWx3Jim3GCw5g/Qw==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.4.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@types/jest/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", @@ -6615,6 +6757,21 @@ "integrity": "sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==", "dev": true }, + "node_modules/@types/stack-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", + "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", + "dev": true + }, + "node_modules/@types/testing-library__jest-dom": { + "version": "5.14.5", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.5.tgz", + "integrity": "sha512-SBwbxYoyPIvxHbeHxTZX2Pe/74F/tX2/D3mMvzabdeJ25bBojfW0TyB8BHrbq/9zaaKICJZjLP+8r6AeZMFCuQ==", + "dev": true, + "dependencies": { + "@types/jest": "*" + } + }, "node_modules/@types/uglify-js": { "version": "3.17.1", "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.17.1.tgz", @@ -6644,6 +6801,15 @@ "node": ">= 8" } }, + "node_modules/@types/yargs": { + "version": "17.0.24", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", + "integrity": "sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, "node_modules/@types/yargs-parser": { "version": "21.0.0", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", @@ -6894,18 +7060,6 @@ "node": ">= 8" } }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", @@ -8464,15 +8618,6 @@ "@types/node": "*" } }, - "node_modules/babel-jest/node_modules/@types/istanbul-reports": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", - "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", - "dev": true, - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, "node_modules/babel-jest/node_modules/@types/yargs": { "version": "15.0.14", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz", @@ -11442,6 +11587,12 @@ "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==" }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, "node_modules/cssnano": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.0.12.tgz", @@ -12382,6 +12533,15 @@ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", "dev": true }, + "node_modules/diff-sequences": { + "version": "29.4.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.4.3.tgz", + "integrity": "sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -14266,6 +14426,22 @@ "node": ">= 0.6" } }, + "node_modules/expect": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.5.0.tgz", + "integrity": "sha512-yM7xqUrCO2JdpFo4XpM82t+PJBFybdqoQuJLDGeDX2ij8NZzqRHyu3Hp188/JX7SWqud+7t4MUdvcgGBICMHZg==", + "dev": true, + "dependencies": { + "@jest/expect-utils": "^29.5.0", + "jest-get-type": "^29.4.3", + "jest-matcher-utils": "^29.5.0", + "jest-message-util": "^29.5.0", + "jest-util": "^29.5.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/ext-list": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/ext-list/-/ext-list-2.2.2.tgz", @@ -22393,6 +22569,170 @@ "node": ">= 10.14.2" } }, + "node_modules/jest-diff": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.5.0.tgz", + "integrity": "sha512-LtxijLLZBduXnHSniy0WMdaHjmQnt3g5sa16W4p0HqukYTTsyTW3GD1q41TyGl5YFXj/5B2U6dlh5FM1LIMgxw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.4.3", + "jest-get-type": "^29.4.3", + "pretty-format": "^29.5.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-diff/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/pretty-format": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.5.0.tgz", + "integrity": "sha512-V2mGkI31qdttvTFX7Mt4efOqHXqJWMu4/r66Xh3Z3BwZaPfPJgp6/gbwoujRpPUtfEF6AUUWx3Jim3GCw5g/Qw==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.4.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-diff/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, + "node_modules/jest-get-type": { + "version": "29.4.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.4.3.tgz", + "integrity": "sha512-J5Xez4nRRMjk8emnTpWrlkyb9pfRQQanDrvWHhsR1+VUfbwxi30eVcZFlcdGInRibU4G5LwHXpI7IRHU0CY+gg==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.5.0.tgz", + "integrity": "sha512-lecRtgm/rjIK0CQ7LPQwzCs2VwW6WAahA55YBuI+xqmhm7LAaxokSB8C97yJeYyT+HvQkH741StzpU41wohhWw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.5.0", + "jest-get-type": "^29.4.3", + "pretty-format": "^29.5.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/pretty-format": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.5.0.tgz", + "integrity": "sha512-V2mGkI31qdttvTFX7Mt4efOqHXqJWMu4/r66Xh3Z3BwZaPfPJgp6/gbwoujRpPUtfEF6AUUWx3Jim3GCw5g/Qw==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.4.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, + "node_modules/jest-message-util": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.5.0.tgz", + "integrity": "sha512-Kijeg9Dag6CKtIDA7O21zNTACqD5MD/8HfIV8pdD94vFyFuer52SigdC3IQMhab3vACxXMiFk+yMHNdbqtyTGA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.5.0", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.5.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/pretty-format": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.5.0.tgz", + "integrity": "sha512-V2mGkI31qdttvTFX7Mt4efOqHXqJWMu4/r66Xh3Z3BwZaPfPJgp6/gbwoujRpPUtfEF6AUUWx3Jim3GCw5g/Qw==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.4.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, + "node_modules/jest-message-util/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/jest-pnp-resolver": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", @@ -22410,6 +22750,38 @@ } } }, + "node_modules/jest-util": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.5.0.tgz", + "integrity": "sha512-RYMgG/MTadOr5t8KdhejfvUU82MxsCu5MF6KuDUHl+NuwzUt+Sm6jJWxTJVrDR1j5M/gJVCPKQEpWXY+yIQ6lQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.5.0", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/ci-info": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", + "integrity": "sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, "node_modules/jest/node_modules/@babel/generator": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.20.5.tgz", @@ -22872,15 +23244,6 @@ "@types/node": "*" } }, - "node_modules/jest/node_modules/@types/istanbul-reports": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", - "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", - "dev": true, - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, "node_modules/jest/node_modules/@types/normalize-package-data": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz", @@ -22893,12 +23256,6 @@ "integrity": "sha512-ri0UmynRRvZiiUJdiz38MmIblKK+oH30MztdBVR95dv/Ubw6neWSb8u1XpRb72L4qsZOhz+L+z9JD40SJmfWow==", "dev": true }, - "node_modules/jest/node_modules/@types/stack-utils": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", - "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", - "dev": true - }, "node_modules/jest/node_modules/@types/yargs": { "version": "15.0.14", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz", @@ -23230,15 +23587,6 @@ "node": ">=8" } }, - "node_modules/jest/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/jest/node_modules/escodegen": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", @@ -25135,18 +25483,6 @@ "source-map": "^0.6.0" } }, - "node_modules/jest/node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jest/node_modules/static-extend": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", @@ -25955,18 +26291,6 @@ "node": ">=8" } }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -26008,6 +26332,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/mini-css-extract-plugin": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-1.6.2.tgz", @@ -26149,6 +26482,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "engines": { + "node": "*" + } + }, + "node_modules/moment-shortformat": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/moment-shortformat/-/moment-shortformat-2.1.0.tgz", + "integrity": "sha512-TBh8jH4cVQOnZU+fQXkyCgj74ti//0CTdQd5sQLDTZuHLMaUP3uFEhbfb3yrrLYNVzgoiUhyiIMf0rDdn5iTJg==", + "deprecated": "Package is no longer maintained", + "engines": { + "node": ">= 4" + }, + "peerDependencies": { + "moment": "^2.4.0" + } + }, "node_modules/moo": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", @@ -27812,6 +28165,18 @@ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", "dev": true }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pify": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", @@ -29182,16 +29547,17 @@ "node": ">=8.10.0" } }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", "dev": true, - "engines": { - "node": ">=8.6" + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": ">=8" } }, "node_modules/redux": { @@ -30234,6 +30600,27 @@ "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility", "dev": true }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -30729,6 +31116,18 @@ "node": ">=6" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -34547,6 +34946,12 @@ } }, "dependencies": { + "@adobe/css-tools": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.2.0.tgz", + "integrity": "sha512-E09FiIft46CmH5Qnjb0wsW54/YQd69LsxeKUOWawmws1XWvyFGURnAChH0mlr7YPFR1ofwvUQfcL0J3lMxXqPA==", + "dev": true + }, "@babel/cli": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.16.0.tgz", @@ -38196,6 +38601,38 @@ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", "dev": true }, + "@jest/expect-utils": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.5.0.tgz", + "integrity": "sha512-fmKzsidoXQT2KwnrwE0SQq3uj8Z763vzR8LnLBwC2qYWEFpjX8daRsk6rHUM1QvNlEW/UJXNXm59ztmJJWs2Mg==", + "dev": true, + "requires": { + "jest-get-type": "^29.4.3" + } + }, + "@jest/schemas": { + "version": "29.4.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.4.3.tgz", + "integrity": "sha512-VLYKXQmtmuEz6IxJsrZwzG9NvtkQsWNnWMsKxqWNu3+CnfzJQhp0WDDKWLVV9hLKr0l3SLLFRqcYHjhtyuDVxg==", + "dev": true, + "requires": { + "@sinclair/typebox": "^0.25.16" + } + }, + "@jest/types": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.5.0.tgz", + "integrity": "sha512-qbu7kN6czmVRc3xWFQcAN03RAUamgppVUdXrvl1Wr3jlNF93o9mJbGcDWrwGB6ht44u7efB1qCFgVQmca24Uog==", + "dev": true, + "requires": { + "@jest/schemas": "^29.4.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + } + }, "@lezer/common": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.0.2.tgz", @@ -38516,6 +38953,12 @@ } } }, + "@sinclair/typebox": { + "version": "0.25.24", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz", + "integrity": "sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==", + "dev": true + }, "@svgr/babel-plugin-remove-jsx-attribute": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-6.5.0.tgz", @@ -39188,6 +39631,35 @@ "pretty-format": "^27.0.2" } }, + "@testing-library/jest-dom": { + "version": "5.16.5", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz", + "integrity": "sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA==", + "dev": true, + "requires": { + "@adobe/css-tools": "^4.0.1", + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" + }, + "dependencies": { + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, "@testing-library/react": { "version": "12.1.1", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.1.tgz", @@ -39309,6 +39781,50 @@ "@types/istanbul-lib-coverage": "*" } }, + "@types/istanbul-reports": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", + "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "@types/jest": { + "version": "29.5.1", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.1.tgz", + "integrity": "sha512-tEuVcHrpaixS36w7hpsfLBLpjtMRJUE09/MHXn923LOVojDwyC14cWcfc0rDs0VEfUyYmt/+iX1kxxp+gZMcaQ==", + "dev": true, + "requires": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + }, + "pretty-format": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.5.0.tgz", + "integrity": "sha512-V2mGkI31qdttvTFX7Mt4efOqHXqJWMu4/r66Xh3Z3BwZaPfPJgp6/gbwoujRpPUtfEF6AUUWx3Jim3GCw5g/Qw==", + "dev": true, + "requires": { + "@jest/schemas": "^29.4.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + } + }, + "react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + } + } + }, "@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", @@ -39381,6 +39897,21 @@ "integrity": "sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==", "dev": true }, + "@types/stack-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", + "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", + "dev": true + }, + "@types/testing-library__jest-dom": { + "version": "5.14.5", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.5.tgz", + "integrity": "sha512-SBwbxYoyPIvxHbeHxTZX2Pe/74F/tX2/D3mMvzabdeJ25bBojfW0TyB8BHrbq/9zaaKICJZjLP+8r6AeZMFCuQ==", + "dev": true, + "requires": { + "@types/jest": "*" + } + }, "@types/uglify-js": { "version": "3.17.1", "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.17.1.tgz", @@ -39409,6 +39940,15 @@ } } }, + "@types/yargs": { + "version": "17.0.24", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", + "integrity": "sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, "@types/yargs-parser": { "version": "21.0.0", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", @@ -39609,14 +40149,6 @@ "requires": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" - }, - "dependencies": { - "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true - } } }, "aproba": { @@ -40750,15 +41282,6 @@ "@types/node": "*" } }, - "@types/istanbul-reports": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", - "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", - "dev": true, - "requires": { - "@types/istanbul-lib-report": "*" - } - }, "@types/yargs": { "version": "15.0.14", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz", @@ -43103,6 +43626,12 @@ "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==" }, + "css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, "cssnano": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.0.12.tgz", @@ -43745,6 +44274,12 @@ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", "dev": true }, + "diff-sequences": { + "version": "29.4.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.4.3.tgz", + "integrity": "sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==", + "dev": true + }, "dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -45199,6 +45734,19 @@ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "dev": true }, + "expect": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.5.0.tgz", + "integrity": "sha512-yM7xqUrCO2JdpFo4XpM82t+PJBFybdqoQuJLDGeDX2ij8NZzqRHyu3Hp188/JX7SWqud+7t4MUdvcgGBICMHZg==", + "dev": true, + "requires": { + "@jest/expect-utils": "^29.5.0", + "jest-get-type": "^29.4.3", + "jest-matcher-utils": "^29.5.0", + "jest-message-util": "^29.5.0", + "jest-util": "^29.5.0" + } + }, "ext-list": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/ext-list/-/ext-list-2.2.2.tgz", @@ -51991,15 +52539,6 @@ "@types/node": "*" } }, - "@types/istanbul-reports": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", - "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", - "dev": true, - "requires": { - "@types/istanbul-lib-report": "*" - } - }, "@types/normalize-package-data": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz", @@ -52012,12 +52551,6 @@ "integrity": "sha512-ri0UmynRRvZiiUJdiz38MmIblKK+oH30MztdBVR95dv/Ubw6neWSb8u1XpRb72L4qsZOhz+L+z9JD40SJmfWow==", "dev": true }, - "@types/stack-utils": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", - "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", - "dev": true - }, "@types/yargs": { "version": "15.0.14", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz", @@ -52289,12 +52822,6 @@ } } }, - "escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true - }, "escodegen": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", @@ -53799,15 +54326,6 @@ "source-map": "^0.6.0" } }, - "stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "requires": { - "escape-string-regexp": "^2.0.0" - } - }, "static-extend": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", @@ -54095,6 +54613,134 @@ } } }, + "jest-diff": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.5.0.tgz", + "integrity": "sha512-LtxijLLZBduXnHSniy0WMdaHjmQnt3g5sa16W4p0HqukYTTsyTW3GD1q41TyGl5YFXj/5B2U6dlh5FM1LIMgxw==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "diff-sequences": "^29.4.3", + "jest-get-type": "^29.4.3", + "pretty-format": "^29.5.0" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + }, + "pretty-format": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.5.0.tgz", + "integrity": "sha512-V2mGkI31qdttvTFX7Mt4efOqHXqJWMu4/r66Xh3Z3BwZaPfPJgp6/gbwoujRpPUtfEF6AUUWx3Jim3GCw5g/Qw==", + "dev": true, + "requires": { + "@jest/schemas": "^29.4.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + } + }, + "react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + } + } + }, + "jest-get-type": { + "version": "29.4.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.4.3.tgz", + "integrity": "sha512-J5Xez4nRRMjk8emnTpWrlkyb9pfRQQanDrvWHhsR1+VUfbwxi30eVcZFlcdGInRibU4G5LwHXpI7IRHU0CY+gg==", + "dev": true + }, + "jest-matcher-utils": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.5.0.tgz", + "integrity": "sha512-lecRtgm/rjIK0CQ7LPQwzCs2VwW6WAahA55YBuI+xqmhm7LAaxokSB8C97yJeYyT+HvQkH741StzpU41wohhWw==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "jest-diff": "^29.5.0", + "jest-get-type": "^29.4.3", + "pretty-format": "^29.5.0" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + }, + "pretty-format": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.5.0.tgz", + "integrity": "sha512-V2mGkI31qdttvTFX7Mt4efOqHXqJWMu4/r66Xh3Z3BwZaPfPJgp6/gbwoujRpPUtfEF6AUUWx3Jim3GCw5g/Qw==", + "dev": true, + "requires": { + "@jest/schemas": "^29.4.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + } + }, + "react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + } + } + }, + "jest-message-util": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.5.0.tgz", + "integrity": "sha512-Kijeg9Dag6CKtIDA7O21zNTACqD5MD/8HfIV8pdD94vFyFuer52SigdC3IQMhab3vACxXMiFk+yMHNdbqtyTGA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.5.0", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.5.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + }, + "pretty-format": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.5.0.tgz", + "integrity": "sha512-V2mGkI31qdttvTFX7Mt4efOqHXqJWMu4/r66Xh3Z3BwZaPfPJgp6/gbwoujRpPUtfEF6AUUWx3Jim3GCw5g/Qw==", + "dev": true, + "requires": { + "@jest/schemas": "^29.4.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + } + }, + "react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + } + } + }, "jest-pnp-resolver": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", @@ -54102,6 +54748,28 @@ "dev": true, "requires": {} }, + "jest-util": { + "version": "29.5.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.5.0.tgz", + "integrity": "sha512-RYMgG/MTadOr5t8KdhejfvUU82MxsCu5MF6KuDUHl+NuwzUt+Sm6jJWxTJVrDR1j5M/gJVCPKQEpWXY+yIQ6lQ==", + "dev": true, + "requires": { + "@jest/types": "^29.5.0", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "dependencies": { + "ci-info": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", + "integrity": "sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==", + "dev": true + } + } + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -54478,12 +55146,6 @@ "requires": { "fill-range": "^7.0.1" } - }, - "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true } } }, @@ -54513,6 +55175,12 @@ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==" }, + "min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true + }, "mini-css-extract-plugin": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-1.6.2.tgz", @@ -54618,6 +55286,17 @@ } } }, + "moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" + }, + "moment-shortformat": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/moment-shortformat/-/moment-shortformat-2.1.0.tgz", + "integrity": "sha512-TBh8jH4cVQOnZU+fQXkyCgj74ti//0CTdQd5sQLDTZuHLMaUP3uFEhbfb3yrrLYNVzgoiUhyiIMf0rDdn5iTJg==", + "requires": {} + }, "moo": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", @@ -55847,6 +56526,12 @@ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", "dev": true }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + }, "pify": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", @@ -56908,14 +57593,16 @@ "dev": true, "requires": { "picomatch": "^2.2.1" - }, - "dependencies": { - "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true - } + } + }, + "redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "requires": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" } }, "redux": { @@ -57710,6 +58397,23 @@ "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", "dev": true }, + "stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "requires": { + "escape-string-regexp": "^2.0.0" + }, + "dependencies": { + "escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true + } + } + }, "statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -58073,6 +58777,15 @@ "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", "dev": true }, + "strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "requires": { + "min-indent": "^1.0.0" + } + }, "strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", diff --git a/package.json b/package.json index 5be2b045c..828bfd45a 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@edx/paragon": "^20.28.0", "@edx/reactifex": "^2.1.1", "@testing-library/dom": "^8.13.0", + "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.1", "@testing-library/user-event": "^13.5.0", "codecov": "3.8.3", @@ -69,6 +70,8 @@ "codemirror": "^6.0.0", "fast-xml-parser": "^4.0.10", "lodash-es": "^4.17.21", + "moment": "^2.29.4", + "moment-shortformat": "^2.1.0", "react-redux": "^7.2.8", "react-responsive": "8.2.0", "react-transition-group": "4.4.2", diff --git a/src/editors/VideoSelector.jsx b/src/editors/VideoSelector.jsx new file mode 100644 index 000000000..ae5961f73 --- /dev/null +++ b/src/editors/VideoSelector.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { useDispatch } from 'react-redux'; +import PropTypes from 'prop-types'; +import VideoGallery from './containers/VideoGallery'; +import * as hooks from './hooks'; + +export const VideoSelector = ({ + learningContextId, + lmsEndpointUrl, + studioEndpointUrl, +}) => { + const dispatch = useDispatch(); + hooks.initializeApp({ + dispatch, + data: { + blockId: '', + blockType: 'video', + learningContextId, + lmsEndpointUrl, + studioEndpointUrl, + }, + }); + return ( + + ); +}; + +VideoSelector.propTypes = { + learningContextId: PropTypes.string.isRequired, + lmsEndpointUrl: PropTypes.string.isRequired, + studioEndpointUrl: PropTypes.string.isRequired, +}; + +export default VideoSelector; diff --git a/src/editors/VideoSelector.test.jsx b/src/editors/VideoSelector.test.jsx new file mode 100644 index 000000000..fa518a0cb --- /dev/null +++ b/src/editors/VideoSelector.test.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { useDispatch } from 'react-redux'; +import { shallow } from 'enzyme'; +import * as hooks from './hooks'; +import VideoSelector from './VideoSelector'; + +jest.mock('./hooks', () => ({ + initializeApp: jest.fn(), +})); + +jest.mock('./containers/VideoGallery', () => 'VideoGallery'); + +const props = { + learningContextId: 'course-v1:edX+DemoX+Demo_Course', + lmsEndpointUrl: 'evenfakerurl.com', + studioEndpointUrl: 'fakeurl.com', +}; + +const initData = { + blockId: '', + blockType: 'video', + ...props, +}; + +describe('Video Selector', () => { + describe('render', () => { + test('rendering correctly with expected Input', () => { + expect(shallow()).toMatchSnapshot(); + }); + }); + describe('behavior', () => { + it('calls initializeApp hook with dispatch, and passed data', () => { + shallow(); + expect(hooks.initializeApp).toHaveBeenCalledWith({ + dispatch: useDispatch(), + data: initData, + }); + }); + }); +}); diff --git a/src/editors/VideoSelectorPage.jsx b/src/editors/VideoSelectorPage.jsx new file mode 100644 index 000000000..371cfa5e4 --- /dev/null +++ b/src/editors/VideoSelectorPage.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import PropTypes from 'prop-types'; +import ErrorBoundary from './sharedComponents/ErrorBoundary'; +import { VideoSelector } from './VideoSelector'; +import store from './data/store'; + +const VideoSelectorPage = ({ + courseId, + lmsEndpointUrl, + studioEndpointUrl, +}) => ( + + + + + +); + +VideoSelectorPage.defaultProps = { + courseId: null, + lmsEndpointUrl: null, + studioEndpointUrl: null, +}; + +VideoSelectorPage.propTypes = { + courseId: PropTypes.string, + lmsEndpointUrl: PropTypes.string, + studioEndpointUrl: PropTypes.string, +}; + +export default VideoSelectorPage; diff --git a/src/editors/VideoSelectorPage.test.jsx b/src/editors/VideoSelectorPage.test.jsx new file mode 100644 index 000000000..8cab51d13 --- /dev/null +++ b/src/editors/VideoSelectorPage.test.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import VideoSelectorPage from './VideoSelectorPage'; + +const props = { + courseId: 'course-v1:edX+DemoX+Demo_Course', + lmsEndpointUrl: 'evenfakerurl.com', + studioEndpointUrl: 'fakeurl.com', +}; + +jest.mock('react-redux', () => ({ + Provider: 'Provider', +})); +jest.mock('./VideoSelector', () => 'VideoSelector'); + +describe('Video Selector Page', () => { + describe('snapshots', () => { + test('rendering correctly with expected Input', () => { + expect(shallow()).toMatchSnapshot(); + }); + test('rendering with props to null', () => { + expect(shallow()).toMatchSnapshot(); + }); + }); +}); diff --git a/src/editors/__snapshots__/VideoSelector.test.jsx.snap b/src/editors/__snapshots__/VideoSelector.test.jsx.snap new file mode 100644 index 000000000..d067c4a41 --- /dev/null +++ b/src/editors/__snapshots__/VideoSelector.test.jsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Video Selector render rendering correctly with expected Input 1`] = ``; diff --git a/src/editors/__snapshots__/VideoSelectorPage.test.jsx.snap b/src/editors/__snapshots__/VideoSelectorPage.test.jsx.snap new file mode 100644 index 000000000..390794300 --- /dev/null +++ b/src/editors/__snapshots__/VideoSelectorPage.test.jsx.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Video Selector Page snapshots rendering correctly with expected Input 1`] = ` + + + + + +`; + +exports[`Video Selector Page snapshots rendering with props to null 1`] = ` + + + + + +`; diff --git a/src/editors/containers/EditorContainer/__snapshots__/index.test.jsx.snap b/src/editors/containers/EditorContainer/__snapshots__/index.test.jsx.snap index acc78abdb..6faecead0 100644 --- a/src/editors/containers/EditorContainer/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/EditorContainer/__snapshots__/index.test.jsx.snap @@ -5,6 +5,7 @@ exports[`EditorContainer component render snapshot: initialized. enable save and className="position-relative zindex-0" > } footerAction={null} + headerComponent={null} + isFullscreenScroll={true} isOpen={false} size="md" title="Exit the editor?" @@ -83,6 +86,7 @@ exports[`EditorContainer component render snapshot: not initialized. disable sav className="position-relative zindex-0" > } footerAction={null} + headerComponent={null} + isFullscreenScroll={true} isOpen={false} size="md" title="Exit the editor?" diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.test.jsx index 3b039975d..5df664cea 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.test.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.test.jsx @@ -10,6 +10,9 @@ jest.mock('../../../../../data/redux', () => ({ problemType: jest.fn(state => ({ problemType: state })), }, }, + thunkActions: { + video: jest.fn(), + }, })); describe('AnswerOption', () => { diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.test.jsx index cef3d4ed5..178da1e86 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.test.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/ExplanationWidget/index.test.jsx @@ -11,6 +11,11 @@ jest.mock('../../../../../data/redux', () => ({ settings: jest.fn(state => ({ question: state })), }, }, + thunkActions: { + video: { + importTranscript: jest.fn(), + }, + }, })); jest.mock('../../../../../sharedComponents/TinyMceWidget/hooks', () => ({ diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.test.jsx index 561602f53..80b288a7b 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.test.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/QuestionWidget/index.test.jsx @@ -21,6 +21,11 @@ jest.mock('../../../../../data/redux', () => ({ question: jest.fn(state => ({ question: state })), }, }, + thunkActions: { + video: { + importTranscript: jest.fn(), + }, + }, })); jest.mock('../../../../../sharedComponents/TinyMceWidget/hooks', () => ({ diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ShowAnswerCard.test.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ShowAnswerCard.test.jsx index 81f58d130..36b4ff0b4 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ShowAnswerCard.test.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/ShowAnswerCard.test.jsx @@ -16,6 +16,9 @@ jest.mock('../../../../../../data/redux', () => ({ learningContextId: jest.fn(state => ({ learningContextId: state })), }, }, + thunkActions: { + video: jest.fn(), + }, })); describe('ShowAnswerCard', () => { diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/SwitchToAdvancedEditorCard.test.jsx.snap b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/SwitchToAdvancedEditorCard.test.jsx.snap index defddbed5..124838e2d 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/SwitchToAdvancedEditorCard.test.jsx.snap +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/SettingsWidget/settingsComponents/__snapshots__/SwitchToAdvancedEditorCard.test.jsx.snap @@ -5,6 +5,7 @@ exports[`SwitchToAdvancedEditorCard snapshot snapshot: SwitchToAdvancedEditorCar className="border border-light-700 shadow-none" > } footerAction={null} + headerComponent={null} + isFullscreenScroll={true} isOpen={false} size="md" title={ diff --git a/src/editors/containers/TextEditor/index.test.jsx b/src/editors/containers/TextEditor/index.test.jsx index 2ee14c75a..196f5028b 100644 --- a/src/editors/containers/TextEditor/index.test.jsx +++ b/src/editors/containers/TextEditor/index.test.jsx @@ -61,6 +61,11 @@ jest.mock('../../data/redux', () => ({ isFinished: jest.fn((state, params) => ({ isFailed: { state, params } })), }, }, + thunkActions: { + video: { + importTranscript: jest.fn(), + }, + }, })); describe('TextEditor', () => { diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/index.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/index.jsx index 760a3fde6..f958b9cea 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/index.jsx +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/index.jsx @@ -26,6 +26,7 @@ import { ErrorAlert } from '../../../../../../sharedComponents/ErrorAlerts/Error import { UploadErrorAlert } from '../../../../../../sharedComponents/ErrorAlerts/UploadErrorAlert'; import CollapsibleFormWidget from '../CollapsibleFormWidget'; import { ErrorContext } from '../../../../hooks'; +import { RequestKeys } from '../../../../../../data/constants/requests'; /** * Collapsible Form widget controlling video handouts @@ -38,6 +39,7 @@ export const HandoutWidget = ({ handout, getHandoutDownloadUrl, updateField, + isUploadError, }) => { const [error] = React.useContext(ErrorContext).handout; const { fileSizeError } = hooks.fileSizeError(); @@ -59,7 +61,7 @@ export const HandoutWidget = ({ > - + {handout ? ( @@ -125,6 +127,7 @@ export const mapStateToProps = (state) => ({ isLibrary: selectors.app.isLibrary(state), handout: selectors.video.handout(state), getHandoutDownloadUrl: selectors.video.getHandoutDownloadUrl(state), + isUploadError: selectors.requests.isFailed(state, { requestKey: RequestKeys.uploadAsset }), }); export const mapDispatchToProps = (dispatch) => ({ diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/index.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/index.test.jsx index 96dba8257..4256f060c 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/index.test.jsx +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/HandoutWidget/index.test.jsx @@ -24,6 +24,9 @@ jest.mock('../../../../../../data/redux', () => ({ app: { isLibrary: jest.fn(args => ({ isLibrary: args })), }, + requests: { + isFailed: jest.fn(args => ({ isFailed: args })), + }, }, })); diff --git a/src/editors/containers/VideoGallery/hooks.js b/src/editors/containers/VideoGallery/hooks.js new file mode 100644 index 000000000..2eabcfc28 --- /dev/null +++ b/src/editors/containers/VideoGallery/hooks.js @@ -0,0 +1,194 @@ +import React from 'react'; +import * as module from './hooks'; +import messages from './messages'; +import { + filterKeys, + filterMessages, + sortKeys, + sortMessages, + sortFunctions, +} from './utils'; + +export const state = { + highlighted: (val) => React.useState(val), + searchString: (val) => React.useState(val), + showSelectVideoError: (val) => React.useState(val), + showSizeError: (val) => React.useState(val), + sortBy: (val) => React.useState(val), + filertBy: (val) => React.useState(val), + hideSelectedVideos: (val) => React.useState(val), +}; + +export const searchAndSortProps = () => { + const [searchString, setSearchString] = module.state.searchString(''); + const [sortBy, setSortBy] = module.state.sortBy(sortKeys.dateNewest); + const [filterBy, setFilterBy] = module.state.filertBy(filterKeys.videoStatus); + const [hideSelectedVideos, setHideSelectedVideos] = module.state.hideSelectedVideos(false); + + return { + searchString, + onSearchChange: (e) => setSearchString(e.target.value), + clearSearchString: () => setSearchString(''), + sortBy, + onSortClick: (key) => () => setSortBy(key), + sortKeys, + sortMessages, + filterBy, + onFilterClick: (key) => () => setFilterBy(key), + filterKeys, + filterMessages, + showSwitch: true, + hideSelectedVideos, + switchMessage: messages.hideSelectedCourseVideosSwitchLabel, + onSwitchClick: () => setHideSelectedVideos(!hideSelectedVideos), + }; +}; + +export const filterListBySearch = ({ searchString, videoList }) => ( + videoList.filter(({ displayName }) => displayName.toLowerCase().includes(searchString.toLowerCase())) +); + +export const filterListByStatus = ({ statusFilter, videoList }) => { + if (statusFilter === filterKeys.videoStatus) { + return videoList; + } + return videoList.filter(({ status }) => status === statusFilter); +}; + +export const filterListByHideSelectedCourse = ({ videoList }) => ( + // TODO Missing to implement this + videoList +); + +export const filterList = ({ + sortBy, + filterBy, + searchString, + videos, +}) => { + let filteredList = module.filterListBySearch({ + searchString, + videoList: videos, + }); + filteredList = module.filterListByStatus({ + statusFilter: filterBy, + videoList: filteredList, + }); + filteredList = module.filterListByHideSelectedCourse({ + videoList: filteredList, + }); + return filteredList.sort(sortFunctions[sortBy in sortKeys ? sortKeys[sortBy] : sortKeys.dateNewest]); +}; + +export const videoListProps = ({ searchSortProps, videos }) => { + const [highlighted, setHighlighted] = module.state.highlighted(null); + const [ + showSelectVideoError, + setShowSelectVideoError, + ] = module.state.showSelectVideoError(false); + const [ + showSizeError, + setShowSizeError, + ] = module.state.showSizeError(false); + const filteredList = module.filterList({ ...searchSortProps, videos }); + return { + galleryError: { + show: showSelectVideoError, + set: () => setShowSelectVideoError(true), + dismiss: () => setShowSelectVideoError(false), + message: messages.selectVideoError, + }, + // TODO We need to update this message when implementing the video upload screen + inputError: { + show: showSizeError, + set: () => setShowSizeError(true), + dismiss: () => setShowSelectVideoError(false), + message: messages.fileSizeError, + }, + galleryProps: { + galleryIsEmpty: Object.keys(filteredList).length === 0, + searchIsEmpty: filteredList.length === 0, + displayList: filteredList, + highlighted, + onHighlightChange: (e) => setHighlighted(e.target.value), + emptyGalleryLabel: messages.emptyGalleryLabel, + showIdsOnCards: true, + height: '100%', + }, + selectBtnProps: { + onclick: () => { + // TODO Update this when implementing the selection feature + }, + }, + }; +}; + +export const fileInputProps = () => { + // TODO [Update video] Implement this + const ref = React.useRef(); + const click = () => ref.current.click(); + + return { + click, + addFile: () => {}, + ref, + }; +}; + +export const buildVideos = ({ rawVideos }) => { + let videos = []; + const rawVideoList = Object.values(rawVideos); + if (rawVideoList.length > 0) { + videos = rawVideoList.map(video => ({ + id: video.edx_video_id, + displayName: video.client_video_id, + externalUrl: video.course_video_image_url, + dateAdded: video.created, + locked: false, + thumbnail: video.course_video_image_url, + status: video.status, + statusBadgeVariant: module.getstatusBadgeVariant({ status: video.status }), + duration: video.duration, + transcripts: video.transcripts, + })); + } + return videos; +}; + +export const getstatusBadgeVariant = ({ status }) => { + switch (status) { + case filterKeys.failed: + return 'danger'; + case filterKeys.uploading: + case filterKeys.processing: + return 'light'; + default: + return null; + } +}; + +export const videoProps = ({ videos }) => { + const searchSortProps = module.searchAndSortProps(); + const videoList = module.videoListProps({ searchSortProps, videos }); + const { + galleryError, + galleryProps, + inputError, + selectBtnProps, + } = videoList; + const fileInput = module.fileInputProps(); + + return { + galleryError, + inputError, + fileInput, + galleryProps, + searchSortProps, + selectBtnProps, + }; +}; + +export default { + videoProps, + buildVideos, +}; diff --git a/src/editors/containers/VideoGallery/hooks.test.js b/src/editors/containers/VideoGallery/hooks.test.js new file mode 100644 index 000000000..d3ad83e97 --- /dev/null +++ b/src/editors/containers/VideoGallery/hooks.test.js @@ -0,0 +1,253 @@ +import * as hooks from './hooks'; +import { filterKeys, sortKeys } from './utils'; +import { MockUseState } from '../../../testUtils'; +import { keyStore } from '../../utils'; + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useRef: jest.fn(val => ({ current: val })), + useEffect: jest.fn(), + useCallback: (cb, prereqs) => ({ cb, prereqs }), +})); + +jest.mock('react-redux', () => { + const dispatchFn = jest.fn(); + return { + ...jest.requireActual('react-redux'), + dispatch: dispatchFn, + useDispatch: jest.fn(() => dispatchFn), + }; +}); + +const state = new MockUseState(hooks); +const hookKeys = keyStore(hooks); +let hook; +const testValue = 'testVALUEVALID'; + +describe('VideoGallery hooks', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('state hooks', () => { + state.testGetter(state.keys.highlighted); + state.testGetter(state.keys.searchString); + state.testGetter(state.keys.showSelectVideoError); + state.testGetter(state.keys.showSizeError); + state.testGetter(state.keys.sortBy); + state.testGetter(state.keys.filertBy); + state.testGetter(state.keys.hideSelectedVideos); + }); + describe('using state', () => { + beforeEach(() => { state.mock(); }); + afterEach(() => { state.restore(); }); + + describe('searchAndSortProps', () => { + beforeEach(() => { + hook = hooks.searchAndSortProps(); + }); + it('returns searchString value, initialized to an empty string', () => { + expect(state.stateVals.searchString).toEqual(hook.searchString); + expect(state.stateVals.searchString).toEqual(''); + }); + it('returns highlighted value, initialized to dateNewest', () => { + expect(state.stateVals.sortBy).toEqual(hook.sortBy); + expect(state.stateVals.sortBy).toEqual(sortKeys.dateNewest); + }); + test('onSearchChange sets searchString with event target value', () => { + hook.onSearchChange({ target: { value: testValue } }); + expect(state.setState.searchString).toHaveBeenCalledWith(testValue); + }); + test('clearSearchString sets search string to empty string', () => { + hook.clearSearchString(); + expect(state.setState.searchString).toHaveBeenCalledWith(''); + }); + test('onSortClick takes a key and returns callback to set sortBY to that key', () => { + hook.onSortClick(testValue); + expect(state.setState.sortBy).not.toHaveBeenCalled(); + hook.onSortClick(testValue)(); + expect(state.setState.sortBy).toHaveBeenCalledWith(testValue); + }); + }); + describe('filterListBySearch', () => { + const matching = [ + 'test', + 'TEst', + 'eeees', + 'essSSSS', + ]; + const notMatching = ['bad', 'other', 'bad stuff']; + const searchString = 'eS'; + test('returns list filtered lowercase by displayName', () => { + const filter = jest.fn(cb => ({ filter: cb })); + hook = hooks.filterListBySearch({ searchString, videoList: { filter } }); + expect(filter).toHaveBeenCalled(); + const [[filterCb]] = filter.mock.calls; + matching.forEach(val => expect(filterCb({ displayName: val })).toEqual(true)); + notMatching.forEach(val => expect(filterCb({ displayName: val })).toEqual(false)); + }); + }); + describe('buildVideos', () => { + const rawVideos = [ + { + edx_video_id: 'id_1', + client_video_id: 'client_id_1', + course_video_image_url: 'course_video_image_url_1', + created: 'created_1', + status: 'status_1', + duration: 1, + transcripts: [], + }, + { + edx_video_id: 'id_2', + client_video_id: 'client_id_2', + course_video_image_url: 'course_video_image_url_2', + created: 'created_2', + status: 'status_2', + duration: 2, + transcripts: [], + }, + ]; + const expectedValues = [ + { + id: 'id_1', + displayName: 'client_id_1', + externalUrl: 'course_video_image_url_1', + dateAdded: 'created_1', + locked: false, + thumbnail: 'course_video_image_url_1', + status: 'status_1', + statusBadgeVariant: null, + duration: 1, + transcripts: [], + }, + { + id: 'id_2', + displayName: 'client_id_2', + externalUrl: 'course_video_image_url_2', + dateAdded: 'created_2', + locked: false, + thumbnail: 'course_video_image_url_2', + status: 'status_2', + statusBadgeVariant: null, + duration: 2, + transcripts: [], + }, + ]; + test('return the expected values', () => { + const values = hooks.buildVideos({ rawVideos }); + expect(values).toEqual(expectedValues); + }); + }); + describe('getstatusBadgeVariant', () => { + test('return the expected values', () => { + let value = hooks.getstatusBadgeVariant({ status: filterKeys.failed }); + expect(value).toEqual('danger'); + value = hooks.getstatusBadgeVariant({ status: filterKeys.uploading }); + expect(value).toEqual('light'); + value = hooks.getstatusBadgeVariant({ status: filterKeys.processing }); + expect(value).toEqual('light'); + value = hooks.getstatusBadgeVariant({ status: filterKeys.videoStatus }); + expect(value).toBeNull(); + value = hooks.getstatusBadgeVariant({ status: filterKeys.ready }); + expect(value).toBeNull(); + }); + }); + describe('videoListProps outputs', () => { + const props = { + searchSortProps: { + searchString: 'Es', + sortBy: sortKeys.dateNewest, + filterBy: filterKeys.videoStatus, + }, + videos: [ + { + displayName: 'sOmEuiMAge', + staTICUrl: '/assets/sOmEuiMAge', + id: 'sOmEuiMAgeURl', + }, + ], + }; + const filterList = (args) => ({ filterList: args }); + const load = () => { + jest.spyOn(hooks, hookKeys.filterList).mockImplementationOnce(filterList); + hook = hooks.videoListProps(props); + }; + beforeEach(() => { + load(); + }); + describe('galleryProps', () => { + it('returns highlighted value, initialized to null', () => { + expect(hook.galleryProps.highlighted).toEqual(state.stateVals.highlighted); + expect(state.stateVals.highlighted).toEqual(null); + }); + test('onHighlightChange sets highlighted with event target value', () => { + hook.galleryProps.onHighlightChange({ target: { value: testValue } }); + expect(state.setState.highlighted).toHaveBeenCalledWith(testValue); + }); + test('displayList returns filterListhook called with searchSortProps and videos', () => { + expect(hook.galleryProps.displayList).toEqual(filterList({ + ...props.searchSortProps, + videos: props.videos, + })); + }); + }); + describe('galleryError', () => { + test('show is initialized to false and returns properly', () => { + const show = 'sHOWSelectiRROr'; + expect(hook.galleryError.show).toEqual(false); + state.mockVal(state.keys.showSelectVideoError, show); + hook = hooks.videoListProps(props); + expect(hook.galleryError.show).toEqual(show); + }); + test('set sets showSelectVideoError to true', () => { + hook.galleryError.set(); + expect(state.setState.showSelectVideoError).toHaveBeenCalledWith(true); + }); + test('dismiss sets showSelectVideoError to false', () => { + hook.galleryError.dismiss(); + expect(state.setState.showSelectVideoError).toHaveBeenCalledWith(false); + }); + }); + }); + }); + describe('videoProps', () => { + const videoList = { + galleryProps: 'some gallery props', + selectBtnProps: 'some select btn props', + }; + const searchAndSortProps = { search: 'props' }; + const fileInput = { file: 'input hooks' }; + const videos = { video: { staTICUrl: '/assets/sOmEuiMAge' } }; + const spies = {}; + beforeEach(() => { + spies.videoList = jest.spyOn(hooks, hookKeys.videoListProps) + .mockReturnValueOnce(videoList); + spies.search = jest.spyOn(hooks, hookKeys.searchAndSortProps) + .mockReturnValueOnce(searchAndSortProps); + spies.file = jest.spyOn(hooks, hookKeys.fileInputProps) + .mockReturnValueOnce(fileInput); + hook = hooks.videoProps({ videos }); + }); + it('forwards fileInput as fileInput', () => { + expect(hook.fileInput).toEqual(fileInput); + expect(spies.file.mock.calls.length).toEqual(1); + expect(spies.file).toHaveBeenCalled(); + }); + it('initializes videoList', () => { + expect(spies.videoList.mock.calls.length).toEqual(1); + expect(spies.videoList).toHaveBeenCalledWith({ + searchSortProps: searchAndSortProps, + videos, + }); + }); + it('forwards searchAndSortHooks as searchSortProps', () => { + expect(hook.searchSortProps).toEqual(searchAndSortProps); + expect(spies.search.mock.calls.length).toEqual(1); + expect(spies.search).toHaveBeenCalled(); + }); + it('forwards galleryProps and selectBtnProps from the video list hooks', () => { + expect(hook.galleryProps).toEqual(videoList.galleryProps); + expect(hook.selectBtnProps).toEqual(videoList.selectBtnProps); + }); + }); +}); diff --git a/src/editors/containers/VideoGallery/index.jsx b/src/editors/containers/VideoGallery/index.jsx new file mode 100644 index 000000000..8ea788349 --- /dev/null +++ b/src/editors/containers/VideoGallery/index.jsx @@ -0,0 +1,77 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { selectors } from '../../data/redux'; +import hooks from './hooks'; +import SelectionModal from '../../sharedComponents/SelectionModal'; +import { acceptedImgKeys } from './utils'; +import messages from './messages'; +import { RequestKeys } from '../../data/constants/requests'; + +export const VideoGallery = ({ + // redux + rawVideos, + isLoaded, + isFetchError, + isUploadError, +}) => { + const videos = hooks.buildVideos({ rawVideos }); + const { + galleryError, + inputError, + fileInput, + galleryProps, + searchSortProps, + selectBtnProps, + } = hooks.videoProps({ videos }); + + const modalMessages = { + confirmMsg: messages.selectVideoButtonlabel, + titleMsg: messages.titleLabel, + uploadButtonMsg: messages.uploadButtonLabel, + fetchError: messages.fetchVideosError, + uploadError: messages.uploadVideoError, + }; + + return ( +
+ { /* TODO */ }, + size: 'fullscreen', + isFullscreenScroll: false, + galleryError, + inputError, + fileInput, + galleryProps, + searchSortProps, + selectBtnProps, + acceptedFiles: acceptedImgKeys, + modalMessages, + isLoaded, + isUploadError, + isFetchError, + }} + /> +
+ ); +}; + +VideoGallery.propTypes = { + rawVideos: PropTypes.shape({}).isRequired, + isLoaded: PropTypes.bool.isRequired, + isFetchError: PropTypes.bool.isRequired, + isUploadError: PropTypes.bool.isRequired, +}; + +export const mapStateToProps = (state) => ({ + rawVideos: selectors.app.videos(state), + isLoaded: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchVideos }), + isFetchError: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchVideos }), + isUploadError: selectors.requests.isFailed(state, { requestKey: RequestKeys.uploadVideo }), +}); + +export const mapDispatchToProps = {}; + +export default connect(mapStateToProps, mapDispatchToProps)(VideoGallery); diff --git a/src/editors/containers/VideoGallery/index.test.jsx b/src/editors/containers/VideoGallery/index.test.jsx new file mode 100644 index 000000000..31d3896ba --- /dev/null +++ b/src/editors/containers/VideoGallery/index.test.jsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import SelectionModal from '../../sharedComponents/SelectionModal'; +import hooks from './hooks'; +import * as module from '.'; + +jest.mock('../../sharedComponents/SelectionModal', () => 'SelectionModal'); + +jest.mock('./hooks', () => ({ + buildVideos: jest.fn(() => []), + videoProps: jest.fn(() => ({ + galleryError: { + show: 'ShoWERror gAlLery', + set: jest.fn(), + dismiss: jest.fn(), + message: { + id: 'Gallery error id', + defaultMessage: 'Gallery error', + description: 'Gallery error', + }, + }, + inputError: { + show: 'ShoWERror inPUT', + set: jest.fn(), + dismiss: jest.fn(), + message: { + id: 'Input error id', + defaultMessage: 'Input error', + description: 'Input error', + }, + }, + fileInput: { + addFile: 'videoHooks.fileInput.addFile', + click: 'videoHooks.fileInput.click', + ref: 'videoHooks.fileInput.ref', + }, + galleryProps: { gallery: 'props' }, + searchSortProps: { search: 'sortProps' }, + selectBtnProps: { select: 'btnProps' }, + })), +})); + +jest.mock('../../data/redux', () => ({ + selectors: { + requests: { + isLoaded: (state, { requestKey }) => ({ isLoaded: { state, requestKey } }), + isFetchError: (state, { requestKey }) => ({ isFetchError: { state, requestKey } }), + isUploadError: (state, { requestKey }) => ({ isUploadError: { state, requestKey } }), + }, + }, +})); + +describe('VideoGallery', () => { + describe('component', () => { + const props = { + rawVideos: { sOmEaSsET: { staTICUrl: '/video/sOmEaSsET' } }, + isLoaded: false, + isFetchError: false, + isUploadError: false, + }; + let el; + const videoProps = hooks.videoProps(); + beforeEach(() => { + el = shallow(); + }); + it('provides confirm action, forwarding selectBtnProps from imgHooks', () => { + expect(el.find(SelectionModal).props().selectBtnProps).toEqual( + expect.objectContaining({ ...hooks.videoProps().selectBtnProps }), + ); + }); + it('provides file upload button linked to fileInput.click', () => { + expect(el.find(SelectionModal).props().fileInput.click).toEqual( + videoProps.fileInput.click, + ); + }); + it('provides a SearchSort component with searchSortProps from imgHooks', () => { + expect(el.find(SelectionModal).props().searchSortProps).toEqual(videoProps.searchSortProps); + }); + it('provides a Gallery component with galleryProps from imgHooks', () => { + expect(el.find(SelectionModal).props().galleryProps).toEqual(videoProps.galleryProps); + }); + it('provides a FileInput component with fileInput props from imgHooks', () => { + expect(el.find(SelectionModal).props().fileInput).toMatchObject(videoProps.fileInput); + }); + }); +}); diff --git a/src/editors/containers/VideoGallery/messages.js b/src/editors/containers/VideoGallery/messages.js new file mode 100644 index 000000000..a846b3956 --- /dev/null +++ b/src/editors/containers/VideoGallery/messages.js @@ -0,0 +1,117 @@ +export const messages = { + // Gallery + emptyGalleryLabel: { + id: 'authoring.selectvideomodal.emptyGalleryLabel', + defaultMessage: + 'No results found.', + description: 'Label for when video gallery is empty.', + }, + selectVideoButtonlabel: { + id: 'authoring.selectvideomodal.selectvideo.label', + defaultMessage: 'Select video', + description: 'Label for Select video button', + }, + titleLabel: { + id: 'authoring.selectvideomodal.title.label', + defaultMessage: 'Add video to your course', + description: 'Title for the select video modal', + }, + uploadButtonLabel: { + id: 'authoring.selectvideomodal.upload.label', + defaultMessage: 'Upload or embed a new video', + description: 'Label for upload button', + }, + + // Sort Dropdown + sortByDateNewest: { + id: 'authoring.selectvideomodal.sort.datenewest.label', + defaultMessage: 'By date added (newest)', + description: 'Dropdown label for sorting by date (newest)', + }, + sortByDateOldest: { + id: 'authoring.selectvideomodal.sort.dateoldest.label', + defaultMessage: 'By date added (oldest)', + description: 'Dropdown label for sorting by date (oldest)', + }, + sortByNameAscending: { + id: 'authoring.selectvideomodal.sort.nameascending.label', + defaultMessage: 'By name (ascending)', + description: 'Dropdown label for sorting by name (ascending)', + }, + sortByNameDescending: { + id: 'authoring.selectvideomodal.sort.namedescending.label', + defaultMessage: 'By name (descending)', + description: 'Dropdown label for sorting by name (descending)', + }, + sortByDurationShortest: { + id: 'authoring.selectvideomodal.sort.durationshortest.label', + defaultMessage: 'By duration (shortest)', + description: 'Dropdown label for sorting by duration (shortest)', + }, + sortByDurationLongest: { + id: 'authoring.selectvideomodal.sort.durationlongest.label', + defaultMessage: 'By duration (longest)', + description: 'Dropdown label for sorting by duration (longest)', + }, + + // Filter Dropdown + filterByVideoStatusNone: { + id: 'authoring.selectvideomodal.filter.videostatusnone.label', + defaultMessage: 'Video status', + description: 'Dropdown label for filter by video status (none)', + }, + filterByVideoStatusUploading: { + id: 'authoring.selectvideomodal.filter.videostatusuploading.label', + defaultMessage: 'Uploading', + description: 'Dropdown label for filter by video status (uploading)', + }, + filterByVideoStatusProcessing: { + id: 'authoring.selectvideomodal.filter.videostatusprocessing.label', + defaultMessage: 'Processing', + description: 'Dropdown label for filter by video status (processing)', + }, + filterByVideoStatusReady: { + id: 'authoring.selectvideomodal.filter.videostatusready.label', + defaultMessage: 'Ready', + description: 'Dropdown label for filter by video status (ready)', + }, + filterByVideoStatusFailed: { + id: 'authoring.selectvideomodal.filter.videostatusfailed.label', + defaultMessage: 'Failed', + description: 'Dropdown label for filter by video status (failed)', + }, + + // Hide switch + hideSelectedCourseVideosSwitchLabel: { + id: 'authoring.selectvideomodal.switch.hideselectedcoursevideos.label', + defaultMessage: 'Hide selected course videos', + description: 'Switch label for hide selected course videos', + }, + + // Errors + selectVideoError: { + id: 'authoring.selectvideomodal.error.selectVideoError', + defaultMessage: 'Select a video to continue.', + description: + 'Message presented to user when clicking Next without selecting a video', + }, + fileSizeError: { + id: 'authoring.selectvideomodal.error.fileSizeError', + defaultMessage: + 'Video must be 10 MB or less. Please resize image and try again.', + description: + 'Message presented to user when file size of video is larger than 10 MB', + }, + uploadVideoError: { + id: 'authoring.selectvideomodal.error.uploadVideoError', + defaultMessage: 'Failed to upload video. Please try again.', + description: 'Message presented to user when video fails to upload', + }, + fetchVideosError: { + id: 'authoring.selectvideomodal.error.fetchVideosError', + defaultMessage: 'Failed to obtain course videos. Please try again.', + description: 'Message presented to user when videos are not found', + }, +}; + +export default messages; diff --git a/src/editors/containers/VideoGallery/utils.js b/src/editors/containers/VideoGallery/utils.js new file mode 100644 index 000000000..989a84e80 --- /dev/null +++ b/src/editors/containers/VideoGallery/utils.js @@ -0,0 +1,63 @@ +import { StrictDict, keyStore } from '../../utils'; +import messages from './messages'; + +const messageKeys = keyStore(messages); + +export const sortKeys = StrictDict({ + dateNewest: 'dateNewest', + dateOldest: 'dateOldest', + nameAscending: 'nameAscending', + nameDescending: 'nameDescending', + durationShortest: 'durationShortest', + durationLongest: 'durationLongest', +}); + +export const sortMessages = StrictDict({ + dateNewest: messages[messageKeys.sortByDateNewest], + dateOldest: messages[messageKeys.sortByDateOldest], + nameAscending: messages[messageKeys.sortByNameAscending], + nameDescending: messages[messageKeys.sortByNameDescending], + durationShortest: messages[messageKeys.sortByDurationShortest], + durationLongest: messages[messageKeys.sortByDurationLongest], +}); + +export const filterKeys = StrictDict({ + videoStatus: 'videoStatus', + uploading: 'uploading', + processing: 'processing', + ready: 'ready', + failed: 'failed', +}); + +export const filterMessages = StrictDict({ + videoStatus: messages[messageKeys.filterByVideoStatusNone], + uploading: messages[messageKeys.filterByVideoStatusUploading], + processing: messages[messageKeys.filterByVideoStatusProcessing], + ready: messages[messageKeys.filterByVideoStatusReady], + failed: messages[messageKeys.filterByVideoStatusFailed], +}); + +export const sortFunctions = StrictDict({ + dateNewest: (a, b) => b.dateAdded - a.dateAdded, + dateOldest: (a, b) => a.dateAdded - b.dateAdded, + nameAscending: (a, b) => { + const nameA = a.displayName.toLowerCase(); + const nameB = b.displayName.toLowerCase(); + if (nameA < nameB) { return -1; } + if (nameB < nameA) { return 1; } + return b.dateAdded - a.dateAdded; + }, + nameDescending: (a, b) => { + const nameA = a.displayName.toLowerCase(); + const nameB = b.displayName.toLowerCase(); + if (nameA < nameB) { return 1; } + if (nameB < nameA) { return -1; } + return b.dateAdded - a.dateAdded; + }, + durationShortest: (a, b) => a.duration - b.duration, + durationLongest: (a, b) => b.duration - a.duration, +}); + +export const acceptedImgKeys = StrictDict({ + mp4: '.mp4', +}); diff --git a/src/editors/containers/VideoGallery/utils.test.js b/src/editors/containers/VideoGallery/utils.test.js new file mode 100644 index 000000000..ef9818833 --- /dev/null +++ b/src/editors/containers/VideoGallery/utils.test.js @@ -0,0 +1,62 @@ +import { sortFunctions } from './utils'; + +describe('VideGallery utils', () => { + describe('sortFunctions', () => { + const dateA = { + dateAdded: new Date('2023-03-30'), + }; + const dateB = { + dateAdded: new Date('2023-03-31'), + }; + const nameA = { + displayName: 'This is the Name A', + dateAdded: new Date('2023-03-30'), + }; + const nameB = { + displayName: 'Hello World', + dateAdded: new Date('2023-03-30'), + }; + const nameC = { + displayName: 'Hello World', + dateAdded: new Date('2023-03-31'), + }; + const durationA = { + duration: 10, + }; + const durationB = { + duration: 100, + }; + test('correct functionality of dateNewest', () => { + expect(sortFunctions.dateNewest(dateA, dateB)).toBeGreaterThan(0); + expect(sortFunctions.dateNewest(dateB, dateA)).toBeLessThan(0); + expect(sortFunctions.dateNewest(dateA, dateA)).toEqual(0); + }); + test('correct functionality of dateOldest', () => { + expect(sortFunctions.dateOldest(dateA, dateB)).toBeLessThan(0); + expect(sortFunctions.dateOldest(dateB, dateA)).toBeGreaterThan(0); + expect(sortFunctions.dateOldest(dateA, dateA)).toEqual(0); + }); + test('correct functionality of nameAscending', () => { + expect(sortFunctions.nameAscending(nameA, nameB)).toEqual(1); + expect(sortFunctions.nameAscending(nameB, nameA)).toEqual(-1); + expect(sortFunctions.nameAscending(nameA, nameA)).toEqual(0); + expect(sortFunctions.nameAscending(nameB, nameC)).toBeGreaterThan(0); + }); + test('correct functionality of nameDescending', () => { + expect(sortFunctions.nameDescending(nameA, nameB)).toEqual(-1); + expect(sortFunctions.nameDescending(nameB, nameA)).toEqual(1); + expect(sortFunctions.nameDescending(nameA, nameA)).toEqual(0); + expect(sortFunctions.nameDescending(nameB, nameC)).toBeGreaterThan(0); + }); + test('correct functionality of durationShortest', () => { + expect(sortFunctions.durationShortest(durationA, durationB)).toBeLessThan(0); + expect(sortFunctions.durationShortest(durationB, durationA)).toBeGreaterThan(0); + expect(sortFunctions.durationShortest(durationA, durationA)).toEqual(0); + }); + test('correct functionality of durationLongest', () => { + expect(sortFunctions.durationLongest(durationA, durationB)).toBeGreaterThan(0); + expect(sortFunctions.durationLongest(durationB, durationA)).toBeLessThan(0); + expect(sortFunctions.durationLongest(durationA, durationA)).toEqual(0); + }); + }); +}); diff --git a/src/editors/data/constants/requests.js b/src/editors/data/constants/requests.js index 195bbf469..989661420 100644 --- a/src/editors/data/constants/requests.js +++ b/src/editors/data/constants/requests.js @@ -9,12 +9,14 @@ export const RequestStates = StrictDict({ export const RequestKeys = StrictDict({ fetchAssets: 'fetchAssets', + fetchVideos: 'fetchVideos', fetchBlock: 'fetchBlock', fetchImages: 'fetchImages', fetchUnit: 'fetchUnit', fetchStudioView: 'fetchStudioView', saveBlock: 'saveBlock', uploadAsset: 'uploadAsset', + uploadVideo: 'uploadVideo', allowThumbnailUpload: 'allowThumbnailUpload', uploadThumbnail: 'uploadThumbnail', uploadTranscript: 'uploadTranscript', diff --git a/src/editors/data/redux/app/reducer.js b/src/editors/data/redux/app/reducer.js index cc73b2b79..59a189f40 100644 --- a/src/editors/data/redux/app/reducer.js +++ b/src/editors/data/redux/app/reducer.js @@ -16,6 +16,7 @@ const initialState = { studioEndpointUrl: null, lmsEndpointUrl: null, assets: {}, + videos: {}, courseDetails: {}, }; @@ -45,6 +46,7 @@ const app = createSlice({ setSaveResponse: (state, { payload }) => ({ ...state, saveResponse: payload }), initializeEditor: (state) => ({ ...state, editorInitialized: true }), setAssets: (state, { payload }) => ({ ...state, assets: payload }), + setVideos: (state, { payload }) => ({ ...state, videos: payload }), setCourseDetails: (state, { payload }) => ({ ...state, courseDetails: payload }), }, }); diff --git a/src/editors/data/redux/app/reducer.test.js b/src/editors/data/redux/app/reducer.test.js index 09b1bccdb..f23e8c14c 100644 --- a/src/editors/data/redux/app/reducer.test.js +++ b/src/editors/data/redux/app/reducer.test.js @@ -48,6 +48,7 @@ describe('app reducer', () => { ['setBlockTitle', 'blockTitle'], ['setSaveResponse', 'saveResponse'], ['setAssets', 'assets'], + ['setVideos', 'videos'], ['setCourseDetails', 'courseDetails'], ].map(args => setterTest(...args)); describe('setBlockValue', () => { diff --git a/src/editors/data/redux/app/selectors.js b/src/editors/data/redux/app/selectors.js index 4df9eceae..7c6a99d40 100644 --- a/src/editors/data/redux/app/selectors.js +++ b/src/editors/data/redux/app/selectors.js @@ -22,6 +22,7 @@ export const simpleSelectors = { unitUrl: mkSimpleSelector(app => app.unitUrl), blockTitle: mkSimpleSelector(app => app.blockTitle), assets: mkSimpleSelector(app => app.assets), + videos: mkSimpleSelector(app => app.videos), }; export const returnUrl = createSelector( diff --git a/src/editors/data/redux/app/selectors.test.js b/src/editors/data/redux/app/selectors.test.js index d2fb44f28..ac7cc400a 100644 --- a/src/editors/data/redux/app/selectors.test.js +++ b/src/editors/data/redux/app/selectors.test.js @@ -48,6 +48,7 @@ describe('app selectors unit tests', () => { simpleKeys.blockTitle, simpleKeys.studioView, simpleKeys.assets, + simpleKeys.videos, ].map(testSimpleSelector); }); }); diff --git a/src/editors/data/redux/requests/reducer.js b/src/editors/data/redux/requests/reducer.js index 4f604cd12..15274560f 100644 --- a/src/editors/data/redux/requests/reducer.js +++ b/src/editors/data/redux/requests/reducer.js @@ -16,6 +16,8 @@ const initialState = { [RequestKeys.deleteTranscript]: { status: RequestStates.inactive }, [RequestKeys.fetchCourseDetails]: { status: RequestStates.inactive }, [RequestKeys.fetchAssets]: { status: RequestStates.inactive }, + [RequestKeys.fetchVideos]: { status: RequestStates.inactive }, + [RequestKeys.uploadVideo]: { status: RequestStates.inactive }, [RequestKeys.checkTranscriptsForImport]: { status: RequestStates.inactive }, [RequestKeys.importTranscript]: { status: RequestStates.inactive }, [RequestKeys.fetchVideoFeatures]: { status: RequestStates.inactive }, diff --git a/src/editors/data/redux/thunkActions/app.js b/src/editors/data/redux/thunkActions/app.js index aca044751..d080cb528 100644 --- a/src/editors/data/redux/thunkActions/app.js +++ b/src/editors/data/redux/thunkActions/app.js @@ -31,6 +31,12 @@ export const fetchAssets = () => (dispatch) => { })); }; +export const fetchVideos = () => (dispatch) => { + dispatch(requests.fetchVideos({ + onSuccess: (response) => dispatch(actions.app.setVideos(response.data.videos)), + })); +}; + export const fetchCourseDetails = () => (dispatch) => { dispatch(requests.fetchCourseDetails({ onSuccess: (response) => dispatch(actions.app.setCourseDetails(response)), @@ -50,6 +56,7 @@ export const initialize = (data) => (dispatch) => { dispatch(module.fetchUnit()); dispatch(module.fetchStudioView()); dispatch(module.fetchAssets()); + dispatch(module.fetchVideos()); dispatch(module.fetchCourseDetails()); }; @@ -74,11 +81,6 @@ export const uploadImage = ({ file, setSelection }) => (dispatch) => { })); }; -export const fetchVideos = ({ onSuccess }) => (dispatch) => { - dispatch(requests.fetchAssets({ onSuccess })); - // onSuccess(mockData.mockVideoData); -}; - export default StrictDict({ fetchBlock, fetchCourseDetails, diff --git a/src/editors/data/redux/thunkActions/app.test.js b/src/editors/data/redux/thunkActions/app.test.js index 01373e9af..f6ee54837 100644 --- a/src/editors/data/redux/thunkActions/app.test.js +++ b/src/editors/data/redux/thunkActions/app.test.js @@ -9,6 +9,7 @@ jest.mock('./requests', () => ({ uploadAsset: (args) => ({ uploadAsset: args }), fetchStudioView: (args) => ({ fetchStudioView: args }), fetchAssets: (args) => ({ fetchAssets: args }), + fetchVideos: (args) => ({ fetchVideos: args }), fetchCourseDetails: (args) => ({ fetchCourseDetails: args }), })); @@ -105,12 +106,14 @@ describe('app thunkActions', () => { fetchUnit, fetchStudioView, fetchAssets, + fetchVideos, fetchCourseDetails, } = thunkActions; thunkActions.fetchBlock = () => 'fetchBlock'; thunkActions.fetchUnit = () => 'fetchUnit'; thunkActions.fetchStudioView = () => 'fetchStudioView'; thunkActions.fetchAssets = () => 'fetchAssets'; + thunkActions.fetchVideos = () => 'fetchVideos'; thunkActions.fetchCourseDetails = () => 'fetchCourseDetails'; thunkActions.initialize(testValue)(dispatch); expect(dispatch.mock.calls).toEqual([ @@ -119,12 +122,14 @@ describe('app thunkActions', () => { [thunkActions.fetchUnit()], [thunkActions.fetchStudioView()], [thunkActions.fetchAssets()], + [thunkActions.fetchVideos()], [thunkActions.fetchCourseDetails()], ]); thunkActions.fetchBlock = fetchBlock; thunkActions.fetchUnit = fetchUnit; thunkActions.fetchStudioView = fetchStudioView; thunkActions.fetchAssets = fetchAssets; + thunkActions.fetchVideos = fetchVideos; thunkActions.fetchCourseDetails = fetchCourseDetails; }); }); @@ -161,6 +166,15 @@ describe('app thunkActions', () => { expect(dispatch).toHaveBeenCalledWith(actions.app.setAssets(response)); }); }); + describe('fetchVideos', () => { + it('dispatches fetchVideos action with setVideos for onSuccess param', () => { + const response = { data: { videos: 'testRESPONSE' } }; + thunkActions.fetchVideos()(dispatch); + const [[dispatchCall]] = dispatch.mock.calls; + dispatchCall.fetchVideos.onSuccess(response); + expect(dispatch).toHaveBeenCalledWith(actions.app.setVideos(response.data.videos)); + }); + }); describe('uploadImage', () => { const setSelection = jest.fn(); beforeEach(() => { diff --git a/src/editors/data/redux/thunkActions/requests.js b/src/editors/data/redux/thunkActions/requests.js index c8014b5a4..efdec1292 100644 --- a/src/editors/data/redux/thunkActions/requests.js +++ b/src/editors/data/redux/thunkActions/requests.js @@ -136,6 +136,18 @@ export const fetchAssets = ({ ...rest }) => (dispatch, getState) => { })); }; +export const fetchVideos = ({ ...rest }) => (dispatch, getState) => { + dispatch(module.networkRequest({ + requestKey: RequestKeys.fetchVideos, + promise: api + .fetchVideos({ + studioEndpointUrl: selectors.app.studioEndpointUrl(getState()), + learningContextId: selectors.app.learningContextId(getState()), + }), + ...rest, + })); +}; + export const allowThumbnailUpload = ({ ...rest }) => (dispatch, getState) => { dispatch(module.networkRequest({ requestKey: RequestKeys.allowThumbnailUpload, @@ -289,6 +301,7 @@ export default StrictDict({ fetchUnit, saveBlock, fetchAssets, + fetchVideos, uploadAsset, allowThumbnailUpload, uploadThumbnail, diff --git a/src/editors/data/redux/thunkActions/requests.test.js b/src/editors/data/redux/thunkActions/requests.test.js index ea77e5d81..e2b3c548a 100644 --- a/src/editors/data/redux/thunkActions/requests.test.js +++ b/src/editors/data/redux/thunkActions/requests.test.js @@ -27,6 +27,7 @@ jest.mock('../../services/cms/api', () => ({ fetchCourseDetails: (args) => args, saveBlock: (args) => args, fetchAssets: ({ id, url }) => ({ id, url }), + fetchVideos: ({ id, url }) => ({ id, url }), uploadAsset: (args) => args, loadImages: jest.fn(), uploadThumbnail: (args) => args, @@ -265,6 +266,32 @@ describe('requests thunkActions module', () => { expect(loadImages).toHaveBeenCalledWith({ fetchAssets: expectedArgs }); }); }); + describe('fetchVideos', () => { + const expectedArgs = { + studioEndpointUrl: selectors.app.studioEndpointUrl(testState), + learningContextId: selectors.app.learningContextId(testState), + }; + let fetchVideos; + let dispatchedAction; + beforeEach(() => { + fetchVideos = jest.fn((args) => new Promise((resolve) => { + resolve({ data: { videos: { fetchVideos: args } } }); + })); + jest.spyOn(api, apiKeys.fetchVideos).mockImplementationOnce(fetchVideos); + requests.fetchVideos({ ...fetchParams, onSuccess, onFailure })(dispatch, () => testState); + [[dispatchedAction]] = dispatch.mock.calls; + }); + it('dispatches networkRequest', () => { + expect(dispatchedAction.networkRequest).not.toEqual(undefined); + }); + test('forwards onSuccess and onFailure', () => { + expect(dispatchedAction.networkRequest.onSuccess).toEqual(onSuccess); + expect(dispatchedAction.networkRequest.onFailure).toEqual(onFailure); + }); + test('api.fetchVideos promise called with studioEndpointUrl and learningContextId', () => { + expect(fetchVideos).toHaveBeenCalledWith(expectedArgs); + }); + }); describe('saveBlock', () => { const content = 'SoME HtMl CoNtent As String'; testNetworkRequestAction({ diff --git a/src/editors/data/services/cms/api.js b/src/editors/data/services/cms/api.js index 141bd3b43..f0739b504 100644 --- a/src/editors/data/services/cms/api.js +++ b/src/editors/data/services/cms/api.js @@ -18,6 +18,9 @@ export const apiMethods = { fetchAssets: ({ learningContextId, studioEndpointUrl }) => get( urls.courseAssets({ studioEndpointUrl, learningContextId }), ), + fetchVideos: ({ studioEndpointUrl, learningContextId }) => get( + urls.courseVideos({ studioEndpointUrl, learningContextId }), + ), fetchCourseDetails: ({ studioEndpointUrl, learningContextId }) => get( urls.courseDetailsUrl({ studioEndpointUrl, learningContextId }), ), diff --git a/src/editors/data/services/cms/api.test.js b/src/editors/data/services/cms/api.test.js index b2a97fa7f..6e94bb8e0 100644 --- a/src/editors/data/services/cms/api.test.js +++ b/src/editors/data/services/cms/api.test.js @@ -21,8 +21,11 @@ jest.mock('./urls', () => ({ allowThumbnailUpload: jest.fn().mockName('urls.allowThumbnailUpload'), thumbnailUpload: jest.fn().mockName('urls.thumbnailUpload'), checkTranscriptsForImport: jest.fn().mockName('urls.checkTranscriptsForImport'), + courseDetailsUrl: jest.fn().mockName('urls.courseDetailsUrl'), + courseAdvanceSettings: jest.fn().mockName('urls.courseAdvanceSettings'), replaceTranscript: jest.fn().mockName('urls.replaceTranscript'), videoFeatures: jest.fn().mockName('urls.videoFeatures'), + courseVideos: jest.fn().mockName('urls.courseVideos'), })); jest.mock('./utils', () => ({ @@ -73,6 +76,27 @@ describe('cms api', () => { }); }); + describe('fetchCourseDetails', () => { + it('should call get with url.courseDetailsUrl', () => { + apiMethods.fetchCourseDetails({ learningContextId, studioEndpointUrl }); + expect(get).toHaveBeenCalledWith(urls.courseDetailsUrl({ studioEndpointUrl, learningContextId })); + }); + }); + + describe('fetchVideos', () => { + it('should call get with url.courseVideos', () => { + apiMethods.fetchVideos({ learningContextId, studioEndpointUrl }); + expect(get).toHaveBeenCalledWith(urls.courseVideos({ studioEndpointUrl, learningContextId })); + }); + }); + + describe('fetchAdvancedSettings', () => { + it('should call get with url.courseAdvanceSettings', () => { + apiMethods.fetchAdvancedSettings({ learningContextId, studioEndpointUrl }); + expect(get).toHaveBeenCalledWith(urls.courseAdvanceSettings({ studioEndpointUrl, learningContextId })); + }); + }); + describe('normalizeContent', () => { test('return value for blockType: html', () => { const content = 'Im baby palo santo ugh celiac fashion axe. La croix lo-fi venmo whatever. Beard man braid migas single-origin coffee forage ramps.'; diff --git a/src/editors/data/services/cms/urls.js b/src/editors/data/services/cms/urls.js index c8642c741..c8d2e0618 100644 --- a/src/editors/data/services/cms/urls.js +++ b/src/editors/data/services/cms/urls.js @@ -66,3 +66,7 @@ export const courseAdvanceSettings = ({ studioEndpointUrl, learningContextId }) export const videoFeatures = ({ studioEndpointUrl, learningContextId }) => ( `${studioEndpointUrl}/video_features/${learningContextId}` ); + +export const courseVideos = ({ studioEndpointUrl, learningContextId }) => ( + `${studioEndpointUrl}/videos/${learningContextId}` +); diff --git a/src/editors/data/services/cms/urls.test.js b/src/editors/data/services/cms/urls.test.js index 9f5282a37..47dbd5cff 100644 --- a/src/editors/data/services/cms/urls.test.js +++ b/src/editors/data/services/cms/urls.test.js @@ -13,7 +13,9 @@ import { courseDetailsUrl, checkTranscriptsForImport, replaceTranscript, + courseAdvanceSettings, videoFeatures, + courseVideos, } from './urls'; describe('cms url methods', () => { @@ -125,10 +127,22 @@ describe('cms url methods', () => { .toEqual(`${studioEndpointUrl}/transcripts/replace?data=${parameters}`); }); }); + describe('courseAdvanceSettings', () => { + it('returns url with studioEndpointUrl and learningContextId', () => { + expect(courseAdvanceSettings({ studioEndpointUrl, learningContextId })) + .toEqual(`${studioEndpointUrl}/api/contentstore/v0/advanced_settings/${learningContextId}`); + }); + }); describe('videoFeatures', () => { it('returns url with studioEndpointUrl and learningContextId', () => { expect(videoFeatures({ studioEndpointUrl, learningContextId })) .toEqual(`${studioEndpointUrl}/video_features/${learningContextId}`); }); }); + describe('courseVideos', () => { + it('returns url with studioEndpointUrl and learningContextId', () => { + expect(courseVideos({ studioEndpointUrl, learningContextId })) + .toEqual(`${studioEndpointUrl}/videos/${learningContextId}`); + }); + }); }); diff --git a/src/editors/sharedComponents/BaseModal/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/BaseModal/__snapshots__/index.test.jsx.snap index e15c4ccdc..00d2ed5d9 100644 --- a/src/editors/sharedComponents/BaseModal/__snapshots__/index.test.jsx.snap +++ b/src/editors/sharedComponents/BaseModal/__snapshots__/index.test.jsx.snap @@ -10,12 +10,20 @@ exports[`BaseModal ImageUploadModal template component snapshot 1`] = ` size="lg" variant="default" > - + props.title node - + props.children node diff --git a/src/editors/sharedComponents/BaseModal/index.jsx b/src/editors/sharedComponents/BaseModal/index.jsx index fd029e673..66521fcb7 100644 --- a/src/editors/sharedComponents/BaseModal/index.jsx +++ b/src/editors/sharedComponents/BaseModal/index.jsx @@ -14,9 +14,12 @@ export const BaseModal = ({ close, title, children, + headerComponent, confirmAction, footerAction, size, + isFullscreenScroll, + bodyStyle, }) => ( - + {title} + {headerComponent} - + {children} @@ -50,7 +54,10 @@ export const BaseModal = ({ BaseModal.defaultProps = { footerAction: null, + headerComponent: null, size: 'lg', + isFullscreenScroll: true, + bodyStyle: null, }; BaseModal.propTypes = { @@ -60,7 +67,10 @@ BaseModal.propTypes = { children: PropTypes.node.isRequired, confirmAction: PropTypes.node.isRequired, footerAction: PropTypes.node, + headerComponent: PropTypes.node, size: PropTypes.string, + isFullscreenScroll: PropTypes.bool, + bodyStyle: PropTypes.shape({}), }; export default BaseModal; diff --git a/src/editors/sharedComponents/ErrorAlerts/ErrorAlert.jsx b/src/editors/sharedComponents/ErrorAlerts/ErrorAlert.jsx index fe73eb053..40a813e89 100644 --- a/src/editors/sharedComponents/ErrorAlerts/ErrorAlert.jsx +++ b/src/editors/sharedComponents/ErrorAlerts/ErrorAlert.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Alert } from '@edx/paragon'; -import { Outline } from '@edx/paragon/icons'; +import { Error } from '@edx/paragon/icons'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import messages from './messages'; @@ -42,7 +42,7 @@ export const ErrorAlert = ({ return ( diff --git a/src/editors/sharedComponents/ErrorAlerts/FetchErrorAlert.jsx b/src/editors/sharedComponents/ErrorAlerts/FetchErrorAlert.jsx index 4f916f5aa..d098c7678 100644 --- a/src/editors/sharedComponents/ErrorAlerts/FetchErrorAlert.jsx +++ b/src/editors/sharedComponents/ErrorAlerts/FetchErrorAlert.jsx @@ -1,17 +1,12 @@ import React from 'react'; -import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import ErrorAlert from './ErrorAlert'; -import { selectors } from '../../data/redux'; -import { RequestKeys } from '../../data/constants/requests'; export const FetchErrorAlert = ({ message, - // redux isFetchError, - // inject }) => ( ({ - isFetchError: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchAssets }), -}); -export const mapDispatchToProps = {}; -export default connect(mapStateToProps, mapDispatchToProps)(FetchErrorAlert); + +export default FetchErrorAlert; diff --git a/src/editors/sharedComponents/ErrorAlerts/FetchErrorAlert.test.jsx b/src/editors/sharedComponents/ErrorAlerts/FetchErrorAlert.test.jsx index ddae06cdd..ddb838388 100644 --- a/src/editors/sharedComponents/ErrorAlerts/FetchErrorAlert.test.jsx +++ b/src/editors/sharedComponents/ErrorAlerts/FetchErrorAlert.test.jsx @@ -1,8 +1,6 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { FetchErrorAlert, mapStateToProps } from './FetchErrorAlert'; -import { selectors } from '../../data/redux'; -import { RequestKeys } from '../../data/constants/requests'; +import { FetchErrorAlert } from './FetchErrorAlert'; jest.mock('../../data/redux', () => ({ selectors: { @@ -18,12 +16,4 @@ describe('FetchErrorAlert', () => { expect(shallow()).toMatchSnapshot(); }); }); - describe('mapStateToProps', () => { - const testState = { A: 'pple', B: 'anana', C: 'ucumber' }; - test('isFetchError from requests.isFinished', () => { - expect( - mapStateToProps(testState).isFetchError, - ).toEqual(selectors.requests.isFailed(testState, { requestKey: RequestKeys.fetchAssets })); - }); - }); }); diff --git a/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.jsx b/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.jsx index 74f4e664a..9119c4627 100644 --- a/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.jsx +++ b/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.jsx @@ -1,17 +1,12 @@ import React from 'react'; -import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import ErrorAlert from './ErrorAlert'; -import { selectors } from '../../data/redux'; -import { RequestKeys } from '../../data/constants/requests'; export const UploadErrorAlert = ({ message, - // redux isUploadError, - // inject }) => ( ({ - isUploadError: selectors.requests.isFailed(state, { requestKey: RequestKeys.uploadAsset }), -}); -export const mapDispatchToProps = {}; -export default connect(mapStateToProps, mapDispatchToProps)(UploadErrorAlert); + +export default UploadErrorAlert; diff --git a/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.test.jsx b/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.test.jsx index 8ae5d03ff..4365c6903 100644 --- a/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.test.jsx +++ b/src/editors/sharedComponents/ErrorAlerts/UploadErrorAlert.test.jsx @@ -1,8 +1,6 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { UploadErrorAlert, mapStateToProps } from './UploadErrorAlert'; -import { selectors } from '../../data/redux'; -import { RequestKeys } from '../../data/constants/requests'; +import { UploadErrorAlert } from './UploadErrorAlert'; jest.mock('../../data/redux', () => ({ selectors: { @@ -18,12 +16,4 @@ describe('UploadErrorAlert', () => { expect(shallow()).toMatchSnapshot(); }); }); - describe('mapStateToProps', () => { - const testState = { A: 'pple', B: 'anana', C: 'ucumber' }; - test('isUploadError from requests.isFinished', () => { - expect( - mapStateToProps(testState).isUploadError, - ).toEqual(selectors.requests.isFailed(testState, { requestKey: RequestKeys.uploadAsset })); - }); - }); }); diff --git a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/__snapshots__/index.test.jsx.snap index b3091d827..c0459220a 100644 --- a/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/__snapshots__/index.test.jsx.snap +++ b/src/editors/sharedComponents/ImageUploadModal/ImageSettingsModal/__snapshots__/index.test.jsx.snap @@ -2,6 +2,7 @@ exports[`ImageSettingsModal render snapshot 1`] = ` } footerAction={null} + headerComponent={null} + isFullscreenScroll={true} isOpen={false} size="lg" title="Image Settings" diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/GalleryCard.jsx b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/GalleryCard.jsx deleted file mode 100644 index c031fd32e..000000000 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/GalleryCard.jsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -import { Image, SelectableBox } from '@edx/paragon'; -import { FormattedMessage, FormattedDate, FormattedTime } from '@edx/frontend-platform/i18n'; - -import messages from './messages'; - -export const GalleryCard = ({ - img, -}) => ( - -
- -
-

{img.displayName}

-

- , - time: , - }} - /> -

-
-
-
-); - -GalleryCard.propTypes = { - img: PropTypes.shape({ - contentType: PropTypes.string, - displayName: PropTypes.string, - externalUrl: PropTypes.string, - id: PropTypes.string, - dateAdded: PropTypes.number, - locked: PropTypes.bool, - portableUrl: PropTypes.string, - thumbnail: PropTypes.string, - url: PropTypes.string, - }).isRequired, -}; - -export default GalleryCard; diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/SearchSort.jsx b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/SearchSort.jsx deleted file mode 100644 index a2b406be7..000000000 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/SearchSort.jsx +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -import { - ActionRow, Dropdown, Form, Icon, IconButton, -} from '@edx/paragon'; -import { Close, Search } from '@edx/paragon/icons'; -import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; - -import { sortKeys, sortMessages } from './utils'; -import messages from './messages'; - -export const SearchSort = ({ - searchString, - onSearchChange, - clearSearchString, - sortBy, - onSortClick, - // injected - intl, -}) => ( - - - - ) - : - } - value={searchString} - /> - - - - - - - - {Object.keys(sortKeys).map(key => ( - - - - ))} - - - -); - -SearchSort.propTypes = { - searchString: PropTypes.string.isRequired, - onSearchChange: PropTypes.func.isRequired, - clearSearchString: PropTypes.func.isRequired, - sortBy: PropTypes.string.isRequired, - onSortClick: PropTypes.func.isRequired, - // injected - intl: intlShape.isRequired, -}; - -export default injectIntl(SearchSort); diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/SearchSort.test.jsx b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/SearchSort.test.jsx deleted file mode 100644 index bb2b98676..000000000 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/SearchSort.test.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import { Dropdown } from '@edx/paragon'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; - -import { formatMessage } from '../../../../testUtils'; - -import { sortKeys, sortMessages } from './utils'; -import { SearchSort } from './SearchSort'; - -describe('SearchSort component', () => { - const props = { - searchString: 'props.searchString', - onSearchChange: jest.fn().mockName('props.onSearchChange'), - clearSearchString: jest.fn().mockName('props.clearSearchString'), - sortBy: sortKeys.dateOldest, - onSortClick: jest.fn().mockName('props.onSortClick'), - intl: { formatMessage }, - }; - describe('snapshots', () => { - test('with search string (close button)', () => { - expect(shallow()).toMatchSnapshot(); - }); - test('without search string (search icon)', () => { - expect(shallow()).toMatchSnapshot(); - }); - test('adds a sort option for each sortKey', () => { - const el = shallow(); - expect(el.find(Dropdown).containsMatchingElement( - , - )).toEqual(true); - expect(el.find(Dropdown).containsMatchingElement( - , - )).toEqual(true); - expect(el.find(Dropdown).containsMatchingElement( - , - )).toEqual(true); - expect(el.find(Dropdown).containsMatchingElement( - , - )).toEqual(true); - }); - }); -}); diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/__snapshots__/SearchSort.test.jsx.snap b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/__snapshots__/SearchSort.test.jsx.snap deleted file mode 100644 index 7f90e9090..000000000 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/__snapshots__/SearchSort.test.jsx.snap +++ /dev/null @@ -1,152 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SearchSort component snapshots with search string (close button) 1`] = ` - - - - } - value="props.searchString" - /> - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[`SearchSort component snapshots without search string (search icon) 1`] = ` - - - } - value="" - /> - - - - - - - - - - - - - - - - - - - - - - -`; diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/__snapshots__/index.test.jsx.snap index 797f148e3..6c4ef7516 100644 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/__snapshots__/index.test.jsx.snap +++ b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/__snapshots__/index.test.jsx.snap @@ -1,189 +1,94 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`SelectImageModal component snapshot 1`] = ` - - - + - - + close={[MockFunction props.close]} + fileInput={ + Object { + "addFile": "imgHooks.fileInput.addFile", + "click": "imgHooks.fileInput.click", + "ref": "imgHooks.fileInput.ref", + } + } + galleryError={ + Object { + "dismiss": [MockFunction], + "message": Object { + "defaultMessage": "Gallery error", + "description": "Gallery error", + "id": "Gallery error id", + }, + "set": [MockFunction], + "show": "ShoWERror gAlLery", + } + } + galleryProps={ + Object { + "gallery": "props", + } + } + inputError={ + Object { + "dismiss": [MockFunction], + "message": Object { + "defaultMessage": "Input error", + "description": "Input error", + "id": "Input error id", + }, + "set": [MockFunction], + "show": "ShoWERror inPUT", + } } isOpen={true} - title="Add an image" -> - - - - - - - - - - - - - - -`; - -exports[`SelectImageModal component snapshot: uploaded image not loaded, show spinner 1`] = ` - - - } - footerAction={ - + searchSortProps={ + Object { + "search": "sortProps", + } } - isOpen={true} - title="Add an image" -> - - - - - - - - - - - - - - + } +/> `; diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/hooks.js b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/hooks.js index b8e813770..e5e2e95bb 100644 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/hooks.js +++ b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/hooks.js @@ -3,7 +3,8 @@ import { useDispatch } from 'react-redux'; import { thunkActions } from '../../../data/redux'; import * as module from './hooks'; -import { sortFunctions, sortKeys } from './utils'; +import { sortFunctions, sortKeys, sortMessages } from './utils'; +import messages from './messages'; export const state = { highlighted: (val) => React.useState(val), @@ -22,6 +23,8 @@ export const searchAndSortHooks = () => { clearSearchString: () => setSearchString(''), sortBy, onSortClick: (key) => () => setSortBy(key), + sortKeys, + sortMessages, }; }; @@ -49,11 +52,13 @@ export const imgListHooks = ({ searchSortProps, setSelection, images }) => { show: showSelectImageError, set: () => setShowSelectImageError(true), dismiss: () => setShowSelectImageError(false), + message: messages.selectImageError, }, inputError: { show: showSizeError, set: () => setShowSizeError(true), dismiss: () => setShowSizeError(false), + message: messages.fileSizeError, }, images, galleryProps: { @@ -62,6 +67,7 @@ export const imgListHooks = ({ searchSortProps, setSelection, images }) => { displayList: list, highlighted, onHighlightChange: (e) => setHighlighted(e.target.value), + emptyGalleryLabel: messages.emptyGalleryLabel, }, // highlight by id selectBtnProps: { diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.jsx b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.jsx index e2ee598bf..c56637a66 100644 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.jsx +++ b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.jsx @@ -1,27 +1,11 @@ -import React from 'react'; -import { connect } from 'react-redux'; import PropTypes from 'prop-types'; - -import { Button, Stack, Spinner } from '@edx/paragon'; -import { Add } from '@edx/paragon/icons'; -import { - FormattedMessage, - injectIntl, - intlShape, -} from '@edx/frontend-platform/i18n'; -import { selectors } from '../../../data/redux'; -import { RequestKeys } from '../../../data/constants/requests'; -import { acceptedImgKeys } from './utils'; - +import { connect } from 'react-redux'; import hooks from './hooks'; +import { acceptedImgKeys } from './utils'; +import SelectionModal from '../../SelectionModal'; import messages from './messages'; -import BaseModal from '../../BaseModal'; -import SearchSort from './SearchSort'; -import Gallery from './Gallery'; -import FileInput from '../../FileInput'; -import FetchErrorAlert from '../../ErrorAlerts/FetchErrorAlert'; -import UploadErrorAlert from '../../ErrorAlerts/UploadErrorAlert'; -import ErrorAlert from '../../ErrorAlerts/ErrorAlert'; +import { RequestKeys } from '../../../data/constants/requests'; +import { selectors } from '../../../data/redux'; export const SelectImageModal = ({ isOpen, @@ -29,10 +13,10 @@ export const SelectImageModal = ({ setSelection, clearSelection, images, - // injected - intl, // redux - inputIsLoading, + isLoaded, + isFetchError, + isUploadError, }) => { const { galleryError, @@ -43,53 +27,32 @@ export const SelectImageModal = ({ selectBtnProps, } = hooks.imgHooks({ setSelection, clearSelection, images }); - return ( - - - - )} - isOpen={isOpen} - footerAction={( - - )} - title={intl.formatMessage(messages.titleLabel)} - > - {/* Error Alerts */} - - - - - + const modalMessages = { + confirmMsg: messages.nextButtonLabel, + titleMsg: messages.titleLabel, + uploadButtonMsg: messages.uploadButtonLabel, + fetchError: messages.fetchImagesError, + uploadError: messages.uploadImageError, + }; - {/* User Feedback Alerts */} - - - - - - {!inputIsLoading ? : ( - - )} - - - + return ( + ); }; @@ -99,16 +62,18 @@ SelectImageModal.propTypes = { setSelection: PropTypes.func.isRequired, clearSelection: PropTypes.func.isRequired, images: PropTypes.arrayOf(PropTypes.string).isRequired, - // injected - intl: intlShape.isRequired, // redux - inputIsLoading: PropTypes.bool.isRequired, + isLoaded: PropTypes.bool.isRequired, + isFetchError: PropTypes.bool.isRequired, + isUploadError: PropTypes.bool.isRequired, }; export const mapStateToProps = (state) => ({ - inputIsLoading: selectors.requests.isPending(state, { requestKey: RequestKeys.uploadAsset }), + isLoaded: selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchAssets }), + isFetchError: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchAssets }), + isUploadError: selectors.requests.isFailed(state, { requestKey: RequestKeys.uploadAsset }), }); export const mapDispatchToProps = {}; -export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(SelectImageModal)); +export default connect(mapStateToProps, mapDispatchToProps)(SelectImageModal); diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.test.jsx b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.test.jsx index cc6cbf1b6..d834cfdfe 100644 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.test.jsx +++ b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/index.test.jsx @@ -2,22 +2,11 @@ import React from 'react'; import { shallow } from 'enzyme'; import { formatMessage } from '../../../../testUtils'; -import { RequestKeys } from '../../../data/constants/requests'; -import { selectors } from '../../../data/redux'; -import BaseModal from '../../BaseModal'; -import FileInput from '../../FileInput'; -import Gallery from './Gallery'; -import SearchSort from './SearchSort'; +import SelectionModal from '../../SelectionModal'; import hooks from './hooks'; -import { SelectImageModal, mapStateToProps, mapDispatchToProps } from '.'; +import { SelectImageModal } from '.'; -jest.mock('../../BaseModal', () => 'BaseModal'); -jest.mock('../../FileInput', () => 'FileInput'); -jest.mock('./Gallery', () => 'Gallery'); -jest.mock('./SearchSort', () => 'SearchSort'); -jest.mock('../../ErrorAlerts/FetchErrorAlert', () => 'FetchErrorAlert'); -jest.mock('../../ErrorAlerts/UploadErrorAlert', () => 'UploadErrorAlert'); -jest.mock('../..//ErrorAlerts/ErrorAlert', () => 'ErrorAlert'); +jest.mock('../../SelectionModal', () => 'SelectionModal'); jest.mock('./hooks', () => ({ imgHooks: jest.fn(() => ({ @@ -25,11 +14,21 @@ jest.mock('./hooks', () => ({ show: 'ShoWERror gAlLery', set: jest.fn(), dismiss: jest.fn(), + message: { + id: 'Gallery error id', + defaultMessage: 'Gallery error', + description: 'Gallery error', + }, }, inputError: { show: 'ShoWERror inPUT', set: jest.fn(), dismiss: jest.fn(), + message: { + id: 'Input error id', + defaultMessage: 'Input error', + description: 'Input error', + }, }, fileInput: { addFile: 'imgHooks.fileInput.addFile', @@ -58,7 +57,6 @@ describe('SelectImageModal', () => { setSelection: jest.fn().mockName('props.setSelection'), clearSelection: jest.fn().mockName('props.clearSelection'), intl: { formatMessage }, - inputIsLoading: false, }; let el; const imgHooks = hooks.imgHooks(); @@ -68,42 +66,24 @@ describe('SelectImageModal', () => { test('snapshot', () => { expect(el).toMatchSnapshot(); }); - test('snapshot: uploaded image not loaded, show spinner', () => { - props.inputIsLoading = true; - expect(shallow()).toMatchSnapshot(); - props.inputIsLoading = false; - }); it('provides confirm action, forwarding selectBtnProps from imgHooks', () => { - expect(el.find(BaseModal).props().confirmAction.props).toEqual( - expect.objectContaining({ ...hooks.imgHooks().selectBtnProps, variant: 'primary' }), + expect(el.find(SelectionModal).props().selectBtnProps).toEqual( + expect.objectContaining({ ...hooks.imgHooks().selectBtnProps }), ); }); it('provides file upload button linked to fileInput.click', () => { - expect(el.find(BaseModal).props().footerAction.props.onClick).toEqual( + expect(el.find(SelectionModal).props().fileInput.click).toEqual( imgHooks.fileInput.click, ); }); it('provides a SearchSort component with searchSortProps from imgHooks', () => { - expect(el.find(SearchSort).props()).toEqual(imgHooks.searchSortProps); + expect(el.find(SelectionModal).props().searchSortProps).toEqual(imgHooks.searchSortProps); }); it('provides a Gallery component with galleryProps from imgHooks', () => { - expect(el.find(Gallery).props()).toEqual(imgHooks.galleryProps); + expect(el.find(SelectionModal).props().galleryProps).toEqual(imgHooks.galleryProps); }); it('provides a FileInput component with fileInput props from imgHooks', () => { - expect(el.find(FileInput).props()).toMatchObject({ fileInput: imgHooks.fileInput }); - }); - }); - describe('mapStateToProps', () => { - const testState = { some: 'testState' }; - test('loads inputIsLoading from requests.isPending selector for uploadAsset request', () => { - expect(mapStateToProps(testState).inputIsLoading).toEqual( - selectors.requests.isPending(testState, { requestKey: RequestKeys.uploadAsset }), - ); - }); - }); - describe('mapDispatchToProps', () => { - test('is empty', () => { - expect(mapDispatchToProps).toEqual({}); + expect(el.find(SelectionModal).props().fileInput).toMatchObject(imgHooks.fileInput); }); }); }); diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/messages.js b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/messages.js index d31b9d7b6..f39c30779 100644 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/messages.js +++ b/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/messages.js @@ -17,11 +17,6 @@ const messages = defineMessages({ defaultMessage: 'Add an image', description: 'Title for the select image modal', }, - searchPlaceholder: { - id: 'authoring.texteditor.selectimagemodal.search.placeholder', - defaultMessage: 'Search', - description: 'Placeholder text for search bar', - }, // Sort Dropdown sortByDateNewest: { @@ -46,27 +41,12 @@ const messages = defineMessages({ }, // Gallery - addedDate: { - id: 'authoring.texteditor.selectimagemodal.addedDate.label', - defaultMessage: 'Added {date} at {time}', - description: 'File date-added string', - }, - loading: { - id: 'authoring.texteditor.selectimagemodal.spinner.readertext', - defaultMessage: 'loading...', - description: 'Gallery loading spinner screen-reader text', - }, emptyGalleryLabel: { id: 'authoring.texteditor.selectimagemodal.emptyGalleryLabel', defaultMessage: 'No images found in your gallery. Please upload an image using the button below.', description: 'Label for when image gallery is empty.', }, - emptySearchLabel: { - id: 'authoring.texteditor.selectimagemodal.emptySearchLabel', - defaultMessage: 'No search results.', - description: 'Label for when search returns nothing.', - }, // Errors uploadImageError: { diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/Gallery.jsx b/src/editors/sharedComponents/SelectionModal/Gallery.jsx similarity index 52% rename from src/editors/sharedComponents/ImageUploadModal/SelectImageModal/Gallery.jsx rename to src/editors/sharedComponents/SelectionModal/Gallery.jsx index 99419a6a9..e497a40c2 100644 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/Gallery.jsx +++ b/src/editors/sharedComponents/SelectionModal/Gallery.jsx @@ -1,55 +1,69 @@ import React from 'react'; -import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import { Scrollable, SelectableBox, Spinner, } from '@edx/paragon'; -import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; - -import { selectors } from '../../../data/redux'; -import { RequestKeys } from '../../../data/constants/requests'; +import { + FormattedMessage, + injectIntl, + intlShape, +} from '@edx/frontend-platform/i18n'; import messages from './messages'; import GalleryCard from './GalleryCard'; export const Gallery = ({ + show, galleryIsEmpty, searchIsEmpty, displayList, highlighted, onHighlightChange, + emptyGalleryLabel, + showIdsOnCards, + height, + isLoaded, // injected intl, - // redux - isLoaded, }) => { + if (!show) { + return null; + } if (!isLoaded) { return ( - +
+ +
); } if (galleryIsEmpty) { return ( -
- +
+
); } if (searchIsEmpty) { return ( -
+
); } return ( - +
- {displayList.map(img => )} + { displayList.map(asset => ) }
@@ -67,24 +81,23 @@ export const Gallery = ({ Gallery.defaultProps = { highlighted: '', + showIdsOnCards: false, + height: '375px', + show: true, }; Gallery.propTypes = { + show: PropTypes.bool, + isLoaded: PropTypes.bool.isRequired, galleryIsEmpty: PropTypes.bool.isRequired, searchIsEmpty: PropTypes.bool.isRequired, displayList: PropTypes.arrayOf(PropTypes.object).isRequired, highlighted: PropTypes.string, onHighlightChange: PropTypes.func.isRequired, + emptyGalleryLabel: PropTypes.shape({}).isRequired, + showIdsOnCards: PropTypes.bool, + height: PropTypes.string, // injected intl: intlShape.isRequired, - // redux - isLoaded: PropTypes.bool.isRequired, }; -const requestKey = RequestKeys.fetchAssets; -export const mapStateToProps = (state) => ({ - isLoaded: selectors.requests.isFinished(state, { requestKey }), -}); - -export const mapDispatchToProps = {}; - -export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Gallery)); +export default injectIntl(Gallery); diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/Gallery.test.jsx b/src/editors/sharedComponents/SelectionModal/Gallery.test.jsx similarity index 62% rename from src/editors/sharedComponents/ImageUploadModal/SelectImageModal/Gallery.test.jsx rename to src/editors/sharedComponents/SelectionModal/Gallery.test.jsx index 1227f08ea..eafa533df 100644 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/Gallery.test.jsx +++ b/src/editors/sharedComponents/SelectionModal/Gallery.test.jsx @@ -1,12 +1,10 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { formatMessage } from '../../../../testUtils'; -import { RequestKeys } from '../../../data/constants/requests'; -import { selectors } from '../../../data/redux'; -import { Gallery, mapStateToProps, mapDispatchToProps } from './Gallery'; +import { formatMessage } from '../../../testUtils'; +import { Gallery } from './Gallery'; -jest.mock('../../../data/redux', () => ({ +jest.mock('../../data/redux', () => ({ selectors: { requests: { isFinished: (state, { requestKey }) => ({ isFinished: { state, requestKey } }), @@ -39,18 +37,9 @@ describe('TextEditor Image Gallery component', () => { test('snapshot: loaded, show gallery', () => { expect(shallow()).toMatchSnapshot(); }); - }); - describe('mapStateToProps', () => { - const testState = { some: 'testState' }; - test('loads isLoaded from requests.isFinished selector for fetchAssets request', () => { - expect(mapStateToProps(testState).isLoaded).toEqual( - selectors.requests.isFinished(testState, { requestKey: RequestKeys.fetchAssets }), - ); - }); - }); - describe('mapDispatchToProps', () => { - test('is empty', () => { - expect(mapDispatchToProps).toEqual({}); + test('snapshot: not shot gallery', () => { + const wrapper = shallow(); + expect(wrapper.type()).toBeNull(); }); }); }); diff --git a/src/editors/sharedComponents/SelectionModal/GalleryCard.jsx b/src/editors/sharedComponents/SelectionModal/GalleryCard.jsx new file mode 100644 index 000000000..faad6f273 --- /dev/null +++ b/src/editors/sharedComponents/SelectionModal/GalleryCard.jsx @@ -0,0 +1,83 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { + Badge, + Image, + SelectableBox, +} from '@edx/paragon'; +import { FormattedMessage, FormattedDate, FormattedTime } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; +import { formatDuration } from '../../utils'; +import LanguageNamesWidget from '../../containers/VideoEditor/components/VideoSettingsModal/components/VideoPreviewWidget/LanguageNamesWidget'; + +export const GalleryCard = ({ + asset, +}) => ( + +
+
+ + { asset.status && asset.statusBadgeVariant && ( + + {asset.status} + + )} + { asset.duration >= 0 && ( + + {formatDuration(asset.duration)} + + )} +
+
+

{asset.displayName}

+ { asset.transcripts && ( +
+ +
+ )} +

+ , + time: , + }} + /> +

+
+
+
+); + +GalleryCard.propTypes = { + asset: PropTypes.shape({ + contentType: PropTypes.string, + displayName: PropTypes.string, + externalUrl: PropTypes.string, + id: PropTypes.string, + dateAdded: PropTypes.number, + locked: PropTypes.bool, + portableUrl: PropTypes.string, + thumbnail: PropTypes.string, + url: PropTypes.string, + duration: PropTypes.number, + status: PropTypes.string, + statusBadgeVariant: PropTypes.string, + transcripts: PropTypes.array, + }).isRequired, +}; + +export default GalleryCard; diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/GalleryCard.test.jsx b/src/editors/sharedComponents/SelectionModal/GalleryCard.test.jsx similarity index 92% rename from src/editors/sharedComponents/ImageUploadModal/SelectImageModal/GalleryCard.test.jsx rename to src/editors/sharedComponents/SelectionModal/GalleryCard.test.jsx index 7769d71b4..cd8577d18 100644 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/GalleryCard.test.jsx +++ b/src/editors/sharedComponents/SelectionModal/GalleryCard.test.jsx @@ -12,7 +12,7 @@ describe('GalleryCard component', () => { }; let el; beforeEach(() => { - el = shallow(); + el = shallow(); }); test(`snapshot: dateAdded=${img.dateAdded}`, () => { expect(el).toMatchSnapshot(); diff --git a/src/editors/sharedComponents/SelectionModal/SearchSort.jsx b/src/editors/sharedComponents/SelectionModal/SearchSort.jsx new file mode 100644 index 000000000..11a5f9145 --- /dev/null +++ b/src/editors/sharedComponents/SelectionModal/SearchSort.jsx @@ -0,0 +1,133 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { + ActionRow, Dropdown, Form, Icon, IconButton, +} from '@edx/paragon'; +import { Close, Search } from '@edx/paragon/icons'; +import { + FormattedMessage, + injectIntl, + intlShape, +} from '@edx/frontend-platform/i18n'; + +import messages from './messages'; + +export const SearchSort = ({ + searchString, + onSearchChange, + clearSearchString, + sortBy, + onSortClick, + sortKeys, + sortMessages, + filterBy, + onFilterClick, + filterKeys, + filterMessages, + showSwitch, + switchMessage, + onSwitchClick, + // injected + intl, +}) => ( + + + + ) + : + } + value={searchString} + /> + + + { !showSwitch && } + + + + + + {Object.keys(sortKeys).map(key => ( + + + + ))} + + + + { filterKeys && filterMessages && ( + + + + + + {Object.keys(filterKeys).map(key => ( + + + + ))} + + + )} + + { showSwitch && ( + <> + + + + + + + + )} + + +); + +SearchSort.defaultProps = { + filterBy: '', + onFilterClick: null, + filterKeys: null, + filterMessages: null, + showSwitch: false, + onSwitchClick: null, +}; + +SearchSort.propTypes = { + searchString: PropTypes.string.isRequired, + onSearchChange: PropTypes.func.isRequired, + clearSearchString: PropTypes.func.isRequired, + sortBy: PropTypes.string.isRequired, + onSortClick: PropTypes.func.isRequired, + sortKeys: PropTypes.shape({}).isRequired, + sortMessages: PropTypes.shape({}).isRequired, + filterBy: PropTypes.string, + onFilterClick: PropTypes.func, + filterKeys: PropTypes.shape({}), + filterMessages: PropTypes.shape({}), + showSwitch: PropTypes.bool, + switchMessage: PropTypes.shape({}).isRequired, + onSwitchClick: PropTypes.func, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(SearchSort); diff --git a/src/editors/sharedComponents/SelectionModal/SearchSort.test.jsx b/src/editors/sharedComponents/SelectionModal/SearchSort.test.jsx new file mode 100644 index 000000000..ccf8d36c8 --- /dev/null +++ b/src/editors/sharedComponents/SelectionModal/SearchSort.test.jsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { Dropdown } from '@edx/paragon'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +import { formatMessage } from '../../../testUtils'; + +import { sortKeys, sortMessages } from '../ImageUploadModal/SelectImageModal/utils'; +import { filterKeys, filterMessages } from '../../containers/VideoGallery/utils'; +import { SearchSort } from './SearchSort'; + +describe('SearchSort component', () => { + describe('snapshots without filterKeys', () => { + const props = { + searchString: 'props.searchString', + onSearchChange: jest.fn().mockName('props.onSearchChange'), + clearSearchString: jest.fn().mockName('props.clearSearchString'), + sortBy: sortKeys.dateOldest, + sortKeys, + sortMessages, + onSortClick: jest.fn().mockName('props.onSortClick'), + intl: { formatMessage }, + }; + test('with search string (close button)', () => { + expect(shallow()).toMatchSnapshot(); + }); + test('without search string (search icon)', () => { + expect(shallow()).toMatchSnapshot(); + }); + test('adds a sort option for each sortKey', () => { + const el = shallow(); + expect(el.find(Dropdown).containsMatchingElement( + , + )).toEqual(true); + expect(el.find(Dropdown).containsMatchingElement( + , + )).toEqual(true); + expect(el.find(Dropdown).containsMatchingElement( + , + )).toEqual(true); + expect(el.find(Dropdown).containsMatchingElement( + , + )).toEqual(true); + }); + }); + describe('snapshots with filterKeys', () => { + const props = { + searchString: 'props.searchString', + onSearchChange: jest.fn().mockName('props.onSearchChange'), + clearSearchString: jest.fn().mockName('props.clearSearchString'), + sortBy: sortKeys.dateOldest, + sortKeys, + sortMessages, + filterKeys, + filterMessages, + showSwitch: true, + onSortClick: jest.fn().mockName('props.onSortClick'), + onFilterClick: jest.fn().mockName('props.onFilterClick'), + intl: { formatMessage }, + }; + test('with search string (close button)', () => { + expect(shallow()).toMatchSnapshot(); + }); + test('without search string (search icon)', () => { + expect(shallow()).toMatchSnapshot(); + }); + test('adds a sort option for each sortKey', () => { + const el = shallow(); + expect(el.find(Dropdown).containsMatchingElement( + , + )).toEqual(true); + expect(el.find(Dropdown).containsMatchingElement( + , + )).toEqual(true); + expect(el.find(Dropdown).containsMatchingElement( + , + )).toEqual(true); + expect(el.find(Dropdown).containsMatchingElement( + , + )).toEqual(true); + }); + test('adds a filter option for each filterKet', () => { + const el = shallow(); + expect(el.find(Dropdown).containsMatchingElement( + , + )).toEqual(true); + expect(el.find(Dropdown).containsMatchingElement( + , + )).toEqual(true); + expect(el.find(Dropdown).containsMatchingElement( + , + )).toEqual(true); + expect(el.find(Dropdown).containsMatchingElement( + , + )).toEqual(true); + expect(el.find(Dropdown).containsMatchingElement( + , + )).toEqual(true); + }); + }); +}); diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/__snapshots__/Gallery.test.jsx.snap b/src/editors/sharedComponents/SelectionModal/__snapshots__/Gallery.test.jsx.snap similarity index 74% rename from src/editors/sharedComponents/ImageUploadModal/SelectImageModal/__snapshots__/Gallery.test.jsx.snap rename to src/editors/sharedComponents/SelectionModal/__snapshots__/Gallery.test.jsx.snap index 74efb5b18..9c2eacf95 100644 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/__snapshots__/Gallery.test.jsx.snap +++ b/src/editors/sharedComponents/SelectionModal/__snapshots__/Gallery.test.jsx.snap @@ -6,14 +6,11 @@ exports[`TextEditor Image Gallery component component snapshot: loaded but no im style={ Object { "height": "375px", + "margin": "0 -1.5rem", } } > - +
`; @@ -23,13 +20,14 @@ exports[`TextEditor Image Gallery component component snapshot: loaded but searc style={ Object { "height": "375px", + "margin": "0 -1.5rem", } } >
`; @@ -40,6 +38,7 @@ exports[`TextEditor Image Gallery component component snapshot: loaded, show gal style={ Object { "height": "375px", + "margin": "0 -1.5rem", } } > @@ -54,28 +53,31 @@ exports[`TextEditor Image Gallery component component snapshot: loaded, show gal value="props.highlighted" > @@ -83,9 +85,20 @@ exports[`TextEditor Image Gallery component component snapshot: loaded, show gal `; exports[`TextEditor Image Gallery component component snapshot: not loaded, show spinner 1`] = ` - +
+ +
`; diff --git a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/__snapshots__/GalleryCard.test.jsx.snap b/src/editors/sharedComponents/SelectionModal/__snapshots__/GalleryCard.test.jsx.snap similarity index 59% rename from src/editors/sharedComponents/ImageUploadModal/SelectImageModal/__snapshots__/GalleryCard.test.jsx.snap rename to src/editors/sharedComponents/SelectionModal/__snapshots__/GalleryCard.test.jsx.snap index 51636fd3d..684f7189c 100644 --- a/src/editors/sharedComponents/ImageUploadModal/SelectImageModal/__snapshots__/GalleryCard.test.jsx.snap +++ b/src/editors/sharedComponents/SelectionModal/__snapshots__/GalleryCard.test.jsx.snap @@ -4,31 +4,53 @@ exports[`GalleryCard component snapshot: dateAdded=12345 1`] = `
- + > + +

props.img.displayName

-

+

+ + + } + value="props.searchString" + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`SearchSort component snapshots with filterKeys without search string (search icon) 1`] = ` + + + } + value="" + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`SearchSort component snapshots without filterKeys with search string (close button) 1`] = ` + + + + } + value="props.searchString" + /> + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`SearchSort component snapshots without filterKeys without search string (search icon) 1`] = ` + + + } + value="" + /> + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/src/editors/sharedComponents/SelectionModal/index.jsx b/src/editors/sharedComponents/SelectionModal/index.jsx new file mode 100644 index 000000000..3e8592288 --- /dev/null +++ b/src/editors/sharedComponents/SelectionModal/index.jsx @@ -0,0 +1,155 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Button, Stack } from '@edx/paragon'; +import { Add } from '@edx/paragon/icons'; +import { + FormattedMessage, + injectIntl, + intlShape, +} from '@edx/frontend-platform/i18n'; + +import BaseModal from '../BaseModal'; +import SearchSort from './SearchSort'; +import Gallery from './Gallery'; +import FileInput from '../FileInput'; +import ErrorAlert from '../ErrorAlerts/ErrorAlert'; +import FetchErrorAlert from '../ErrorAlerts/FetchErrorAlert'; +import UploadErrorAlert from '../ErrorAlerts/UploadErrorAlert'; + +export const SelectionModal = ({ + isOpen, + close, + size, + isFullscreenScroll, + galleryError, + inputError, + fileInput, + galleryProps, + searchSortProps, + selectBtnProps, + acceptedFiles, + modalMessages, + isLoaded, + isFetchError, + isUploadError, + // injected + intl, +}) => { + const { + confirmMsg, + uploadButtonMsg, + titleMsg, + fetchError, + uploadError, + } = modalMessages; + + let background = '#FFFFFF'; + let showGallery = true; + if (isLoaded && !isFetchError && !isUploadError && !inputError.show) { + background = '#EBEBEB'; + } else if (isLoaded) { + showGallery = false; + } + + const galleryPropsValues = { + isLoaded, + show: showGallery, + ...galleryProps, + }; + return ( + + + + )} + isOpen={isOpen} + size={size} + isFullscreenScroll={isFullscreenScroll} + footerAction={( + + )} + title={intl.formatMessage(titleMsg)} + bodyStyle={{ background, padding: '9px 24px' }} + headerComponent={( +

+ +
+ )} + > + {/* Error Alerts */} + + + + + + + {/* User Feedback Alerts */} + + + + + + + + + ); +}; + +SelectionModal.defaultProps = { + size: 'lg', + isFullscreenScroll: true, +}; + +SelectionModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + close: PropTypes.func.isRequired, + size: PropTypes.string, + isFullscreenScroll: PropTypes.bool, + galleryError: PropTypes.shape({ + dismiss: PropTypes.func.isRequired, + show: PropTypes.bool.isRequired, + set: PropTypes.func.isRequired, + message: PropTypes.shape({}).isRequired, + }).isRequired, + inputError: PropTypes.shape({ + dismiss: PropTypes.func.isRequired, + show: PropTypes.bool.isRequired, + set: PropTypes.func.isRequired, + message: PropTypes.shape({}).isRequired, + }).isRequired, + fileInput: PropTypes.shape({ + click: PropTypes.func.isRequired, + addFile: PropTypes.func.isRequired, + }).isRequired, + galleryProps: PropTypes.shape({}).isRequired, + searchSortProps: PropTypes.shape({}).isRequired, + selectBtnProps: PropTypes.shape({}).isRequired, + acceptedFiles: PropTypes.shape({}).isRequired, + modalMessages: PropTypes.shape({ + confirmMsg: PropTypes.shape({}).isRequired, + uploadButtonMsg: PropTypes.shape({}).isRequired, + titleMsg: PropTypes.shape({}).isRequired, + fetchError: PropTypes.shape({}).isRequired, + uploadError: PropTypes.shape({}).isRequired, + }).isRequired, + isLoaded: PropTypes.bool.isRequired, + isFetchError: PropTypes.bool.isRequired, + isUploadError: PropTypes.bool.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(SelectionModal); diff --git a/src/editors/sharedComponents/SelectionModal/index.test.jsx b/src/editors/sharedComponents/SelectionModal/index.test.jsx new file mode 100644 index 000000000..164204194 --- /dev/null +++ b/src/editors/sharedComponents/SelectionModal/index.test.jsx @@ -0,0 +1,185 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { render, screen } from '@testing-library/react'; +import { formatMessage } from '../../../testUtils'; +import SelectionModal from '.'; +import '@testing-library/jest-dom'; + +const props = { + isOpen: jest.fn(), + isClose: jest.fn(), + size: 'fullscreen', + isFullscreenScroll: false, + galleryError: { + show: false, + set: jest.fn(), + dismiss: jest.fn(), + message: { + id: 'Gallery error id', + defaultMessage: 'Gallery error', + description: 'Gallery error', + }, + }, + inputError: { + show: false, + set: jest.fn(), + dismiss: jest.fn(), + message: { + id: 'Input error id', + defaultMessage: 'Input error', + description: 'Input error', + }, + }, + fileInput: { + addFile: 'imgHooks.fileInput.addFile', + click: 'imgHooks.fileInput.click', + ref: 'imgHooks.fileInput.ref', + }, + galleryProps: { gallery: 'props' }, + searchSortProps: { search: 'sortProps' }, + selectBtnProps: { select: 'btnProps' }, + acceptedFiles: { png: '.png' }, + modalMessages: { + confirmMsg: { + id: 'confirmMsg', + defaultMessage: 'confirmMsg', + description: 'confirmMsg', + }, + uploadButtonMsg: { + id: 'uploadButtonMsg', + defaultMessage: 'uploadButtonMsg', + description: 'uploadButtonMsg', + }, + titleMsg: { + id: 'titleMsg', + defaultMessage: 'titleMsg', + description: 'titleMsg', + }, + fetchError: { + id: 'fetchError', + defaultMessage: 'fetchError', + description: 'fetchError', + }, + uploadError: { + id: 'uploadError', + defaultMessage: 'uploadError', + description: 'uploadError', + }, + }, + isLoaded: true, + isFetchError: false, + isUploadError: false, + intl: { formatMessage }, +}; + +const mockGalleryFn = jest.fn(); +const mockFileInputFn = jest.fn(); +const mockFetchErrorAlertFn = jest.fn(); +const mockUploadErrorAlertFn = jest.fn(); + +jest.mock('../BaseModal', () => 'BaseModal'); +jest.mock('./SearchSort', () => 'SearchSort'); +jest.mock('./Gallery', () => (componentProps) => { + mockGalleryFn(componentProps); + return (
Gallery
); +}); +jest.mock('../FileInput', () => (componentProps) => { + mockFileInputFn(componentProps); + return (
FileInput
); +}); +jest.mock('../ErrorAlerts/ErrorAlert', () => () => (
ErrorAlert
)); +jest.mock('../ErrorAlerts/FetchErrorAlert', () => (componentProps) => { + mockFetchErrorAlertFn(componentProps); + return (
FetchErrorAlert
); +}); +jest.mock('../ErrorAlerts/UploadErrorAlert', () => (componentProps) => { + mockUploadErrorAlertFn(componentProps); + return (
UploadErrorAlert
); +}); + +describe('Selection Modal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + test('rendering correctly with expected Input', async () => { + render( + + + , + ); + expect(screen.getByText('Gallery')).toBeInTheDocument(); + expect(screen.getByText('FileInput')).toBeInTheDocument(); + expect(screen.getByText('FetchErrorAlert')).toBeInTheDocument(); + expect(screen.getByText('UploadErrorAlert')).toBeInTheDocument(); + + expect(mockGalleryFn).toHaveBeenCalledWith( + expect.objectContaining({ + ...props.galleryProps, + isLoaded: props.isLoaded, + show: true, + }), + ); + expect(mockFetchErrorAlertFn).toHaveBeenCalledWith( + expect.objectContaining({ + isFetchError: props.isFetchError, + message: props.modalMessages.fetchError, + }), + ); + expect(mockUploadErrorAlertFn).toHaveBeenCalledWith( + expect.objectContaining({ + isUploadError: props.isUploadError, + message: props.modalMessages.uploadError, + }), + ); + expect(mockFileInputFn).toHaveBeenCalledWith( + expect.objectContaining({ + acceptedFiles: '.png', + fileInput: props.fileInput, + }), + ); + }); + test('rendering correctly with errors', () => { + render( + + + , + ); + expect(screen.getByText('Gallery')).toBeInTheDocument(); + expect(screen.getByText('FileInput')).toBeInTheDocument(); + expect(screen.getByText('FetchErrorAlert')).toBeInTheDocument(); + expect(screen.getByText('UploadErrorAlert')).toBeInTheDocument(); + + expect(mockFetchErrorAlertFn).toHaveBeenCalledWith( + expect.objectContaining({ + isFetchError: true, + message: props.modalMessages.fetchError, + }), + ); + expect(mockGalleryFn).toHaveBeenCalledWith( + expect.objectContaining({ + ...props.galleryProps, + isLoaded: props.isLoaded, + show: false, + }), + ); + }); + test('rendering correctly with loading', () => { + render( + + + , + ); + expect(screen.getByText('Gallery')).toBeInTheDocument(); + expect(screen.getByText('FileInput')).toBeInTheDocument(); + expect(screen.getByText('FetchErrorAlert')).toBeInTheDocument(); + expect(screen.getByText('UploadErrorAlert')).toBeInTheDocument(); + + expect(mockGalleryFn).toHaveBeenCalledWith( + expect.objectContaining({ + ...props.galleryProps, + isLoaded: false, + show: true, + }), + ); + }); +}); diff --git a/src/editors/sharedComponents/SelectionModal/messages.js b/src/editors/sharedComponents/SelectionModal/messages.js new file mode 100644 index 000000000..10d18d0c2 --- /dev/null +++ b/src/editors/sharedComponents/SelectionModal/messages.js @@ -0,0 +1,24 @@ +export const messages = { + searchPlaceholder: { + id: 'authoring.selectionmodal.search.placeholder', + defaultMessage: 'Search', + description: 'Placeholder text for search bar', + }, + emptySearchLabel: { + id: 'authoring.selectionmodal.emptySearchLabel', + defaultMessage: 'No search results.', + description: 'Label for when search returns nothing.', + }, + loading: { + id: 'authoring.selectionmodal.spinner.readertext', + defaultMessage: 'loading...', + description: 'Gallery loading spinner screen-reader text', + }, + addedDate: { + id: 'authoring.selectionmodal.addedDate.label', + defaultMessage: 'Added {date} at {time}', + description: 'File date-added string', + }, +}; + +export default messages; diff --git a/src/editors/sharedComponents/SourceCodeModal/__snapshots__/index.test.jsx.snap b/src/editors/sharedComponents/SourceCodeModal/__snapshots__/index.test.jsx.snap index c7b019463..2676d51a9 100644 --- a/src/editors/sharedComponents/SourceCodeModal/__snapshots__/index.test.jsx.snap +++ b/src/editors/sharedComponents/SourceCodeModal/__snapshots__/index.test.jsx.snap @@ -2,6 +2,7 @@ exports[`SourceCodeModal renders as expected with default behavior 1`] = ` } footerAction={null} + headerComponent={null} + isFullscreenScroll={true} isOpen={false} size="xl" title="Edit Source Code" diff --git a/src/editors/utils/formatDuration.js b/src/editors/utils/formatDuration.js new file mode 100644 index 000000000..8458f51fc --- /dev/null +++ b/src/editors/utils/formatDuration.js @@ -0,0 +1,18 @@ +import * as moment from 'moment-shortformat'; + +const formatDuration = (duration) => { + const d = moment.duration(duration, 'seconds'); + if (d.hours() > 0) { + return ( + `${d.hours().toString().padStart(2, '0')}:` + + `${d.minutes().toString().padStart(2, '0')}:` + + `${d.seconds().toString().padStart(2, '0')}` + ); + } + return ( + `${d.minutes().toString().padStart(2, '0')}:` + + `${d.seconds().toString().padStart(2, '0')}` + ); +}; + +export default formatDuration; diff --git a/src/editors/utils/formatDuration.test.js b/src/editors/utils/formatDuration.test.js new file mode 100644 index 000000000..6720130d7 --- /dev/null +++ b/src/editors/utils/formatDuration.test.js @@ -0,0 +1,12 @@ +import formatDuration from './formatDuration'; + +describe('formatDuration', () => { + test.each([ + [60, '01:00'], + [35, '00:35'], + [60 * 10 + 15, '10:15'], + [60 * 60 + 60 * 15 + 13, '01:15:13'], + ])('correct functionality of formatDuration with duration as %p', (duration, expected) => { + expect(formatDuration(duration)).toEqual(expected); + }); +}); diff --git a/src/editors/utils/index.js b/src/editors/utils/index.js index b765c1382..14ecc749c 100644 --- a/src/editors/utils/index.js +++ b/src/editors/utils/index.js @@ -3,3 +3,4 @@ export { default as StrictDict } from './StrictDict'; export { default as keyStore } from './keyStore'; export { default as camelizeKeys } from './camelizeKeys'; export { default as removeItemOnce } from './removeOnce'; +export { default as formatDuration } from './formatDuration'; diff --git a/src/index.jsx b/src/index.jsx index a92a3b872..847deec60 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -1,6 +1,7 @@ import Placeholder from './Placeholder'; import messages from './i18n/index'; import EditorPage from './editors/EditorPage'; +import VideoSelectorPage from './editors/VideoSelectorPage'; -export { messages, EditorPage }; +export { messages, EditorPage, VideoSelectorPage }; export default Placeholder;