Merge pull request #278 from open-craft/chris/FAL-3375-video-selection-gallery

[FAL-3375] Feat: Video selection gallery page
This commit is contained in:
kenclary
2023-04-25 15:54:05 -04:00
committed by GitHub
72 changed files with 3478 additions and 883 deletions

989
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 (
<VideoGallery />
);
};
VideoSelector.propTypes = {
learningContextId: PropTypes.string.isRequired,
lmsEndpointUrl: PropTypes.string.isRequired,
studioEndpointUrl: PropTypes.string.isRequired,
};
export default VideoSelector;

View File

@@ -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(<VideoSelector {...props} />)).toMatchSnapshot();
});
});
describe('behavior', () => {
it('calls initializeApp hook with dispatch, and passed data', () => {
shallow(<VideoSelector {...props} />);
expect(hooks.initializeApp).toHaveBeenCalledWith({
dispatch: useDispatch(),
data: initData,
});
});
});
});

View File

@@ -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,
}) => (
<ErrorBoundary>
<Provider store={store}>
<VideoSelector
{...{
learningContextId: courseId,
lmsEndpointUrl,
studioEndpointUrl,
}}
/>
</Provider>
</ErrorBoundary>
);
VideoSelectorPage.defaultProps = {
courseId: null,
lmsEndpointUrl: null,
studioEndpointUrl: null,
};
VideoSelectorPage.propTypes = {
courseId: PropTypes.string,
lmsEndpointUrl: PropTypes.string,
studioEndpointUrl: PropTypes.string,
};
export default VideoSelectorPage;

View File

@@ -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(<VideoSelectorPage {...props} />)).toMatchSnapshot();
});
test('rendering with props to null', () => {
expect(shallow(<VideoSelectorPage />)).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Video Selector render rendering correctly with expected Input 1`] = `<VideoGallery />`;

View File

@@ -0,0 +1,45 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Video Selector Page snapshots rendering correctly with expected Input 1`] = `
<ErrorBoundary>
<Provider
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
}
>
<Component
learningContextId="course-v1:edX+DemoX+Demo_Course"
lmsEndpointUrl="evenfakerurl.com"
studioEndpointUrl="fakeurl.com"
/>
</Provider>
</ErrorBoundary>
`;
exports[`Video Selector Page snapshots rendering with props to null 1`] = `
<ErrorBoundary>
<Provider
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
}
>
<Component
learningContextId={null}
lmsEndpointUrl={null}
studioEndpointUrl={null}
/>
</Provider>
</ErrorBoundary>
`;

View File

@@ -5,6 +5,7 @@ exports[`EditorContainer component render snapshot: initialized. enable save and
className="position-relative zindex-0"
>
<BaseModal
bodyStyle={null}
close={[MockFunction closeCancelConfirmModal]}
confirmAction={
<Button
@@ -25,6 +26,8 @@ exports[`EditorContainer component render snapshot: initialized. enable save and
</Button>
}
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"
>
<BaseModal
bodyStyle={null}
close={[MockFunction closeCancelConfirmModal]}
confirmAction={
<Button
@@ -103,6 +107,8 @@ exports[`EditorContainer component render snapshot: not initialized. disable sav
</Button>
}
footerAction={null}
headerComponent={null}
isFullscreenScroll={true}
isOpen={false}
size="md"
title="Exit the editor?"

View File

@@ -10,6 +10,9 @@ jest.mock('../../../../../data/redux', () => ({
problemType: jest.fn(state => ({ problemType: state })),
},
},
thunkActions: {
video: jest.fn(),
},
}));
describe('AnswerOption', () => {

View File

@@ -11,6 +11,11 @@ jest.mock('../../../../../data/redux', () => ({
settings: jest.fn(state => ({ question: state })),
},
},
thunkActions: {
video: {
importTranscript: jest.fn(),
},
},
}));
jest.mock('../../../../../sharedComponents/TinyMceWidget/hooks', () => ({

View File

@@ -21,6 +21,11 @@ jest.mock('../../../../../data/redux', () => ({
question: jest.fn(state => ({ question: state })),
},
},
thunkActions: {
video: {
importTranscript: jest.fn(),
},
},
}));
jest.mock('../../../../../sharedComponents/TinyMceWidget/hooks', () => ({

View File

@@ -16,6 +16,9 @@ jest.mock('../../../../../../data/redux', () => ({
learningContextId: jest.fn(state => ({ learningContextId: state })),
},
},
thunkActions: {
video: jest.fn(),
},
}));
describe('ShowAnswerCard', () => {

View File

@@ -5,6 +5,7 @@ exports[`SwitchToAdvancedEditorCard snapshot snapshot: SwitchToAdvancedEditorCar
className="border border-light-700 shadow-none"
>
<BaseModal
bodyStyle={null}
close={[Function]}
confirmAction={
<Button
@@ -21,6 +22,8 @@ exports[`SwitchToAdvancedEditorCard snapshot snapshot: SwitchToAdvancedEditorCar
</Button>
}
footerAction={null}
headerComponent={null}
isFullscreenScroll={true}
isOpen={false}
size="md"
title={

View File

@@ -61,6 +61,11 @@ jest.mock('../../data/redux', () => ({
isFinished: jest.fn((state, params) => ({ isFailed: { state, params } })),
},
},
thunkActions: {
video: {
importTranscript: jest.fn(),
},
},
}));
describe('TextEditor', () => {

View File

@@ -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 = ({
>
<FormattedMessage {...messages.fileSizeError} />
</ErrorAlert>
<UploadErrorAlert message={messages.uploadHandoutError} />
<UploadErrorAlert isUploadError={isUploadError} message={messages.uploadHandoutError} />
<FileInput fileInput={fileInput} />
{handout ? (
<Stack gap={3}>
@@ -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) => ({

View File

@@ -24,6 +24,9 @@ jest.mock('../../../../../../data/redux', () => ({
app: {
isLibrary: jest.fn(args => ({ isLibrary: args })),
},
requests: {
isFailed: jest.fn(args => ({ isFailed: args })),
},
},
}));

View File

@@ -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,
};

View File

@@ -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);
});
});
});

View File

@@ -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 (
<div>
<SelectionModal
{...{
isOpen: true,
close: () => { /* TODO */ },
size: 'fullscreen',
isFullscreenScroll: false,
galleryError,
inputError,
fileInput,
galleryProps,
searchSortProps,
selectBtnProps,
acceptedFiles: acceptedImgKeys,
modalMessages,
isLoaded,
isUploadError,
isFetchError,
}}
/>
</div>
);
};
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);

View File

@@ -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(<module.VideoGallery {...props} />);
});
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);
});
});
});

View File

@@ -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;

View File

@@ -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',
});

View File

@@ -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);
});
});
});

View File

@@ -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',

View File

@@ -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 }),
},
});

View File

@@ -48,6 +48,7 @@ describe('app reducer', () => {
['setBlockTitle', 'blockTitle'],
['setSaveResponse', 'saveResponse'],
['setAssets', 'assets'],
['setVideos', 'videos'],
['setCourseDetails', 'courseDetails'],
].map(args => setterTest(...args));
describe('setBlockValue', () => {

View File

@@ -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(

View File

@@ -48,6 +48,7 @@ describe('app selectors unit tests', () => {
simpleKeys.blockTitle,
simpleKeys.studioView,
simpleKeys.assets,
simpleKeys.videos,
].map(testSimpleSelector);
});
});

View File

@@ -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 },

View File

@@ -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,

View File

@@ -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(() => {

View File

@@ -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,

View File

@@ -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({

View File

@@ -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 }),
),

View File

@@ -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.';

View File

@@ -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}`
);

View File

@@ -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}`);
});
});
});

View File

@@ -10,12 +10,20 @@ exports[`BaseModal ImageUploadModal template component snapshot 1`] = `
size="lg"
variant="default"
>
<ModalDialog.Header>
<ModalDialog.Header
style={
Object {
"zIndex": 10000,
}
}
>
<ModalDialog.Title>
props.title node
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
<ModalDialog.Body
style={null}
>
props.children node
</ModalDialog.Body>
<ModalDialog.Footer>

View File

@@ -14,9 +14,12 @@ export const BaseModal = ({
close,
title,
children,
headerComponent,
confirmAction,
footerAction,
size,
isFullscreenScroll,
bodyStyle,
}) => (
<ModalDialog
isOpen={isOpen}
@@ -25,14 +28,15 @@ export const BaseModal = ({
variant="default"
hasCloseButton
isFullscreenOnMobile
isFullscreenScroll
isFullscreenScroll={isFullscreenScroll}
>
<ModalDialog.Header>
<ModalDialog.Header style={{ zIndex: 10000 }}>
<ModalDialog.Title>
{title}
</ModalDialog.Title>
{headerComponent}
</ModalDialog.Header>
<ModalDialog.Body>
<ModalDialog.Body style={bodyStyle}>
{children}
</ModalDialog.Body>
<ModalDialog.Footer>
@@ -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;

View File

@@ -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 (
<Alert
variant="danger"
icon={Outline}
icon={Error}
dismissible
onClose={dismissAlert}
>

View File

@@ -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
}) => (
<ErrorAlert
isError={isFetchError}
@@ -28,12 +23,8 @@ FetchErrorAlert.propTypes = {
defaultMessage: PropTypes.string,
description: PropTypes.string,
}).isRequired,
// redux
isFetchError: PropTypes.bool.isRequired,
};
export const mapStateToProps = (state) => ({
isFetchError: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchAssets }),
});
export const mapDispatchToProps = {};
export default connect(mapStateToProps, mapDispatchToProps)(FetchErrorAlert);
export default FetchErrorAlert;

View File

@@ -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(<FetchErrorAlert isFetchError />)).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 }));
});
});
});

