diff --git a/.env.example b/.env.example index ecfdf7b51c..02c73e6d2f 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ -REACT_APP_LUMINUS_CLIENT_ID=your_luminus_client_id_here +REACT_APP_LUMINUS_CLIENT_ID=nus_apps REACT_APP_VERSION=$npm_package_version REACT_APP_BACKEND_URL=http://localhost:4001 REACT_APP_USE_BACKEND=TRUE REACT_APP_CHATKIT_INSTANCE_LOCATOR=instance_locator_here_otherwise_empty_string -MODULE_BACKEND_URL=http://ec2-54-169-81-133.ap-southeast-1.compute.amazonaws.com \ No newline at end of file +MODULE_BACKEND_URL=http://ec2-54-169-81-133.ap-southeast-1.compute.amazonaws.com diff --git a/package-lock.json b/package-lock.json index 34a715bdcf..4428460d01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -157,6 +157,155 @@ } } }, + "@blueprintjs/datetime": { + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/@blueprintjs/datetime/-/datetime-3.15.2.tgz", + "integrity": "sha512-FQw1BqbO9RBKzLWiXHkSVFxyGFRXHaugG5ST4go+p2IibrxuRDjD6YvrFXo+FLEzi+MsftMo6FkPNm2xApfmHw==", + "requires": { + "@blueprintjs/core": "^3.23.0", + "classnames": "^2.2", + "react-day-picker": "7.3.2", + "tslib": "~1.9.0" + }, + "dependencies": { + "@blueprintjs/core": { + "version": "3.24.0", + "resolved": "https://registry.npmjs.org/@blueprintjs/core/-/core-3.24.0.tgz", + "integrity": "sha512-qW29DDPjzYsT27J6n97C0jZ1ifvEEziwNC98UhaKdSE7I8qxbLsb+ft2JOop+pEX4ab67T1lhQKAiQjWCPKZng==", + "requires": { + "@blueprintjs/icons": "^3.14.0", + "@types/dom4": "^2.0.1", + "classnames": "^2.2", + "dom4": "^2.1.5", + "normalize.css": "^8.0.1", + "popper.js": "^1.15.0", + "react-lifecycles-compat": "^3.0.4", + "react-popper": "^1.3.7", + "react-transition-group": "^2.9.0", + "resize-observer-polyfill": "^1.5.1", + "tslib": "~1.10.0" + }, + "dependencies": { + "tslib": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz", + "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==" + } + } + }, + "@blueprintjs/icons": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@blueprintjs/icons/-/icons-3.14.0.tgz", + "integrity": "sha512-cvQ3CSdy0DqVqcXcPqSxoycJw497TVP5goyE6xCFlVs84477ahxh7Uung6J+CCoDVBuI87h576LtuyjwSxorvQ==", + "requires": { + "classnames": "^2.2", + "tslib": "~1.10.0" + }, + "dependencies": { + "tslib": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz", + "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==" + } + } + }, + "create-react-context": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/create-react-context/-/create-react-context-0.3.0.tgz", + "integrity": "sha512-dNldIoSuNSvlTJ7slIKC/ZFGKexBMBrrcc+TTe1NdmROnaASuLPvqpwj9v4XS4uXZ8+YPu0sNmShX2rXI5LNsw==", + "requires": { + "gud": "^1.0.0", + "warning": "^4.0.3" + }, + "dependencies": { + "warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "requires": { + "loose-envify": "^1.0.0" + } + } + } + }, + "deep-equal": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", + "integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==", + "requires": { + "is-arguments": "^1.0.4", + "is-date-object": "^1.0.1", + "is-regex": "^1.0.4", + "object-is": "^1.0.1", + "object-keys": "^1.1.1", + "regexp.prototype.flags": "^1.2.0" + } + }, + "dom-helpers": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", + "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", + "requires": { + "@babel/runtime": "^7.1.2" + } + }, + "normalize.css": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/normalize.css/-/normalize.css-8.0.1.tgz", + "integrity": "sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg==" + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" + }, + "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" + } + }, + "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-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-nmqYTx7QVjCm3WUZLeuOomna138R1luC4EqkW3hxJUrAe+3eNz3oFCLYdnPwILfn0mX1Ew2c3wctrjlUMYYUww==", + "requires": { + "@babel/runtime": "^7.1.2", + "create-react-context": "^0.3.0", + "deep-equal": "^1.1.1", + "popper.js": "^1.14.4", + "prop-types": "^15.6.1", + "typed-styles": "^0.0.7", + "warning": "^4.0.2" + } + }, + "react-transition-group": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz", + "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==", + "requires": { + "dom-helpers": "^3.4.0", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2", + "react-lifecycles-compat": "^3.0.4" + } + }, + "tslib": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz", + "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==" + } + } + }, "@blueprintjs/icons": { "version": "3.9.1", "resolved": "https://registry.npmjs.org/@blueprintjs/icons/-/icons-3.9.1.tgz", @@ -921,8 +1070,7 @@ "@types/dom4": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/dom4/-/dom4-2.0.1.tgz", - "integrity": "sha512-kSkVAvWmMZiCYtvqjqQEwOmvKwcH+V4uiv3qPQ8pAh1Xl39xggGEo8gHUqV4waYGHezdFw0rKBR8Jt0CrQSDZA==", - "dev": true + "integrity": "sha512-kSkVAvWmMZiCYtvqjqQEwOmvKwcH+V4uiv3qPQ8pAh1Xl39xggGEo8gHUqV4waYGHezdFw0rKBR8Jt0CrQSDZA==" }, "@types/dotenv": { "version": "6.1.1", @@ -4793,6 +4941,12 @@ "integrity": "sha1-yBSQPkViM3GgR3tAEJqq++6t27Q=", "dev": true }, + "cssfontparser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz", + "integrity": "sha1-9AIvyPlwDGgCnVQghK+69CWj8+M=", + "dev": true + }, "cssnano": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-3.10.0.tgz", @@ -5338,8 +5492,7 @@ "dom4": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/dom4/-/dom4-2.1.5.tgz", - "integrity": "sha512-gJbnVGq5zaBUY0lUh0LUEVGYrtN75Ks8ZwpwOYvnVFrKy/qzXK4R/1WuLIFExWj/tBxbRAkTzZUGJHXmqsBNjQ==", - "dev": true + "integrity": "sha512-gJbnVGq5zaBUY0lUh0LUEVGYrtN75Ks8ZwpwOYvnVFrKy/qzXK4R/1WuLIFExWj/tBxbRAkTzZUGJHXmqsBNjQ==" }, "domain-browser": { "version": "1.2.0", @@ -7925,8 +8078,7 @@ "gud": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz", - "integrity": "sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw==", - "dev": true + "integrity": "sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw==" }, "gzip-size": { "version": "3.0.0", @@ -8015,8 +8167,7 @@ "has-symbols": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz", - "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=", - "dev": true + "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=" }, "has-unicode": { "version": "2.0.1", @@ -8998,8 +9149,7 @@ "is-arguments": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.0.4.tgz", - "integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==", - "dev": true + "integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==" }, "is-arrayish": { "version": "0.2.1", @@ -9801,6 +9951,16 @@ } } }, + "jest-canvas-mock": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-2.2.0.tgz", + "integrity": "sha512-DcJdchb7eWFZkt6pvyceWWnu3lsp5QWbUeXiKgEMhwB3sMm5qHM1GQhDajvJgBeiYpgKcojbzZ53d/nz6tXvJw==", + "dev": true, + "requires": { + "cssfontparser": "^1.2.1", + "parse-color": "^1.0.0" + } + }, "jest-changed-files": { "version": "22.4.3", "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-22.4.3.tgz", @@ -15664,8 +15824,7 @@ "object-is": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.0.1.tgz", - "integrity": "sha1-CqYOyZiaCz7Xlc9NBvYs8a1lObY=", - "dev": true + "integrity": "sha1-CqYOyZiaCz7Xlc9NBvYs8a1lObY=" }, "object-keys": { "version": "1.0.12", @@ -15684,7 +15843,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", - "dev": true, "requires": { "define-properties": "^1.1.2", "function-bind": "^1.1.1", @@ -16062,6 +16220,23 @@ "pbkdf2": "^3.0.3" } }, + "parse-color": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-color/-/parse-color-1.0.0.tgz", + "integrity": "sha1-e3SLlag/A/FqlPU15S1/PZRlhhk=", + "dev": true, + "requires": { + "color-convert": "~0.5.0" + }, + "dependencies": { + "color-convert": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-0.5.3.tgz", + "integrity": "sha1-vbbGnOZg+t/+CwAHzER+G59ygr0=", + "dev": true + } + } + }, "parse-github-url": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-github-url/-/parse-github-url-1.0.2.tgz", @@ -16426,8 +16601,7 @@ "popper.js": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", - "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==", - "dev": true + "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==" }, "portfinder": { "version": "1.0.16", @@ -18266,6 +18440,31 @@ "prop-types": "^15.5.8" } }, + "react-day-picker": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-7.3.2.tgz", + "integrity": "sha512-mij2j2Un/v2V2ow+hf/hFBMdl6Eis/C/YhBtlI6Xpbvh3Q6WMix78zEkCdw6i9GldafOrpnupWKofv/h5oSI4g==", + "requires": { + "prop-types": "^15.6.2" + }, + "dependencies": { + "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" + } + }, + "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-dev-utils": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-5.0.1.tgz", @@ -18937,7 +19136,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz", "integrity": "sha512-2+Q0C5g951OlYlJz6yu5/M33IcsESLlLfsyIaLJaG4FA2r4yP8MvVMJUUP/fVBkSpbbbZlS5gynbEWLipiiXiQ==", - "dev": true, "requires": { "define-properties": "^1.1.3", "es-abstract": "^1.17.0-next.1" @@ -18947,7 +19145,6 @@ "version": "1.17.5", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz", "integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==", - "dev": true, "requires": { "es-to-primitive": "^1.2.1", "function-bind": "^1.1.1", @@ -18966,7 +19163,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dev": true, "requires": { "is-callable": "^1.1.4", "is-date-object": "^1.0.1", @@ -18976,20 +19172,17 @@ "has-symbols": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", - "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", - "dev": true + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==" }, "is-callable": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz", - "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==", - "dev": true + "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==" }, "is-regex": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz", "integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==", - "dev": true, "requires": { "has": "^1.0.3" } @@ -18998,7 +19191,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", - "dev": true, "requires": { "has-symbols": "^1.0.1" } @@ -19006,14 +19198,12 @@ "object-inspect": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", - "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==", - "dev": true + "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==" }, "object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" } } }, @@ -19259,8 +19449,7 @@ "resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", - "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", - "dev": true + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" }, "resolve": { "version": "1.6.0", @@ -20919,24 +21108,306 @@ } } }, + "string.prototype.trimend": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.0.tgz", + "integrity": "sha512-EEJnGqa/xNfIg05SxiPSqRS7S9qwDhYts1TSLR1BQfYUfPe1stofgGKvwERK9+9yf+PpfBMlpBaCHucXGPQfUA==", + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + }, + "dependencies": { + "es-abstract": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz", + "integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==", + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.1.5", + "is-regex": "^1.0.5", + "object-inspect": "^1.7.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.0", + "string.prototype.trimleft": "^2.1.1", + "string.prototype.trimright": "^2.1.1" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==" + }, + "is-callable": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz", + "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==" + }, + "is-regex": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz", + "integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==", + "requires": { + "has": "^1.0.3" + } + }, + "is-symbol": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", + "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", + "requires": { + "has-symbols": "^1.0.1" + } + }, + "object-inspect": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", + "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==" + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" + } + } + }, "string.prototype.trimleft": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz", - "integrity": "sha512-iu2AGd3PuP5Rp7x2kEZCrB2Nf41ehzh+goo8TV7z8/XDBbsvc6HQIlUl9RjkZ4oyrW1XM5UwlGl1oVEaDjg6Ag==", - "dev": true, + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz", + "integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==", "requires": { "define-properties": "^1.1.3", - "function-bind": "^1.1.1" + "es-abstract": "^1.17.5", + "string.prototype.trimstart": "^1.0.0" + }, + "dependencies": { + "es-abstract": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz", + "integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==", + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.1.5", + "is-regex": "^1.0.5", + "object-inspect": "^1.7.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.0", + "string.prototype.trimleft": "^2.1.1", + "string.prototype.trimright": "^2.1.1" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==" + }, + "is-callable": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz", + "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==" + }, + "is-regex": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz", + "integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==", + "requires": { + "has": "^1.0.3" + } + }, + "is-symbol": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", + "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", + "requires": { + "has-symbols": "^1.0.1" + } + }, + "object-inspect": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", + "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==" + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" + } } }, "string.prototype.trimright": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.1.tgz", - "integrity": "sha512-qFvWL3/+QIgZXVmJBfpHmxLB7xsUXz6HsUmP8+5dRaC3Q7oKUv9Vo6aMCRZC1smrtyECFsIT30PqBJ1gTjAs+g==", - "dev": true, + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz", + "integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==", "requires": { "define-properties": "^1.1.3", - "function-bind": "^1.1.1" + "es-abstract": "^1.17.5", + "string.prototype.trimend": "^1.0.0" + }, + "dependencies": { + "es-abstract": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz", + "integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==", + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.1.5", + "is-regex": "^1.0.5", + "object-inspect": "^1.7.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.0", + "string.prototype.trimleft": "^2.1.1", + "string.prototype.trimright": "^2.1.1" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==" + }, + "is-callable": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz", + "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==" + }, + "is-regex": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz", + "integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==", + "requires": { + "has": "^1.0.3" + } + }, + "is-symbol": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", + "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", + "requires": { + "has-symbols": "^1.0.1" + } + }, + "object-inspect": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", + "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==" + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" + } + } + }, + "string.prototype.trimstart": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.0.tgz", + "integrity": "sha512-iCP8g01NFYiiBOnwG1Xc3WZLyoo+RuBymwIlWncShXDDJYWN6DbnM3odslBJdgCdRlq94B5s63NWAZlcn2CS4w==", + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + }, + "dependencies": { + "es-abstract": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz", + "integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==", + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.1.5", + "is-regex": "^1.0.5", + "object-inspect": "^1.7.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.0", + "string.prototype.trimleft": "^2.1.1", + "string.prototype.trimright": "^2.1.1" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==" + }, + "is-callable": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz", + "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==" + }, + "is-regex": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz", + "integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==", + "requires": { + "has": "^1.0.3" + } + }, + "is-symbol": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", + "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", + "requires": { + "has-symbols": "^1.0.1" + } + }, + "object-inspect": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", + "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==" + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" + } } }, "string_decoder": { @@ -21824,8 +22295,7 @@ "typed-styles": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/typed-styles/-/typed-styles-0.0.7.tgz", - "integrity": "sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q==", - "dev": true + "integrity": "sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q==" }, "typedarray": { "version": "0.0.6", diff --git a/package.json b/package.json index 0cdae6d115..bc183bfe1a 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ } }, "dependencies": { + "@blueprintjs/datetime": "^3.15.2", "@pusher/chatkit-client": "^1.5.0", "ace-builds": "^1.4.8", "acorn": "^5.7.4", @@ -122,6 +123,7 @@ "enzyme": "^3.11.0", "enzyme-adapter-react-16": "^1.14.0", "husky": "^1.3.1", + "jest-canvas-mock": "^2.2.0", "local-cors-proxy": "^1.0.2", "prettier": "^1.18.2", "react-scripts-ts": "^2.16.0", diff --git a/src/actions/__tests__/groundControl.ts b/src/actions/__tests__/groundControl.ts new file mode 100644 index 0000000000..be02d7d3ab --- /dev/null +++ b/src/actions/__tests__/groundControl.ts @@ -0,0 +1,57 @@ +import * as actionTypes from '../actionTypes'; +import { + changeDateAssessment, + deleteAssessment, + publishAssessment, + uploadAssessment +} from '../groundControl'; + +test('changeDateAssessment generates correct action object', () => { + const id = 10; + const openAt = '2020-01-01T00:00:00.000Z'; + const closeAt = '2021-01-01T00:00:00.000Z'; + const action = changeDateAssessment(id, openAt, closeAt); + expect(action).toEqual({ + type: actionTypes.CHANGE_DATE_ASSESSMENT, + payload: { + id, + openAt, + closeAt + } + }); +}); + +test('deleteAssessment generates correct action object', () => { + const id = 12; + const action = deleteAssessment(id); + expect(action).toEqual({ + type: actionTypes.DELETE_ASSESSMENT, + payload: id + }); +}); + +test('publishAssessment generates correct action object', () => { + const id = 54; + const togglePublishTo = false; + const action = publishAssessment(togglePublishTo, id); + expect(action).toEqual({ + type: actionTypes.PUBLISH_ASSESSMENT, + payload: { + togglePublishTo, + id + } + }); +}); + +test(' generates correct action object', () => { + const file = new File([''], 'testFile'); + const forceUpdate = true; + const action = uploadAssessment(file, forceUpdate); + expect(action).toEqual({ + type: actionTypes.UPLOAD_ASSESSMENT, + payload: { + file, + forceUpdate + } + }); +}); diff --git a/src/actions/__tests__/session.ts b/src/actions/__tests__/session.ts index 082ebb4b63..fc55c868fe 100644 --- a/src/actions/__tests__/session.ts +++ b/src/actions/__tests__/session.ts @@ -1,7 +1,7 @@ import { Grading, GradingOverview } from '../../components/academy/grading/gradingShape'; import { IAssessment, IAssessmentOverview } from '../../components/assessment/assessmentShape'; import { Notification } from '../../components/notification/notificationShape'; -import { Role, Story } from '../../reducers/states'; +import { GameState, Role, Story } from '../../reducers/states'; import * as actionTypes from '../actionTypes'; import { acknowledgeNotifications, @@ -156,7 +156,8 @@ test('setUser generates correct action object', () => { role: 'student' as Role, group: '42D', grade: 150, - story: {} as Story + story: {} as Story, + gameState: {} as GameState }; const action = setUser(user); expect(action).toEqual({ diff --git a/src/actions/actionTypes.ts b/src/actions/actionTypes.ts index 40d7922be3..116341844e 100755 --- a/src/actions/actionTypes.ts +++ b/src/actions/actionTypes.ts @@ -125,7 +125,17 @@ export const ACKNOWLEDGE_NOTIFICATIONS = 'ACKNOWLEDGE_NOTIFICATIONS'; export const UPDATE_NOTIFICATIONS = 'UPDATE_NOTIFICATIONS'; export const NOTIFY_CHATKIT_USERS = 'NOTIFY_CHATKIT_USERS'; -/** Dashboard */ +/** GAMEDEV */ +export const FETCH_TEST_STORIES = 'FETCH_TEST_STORIES'; +export const SAVE_USER_STATE = 'SAVE_USER_STATE'; +export const SET_GAME_STATE = 'SET_GAME_STATE'; + +/** GroundControl */ +export const CHANGE_DATE_ASSESSMENT = 'CHANGE_DATE_ASSESSMENT'; +export const DELETE_ASSESSMENT = 'DELETE_ASSESSMENT'; +export const PUBLISH_ASSESSMENT = 'PUBLISH_ASSESSMENT'; +export const UPLOAD_ASSESSMENT = 'UPLOAD_ASSESSMENT'; +/** Dashboard */ export const FETCH_GROUP_OVERVIEWS = 'FETCH_GROUP_OVERVIEWS'; export const UPDATE_GROUP_OVERVIEWS = 'UPDATE_GROUP_OVERVIEWS'; diff --git a/src/actions/game.ts b/src/actions/game.ts index c552f98a12..22fce7c6bc 100644 --- a/src/actions/game.ts +++ b/src/actions/game.ts @@ -1,5 +1,8 @@ +import { GameState } from 'src/reducers/states'; import { action } from 'typesafe-actions'; - import * as actionTypes from './actionTypes'; +export const fetchTestStories = () => action(actionTypes.FETCH_TEST_STORIES); export const saveCanvas = (canvas: HTMLCanvasElement) => action(actionTypes.SAVE_CANVAS, canvas); +export const saveUserData = (gameState: GameState) => + action(actionTypes.SAVE_USER_STATE, gameState); diff --git a/src/actions/groundControl.ts b/src/actions/groundControl.ts new file mode 100644 index 0000000000..87e548ad99 --- /dev/null +++ b/src/actions/groundControl.ts @@ -0,0 +1,14 @@ +import { action } from 'typesafe-actions'; + +import * as actionTypes from './actionTypes'; + +export const changeDateAssessment = (id: number, openAt: string, closeAt: string) => + action(actionTypes.CHANGE_DATE_ASSESSMENT, { id, openAt, closeAt }); + +export const deleteAssessment = (id: number) => action(actionTypes.DELETE_ASSESSMENT, id); + +export const publishAssessment = (togglePublishTo: boolean, id: number) => + action(actionTypes.PUBLISH_ASSESSMENT, { id, togglePublishTo }); + +export const uploadAssessment = (file: File, forceUpdate: boolean) => + action(actionTypes.UPLOAD_ASSESSMENT, { file, forceUpdate }); diff --git a/src/actions/index.ts b/src/actions/index.ts index 2ca34edd99..12d9472fb1 100755 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -2,6 +2,7 @@ export * from './collabEditing'; export * from './commons'; export * from './dashboard'; export * from './game'; +export * from './groundControl'; export * from './interpreter'; export * from './material'; export * from './playground'; diff --git a/src/actions/session.ts b/src/actions/session.ts index 141c5483b8..36d1b1ae8e 100755 --- a/src/actions/session.ts +++ b/src/actions/session.ts @@ -6,7 +6,7 @@ import { Notification, NotificationFilterFunction } from '../components/notification/notificationShape'; -import { Story } from '../reducers/states'; +import { GameState, Story } from '../reducers/states'; import * as actionTypes from './actionTypes'; import { Role } from '../reducers/states'; @@ -31,6 +31,8 @@ export const fetchGradingOverviews = (filterToGroup = true) => export const login = () => action(actionTypes.LOGIN); +export const setGameState = (gameState: GameState) => action(actionTypes.SET_GAME_STATE, gameState); + export const setTokens = ({ accessToken, refreshToken @@ -48,7 +50,8 @@ export const setUser = (user: { role: Role; group: string | null; grade: number; - story: Story; + story?: Story; + gameState?: GameState; }) => action(actionTypes.SET_USER, user); export const submitAnswer = (id: number, answer: string | number) => diff --git a/src/components/Playground.tsx b/src/components/Playground.tsx old mode 100755 new mode 100644 diff --git a/src/components/academy/NavigationBar.tsx b/src/components/academy/NavigationBar.tsx index 708edea428..68af3b0b4b 100644 --- a/src/components/academy/NavigationBar.tsx +++ b/src/components/academy/NavigationBar.tsx @@ -85,6 +85,15 @@ const NavigationBar: React.SFC = props => ( + + +
Ground Control
+
+ = props => ( disableHover={true} /> + + + +
Game Dev
+
) : null} diff --git a/src/components/academy/__tests__/__snapshots__/NavigationBar.tsx.snap b/src/components/academy/__tests__/__snapshots__/NavigationBar.tsx.snap index df73af1e1c..7be7411839 100644 --- a/src/components/academy/__tests__/__snapshots__/NavigationBar.tsx.snap +++ b/src/components/academy/__tests__/__snapshots__/NavigationBar.tsx.snap @@ -81,6 +81,12 @@ exports[`Grading NavLink renders for Role.Admin 1`] = ` + + +
+ Ground Control +
+
@@ -106,6 +112,12 @@ exports[`Grading NavLink renders for Role.Admin 1`] = `
+ + +
+ Game Dev +
+
" `; @@ -150,6 +162,12 @@ exports[`Grading NavLink renders for Role.Staff 1`] = ` + + +
+ Ground Control +
+
@@ -175,6 +193,12 @@ exports[`Grading NavLink renders for Role.Staff 1`] = `
+ + +
+ Game Dev +
+
" `; diff --git a/src/components/academy/game/backend/game-state.js b/src/components/academy/game/backend/game-state.js new file mode 100644 index 0000000000..78f5ec381a --- /dev/null +++ b/src/components/academy/game/backend/game-state.js @@ -0,0 +1,208 @@ +import { storyXMLPathTest, storyXMLPathLive, SAVE_DATA_KEY, LOCATION_KEY } from '../constants/constants' +import { isStudent } from './user'; + +var SaveManager = require('../save-manager/save-manager.js'); + +/** + * Handles data regarding the game state. + * - The student's list of completed quests and collectibles + * - The student's current story mission + * - The global list of missions that are open + * - The action to save user state to server. + */ +let handleSaveData = undefined; + +//everything is going to be stored in session storage since we discovered game-state dosent persist over pages +const OVERRIDE_KEY = "source_academy_override", +OVERRIDE_DATES_KEY = "source_academy_override_dates", +OVERRIDE_PUBLISH_KEY = "source_academy_override_publish", +SESSION_DATA_KEY = "source_academy_session_data"; + +let sessionData = undefined; + +export function fetchGameData(story, gameStates, missions, callback) { + // fetch only needs to be called once; if there are additional calls somehow then ignore them + // only for students + if(hasBeenFetched() && isStudent()) { + callback(getSessionData().story.story); + return; + } + const data = { + "story":story, + "gameStates":gameStates, + "currentDate": Date() + } + if (!getSessionData()) { + setSessionData(data); + } + if (!isStudent()) { + // resets current progress (local storage) for testers + SaveManager.resetLocalSaveData(); + } + missions = organiseMissions(missions); + getMissionPointer(missions, callback); +} + +function printSessionData() { + console.log("SessionData = " + (sessionStorage.getItem(SESSION_DATA_KEY))); +} + +function setSessionData(sessionData) { + sessionStorage.setItem(SESSION_DATA_KEY, JSON.stringify(sessionData)); +} + +function getSessionData() { + return JSON.parse(sessionStorage.getItem(SESSION_DATA_KEY)); +} + +function hasBeenFetched() { + return sessionStorage.hasOwnProperty(SESSION_DATA_KEY); +} + +function removeSessionStorage() { + sessionStorage.removeItem(SESSION_DATA_KEY); + sessionStorage.removeItem(OVERRIDE_KEY); + sessionStorage.removeItem(OVERRIDE_PUBLISH_KEY); + sessionStorage.removeItem(OVERRIDE_DATES_KEY); +} + +// override student session data +export function overrideSessionData(data) { + if (data) { + setSessionData(data.sessionData); + sessionStorage.setItem(OVERRIDE_KEY, "true"); + if (data.overridePublish) { + sessionStorage.setItem(OVERRIDE_PUBLISH_KEY, "will override published"); + } + if (data.overrideDates) { + sessionStorage.setItem(OVERRIDE_DATES_KEY, "will override dates"); + } + } else { + removeSessionStorage(); + } + printSessionData(); +} + +export function setSaveHandler(saveData) { + handleSaveData = saveData; +} + +export function saveUserData(data) { + if (data && handleSaveData !== undefined) { + handleSaveData(data) + setSessionData(data); + } +} + +export function saveCollectible(collectible) { + const sessionData = getSessionData(); + data.gameStates.collectibles[collectible] = 'completed'; + saveUserData(sessionData); +} + +export function hasCollectible(collectible) { + return hasBeenFetched() && + getSessionData().gameStates.collectibles[collectible] === 'completed'; +} + +export function saveQuest(questId) { + const sessionData = getSessionData(); + sessionData.gameStates.completed_quests.push(questId); + saveUserData(sessionData); +} + +export function hasCompletedQuest(questId) { + return hasBeenFetched() && + getSessionData().gameStates.completed_quests.includes(questId); +} + + +function getStudentStory() { + //tries to retrieve local version of story + //if unable to find, use backend's version. + //this is to prevent any jumps in story after student completes a mission + if (SaveManager.hasLocalSave()) { + let actionSequence = SaveManager.getLocalSaveData().actionSequence; + let story = actionSequence[actionSequence.length - 1].storyID; + return story; + } else { + return hasBeenFetched() + ? getSessionData().story.story + : undefined; + } +} + +function organiseMissions(missions) { + function compareMissions(x, y) { + //compares with opening dates first and if equal, compare closing dates. + //sort by earliest date + const openX = new Date(x.openAt).getTime(); + const openY = new Date(y.openAt).getTime(); + const closeX = new Date(x.closeAt).getTime(); + const closeY = new Date(y.closeAt).getTime(); + return openX === openY + ? Math.sign(closeX - closeY) + : Math.sign(openX - openY); + } + function isWithinDates(mission, date) { + return new Date(mission.openAt) <= now && + now <= new Date(mission.closeAt); + } + let predicate; + const now = new Date(getSessionData().currentDate); + if (isStudent()) { + predicate = (mission) => + mission.isPublished && isWithinDates(mission, now); + } else { + // resets current progress + SaveManager.resetLocalSaveData(); + //testers will play unpublished missions too and can go out of bounds unless + //they state that they don't want to in the json file, using the override keys + predicate = (mission) => { + let toPass = true; + if (!sessionStorage.getItem(OVERRIDE_DATES_KEY)) { + toPass = isWithinDates(mission, now); + } + if (!sessionStorage.getItem(OVERRIDE_PUBLISH_KEY)) { + toPass = toPass && mission.isPublished; + } + return toPass; + } + } + const sorted_missions = missions.sort(compareMissions); + const remaining_missions = sorted_missions.filter(predicate); + //if no more remaining missions, use the last remaining mission. + return remaining_missions.length > 0 + ? remaining_missions + : [sorted_missions[sorted_missions.length - 1]]; +} + +/** + * Obtain the story mission to load. This will usually be the student's current mission pointer. + * However, in the event the student's current mission pointer falls outside the bounds of the + * global list of open missions, then the corresponding upper (or lower) bound will be used. + */ +function getMissionPointer(missions, callback) { + // in the scenario with no missions + if (missions == undefined) { + return; + } + //finds the mission id's mission pointer + let studentStory = getStudentStory(); + const isStoryEmpty = story => story === undefined || story.length === 0; + let missionPointer = isStoryEmpty(studentStory) + ? missions[0] + : missions.find(mission => mission.story === studentStory); + //if mission pointer is in localStorage and can't find any proper story. + if (missionPointer === undefined && SaveManager.hasLocalSave()) { + localStorage.removeItem(SAVE_DATA_KEY); + studentStory = getStudentStory(); + missionPointer = isStoryEmpty(studentStory) + ? missions[0] + : missions.find(mission => mission.story === studentStory); + } + if (missionPointer === undefined) { + missionPointer = missions[0]; + } + callback(missionPointer.story); +} \ No newline at end of file diff --git a/src/components/academy/game/backend/hosting.js b/src/components/academy/game/backend/hosting.js new file mode 100644 index 0000000000..ba841ea7f2 --- /dev/null +++ b/src/components/academy/game/backend/hosting.js @@ -0,0 +1,8 @@ +import { isStudent } from './user'; + +const LIVE_ASSETS_HOST = 'https://s3-ap-southeast-1.amazonaws.com/source-academy-assets/'; + +export const TEST_STORIES_HOST = 'https://s3-ap-southeast-1.amazonaws.com/source-academy-assets/stories/'; //this should be your materials folder +export const LIVE_STORIES_HOST = 'https://s3-ap-southeast-1.amazonaws.com/source-academy-assets/stories/'; + +export const ASSETS_HOST = LIVE_ASSETS_HOST; \ No newline at end of file diff --git a/src/components/academy/game/backend/user.js b/src/components/academy/game/backend/user.js new file mode 100644 index 0000000000..19c404dd12 --- /dev/null +++ b/src/components/academy/game/backend/user.js @@ -0,0 +1,8 @@ +let userRole = undefined; +export function setUserRole(role) { + userRole = role; +} + +export function isStudent() { + return userRole === "student"; +} \ No newline at end of file diff --git a/src/components/academy/game/constants/constants.js b/src/components/academy/game/constants/constants.js index c9451ca822..96eb589f79 100644 --- a/src/components/academy/game/constants/constants.js +++ b/src/components/academy/game/constants/constants.js @@ -1,26 +1,30 @@ -module.exports = { - screenWidth: 1920, - screenHeight: 1080, - dialogBoxHeight: 260, - dialogBoxWidth: 1720, - dialogBoxPadding: 25, - fontSize: 44, - innerDialogPadding: 46, - avatarOffset: 46, - nameBoxXPadding: 50, - nameBoxHeight: 80, - playerAvatarSize: 300, - playerAvatarLineWidth: 10, - playerAvatarOffset: 40, - glowDistance: 30, - textSpeed: 0.02, - storyXMLPath: ASSETS_HOST + 'stories/', - locationPath: ASSETS_HOST + 'locations/', - objectPath: ASSETS_HOST + 'objects/', - imgPath: ASSETS_HOST + 'images/', - avatarPath: ASSETS_HOST + 'avatars/', - uiPath: ASSETS_HOST + 'UI/', - soundPath: ASSETS_HOST + 'sounds/', - fadeTime: 0.3, - nullFunction: function() {} -}; +import { ASSETS_HOST, LIVE_STORIES_HOST, TEST_STORIES_HOST } from '../backend/hosting' + +export const + screenWidth = 1920, + screenHeight = 1080, + dialogBoxHeight = 260, + dialogBoxWidth = 1720, + dialogBoxPadding = 25, + fontSize = 44, + innerDialogPadding = 46, + avatarOffset = 46, + nameBoxXPadding = 50, + nameBoxHeight = 80, + playerAvatarSize = 300, + playerAvatarLineWidth = 10, + playerAvatarOffset = 40, + glowDistance = 30, + textSpeed = 0.02, + storyXMLPathLive = LIVE_STORIES_HOST, + storyXMLPathTest = TEST_STORIES_HOST, + locationPath = ASSETS_HOST + 'locations/', + objectPath = ASSETS_HOST + 'objects/', + imgPath = ASSETS_HOST + 'images/', + avatarPath = ASSETS_HOST + 'avatars/', + uiPath = ASSETS_HOST + 'UI/', + soundPath = ASSETS_HOST + 'sounds/', + SAVE_DATA_KEY = "source_academy_save_data", + LOCATION_KEY = "source_academy_location", + fadeTime = 0.3, + nullFunction = function() {}; \ No newline at end of file diff --git a/src/components/academy/game/create-initializer.js b/src/components/academy/game/create-initializer.js index 5e7a32208c..2d798b2571 100644 --- a/src/components/academy/game/create-initializer.js +++ b/src/components/academy/game/create-initializer.js @@ -1,12 +1,9 @@ import {LINKS} from '../../../utils/constants' import {history} from '../../../utils/history' +import {soundPath, LOCATION_KEY} from './constants/constants' +import {fetchGameData, getMissionPointer, getStudentData, saveCollectible, saveQuest} from './backend/game-state' -export default function (StoryXMLPlayer, story, username, attemptedAll) { - function saveToServer() { - } - - function loadFromServer() { - } +export default function (StoryXMLPlayer, username, userStory, gameState, missions) { var hookHandlers = { startMission: function () { @@ -39,50 +36,39 @@ export default function (StoryXMLPlayer, story, username, attemptedAll) { return window.open(LINKS.LUMINUS); } }, - pickUpCollectible: function (collectible) { - if (typeof Storage !== 'undefined') { - localStorage.setItem(collectible, 'collected'); - } - }, + pickUpCollectible: saveCollectible, playSound: function (name) { - var sound = new Audio(ASSETS_HOST + 'sounds/' + name + '.mp3'); + var sound = new Audio(soundPath + name + '.mp3'); if (sound) { sound.play(); } }, - saveCompletedQuest: function (questId) { - if (typeof Storage !== 'undefined') { - localStorage.setItem(questId, 'completed'); - } - } + saveCompletedQuest: saveQuest }; function openWristDevice() { window.open(LINKS.LUMINUS); } - function startGame(div, canvas, saveData) { - saveData = saveData || loadFromServer(); + function startGame(div, canvas) { StoryXMLPlayer.init(div, canvas, { - saveData: saveData, hookHandlers: hookHandlers, - saveFunc: saveToServer, wristDeviceFunc: openWristDevice, playerName: username, playerImageCanvas: $(''), changeLocationHook: function (newLocation) { if (typeof Storage !== 'undefined') { // Code for localStorage/sessionStorage. - localStorage.cs1101s_source_academy_location = newLocation; + localStorage.setItem(LOCATION_KEY, newLocation); } } }); } - function initialize(div, canvas) { + function initialize(story, div, canvas) { startGame(div, canvas); - StoryXMLPlayer.loadStory('master', function () {}); + StoryXMLPlayer.loadStory(story, function () {}); } - return initialize; + return (div, canvas) => fetchGameData(userStory, gameState, missions, (story) => initialize(story, div, canvas)); }; diff --git a/src/components/academy/game/filter-effects/filter-effects.js b/src/components/academy/game/filter-effects/filter-effects.js index ed2b2d6cf4..011e69d22a 100644 --- a/src/components/academy/game/filter-effects/filter-effects.js +++ b/src/components/academy/game/filter-effects/filter-effects.js @@ -37,6 +37,31 @@ export function createGlowTexture(displayObject) { return createTexture(container, [glowFilter], width, height); } +export function createTelescopeEffect(parent) { + const background = parent; + const radius = 300; + const blurSize = 16; + const circle = new PIXI.Graphics() + .beginFill(0xff0000) + .drawCircle(radius + blurSize, radius + blurSize, radius) + .endFill(); + circle.filters = [new PIXI.filters.BlurFilter(blurSize)]; + const bounds = new PIXI.Rectangle(0, 0, (radius + blurSize) * 2, (radius + blurSize) * 2); + const renderer = getRenderer(); + const texture = renderer.generateTexture(circle, PIXI.SCALE_MODES.NEAREST, 1, bounds); + const focus = new PIXI.Sprite(texture); + parent.addChild(focus); + background.mask = focus; + parent.interactive = true; + parent.on('mousemove', pointerMove); + function pointerMove(event) { + focus.position.x = event.data.global.x - focus.width / 2; + focus.position.y = event.data.global.y - focus.height / 2; + } +} + + + export function createDarkenedTexture(texture) { return createFilteredTexture(texture, [darkFilter]); } diff --git a/src/components/academy/game/game.js b/src/components/academy/game/game.js index 63b228f8cd..ca2b6436f5 100644 --- a/src/components/academy/game/game.js +++ b/src/components/academy/game/game.js @@ -1,10 +1,8 @@ import createInitializer from './create-initializer' -export default function(div, canvas, username, story, attemptedAll) { - window.ASSETS_HOST = - 'https://s3-ap-southeast-1.amazonaws.com/source-academy-assets/'; +export default function(div, canvas, username, userStory, gameState, missions) { var StoryXMLPlayer = require('./story-xml-player'); var container = document.getElementById('game-display') - var initialize = createInitializer(StoryXMLPlayer, story, username, attemptedAll) + var initialize = createInitializer(StoryXMLPlayer, username, userStory, gameState, missions) initialize(div, canvas); } diff --git a/src/components/academy/game/index.tsx b/src/components/academy/game/index.tsx index e9d4e14ba0..3a57444f64 100644 --- a/src/components/academy/game/index.tsx +++ b/src/components/academy/game/index.tsx @@ -1,18 +1,24 @@ import * as React from 'react'; - -import { store } from '../../../createStore'; -import { Story } from '../../../reducers/states'; +import { IAssessmentOverview } from 'src/components/assessment/assessmentShape'; +import { GameState, Role, Story } from '../../../reducers/states'; +import { setSaveHandler } from './backend/game-state'; +import { setUserRole } from './backend/user'; type GameProps = DispatchProps & StateProps; export type DispatchProps = { handleSaveCanvas: (c: HTMLCanvasElement) => void; + handleSaveData: (s: GameState) => void; + handleAssessmentOverviewFetch: () => void; }; export type StateProps = { canvas?: HTMLCanvasElement; - name: string; - story?: Story; + name?: string; + story: Story; + gameState: GameState; + role?: Role; + assessmentOverviews?: IAssessmentOverview[]; }; export class Game extends React.Component { @@ -36,18 +42,43 @@ export class Game extends React.Component { * backend sends us 'playStory', which is the negation (!) of `attemptedAll`. */ public async componentDidMount() { - const story: any = (await import('./game.js')).default; - if (this.props.canvas === undefined) { - const storyOpts = await this.getStoryOpts(); - story(this.div, this.canvas, this.props.name, ...storyOpts); + if (this.props.name && this.props.role && !this.props.assessmentOverviews) { + // If assessment overviews are not loaded, fetch them + this.props.handleAssessmentOverviewFetch(); + const loadingScreen: any = (await import('./story-xml-player.js')).loadingScreen; + loadingScreen(this.div, this.canvas); this.props.handleSaveCanvas(this.canvas); - } else { + } + if (this.props.canvas !== undefined) { // This browser window has loaded the Game component & canvas before + this.canvas = this.props.canvas; this.div.innerHTML = ''; this.div.appendChild(this.props.canvas); } } + public async componentDidUpdate(prevProps: Readonly) { + // loads only once after assessmentOverviews are up + const isLoaded = + this.props.name && this.props.role && this.props.assessmentOverviews && this.props.canvas; + const prevLoaded = + prevProps.name && prevProps.role && prevProps.assessmentOverviews && prevProps.canvas; + if (isLoaded && isLoaded !== prevLoaded) { + const story: any = (await import('./game.js')).default; + setUserRole(this.props.role); + setSaveHandler((gameState: GameState) => this.props.handleSaveData(gameState)); + story( + this.div, + this.canvas, + this.props.name, + this.props.story, + this.props.gameState, + this.props.assessmentOverviews + ); + this.props.handleSaveCanvas(this.canvas); + } + } + public render() { return (
(this.div = e!)}> @@ -55,29 +86,6 @@ export class Game extends React.Component {
); } - - private async getStoryOpts() { - if (this.props.story) { - // no missions, no story from backend, just play intro - return this.props.story.story - ? [this.props.story.story, !this.props.story.playStory] - : ['mission-1', true]; - } else { - // this.props.story is null if creating 'fresh' store from localStorage - const state = store.getState(); - if (state.session.story) { - // no missions, no story from backend, just play intro - return state.session.story.story - ? [state.session.story.story, !state.session.story.playStory] - : ['mission-1', true]; - } else { - // if user is null, actions.logOut is called anyways; nonetheless we - // return a storyOpts, otherwise typescript complains about using storyOpts - // before assignment in story/4 below - return ['mission-1', true]; - } - } - } } export default Game; diff --git a/src/components/academy/game/object-manager/object-manager.js b/src/components/academy/game/object-manager/object-manager.js index f464ad7a49..8c6b61e908 100644 --- a/src/components/academy/game/object-manager/object-manager.js +++ b/src/components/academy/game/object-manager/object-manager.js @@ -10,6 +10,7 @@ var ExternalManager = require('../external-manager/external-manager.js'); var MapOverlay = require('../map-overlay/map-overlay.js'); var Utils = require('../utils/utils.js'); var FilterEffects = require('../filter-effects/filter-effects.js'); +var GameState = require('../backend/game-state') var mapObjects; var sequenceObjects; @@ -164,8 +165,8 @@ export function processTempObject(gameLocation, node) { } var collectible = node.getAttribute('name'); var isInDorm = gameLocation.name == 'yourRoom'; - if ((isInDorm && !localStorage.hasOwnProperty(collectible))|| - (!isInDorm && localStorage.hasOwnProperty(collectible))) { + if ((isInDorm && !GameState.hasCollectible(collectible))|| + (!isInDorm && GameState.hasCollectible(collectible))) { return; //don't load the collectible in dorm if it's not collected || don't load if in hidden location and collected } if (isInDorm) { diff --git a/src/components/academy/game/quest-manager/quest-manager.js b/src/components/academy/game/quest-manager/quest-manager.js index b2e8c4d301..00ad1b56af 100644 --- a/src/components/academy/game/quest-manager/quest-manager.js +++ b/src/components/academy/game/quest-manager/quest-manager.js @@ -6,6 +6,7 @@ var StoryManager = require('../story-manager/story-manager.js'); var SaveManager = require('../save-manager/save-manager.js'); var Utils = require('../utils/utils.js'); var ExternalManager = require('../external-manager/external-manager.js'); +var GameState = require('../backend/game-state'); var loadedQuests = {}; var activeQuests = {}; @@ -65,7 +66,7 @@ export function unlockQuest(storyId, questId, callback) { if (!activeQuests[storyId]) { activeQuests[storyId] = {}; } - if (typeof Storage !== 'undefined' && localStorage.hasOwnProperty(questId)) { + if (typeof Storage !== 'undefined' && GameState.hasCompletedQuest(questId)) { // skip sequence skipEffects(quest.children[0]); SaveManager.saveUnlockQuest(storyId, questId); diff --git a/src/components/academy/game/save-manager/save-manager.js b/src/components/academy/game/save-manager/save-manager.js index 8c93a65c1c..d771283953 100644 --- a/src/components/academy/game/save-manager/save-manager.js +++ b/src/components/academy/game/save-manager/save-manager.js @@ -1,3 +1,6 @@ +import {saveStudentData} from '../backend/game-state'; +import {SAVE_DATA_KEY, LOCATION_KEY} from "../constants/constants"; + var LocationManager = require('../location-manager/location-manager.js'); var QuestManager = require('../quest-manager/quest-manager.js'); var StoryManager = require('../story-manager/story-manager.js'); @@ -7,22 +10,23 @@ var ObjectManager = require('../object-manager/object-manager.js'); var Utils = require('../utils/utils.js'); var actionSequence = []; -var saveFunction; -export function init(saveFunc, saveData, callback) { - saveFunction = saveFunc; +// finds existing save data, which consists of action sequence and starting location +export function init() { + let saveData = getLocalSaveData(); if (saveData) { - saveData = JSON.parse(saveData); actionSequence = saveData.actionSequence; var storyXMLs = []; - // TODO: this is assuming that all 'loadStory' appear at the start - // This may not be the case. Need to improve for (var i = 0; i < actionSequence.length; i++) { if (actionSequence[i].type == 'loadStory') { storyXMLs.push(actionSequence[i].storyId); } } + //callback wasn't being used, but was required in location manager + // Made it an empty function + let callback = () => {}; + StoryManager.loadStoryXML(storyXMLs, false, function() { LocationManager.changeStartLocation(saveData.startLocation); if (hasPending()) { @@ -109,8 +113,24 @@ export function saveLoadStories(stories) { saveGame(); } +export function hasLocalSave() { + return localStorage.hasOwnProperty(SAVE_DATA_KEY); +} + + +export function getLocalSaveData() { + const jsonString = localStorage.getItem(SAVE_DATA_KEY); + return jsonString ? JSON.parse(jsonString) : undefined; +} + +export function resetLocalSaveData() { + localStorage.removeItem(SAVE_DATA_KEY); + localStorage.removeItem(LOCATION_KEY); +} + +// saves actionsequence and start location into local storage function saveGame() { - saveFunction( + localStorage.setItem(SAVE_DATA_KEY, JSON.stringify({ actionSequence: actionSequence, startLocation: LocationManager.getStartLocation() diff --git a/src/components/academy/game/story-manager/story-manager.js b/src/components/academy/game/story-manager/story-manager.js index dc65368894..5c30738a56 100644 --- a/src/components/academy/game/story-manager/story-manager.js +++ b/src/components/academy/game/story-manager/story-manager.js @@ -1,4 +1,5 @@ import * as PIXI from 'pixi.js' +import { isStudent } from '../backend/user.js'; var Constants = require('../constants/constants.js'); var QuestManager = require('../quest-manager/quest-manager.js'); @@ -121,9 +122,10 @@ export function loadStoryXML(storyXMLs, willSave, callback) { } else { // download the story downloadRequestSent[curId] = true; - $.ajax({ + const makeAjax = isTest => $.ajax({ type: 'GET', - url: Constants.storyXMLPath + curId + '.story.xml', + url: (isTest ? Constants.storyXMLPathTest : Constants.storyXMLPathLive) + + curId + '.story.xml', dataType: 'xml', success: function(xml) { var story = xml.children[0]; @@ -155,11 +157,17 @@ export function loadStoryXML(storyXMLs, willSave, callback) { } } }, - error: function() { - loadingOverlay.visible = false; - console.error('Cannot find story ' + curId); - } + error: isTest + ? () => { + console.log('Trying on live...'); + makeAjax(false); + } + : () => { + loadingOverlay.visible = false; + console.error('Cannot find story ' + curId); + } }); + makeAjax(!isStudent()); download(i + 1, storyXMLs, callback); } } diff --git a/src/components/academy/game/story-xml-player.js b/src/components/academy/game/story-xml-player.js index 2abe8f5d36..ed92bffb31 100644 --- a/src/components/academy/game/story-xml-player.js +++ b/src/components/academy/game/story-xml-player.js @@ -18,9 +18,9 @@ var renderer; var stage; //--------LOGIC-------- // options contains the following properties: -// saveData, hookHandlers, saveFunc, wristDeviceFunc +// saveData, hookHandlers, wristDeviceFunc // changeLocationHook, playerImageCanvas, playerName -export function init(div, canvas, options, callback) { +export function init(div, canvas, options) { renderer = PIXI.autoDetectRenderer( Constants.screenWidth, Constants.screenHeight, @@ -51,12 +51,34 @@ export function init(div, canvas, options, callback) { } animate(); - SaveManager.init(options.saveFunc, options.saveData, callback); - + SaveManager.init(); + // a pixi.container on top of everything that is exported stage.addChild(ExternalManager.init(options.hookHandlers)); }; +export function loadingScreen(div, canvas) { + renderer = PIXI.autoDetectRenderer( + Constants.screenWidth, + Constants.screenHeight, + { backgroundColor: 0x000000, view: canvas } + ); + div.append(renderer.view); + Utils.saveRenderer(renderer); + // create the root of the scene graph + stage = new PIXI.Container(); + stage.addChild(BlackOverlay.init()); + const loadingScreen = StoryManager.init(); + loadingScreen.visible = true; + stage.addChild(StoryManager.init()); + + function animate() { + requestAnimationFrame(animate); + renderer.render(stage); + } + animate(); +}; + export { getExternalOverlay } from './external-manager/external-manager.js' export { changeStartLocation, gotoStartLocation, gotoLocation } from './location-manager/location-manager.js' export { loadStory, loadStoryWithoutFirstQuest } from './story-manager/story-manager.js' diff --git a/src/components/academy/index.tsx b/src/components/academy/index.tsx index cfe6d56724..d232326699 100644 --- a/src/components/academy/index.tsx +++ b/src/components/academy/index.tsx @@ -4,7 +4,9 @@ import { Redirect, Route, RouteComponentProps, Switch } from 'react-router'; import Grading from '../../containers/academy/grading'; import AssessmentContainer from '../../containers/assessment'; import Dashboard from '../../containers/dashboard/DashboardContainer'; +import StoryUpload from '../../containers/game-dev/StoryUploadContainer'; import Game from '../../containers/GameContainer'; +import GroundControl from '../../containers/groundControl/GroundControlContainer'; import MaterialUpload from '../../containers/material/MaterialUploadContainer'; import Sourcereel from '../../containers/sourcecast/SourcereelContainer'; import { isAcademyRe } from '../../reducers/session'; @@ -77,11 +79,12 @@ class Academy extends React.Component { )}/${assessmentRegExp}`} render={assessmentRenderFactory(AssessmentCategories.Practical)} /> - + + diff --git a/src/components/assessment/assessmentShape.ts b/src/components/assessment/assessmentShape.ts index ef801ba028..a46541dec7 100644 --- a/src/components/assessment/assessmentShape.ts +++ b/src/components/assessment/assessmentShape.ts @@ -27,6 +27,7 @@ export interface IAssessmentOverview { xp: number; gradingStatus: GradingStatus; private?: boolean; + isPublished?: boolean; } export enum AssessmentStatuses { diff --git a/src/components/game-dev/DeleteCell.tsx b/src/components/game-dev/DeleteCell.tsx new file mode 100644 index 0000000000..a10fea649e --- /dev/null +++ b/src/components/game-dev/DeleteCell.tsx @@ -0,0 +1,67 @@ +import { Classes, Dialog } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import * as React from 'react'; + +import { controlButton } from '../commons'; +import { MaterialData } from './storyShape'; + +interface IDeleteCellProps { + data: MaterialData; + handleDeleteMaterial: (id: number) => void; + handleDeleteMaterialFolder: (id: number) => void; +} + +interface IDeleteCellState { + dialogOpen: boolean; +} + +class DeleteCell extends React.Component { + public constructor(props: IDeleteCellProps) { + super(props); + this.state = { + dialogOpen: false + }; + } + + public render() { + return ( +
+ {controlButton('', IconNames.TRASH, this.handleOpenDialog)} + +
+ {this.props.data.url ? ( +

Are you sure to delete this material file?

+ ) : ( +

Are you sure to delete this material folder?

+ )} +
+
+
+ {controlButton('Confirm Delete', IconNames.TRASH, this.handleDelete)} + {controlButton('Cancel', IconNames.CROSS, this.handleCloseDialog)} +
+
+
+
+ ); + } + + private handleCloseDialog = () => this.setState({ dialogOpen: false }); + private handleOpenDialog = () => this.setState({ dialogOpen: true }); + private handleDelete = () => { + const { data } = this.props; + if (data.url) { + this.props.handleDeleteMaterial(data.id); + } else { + this.props.handleDeleteMaterialFolder(data.id); + } + }; +} + +export default DeleteCell; diff --git a/src/components/game-dev/DownloadCell.tsx b/src/components/game-dev/DownloadCell.tsx new file mode 100644 index 0000000000..cd400a804f --- /dev/null +++ b/src/components/game-dev/DownloadCell.tsx @@ -0,0 +1,43 @@ +import { IconNames } from '@blueprintjs/icons'; +import * as React from 'react'; + +import { controlButton } from '../commons'; +import { MaterialData } from './storyShape'; + +interface ISelectCellProps { + data: MaterialData; + handleFetchMaterialIndex: (id?: number) => void; +} + +class DownloadCell extends React.Component { + public constructor(props: ISelectCellProps) { + super(props); + } + + public render() { + return ( +
+ {this.props.data.url + ? controlButton(`${this.props.data.title}`, null, this.handleDownload) + : controlButton(`${this.props.data.title}`, IconNames.FOLDER_CLOSE, this.handleSelect)} +
+ ); + } + + private handleDownload = () => { + const url = this.props.data.url; + const click = document.createEvent('Event'); + click.initEvent('click', true, true); + const link = document.createElement('A') as HTMLAnchorElement; + link.href = url; + link.dispatchEvent(click); + link.click(); + return link; + }; + + private handleSelect = () => { + this.props.handleFetchMaterialIndex(this.props.data.id); + }; +} + +export default DownloadCell; diff --git a/src/components/game-dev/Dropzone.tsx b/src/components/game-dev/Dropzone.tsx new file mode 100644 index 0000000000..c445e2e3e6 --- /dev/null +++ b/src/components/game-dev/Dropzone.tsx @@ -0,0 +1,125 @@ +import { Card, EditableText, Elevation } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import { FlexDirectionProperty } from 'csstype'; +import * as React from 'react'; +import { useDropzone } from 'react-dropzone'; + +import { controlButton } from '../commons'; + +interface IDropzoneType { + handleUploadMaterial: (file: File, title: string, description: string) => void; +} + +// Dropzone styling +const dropZoneStyle = { + baseStyle: { + flex: 1, + display: 'flex', + height: '30vh', + flexDirection: 'column' as FlexDirectionProperty, + alignItems: 'center', + justifyContent: 'center', + padding: '20px', + borderWidth: 2, + borderRadius: 2, + borderColor: '#eeeeee', + borderStyle: 'dashed', + backgroundColor: '#fafafa', + color: '#bdbdbd', + outline: 'none', + transition: 'border .24s ease-in-out' + }, + + activeStyle: { + borderColor: '#2196f3' + }, + + acceptStyle: { + borderColor: '#00e676' + }, + + rejectStyle: { + borderColor: '#ff1744' + } +}; + +const MaterialDropzone: React.FC = props => { + const [file, setFile] = React.useState(); + const [title, setTitle] = React.useState(); + const [description, setDescription] = React.useState(''); + const handleSetTitle = (value: string) => setTitle(value); + const handleSetDescription = (value: string) => setDescription(value); + const handleConfirmUpload = () => { + props.handleUploadMaterial(file!, title!, description); + setFile(undefined); + }; + const handleCancelUpload = () => setFile(undefined); + + const { + getRootProps, + getInputProps, + isDragActive, + isDragAccept, + isDragReject, + isFocused + } = useDropzone({ + onDrop: acceptedFiles => { + setFile(acceptedFiles[0]); + setTitle(acceptedFiles[0].name); + } + }); + const style = React.useMemo( + () => ({ + ...dropZoneStyle.baseStyle, + ...(isDragActive ? dropZoneStyle.activeStyle : {}), + ...(isDragAccept ? dropZoneStyle.acceptStyle : {}), + ...(isDragReject ? dropZoneStyle.rejectStyle : {}), + ...(isFocused ? dropZoneStyle.activeStyle : {}) + }), + [isDragActive, isDragAccept, isDragReject, isFocused] + ); + + return ( + <> + +
+ +

