diff --git a/.eslintrc.json b/.eslintrc.json index 6e71b0e28..c431511c0 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -2,13 +2,15 @@ "env": { "browser": true, "commonjs": false, - "es6": false, + "es2020": true, "amd": true }, "extends": [ "standard" ], "globals": { + "ReactDOM": true, + "React": true, "Promise": true, "requirejs": true, "Backbone": true, @@ -39,4 +41,4 @@ "requirejs/no-commonjs-wrapper": 2, "requirejs/no-object-define": 1 } -} \ No newline at end of file +} diff --git a/grunt/config/javascript.js b/grunt/config/javascript.js index 8e5e5b4bf..33b3550d9 100644 --- a/grunt/config/javascript.js +++ b/grunt/config/javascript.js @@ -19,6 +19,17 @@ module.exports = function(grunt, options) { return grunt.config('helpers').includedFilter(filepath); }, umdImports: [ + '../node_modules/object.assign/dist/browser.js', + '../node_modules/react/umd/react.development.js', + '../node_modules/react-dom/umd/react-dom.development.js', + '../node_modules/html-react-parser/dist/html-react-parser.min.js' + ], + reactTemplates: [ + '<%= sourcedir %>core/templates/**/*.jsx', + '<%= sourcedir %>components/*/templates/**/*.jsx', + '<%= sourcedir %>extensions/*/templates/**/*.jsx', + '<%= sourcedir %>menu/*/templates/**/*.jsx', + '<%= sourcedir %>theme/*/templates/**/*.jsx' ], external: { jquery: 'empty:', @@ -74,6 +85,17 @@ module.exports = function(grunt, options) { return grunt.config('helpers').includedFilter(filepath); }, umdImports: [ + '../node_modules/object.assign/dist/browser.js', + '../node_modules/react/umd/react.production.min.js', + '../node_modules/react-dom/umd/react-dom.production.min.js', + '../node_modules/html-react-parser/dist/html-react-parser.min.js' + ], + reactTemplates: [ + '<%= sourcedir %>core/templates/**/*.jsx', + '<%= sourcedir %>components/*/templates/**/*.jsx', + '<%= sourcedir %>extensions/*/templates/**/*.jsx', + '<%= sourcedir %>menu/*/templates/**/*.jsx', + '<%= sourcedir %>theme/*/templates/**/*.jsx' ], external: { jquery: 'empty:', diff --git a/grunt/config/watch.js b/grunt/config/watch.js index db090622e..dd55fd42a 100644 --- a/grunt/config/watch.js +++ b/grunt/config/watch.js @@ -25,7 +25,7 @@ module.exports = { tasks: ['newer:copy:courseAssets'] }, js: { - files: ['<%= sourcedir %>**/*.js'], + files: ['<%= sourcedir %>**/*.js', '<%= sourcedir %>**/*.jsx'], options: { spawn: false }, diff --git a/grunt/tasks/javascript.js b/grunt/tasks/javascript.js index 98f3d6290..7f47f3f00 100644 --- a/grunt/tasks/javascript.js +++ b/grunt/tasks/javascript.js @@ -16,7 +16,7 @@ module.exports = function(grunt) { const isDisableCache = process.argv.includes('--disable-cache'); let cache; - const extensions = ['.js']; + const extensions = ['.js', '.jsx']; const restoreCache = async (cachePath, basePath) => { if (isDisableCache || cache || !fs.existsSync(cachePath)) return; @@ -161,6 +161,14 @@ module.exports = function(grunt) { }); } + // Collect react templates + const reactTemplatePaths = []; + options.reactTemplates.forEach(pattern => { + grunt.file.expand({ + filter: options.pluginsFilter + }, pattern).forEach(templatePath => reactTemplatePaths.push(templatePath.replace(convertSlashes, '/'))); + }); + // Process remapping and external model configurations const mapParts = Object.keys(options.map); const externalParts = Object.keys(options.external); @@ -257,6 +265,8 @@ module.exports = function(grunt) { // Dynamically construct plugins.js with plugin dependencies code = `define([${pluginPaths.map(filename => { return `"${filename}"`; + }).join(',')}, ${reactTemplatePaths.map(filename => { + return `"${filename}"`; }).join(',')}], function() {});`; return code; } @@ -280,6 +290,12 @@ module.exports = function(grunt) { '**/node_modules/**' ], presets: [ + [ + '@babel/preset-react', + { + runtime: 'classic' + } + ], [ '@babel/preset-env', { @@ -302,7 +318,20 @@ module.exports = function(grunt) { ignoreNestedRequires: true, defineFunctionName: '__AMD', defineModuleId: (moduleId) => moduleId.replace(convertSlashes, '/').replace(basePath, '').replace('.js', ''), - excludes: [] + excludes: [ + '**/templates/**/*.jsx' + ] + } + ], + [ + 'transform-react-templates', + { + includes: [ + '**/templates/**/*.jsx' + ], + importRegisterFunctionFromModule: path.resolve(basePath, 'core/js/reactHelpers.js').replace(convertSlashes, '/'), + registerFunctionName: 'register', + registerTemplateName: (moduleId) => path.parse(moduleId).name } ] ] diff --git a/package-lock.json b/package-lock.json index 16114cc93..092c17341 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,83 @@ "@babel/types": "^7.10.1" } }, + "@babel/helper-builder-react-jsx": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-react-jsx/-/helper-builder-react-jsx-7.10.4.tgz", + "integrity": "sha512-5nPcIZ7+KKDxT1427oBivl9V9YTal7qk0diccnh7RrcgrT/pGFOjgGw1dgryyx1GvHEpXVfoDF6Ak3rTiWh8Rg==", + "requires": { + "@babel/helper-annotate-as-pure": "^7.10.4", + "@babel/types": "^7.10.4" + }, + "dependencies": { + "@babel/helper-annotate-as-pure": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz", + "integrity": "sha512-XQlqKQP4vXFB7BN8fEEerrmYvHp3fK/rBkRFz9jaJbzK0B1DSfej9Kc7ZzE8Z/OnId1jpJdNAZ3BFQjWG68rcA==", + "requires": { + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz", + "integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==" + }, + "@babel/types": { + "version": "7.12.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.12.6.tgz", + "integrity": "sha512-hwyjw6GvjBLiyy3W0YQf0Z5Zf4NpYejUnKFcfcUhZCSffoBBp30w6wP2Wn6pk31jMYZvcOrB/1b7cGXvEoKogA==", + "requires": { + "@babel/helper-validator-identifier": "^7.10.4", + "lodash": "^4.17.19", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-builder-react-jsx-experimental": { + "version": "7.12.4", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-react-jsx-experimental/-/helper-builder-react-jsx-experimental-7.12.4.tgz", + "integrity": "sha512-AjEa0jrQqNk7eDQOo0pTfUOwQBMF+xVqrausQwT9/rTKy0g04ggFNaJpaE09IQMn9yExluigWMJcj0WC7bq+Og==", + "requires": { + "@babel/helper-annotate-as-pure": "^7.10.4", + "@babel/helper-module-imports": "^7.12.1", + "@babel/types": "^7.12.1" + }, + "dependencies": { + "@babel/helper-annotate-as-pure": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz", + "integrity": "sha512-XQlqKQP4vXFB7BN8fEEerrmYvHp3fK/rBkRFz9jaJbzK0B1DSfej9Kc7ZzE8Z/OnId1jpJdNAZ3BFQjWG68rcA==", + "requires": { + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-module-imports": { + "version": "7.12.5", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.12.5.tgz", + "integrity": "sha512-SR713Ogqg6++uexFRORf/+nPXMmWIn80TALu0uaFb+iQIUoR7bOC7zBWyzBs5b3tBBJXuyD0cRu1F15GyzjOWA==", + "requires": { + "@babel/types": "^7.12.5" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz", + "integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==" + }, + "@babel/types": { + "version": "7.12.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.12.6.tgz", + "integrity": "sha512-hwyjw6GvjBLiyy3W0YQf0Z5Zf4NpYejUnKFcfcUhZCSffoBBp30w6wP2Wn6pk31jMYZvcOrB/1b7cGXvEoKogA==", + "requires": { + "@babel/helper-validator-identifier": "^7.10.4", + "lodash": "^4.17.19", + "to-fast-properties": "^2.0.0" + } + } + } + }, "@babel/helper-compilation-targets": { "version": "7.10.2", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.10.2.tgz", @@ -418,6 +495,21 @@ "@babel/helper-plugin-utils": "^7.8.0" } }, + "@babel/plugin-syntax-jsx": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz", + "integrity": "sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg==", + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==" + } + } + }, "@babel/plugin-syntax-nullish-coalescing-operator": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", @@ -674,6 +766,125 @@ "@babel/helper-plugin-utils": "^7.10.1" } }, + "@babel/plugin-transform-react-display-name": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.12.1.tgz", + "integrity": "sha512-cAzB+UzBIrekfYxyLlFqf/OagTvHLcVBb5vpouzkYkBclRPraiygVnafvAoipErZLI8ANv8Ecn6E/m5qPXD26w==", + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==" + } + } + }, + "@babel/plugin-transform-react-jsx": { + "version": "7.12.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.12.5.tgz", + "integrity": "sha512-2xkcPqqrYiOQgSlM/iwto1paPijjsDbUynN13tI6bosDz/jOW3CRzYguIE8wKX32h+msbBM22Dv5fwrFkUOZjQ==", + "requires": { + "@babel/helper-builder-react-jsx": "^7.10.4", + "@babel/helper-builder-react-jsx-experimental": "^7.12.1", + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/plugin-syntax-jsx": "^7.12.1" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==" + } + } + }, + "@babel/plugin-transform-react-jsx-development": { + "version": "7.12.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.12.5.tgz", + "integrity": "sha512-1JJusg3iPgsZDthyWiCr3KQiGs31ikU/mSf2N2dSYEAO0GEImmVUbWf0VoSDGDFTAn5Dj4DUiR6SdIXHY7tELA==", + "requires": { + "@babel/helper-builder-react-jsx-experimental": "^7.12.1", + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/plugin-syntax-jsx": "^7.12.1" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==" + } + } + }, + "@babel/plugin-transform-react-jsx-self": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.12.1.tgz", + "integrity": "sha512-FbpL0ieNWiiBB5tCldX17EtXgmzeEZjFrix72rQYeq9X6nUK38HCaxexzVQrZWXanxKJPKVVIU37gFjEQYkPkA==", + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==" + } + } + }, + "@babel/plugin-transform-react-jsx-source": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.12.1.tgz", + "integrity": "sha512-keQ5kBfjJNRc6zZN1/nVHCd6LLIHq4aUKcVnvE/2l+ZZROSbqoiGFRtT5t3Is89XJxBQaP7NLZX2jgGHdZvvFQ==", + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==" + } + } + }, + "@babel/plugin-transform-react-pure-annotations": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.12.1.tgz", + "integrity": "sha512-RqeaHiwZtphSIUZ5I85PEH19LOSzxfuEazoY7/pWASCAIBuATQzpSVD+eT6MebeeZT2F4eSL0u4vw6n4Nm0Mjg==", + "requires": { + "@babel/helper-annotate-as-pure": "^7.10.4", + "@babel/helper-plugin-utils": "^7.10.4" + }, + "dependencies": { + "@babel/helper-annotate-as-pure": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz", + "integrity": "sha512-XQlqKQP4vXFB7BN8fEEerrmYvHp3fK/rBkRFz9jaJbzK0B1DSfej9Kc7ZzE8Z/OnId1jpJdNAZ3BFQjWG68rcA==", + "requires": { + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==" + }, + "@babel/helper-validator-identifier": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz", + "integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==" + }, + "@babel/types": { + "version": "7.12.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.12.6.tgz", + "integrity": "sha512-hwyjw6GvjBLiyy3W0YQf0Z5Zf4NpYejUnKFcfcUhZCSffoBBp30w6wP2Wn6pk31jMYZvcOrB/1b7cGXvEoKogA==", + "requires": { + "@babel/helper-validator-identifier": "^7.10.4", + "lodash": "^4.17.19", + "to-fast-properties": "^2.0.0" + } + } + } + }, "@babel/plugin-transform-regenerator": { "version": "7.10.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.10.1.tgz", @@ -832,6 +1043,27 @@ "esutils": "^2.0.2" } }, + "@babel/preset-react": { + "version": "7.12.5", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.12.5.tgz", + "integrity": "sha512-jcs++VPrgyFehkMezHtezS2BpnUlR7tQFAyesJn1vGTO9aTFZrgIQrA5YydlTwxbcjMwkFY6i04flCigRRr3GA==", + "requires": { + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/plugin-transform-react-display-name": "^7.12.1", + "@babel/plugin-transform-react-jsx": "^7.12.5", + "@babel/plugin-transform-react-jsx-development": "^7.12.5", + "@babel/plugin-transform-react-jsx-self": "^7.12.1", + "@babel/plugin-transform-react-jsx-source": "^7.12.1", + "@babel/plugin-transform-react-pure-annotations": "^7.12.1" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==" + } + } + }, "@babel/runtime": { "version": "7.10.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.2.tgz", @@ -981,6 +1213,16 @@ "@types/node": "*" } }, + "@types/htmlparser2": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@types/htmlparser2/-/htmlparser2-3.10.1.tgz", + "integrity": "sha512-fCxmHS4ryCUCfV9+CJZY1UjkbR+6Al/EQdX5Jh03qBj9gdlPG5q+7uNoDgE/ZNXb3XNWSAQgqKIWnbRCbOyyWA==", + "requires": { + "@types/domhandler": "*", + "@types/domutils": "*", + "@types/node": "*" + } + }, "@types/jquery": { "version": "3.3.38", "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.3.38.tgz", @@ -2313,6 +2555,14 @@ "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", "optional": true }, + "domhandler": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", + "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", + "requires": { + "domelementtype": "1" + } + }, "domutils": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", @@ -3857,6 +4107,57 @@ "integrity": "sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ==", "optional": true }, + "html-dom-parser": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/html-dom-parser/-/html-dom-parser-0.3.0.tgz", + "integrity": "sha512-WDEYpO5gHGKuJbf0rwndGq7yUHJ4xboNj9l9mRGw5RsKFc3jfRozCsGAMu69zXxt4Ol8UkbqubKxu8ys0BLKtA==", + "requires": { + "@types/domhandler": "2.4.1", + "domhandler": "2.4.2", + "htmlparser2": "3.10.1" + } + }, + "html-react-parser": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-0.13.0.tgz", + "integrity": "sha512-hU94hE2p9xhMM61EOoiY3Kr+DfzH/uY7hGeVXQpGFRjgbYRUeyuSKORDNMIaY8IAcuHQ6Ov9pJ3x94Wvso/OmQ==", + "requires": { + "@types/htmlparser2": "3.10.1", + "html-dom-parser": "0.3.0", + "react-property": "1.0.1", + "style-to-object": "0.3.0" + } + }, + "htmlparser2": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", + "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==", + "requires": { + "domelementtype": "^1.3.1", + "domhandler": "^2.3.0", + "domutils": "^1.5.1", + "entities": "^1.1.1", + "inherits": "^2.0.1", + "readable-stream": "^3.1.1" + }, + "dependencies": { + "entities": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", + "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==" + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, "http-cache-semantics": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-3.8.1.tgz", @@ -4152,6 +4453,11 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, + "inline-style-parser": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", + "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==" + }, "inquirer": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.2.0.tgz", @@ -5734,6 +6040,16 @@ "asap": "~2.0.3" } }, + "prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + } + }, "proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -5828,6 +6144,37 @@ } } }, + "react": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", + "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2" + } + }, + "react-dom": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz", + "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.19.1" + } + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "react-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/react-property/-/react-property-1.0.1.tgz", + "integrity": "sha512-1tKOwxFn3dXVomH6pM9IkLkq2Y8oh+fh/lYW3MJ/B03URswUTqttgckOlbxY2XHF3vPG6uanSc4dVsLW/wk3wQ==" + }, "read-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", @@ -6126,6 +6473,15 @@ "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", "optional": true }, + "scheduler": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", + "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, "seek-bzip": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.6.tgz", @@ -6589,6 +6945,14 @@ "escape-string-regexp": "^1.0.2" } }, + "style-to-object": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz", + "integrity": "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==", + "requires": { + "inline-style-parser": "0.1.1" + } + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", diff --git a/package.json b/package.json index 4392c2aa4..527c91a82 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,15 @@ }, "dependencies": { "@babel/core": "^7.8.4", + "@babel/plugin-transform-react-jsx": "^7.10.4", "@babel/preset-env": "^7.8.4", + "@babel/preset-react": "^7.10.4", "@rollup/plugin-babel": "^5.0.4", "@types/backbone": "^1.4.1", "@types/jquery": "^3.3.31", "async": "^3.1.1", "babel-plugin-transform-amd-to-es6": "^0.4.0", + "babel-plugin-transform-react-templates": "^0.1.0", "chalk": "^2.4.1", "columnify": "^1.5.4", "csv": "^5.0.0", @@ -40,6 +43,7 @@ "grunt-newer": "^1.3.0", "grunt-replace": "^1.0.1", "handlebars": "^4.0.12", + "html-react-parser": "^0.13.0", "iconv-lite": "^0.4.24", "jit-grunt": "^0.10.0", "jschardet": "^1.6.0", @@ -48,6 +52,8 @@ "load-grunt-config": "^1.0.1", "lodash": "^4.17.19", "nsdeclare": "^0.1.0", + "react": "^16.13.1", + "react-dom": "^16.13.1", "rollup": "^2.18.1", "time-grunt": "^2.0.0", "underscore": "^1.9.1", diff --git a/src/core/js/fixes/img.lazyload.js b/src/core/js/fixes/img.lazyload.js index c6b975707..29fd63bde 100644 --- a/src/core/js/fixes/img.lazyload.js +++ b/src/core/js/fixes/img.lazyload.js @@ -1,5 +1,6 @@ import Adapt from 'core/js/adapt'; import 'core/js/templates'; +import { find, clone } from 'core/js/reactHelpers'; /** * 27 April 2020 https://github.com/adaptlearning/adapt_framework/issues/2734 @@ -31,4 +32,18 @@ function applyImgLoadingFix() { return value.replace(img, img.replace(findImgTag, '')); }, event.value); }); + Adapt.on('reactTemplate:postRender', function(event) { + const hasImageTagWithNoLoadingAttr = find(event.value, component => { + if (component.type !== 'img') return; + if (component.props.loading) return; + return true; + }); + if (!hasImageTagWithNoLoadingAttr) return; + // Strip object freeze and write locks by cloning + event.value = clone(event.value, true, component => { + if (component.type !== 'img') return; + if (component.props.loading) return; + component.props.loading = 'eager'; + }); + }); } diff --git a/src/core/js/reactHelpers.js b/src/core/js/reactHelpers.js new file mode 100644 index 000000000..dca13b672 --- /dev/null +++ b/src/core/js/reactHelpers.js @@ -0,0 +1,148 @@ +import Adapt from 'core/js/adapt'; +import TemplateRenderEvent from './templateRenderEvent'; + +/** + * Finds a node in a react node hierarchy + * Return true from the iterator to stop traversal + * @param {object} hierarchy + * @param {function} iterator + */ +export function find(hierarchy, iterator) { + if (iterator(hierarchy)) { + return true; + } + if (!hierarchy.props || !hierarchy.props.children) return; + if (Array.isArray(hierarchy.props.children)) { + return hierarchy.props.children.find(child => { + if (!child) return; + return find(child, iterator); + }); + } + return find(hierarchy.props.children, iterator); +}; + +/** + * Allows clone and modification of a react node hierarchy + * @param {*} value + * @param {boolean} isDeep=false + * @param {function} modifier + * @returns {*} + */ +export function clone(value, isDeep = false, modifier = null) { + if (typeof value !== 'object' || value === null) { + return value; + } + const cloned = Array.isArray(value) ? [] : {}; + const descriptors = Object.getOwnPropertyDescriptors(value); + for (let name in descriptors) { + const descriptor = descriptors[name]; + if (!descriptor.hasOwnProperty('value')) { + Object.defineProperty(cloned, name, descriptor); + continue; + } + let value = descriptor.value; + if (typeof value === 'object' && value !== null) { + if (isDeep) { + value = descriptor.value = clone(value, isDeep, modifier); + } + if (modifier && typeof value.$$typeof === 'symbol') { + modifier(value); + } + } + descriptor.writable = true; + Object.defineProperty(cloned, name, descriptor); + } + if (modifier && typeof cloned.$$typeof === 'symbol') { + modifier(cloned); + } + return cloned; +}; + +/** + * Used by babel plugin babel-plugin-transform-react-templates to inject react templates + */ +export default function register(name, component) { + templates[name] = (...args) => { + // Trap render calls to emit preRender and postRender events + const mode = 'reactTemplate'; + // Send preRender event to allow modification of args + const preRenderEvent = new TemplateRenderEvent(`${mode}:preRender`, name, mode, null, args); + Adapt.trigger(preRenderEvent.type, preRenderEvent); + // Execute template + const value = component(...preRenderEvent.args); + // Send postRender event to allow modification of rendered template + const postRenderEvent = new TemplateRenderEvent(`${mode}:postRender`, name, mode, value, preRenderEvent.args); + Adapt.trigger(postRenderEvent.type, postRenderEvent); + // Return rendered, modified template + return postRenderEvent.value; + }; +}; + +/** + * Storage for react templates + */ +export const templates = {}; + +/** + * Convert html strings to react dom, equivalent to handlebars {{{html}}} + * @param {string} html + */ +export function html(html, ref = null) { + if (!html) return; + let node = html ? window.HTMLReactParser(html) : ''; + if (typeof node === 'object' && ref) { + // Strip object freeze and write locks by cloning + node = clone(node); + node.ref = ref; + } + return node; +} + +/** + * Render the named react component + * @param {string} name React template name + * @param {...any} args React template arguments + */ +export function render(name, ...args) { + const template = templates[name]; + const component = template(...args); + return component; +}; + +/** + * Handlebars compile integration + * @param {string} name Handlebars template + * @param {...any} args Template arguments + */ +export function compile(template, ...args) { + const output = Handlebars.compile(template)(...args); + return output; +}; + +/** + * Handlebars partials integration + * @param {string} name Partial name + * @param {...any} args Partial arguments + */ +export function partial(name, ...args) { + const output = Handlebars.partials[name](...args); + return output; +}; + +/** + * Handlebars helpers integration + * @param {string} name Helper name + * @param {...any} args Helper arguments + */ +export function helper(name, ...args) { + const output = Handlebars.helpers[name].call(args[0]); + return (output && output.string) || output; +}; + +/** + * Helper for a list of classes, filtering out falsies and joining with spaces + * @param {...any} args List or arrays of classes + */ +export function classes(...args) { + return _.flatten(args).filter(Boolean).join(' '); +}; diff --git a/src/core/js/views/adaptView.js b/src/core/js/views/adaptView.js index 8d23a2ef1..ae77f165e 100644 --- a/src/core/js/views/adaptView.js +++ b/src/core/js/views/adaptView.js @@ -1,5 +1,6 @@ import Adapt from 'core/js/adapt'; import ChildEvent from 'core/js/childEvent'; +import { render } from 'core/js/reactHelpers'; class AdaptView extends Backbone.View { @@ -15,6 +16,13 @@ class AdaptView extends Backbone.View { 'change:_isHidden': this.toggleHidden, 'change:_isComplete': this.onIsCompleteChange }); + this.isReact = (this.constructor.template || '').includes('.jsx'); + if (this.isReact) { + this._classSet = new Set(_.result(this, 'className').trim().split(/\s+/)); + this.listenTo(this.model, 'all', this.changed); + // Facilitate adaptive react views + this.listenTo(Adapt, 'device:changed', this.changed); + } this.model.set({ '_globals': Adapt.course.get('_globals'), '_isReady': false @@ -40,10 +48,14 @@ class AdaptView extends Backbone.View { const type = this.constructor.type; Adapt.trigger(`${type}View:preRender view:preRender`, this); - const data = this.model.toJSON(); - data.view = this; - const template = Handlebars.templates[this.constructor.template]; - this.$el.html(template(data)); + if (this.isReact) { + this.changed(); + } else { + const data = this.model.toJSON(); + data.view = this; + const template = Handlebars.templates[this.constructor.template]; + this.$el.html(template(data)); + } Adapt.trigger(`${type}View:render view:render`, this); @@ -58,6 +70,32 @@ class AdaptView extends Backbone.View { return this; } + /** + * Re-render a react template + * @param {string} eventName=null Backbone change event name + */ + changed(eventName = null) { + if (!this.isReact) { + throw new Error('Cannot call changed on a non-react view'); + } + if (typeof eventName === 'string' && eventName.startsWith('bubble')) { + // Ignore bubbling events as they are outside of this view's scope + return; + } + const element = render(this.constructor.template.replace('.jsx', ''), this.model, this); + this.updateViewProperties(); + ReactDOM.render(element, this.el); + } + + updateViewProperties() { + const classesToAdd = _.result(this, 'className').trim().split(/\s+/); + classesToAdd.forEach(i => this._classSet.add(i)); + const classesToRemove = [ ...this._classSet ].filter(i => !classesToAdd.includes(i)); + classesToRemove.forEach(i => this._classSet.delete(i)); + this._setAttributes({ ..._.result(this, 'attributes'), id: _.result(this, 'id') }); + this.$el.removeClass(classesToRemove).addClass(classesToAdd); + } + setupOnScreenHandler() { const onscreen = this.model.get('_onScreen'); @@ -289,6 +327,9 @@ class AdaptView extends Backbone.View { this.stopListening(); Adapt.wait.for(end => { + if (this.isReact) { + ReactDOM.unmountComponentAtNode(this.el); + } this.$el.off('onscreen.adaptView'); super.remove(); _.defer(() => { @@ -321,10 +362,12 @@ class AdaptView extends Backbone.View { } /** - * @returns {[AdaptViews]} + * @returns {[AdaptView]} */ getChildViews() { - return this._childViews; + if (!this._childViews) return this._childViews; + // Allow both a deprecated id/view map or a new array of child views + return Object.entries(this._childViews).map(([key, value]) => value); } /** @@ -336,12 +379,24 @@ class AdaptView extends Backbone.View { /** * Returns an indexed by id list of child views. - * @deprecated since 0.5.5 + * @deprecated since 5.7.0 * @returns {{ view.model.get('_id')); + Adapt.log.deprecated(`view.childViews use view.getChildViews() and view.setChildViews([])`); + if (Array.isArray(this._childViews)) { + return _.indexBy(this._childViews, view => view.model.get('_id')); + } + return this._childViews; + } + + /** + * Sets an indexed by id list of child views. + * @deprecated since 5.7.0 + */ + set childViews(value) { + Adapt.log.deprecated(`view.childViews use view.getChildViews() and view.setChildViews([])`); + this.setChildViews(value); } } diff --git a/src/core/js/views/contentObjectView.js b/src/core/js/views/contentObjectView.js index d8973402f..2e0b99e39 100644 --- a/src/core/js/views/contentObjectView.js +++ b/src/core/js/views/contentObjectView.js @@ -34,10 +34,14 @@ export default class ContentObjectView extends AdaptView { const type = this.constructor.type; Adapt.trigger(`${type}View:preRender contentObjectView:preRender view:preRender`, this); - const data = this.model.toJSON(); - data.view = this; - const template = Handlebars.templates[this.constructor.template]; - this.$el.html(template(data)); + if (this.isReact) { + this.changed(); + } else { + const data = this.model.toJSON(); + data.view = this; + const template = Handlebars.templates[this.constructor.template]; + this.$el.html(template(data)); + } Adapt.trigger(`${type}View:render contentObjectView:render view:render`, this); @@ -137,6 +141,9 @@ export default class ContentObjectView extends AdaptView { this._isRemoved = true; Adapt.wait.for(end => { + if (this.isReact) { + ReactDOM.unmountComponentAtNode(this.el); + } this.$el.off('onscreen.adaptView'); this.findDescendantViews().reverse().forEach(view => { view.remove(); diff --git a/src/core/templates/partials/component.hbs b/src/core/templates/partials/component.hbs index a94473625..b5e4fde1c 100644 --- a/src/core/templates/partials/component.hbs +++ b/src/core/templates/partials/component.hbs @@ -13,7 +13,7 @@ {{~/unless~}} diff --git a/src/core/templates/partials/component.jsx b/src/core/templates/partials/component.jsx new file mode 100644 index 000000000..c89ef1dc7 --- /dev/null +++ b/src/core/templates/partials/component.jsx @@ -0,0 +1,65 @@ +import Adapt from 'core/js/adapt'; +import { + compile, + classes, + helper, + html +} from 'core/js/reactHelpers'; + +export default function(model, view) { + const data = model.toJSON(); + data._globals = Adapt.course.get('_globals'); + // Create references to un-controlled view containers + view.jsxHeading = view.jsxHeading || React.createRef(); + view.jsxComponentDescription = view.jsxComponentDescription || React.createRef(); + const { + displayTitle, + body, + instruction, + mobileInstruction, + _component, + _disableAccessibilityState + } = data; + const type = _component.toLowerCase(); + const sizedInstruction = (mobileInstruction && Adapt.device.screenSize !== 'large') ? + mobileInstruction : + instruction; + return (displayTitle || body || sizedInstruction) && ( +
+
+ {displayTitle && +
+ + {!_disableAccessibilityState && +
+ } + +
+ {html(compile(displayTitle, data))} +
+ +
+ } + + {html(helper('component_description', data), view.jsxComponentDescription)} + + {body && +
+
+ {html(compile(body, data))} +
+
+ } + + {sizedInstruction && +
+
+ {html(compile(sizedInstruction, data))} +
+
+ } + +
+
+ ); +}