View File

@@ -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
}) => (
<ErrorAlert
isError={isUploadError}
@@ -28,11 +23,7 @@ UploadErrorAlert.propTypes = {
defaultMessage: PropTypes.string,
description: PropTypes.string,
}).isRequired,
// redux
isUploadError: PropTypes.bool.isRequired,
};
export const mapStateToProps = (state) => ({
isUploadError: selectors.requests.isFailed(state, { requestKey: RequestKeys.uploadAsset }),
});
export const mapDispatchToProps = {};
export default connect(mapStateToProps, mapDispatchToProps)(UploadErrorAlert);
export default UploadErrorAlert;

View File

@@ -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(<UploadErrorAlert isUploadError />)).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 }));
});
});
});

View File

@@ -2,6 +2,7 @@
exports[`ImageSettingsModal render snapshot 1`] = `
<BaseModal
bodyStyle={null}
close={[MockFunction props.close]}
confirmAction={
<Button
@@ -35,6 +36,8 @@ exports[`ImageSettingsModal render snapshot 1`] = `
</Button>
}
footerAction={null}
headerComponent={null}
isFullscreenScroll={true}
isOpen={false}
size="lg"
title="Image Settings"

View File

@@ -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,
}) => (
<SelectableBox className="card bg-white" key={img.externalUrl} type="radio" value={img.id}>
<div className="card-div d-flex flex-row flex-nowrap">
<Image
style={{ width: '100px', height: '100px' }}
src={img.externalUrl}
/>
<div className="img-text p-3">
<h3>{img.displayName}</h3>
<p>
<FormattedMessage
{...messages.addedDate}
values={{
date: <FormattedDate value={img.dateAdded} />,
time: <FormattedTime value={img.dateAdded} />,
}}
/>
</p>
</div>
</div>
</SelectableBox>
);
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;

View File