Drag 'n' drop some files here, or click to select files

+
+
+ {file && ( + + +
+ +
+ {controlButton('Confirm Upload', IconNames.UPLOAD, handleConfirmUpload)} + {controlButton('Cancel Upload', IconNames.DELETE, handleCancelUpload)} +
+ )} + + ); +}; + +export default MaterialDropzone; diff --git a/src/components/game-dev/JsonUpload.tsx b/src/components/game-dev/JsonUpload.tsx new file mode 100644 index 0000000000..b15a19b704 --- /dev/null +++ b/src/components/game-dev/JsonUpload.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { overrideSessionData } from '../academy/game/backend/game-state.js'; + +class JsonUpload extends React.Component { + private static onFormSubmit(e: { preventDefault: () => void }) { + e.preventDefault(); // Stop form submit + } + + constructor(props: Readonly<{}>) { + super(props); + overrideSessionData(undefined); + JsonUpload.onFormSubmit = JsonUpload.onFormSubmit.bind(this); + this.onChange = this.onChange.bind(this); + } + + public render() { + return ( +
+

Game State Override

+ +
+ ); + } + private onChange(e: { target: any }) { + const reader = new FileReader(); + reader.onloadend = (event: Event) => { + if (typeof reader.result === 'string') { + overrideSessionData(JSON.parse(reader.result)); + } + }; + if (e.target.files && e.target.files[0] instanceof Blob) { + reader.readAsText(e.target.files[0]); + } else { + overrideSessionData(undefined); + e.target.value = null; + } + } +} + +export default JsonUpload; diff --git a/src/components/game-dev/StoryTable.tsx b/src/components/game-dev/StoryTable.tsx new file mode 100644 index 0000000000..990844b780 --- /dev/null +++ b/src/components/game-dev/StoryTable.tsx @@ -0,0 +1,228 @@ +import { + Button, + Card, + Classes, + Divider, + Elevation, + FormGroup, + InputGroup, + NonIdealState, + OverflowList, + Spinner +} from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import { ColDef, GridApi, GridReadyEvent } from 'ag-grid'; +import { AgGridReact } from 'ag-grid-react'; +import 'ag-grid/dist/styles/ag-grid.css'; +import 'ag-grid/dist/styles/ag-theme-material.css'; +import * as classNames from 'classnames'; +import { sortBy } from 'lodash'; +import * as React from 'react'; + +import { getStandardDateTime } from '../../utils/dateHelpers'; +import { controlButton } from '../commons'; +import DeleteCell from './DeleteCell'; +import DownloadCell from './DownloadCell'; +import JsonUpload from './JsonUpload'; +import { DirectoryData, MaterialData } from './storyShape'; + +/** + * Column Definitions are defined within the state, so that data + * can be manipulated easier. See constructor for an example. + */ +type State = { + columnDefs: ColDef[]; + dialogOpen: boolean; + filterValue: string; + groupFilterEnabled: boolean; + newFolderName: string; +}; + +type IMaterialTableProps = IOwnProps; + +export interface IOwnProps { + handleCreateMaterialFolder?: (title: string) => void; + handleDeleteMaterial?: (id: number) => void; + handleDeleteMaterialFolder?: (id: number) => void; + handleFetchMaterialIndex: (id?: number) => void; + handleFetchTestStories: () => void; + materialDirectoryTree: DirectoryData[] | null; + materialIndex: MaterialData[] | null; +} + +class StoryTable extends React.Component { + private gridApi?: GridApi; + + public constructor(props: IMaterialTableProps) { + super(props); + + this.state = { + columnDefs: [ + { + headerName: 'Title', + field: 'title', + cellRendererFramework: DownloadCell, + cellRendererParams: { + handleFetchMaterialIndex: this.props.handleFetchMaterialIndex + }, + width: 800, + suppressMovable: true, + suppressMenu: true, + autoHeight: true, + cellStyle: { + 'text-align': 'left' + } + }, + { + headerName: 'Uploader', + field: 'uploader.name', + width: 400, + suppressMovable: true, + suppressMenu: true + }, + { + headerName: 'Date', + valueGetter: params => getStandardDateTime(params.data.inserted_at), + width: 400, + suppressMovable: true, + suppressMenu: true + }, + { + headerName: 'Delete', + field: '', + cellRendererFramework: DeleteCell, + cellRendererParams: { + handleDeleteMaterial: this.props.handleDeleteMaterial, + handleDeleteMaterialFolder: this.props.handleDeleteMaterialFolder + }, + width: 150, + suppressSorting: true, + suppressMovable: true, + suppressMenu: true, + cellStyle: { + padding: 0 + }, + hide: !this.props.handleDeleteMaterial + }, + { headerName: 'description', field: 'description', hide: true }, + { headerName: 'inserted_at', field: 'inserted_at', hide: true }, + { headerName: 'updated_at', field: 'updated_at', hide: true }, + { headerName: 'url', field: 'url', hide: true } + ], + dialogOpen: false, + filterValue: '', + groupFilterEnabled: false, + newFolderName: '' + }; + } + + public componentDidMount() { + this.props.handleFetchTestStories(); + } + + public render() { + /* Display either a loading screen or a table with overviews. */ + const loadingDisplay = ( + } + /> + ); + const data = sortBy(this.props.materialIndex, [(a: any) => -a.url]); + const grid = ( +
+
+

Story XML files

+ + +
+ +
+
+
+ +
+
+ +
+
+ +
+ + {' '} + +
+ ); + return ( + + {this.props.materialIndex === undefined ? loadingDisplay : grid} + + ); + } + + private renderBreadcrumb = (data: DirectoryData, index: number) => { + return ( + + {controlButton(`${data.title}`, IconNames.CHEVRON_RIGHT, () => + this.props.handleFetchMaterialIndex(data.id) + )} + + ); + }; + + private handleFilterChange = (event: React.ChangeEvent) => { + const changeVal = event.target.value; + this.setState({ filterValue: changeVal }); + + if (this.gridApi) { + this.gridApi.setQuickFilter(changeVal); + } + }; + + private handleTest = () => { + window.open('/academy/game'); + }; + + private handleReset = () => { + window.alert('Are you sure you want to discard all changes made?'); + }; + + private onGridReady = (params: GridReadyEvent) => { + this.gridApi = params.api; + this.gridApi.sizeColumnsToFit(); + window.onresize = () => this.gridApi!.sizeColumnsToFit(); + }; +} + +export default StoryTable; diff --git a/src/components/game-dev/StoryUpload.tsx b/src/components/game-dev/StoryUpload.tsx new file mode 100644 index 0000000000..a876f0af1e --- /dev/null +++ b/src/components/game-dev/StoryUpload.tsx @@ -0,0 +1,46 @@ +import { Divider } from '@blueprintjs/core'; +import * as React from 'react'; + +import Dropzone from './Dropzone'; +import { DirectoryData, MaterialData } from './storyShape'; +import StoryTable from './StoryTable'; + +export interface IStoryProps extends IDispatchProps, IStateProps {} + +export interface IDispatchProps { + handleCreateMaterialFolder: (title: string) => void; + handleDeleteMaterial: (id: number) => void; + handleDeleteMaterialFolder: (id: number) => void; + handleFetchMaterialIndex: (id: number) => void; + handleFetchTestStories: () => void; + handleUploadMaterial: (file: File, title: string, description: string) => void; +} + +export interface IStateProps { + materialDirectoryTree: DirectoryData[] | null; + materialIndex: MaterialData[] | null; +} + +class StoryUpload extends React.Component { + public render() { + return ( +
+
+ + + +
+
+ ); + } +} + +export default StoryUpload; diff --git a/src/components/game-dev/__tests__/StoryTable.tsx b/src/components/game-dev/__tests__/StoryTable.tsx new file mode 100644 index 0000000000..9ce430e508 --- /dev/null +++ b/src/components/game-dev/__tests__/StoryTable.tsx @@ -0,0 +1,24 @@ +import { shallow } from 'enzyme'; +import * as React from 'react'; + +import StoryTable, { IOwnProps } from '../StoryTable'; + +const componentDidMountSpy = jest.fn(); + +jest.spyOn(StoryTable.prototype, 'componentDidMount').mockImplementation(componentDidMountSpy); + +test('Story table renders correctly', () => { + const props: IOwnProps = { + handleCreateMaterialFolder(p1: string) {}, + handleDeleteMaterial(p1: number) {}, + handleDeleteMaterialFolder(p1: number) {}, + handleFetchMaterialIndex(p1?: number) {}, + handleFetchTestStories() {}, + materialDirectoryTree: null, + materialIndex: null + }; + const app = ; + const tree = shallow(app); + expect(tree.debug()).toMatchSnapshot(); + expect(componentDidMountSpy).toHaveBeenCalled(); +}); diff --git a/src/components/game-dev/__tests__/StoryUpload.tsx b/src/components/game-dev/__tests__/StoryUpload.tsx new file mode 100644 index 0000000000..6a0eb90f45 --- /dev/null +++ b/src/components/game-dev/__tests__/StoryUpload.tsx @@ -0,0 +1,20 @@ +import { shallow } from 'enzyme'; +import * as React from 'react'; + +import StoryUpload, { IStoryProps } from '../StoryUpload'; + +test('Story Upload area renders correctly', () => { + const props: IStoryProps = { + handleCreateMaterialFolder(p1: string) {}, + handleDeleteMaterial(p1: number) {}, + handleDeleteMaterialFolder(p1: number) {}, + handleFetchMaterialIndex(p1: number) {}, + handleFetchTestStories() {}, + handleUploadMaterial(p1: File, p2: string, p3: string) {}, + materialDirectoryTree: null, + materialIndex: null + }; + const app = ; + const tree = shallow(app); + expect(tree.debug()).toMatchSnapshot(); +}); diff --git a/src/components/game-dev/__tests__/__snapshots__/StoryTable.tsx.snap b/src/components/game-dev/__tests__/__snapshots__/StoryTable.tsx.snap new file mode 100644 index 0000000000..a703277c0e --- /dev/null +++ b/src/components/game-dev/__tests__/__snapshots__/StoryTable.tsx.snap @@ -0,0 +1,38 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Story table renders correctly 1`] = ` +" +
+
+

