chore: update with master

This commit is contained in:
Braden MacDonald
2024-08-17 16:27:00 -07:00
29 changed files with 1429 additions and 229 deletions

246
package-lock.json generated
View File

@@ -2396,9 +2396,9 @@
}
},
"node_modules/@edx/frontend-component-footer": {
"version": "14.0.5",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-14.0.5.tgz",
"integrity": "sha512-r64SGM8wzYZCtAG/J8i+7S7c2XCylKdA4VesSU6Q/Ig8SMndJAhfLc9eo9H4dRrq1E0cYqV2+bQroEucXjzSuQ==",
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-14.0.8.tgz",
"integrity": "sha512-0H91yt9dgFdrjWMyAUI4cgtWqsutylHm1PHXH3sNHXNQcTMPeLyvFfknrA2OLVo0eeDDY0/P23cxmOXG3tAEng==",
"license": "AGPL-3.0",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "6.5.2",
@@ -2418,81 +2418,6 @@
"react-dom": "^16.9.0 || ^17.0.0"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.2.tgz",
"integrity": "sha512-gBxPg3aVO6J0kpfHNILc+NMhXnqHumFxOmjYCFfOiLZfwhnnfhtsdA2hfJlDnj+8PjAs6kKQPenOTKj3Rf7zHw==",
"hasInstallScript": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.2.tgz",
"integrity": "sha512-5CdaCBGl8Rh9ohNdxeeTMxIj8oc3KNBgIeLMvJosBMdslK/UnEB8rzyDRrbKdL1kDweqBPo4GT9wvnakHWucZw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.5.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/free-brands-svg-icons": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.5.2.tgz",
"integrity": "sha512-zi5FNYdmKLnEc0jc0uuHH17kz/hfYTg4Uei0wMGzcoCL/4d3WM3u1VMc0iGGa31HuhV5i7ZK8ZlTCQrHqRHSGQ==",
"hasInstallScript": true,
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.5.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.5.2.tgz",
"integrity": "sha512-iabw/f5f8Uy2nTRtJ13XZTS1O5+t+anvlamJ3zJGLEVE2pKsAWhPv2lq01uQlfgCX7VaveT3EVs515cCN9jRbw==",
"hasInstallScript": true,
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.5.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.2.tgz",
"integrity": "sha512-QWFZYXFE7O1Gr1dTIp+D6UcFUF0qElOnZptpi7PBUMylJh+vFmIedVe1Ir6RM1t2tEQLLSV1k7bR4o92M+uqlw==",
"hasInstallScript": true,
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.5.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/react-fontawesome": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz",
"integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~1 || ~6",
"react": ">=16.3"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/jest-environment-jsdom": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz",
@@ -2521,14 +2446,15 @@
}
},
"node_modules/@edx/frontend-component-header": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-5.3.3.tgz",
"integrity": "sha512-qOPU8YFg3VT4PbyjqFY0eOfRbLyi7jQFjPCsXn+EEwII4LVRbbg2bW3dtDvscMM1J1awaQZtqFHm2m5I/Rpxtg==",
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-5.3.4.tgz",
"integrity": "sha512-niuaXu0+qWPHud9Bs1pqmNXvZc9jpf8WS270/2YEH5owokd+BiDwQ6MWkvS9qbuQIVGPGTSZFFTttUKmQO5O0A==",
"license": "AGPL-3.0",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "6.5.2",
"@fortawesome/free-brands-svg-icons": "6.5.2",
"@fortawesome/free-regular-svg-icons": "6.5.2",
"@fortawesome/free-solid-svg-icons": "6.5.2",
"@fortawesome/fontawesome-svg-core": "6.6.0",
"@fortawesome/free-brands-svg-icons": "6.6.0",
"@fortawesome/free-regular-svg-icons": "6.6.0",
"@fortawesome/free-solid-svg-icons": "6.6.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"axios-mock-adapter": "1.22.0",
"babel-polyfill": "6.26.0",
@@ -2545,57 +2471,57 @@
}
},
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.2.tgz",
"integrity": "sha512-gBxPg3aVO6J0kpfHNILc+NMhXnqHumFxOmjYCFfOiLZfwhnnfhtsdA2hfJlDnj+8PjAs6kKQPenOTKj3Rf7zHw==",
"hasInstallScript": true,
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz",
"integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.2.tgz",
"integrity": "sha512-5CdaCBGl8Rh9ohNdxeeTMxIj8oc3KNBgIeLMvJosBMdslK/UnEB8rzyDRrbKdL1kDweqBPo4GT9wvnakHWucZw==",
"hasInstallScript": true,
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz",
"integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.5.2"
"@fortawesome/fontawesome-common-types": "6.6.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-brands-svg-icons": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.5.2.tgz",
"integrity": "sha512-zi5FNYdmKLnEc0jc0uuHH17kz/hfYTg4Uei0wMGzcoCL/4d3WM3u1VMc0iGGa31HuhV5i7ZK8ZlTCQrHqRHSGQ==",
"hasInstallScript": true,
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.6.0.tgz",
"integrity": "sha512-1MPD8lMNW/earme4OQi1IFHtmHUwAKgghXlNwWi9GO7QkTfD+IIaYpIai4m2YJEzqfEji3jFHX1DZI5pbY/biQ==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.5.2"
"@fortawesome/fontawesome-common-types": "6.6.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.5.2.tgz",
"integrity": "sha512-iabw/f5f8Uy2nTRtJ13XZTS1O5+t+anvlamJ3zJGLEVE2pKsAWhPv2lq01uQlfgCX7VaveT3EVs515cCN9jRbw==",
"hasInstallScript": true,
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.6.0.tgz",
"integrity": "sha512-Yv9hDzL4aI73BEwSEh20clrY8q/uLxawaQ98lekBx6t9dQKDHcDzzV1p2YtBGTtolYtNqcWdniOnhzB+JPnQEQ==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.5.2"
"@fortawesome/fontawesome-common-types": "6.6.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.2.tgz",
"integrity": "sha512-QWFZYXFE7O1Gr1dTIp+D6UcFUF0qElOnZptpi7PBUMylJh+vFmIedVe1Ir6RM1t2tEQLLSV1k7bR4o92M+uqlw==",
"hasInstallScript": true,
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz",
"integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.5.2"
"@fortawesome/fontawesome-common-types": "6.6.0"
},
"engines": {
"node": ">=6"
@@ -2605,6 +2531,7 @@
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz",
"integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==",
"license": "MIT",
"dependencies": {
"@jest/environment": "^29.7.0",
"@jest/fake-timers": "^29.7.0",
@@ -2631,6 +2558,7 @@
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.2.0.tgz",
"integrity": "sha512-iagCqVrw4QSjhxKp3I/YK6+ODkWY6G+YPElvdYKiUUbywwh9Ds0M7r26Fj2/7dWFFbOpcGnJE6uE7aMck8j5Qg==",
"license": "MIT",
"dependencies": {
"hyphenate-style-name": "^1.0.0",
"matchmediaquery": "^0.3.0",
@@ -2703,9 +2631,10 @@
}
},
"node_modules/@edx/openedx-atlas": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@edx/openedx-atlas/-/openedx-atlas-0.6.0.tgz",
"integrity": "sha512-wZO7hA4VJ/bXjaQNNR7KXGYyTCNs1mBJd3HwQK2EmOwFZYFNX6nzSAm9S7HCfi/kb1PCRpmp3wJt+v/Eu9BEQg==",
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@edx/openedx-atlas/-/openedx-atlas-0.6.1.tgz",
"integrity": "sha512-n5b2fN3usGqOHREji4QZDsXSzRwH7b6Bf9NiA49OcHKjbMYhaPNp4BVakIbu3f3wuPyyVY+bgUODx7wRB4OyIQ==",
"license": "AGPL-3.0",
"bin": {
"atlas": "atlas"
}
@@ -3238,32 +3167,72 @@
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "0.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz",
"integrity": "sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg==",
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.2.tgz",
"integrity": "sha512-gBxPg3aVO6J0kpfHNILc+NMhXnqHumFxOmjYCFfOiLZfwhnnfhtsdA2hfJlDnj+8PjAs6kKQPenOTKj3Rf7zHw==",
"hasInstallScript": true,
"peer": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "1.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.36.tgz",
"integrity": "sha512-YUcsLQKYb6DmaJjIHdDWpBIGCcyE/W+p/LMGvjQem55Mm2XWVAP5kWTMKWLv9lwpCVjpLxPyOMOyUocP1GxrtA==",
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.2.tgz",
"integrity": "sha512-5CdaCBGl8Rh9ohNdxeeTMxIj8oc3KNBgIeLMvJosBMdslK/UnEB8rzyDRrbKdL1kDweqBPo4GT9wvnakHWucZw==",
"hasInstallScript": true,
"peer": true,
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-common-types": "^0.2.36"
"@fortawesome/fontawesome-common-types": "6.5.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.5.2.tgz",
"integrity": "sha512-zi5FNYdmKLnEc0jc0uuHH17kz/hfYTg4Uei0wMGzcoCL/4d3WM3u1VMc0iGGa31HuhV5i7ZK8ZlTCQrHqRHSGQ==",
"hasInstallScript": true,
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.5.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.5.2.tgz",
"integrity": "sha512-iabw/f5f8Uy2nTRtJ13XZTS1O5+t+anvlamJ3zJGLEVE2pKsAWhPv2lq01uQlfgCX7VaveT3EVs515cCN9jRbw==",
"hasInstallScript": true,
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.5.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.2.tgz",
"integrity": "sha512-QWFZYXFE7O1Gr1dTIp+D6UcFUF0qElOnZptpi7PBUMylJh+vFmIedVe1Ir6RM1t2tEQLLSV1k7bR4o92M+uqlw==",
"hasInstallScript": true,
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.5.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/react-fontawesome": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.0.tgz",
"integrity": "sha512-uHg75Rb/XORTtVt7OS9WoK8uM276Ufi7gCzshVWkUJbHhh3svsUUeqXerrM96Wm7fRiDzfKRwSoahhMIkGAYHw==",
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz",
"integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.1"
},
@@ -4329,9 +4298,9 @@
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
},
"node_modules/@openedx/paragon": {
"version": "22.6.1",
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.6.1.tgz",
"integrity": "sha512-xblrspAfsYsiDzyLIh+tceiTPgx1HY6v0eceatTYSj/BINxN8Dcqh9uQOZi2eJc1os3w2dr0nZRGnTt8cYu2BA==",
"version": "22.7.0",
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.7.0.tgz",
"integrity": "sha512-BWj4vYXUmLS0BinJckxbhNp0o1UPfwURinaSgTxxBkF0L2VUtAO+SldVWvKDqlltzoR062yjcBA5QSGq8Jxgeg==",
"license": "Apache-2.0",
"workspaces": [
"example",
@@ -4380,29 +4349,6 @@
"react-intl": "^5.25.1 || ^6.4.0"
}
},
"node_modules/@openedx/paragon/node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.2.tgz",
"integrity": "sha512-gBxPg3aVO6J0kpfHNILc+NMhXnqHumFxOmjYCFfOiLZfwhnnfhtsdA2hfJlDnj+8PjAs6kKQPenOTKj3Rf7zHw==",
"hasInstallScript": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@openedx/paragon/node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.2.tgz",
"integrity": "sha512-5CdaCBGl8Rh9ohNdxeeTMxIj8oc3KNBgIeLMvJosBMdslK/UnEB8rzyDRrbKdL1kDweqBPo4GT9wvnakHWucZw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.5.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@openedx/paragon/node_modules/@fortawesome/react-fontawesome": {
"version": "0.1.19",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.19.tgz",

View File

@@ -1,6 +1,9 @@
// @ts-check
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { getClipboard } from '../../data/api';
import { updateClipboardData } from '../../data/slice';
import { CLIPBOARD_STATUS, STRUCTURAL_XBLOCK_TYPES, STUDIO_CLIPBOARD_CHANNEL } from '../../../constants';
import { getClipboardData } from '../../data/selectors';
@@ -14,6 +17,7 @@ import { getClipboardData } from '../../data/selectors';
* @property {Object} sharedClipboardData - The shared clipboard data object.
*/
const useCopyToClipboard = (canEdit = true) => {
const dispatch = useDispatch();
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
const [showPasteUnit, setShowPasteUnit] = useState(false);
const [showPasteXBlock, setShowPasteXBlock] = useState(false);
@@ -30,6 +34,22 @@ const useCopyToClipboard = (canEdit = true) => {
setShowPasteUnit(!!isPasteableUnit);
};
// Called on initial render to fetch and populate the initial clipboard data in redux state.
// Without this, the initial clipboard data redux state is always null.
useEffect(() => {
const fetchInitialClipboardData = async () => {
try {
const userClipboard = await getClipboard();
dispatch(updateClipboardData(userClipboard));
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Failed to fetch initial clipboard data: ${error}`);
}
};
fetchInitialClipboardData();
}, [dispatch]);
useEffect(() => {
// Handle updates to clipboard data
if (canEdit) {

View File

@@ -18,6 +18,7 @@
@import "export-page/CourseExportPage";
@import "import-page/CourseImportPage";
@import "taxonomy";
@import "library-authoring";
@import "files-and-videos";
@import "content-tags-drawer";
@import "course-outline/CourseOutline";
@@ -30,7 +31,6 @@
@import "search-manager";
@import "certificates/scss/Certificates";
@import "group-configurations/GroupConfigurations";
@import "library-authoring";
// To apply the glow effect to the selected Section/Subsection, in the Course Outline
div.row:has(> div > div.highlight) {

View File

@@ -83,6 +83,9 @@ const libraryData: ContentLibrary = {
numBlocks: 2,
version: 0,
lastPublished: null,
lastDraftCreated: '2024-07-22',
publishedBy: 'staff',
lastDraftCreatedBy: 'staff',
allowLti: false,
allowPublicLearning: false,
allowPublicRead: false,
@@ -90,8 +93,17 @@ const libraryData: ContentLibrary = {
hasUnpublishedDeletes: false,
canEditLibrary: true,
license: '',
created: '2024-06-26',
updated: '2024-07-20',
};
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
@@ -177,7 +189,7 @@ describe('<LibraryAuthoringPage />', () => {
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
const {
getByRole, getByText, getAllByText, queryByText,
getByRole, getByText, queryByText, findByText, findAllByText,
} = render(<RootWrapper />);
// Ensure the search endpoint is called:
@@ -185,15 +197,15 @@ describe('<LibraryAuthoringPage />', () => {
// Call 2: To fetch the recently modified components only
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
expect(getByText('Content library')).toBeInTheDocument();
expect(getByText(libraryData.title)).toBeInTheDocument();
expect(await findByText('Content library')).toBeInTheDocument();
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
expect(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();
expect(getByText('Recently Modified')).toBeInTheDocument();
expect(getByText('Collections (0)')).toBeInTheDocument();
expect(getByText('Components (6)')).toBeInTheDocument();
expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument();
expect((await findAllByText('Test HTML Block'))[0]).toBeInTheDocument();
// Navigate to the components tab
fireEvent.click(getByRole('tab', { name: 'Components' }));
@@ -222,10 +234,10 @@ describe('<LibraryAuthoringPage />', () => {
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
const { findByText, getByText } = render(<RootWrapper />);
const { findByText, getByText, findAllByText } = render(<RootWrapper />);
expect(await findByText('Content library')).toBeInTheDocument();
expect(await findByText(libraryData.title)).toBeInTheDocument();
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
// Ensure the search endpoint is called:
// Call 1: To fetch searchable/filterable/sortable library data
@@ -282,10 +294,15 @@ describe('<LibraryAuthoringPage />', () => {
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
const { findByText, getByRole, getByText } = render(<RootWrapper />);
const {
findByText,
getByRole,
getByText,
findAllByText,
} = render(<RootWrapper />);
expect(await findByText('Content library')).toBeInTheDocument();
expect(await findByText(libraryData.title)).toBeInTheDocument();
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
// Ensure the search endpoint is called:
// Call 1: To fetch searchable/filterable/sortable library data
@@ -329,12 +346,54 @@ describe('<LibraryAuthoringPage />', () => {
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
});
it('should open Library Info by default', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
render(<RootWrapper />);
expect(await screen.findByText('Content library')).toBeInTheDocument();
expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument();
expect((await screen.findAllByText(libraryData.title))[1]).toBeInTheDocument();
expect(screen.getByText('Draft')).toBeInTheDocument();
expect(screen.getByText('(Never Published)')).toBeInTheDocument();
expect(screen.getByText('July 22, 2024')).toBeInTheDocument();
expect(screen.getByText('staff')).toBeInTheDocument();
expect(screen.getByText(libraryData.org)).toBeInTheDocument();
expect(screen.getByText('July 20, 2024')).toBeInTheDocument();
expect(screen.getByText('June 26, 2024')).toBeInTheDocument();
});
it('should close and open Library Info', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
render(<RootWrapper />);
expect(await screen.findByText('Content library')).toBeInTheDocument();
expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument();
expect((await screen.findAllByText(libraryData.title))[1]).toBeInTheDocument();
const closeButton = screen.getByRole('button', { name: /close/i });
fireEvent.click(closeButton);
expect(screen.queryByText('Draft')).not.toBeInTheDocument();
expect(screen.queryByText('(Never Published)')).not.toBeInTheDocument();
const libraryInfoButton = screen.getByRole('button', { name: /library info/i });
fireEvent.click(libraryInfoButton);
expect(screen.getByText('Draft')).toBeInTheDocument();
expect(screen.getByText('(Never Published)')).toBeInTheDocument();
});
it('show the "View All" button when viewing library with many components', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
const {
getByRole, getByText, queryByText, getAllByText,
getByRole, getByText, queryByText, getAllByText, findAllByText,
} = render(<RootWrapper />);
// Ensure the search endpoint is called:
@@ -343,7 +402,7 @@ describe('<LibraryAuthoringPage />', () => {
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
expect(getByText('Content library')).toBeInTheDocument();
expect(getByText(libraryData.title)).toBeInTheDocument();
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
await waitFor(() => { expect(getByText('Recently Modified')).toBeInTheDocument(); });
expect(getByText('Collections (0)')).toBeInTheDocument();
@@ -376,7 +435,7 @@ describe('<LibraryAuthoringPage />', () => {
fetchMock.post(searchEndpoint, returnLowNumberResults, { overwriteRoutes: true });
const {
getByText, queryByText, getAllByText,
getByText, queryByText, getAllByText, findAllByText,
} = render(<RootWrapper />);
// Ensure the search endpoint is called:
@@ -385,7 +444,7 @@ describe('<LibraryAuthoringPage />', () => {
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
expect(getByText('Content library')).toBeInTheDocument();
expect(getByText(libraryData.title)).toBeInTheDocument();
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
await waitFor(() => { expect(getByText('Recently Modified')).toBeInTheDocument(); });
expect(getByText('Collections (0)')).toBeInTheDocument();

View File

@@ -1,4 +1,4 @@
import React, { useContext } from 'react';
import React, { useContext, useEffect } from 'react';
import { StudioFooter } from '@edx/frontend-component-footer';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
@@ -6,8 +6,6 @@ import {
Button,
Col,
Container,
Icon,
IconButton,
Row,
Stack,
Tab,
@@ -52,6 +50,7 @@ const HeaderActions = ({ canEditLibrary }: HeaderActionsProps) => {
const intl = useIntl();
const {
openAddContentSidebar,
openInfoSidebar,
} = useContext(LibraryContext);
if (!canEditLibrary) {
@@ -59,30 +58,32 @@ const HeaderActions = ({ canEditLibrary }: HeaderActionsProps) => {
}
return (
<Button
iconBefore={Add}
variant="primary rounded-0"
onClick={() => openAddContentSidebar()}
disabled={!canEditLibrary}
>
{intl.formatMessage(messages.newContentButton)}
</Button>
<>
<Button
iconBefore={InfoOutline}
variant="outline-primary rounded-0"
onClick={openInfoSidebar}
>
{intl.formatMessage(messages.libraryInfoButton)}
</Button>
<Button
iconBefore={Add}
variant="primary rounded-0"
onClick={openAddContentSidebar}
disabled={!canEditLibrary}
>
{intl.formatMessage(messages.newContentButton)}
</Button>
</>
);
};
const SubHeaderTitle = ({ title, canEditLibrary }: { title: string, canEditLibrary: boolean }) => {
const intl = useIntl();
return (
<Stack direction="vertical">
<Stack direction="horizontal">
{title}
<IconButton
src={InfoOutline}
iconAs={Icon}
alt={intl.formatMessage(messages.headingInfoAlt)}
className="mr-2"
/>
</Stack>
{title}
{ !canEditLibrary && (
<div>
<Badge variant="primary" style={{ fontSize: '50%' }}>
@@ -104,7 +105,14 @@ const LibraryAuthoringPage = () => {
const currentPath = location.pathname.split('/').pop();
const activeKey = (currentPath && currentPath in TabList) ? TabList[currentPath] : TabList.home;
const { sidebarBodyComponent } = useContext(LibraryContext);
const {
sidebarBodyComponent,
openInfoSidebar,
} = useContext(LibraryContext);
useEffect(() => {
openInfoSidebar();
}, []);
const [searchParams] = useSearchParams();
@@ -190,8 +198,8 @@ const LibraryAuthoringPage = () => {
<StudioFooter />
</Col>
{ sidebarBodyComponent !== null && (
<Col xs={6} md={4} className="box-shadow-left-1">
<LibrarySidebar />
<Col xs={3} md={3} className="box-shadow-left-1">
<LibrarySidebar library={libraryData} />
</Col>
)}
</Row>

View File

@@ -10,7 +10,10 @@ import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import AddContentContainer from './AddContentContainer';
import initializeStore from '../../store';
import { getCreateLibraryBlockUrl } from '../data/api';
import { getCreateLibraryBlockUrl, getLibraryPasteClipboardUrl } from '../data/api';
import { getClipboardUrl } from '../../generic/data/api';
import { clipboardXBlock } from '../../__mocks__';
const mockUseParams = jest.fn();
let axiosMock;
@@ -31,6 +34,13 @@ const queryClient = new QueryClient({
},
});
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
@@ -69,6 +79,7 @@ describe('<AddContentContainer />', () => {
expect(screen.getByRole('button', { name: /drag drop/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /video/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /advanced \/ other/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /copy from clipboard/i })).not.toBeInTheDocument();
});
it('should create a content', async () => {
@@ -82,4 +93,49 @@ describe('<AddContentContainer />', () => {
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
});
it('should render paste button if clipboard contains pastable xblock', async () => {
const url = getClipboardUrl();
axiosMock.onGet(url).reply(200, clipboardXBlock);
render(<RootWrapper />);
await waitFor(() => expect(axiosMock.history.get[0].url).toEqual(url));
expect(screen.getByRole('button', { name: /paste from clipboard/i })).toBeInTheDocument();
});
it('should paste content', async () => {
const clipboardUrl = getClipboardUrl();
axiosMock.onGet(clipboardUrl).reply(200, clipboardXBlock);
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
axiosMock.onPost(pasteUrl).reply(200);
render(<RootWrapper />);
await waitFor(() => expect(axiosMock.history.get[0].url).toEqual(clipboardUrl));
const pasteButton = screen.getByRole('button', { name: /paste from clipboard/i });
fireEvent.click(pasteButton);
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
});
it('should fail pasting content', async () => {
const clipboardUrl = getClipboardUrl();
axiosMock.onGet(clipboardUrl).reply(200, clipboardXBlock);
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
axiosMock.onPost(pasteUrl).reply(400);
render(<RootWrapper />);
await waitFor(() => expect(axiosMock.history.get[0].url).toEqual(clipboardUrl));
const pasteButton = screen.getByRole('button', { name: /paste from clipboard/i });
fireEvent.click(pasteButton);
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
});
});

View File

@@ -1,4 +1,5 @@
import React, { useContext } from 'react';
import { useSelector } from 'react-redux';
import {
Stack,
Button,
@@ -12,18 +13,25 @@ import {
ThumbUpOutline,
Question,
VideoCamera,
ContentPaste,
} from '@openedx/paragon/icons';
import { v4 as uuid4 } from 'uuid';
import { useParams } from 'react-router-dom';
import { ToastContext } from '../../generic/toast-context';
import { useCreateLibraryBlock } from '../data/apiHooks';
import { useCopyToClipboard } from '../../generic/clipboard';
import { getCanEdit } from '../../course-unit/data/selectors';
import { useCreateLibraryBlock, useLibraryPasteClipboard } from '../data/apiHooks';
import messages from './messages';
const AddContentContainer = () => {
const intl = useIntl();
const { libraryId } = useParams();
const createBlockMutation = useCreateLibraryBlock();
const pasteClipboardMutation = useLibraryPasteClipboard();
const { showToast } = useContext(ToastContext);
const canEdit = useSelector(getCanEdit);
const { showPasteXBlock } = useCopyToClipboard(canEdit);
const contentTypes = [
{
@@ -64,20 +72,47 @@ const AddContentContainer = () => {
},
];
// Include the 'Paste from Clipboard' button if there is an Xblock in the clipboard
// that can be pasted
if (showPasteXBlock) {
const pasteButton = {
name: intl.formatMessage(messages.pasteButton),
disabled: false,
icon: ContentPaste,
blockType: 'paste',
};
contentTypes.push(pasteButton);
}
const onCreateContent = (blockType: string) => {
if (libraryId) {
createBlockMutation.mutateAsync({
libraryId,
blockType,
definitionId: `${uuid4()}`,
}).then(() => {
showToast(intl.formatMessage(messages.successCreateMessage));
}).catch(() => {
showToast(intl.formatMessage(messages.errorCreateMessage));
});
if (blockType === 'paste') {
pasteClipboardMutation.mutateAsync({
libraryId,
blockId: `${uuid4()}`,
}).then(() => {
showToast(intl.formatMessage(messages.successPasteClipboardMessage));
}).catch(() => {
showToast(intl.formatMessage(messages.errorPasteClipboardMessage));
});
} else {
createBlockMutation.mutateAsync({
libraryId,
blockType,
definitionId: `${uuid4()}`,
}).then(() => {
showToast(intl.formatMessage(messages.successCreateMessage));
}).catch(() => {
showToast(intl.formatMessage(messages.errorCreateMessage));
});
}
}
};
if (pasteClipboardMutation.isLoading) {
showToast(intl.formatMessage(messages.pastingClipboardMessage));
}
return (
<Stack direction="vertical">
<Button

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import messages from './messages';
const AddContentHeader = () => (
<span className="font-weight-bold m-1.5">
<FormattedMessage {...messages.addContentTitle} />
</span>
);
export default AddContentHeader;

View File

@@ -1,2 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as AddContentContainer } from './AddContentContainer';
export { default as AddContentHeader } from './AddContentHeader';

View File

@@ -40,6 +40,11 @@ const messages = defineMessages({
defaultMessage: 'Advanced / Other',
description: 'Content of button to create a Advanced / Other component.',
},
pasteButton: {
id: 'course-authoring.library-authoring.add-content.buttons.paste',
defaultMessage: 'Paste From Clipboard',
description: 'Content of button to paste from clipboard.',
},
successCreateMessage: {
id: 'course-authoring.library-authoring.add-content.success.text',
defaultMessage: 'Content created successfully.',
@@ -50,6 +55,26 @@ const messages = defineMessages({
defaultMessage: 'There was an error creating the content.',
description: 'Message when creation of content in library is on error',
},
addContentTitle: {
id: 'course-authoring.library-authoring.sidebar.title.add-content',
defaultMessage: 'Add Content',
description: 'Title of add content in library container.',
},
successPasteClipboardMessage: {
id: 'course-authoring.library-authoring.paste-clipboard.success.text',
defaultMessage: 'Content pasted successfully.',
description: 'Message when pasting clipboard in library is successful',
},
errorPasteClipboardMessage: {
id: 'course-authoring.library-authoring.paste-clipboard.error.text',
defaultMessage: 'There was an error pasting the content.',
description: 'Message when pasting clipboard in library errors',
},
pastingClipboardMessage: {
id: 'course-authoring.library-authoring.paste-clipboard.loading.text',
defaultMessage: 'Pasting content from clipboard...',
description: 'Message when in process of pasting content in library',
},
});
export default messages;

View File

@@ -1,20 +1,23 @@
/* eslint-disable react/require-default-props */
import React from 'react';
enum SidebarBodyComponentId {
export enum SidebarBodyComponentId {
AddContent = 'add-content',
Info = 'info',
}
export interface LibraryContextData {
sidebarBodyComponent: SidebarBodyComponentId | null;
closeLibrarySidebar: () => void;
openAddContentSidebar: () => void;
openInfoSidebar: () => void;
}
export const LibraryContext = React.createContext({
sidebarBodyComponent: null,
closeLibrarySidebar: () => {},
openAddContentSidebar: () => {},
openInfoSidebar: () => {},
} as LibraryContextData);
/**
@@ -25,12 +28,19 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => {
const closeLibrarySidebar = React.useCallback(() => setSidebarBodyComponent(null), []);
const openAddContentSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.AddContent), []);
const openInfoSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.Info), []);
const context = React.useMemo(() => ({
sidebarBodyComponent,
closeLibrarySidebar,
openAddContentSidebar,
}), [sidebarBodyComponent, closeLibrarySidebar, openAddContentSidebar]);
openInfoSidebar,
}), [
sidebarBodyComponent,
closeLibrarySidebar,
openAddContentSidebar,
openInfoSidebar,
]);
return (
<LibraryContext.Provider value={context}>

View File

@@ -40,6 +40,13 @@ const contentHit: ContentHit = {
lastPublished: null,
};
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en">

View File

@@ -1,4 +1,4 @@
import React, { useContext, useMemo } from 'react';
import React, { useContext, useMemo, useState } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
@@ -17,6 +17,7 @@ import TagCount from '../../generic/tag-count';
import { ToastContext } from '../../generic/toast-context';
import { type ContentHit, Highlight } from '../../search-manager';
import messages from './messages';
import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants';
type ComponentCardProps = {
contentHit: ContentHit,
@@ -26,9 +27,13 @@ type ComponentCardProps = {
const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => {
const intl = useIntl();
const { showToast } = useContext(ToastContext);
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
const updateClipboardClick = () => {
updateClipboard(usageKey)
.then(() => showToast(intl.formatMessage(messages.copyToClipboardSuccess)))
.then((clipboardData) => {
clipboardBroadcastChannel.postMessage(clipboardData);
showToast(intl.formatMessage(messages.copyToClipboardSuccess));
})
.catch(() => showToast(intl.formatMessage(messages.copyToClipboardError)));
};

View File

@@ -84,6 +84,13 @@ jest.mock('../../search-manager', () => ({
useSearchContext: () => mockUseSearchContext(),
}));
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
const RootWrapper = (props) => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>

View File

@@ -1,7 +1,13 @@
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { createLibraryBlock, getCreateLibraryBlockUrl } from './api';
import {
commitLibraryChanges,
createLibraryBlock,
getCommitLibraryChangesUrl,
getCreateLibraryBlockUrl,
revertLibraryChanges,
} from './api';
let axiosMock;
@@ -21,6 +27,7 @@ describe('library api calls', () => {
afterEach(() => {
jest.clearAllMocks();
axiosMock.restore();
});
it('should create library block', async () => {
@@ -35,4 +42,24 @@ describe('library api calls', () => {
expect(axiosMock.history.post[0].url).toEqual(url);
});
it('should commit library changes', async () => {
const libraryId = 'lib:org:1';
const url = getCommitLibraryChangesUrl(libraryId);
axiosMock.onPost(url).reply(200);
await commitLibraryChanges(libraryId);
expect(axiosMock.history.post[0].url).toEqual(url);
});
it('should revert library changes', async () => {
const libraryId = 'lib:org:1';
const url = getCommitLibraryChangesUrl(libraryId);
axiosMock.onDelete(url).reply(200);
await revertLibraryChanges(libraryId);
expect(axiosMock.history.delete[0].url).toEqual(url);
});
});

View File

@@ -16,6 +16,14 @@ export const getLibraryBlockTypesUrl = (libraryId: string) => `${getApiBaseUrl()
*/
export const getCreateLibraryBlockUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/blocks/`;
export const getContentLibraryV2ListApiUrl = () => `${getApiBaseUrl()}/api/libraries/v2/`;
/**
* Get the URL for commit/revert changes in library.
*/
export const getCommitLibraryChangesUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/commit/`;
/**
* Get the URL for paste clipboard content into library.
*/
export const getLibraryPasteClipboardUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/paste_clipboard/`;
export interface ContentLibrary {
id: string;
@@ -26,7 +34,10 @@ export interface ContentLibrary {
description: string;
numBlocks: number;
version: number;
lastPublished: Date | null;
lastPublished: string | null;
lastDraftCreated: string | null;
publishedBy: string | null;
lastDraftCreatedBy: string | null;
allowLti: boolean;
allowPublicLearning: boolean;
allowPublicRead: boolean;
@@ -34,6 +45,8 @@ export interface ContentLibrary {
hasUnpublishedDeletes: boolean;
canEditLibrary: boolean;
license: string;
created: string | null;
updated: string | null;
}
export interface LibraryBlockType {
@@ -41,18 +54,6 @@ export interface LibraryBlockType {
displayName: string;
}
/**
* Fetch block types of a library
*/
export async function getLibraryBlockTypes(libraryId?: string): Promise<LibraryBlockType[]> {
if (!libraryId) {
throw new Error('libraryId is required');
}
const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockTypesUrl(libraryId));
return camelCaseObject(data);
}
export interface LibrariesV2Response {
next: string | null,
previous: string | null,
@@ -94,6 +95,33 @@ export interface CreateBlockDataResponse {
tagsCount: number;
}
export interface UpdateLibraryDataRequest {
id: string;
title?: string;
description?: string;
allow_public_learning?: boolean;
allow_public_read?: boolean;
type?: string;
license?: string;
}
export interface LibraryPasteClipboardRequest {
libraryId: string;
blockId: string;
}
/**
* Fetch block types of a library
*/
export async function getLibraryBlockTypes(libraryId?: string): Promise<LibraryBlockType[]> {
if (!libraryId) {
throw new Error('libraryId is required');
}
const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockTypesUrl(libraryId));
return camelCaseObject(data);
}
/**
* Fetch a content library by its ID.
*/
@@ -122,6 +150,16 @@ export async function createLibraryBlock({
return data;
}
/**
* Update library metadata.
*/
export async function updateLibraryMetadata(libraryData: UpdateLibraryDataRequest): Promise<ContentLibrary> {
const client = getAuthenticatedHttpClient();
const { data } = await client.patch(getContentLibraryApiUrl(libraryData.id), libraryData);
return camelCaseObject(data);
}
/**
* Get a list of content libraries.
*/
@@ -140,3 +178,36 @@ export async function getContentLibraryV2List(customParams: GetLibrariesV2Custom
.get(getContentLibraryV2ListApiUrl(), { params: customParamsFormated });
return camelCaseObject(data);
}
/**
* Commit library changes.
*/
export async function commitLibraryChanges(libraryId: string) {
const client = getAuthenticatedHttpClient();
await client.post(getCommitLibraryChangesUrl(libraryId));
}
/**
* Revert library changes.
*/
export async function revertLibraryChanges(libraryId: string) {
const client = getAuthenticatedHttpClient();
await client.delete(getCommitLibraryChangesUrl(libraryId));
}
/**
* Paste clipboard content into library.
*/
export async function libraryPasteClipboard({
libraryId,
blockId,
}: LibraryPasteClipboardRequest): Promise<CreateBlockDataResponse> {
const client = getAuthenticatedHttpClient();
const { data } = await client.post(
getLibraryPasteClipboardUrl(libraryId),
{
block_id: blockId,
},
);
return data;
}

View File

@@ -5,8 +5,8 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { renderHook } from '@testing-library/react-hooks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import MockAdapter from 'axios-mock-adapter';
import { getCreateLibraryBlockUrl } from './api';
import { useCreateLibraryBlock } from './apiHooks';
import { getCommitLibraryChangesUrl, getCreateLibraryBlockUrl } from './api';
import { useCommitLibraryChanges, useCreateLibraryBlock, useRevertLibraryChanges } from './apiHooks';
let axiosMock;
@@ -50,4 +50,24 @@ describe('library api hooks', () => {
expect(axiosMock.history.post[0].url).toEqual(url);
});
it('should commit library changes', async () => {
const libraryId = 'lib:org:1';
const url = getCommitLibraryChangesUrl(libraryId);
axiosMock.onPost(url).reply(200);
const { result } = renderHook(() => useCommitLibraryChanges(), { wrapper });
await result.current.mutateAsync(libraryId);
expect(axiosMock.history.post[0].url).toEqual(url);
});
it('should revert library changes', async () => {
const libraryId = 'lib:org:1';
const url = getCommitLibraryChangesUrl(libraryId);
axiosMock.onDelete(url).reply(200);
const { result } = renderHook(() => useRevertLibraryChanges(), { wrapper });
await result.current.mutateAsync(libraryId);
expect(axiosMock.history.delete[0].url).toEqual(url);
});
});

View File

@@ -6,6 +6,11 @@ import {
getLibraryBlockTypes,
createLibraryBlock,
getContentLibraryV2List,
commitLibraryChanges,
revertLibraryChanges,
updateLibraryMetadata,
ContentLibrary,
libraryPasteClipboard,
} from './api';
export const libraryAuthoringQueryKeys = {
@@ -61,6 +66,35 @@ export const useCreateLibraryBlock = () => {
});
};
export const useUpdateLibraryMetadata = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateLibraryMetadata,
onMutate: async (data) => {
const queryKey = libraryAuthoringQueryKeys.contentLibrary(data.id);
const previousLibraryData = queryClient.getQueriesData(queryKey)[0][1] as ContentLibrary;
const newLibraryData = {
...previousLibraryData,
title: data.title,
};
queryClient.setQueryData(queryKey, newLibraryData);
return { previousLibraryData, newLibraryData };
},
onError: (_err, data, context) => {
queryClient.setQueryData(
libraryAuthoringQueryKeys.contentLibrary(data.id),
context?.previousLibraryData,
);
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(variables.id) });
},
});
};
/**
* Builds the query to fetch list of V2 Libraries
*/
@@ -71,3 +105,34 @@ export const useContentLibraryV2List = (customParams: GetLibrariesV2CustomParams
keepPreviousData: true,
})
);
export const useCommitLibraryChanges = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: commitLibraryChanges,
onSettled: (_data, _error, libraryId) => {
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) });
},
});
};
export const useRevertLibraryChanges = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: revertLibraryChanges,
onSettled: (_data, _error, libraryId) => {
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) });
},
});
};
export const useLibraryPasteClipboard = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: libraryPasteClipboard,
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(variables.libraryId) });
queryClient.invalidateQueries({ queryKey: ['content_search'] });
},
});
};

View File

@@ -1 +1,2 @@
@import "library-authoring/components/ComponentCard";
@import "library-authoring/library-info/LibraryPublishStatus";

View File

@@ -0,0 +1,207 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
render,
screen,
fireEvent,
waitFor,
} from '@testing-library/react';
import LibraryInfo from './LibraryInfo';
import { ToastProvider } from '../../generic/toast-context';
import { ContentLibrary, getCommitLibraryChangesUrl } from '../data/api';
import initializeStore from '../../store';
let store;
let axiosMock;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const libraryData: ContentLibrary = {
id: 'lib:org1:lib1',
type: 'complex',
org: 'org1',
slug: 'lib1',
title: 'lib1',
description: 'lib1',
numBlocks: 2,
version: 0,
lastPublished: null,
lastDraftCreated: '2024-07-22',
publishedBy: 'staff',
lastDraftCreatedBy: 'staff',
allowLti: false,
allowPublicLearning: false,
allowPublicRead: false,
hasUnpublishedChanges: true,
hasUnpublishedDeletes: false,
canEditLibrary: true,
license: '',
created: '2024-06-26',
updated: '2024-07-20',
};
interface WrapperProps {
data: ContentLibrary,
}
const RootWrapper = ({ data } : WrapperProps) => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<ToastProvider>
<LibraryInfo library={data} />
</ToastProvider>
</QueryClientProvider>
</IntlProvider>
</AppProvider>
);
describe('<LibraryInfo />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
afterEach(() => {
jest.clearAllMocks();
axiosMock.restore();
});
it('should render Library info sidebar', () => {
render(<RootWrapper data={libraryData} />);
expect(screen.getByText('Draft')).toBeInTheDocument();
expect(screen.getByText('(Never Published)')).toBeInTheDocument();
expect(screen.getByText('July 22, 2024')).toBeInTheDocument();
expect(screen.getByText('staff')).toBeInTheDocument();
expect(screen.getByText(libraryData.org)).toBeInTheDocument();
expect(screen.getByText('July 20, 2024')).toBeInTheDocument();
expect(screen.getByText('June 26, 2024')).toBeInTheDocument();
});
it('should render Library info in draft state without user', () => {
const data = {
...libraryData,
lastDraftCreatedBy: null,
};
render(<RootWrapper data={data} />);
expect(screen.getByText('Draft')).toBeInTheDocument();
expect(screen.getByText('(Never Published)')).toBeInTheDocument();
expect(screen.getByText('July 22, 2024')).toBeInTheDocument();
expect(screen.queryByText('staff')).not.toBeInTheDocument();
});
it('should render Library creation date if last draft created date is null', () => {
const data = {
...libraryData,
lastDraftCreated: null,
};
render(<RootWrapper data={data} />);
expect(screen.getByText('Draft')).toBeInTheDocument();
expect(screen.getByText('(Never Published)')).toBeInTheDocument();
expect(screen.getAllByText('June 26, 2024')[0]).toBeInTheDocument();
expect(screen.getAllByText('June 26, 2024')[1]).toBeInTheDocument();
});
it('should render library info in draft state without date', () => {
const data = {
...libraryData,
lastDraftCreated: null,
created: null,
};
render(<RootWrapper data={data} />);
expect(screen.getByText('Draft')).toBeInTheDocument();
expect(screen.getByText('(Never Published)')).toBeInTheDocument();
});
it('should render draft library info sidebar', () => {
const data = {
...libraryData,
lastPublished: '2024-07-26',
};
render(<RootWrapper data={data} />);
expect(screen.getByText('Draft')).toBeInTheDocument();
expect(screen.queryByText('(Never Published)')).not.toBeInTheDocument();
expect(screen.getByText('July 22, 2024')).toBeInTheDocument();
expect(screen.getByText('staff')).toBeInTheDocument();
});
it('should render published library info sidebar', () => {
const data = {
...libraryData,
lastPublished: '2024-07-26',
hasUnpublishedChanges: false,
};
render(<RootWrapper data={data} />);
expect(screen.getByText('Published')).toBeInTheDocument();
expect(screen.getByText('July 26, 2024')).toBeInTheDocument();
expect(screen.getByText('staff')).toBeInTheDocument();
});
it('should render published library info without user', () => {
const data = {
...libraryData,
lastPublished: '2024-07-26',
hasUnpublishedChanges: false,
publishedBy: null,
};
render(<RootWrapper data={data} />);
expect(screen.getByText('Published')).toBeInTheDocument();
expect(screen.getByText('July 26, 2024')).toBeInTheDocument();
expect(screen.queryByText('staff')).not.toBeInTheDocument();
});
it('should publish library', async () => {
const url = getCommitLibraryChangesUrl(libraryData.id);
axiosMock.onPost(url).reply(200);
render(<RootWrapper data={libraryData} />);
const publishButton = screen.getByRole('button', { name: /publish/i });
fireEvent.click(publishButton);
expect(await screen.findByText('Library published successfully')).toBeInTheDocument();
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
});
it('should show error on publish library', async () => {
const url = getCommitLibraryChangesUrl(libraryData.id);
axiosMock.onPost(url).reply(500);
render(<RootWrapper data={libraryData} />);
const publishButton = screen.getByRole('button', { name: /publish/i });
fireEvent.click(publishButton);
expect(await screen.findByText('There was an error publishing the library.')).toBeInTheDocument();
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
});
});

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { Stack } from '@openedx/paragon';
import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import LibraryPublishStatus from './LibraryPublishStatus';
import { ContentLibrary } from '../data/api';
type LibraryInfoProps = {
library: ContentLibrary,
};
const LibraryInfo = ({ library } : LibraryInfoProps) => {
const intl = useIntl();
return (
<Stack direction="vertical" gap={2.5}>
<LibraryPublishStatus library={library} />
<Stack gap={3} direction="vertical">
<span className="font-weight-bold">
{intl.formatMessage(messages.organizationSectionTitle)}
</span>
<span>
{library.org}
</span>
</Stack>
<Stack gap={3}>
<span className="font-weight-bold">
{intl.formatMessage(messages.libraryHistorySectionTitle)}
</span>
<Stack gap={1}>
<span className="small text-gray-500">
{intl.formatMessage(messages.lastModifiedLabel)}
</span>
<span className="small">
<FormattedDate
value={library.updated}
year="numeric"
month="long"
day="2-digit"
/>
</span>
</Stack>
<Stack gap={1}>
<span className="small text-gray-500">
{intl.formatMessage(messages.createdLabel)}
</span>
<span className="small">
<FormattedDate
value={library.created}
year="numeric"
month="long"
day="2-digit"
/>
</span>
</Stack>
</Stack>
</Stack>
);
};
export default LibraryInfo;

View File

@@ -0,0 +1,159 @@
import React from 'react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
render,
screen,
fireEvent,
waitFor,
} from '@testing-library/react';
import { ContentLibrary, getContentLibraryApiUrl } from '../data/api';
import initializeStore from '../../store';
import { ToastProvider } from '../../generic/toast-context';
import LibraryInfoHeader from './LibraryInfoHeader';
let store;
let axiosMock;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const libraryData: ContentLibrary = {
id: 'lib:org1:lib1',
type: 'complex',
org: 'org1',
slug: 'lib1',
title: 'lib1',
description: 'lib1',
numBlocks: 2,
version: 0,
lastPublished: null,
lastDraftCreated: '2024-07-22',
publishedBy: 'staff',
lastDraftCreatedBy: 'staff',
allowLti: false,
allowPublicLearning: false,
allowPublicRead: false,
hasUnpublishedChanges: true,
hasUnpublishedDeletes: false,
canEditLibrary: true,
license: '',
created: '2024-06-26',
updated: '2024-07-20',
};
interface WrapperProps {
data: ContentLibrary,
}
const RootWrapper = ({ data } : WrapperProps) => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<ToastProvider>
<LibraryInfoHeader library={data} />
</ToastProvider>
</QueryClientProvider>
</IntlProvider>
</AppProvider>
);
describe('<LibraryInfoHeader />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
afterEach(() => {
jest.clearAllMocks();
axiosMock.restore();
});
it('should render Library info Header', () => {
render(<RootWrapper data={libraryData} />);
expect(screen.getByText(libraryData.title)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /edit library name/i })).toBeInTheDocument();
});
it('should not render edit title button without permission', () => {
const data = {
...libraryData,
canEditLibrary: false,
};
render(<RootWrapper data={data} />);
expect(screen.queryByRole('button', { name: /edit library name/i })).not.toBeInTheDocument();
});
it('should edit library title', async () => {
queryClient.getQueriesData = jest.fn().mockReturnValue([[null, { id: 1, title: 'Old Title' }]]);
const url = getContentLibraryApiUrl(libraryData.id);
axiosMock.onPatch(url).reply(200);
render(<RootWrapper data={libraryData} />);
const editTitleButton = screen.getByRole('button', { name: /edit library name/i });
fireEvent.click(editTitleButton);
const textBox = screen.getByRole('textbox', { name: /title input/i });
fireEvent.change(textBox, { target: { value: 'New Library Title' } });
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });
expect(textBox).not.toBeInTheDocument();
expect(await screen.findByText('Library updated successfully')).toBeInTheDocument();
await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(url));
});
it('should close edit library title on press Escape', async () => {
const url = getContentLibraryApiUrl(libraryData.id);
axiosMock.onPatch(url).reply(200);
render(<RootWrapper data={libraryData} />);
const editTitleButton = screen.getByRole('button', { name: /edit library name/i });
fireEvent.click(editTitleButton);
const textBox = screen.getByRole('textbox', { name: /title input/i });
fireEvent.keyDown(textBox, { key: 'Escape', code: 'Escape', charCode: 27 });
expect(textBox).not.toBeInTheDocument();
await waitFor(() => expect(axiosMock.history.patch.length).toEqual(0));
});
it('should show error on edit library tittle', async () => {
const url = getContentLibraryApiUrl(libraryData.id);
axiosMock.onPatch(url).reply(500);
render(<RootWrapper data={libraryData} />);
const editTitleButton = screen.getByRole('button', { name: /edit library name/i });
fireEvent.click(editTitleButton);
const textBox = screen.getByRole('textbox', { name: /title input/i });
fireEvent.change(textBox, { target: { value: 'New Library Title' } });
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });
expect(await screen.findByText('There was an error updating the library')).toBeInTheDocument();
await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(url));
});
});

View File

@@ -0,0 +1,86 @@
import React, { useState, useContext } from 'react';
import {
Icon,
IconButton,
Stack,
Form,
} from '@openedx/paragon';
import { Edit } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import { ContentLibrary } from '../data/api';
import { useUpdateLibraryMetadata } from '../data/apiHooks';
import { ToastContext } from '../../generic/toast-context';
type LibraryInfoHeaderProps = {
library: ContentLibrary,
};
const LibraryInfoHeader = ({ library } : LibraryInfoHeaderProps) => {
const intl = useIntl();
const [inputIsActive, setIsActive] = useState(false);
const updateMutation = useUpdateLibraryMetadata();
const { showToast } = useContext(ToastContext);
const handleSaveTitle = (event) => {
const newTitle = event.target.value;
if (newTitle && newTitle !== library.title) {
updateMutation.mutateAsync({
id: library.id,
title: newTitle,
}).then(() => {
showToast(intl.formatMessage(messages.updateLibrarySuccessMsg));
}).catch(() => {
showToast(intl.formatMessage(messages.updateLibraryErrorMsg));
});
}
setIsActive(false);
};
const handleClick = () => {
setIsActive(true);
};
const hanldeOnKeyDown = (event) => {
if (event.key === 'Enter') {
handleSaveTitle(event);
} else if (event.key === 'Escape') {
setIsActive(false);
}
};
return (
<Stack direction="horizontal">
{ inputIsActive
? (
<Form.Control
autoFocus
name="title"
id="title"
type="text"
aria-label="Title input"
defaultValue={library.title}
onBlur={handleSaveTitle}
onKeyDown={hanldeOnKeyDown}
/>
)
: (
<>
<span className="font-weight-bold m-1.5">
{library.title}
</span>
{library.canEditLibrary && (
<IconButton
src={Edit}
iconAs={Icon}
alt={intl.formatMessage(messages.editNameButtonAlt)}
onClick={handleClick}
/>
)}
</>
)}
</Stack>
);
};
export default LibraryInfoHeader;

View File

@@ -0,0 +1,11 @@
.library-publish-status {
&.draft-status {
background-color: #FDF3E9;
border-top: 4px solid #F4B57B;
}
&.published-status {
background-color: $info-100;
border-top: 4px solid $info-400;
}
}

View File

@@ -0,0 +1,171 @@
import React, { useCallback, useContext, useMemo } from 'react';
import classNames from 'classnames';
import { Button, Container, Stack } from '@openedx/paragon';
import { FormattedDate, FormattedTime, useIntl } from '@edx/frontend-platform/i18n';
import { useCommitLibraryChanges } from '../data/apiHooks';
import { ContentLibrary } from '../data/api';
import { ToastContext } from '../../generic/toast-context';
import messages from './messages';
type LibraryPublishStatusProps = {
library: ContentLibrary,
};
const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => {
const intl = useIntl();
const commitLibraryChanges = useCommitLibraryChanges();
const { showToast } = useContext(ToastContext);
const commit = useCallback(() => {
commitLibraryChanges.mutateAsync(library.id)
.then(() => {
showToast(intl.formatMessage(messages.publishSuccessMsg));
}).catch(() => {
showToast(intl.formatMessage(messages.publishErrorMsg));
});
}, []);
/**
* TODO, the discard changes breaks the library.
* Discomment this when discard changes is fixed.
const revert = useCallback(() => {
revertLibraryChanges.mutateAsync(library.id)
.then(() => {
showToast(intl.formatMessage(messages.revertSuccessMsg));
}).catch(() => {
showToast(intl.formatMessage(messages.revertErrorMsg));
});
}, []);
*/
const {
isPublished,
statusMessage,
extraStatusMessage,
bodyMessage,
} = useMemo(() => {
let isPublishedResult: boolean;
let statusMessageResult : string;
let extraStatusMessageResult : string | undefined;
let bodyMessageResult : string | undefined;
const buildDate = ((date : string) => (
<b>
<FormattedDate
value={date}
year="numeric"
month="long"
day="2-digit"
/>
</b>
));
const buildTime = ((date: string) => (
<b>
<FormattedTime
value={date}
hour12={false}
/>
</b>
));
const buildDraftBodyMessage = (() => {
if (library.lastDraftCreatedBy && library.lastDraftCreated) {
return intl.formatMessage(messages.lastDraftMsg, {
date: buildDate(library.lastDraftCreated),
time: buildTime(library.lastDraftCreated),
user: <b>{library.lastDraftCreatedBy}</b>,
});
}
if (library.lastDraftCreated) {
return intl.formatMessage(messages.lastDraftMsgWithoutUser, {
date: buildDate(library.lastDraftCreated),
time: buildTime(library.lastDraftCreated),
});
}
if (library.created) {
return intl.formatMessage(messages.lastDraftMsgWithoutUser, {
date: buildDate(library.created),
time: buildTime(library.created),
});
}
return '';
});
if (!library.lastPublished) {
// Library is never published (new)
isPublishedResult = false;
statusMessageResult = intl.formatMessage(messages.draftStatusLabel);
extraStatusMessageResult = intl.formatMessage(messages.neverPublishedLabel);
bodyMessageResult = buildDraftBodyMessage();
} else if (library.hasUnpublishedChanges || library.hasUnpublishedDeletes) {
// Library is on Draft state
isPublishedResult = false;
statusMessageResult = intl.formatMessage(messages.draftStatusLabel);
extraStatusMessageResult = intl.formatMessage(messages.unpublishedStatusLabel);
bodyMessageResult = buildDraftBodyMessage();
} else {
// Library is published
isPublishedResult = true;
statusMessageResult = intl.formatMessage(messages.publishedStatusLabel);
if (library.publishedBy) {
bodyMessageResult = intl.formatMessage(messages.lastPublishedMsg, {
date: buildDate(library.lastPublished),
time: buildTime(library.lastPublished),
user: <b>{library.publishedBy}</b>,
});
} else {
bodyMessageResult = intl.formatMessage(messages.lastPublishedMsgWithoutUser, {
date: buildDate(library.lastPublished),
time: buildTime(library.lastPublished),
});
}
}
return {
isPublished: isPublishedResult,
statusMessage: statusMessageResult,
extraStatusMessage: extraStatusMessageResult,
bodyMessage: bodyMessageResult,
};
}, [library]);
return (
<Stack>
<Container className={classNames('library-publish-status', {
'draft-status': !isPublished,
'published-status': isPublished,
})}
>
<span className="font-weight-bold">
{statusMessage}
</span>
{ extraStatusMessage && (
<span className="ml-1">
{extraStatusMessage}
</span>
)}
</Container>
<Container className="mt-3">
<Stack gap={3}>
<span>
{bodyMessage}
</span>
<Button disabled={isPublished} onClick={commit}>
{intl.formatMessage(messages.publishButtonLabel)}
</Button>
{ /*
* TODO, the discard changes breaks the library.
* Discomment this when discard changes is fixed.
<div className="d-flex justify-content-end">
<Button disabled={isPublished} variant="link" onClick={revert}>
{intl.formatMessage(messages.discardChangesButtonLabel)}
</Button>
</div>
*/ }
</Stack>
</Container>
</Stack>
);
};
export default LibraryPublishStatus;

View File

@@ -0,0 +1,2 @@
export { default as LibraryInfo } from './LibraryInfo';
export { default as LibraryInfoHeader } from './LibraryInfoHeader';

View File

@@ -0,0 +1,111 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
editNameButtonAlt: {
id: 'course-authoring.library-authoring.sidebar.info.edit-name.alt',
defaultMessage: 'Edit library name',
description: 'Alt text for edit library name icon button',
},
organizationSectionTitle: {
id: 'course-authoring.library-authoring.sidebar.info.organization.title',
defaultMessage: 'Organization',
description: 'Title for Organization section in Library info sidebar.',
},
libraryHistorySectionTitle: {
id: 'course-authoring.library-authoring.sidebar.info.history.title',
defaultMessage: 'Library History',
description: 'Title for Library History section in Library info sidebar.',
},
lastModifiedLabel: {
id: 'course-authoring.library-authoring.sidebar.info.history.last-modified',
defaultMessage: 'Last Modified',
description: 'Last Modified label used in Library History section.',
},
createdLabel: {
id: 'course-authoring.library-authoring.sidebar.info.history.created',
defaultMessage: 'Created',
description: 'Created label used in Library History section.',
},
draftStatusLabel: {
id: 'course-authoring.library-authoring.sidebar.info.publish-status.draft',
defaultMessage: 'Draft',
description: 'Label in library info sidebar when the library is on draft status',
},
neverPublishedLabel: {
id: 'course-authoring.library-authoring.sidebar.info.publish-status.never',
defaultMessage: '(Never Published)',
description: 'Label in library info sidebar when the library is never published',
},
unpublishedStatusLabel: {
id: 'course-authoring.library-authoring.sidebar.info.publish-status.unpublished',
defaultMessage: '(Unpublished Changes)',
description: 'Label in library info sidebar when the library has unpublished changes',
},
publishedStatusLabel: {
id: 'course-authoring.library-authoring.sidebar.info.publish-status.published',
defaultMessage: 'Published',
description: 'Label in library info sidebar when the library is on published status',
},
publishButtonLabel: {
id: 'course-authoring.library-authoring.sidebar.info.publish-status.publish-button',
defaultMessage: 'Publish',
description: 'Label of publish button for a library.',
},
discardChangesButtonLabel: {
id: 'course-authoring.library-authoring.sidebar.info.publish-status.discard-button',
defaultMessage: 'Discard Changes',
description: 'Label of discard changes button for a library.',
},
lastPublishedMsg: {
id: 'course-authoring.library-authoring.sidebar.info.publish-status.last-published',
defaultMessage: 'Last published on {date} at {time} UTC by {user}.',
description: 'Body meesage of the library info sidebar when library is published.',
},
lastPublishedMsgWithoutUser: {
id: 'course-authoring.library-authoring.sidebar.info.publish-status.last-published-no-user',
defaultMessage: 'Last published on {date} at {time} UTC.',
description: 'Body meesage of the library info sidebar when library is published.',
},
lastDraftMsg: {
id: 'course-authoring.library-authoring.sidebar.info.publish-status.last-draft',
defaultMessage: 'Draft saved on {date} at {time} UTC by {user}.',
description: 'Body meesage of the library info sidebar when library is on draft status.',
},
lastDraftMsgWithoutUser: {
id: 'course-authoring.library-authoring.sidebar.info.publish-status.last-draft-no-user',
defaultMessage: 'Draft saved on {date} at {time} UTC.',
description: 'Body meesage of the library info sidebar when library is on draft status.',
},
publishSuccessMsg: {
id: 'course-authoring.library-authoring.publish.success',
defaultMessage: 'Library published successfully',
description: 'Message when the library is published successfully.',
},
publishErrorMsg: {
id: 'course-authoring.library-authoring.publish.error',
defaultMessage: 'There was an error publishing the library.',
description: 'Message when there is an error when publishing the library.',
},
revertSuccessMsg: {
id: 'course-authoring.library-authoring.revert.success',
defaultMessage: 'Library changes reverted successfully',
description: 'Message when the library changes are reverted successfully.',
},
revertErrorMsg: {
id: 'course-authoring.library-authoring.publish.error',
defaultMessage: 'There was an error reverting changes in the library.',
description: 'Message when there is an error when reverting changes in the library.',
},
updateLibrarySuccessMsg: {
id: 'course-authoring.library-authoring.library.update.success',
defaultMessage: 'Library updated successfully',
description: 'Message when the library is updated successfully',
},
updateLibraryErrorMsg: {
id: 'course-authoring.library-authoring.library.update.error',
defaultMessage: 'There was an error updating the library',
description: 'Message when there is an error when updating the library',
},
});
export default messages;

View File

@@ -7,8 +7,14 @@ import {
import { Close } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from '../messages';
import { AddContentContainer } from '../add-content';
import { LibraryContext } from '../common/context';
import { AddContentContainer, AddContentHeader } from '../add-content';
import { LibraryContext, SidebarBodyComponentId } from '../common/context';
import { LibraryInfo, LibraryInfoHeader } from '../library-info';
import { ContentLibrary } from '../data/api';
type LibrarySidebarProps = {
library: ContentLibrary,
};
/**
* Sidebar container for library pages.
@@ -19,23 +25,29 @@ import { LibraryContext } from '../common/context';
* You can add more components in `bodyComponentMap`.
* Use the slice actions to open and close this sidebar.
*/
const LibrarySidebar = () => {
const LibrarySidebar = ({ library }: LibrarySidebarProps) => {
const intl = useIntl();
const { sidebarBodyComponent, closeLibrarySidebar } = useContext(LibraryContext);
const bodyComponentMap = {
'add-content': <AddContentContainer />,
[SidebarBodyComponentId.AddContent]: <AddContentContainer />,
[SidebarBodyComponentId.Info]: <LibraryInfo library={library} />,
unknown: null,
};
const headerComponentMap = {
'add-content': <AddContentHeader />,
info: <LibraryInfoHeader library={library} />,
unknown: null,
};
const buildBody = () : React.ReactNode | null => bodyComponentMap[sidebarBodyComponent || 'unknown'];
const buildHeader = (): React.ReactNode | null => headerComponentMap[sidebarBodyComponent || 'unknown'];
return (
<div className="p-2 vh-100">
<Stack gap={4} className="p-2 vh-100 text-primary-700">
<Stack direction="horizontal" className="d-flex justify-content-between">
<span className="font-weight-bold m-1.5">
{intl.formatMessage(messages.addContentTitle)}
</span>
{buildHeader()}
<IconButton
src={Close}
iconAs={Icon}
@@ -44,8 +56,10 @@ const LibrarySidebar = () => {
variant="black"
/>
</Stack>
{buildBody()}
</div>
<div>
{buildBody()}
</div>
</Stack>
);
};

View File

@@ -100,6 +100,11 @@ const messages = defineMessages({
defaultMessage: 'Close',
description: 'Alt text of close button',
},
libraryInfoButton: {
id: 'course-authoring.library-authoring.buttons.library-info.text',
defaultMessage: 'Library Info',
description: 'Text of button to open "Library Info sidebar"',
},
readOnlyBadge: {
id: 'course-authoring.library-authoring.badge.read-only',
defaultMessage: 'Read Only',