@@ -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,
}) => (
<ActionRow>
<Form.Group style={{ margin: 0 }}>
<Form.Control
autoFocus
onChange={onSearchChange}
placeholder={intl.formatMessage(messages.searchPlaceholder)}
trailingElement={
searchString
? (
<IconButton
iconAs={Icon}
invertColors
isActive
onClick={clearSearchString}
size="sm"
src={Close}
/>
)
: <Icon src={Search} />
}
value={searchString}
/>
</Form.Group>
<ActionRow.Spacer />
<Dropdown>
<Dropdown.Toggle id="img-sort-button" variant="tertiary">
<FormattedMessage {...sortMessages[sortBy]} />
</Dropdown.Toggle>
<Dropdown.Menu>
{Object.keys(sortKeys).map(key => (
<Dropdown.Item key={key} onClick={onSortClick(key)}>
<FormattedMessage {...sortMessages[key]} />
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
</ActionRow>
);
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);

View File

@@ -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(<SearchSort {...props} />)).toMatchSnapshot();
});
test('without search string (search icon)', () => {
expect(shallow(<SearchSort {...props} searchString="" />)).toMatchSnapshot();
});
test('adds a sort option for each sortKey', () => {
const el = shallow(<SearchSort {...props} />);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...sortMessages.dateNewest} />,
)).toEqual(true);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...sortMessages.dateOldest} />,
)).toEqual(true);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...sortMessages.nameAscending} />,
)).toEqual(true);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...sortMessages.nameDescending} />,
)).toEqual(true);
});
});
});

View File