+ Story XML files +

+ + +
+ +
+
+
+ +
+
+ +
+
+ +
+ +
+ Open Game in new Window +
+
+ + +
+ Load XML files from AWS +
+
+
+
" +`; diff --git a/src/components/game-dev/__tests__/__snapshots__/StoryUpload.tsx.snap b/src/components/game-dev/__tests__/__snapshots__/StoryUpload.tsx.snap new file mode 100644 index 0000000000..03345312b5 --- /dev/null +++ b/src/components/game-dev/__tests__/__snapshots__/StoryUpload.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Story Upload area renders correctly 1`] = ` +"
+
+ + + +
+
" +`; diff --git a/src/components/game-dev/storyShape.ts b/src/components/game-dev/storyShape.ts new file mode 100644 index 0000000000..63ff9db4e3 --- /dev/null +++ b/src/components/game-dev/storyShape.ts @@ -0,0 +1,17 @@ +export type MaterialData = { + title: string; + description: string; + inserted_at: string; + updated_at: string; + id: number; + uploader: { + id: number; + name: string; + }; + url: string; +}; + +export type DirectoryData = { + id: number; + title: string; +}; diff --git a/src/components/groundControl/DeleteCell.tsx b/src/components/groundControl/DeleteCell.tsx new file mode 100644 index 0000000000..0605614ed6 --- /dev/null +++ b/src/components/groundControl/DeleteCell.tsx @@ -0,0 +1,60 @@ +import { Classes, Dialog } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import * as React from 'react'; + +import { IAssessmentOverview } from '../assessment/assessmentShape'; +import { controlButton } from '../commons'; + +interface IDeleteCellProps { + data: IAssessmentOverview; + handleDeleteAssessment: (id: number) => void; +} + +interface IDeleteCellState { + dialogOpen: boolean; +} + +class DeleteCell extends React.Component { + public constructor(props: IDeleteCellProps) { + super(props); + this.state = { + dialogOpen: false + }; + } + + public render() { + return ( +
+ {controlButton('', IconNames.TRASH, this.handleOpenDialog)} + +
+ {

Are you sure that you want to delete this Assessment?

} + {

Students' answers and submissions will be deleted as well.

} +
+
+
+ {controlButton('Confirm Delete', IconNames.TRASH, this.handleDelete)} + {controlButton('Cancel', IconNames.CROSS, this.handleCloseDialog)} +
+
+
+
+ ); + } + + private handleCloseDialog = () => this.setState({ dialogOpen: false }); + private handleOpenDialog = () => this.setState({ dialogOpen: true }); + private handleDelete = () => { + const { data } = this.props; + this.props.handleDeleteAssessment(data.id); + this.handleCloseDialog(); + }; +} + +export default DeleteCell; diff --git a/src/components/groundControl/Dropzone.tsx b/src/components/groundControl/Dropzone.tsx new file mode 100644 index 0000000000..fc9cf91935 --- /dev/null +++ b/src/components/groundControl/Dropzone.tsx @@ -0,0 +1,151 @@ +import { Card, Elevation, Switch } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import { FlexDirectionProperty } from 'csstype'; +import * as React from 'react'; +import { useDropzone } from 'react-dropzone'; + +import { controlButton } from '../commons'; + +interface IDispatchProps { + handleUploadAssessment: (file: File) => void; + toggleForceUpdate: () => void; + toggleDisplayConfirmation: () => void; +} + +interface IStateProps { + forceUpdate: boolean; + displayConfirmation: boolean; +} + +interface IDropzoneProps extends IDispatchProps, IStateProps {} + +// Dropzone styling +const dropZoneStyle = { + baseStyle: { + flex: 1, + display: 'flex', + height: '30vh', + flexDirection: 'column' as FlexDirectionProperty, + alignItems: 'center', + justifyContent: 'center', + padding: '20px', + borderWidth: 2, + borderRadius: 2, + borderColor: '#eeeeee', + borderStyle: 'dashed', + backgroundColor: '#fafafa', + color: '#bdbdbd', + outline: 'none', + transition: 'border .24s ease-in-out' + }, + + activeStyle: { + borderColor: '#2196f3' + }, + + acceptStyle: { + borderColor: '#00e676' + }, + + rejectStyle: { + borderColor: '#ff1744' + } +}; + +const MaterialDropzone: React.FC = props => { + const [file, setFile] = React.useState(); + const [title, setTitle] = React.useState(); + const handleConfirmUpload = () => { + props.handleUploadAssessment(file!); + setFile(undefined); + }; + const handleCancelUpload = () => setFile(undefined); + + const { + getRootProps, + getInputProps, + isDragActive, + isDragAccept, + isDragReject, + isFocused + } = useDropzone({ + onDrop: acceptedFiles => { + setFile(acceptedFiles[0]); + setTitle(acceptedFiles[0].name); + } + }); + const style = React.useMemo( + () => ({ + ...dropZoneStyle.baseStyle, + ...(isDragActive ? dropZoneStyle.activeStyle : {}), + ...(isDragAccept ? dropZoneStyle.acceptStyle : {}), + ...(isDragReject ? dropZoneStyle.rejectStyle : {}), + ...(isFocused ? dropZoneStyle.activeStyle : {}) + }), + [isDragActive, isDragAccept, isDragReject, isFocused] + ); + + const handleToggleOnChange = () => { + if (!props.forceUpdate) { + props.toggleDisplayConfirmation(); + props.toggleForceUpdate(); + } else { + props.toggleForceUpdate(); + } + }; + + const toggleButton = () => { + return ( +
+ +
+ ); + }; + + const handleConfirmForceUpdate = () => { + props.toggleDisplayConfirmation(); + }; + + const handleCancelForceUpdate = () => { + props.toggleDisplayConfirmation(); + props.toggleForceUpdate(); + }; + + const confirmationMessage = () => { + return ( +
+

Are you sure that you want to force update the assessment?

+ {controlButton('Yes', IconNames.CONFIRM, handleConfirmForceUpdate)} + {controlButton('No', IconNames.CROSS, handleCancelForceUpdate)} +
+ ); + }; + + return ( + <> + +
+ +

Drag 'n' drop some files here, or click to select files

+
+
+ {file && ( + +
{title}
+
+ {!props.displayConfirmation && + controlButton('Confirm Upload', IconNames.UPLOAD, handleConfirmUpload)} + {!props.displayConfirmation && + controlButton('Cancel Upload', IconNames.DELETE, handleCancelUpload)} +
+
+ {!props.displayConfirmation &&

Force update opened assessment

} + {props.displayConfirmation && confirmationMessage()} + {!props.displayConfirmation && toggleButton()} +
+ )} + + ); +}; + +export default MaterialDropzone; diff --git a/src/components/groundControl/EditCell.tsx b/src/components/groundControl/EditCell.tsx new file mode 100644 index 0000000000..ad2a91160c --- /dev/null +++ b/src/components/groundControl/EditCell.tsx @@ -0,0 +1,103 @@ +import { Classes, Dialog } from '@blueprintjs/core'; +import { DateInput } from '@blueprintjs/datetime'; +import { IconNames } from '@blueprintjs/icons'; +import * as React from 'react'; + +import { controlButton } from '../commons'; +import { IGroundControlAssessmentOverview } from './GroundControl'; + +interface IEditCellProps { + data: IGroundControlAssessmentOverview; + handleAssessmentChangeDate: (id: number, openAt: string, closeAt: string) => void; + forOpenDate: boolean; +} + +interface IEditCellState extends IEditCellDateState { + dialogOpen: boolean; +} + +interface IEditCellDateState { + openAt: Date; + closeAt: Date; +} + +class EditCell extends React.Component { + private maxDate = new Date(new Date(Date.now()).setFullYear(2100)); + + public constructor(props: IEditCellProps) { + super(props); + this.state = { + dialogOpen: false, + openAt: new Date(Date.parse(this.props.data.openAt)), + closeAt: new Date(Date.parse(this.props.data.closeAt)) + }; + } + + public render() { + const fieldName = this.props.forOpenDate ? 'Opening' : 'Closing'; + return ( +
+ {this.props.forOpenDate ? this.props.data.prettyOpenAt : this.props.data.prettyCloseAt} + {controlButton('', IconNames.EDIT, this.handleOpenDialog)} + +
+ {fieldName} Date: {this.dateInput()} +
+
+
+ {controlButton('Confirm Update', IconNames.TICK, this.handleUpdate)} + {controlButton('Cancel', IconNames.CROSS, this.handleCloseDialog)} +
+
+
+
+ ); + } + + private dateInput = () => { + return ( + + ); + }; + + private parseDate = (str: string) => new Date(str); + + private formatDate = (date: Date) => date.toLocaleString(); + + private handleDateChange = (selectedDate: Date) => { + if (this.props.forOpenDate) { + this.setState({ openAt: selectedDate }); + } else { + this.setState({ closeAt: selectedDate }); + } + }; + + private handleCloseDialog = () => this.setState({ dialogOpen: false }); + private handleOpenDialog = () => this.setState({ dialogOpen: true }); + private handleUpdate = () => { + const { data } = this.props; + this.props.handleAssessmentChangeDate( + data.id, + this.state.openAt.toISOString(), + this.state.closeAt.toISOString() + ); + this.handleCloseDialog(); + }; +} + +export default EditCell; diff --git a/src/components/groundControl/GroundControl.tsx b/src/components/groundControl/GroundControl.tsx new file mode 100644 index 0000000000..a054424580 --- /dev/null +++ b/src/components/groundControl/GroundControl.tsx @@ -0,0 +1,230 @@ +import { ColDef, GridApi, GridReadyEvent } from 'ag-grid'; +import { AgGridReact } from 'ag-grid-react'; +import 'ag-grid/dist/styles/ag-grid.css'; +import 'ag-grid/dist/styles/ag-theme-balham.css'; +import { sortBy } from 'lodash'; +import * as React from 'react'; + +import { getPrettyDate } from '../../utils/dateHelpers'; +import { IAssessmentOverview } from '../assessment/assessmentShape'; +import ContentDisplay from '../commons/ContentDisplay'; +import DeleteCell from './DeleteCell'; +import Dropzone from './Dropzone'; +import EditCell from './EditCell'; +import PublishCell from './PublishCell'; + +export interface IDispatchProps { + handleAssessmentOverviewFetch: () => void; + handleDeleteAssessment: (id: number) => void; + handleUploadAssessment: (file: File, forceUpdate: boolean) => void; + handlePublishAssessment: (togglePublishTo: boolean, id: number) => void; + handleAssessmentChangeDate: (id: number, openAt: string, closeAt: string) => void; +} + +export interface IGroundControlAssessmentOverview extends IAssessmentOverview { + prettyOpenAt: string; + prettyCloseAt: string; + formattedOpenAt: Date; + formattedCloseAt: Date; +} + +export interface IStateProps { + assessmentOverviews: IAssessmentOverview[]; +} + +export interface IGroundControlProps extends IDispatchProps, IStateProps {} + +interface IGroundControlState { + forceUpdate: boolean; + displayConfirmation: boolean; +} + +class GroundControl extends React.Component { + private columnDefs: ColDef[]; + private gridApi?: GridApi; + + public constructor(props: IGroundControlProps) { + super(props); + this.state = { + forceUpdate: false, + displayConfirmation: false + }; + this.columnDefs = [ + { + headerName: 'Title', + field: 'title' + }, + { + headerName: 'Category', + field: 'category', + width: 100 + }, + { + headerName: 'Open Date', + field: '', + cellRendererFramework: EditCell, + cellRendererParams: { + handleAssessmentChangeDate: this.props.handleAssessmentChangeDate, + forOpenDate: true + }, + width: 150, + suppressSorting: true, + suppressMovable: true, + suppressMenu: true, + cellStyle: { + padding: 0 + } + }, + { + headerName: 'Close Date', + field: '', + cellRendererFramework: EditCell, + cellRendererParams: { + handleAssessmentChangeDate: this.props.handleAssessmentChangeDate, + forOpenDate: false + }, + width: 150, + suppressSorting: true, + suppressMovable: true, + suppressMenu: true, + cellStyle: { + padding: 0 + } + }, + { + headerName: 'Publish', + field: '', + cellRendererFramework: PublishCell, + cellRendererParams: { + handlePublishAssessment: this.props.handlePublishAssessment + }, + width: 100, + suppressSorting: true, + suppressMovable: true, + suppressMenu: true, + cellStyle: { + padding: 0 + }, + hide: !this.props.handlePublishAssessment + }, + { + headerName: 'Delete', + field: '', + cellRendererFramework: DeleteCell, + cellRendererParams: { + handleDeleteAssessment: this.props.handleDeleteAssessment + }, + width: 100, + suppressSorting: true, + suppressMovable: true, + suppressMenu: true, + cellStyle: { + padding: 0 + }, + hide: !this.props.handleDeleteAssessment + } + ]; + } + + public componentDidUpdate(prevProps: IGroundControlProps) { + if ( + this.gridApi && + this.props.assessmentOverviews.length !== prevProps.assessmentOverviews.length + ) { + this.gridApi.setRowData(this.sortByCategoryAndDate()); + } + } + + public render() { + const data = this.sortByCategoryAndDate(); + const Grid = () => ( +
+
+ +
+
+ ); + + const display = ( +
+ + +
+ ); + + return ( +
+ +
+ ); + } + + private sortByCategoryAndDate = () => { + if (!this.props.assessmentOverviews) { + return []; + } + + const overview: IGroundControlAssessmentOverview[] = this.props.assessmentOverviews + .slice() + .map(assessmentOverview => { + const clone: IGroundControlAssessmentOverview = JSON.parse( + JSON.stringify(assessmentOverview) + ); + clone.prettyCloseAt = getPrettyDate(clone.closeAt); + clone.prettyOpenAt = getPrettyDate(clone.openAt); + clone.formattedOpenAt = new Date(Date.parse(clone.openAt)); + clone.formattedCloseAt = new Date(Date.parse(clone.closeAt)); + return clone; + }); + return sortBy(overview, ['category', 'formattedOpenAt', 'formattedCloseAt']); + }; + + private onGridReady = (params: GridReadyEvent) => { + this.gridApi = params.api; + this.gridApi.sizeColumnsToFit(); + }; + + private resizeGrid = () => { + if (this.gridApi) { + this.gridApi.sizeColumnsToFit(); + } + }; + + private handleUploadAssessment = (file: File) => { + this.props.handleUploadAssessment(file, this.state.forceUpdate); + this.setState({ forceUpdate: false }); + }; + + private toggleForceUpdate = () => { + this.setState({ forceUpdate: !this.state.forceUpdate }); + }; + + private toggleDisplayConfirmation = () => { + this.setState({ displayConfirmation: !this.state.displayConfirmation }); + }; +} + +export default GroundControl; diff --git a/src/components/groundControl/PublishCell.tsx b/src/components/groundControl/PublishCell.tsx new file mode 100644 index 0000000000..0874c81688 --- /dev/null +++ b/src/components/groundControl/PublishCell.tsx @@ -0,0 +1,70 @@ +import { Classes, Dialog, Switch } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import * as React from 'react'; + +import { IAssessmentOverview } from '../assessment/assessmentShape'; +import { controlButton } from '../commons'; + +interface IPublishCellProps { + data: IAssessmentOverview; + handlePublishAssessment: (togglePublishTo: boolean, id: number) => void; +} + +interface IPublishCellState { + dialogOpen: boolean; + isPublished: boolean; +} + +class PublishCell extends React.Component { + public constructor(props: IPublishCellProps) { + super(props); + this.state = { + dialogOpen: false, + isPublished: this.props.data.isPublished === undefined ? false : this.props.data.isPublished + }; + } + + public render() { + const text = this.props.data.isPublished ? 'Unpublish' : 'Publish'; + const lowerCaseText = text.toLowerCase(); + const toggleButton = () => { + return ( +
+ +
+ ); + }; + return ( +
+ {toggleButton()} + +
+ {

Are you sure that you want to {lowerCaseText} this Assessment?

} +
+
+
+ {controlButton('Confirm ' + text, IconNames.CONFIRM, this.handleDelete)} + {controlButton('Cancel', IconNames.CROSS, this.handleCloseDialog)} +
+
+
+
+ ); + } + + private handleCloseDialog = () => this.setState({ dialogOpen: false }); + private handleOpenDialog = () => this.setState({ dialogOpen: true }); + private handleDelete = () => { + const { data } = this.props; + this.props.handlePublishAssessment(!data.isPublished, data.id); + this.handleCloseDialog(); + }; +} + +export default PublishCell; diff --git a/src/components/material/Material.tsx b/src/components/material/Material.tsx index bf133d0541..1505068ced 100644 --- a/src/components/material/Material.tsx +++ b/src/components/material/Material.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { DirectoryData, MaterialData } from './materialShape'; import MaterialTable from './MaterialTable'; -interface IMaterialProps extends IDispatchProps, IStateProps {} +export interface IMaterialProps extends IDispatchProps, IStateProps {} export interface IDispatchProps { handleFetchMaterialIndex: (id?: number) => void; diff --git a/src/components/material/MaterialTable.tsx b/src/components/material/MaterialTable.tsx index fb3b0e1f68..ce4ee1180a 100644 --- a/src/components/material/MaterialTable.tsx +++ b/src/components/material/MaterialTable.tsx @@ -40,7 +40,7 @@ type State = { type IMaterialTableProps = IOwnProps; -interface IOwnProps { +export interface IOwnProps { handleCreateMaterialFolder?: (title: string) => void; handleDeleteMaterial?: (id: number) => void; handleDeleteMaterialFolder?: (id: number) => void; diff --git a/src/components/material/MaterialUpload.tsx b/src/components/material/MaterialUpload.tsx index 6a1122f5ed..f63b651ed4 100644 --- a/src/components/material/MaterialUpload.tsx +++ b/src/components/material/MaterialUpload.tsx @@ -5,7 +5,7 @@ import Dropzone from './Dropzone'; import { DirectoryData, MaterialData } from './materialShape'; import MaterialTable from './MaterialTable'; -interface IMaterialProps extends IDispatchProps, IStateProps {} +export interface IMaterialProps extends IDispatchProps, IStateProps {} export interface IDispatchProps { handleCreateMaterialFolder: (title: string) => void; diff --git a/src/components/material/__tests__/Material.tsx b/src/components/material/__tests__/Material.tsx new file mode 100644 index 0000000000..e4919e94a4 --- /dev/null +++ b/src/components/material/__tests__/Material.tsx @@ -0,0 +1,15 @@ +import { shallow } from 'enzyme'; +import * as React from 'react'; + +import Material, { IMaterialProps } from '../Material'; + +test('GameDev page renders correctly', () => { + const props: IMaterialProps = { + handleFetchMaterialIndex(p1: number) {}, + materialDirectoryTree: null, + materialIndex: null + }; + const app = ; + const tree = shallow(app); + expect(tree.debug()).toMatchSnapshot(); +}); diff --git a/src/components/material/__tests__/MaterialTable.tsx b/src/components/material/__tests__/MaterialTable.tsx new file mode 100644 index 0000000000..c7523d2921 --- /dev/null +++ b/src/components/material/__tests__/MaterialTable.tsx @@ -0,0 +1,23 @@ +import { shallow } from 'enzyme'; +import * as React from 'react'; + +import MaterialTable, { IOwnProps } from '../MaterialTable'; + +const componentDidMountSpy = jest.fn(); + +jest.spyOn(MaterialTable.prototype, 'componentDidMount').mockImplementation(componentDidMountSpy); + +test('Material table renders correctly', () => { + const props: IOwnProps = { + handleCreateMaterialFolder(p1: string) {}, + handleDeleteMaterial(p1: number) {}, + handleDeleteMaterialFolder(p1: number) {}, + handleFetchMaterialIndex(p1: number) {}, + materialDirectoryTree: null, + materialIndex: null + }; + const app = ; + const tree = shallow(app); + expect(tree.debug()).toMatchSnapshot(); + expect(componentDidMountSpy).toHaveBeenCalled(); +}); diff --git a/src/components/material/__tests__/MaterialUpload.tsx b/src/components/material/__tests__/MaterialUpload.tsx new file mode 100644 index 0000000000..ef9a26bf16 --- /dev/null +++ b/src/components/material/__tests__/MaterialUpload.tsx @@ -0,0 +1,19 @@ +import { shallow } from 'enzyme'; +import * as React from 'react'; + +import MaterialUpload, { IMaterialProps } from '../MaterialUpload'; + +test('Story Upload area renders correctly', () => { + const props: IMaterialProps = { + handleCreateMaterialFolder(p1: string) {}, + handleDeleteMaterial(p1: number) {}, + handleDeleteMaterialFolder(p1: number) {}, + handleFetchMaterialIndex(p1: number) {}, + handleUploadMaterial(p1: File, p2: string, p3: string) {}, + materialDirectoryTree: null, + materialIndex: null + }; + const app = ; + const tree = shallow(app); + expect(tree.debug()).toMatchSnapshot(); +}); diff --git a/src/components/material/__tests__/__snapshots__/Material.tsx.snap b/src/components/material/__tests__/__snapshots__/Material.tsx.snap new file mode 100644 index 0000000000..d9284365fd --- /dev/null +++ b/src/components/material/__tests__/__snapshots__/Material.tsx.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GameDev page renders correctly 1`] = ` +"
+
+ +
+
" +`; diff --git a/src/components/material/__tests__/__snapshots__/MaterialTable.tsx.snap b/src/components/material/__tests__/__snapshots__/MaterialTable.tsx.snap new file mode 100644 index 0000000000..cd000a05e5 --- /dev/null +++ b/src/components/material/__tests__/__snapshots__/MaterialTable.tsx.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Material table renders correctly 1`] = ` +" +
+
+ + +
+ +
+
+ + Add New Folder + +
+ +
+ +
+
+
+ + Confirm + + + Cancel + +
+
+
+
+
+ +
+
+ +
+
+
+
" +`; diff --git a/src/components/material/__tests__/__snapshots__/MaterialUpload.tsx.snap b/src/components/material/__tests__/__snapshots__/MaterialUpload.tsx.snap new file mode 100644 index 0000000000..cd85d1b57d --- /dev/null +++ b/src/components/material/__tests__/__snapshots__/MaterialUpload.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Story Upload area renders correctly 1`] = ` +"
+
+ + + +
+
" +`; diff --git a/src/components/workspace/Editor.tsx b/src/components/workspace/Editor.tsx old mode 100755 new mode 100644 index 6f4585f440..ec6a5a0fe8 --- a/src/components/workspace/Editor.tsx +++ b/src/components/workspace/Editor.tsx @@ -45,6 +45,8 @@ export interface IEditorProps { handleDeclarationNavigate: (cursorPosition: IPosition) => void; handleEditorEval: () => void; handleEditorValueChange: (newCode: string) => void; + handleReplValueChange?: (newCode: string) => void; + handleReplEval?: () => void; handleEditorUpdateBreakpoints: (breakpoints: string[]) => void; handleFinishInvite?: () => void; handlePromptAutocomplete: (row: number, col: number, callback: any) => void; diff --git a/src/containers/GameContainer.ts b/src/containers/GameContainer.ts index 53388adb31..9306a387c5 100644 --- a/src/containers/GameContainer.ts +++ b/src/containers/GameContainer.ts @@ -1,14 +1,16 @@ import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux'; import { bindActionCreators, Dispatch } from 'redux'; -import { saveCanvas } from '../actions/game'; +import { fetchAssessmentOverviews, saveCanvas, saveUserData } from '../actions'; import Game, { DispatchProps, StateProps } from '../components/academy/game'; import { IState } from '../reducers/states'; const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch) => bindActionCreators( { - handleSaveCanvas: saveCanvas + handleSaveCanvas: saveCanvas, + handleSaveData: saveUserData, + handleAssessmentOverviewFetch: fetchAssessmentOverviews }, dispatch ); @@ -16,7 +18,10 @@ const mapDispatchToProps: MapDispatchToProps = (dispatch: Dis const mapStateToProps: MapStateToProps = state => ({ canvas: state.academy.gameCanvas, name: state.session.name!, - story: state.session.story + story: state.session.story, + gameState: state.session.gameState, + role: state.session.role, + assessmentOverviews: state.session.assessmentOverviews }); export default connect( diff --git a/src/containers/PlaygroundContainer.ts b/src/containers/PlaygroundContainer.ts old mode 100755 new mode 100644 diff --git a/src/containers/game-dev/StoryUploadContainer.ts b/src/containers/game-dev/StoryUploadContainer.ts new file mode 100644 index 0000000000..afd9a9c1b2 --- /dev/null +++ b/src/containers/game-dev/StoryUploadContainer.ts @@ -0,0 +1,37 @@ +import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux'; +import { bindActionCreators, Dispatch } from 'redux'; + +import { + // createMaterialFolder, + deleteMaterial, + deleteMaterialFolder, + fetchMaterialIndex, + fetchTestStories, + uploadMaterial +} from '../../actions'; +import StoryUpload, { IDispatchProps, IStateProps } from '../../components/game-dev/StoryUpload'; +import { IState } from '../../reducers/states'; + +const mapStateToProps: MapStateToProps = state => ({ + materialDirectoryTree: state.session.materialDirectoryTree, + materialIndex: state.session.materialIndex +}); + +const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch) => + bindActionCreators( + { + handleCreateMaterialFolder: (title: string) => null, + handleDeleteMaterial: (id: number) => deleteMaterial(id), + handleDeleteMaterialFolder: (id: number) => deleteMaterialFolder(id), + handleFetchTestStories: () => fetchTestStories(), + handleFetchMaterialIndex: (id?: number) => fetchMaterialIndex(id), + handleUploadMaterial: (file: File, title: string, description: string) => + uploadMaterial(file, title, description) + }, + dispatch + ); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(StoryUpload); diff --git a/src/containers/groundControl/GroundControlContainer.ts b/src/containers/groundControl/GroundControlContainer.ts new file mode 100644 index 0000000000..84610c527d --- /dev/null +++ b/src/containers/groundControl/GroundControlContainer.ts @@ -0,0 +1,36 @@ +import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux'; +import { bindActionCreators, Dispatch } from 'redux'; + +import { + changeDateAssessment, + deleteAssessment, + publishAssessment, + uploadAssessment +} from '../../actions/groundControl'; +import { fetchAssessmentOverviews } from '../../actions/session'; +import GroundControl, { + IDispatchProps, + IStateProps +} from '../../components/groundControl/GroundControl'; +import { IState } from '../../reducers/states'; + +const mapStateToProps: MapStateToProps = state => ({ + assessmentOverviews: state.session.assessmentOverviews ? state.session.assessmentOverviews : [] +}); + +const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch) => + bindActionCreators( + { + handleAssessmentChangeDate: changeDateAssessment, + handleAssessmentOverviewFetch: fetchAssessmentOverviews, + handleDeleteAssessment: deleteAssessment, + handleUploadAssessment: uploadAssessment, + handlePublishAssessment: publishAssessment + }, + dispatch + ); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(GroundControl); diff --git a/src/mocks/backend.ts b/src/mocks/backend.ts index 51a00b3136..8d70a671aa 100644 --- a/src/mocks/backend.ts +++ b/src/mocks/backend.ts @@ -15,7 +15,7 @@ import { NotificationFilterFunction } from '../components/notification/notificationShape'; import { store } from '../createStore'; -import { IState, Role } from '../reducers/states'; +import { GameState, IState, Role } from '../reducers/states'; import { history } from '../utils/history'; import { showSuccessMessage, showWarningMessage } from '../utils/notification'; import { mockAssessmentOverviews, mockAssessments } from './assessmentAPI'; @@ -37,7 +37,8 @@ export function* mockBackendSaga(): SagaIterator { story: 'mission-1', playStory: true }, - grade: 0 + grade: 0, + gameState: {} as GameState }; store.dispatch(actions.setTokens(tokens)); store.dispatch(actions.setUser(user)); diff --git a/src/reducers/__tests__/session.ts b/src/reducers/__tests__/session.ts index 16273b0702..3dd7063aa8 100644 --- a/src/reducers/__tests__/session.ts +++ b/src/reducers/__tests__/session.ts @@ -20,7 +20,7 @@ import { import { Notification } from '../../components/notification/notificationShape'; import { HistoryHelper } from '../../utils/history'; import { reducer } from '../session'; -import { defaultSession, ISessionState, Role, Story } from '../states'; +import { defaultSession, GameState, ISessionState, Role, Story } from '../states'; test('LOG_OUT works correctly on default session', () => { const action = { @@ -56,12 +56,17 @@ test('SET_USER works correctly', () => { story: 'test story', playStory: true }; + const gameState: GameState = { + collectibles: {}, + completed_quests: [] + }; const payload = { name: 'test student', role: Role.Student, group: '42D', grade: 150, - story + story, + gameState }; const action = { diff --git a/src/reducers/session.ts b/src/reducers/session.ts index eb8f510355..2a4c72abfe 100644 --- a/src/reducers/session.ts +++ b/src/reducers/session.ts @@ -4,6 +4,7 @@ import { ActionType } from 'typesafe-actions'; import * as actions from '../actions'; import { LOG_OUT, + SET_GAME_STATE, SET_TOKENS, SET_USER, UPDATE_ASSESSMENT, @@ -88,6 +89,11 @@ export const reducer: Reducer = ( ...state, notifications: action.payload }; + case SET_GAME_STATE: + return { + ...state, + gameState: action.payload + }; default: return state; } diff --git a/src/reducers/states.ts b/src/reducers/states.ts old mode 100755 new mode 100644 index 5be8f2f1dd..8ea5bf4f97 --- a/src/reducers/states.ts +++ b/src/reducers/states.ts @@ -145,7 +145,8 @@ export interface ISessionState { readonly maxXp: number; readonly refreshToken?: string; readonly role?: Role; - readonly story?: Story; + readonly story: Story; + readonly gameState: GameState; readonly name?: string; readonly xp: number; readonly notifications: Notification[]; @@ -164,6 +165,11 @@ export type Story = { playStory: boolean; }; +export type GameState = { + collectibles: { [id: string]: string }; + completed_quests: string[]; +}; + /** * An output while the program is still being run in the interpreter. As a * result, there are no return values or SourceErrors yet. However, there could @@ -435,6 +441,14 @@ export const defaultSession: ISessionState = { refreshToken: undefined, role: undefined, name: undefined, + story: { + story: '', + playStory: false + }, + gameState: { + completed_quests: [], + collectibles: {} + }, xp: 0, notifications: [] }; diff --git a/src/sagas/__tests__/backend.ts b/src/sagas/__tests__/backend.ts index 4781b1afe9..07e9085cca 100644 --- a/src/sagas/__tests__/backend.ts +++ b/src/sagas/__tests__/backend.ts @@ -17,7 +17,7 @@ import { } from '../../mocks/assessmentAPI'; import { mockGroupOverviews } from '../../mocks/groupAPI'; import { mockNotifications } from '../../mocks/userAPI'; -import { Role, Story } from '../../reducers/states'; +import { GameState, Role, Story } from '../../reducers/states'; import { showSuccessMessage, showWarningMessage } from '../../utils/notification'; import backendSaga from '../backend'; import { @@ -70,8 +70,15 @@ describe('Test FETCH_AUTH Action', () => { name: 'user', role: 'student' as Role, group: '42D', - story: {} as Story, - grade: 1 + story: { + story: '', + playStory: false + } as Story, + grade: 1, + gameState: { + collectibles: {}, + completed_quests: [] + } as GameState }; return expectSaga(backendSaga) .call(postAuth, luminusCode) @@ -89,8 +96,15 @@ describe('Test FETCH_AUTH Action', () => { name: 'user', role: 'student' as Role, group: '42D', - story: {} as Story, - grade: 1 + story: { + story: '', + playStory: false + } as Story, + grade: 1, + gameState: { + collectibles: {}, + completed_quests: [] + } as GameState }; return expectSaga(backendSaga) .provide([[call(postAuth, luminusCode), null], [call(getUser, mockTokens), user]]) diff --git a/src/sagas/backend.ts b/src/sagas/backend.ts index 238c5a3a80..142c1866fa 100644 --- a/src/sagas/backend.ts +++ b/src/sagas/backend.ts @@ -3,6 +3,7 @@ import { SagaIterator } from 'redux-saga'; import { call, put, select, takeEvery } from 'redux-saga/effects'; +import { MaterialData } from 'src/components/game-dev/storyShape'; import * as actions from '../actions'; import * as actionTypes from '../actions/actionTypes'; import { WorkspaceLocation } from '../actions/workspaces'; @@ -21,7 +22,7 @@ import { Notification, NotificationFilterFunction } from '../components/notification/notificationShape'; -import { IState, Role } from '../reducers/states'; +import { GameState, IState, Role } from '../reducers/states'; import { history } from '../utils/history'; import { showSuccessMessage, showWarningMessage } from '../utils/notification'; import * as request from './requests'; @@ -596,6 +597,150 @@ function* backendSaga(): SagaIterator { yield put(actions.updateGroupOverviews(groupOverviews)); } }); + + yield takeEvery(actionTypes.CHANGE_DATE_ASSESSMENT, function*( + action: ReturnType + ) { + const tokens = yield select((state: IState) => ({ + accessToken: state.session.accessToken, + refreshToken: state.session.refreshToken + })); + const id = action.payload.id; + const closeAt = action.payload.closeAt; + const openAt = action.payload.openAt; + const respMsg: string | null = yield request.changeDateAssessment(id, closeAt, openAt, tokens); + if (respMsg == null) { + yield request.handleResponseError(respMsg); + return; + } else if (respMsg !== 'OK') { + yield call(showWarningMessage, respMsg, 5000); + return; + } + + yield put(actions.fetchAssessmentOverviews()); + yield call(showSuccessMessage, 'Updated successfully!', 1000); + }); + + yield takeEvery(actionTypes.DELETE_ASSESSMENT, function*( + action: ReturnType + ) { + const tokens = yield select((state: IState) => ({ + accessToken: state.session.accessToken, + refreshToken: state.session.refreshToken + })); + const id = action.payload; + const resp: Response = yield request.deleteAssessment(id, tokens); + + if (!resp || !resp.ok) { + yield request.handleResponseError(resp); + return; + } + + yield put(actions.fetchAssessmentOverviews()); + yield call(showSuccessMessage, 'Deleted successfully!', 1000); + }); + + yield takeEvery(actionTypes.PUBLISH_ASSESSMENT, function*( + action: ReturnType + ) { + const tokens = yield select((state: IState) => ({ + accessToken: state.session.accessToken, + refreshToken: state.session.refreshToken + })); + const id = action.payload.id; + const togglePublishTo = action.payload.togglePublishTo; + const resp: Response = yield request.publishAssessment(id, togglePublishTo, tokens); + + if (!resp || !resp.ok) { + yield request.handleResponseError(resp); + return; + } + + yield put(actions.fetchAssessmentOverviews()); + + if (togglePublishTo) { + yield call(showSuccessMessage, 'Published successfully!', 1000); + } else { + yield call(showSuccessMessage, 'Unpublished successfully!', 1000); + } + }); + + yield takeEvery(actionTypes.UPLOAD_ASSESSMENT, function*( + action: ReturnType + ) { + const tokens = yield select((state: IState) => ({ + accessToken: state.session.accessToken, + refreshToken: state.session.refreshToken + })); + const file = action.payload.file; + const forceUpdate = action.payload.forceUpdate; + const respMsg = yield request.uploadAssessment(file, tokens, forceUpdate); + if (!respMsg) { + yield request.handleResponseError(respMsg); + } else if (respMsg === 'OK') { + yield call(showSuccessMessage, 'Uploaded successfully!', 2000); + } else if (respMsg === 'Force Update OK') { + yield call(showSuccessMessage, 'Assessment force updated successfully!', 2000); + } else { + yield call(showWarningMessage, respMsg, 10000); + return; + } + yield put(actions.fetchAssessmentOverviews()); + }); + + yield takeEvery(actionTypes.FETCH_TEST_STORIES, function*( + action: ReturnType + ) { + const fileName: string = 'Test Stories'; + const tokens = yield select((state: IState) => ({ + accessToken: state.session.accessToken, + refreshToken: state.session.refreshToken + })); + let resp = yield call(request.getMaterialIndex, -1, tokens); + if (resp) { + let materialIndex = resp.index; + let storyFolder = yield materialIndex.find((x: MaterialData) => x.title === fileName); + if (storyFolder === undefined) { + const role = yield select((state: IState) => state.session.role!); + if (role === Role.Student) { + return yield call(showWarningMessage, 'Only staff can create materials folder.'); + } + resp = yield request.postMaterialFolder(fileName, -1, tokens); + if (!resp || !resp.ok) { + yield request.handleResponseError(resp); + return; + } + } + resp = yield call(request.getMaterialIndex, -1, tokens); + if (resp) { + materialIndex = resp.index; + storyFolder = yield materialIndex.find((x: MaterialData) => x.title === fileName); + resp = yield call(request.getMaterialIndex, storyFolder.id, tokens); + if (resp) { + const directory_tree = resp.directory_tree; + materialIndex = resp.index; + yield put(actions.updateMaterialDirectoryTree(directory_tree)); + yield put(actions.updateMaterialIndex(materialIndex)); + } + } + } + }); + + yield takeEvery(actionTypes.SAVE_USER_STATE, function*( + action: ReturnType + ) { + const tokens = yield select((state: IState) => ({ + accessToken: state.session.accessToken, + refreshToken: state.session.refreshToken + })); + const gameState: GameState = action.payload; + const resp = yield request.putUserGameState(gameState, tokens); + if (!resp || !resp.ok) { + yield request.handleResponseError(resp); + return; + } + yield put(actions.setGameState(gameState)); + }); } export default backendSaga; diff --git a/src/sagas/requests.ts b/src/sagas/requests.ts index 4011cf3e6e..8402ef350a 100644 --- a/src/sagas/requests.ts +++ b/src/sagas/requests.ts @@ -2,6 +2,7 @@ /*eslint-env browser*/ import { call } from 'redux-saga/effects'; +import { GameState } from 'src/reducers/states'; import * as actions from '../actions'; import { Grading, @@ -102,6 +103,23 @@ export async function getUser(tokens: Tokens): Promise { return await resp.json(); } +/** + * PUT /user/game_states/ + */ +export async function putUserGameState( + gameStates: GameState, + tokens: Tokens +): Promise { + const resp = await request('user/game_states/save', 'PUT', { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + body: { + gameStates: JSON.stringify(gameStates) + } + }); + return resp; +} + /** * GET /assessments */ @@ -594,6 +612,62 @@ export const postMaterialFolder = async (title: string, parentId: number, tokens return resp; }; +export async function changeDateAssessment( + id: number, + closeAt: string, + openAt: string, + tokens: Tokens +) { + const resp = await request(`assessments/update/${id}`, 'POST', { + accessToken: tokens.accessToken, + body: { closeAt, openAt }, + noHeaderAccept: true, + refreshToken: tokens.refreshToken, + shouldAutoLogout: false, + shouldRefresh: true + }); + return resp ? await resp.text() : null; +} + +export async function deleteAssessment(id: number, tokens: Tokens) { + const resp = await request(`assessments/${id}`, 'DELETE', { + accessToken: tokens.accessToken, + noHeaderAccept: true, + refreshToken: tokens.refreshToken, + shouldAutoLogout: false, + shouldRefresh: true + }); + return resp; +} + +export async function publishAssessment(id: number, togglePublishTo: boolean, tokens: Tokens) { + const resp = await request(`assessments/publish/${id}`, 'POST', { + accessToken: tokens.accessToken, + body: { togglePublishTo }, + noHeaderAccept: true, + refreshToken: tokens.refreshToken, + shouldAutoLogout: false, + shouldRefresh: true + }); + return resp; +} + +export const uploadAssessment = async (file: File, tokens: Tokens, forceUpdate: boolean) => { + const formData = new FormData(); + formData.append('assessment[file]', file); + formData.append('forceUpdate', String(forceUpdate)); + const resp = await request(`assessments`, 'POST', { + accessToken: tokens.accessToken, + body: formData, + noContentType: true, + noHeaderAccept: true, + refreshToken: tokens.refreshToken, + shouldAutoLogout: false, + shouldRefresh: true + }); + return resp ? await resp.text() : null; +}; + export async function getGroupOverviews(tokens: Tokens): Promise { const resp = await request('groups', 'GET', { accessToken: tokens.accessToken, diff --git a/src/setupTests.ts b/src/setupTests.ts index b8acf7f719..2b4b22ba41 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -1,4 +1,4 @@ import { configure } from 'enzyme'; import * as Adapter from 'enzyme-adapter-react-16'; - +import 'jest-canvas-mock'; configure({ adapter: new Adapter() }); diff --git a/src/styles/_groundcontrol.scss b/src/styles/_groundcontrol.scss new file mode 100644 index 0000000000..057f083749 --- /dev/null +++ b/src/styles/_groundcontrol.scss @@ -0,0 +1,7 @@ +.GroundControl { + .toggle-button-wrapper { + margin-left: auto; + margin-right: auto; + width: 20px; + } +} diff --git a/src/styles/index.scss b/src/styles/index.scss index b276917cf3..b255120a54 100755 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -1,5 +1,6 @@ @import '~normalize.css/normalize.css'; @import '~@blueprintjs/core/lib/css/blueprint.css'; +@import '~@blueprintjs/datetime/lib/css/blueprint-datetime.css'; @import '~flexboxgrid/dist/flexboxgrid.css'; @import '~flexboxgrid-helpers/dist/flexboxgrid-helpers.min.css'; @@ -23,6 +24,7 @@ @import 'contributors'; @import 'dropdown'; @import 'game'; +@import 'groundcontrol'; @import 'login'; @import 'material'; @import 'navigationBar'; diff --git a/src/utils/constants.ts b/src/utils/constants.ts old mode 100755 new mode 100644