@@ -1,152 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SearchSort component snapshots with search string (close button) 1`] = `
<ActionRow>
<Form.Group
style={
Object {
"margin": 0,
}
}
>
<Form.Control
autoFocus={true}
onChange={[MockFunction props.onSearchChange]}
placeholder="Search"
trailingElement={
<IconButton
iconAs="Icon"
invertColors={true}
isActive={true}
onClick={[MockFunction props.clearSearchString]}
size="sm"
src={[MockFunction icons.Close]}
/>
}
value="props.searchString"
/>
</Form.Group>
<ActionRow.Spacer />
<Dropdown>
<Dropdown.Toggle
id="img-sort-button"
variant="tertiary"
>
<FormattedMessage
defaultMessage="By date added (oldest)"
description="Dropdown label for sorting by date (oldest)"
id="authoring.texteditor.selectimagemodal.sort.dateoldest.label"
/>
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
key="dateNewest"
>
<FormattedMessage
defaultMessage="By date added (newest)"
description="Dropdown label for sorting by date (newest)"
id="authoring.texteditor.selectimagemodal.sort.datenewest.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="dateOldest"
>
<FormattedMessage
defaultMessage="By date added (oldest)"
description="Dropdown label for sorting by date (oldest)"
id="authoring.texteditor.selectimagemodal.sort.dateoldest.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="nameAscending"
>
<FormattedMessage
defaultMessage="By name (ascending)"
description="Dropdown label for sorting by name (ascending)"
id="authoring.texteditor.selectimagemodal.sort.nameascending.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="nameDescending"
>
<FormattedMessage
defaultMessage="By name (descending)"
description="Dropdown label for sorting by name (descending)"
id="authoring.texteditor.selectimagemodal.sort.namedescending.label"
/>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</ActionRow>
`;
exports[`SearchSort component snapshots without search string (search icon) 1`] = `
<ActionRow>
<Form.Group
style={
Object {
"margin": 0,
}
}
>
<Form.Control
autoFocus={true}
onChange={[MockFunction props.onSearchChange]}
placeholder="Search"
trailingElement={<Icon />}
value=""
/>
</Form.Group>
<ActionRow.Spacer />
<Dropdown>
<Dropdown.Toggle
id="img-sort-button"
variant="tertiary"
>
<FormattedMessage
defaultMessage="By date added (oldest)"
description="Dropdown label for sorting by date (oldest)"
id="authoring.texteditor.selectimagemodal.sort.dateoldest.label"
/>
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
key="dateNewest"
>
<FormattedMessage
defaultMessage="By date added (newest)"
description="Dropdown label for sorting by date (newest)"
id="authoring.texteditor.selectimagemodal.sort.datenewest.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="dateOldest"
>
<FormattedMessage
defaultMessage="By date added (oldest)"
description="Dropdown label for sorting by date (oldest)"
id="authoring.texteditor.selectimagemodal.sort.dateoldest.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="nameAscending"
>
<FormattedMessage
defaultMessage="By name (ascending)"
description="Dropdown label for sorting by name (ascending)"
id="authoring.texteditor.selectimagemodal.sort.nameascending.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="nameDescending"
>
<FormattedMessage
defaultMessage="By name (descending)"
description="Dropdown label for sorting by name (descending)"
id="authoring.texteditor.selectimagemodal.sort.namedescending.label"
/>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</ActionRow>
`;

View File

@@ -1,189 +1,94 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SelectImageModal component snapshot 1`] = `
<BaseModal
close={[MockFunction props.close]}
confirmAction={
<Button
select="btnProps"
variant="primary"
>
<FormattedMessage
defaultMessage="Next"
description="Label for Next button"
id="authoring.texteditor.selectimagemodal.next.label"
/>
</Button>
<SelectionModal
acceptedFiles={
Object {
"gif": ".gif",
"ico": ".ico",
"jpeg": ".jpeg",
"jpg": ".jpg",
"png": ".png",
"tif": ".tif",
"tiff": ".tiff",
}
}
footerAction={
<Button
onClick="imgHooks.fileInput.click"
variant="link"
>
<FormattedMessage
defaultMessage="Upload a new image (10 MB max)"
description="Label for upload button"
id="authoring.texteditor.selectimagemodal.upload.label"
/>
</Button>
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"
>
<FetchErrorAlert
message={
Object {
modalMessages={
Object {
"confirmMsg": Object {
"defaultMessage": "Next",
"description": "Label for Next button",
"id": "authoring.texteditor.selectimagemodal.next.label",
},
"fetchError": Object {
"defaultMessage": "Failed to obtain course images. Please try again.",
"description": "Message presented to user when images are not found",
"id": "authoring.texteditor.selectimagemodal.error.fetchImagesError",
}
}
/>
<UploadErrorAlert
message={
Object {
},
"titleMsg": Object {
"defaultMessage": "Add an image",
"description": "Title for the select image modal",
"id": "authoring.texteditor.selectimagemodal.title.label",
},
"uploadButtonMsg": Object {
"defaultMessage": "Upload a new image (10 MB max)",
"description": "Label for upload button",
"id": "authoring.texteditor.selectimagemodal.upload.label",
},
"uploadError": Object {
"defaultMessage": "Failed to upload image. Please try again.",
"description": "Message presented to user when image fails to upload",
"id": "authoring.texteditor.selectimagemodal.error.uploadImageError",
}
},
}
/>
<ErrorAlert
dismissError={[MockFunction]}
hideHeading={true}
isError="ShoWERror inPUT"
>
<FormattedMessage
defaultMessage="Images must be 10 MB or less. Please resize image and try again."
description=" Message presented to user when file size of image is larger than 10 MB"
id="authoring.texteditor.selectimagemodal.error.fileSizeError"
/>
</ErrorAlert>
<ErrorAlert
dismissError={[MockFunction]}
hideHeading={true}
isError="ShoWERror gAlLery"
>
<FormattedMessage
defaultMessage="Select an image to continue."
description="Message presented to user when clicking Next without selecting an image"
id="authoring.texteditor.selectimagemodal.error.selectImageError"
/>
</ErrorAlert>
<Stack
gap={3}
>
<SearchSort
search="sortProps"
/>
<Gallery
gallery="props"
/>
<FileInput
acceptedFiles=".gif,.jpg,.jpeg,.png,.tif,.tiff,.ico"
fileInput={
Object {
"addFile": "imgHooks.fileInput.addFile",
"click": "imgHooks.fileInput.click",
"ref": "imgHooks.fileInput.ref",
}
}
/>
</Stack>
</BaseModal>
`;
exports[`SelectImageModal component snapshot: uploaded image not loaded, show spinner 1`] = `
<BaseModal
close={[MockFunction props.close]}
confirmAction={
<Button
select="btnProps"
variant="primary"
>
<FormattedMessage
defaultMessage="Next"
description="Label for Next button"
id="authoring.texteditor.selectimagemodal.next.label"
/>
</Button>
}
footerAction={
<Button
onClick="imgHooks.fileInput.click"
variant="link"
>
<FormattedMessage
defaultMessage="Upload a new image (10 MB max)"
description="Label for upload button"
id="authoring.texteditor.selectimagemodal.upload.label"
/>
</Button>
searchSortProps={
Object {
"search": "sortProps",
}
}
isOpen={true}
title="Add an image"
>
<FetchErrorAlert
message={
Object {
"defaultMessage": "Failed to obtain course images. Please try again.",
"description": "Message presented to user when images are not found",
"id": "authoring.texteditor.selectimagemodal.error.fetchImagesError",
}
selectBtnProps={
Object {
"select": "btnProps",
}
/>
<UploadErrorAlert
message={
Object {
"defaultMessage": "Failed to upload image. Please try again.",
"description": "Message presented to user when image fails to upload",
"id": "authoring.texteditor.selectimagemodal.error.uploadImageError",
}
}
/>
<ErrorAlert
dismissError={[MockFunction]}
hideHeading={true}
isError="ShoWERror inPUT"
>
<FormattedMessage
defaultMessage="Images must be 10 MB or less. Please resize image and try again."
description=" Message presented to user when file size of image is larger than 10 MB"
id="authoring.texteditor.selectimagemodal.error.fileSizeError"
/>
</ErrorAlert>
<ErrorAlert
dismissError={[MockFunction]}
hideHeading={true}
isError="ShoWERror gAlLery"
>
<FormattedMessage
defaultMessage="Select an image to continue."
description="Message presented to user when clicking Next without selecting an image"
id="authoring.texteditor.selectimagemodal.error.selectImageError"
/>
</ErrorAlert>
<Stack
gap={3}
>
<SearchSort
search="sortProps"
/>
<Spinner
animation="border"
className="mie-3"
screenReaderText="loading..."
/>
<FileInput
acceptedFiles=".gif,.jpg,.jpeg,.png,.tif,.tiff,.ico"
fileInput={
Object {
"addFile": "imgHooks.fileInput.addFile",
"click": "imgHooks.fileInput.click",
"ref": "imgHooks.fileInput.ref",
}
}
/>
</Stack>
</BaseModal>
}
/>
`;

View File

@@ -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: {

View File

@@ -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 (
<BaseModal
close={close}
confirmAction={(
<Button {...selectBtnProps} variant="primary">
<FormattedMessage {...messages.nextButtonLabel} />
</Button>
)}
isOpen={isOpen}
footerAction={(
<Button iconBefore={Add} onClick={fileInput.click} variant="link">
<FormattedMessage {...messages.uploadButtonLabel} />
</Button>
)}
title={intl.formatMessage(messages.titleLabel)}
>
{/* Error Alerts */}
<FetchErrorAlert message={messages.fetchImagesError} />
<UploadErrorAlert message={messages.uploadImageError} />
<ErrorAlert
dismissError={inputError.dismiss}
hideHeading
isError={inputError.show}
>
<FormattedMessage {...messages.fileSizeError} />
</ErrorAlert>
const modalMessages = {
confirmMsg: messages.nextButtonLabel,
titleMsg: messages.titleLabel,
uploadButtonMsg: messages.uploadButtonLabel,
fetchError: messages.fetchImagesError,
uploadError: messages.uploadImageError,
};
{/* User Feedback Alerts */}
<ErrorAlert
dismissError={galleryError.dismiss}
hideHeading
isError={galleryError.show}
>
<FormattedMessage {...messages.selectImageError} />
</ErrorAlert>
<Stack gap={3}>
<SearchSort {...searchSortProps} />
{!inputIsLoading ? <Gallery {...galleryProps} /> : (
<Spinner
animation="border"
className="mie-3"
screenReaderText={intl.formatMessage(messages.loading)}
/>
)}
<FileInput fileInput={fileInput} acceptedFiles={Object.values(acceptedImgKeys).join()} />
</Stack>
</BaseModal>
return (
<SelectionModal
{...{
isOpen,
close,
galleryError,
inputError,
fileInput,
galleryProps,
searchSortProps,
selectBtnProps,
acceptedFiles: acceptedImgKeys,
modalMessages,
isLoaded,
isFetchError,
isUploadError,
}}
/>
);
};
@@ -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);

View File

@@ -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(<SelectImageModal {...props} />)).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);
});
});
});

View File

@@ -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: {

View File

@@ -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 (
<Spinner
animation="border"
className="mie-3"
screenReaderText={intl.formatMessage(messages.loading)}
/>
<div style={{
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
}}
>
<Spinner
animation="border"
className="mie-3"
screenReaderText={intl.formatMessage(messages.loading)}
/>
</div>
);
}
if (galleryIsEmpty) {
return (
<div className="gallery p-4 bg-gray-100" style={{ height: '375px' }}>
<FormattedMessage {...messages.emptyGalleryLabel} />
<div className="gallery p-4 bg-gray-100" style={{ height, margin: '0 -1.5rem' }}>
<FormattedMessage {...emptyGalleryLabel} />
</div>
);
}
if (searchIsEmpty) {
return (
<div className="gallery p-4 bg-gray-100" style={{ height: '375px' }}>
<div className="gallery p-4 bg-gray-100" style={{ height, margin: '0 -1.5rem' }}>
<FormattedMessage {...messages.emptySearchLabel} />
</div>
);
}
return (
<Scrollable className="gallery bg-gray-100" style={{ height: '375px' }}>
<Scrollable className="gallery bg-gray-100" style={{ height, margin: '0 -1.5rem' }}>
<div className="p-4">
<SelectableBox.Set
columns={1}
@@ -58,7 +72,7 @@ export const Gallery = ({
type="radio"
value={highlighted}
>
{displayList.map(img => <GalleryCard key={img.id} img={img} />)}
{ displayList.map(asset => <GalleryCard key={asset.id} asset={asset} showId={showIdsOnCards} />) }
</SelectableBox.Set>
</div>
</Scrollable>
@@ -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);

View File

@@ -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(<Gallery {...props} />)).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(<Gallery {...props} show={false} />);
expect(wrapper.type()).toBeNull();
});
});
});

View File

@@ -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,
}) => (
<SelectableBox className="card bg-white" key={asset.externalUrl} type="radio" value={asset.id} style={{ padding: '10px 20px' }}>
<div className="card-div d-flex flex-row flex-nowrap">
<div style={{
position: 'relative',
width: '200px',
height: '100px',
margin: '16px 0 0 0',
}}
>
<Image
style={{ width: '200px', height: '100px' }}
src={asset.externalUrl}
/>
{ asset.status && asset.statusBadgeVariant && (
<Badge variant={asset.statusBadgeVariant} style={{ position: 'absolute', left: '6px', top: '6px' }}>
{asset.status}
</Badge>
)}
{ asset.duration >= 0 && (
<Badge variant="dark" style={{ position: 'absolute', right: '6px', bottom: '6px' }}>
{formatDuration(asset.duration)}
</Badge>
)}
</div>
<div className="card-text p-3">
<h3>{asset.displayName}</h3>
{ asset.transcripts && (
<div style={{ margin: '0 0 5px 0' }}>
<LanguageNamesWidget
transcripts={asset.transcripts}
/>
</div>
)}
<p style={{ fontSize: '11px' }}>
<FormattedMessage
{...messages.addedDate}
values={{
date: <FormattedDate value={asset.dateAdded} />,
time: <FormattedTime value={asset.dateAdded} />,
}}
/>
</p>
</div>
</div>
</SelectableBox>
);
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;

View File

@@ -12,7 +12,7 @@ describe('GalleryCard component', () => {
};
let el;
beforeEach(() => {
el = shallow(<GalleryCard img={img} />);
el = shallow(<GalleryCard asset={img} />);
});
test(`snapshot: dateAdded=${img.dateAdded}`, () => {
expect(el).toMatchSnapshot();

View File

@@ -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,
}) => (
<ActionRow>
<Form.Group style={{ margin: 0 }}>
<Form.Control
autoFocus
onChange={onSearchChange}
placeholder={intl.formatMessage(messages.searchPlaceholder)}
trailingElement={
searchString
? (
<IconButton
iconAs={Icon}
invertColors
isActive
onClick={clearSearchString}
size="sm"
src={Close}
/>
)
: <Icon src={Search} />
}
value={searchString}
/>
</Form.Group>
{ !showSwitch && <ActionRow.Spacer /> }
<Dropdown>
<Dropdown.Toggle id="gallery-sort-button" variant="tertiary">
<FormattedMessage {...sortMessages[sortBy]} />
</Dropdown.Toggle>
<Dropdown.Menu>
{Object.keys(sortKeys).map(key => (
<Dropdown.Item key={key} onClick={onSortClick(key)}>
<FormattedMessage {...sortMessages[key]} />
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
{ filterKeys && filterMessages && (
<Dropdown>
<Dropdown.Toggle id="gallery-filter-button" variant="tertiary">
<FormattedMessage {...filterMessages[filterBy]} />
</Dropdown.Toggle>
<Dropdown.Menu>
{Object.keys(filterKeys).map(key => (
<Dropdown.Item key={key} onClick={onFilterClick(key)}>
<FormattedMessage {...filterMessages[key]} />
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
)}
{ showSwitch && (
<>
<ActionRow.Spacer />
<Form.SwitchSet
name="switch"
onChange={onSwitchClick}
isInline
>
<Form.Switch value="switch-value" floatLabelLeft>
<FormattedMessage {...switchMessage} />
</Form.Switch>
</Form.SwitchSet>
</>
)}
</ActionRow>
);
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);

View File

@@ -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(<SearchSort {...props} />)).toMatchSnapshot();
});
test('without search string (search icon)', () => {
expect(shallow(<SearchSort {...props} searchString="" />)).toMatchSnapshot();
});
test('adds a sort option for each sortKey', () => {
const el = shallow(<SearchSort {...props} />);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...sortMessages.dateNewest} />,
)).toEqual(true);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...sortMessages.dateOldest} />,
)).toEqual(true);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...sortMessages.nameAscending} />,
)).toEqual(true);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...sortMessages.nameDescending} />,
)).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(<SearchSort {...props} />)).toMatchSnapshot();
});
test('without search string (search icon)', () => {
expect(shallow(<SearchSort {...props} searchString="" />)).toMatchSnapshot();
});
test('adds a sort option for each sortKey', () => {
const el = shallow(<SearchSort {...props} />);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...sortMessages.dateNewest} />,
)).toEqual(true);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...sortMessages.dateOldest} />,
)).toEqual(true);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...sortMessages.nameAscending} />,
)).toEqual(true);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...sortMessages.nameDescending} />,
)).toEqual(true);
});
test('adds a filter option for each filterKet', () => {
const el = shallow(<SearchSort {...props} />);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...filterMessages.videoStatus} />,
)).toEqual(true);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...filterMessages.uploading} />,
)).toEqual(true);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...filterMessages.processing} />,
)).toEqual(true);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...filterMessages.ready} />,
)).toEqual(true);
expect(el.find(Dropdown).containsMatchingElement(
<FormattedMessage {...filterMessages.failed} />,
)).toEqual(true);
});
});
});

View File

@@ -6,14 +6,11 @@ exports[`TextEditor Image Gallery component component snapshot: loaded but no im
style={
Object {
"height": "375px",
"margin": "0 -1.5rem",
}
}
>
<FormattedMessage
defaultMessage="No images found in your gallery. Please upload an image using the button below."
description="Label for when image gallery is empty."
id="authoring.texteditor.selectimagemodal.emptyGalleryLabel"
/>
<FormattedMessage />
</div>
`;
@@ -23,13 +20,14 @@ exports[`TextEditor Image Gallery component component snapshot: loaded but searc
style={
Object {
"height": "375px",
"margin": "0 -1.5rem",
}
}
>
<FormattedMessage
defaultMessage="No search results."
description="Label for when search returns nothing."
id="authoring.texteditor.selectimagemodal.emptySearchLabel"
id="authoring.selectionmodal.emptySearchLabel"
/>
</div>
`;
@@ -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"
>
<GalleryCard
img={
asset={
Object {
"id": 1,
}
}
key="1"
showId={false}
/>
<GalleryCard
img={
asset={
Object {
"id": 2,
}
}
key="2"
showId={false}
/>
<GalleryCard
img={
asset={
Object {
"id": 3,
}
}
key="3"
showId={false}
/>
</SelectableBox.Set>
</div>
@@ -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`] = `
<Spinner
animation="border"
className="mie-3"
screenReaderText="loading..."
/>
<div
style={
Object {
"left": "50%",
"position": "absolute",
"top": "50%",
"transform": "translate(-50%, -50%)",
}
}
>
<Spinner
animation="border"
className="mie-3"
screenReaderText="loading..."
/>
</div>
`;

View File

@@ -4,31 +4,53 @@ exports[`GalleryCard component snapshot: dateAdded=12345 1`] = `
<SelectableBox
className="card bg-white"
key="props.img.externalUrl"
style={
Object {
"padding": "10px 20px",
}
}
type="radio"
>
<div
className="card-div d-flex flex-row flex-nowrap"
>
<Image
src="props.img.externalUrl"
<div
style={
Object {
"height": "100px",
"width": "100px",
"margin": "16px 0 0 0",
"position": "relative",
"width": "200px",
}
}
/>
>
<Image
src="props.img.externalUrl"
style={
Object {
"height": "100px",
"width": "200px",
}
}
/>
</div>
<div
className="img-text p-3"
className="card-text p-3"
>
<h3>
props.img.displayName
</h3>
<p>
<p
style={
Object {
"fontSize": "11px",
}
}
>
<FormattedMessage
defaultMessage="Added {date} at {time}"
description="File date-added string"
id="authoring.texteditor.selectimagemodal.addedDate.label"
id="authoring.selectionmodal.addedDate.label"
values={
Object {
"date": <FormattedDate

View File

@@ -0,0 +1,437 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SearchSort component snapshots with filterKeys with search string (close button) 1`] = `
<ActionRow>
<Form.Group
style={
Object {
"margin": 0,
}
}
>
<Form.Control
autoFocus={true}
onChange={[MockFunction props.onSearchChange]}
placeholder="Search"
trailingElement={
<IconButton
iconAs="Icon"
invertColors={true}
isActive={true}
onClick={[MockFunction props.clearSearchString]}
size="sm"
src={[MockFunction icons.Close]}
/>
}
value="props.searchString"
/>
</Form.Group>
<Dropdown>
<Dropdown.Toggle
id="gallery-sort-button"
variant="tertiary"
>
<FormattedMessage
defaultMessage="By date added (oldest)"
description="Dropdown label for sorting by date (oldest)"
id="authoring.texteditor.selectimagemodal.sort.dateoldest.label"
/>
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
key="dateNewest"
>
<FormattedMessage
defaultMessage="By date added (newest)"
description="Dropdown label for sorting by date (newest)"
id="authoring.texteditor.selectimagemodal.sort.datenewest.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="dateOldest"
>
<FormattedMessage
defaultMessage="By date added (oldest)"
description="Dropdown label for sorting by date (oldest)"
id="authoring.texteditor.selectimagemodal.sort.dateoldest.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="nameAscending"
>
<FormattedMessage
defaultMessage="By name (ascending)"
description="Dropdown label for sorting by name (ascending)"
id="authoring.texteditor.selectimagemodal.sort.nameascending.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="nameDescending"
>
<FormattedMessage
defaultMessage="By name (descending)"
description="Dropdown label for sorting by name (descending)"
id="authoring.texteditor.selectimagemodal.sort.namedescending.label"
/>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<Dropdown>
<Dropdown.Toggle
id="gallery-filter-button"
variant="tertiary"
>
<FormattedMessage />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
key="videoStatus"
>
<FormattedMessage
defaultMessage="Video status"
description="Dropdown label for filter by video status (none)"
id="authoring.selectvideomodal.filter.videostatusnone.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="uploading"
>
<FormattedMessage
defaultMessage="Uploading"
description="Dropdown label for filter by video status (uploading)"
id="authoring.selectvideomodal.filter.videostatusuploading.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="processing"
>
<FormattedMessage
defaultMessage="Processing"
description="Dropdown label for filter by video status (processing)"
id="authoring.selectvideomodal.filter.videostatusprocessing.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="ready"
>
<FormattedMessage
defaultMessage="Ready"
description="Dropdown label for filter by video status (ready)"
id="authoring.selectvideomodal.filter.videostatusready.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="failed"
>
<FormattedMessage
defaultMessage="Failed"
description="Dropdown label for filter by video status (failed)"
id="authoring.selectvideomodal.filter.videostatusfailed.label"
/>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<ActionRow.Spacer />
<Component
isInline={true}
name="switch"
onChange={null}
>
<Component
floatLabelLeft={true}
value="switch-value"
>
<FormattedMessage />
</Component>
</Component>
</ActionRow>
`;
exports[`SearchSort component snapshots with filterKeys without search string (search icon) 1`] = `
<ActionRow>
<Form.Group
style={
Object {
"margin": 0,
}
}
>
<Form.Control
autoFocus={true}
onChange={[MockFunction props.onSearchChange]}
placeholder="Search"
trailingElement={<Icon />}
value=""
/>
</Form.Group>
<Dropdown>
<Dropdown.Toggle
id="gallery-sort-button"
variant="tertiary"
>
<FormattedMessage
defaultMessage="By date added (oldest)"
description="Dropdown label for sorting by date (oldest)"
id="authoring.texteditor.selectimagemodal.sort.dateoldest.label"
/>
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
key="dateNewest"
>
<FormattedMessage
defaultMessage="By date added (newest)"
description="Dropdown label for sorting by date (newest)"
id="authoring.texteditor.selectimagemodal.sort.datenewest.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="dateOldest"
>
<FormattedMessage
defaultMessage="By date added (oldest)"
description="Dropdown label for sorting by date (oldest)"
id="authoring.texteditor.selectimagemodal.sort.dateoldest.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="nameAscending"
>
<FormattedMessage
defaultMessage="By name (ascending)"
description="Dropdown label for sorting by name (ascending)"
id="authoring.texteditor.selectimagemodal.sort.nameascending.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="nameDescending"
>
<FormattedMessage
defaultMessage="By name (descending)"
description="Dropdown label for sorting by name (descending)"
id="authoring.texteditor.selectimagemodal.sort.namedescending.label"
/>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<Dropdown>
<Dropdown.Toggle
id="gallery-filter-button"
variant="tertiary"
>
<FormattedMessage />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
key="videoStatus"
>
<FormattedMessage
defaultMessage="Video status"
description="Dropdown label for filter by video status (none)"
id="authoring.selectvideomodal.filter.videostatusnone.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="uploading"
>
<FormattedMessage
defaultMessage="Uploading"
description="Dropdown label for filter by video status (uploading)"
id="authoring.selectvideomodal.filter.videostatusuploading.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="processing"
>
<FormattedMessage
defaultMessage="Processing"
description="Dropdown label for filter by video status (processing)"
id="authoring.selectvideomodal.filter.videostatusprocessing.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="ready"
>
<FormattedMessage
defaultMessage="Ready"
description="Dropdown label for filter by video status (ready)"
id="authoring.selectvideomodal.filter.videostatusready.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="failed"
>
<FormattedMessage
defaultMessage="Failed"
description="Dropdown label for filter by video status (failed)"
id="authoring.selectvideomodal.filter.videostatusfailed.label"
/>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<ActionRow.Spacer />
<Component
isInline={true}
name="switch"
onChange={null}
>
<Component
floatLabelLeft={true}
value="switch-value"
>
<FormattedMessage />
</Component>
</Component>
</ActionRow>
`;
exports[`SearchSort component snapshots without filterKeys with search string (close button) 1`] = `
<ActionRow>
<Form.Group
style={
Object {
"margin": 0,
}
}
>
<Form.Control
autoFocus={true}
onChange={[MockFunction props.onSearchChange]}
placeholder="Search"
trailingElement={
<IconButton
iconAs="Icon"
invertColors={true}
isActive={true}
onClick={[MockFunction props.clearSearchString]}
size="sm"
src={[MockFunction icons.Close]}
/>
}
value="props.searchString"
/>
</Form.Group>
<ActionRow.Spacer />
<Dropdown>
<Dropdown.Toggle
id="gallery-sort-button"
variant="tertiary"
>
<FormattedMessage
defaultMessage="By date added (oldest)"
description="Dropdown label for sorting by date (oldest)"
id="authoring.texteditor.selectimagemodal.sort.dateoldest.label"
/>
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
key="dateNewest"
>
<FormattedMessage
defaultMessage="By date added (newest)"
description="Dropdown label for sorting by date (newest)"
id="authoring.texteditor.selectimagemodal.sort.datenewest.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="dateOldest"
>
<FormattedMessage
defaultMessage="By date added (oldest)"
description="Dropdown label for sorting by date (oldest)"
id="authoring.texteditor.selectimagemodal.sort.dateoldest.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="nameAscending"
>
<FormattedMessage
defaultMessage="By name (ascending)"
description="Dropdown label for sorting by name (ascending)"
id="authoring.texteditor.selectimagemodal.sort.nameascending.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="nameDescending"
>
<FormattedMessage
defaultMessage="By name (descending)"
description="Dropdown label for sorting by name (descending)"
id="authoring.texteditor.selectimagemodal.sort.namedescending.label"
/>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</ActionRow>
`;
exports[`SearchSort component snapshots without filterKeys without search string (search icon) 1`] = `
<ActionRow>
<Form.Group
style={
Object {
"margin": 0,
}
}
>
<Form.Control
autoFocus={true}
onChange={[MockFunction props.onSearchChange]}
placeholder="Search"
trailingElement={<Icon />}
value=""
/>
</Form.Group>
<ActionRow.Spacer />
<Dropdown>
<Dropdown.Toggle
id="gallery-sort-button"
variant="tertiary"
>
<FormattedMessage
defaultMessage="By date added (oldest)"
description="Dropdown label for sorting by date (oldest)"
id="authoring.texteditor.selectimagemodal.sort.dateoldest.label"
/>
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
key="dateNewest"
>
<FormattedMessage
defaultMessage="By date added (newest)"
description="Dropdown label for sorting by date (newest)"
id="authoring.texteditor.selectimagemodal.sort.datenewest.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="dateOldest"
>
<FormattedMessage
defaultMessage="By date added (oldest)"
description="Dropdown label for sorting by date (oldest)"
id="authoring.texteditor.selectimagemodal.sort.dateoldest.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="nameAscending"
>
<FormattedMessage
defaultMessage="By name (ascending)"
description="Dropdown label for sorting by name (ascending)"
id="authoring.texteditor.selectimagemodal.sort.nameascending.label"
/>
</Dropdown.Item>
<Dropdown.Item
key="nameDescending"
>
<FormattedMessage
defaultMessage="By name (descending)"
description="Dropdown label for sorting by name (descending)"
id="authoring.texteditor.selectimagemodal.sort.namedescending.label"
/>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</ActionRow>
`;

View File

@@ -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 (
<BaseModal
close={close}
confirmAction={(
<Button {...selectBtnProps} variant="primary">
<FormattedMessage {...confirmMsg} />
</Button>
)}
isOpen={isOpen}
size={size}
isFullscreenScroll={isFullscreenScroll}
footerAction={(
<Button iconBefore={Add} onClick={fileInput.click} variant="link">
<FormattedMessage {...uploadButtonMsg} />
</Button>
)}
title={intl.formatMessage(titleMsg)}
bodyStyle={{ background, padding: '9px 24px' }}
headerComponent={(
<div style={{ zIndex: 10000, margin: '18px 0' }}>
<SearchSort {...searchSortProps} />
</div>
)}
>
{/* Error Alerts */}
<FetchErrorAlert isFetchError={isFetchError} message={fetchError} />
<UploadErrorAlert isUploadError={isUploadError} message={uploadError} />
<ErrorAlert
dismissError={inputError.dismiss}
hideHeading
isError={inputError.show}
>
<FormattedMessage {...inputError.message} />
</ErrorAlert>
{/* User Feedback Alerts */}
<ErrorAlert
dismissError={galleryError.dismiss}
hideHeading
isError={galleryError.show}
>
<FormattedMessage {...galleryError.message} />
</ErrorAlert>
<Stack gap={2}>
<Gallery {...galleryPropsValues} />
<FileInput fileInput={fileInput} acceptedFiles={Object.values(acceptedFiles).join()} />
</Stack>
</BaseModal>
);
};
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);

View File

@@ -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 (<div>Gallery</div>);
});
jest.mock('../FileInput', () => (componentProps) => {
mockFileInputFn(componentProps);
return (<div>FileInput</div>);
});
jest.mock('../ErrorAlerts/ErrorAlert', () => () => (<div>ErrorAlert</div>));
jest.mock('../ErrorAlerts/FetchErrorAlert', () => (componentProps) => {
mockFetchErrorAlertFn(componentProps);
return (<div>FetchErrorAlert</div>);
});
jest.mock('../ErrorAlerts/UploadErrorAlert', () => (componentProps) => {
mockUploadErrorAlertFn(componentProps);
return (<div>UploadErrorAlert</div>);
});
describe('Selection Modal', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('rendering correctly with expected Input', async () => {
render(
<IntlProvider>
<SelectionModal {...props} />
</IntlProvider>,
);
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(
<IntlProvider>
<SelectionModal {...props} isFetchError />
</IntlProvider>,
);
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(
<IntlProvider>
<SelectionModal {...props} isLoaded={false} />
</IntlProvider>,
);
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,
}),
);
});
});

View File

@@ -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;

View File

@@ -2,6 +2,7 @@
exports[`SourceCodeModal renders as expected with default behavior 1`] = `
<BaseModal
bodyStyle={null}
close={[MockFunction]}
confirmAction={
<Button
@@ -24,6 +25,8 @@ exports[`SourceCodeModal renders as expected with default behavior 1`] = `
</Button>
}
footerAction={null}
headerComponent={null}
isFullscreenScroll={true}
isOpen={false}
size="xl"
title="Edit Source Code"

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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';

View File

@@ -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;