diff --git a/.gitlab/client.gitlab-ci.yml b/.gitlab/client.gitlab-ci.yml index f35e73aa6da4b396dda7ca7b5847b83158078a41..a64f1bd85c1bba81556cc1f8fb8052cf14ac40cd 100644 --- a/.gitlab/client.gitlab-ci.yml +++ b/.gitlab/client.gitlab-ci.yml @@ -46,7 +46,7 @@ client:test: - merge_requests script: - cd client - - npm run test:coverage + - npm run unit-test:coverage coverage: /All files\s*\|\s*([\d\.]+)/ artifacts: paths: diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 8b7286d134089b693c07ce77c748073701865719..45f88f114c1419ec24f9794c6d2568ad61c61e1a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,102 +1,109 @@ { - "version": "2.0.0", - "tasks": [ - { - "label": "Start client", - "type": "npm", - "script": "start", - "path": "client/", - "group": "build", - "problemMatcher": [], - "presentation": { - "group": "Client/Server" - } - }, - { - "label": "Test client", - "type": "npm", - "script": "test:coverage:html", - "path": "client/", - "group": "build", - "problemMatcher": [], - }, - { - "label": "Open client coverage", - "type": "shell", - "command": "start ./output/coverage/jest/index.html", - "problemMatcher": [], - "options": { - "cwd": "${workspaceFolder}/client" - }, - }, - { - "label": "Start server", - "type": "shell", - "group": "build", - "command": "env/Scripts/python main.py", - "problemMatcher": [], - "options": { - "cwd": "${workspaceFolder}/server" - }, - "presentation": { - "group": "Client/Server" - } - }, - { - "label": "Test server", - "type": "shell", - "group": "build", - "command": "env/Scripts/pytest.exe --cov-report html --cov app tests/", - "problemMatcher": [], - "options": { - "cwd": "${workspaceFolder}/server" - }, - }, - { - "label": "Populate database", - "type": "shell", - "group": "build", - "command": "env/Scripts/python populate.py", - "problemMatcher": [], - "options": { - "cwd": "${workspaceFolder}/server" - }, - }, - { - "label": "Open server coverage", - "type": "shell", - "command": "start ./htmlcov/index.html", - "problemMatcher": [], - "options": { - "cwd": "${workspaceFolder}/server" - }, - }, - { - "label": "Generate server documentation", - "type": "shell", - "command": "../env/Scripts/activate; ./make html", - "problemMatcher": [], - "options": { - "cwd": "${workspaceFolder}/server/docs" - }, - }, - { - "label": "Open server documentation", - "type": "shell", - "command": "start index.html", - "problemMatcher": [], - "options": { - "cwd": "${workspaceFolder}/server/docs/build/html" - }, - }, - { - "label": "Start client and server", - "group": "build", - "dependsOn": [ - "Start server", - "Start client" - ], - "problemMatcher": [] - } - ] -} \ No newline at end of file + "version": "2.0.0", + "tasks": [ + { + "label": "Start client", + "type": "npm", + "script": "start", + "path": "client/", + "group": "build", + "problemMatcher": [], + "presentation": { + "group": "Client/Server" + } + }, + { + "label": "Unit tests", + "type": "npm", + "script": "unit-test:coverage:html", + "path": "client/", + "group": "build", + "detail": "Run unit tests on client", + "problemMatcher": [] + }, + { + "label": "Run e2e tests", + "type": "npm", + "script": "e2e-test", + "path": "client/", + "group": "build", + "problemMatcher": [], + "detail": "Make sure client and server is running before executing." + }, + { + "label": "Open client coverage", + "type": "shell", + "command": "start ./output/coverage/jest/index.html", + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}/client" + } + }, + { + "label": "Start server", + "type": "shell", + "group": "build", + "command": "env/Scripts/python main.py", + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}/server" + }, + "presentation": { + "group": "Client/Server" + } + }, + { + "label": "Test server", + "type": "shell", + "group": "build", + "command": "env/Scripts/pytest.exe --cov-report html --cov app tests/", + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}/server" + } + }, + { + "label": "Populate database", + "type": "shell", + "group": "build", + "command": "env/Scripts/python populate.py", + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}/server" + } + }, + { + "label": "Open server coverage", + "type": "shell", + "command": "start ./htmlcov/index.html", + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}/server" + } + }, + { + "label": "Generate server documentation", + "type": "shell", + "command": "../env/Scripts/activate; ./make html", + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}/server/docs" + } + }, + { + "label": "Open server documentation", + "type": "shell", + "command": "start index.html", + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}/server/docs/build/html" + } + }, + { + "label": "Start client and server", + "group": "build", + "dependsOn": ["Start server", "Start client"], + "problemMatcher": [] + } + ] +} diff --git a/README.md b/README.md index 56ea97f2b1afc129c0221d36ce6978802a250d89..10a4debc16e5edc64dea58216139c2913884a0cd 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,3 @@ - - - - - - # Scoring system for Teknikåttan This is the scoring system for Teknikåttan! diff --git a/client/package-lock.json b/client/package-lock.json index 2655ad2e5dbc1fb024ba896a3701f49adbfe399d..d857520c196037dc01bf9583d906ddaf9ed11d00 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -2705,6 +2705,15 @@ "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-20.2.0.tgz", "integrity": "sha512-37RSHht+gzzgYeobbG+KWryeAW8J33Nhr69cjTqSYymXVZEN9NbRYWoYlRtDhHKPVT1FyNKwaTPC1NynKZpzRA==" }, + "@types/yauzl": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.1.tgz", + "integrity": "sha512-A1b8SU4D10uoPjwb0lnHmmu8wZhR9d+9o2PKBQT2jU5YPTKsxac6M2qGAdY7VcL+dHHhARVUDmeg0rOrcd9EjA==", + "optional": true, + "requires": { + "@types/node": "*" + } + }, "@typescript-eslint/eslint-plugin": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.2.0.tgz", @@ -3134,6 +3143,14 @@ "regex-parser": "^2.2.11" } }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "requires": { + "debug": "4" + } + }, "aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -4094,6 +4111,27 @@ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "optional": true }, + "bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + }, + "dependencies": { + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + } + } + }, "bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -4295,6 +4333,11 @@ "isarray": "^1.0.0" } }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=" + }, "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", @@ -5805,6 +5848,11 @@ } } }, + "devtools-protocol": { + "version": "0.0.869402", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.869402.tgz", + "integrity": "sha512-VvlVYY+VDJe639yHs5PHISzdWTLL3Aw8rO4cvUtwvoxFd6FHbE4OpHHcde52M6096uYYazAmd4l0o5VuFRO2WA==" + }, "diff-sequences": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", @@ -7357,6 +7405,27 @@ } } }, + "extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "requires": { + "@types/yauzl": "^2.9.1", + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "dependencies": { + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "requires": { + "pump": "^3.0.0" + } + } + } + }, "extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", @@ -7425,6 +7494,14 @@ "bser": "2.1.1" } }, + "fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=", + "requires": { + "pend": "~1.2.0" + } + }, "figgy-pudding": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz", @@ -7785,6 +7862,11 @@ } } }, + "fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, "fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -8497,6 +8579,15 @@ "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=" }, + "https-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "requires": { + "agent-base": "6", + "debug": "4" + } + }, "human-signals": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", @@ -11349,6 +11440,11 @@ "minimist": "^1.2.5" } }, + "mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, "moo": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.1.tgz", @@ -11494,6 +11590,11 @@ } } }, + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" + }, "node-forge": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", @@ -12133,6 +12234,11 @@ "sha.js": "^2.4.8" } }, + "pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=" + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -13434,6 +13540,11 @@ "ipaddr.js": "1.9.1" } }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -13499,6 +13610,35 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, + "puppeteer": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-9.1.1.tgz", + "integrity": "sha512-W+nOulP2tYd/ZG99WuZC/I5ljjQQ7EUw/jQGcIb9eu8mDlZxNY2SgcJXTLG9h5gRvqA3uJOe4hZXYsd3EqioMw==", + "requires": { + "debug": "^4.1.0", + "devtools-protocol": "0.0.869402", + "extract-zip": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.1", + "pkg-dir": "^4.2.0", + "progress": "^2.0.1", + "proxy-from-env": "^1.1.0", + "rimraf": "^3.0.2", + "tar-fs": "^2.0.0", + "unbzip2-stream": "^1.3.3", + "ws": "^7.2.3" + }, + "dependencies": { + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "requires": { + "find-up": "^4.0.0" + } + } + } + }, "q": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", @@ -15976,6 +16116,36 @@ } } }, + "tar-fs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "requires": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + }, + "dependencies": { + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + } + } + }, + "tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "requires": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + } + }, "temp-dir": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", @@ -16143,6 +16313,11 @@ "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==" }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" + }, "through2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", @@ -16419,6 +16594,26 @@ "which-boxed-primitive": "^1.0.1" } }, + "unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "requires": { + "buffer": "^5.2.1", + "through": "^2.3.8" + }, + "dependencies": { + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + } + } + }, "unicode-canonical-property-names-ecmascript": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", @@ -18280,6 +18475,15 @@ } } }, + "yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=", + "requires": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "yeast": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz", diff --git a/client/package.json b/client/package.json index a0c8b7f5c74a6587f48cdf6c7d478311f2529185..7426424b33775f3845151df9163b10cd94f6f494 100644 --- a/client/package.json +++ b/client/package.json @@ -22,6 +22,7 @@ "axios": "^0.21.1", "formik": "^2.2.6", "jwt-decode": "^3.1.2", + "puppeteer": "^9.1.1", "react": "^17.0.1", "react-axios": "^2.0.4", "react-beautiful-dnd": "^13.1.0", @@ -70,8 +71,9 @@ "test": "react-scripts test", "eject": "react-scripts eject", "lint": "eslint \"./src/**/*.{ts,tsx}\"", - "test:coverage": "react-scripts test --coverage --coverageDirectory=output/coverage/jest", - "test:coverage:html": "npm test -- --coverage --watchAll=false --coverageDirectory=output/coverage/jest" + "unit-test:coverage": "react-scripts test --coverage --testPathIgnorePatterns=src/e2e --coverageDirectory=output/coverage/jest", + "unit-test:coverage:html": "npm test -- --testPathIgnorePatterns=src/e2e --coverage --watchAll=false --coverageDirectory=output/coverage/jest", + "e2e-test": "npm test -- --testPathPattern=src/e2e" }, "browserslist": { "production": [ @@ -89,6 +91,7 @@ "collectCoverageFrom": [ "src/**/*.{tsx,ts}", "!src/index.tsx", + "!src/e2e/*", "!src/reportWebVitals.ts", "!src/components/TestConnection.tsx" ], diff --git a/client/public/logo192.png b/client/public/logo192.png deleted file mode 100644 index fc44b0a3796c0e0a64c3d858ca038bd4570465d9..0000000000000000000000000000000000000000 Binary files a/client/public/logo192.png and /dev/null differ diff --git a/client/public/logo512.png b/client/public/logo512.png deleted file mode 100644 index a4e47a6545bc15971f8f63fba70e4013df88a664..0000000000000000000000000000000000000000 Binary files a/client/public/logo512.png and /dev/null differ diff --git a/client/public/manifest.json b/client/public/manifest.json index 080d6c77ac21bb2ef88a6992b2b73ad93daaca92..73371289ff660726868cc23f5f2ab7f275bba3cc 100644 --- a/client/public/manifest.json +++ b/client/public/manifest.json @@ -1,21 +1,11 @@ { - "short_name": "React App", - "name": "Create React App Sample", + "short_name": "Teknikåttan", + "name": "Teknikåttan Scoring System", "icons": [ { "src": "favicon.ico", "sizes": "64x64 32x32 24x24 16x16", "type": "image/x-icon" - }, - { - "src": "logo192.png", - "type": "image/png", - "sizes": "192x192" - }, - { - "src": "logo512.png", - "type": "image/png", - "sizes": "512x512" } ], "start_url": ".", diff --git a/client/src/actions/competitionLogin.test.ts b/client/src/actions/competitionLogin.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf1825e7e02445c2b96b22b5bb0a169e723dc2a4 --- /dev/null +++ b/client/src/actions/competitionLogin.test.ts @@ -0,0 +1,76 @@ +import mockedAxios from 'axios' +import expect from 'expect' // You can use any testing library +import { createMemoryHistory } from 'history' +import configureMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' +import { loginCompetition, logoutCompetition } from './competitionLogin' +import Types from './types' + +const middlewares = [thunk] +const mockStore = configureMockStore(middlewares) + +it('dispatches correct actions when logging into competition', async () => { + const compRes: any = { + data: { + id: 5, + slides: [], + }, + } + const compLoginDataRes: any = { + data: { + access_token: 'TEST_ACCESS_TOKEN', + competition_id: 'test_name', + team_id: 'test_team', + view: 'test_view', + }, + } + ;(mockedAxios.post as jest.Mock).mockImplementation(() => { + return Promise.resolve(compLoginDataRes) + }) + ;(mockedAxios.get as jest.Mock).mockImplementation(() => { + return Promise.resolve(compRes) + }) + const expectedActions = [ + { type: Types.LOADING_COMPETITION_LOGIN }, + { type: Types.CLEAR_COMPETITION_LOGIN_ERRORS }, + { + type: Types.SET_COMPETITION_LOGIN_DATA, + payload: { + competition_id: compLoginDataRes.data.competition_id, + team_id: compLoginDataRes.data.team_id, + view: compLoginDataRes.data.view, + }, + }, + { type: Types.SET_PRESENTATION_COMPETITION, payload: compRes.data }, + ] + const store = mockStore({}) + const history = createMemoryHistory() + await loginCompetition('code', history, true)(store.dispatch, store.getState as any) + expect(store.getActions()).toEqual(expectedActions) +}) + +it('dispatches correct action when logging out from competition', async () => { + ;(mockedAxios.post as jest.Mock).mockImplementation(() => { + return Promise.resolve({ data: {} }) + }) + const store = mockStore({}) + await logoutCompetition()(store.dispatch) + expect(store.getActions()).toEqual([{ type: Types.SET_COMPETITION_LOGIN_UNAUTHENTICATED }]) +}) + +it('dispatches correct action when failing to log in user', async () => { + console.log = jest.fn() + const errorMessage = 'getting teams failed' + ;(mockedAxios.post as jest.Mock).mockImplementation(() => { + return Promise.reject({ response: { data: errorMessage } }) + }) + const store = mockStore({}) + const history = createMemoryHistory() + const expectedActions = [ + { type: Types.LOADING_COMPETITION_LOGIN }, + { type: Types.SET_COMPETITION_LOGIN_ERRORS, payload: errorMessage }, + ] + await loginCompetition('code', history, true)(store.dispatch, store.getState as any) + expect(store.getActions()).toEqual(expectedActions) + expect(console.log).toHaveBeenCalled() +}) diff --git a/client/src/actions/presentation.test.ts b/client/src/actions/presentation.test.ts index d95d9db767b214e4e2583a1a903ed06b226eec16..095a352952680810cfcb3fc0f8f48a5eabc6b1a6 100644 --- a/client/src/actions/presentation.test.ts +++ b/client/src/actions/presentation.test.ts @@ -20,13 +20,13 @@ it('dispatches no actions when failing to get competitions', async () => { return Promise.reject(new Error('getting competitions failed')) }) const store = mockStore({ competitions: { filterParams: [] } }) - await getPresentationCompetition('0')(store.dispatch) + await getPresentationCompetition('0')(store.dispatch, store.getState as any) expect(store.getActions()).toEqual([]) expect(console.log).toHaveBeenCalled() }) it('dispatches correct actions when setting slide', () => { - const testSlide: Slide = { competition_id: 0, id: 5, order: 5, timer: 20, title: '', background_image_id: 0 } + const testSlide: Slide = { competition_id: 0, id: 5, order: 5, timer: 20, title: '', background_image: undefined } const expectedActions = [{ type: Types.SET_PRESENTATION_SLIDE, payload: testSlide }] const store = mockStore({}) setCurrentSlide(testSlide)(store.dispatch) diff --git a/client/src/actions/presentation.ts b/client/src/actions/presentation.ts index 90b728c54aa18a483ed6ab000f718c407b46abfd..32e4d0a4334c4639624588f005b704dbca160ae9 100644 --- a/client/src/actions/presentation.ts +++ b/client/src/actions/presentation.ts @@ -17,7 +17,7 @@ export const getPresentationCompetition = (id: string) => async (dispatch: AppDi type: Types.SET_PRESENTATION_COMPETITION, payload: res.data, }) - if (getState().presentation.slide.id === -1 && res.data.slides[0]) { + if (getState().presentation?.slide.id === -1 && res.data?.slides[0]) { setCurrentSlideByOrder(0)(dispatch) } }) diff --git a/client/src/components/TestConnection.tsx b/client/src/components/TestConnection.tsx deleted file mode 100644 index 7fa4aaeaab31902640232d60b25139a0c26f078e..0000000000000000000000000000000000000000 --- a/client/src/components/TestConnection.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import axios from 'axios' -import React, { useEffect, useState } from 'react' - -interface Message { - message: string -} - -const TestConnection: React.FC = () => { - const [currentMessage, setCurrentMessage] = useState<Message>() - useEffect(() => { - axios.get<Message>('users/test').then((response) => { - setCurrentMessage(response.data) - }) - }, []) - return <p>Connection with server is: {currentMessage?.message}</p> -} - -export default TestConnection diff --git a/client/src/e2e/AdminPage.test.tsx b/client/src/e2e/AdminPage.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c9c67580ba029c41c9849a6b057cfcdba25dd2a0 --- /dev/null +++ b/client/src/e2e/AdminPage.test.tsx @@ -0,0 +1,176 @@ +import puppeteer from 'puppeteer' +import { CLIENT_URL, DEVTOOLS_ENABLED, HEADLESS_ENABLED, SLOW_DOWN_FACTOR } from './TestingConstants' + +describe('Admin page', () => { + const userEmailSelector = '[data-testid="userEmail"]' + const buttonSelector = '[data-testid="submit"]' + const emailSelector = '[data-testid="email"]' + const passwordSelector = '[data-testid="password"]' + let browser: puppeteer.Browser + let page: puppeteer.Page + jest.setTimeout(10000) + beforeEach(async () => { + // Set up testing environment + browser = await puppeteer.launch({ + headless: HEADLESS_ENABLED, + devtools: DEVTOOLS_ENABLED, + slowMo: SLOW_DOWN_FACTOR, + }) + page = await browser.newPage() + + //Navigate to login screen and log in + await page.goto(CLIENT_URL) + await page.waitForSelector('.MuiFormControl-root') + await page.click(emailSelector) + await page.keyboard.type('admin@test.se') + await page.click(passwordSelector) + await page.keyboard.type('password') + await page.click(buttonSelector) + await page.waitForTimeout(2000) + }) + + afterEach(async () => { + await browser.close() + }) + + it('Should show correct email on welcome screen', async () => { + const AdminTitle = await page.evaluate((sel) => { + return document.querySelector(sel).innerText + }, userEmailSelector) + expect(AdminTitle).toEqual('Email: admin@test.se') + }, 9000000) + + it('Should be able to add and remove region', async () => { + const regionTabSelector = '[data-testid="Regioner"]' + const regionTextFieldSelector = '[data-testid="regionTextField"]' + const regionSubmitButton = '[data-testid="regionSubmitButton"]' + const testRegionName = 'New region test' + const testRegionSelector = `[data-testid="${testRegionName}"]` + const removeRegionButtonSelector = '[data-testid="removeRegionButton"]' + //Navigate to region tab + + await page.click(regionTabSelector) + await page.waitForSelector('.MuiFormControl-root') + + //Make sure the test region isnt already in the list + let regions = await page.$$eval('.MuiTableRow-root > td', (elList) => elList.map((p) => p.textContent)) + expect(regions).not.toContain(testRegionName) + + //Add the test region to the list and make sure it's present + await page.click(regionTextFieldSelector) + await page.keyboard.type(testRegionName) + await page.click(regionSubmitButton) + await page.waitForTimeout(1000) + regions = await page.$$eval('.MuiTableRow-root > td', (elList) => elList.map((p) => p.textContent)) + expect(regions).toContain(testRegionName) + + //Remove the test region from the list and make sure it's gone + await page.click(testRegionSelector) + await page.click(removeRegionButtonSelector) + await page.waitForTimeout(1000) + regions = await page.$$eval('.MuiTableRow-root > td', (elList) => elList.map((p) => p.textContent)) + expect(regions).not.toContain(testRegionName) + }, 9000000) + + it('Should be able to add and remove a user', async () => { + const userTabSelector = '[data-testid="Användare"]' + const addUserButtonSelector = '[data-testid="addUserButton"]' + const addUserEmailSelector = '[data-testid="addUserEmail"]' + const addUserPasswordSelector = '[data-testid="addUserPassword"]' + const addUserNameSelector = '[data-testid="addUserName"]' + const userCitySelectSelector = '[data-testid="userCitySelect"]' + const userRoleSelectSelector = '[data-testid="userRoleSelect"]' + const addUserSubmitSelector = '[data-testid="addUserSubmit"]' + const removeUserSelector = '[data-testid="removeUser"]' + const accceptRemoveUserSelector = '[data-testid="acceptRemoveUser"]' + + const testUserEmail = 'NewUser@test.test' + const testUserPassword = 'TestPassword' + const testUserName = 'TestUserName' + const testUserCity = 'Linköping' + const testUserRole = 'Admin' + + const userCitySelector = `[data-testid="${testUserCity}"]` + const userRoleSelector = `[data-testid="${testUserRole}"]` + const moreSelector = `[data-testid="more-${testUserEmail}"]` + + //Navigate to user tab + await page.click(userTabSelector) + await page.waitForSelector(addUserButtonSelector) + //Make sure the test user isnt already in the list + let emails = await page.$$eval('.MuiTableRow-root > td', (elList) => elList.map((p) => p.textContent)) + expect(emails).not.toContain(testUserEmail) + + //Add the test user to the list and make sure it's present + await page.click(addUserButtonSelector) + await page.click(addUserEmailSelector) + await page.keyboard.type(testUserEmail) + await page.click(addUserPasswordSelector) + await page.keyboard.type(testUserPassword) + await page.click(addUserNameSelector) + await page.keyboard.type(testUserName) + await page.click(userCitySelectSelector) + await page.click(userCitySelector) + await page.waitForTimeout(100) + await page.click(userRoleSelectSelector) + await page.waitForTimeout(100) + await page.click(userRoleSelector) + await page.waitForTimeout(100) + await page.click(addUserSubmitSelector) + await page.waitForTimeout(1000) + emails = await page.$$eval('.MuiTableRow-root > td', (elList) => elList.map((p) => p.textContent)) + expect(emails).toContain(testUserEmail) + + //Remove the test user from the list and make sure it's gone + await page.click(moreSelector) + await page.click(removeUserSelector) + await page.click(accceptRemoveUserSelector) + await page.waitForTimeout(1000) + emails = await page.$$eval('.MuiTableRow-root > td', (elList) => elList.map((p) => p.textContent)) + expect(emails).not.toContain(testUserEmail) + }, 9000000) + + it('Should be able to add and remove competition', async () => { + const competitionsTabSelector = '[data-testid="Tävlingshanterare"]' + const addCompetitionsButtonSelector = '[data-testid="addCompetition"]' + const competitionNameSelector = '[data-testid="competitionName"]' + const competitionRegionSelectSelector = '[data-testid="competitionRegion"]' + const acceptAddCompetition = '[data-testid="acceptCompetition"]' + const removeCompetitionButtonSelector = '[data-testid="removeCompetitionButton"]' + + const testCompetitionName = 'New test competition' + const testCompetitionRegion = 'Linköping' + + const testCompetitionRegionSelector = `[data-testid="${testCompetitionRegion}"]` + const testCompetitionSelector = `[data-testid="${testCompetitionName}"]` + //Navigate to competitionManager tab + await page.click(competitionsTabSelector) + await page.waitForSelector('.MuiFormControl-root') + + //Make sure the test region isnt already in the list + let competitions = await page.$$eval('.MuiTableRow-root > td', (elList) => elList.map((p) => p.textContent)) + expect(competitions).not.toContain(testCompetitionName) + + //Add the test region to the list and make sure it's present + await page.click(addCompetitionsButtonSelector) + await page.click(competitionNameSelector) + await page.keyboard.type(testCompetitionName) + await page.click(competitionRegionSelectSelector) + await page.waitForTimeout(100) + await page.click(testCompetitionRegionSelector) + await page.waitForTimeout(100) + await page.click(acceptAddCompetition) + await page.waitForTimeout(1000) + + competitions = await page.$$eval('.MuiTableRow-root > td', (elList) => elList.map((p) => p.textContent)) + expect(competitions).toContain(testCompetitionName) + + //Remove the test region from the list and make sure it's gone + await page.click(testCompetitionSelector) + await page.waitForTimeout(100) + await page.click(removeCompetitionButtonSelector) + await page.waitForTimeout(1000) + competitions = await page.$$eval('.MuiTableRow-root > td', (elList) => elList.map((p) => p.textContent)) + expect(competitions).not.toContain(testCompetitionName) + }, 9000000) +}) diff --git a/client/src/e2e/LoginPage.test.tsx b/client/src/e2e/LoginPage.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6a60ff6bd50aec0965f670126437b393774da38b --- /dev/null +++ b/client/src/e2e/LoginPage.test.tsx @@ -0,0 +1,70 @@ +import puppeteer from 'puppeteer' +import { CLIENT_URL, DEVTOOLS_ENABLED, HEADLESS_ENABLED, SLOW_DOWN_FACTOR } from './TestingConstants' + +describe('Login page', () => { + const buttonSelector = '[data-testid="submit"]' + const emailSelector = '[data-testid="email"]' + const passwordSelector = '[data-testid="password"]' + let browser: puppeteer.Browser + let page: puppeteer.Page + beforeEach(async () => { + // Set up testing environment + browser = await puppeteer.launch({ + headless: HEADLESS_ENABLED, + devtools: DEVTOOLS_ENABLED, + slowMo: SLOW_DOWN_FACTOR, + }) + page = await browser.newPage() + + //Navigate to login screen + await page.goto(CLIENT_URL) + await page.waitForSelector('.MuiFormControl-root') + }) + + afterEach(async () => { + await browser.close() + }) + + it('Can submit login user with correct credentials', async () => { + await page.click(emailSelector) + await page.keyboard.type('admin@test.se') + await page.click(passwordSelector) + await page.keyboard.type('password') + await page.click(buttonSelector) + await page.waitForTimeout(4000) + const AdminTitle = await page.$eval('.MuiTypography-root', (el) => el.textContent) + expect(AdminTitle).toEqual('Startsida') + }, 9000000) + + it('Shows correct error message when logging in user with incorrect credentials', async () => { + await page.click(emailSelector) + await page.keyboard.type('wrong@wrong.se') + await page.click(passwordSelector) + await page.keyboard.type('wrongPassword') + await page.click(buttonSelector) + await page.waitForTimeout(1000) + const errorMessages = await page.$$eval('.MuiAlert-message > p', (elList) => elList.map((p) => p.textContent)) + // The error message is divided into two p elements + const errorMessageRow1 = errorMessages[0] + const errorMessageRow2 = errorMessages[1] + expect(errorMessageRow1).toEqual('Någonting gick fel. Kontrollera') + expect(errorMessageRow2).toEqual('dina användaruppgifter och försök igen') + }, 9000000) + + it('Shows correct error message when email is in incorrect format', async () => { + await page.click(emailSelector) + await page.keyboard.type('email') + await page.click(passwordSelector) + await page.waitForTimeout(1000) + const helperText = await page.$eval('.MuiFormHelperText-root', (el) => el.textContent) + expect(helperText).toEqual('Email inte giltig') + }, 9000000) + + it('Shows correct error message when password is too short (<6 chars)', async () => { + await page.click(passwordSelector) + await page.keyboard.type('short') + await page.click(emailSelector) + const helperText = await page.$eval('.MuiFormHelperText-root', (el) => el.textContent) + expect(helperText).toEqual('Lösenord måste vara minst 6 tecken') + }, 9000000) +}) diff --git a/client/src/e2e/TestingConstants.ts b/client/src/e2e/TestingConstants.ts new file mode 100644 index 0000000000000000000000000000000000000000..3122aa2126c17021211ddd88cf0936ec0111cdf9 --- /dev/null +++ b/client/src/e2e/TestingConstants.ts @@ -0,0 +1,6 @@ +/** This file includes constants for e2e testing */ + +export const HEADLESS_ENABLED = false +export const DEVTOOLS_ENABLED = false +export const SLOW_DOWN_FACTOR = 20 +export const CLIENT_URL = 'http://localhost:3000/' diff --git a/client/src/enum/ComponentTypes.ts b/client/src/enum/ComponentTypes.ts index c0aa738479a2c081ef0529bb6b4a317570b6841d..7ff75bd8d867762550dd9bcb6997038ac8d4b233 100644 --- a/client/src/enum/ComponentTypes.ts +++ b/client/src/enum/ComponentTypes.ts @@ -1,5 +1,5 @@ export enum ComponentTypes { Text = 1, Image, - QuestionAlternative, + Question, } diff --git a/client/src/index.tsx b/client/src/index.tsx index fdd9cf8e2bc1d9655a060c4270091fbf5f13e236..cb658ebf015a5f33f4844d280f358d348cf24a0e 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -3,7 +3,6 @@ import ReactDOM from 'react-dom' import { Provider } from 'react-redux' import App from './App' import './index.css' -import reportWebVitals from './reportWebVitals' import store from './store' // Provider wraps the app component so that it can access store @@ -15,8 +14,3 @@ ReactDOM.render( </Provider>, document.getElementById('root') ) - -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals() diff --git a/client/src/interfaces/ApiModels.ts b/client/src/interfaces/ApiModels.ts index 2734884b7783b662113dc00f61d112f8a58f5dd6..9bcd24c1b916ff925a9472dffd8b2fabd5395bfb 100644 --- a/client/src/interfaces/ApiModels.ts +++ b/client/src/interfaces/ApiModels.ts @@ -94,6 +94,16 @@ export interface TextComponent extends Component { font: string } -export interface QuestionAlternativeComponent extends Component { +export interface QuestionComponent extends Component { + id: number + x: number + y: number + w: number + h: number + slide_id: number + type_id: number + view_type_id: number + text: string + media: Media question_id: number } diff --git a/client/src/logo.svg b/client/src/logo.svg deleted file mode 100644 index 9dfc1c058cebbef8b891c5062be6f31033d7d186..0000000000000000000000000000000000000000 --- a/client/src/logo.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg> \ No newline at end of file diff --git a/client/src/middleware/Middleware_Explanation.txt b/client/src/middleware/Middleware_Explanation.txt deleted file mode 100644 index dc4e91ab6fd33627a7b19cb06be5ba20fee4598d..0000000000000000000000000000000000000000 --- a/client/src/middleware/Middleware_Explanation.txt +++ /dev/null @@ -1,6 +0,0 @@ -Redux middleware provides a third-party extension point between dispatching an action, -and the moment it reaches the reducer. People use Redux middleware for logging, -crash reporting, talking to an asynchronous API, routing, and more. - - -https://redux.js.org/tutorials/fundamentals/part-4-store \ No newline at end of file diff --git a/client/src/pages/admin/AdminPage.tsx b/client/src/pages/admin/AdminPage.tsx index 1b543083da7fbfb1e5c1a48d44a40a2d7fdcc17e..3a375210ae30a021d7a758e6ca9e3c9b18d4d45d 100644 --- a/client/src/pages/admin/AdminPage.tsx +++ b/client/src/pages/admin/AdminPage.tsx @@ -91,6 +91,7 @@ const AdminView: React.FC = () => { const menuItems = isAdmin ? menuAdminItems : menuEditorItems return menuItems.map((value, index) => ( <ListItem + data-testid={value.text} button component={Link} key={value.text} diff --git a/client/src/pages/admin/competitions/AddCompetition.tsx b/client/src/pages/admin/competitions/AddCompetition.tsx index 6053f2f8de9c3d13cde3403ec1a62317e477d047..e404b112a1df1e6ab4f9d8a8759fb946a20ccd52 100644 --- a/client/src/pages/admin/competitions/AddCompetition.tsx +++ b/client/src/pages/admin/competitions/AddCompetition.tsx @@ -88,6 +88,7 @@ const AddCompetition: React.FC = (props: any) => { return ( <div> <AddButton + data-testid="addCompetition" style={{ backgroundColor: '#4caf50', color: '#fcfcfc' }} color="default" variant="contained" @@ -124,6 +125,7 @@ const AddCompetition: React.FC = (props: any) => { {(formik) => ( <AddForm onSubmit={formik.handleSubmit}> <TextField + data-testid="competitionName" label="Namn" name="model.name" helperText={formik.touched.model?.name ? formik.errors.model?.name : ''} @@ -137,6 +139,7 @@ const AddCompetition: React.FC = (props: any) => { Region </InputLabel> <TextField + data-testid="competitionRegion" select name="model.city" id="standard-select-currency" @@ -152,7 +155,12 @@ const AddCompetition: React.FC = (props: any) => { </MenuItem> {cities && cities.map((city) => ( - <MenuItem key={city.name} value={city.name} onClick={() => setSelectedCity(city)}> + <MenuItem + key={city.name} + value={city.name} + onClick={() => setSelectedCity(city)} + data-testid={city.name} + > {city.name} </MenuItem> ))} @@ -170,6 +178,7 @@ const AddCompetition: React.FC = (props: any) => { margin="normal" /> <Button + data-testid="acceptCompetition" type="submit" fullWidth variant="contained" diff --git a/client/src/pages/admin/competitions/CompetitionManager.tsx b/client/src/pages/admin/competitions/CompetitionManager.tsx index b6af32793c9eac86553408fc742430eaeddea7a6..9b72ae04055e5dccea5cbb6cd0826577a0f5068b 100644 --- a/client/src/pages/admin/competitions/CompetitionManager.tsx +++ b/client/src/pages/admin/competitions/CompetitionManager.tsx @@ -131,6 +131,7 @@ const CompetitionManager: React.FC = (props: any) => { } } + /** Start the competition by redirecting with URL with Code */ const handleStartCompetition = () => { const operatorCode = codes.find((code) => code.view_type_id === 4)?.code if (operatorCode) { @@ -138,21 +139,22 @@ const CompetitionManager: React.FC = (props: any) => { } } + /** Fetch all the connection codes from the server */ const getCodes = async (id: number) => { await axios .get(`/api/competitions/${id}/codes`) .then((response) => { - console.log(response.data) setCodes(response.data.items) }) .catch(console.log) } + /** Fetch all the teams from the server that is connected to a specific competition*/ const getTeams = async (id: number) => { await axios .get(`/api/competitions/${id}/teams`) .then((response) => { - console.log(response.data.items) + // console.log(response.data.items) setTeams(response.data.items) }) .catch((err) => { @@ -160,11 +162,12 @@ const CompetitionManager: React.FC = (props: any) => { }) } + /** Fetch the copetition name from the server */ const getCompetitionName = async () => { await axios .get(`/api/competitions/${activeId}`) .then((response) => { - console.log(response.data.name) + // console.log(response.data.name) setCompetitionName(response.data.name) }) .catch((err) => { @@ -199,16 +202,18 @@ const CompetitionManager: React.FC = (props: any) => { return typeName } + /** Handles the opening of the code dialog box */ const handleOpenDialog = async () => { await getCompetitionName() setDialogIsOpen(true) } - + /** Handles the closing of the code dialog box */ const handleCloseDialog = () => { setDialogIsOpen(false) setAnchorEl(null) } + /** Function that copies an existing competition */ const handleDuplicateCompetition = async () => { if (activeId) { await axios @@ -246,12 +251,7 @@ const CompetitionManager: React.FC = (props: any) => { <div> <TopBar> <FilterContainer> - <TextField - className={classes.margin} - value={filterParams.name || ''} - onChange={onSearchChange} - label="Sök" - ></TextField> + <TextField className={classes.margin} value={filterParams.name || ''} onChange={onSearchChange} label="Sök" /> <FormControl className={classes.margin}> <InputLabel shrink id="demo-customized-select-native"> Region @@ -309,7 +309,7 @@ const CompetitionManager: React.FC = (props: any) => { <TableCell align="right">{cities.find((city) => city.id === row.city_id)?.name || ''}</TableCell> <TableCell align="right">{row.year}</TableCell> <TableCell align="right"> - <Button onClick={(event) => handleClick(event, row.id)}> + <Button onClick={(event) => handleClick(event, row.id)} data-testid={row.name}> <MoreHorizIcon /> </Button> </TableCell> @@ -334,7 +334,9 @@ const CompetitionManager: React.FC = (props: any) => { <MenuItem onClick={handleStartCompetition}>Starta</MenuItem> <MenuItem onClick={handleOpenDialog}>Visa koder</MenuItem> <MenuItem onClick={handleDuplicateCompetition}>Duplicera</MenuItem> - <RemoveMenuItem onClick={handleDeleteCompetition}>Ta bort</RemoveMenuItem> + <RemoveMenuItem onClick={handleDeleteCompetition} data-testid="removeCompetitionButton"> + Ta bort + </RemoveMenuItem> </Menu> <Dialog open={dialogIsOpen} diff --git a/client/src/pages/admin/dashboard/Dashboard.tsx b/client/src/pages/admin/dashboard/Dashboard.tsx index a0f569b066a8073a14f9342dcb7d988be63b93b9..51aa4f4084ebf016fefbb740dcb081ac20f0e7fe 100644 --- a/client/src/pages/admin/dashboard/Dashboard.tsx +++ b/client/src/pages/admin/dashboard/Dashboard.tsx @@ -6,6 +6,11 @@ import NumberOfCompetitions from './components/NumberOfCompetitions' import NumberOfRegions from './components/NumberOfRegions' import NumberOfUsers from './components/NumberOfUsers' +/** + * This is the first page that is shown after a user logs in. It shows som statistics about the site. + * + */ + const useStyles = makeStyles((theme: Theme) => createStyles({ root: { diff --git a/client/src/pages/admin/dashboard/components/CurrentUser.tsx b/client/src/pages/admin/dashboard/components/CurrentUser.tsx index 0dfdf557bff55224f513fb742ba59bdc8c8b1f56..933a320b30e637d8100e29226f4bd41a49a48572 100644 --- a/client/src/pages/admin/dashboard/components/CurrentUser.tsx +++ b/client/src/pages/admin/dashboard/components/CurrentUser.tsx @@ -2,6 +2,8 @@ import { Box, Typography } from '@material-ui/core' import React from 'react' import { useAppSelector } from '../../../../hooks' +/** This component show information about the currently logged in user */ + const CurrentUser: React.FC = () => { const currentUser = useAppSelector((state: { user: { userInfo: any } }) => state.user.userInfo) return ( @@ -13,7 +15,9 @@ const CurrentUser: React.FC = () => { </Typography> </div> <div> - <Typography variant="h6">Email: {currentUser && currentUser.email}</Typography> + <Typography data-testid="userEmail" variant="h6"> + Email: {currentUser && currentUser.email} + </Typography> </div> <div> <Typography variant="h6">Region: {currentUser && currentUser.city && currentUser.city.name}</Typography> diff --git a/client/src/pages/admin/dashboard/components/NumberOfCompetitions.tsx b/client/src/pages/admin/dashboard/components/NumberOfCompetitions.tsx index eb667ebd690f6013c184ac13e2038ce1a7726de9..1ebe9dfba3e888e9bf07b998cff321613ba035d3 100644 --- a/client/src/pages/admin/dashboard/components/NumberOfCompetitions.tsx +++ b/client/src/pages/admin/dashboard/components/NumberOfCompetitions.tsx @@ -2,6 +2,8 @@ import { Box, Typography } from '@material-ui/core' import React from 'react' import { useAppSelector } from '../../../../hooks' +/** Shows how many competitions is on the system */ + const NumberOfCompetitions: React.FC = () => { const competitions = useAppSelector((state) => state.statistics.competitions) diff --git a/client/src/pages/admin/dashboard/components/NumberOfRegions.tsx b/client/src/pages/admin/dashboard/components/NumberOfRegions.tsx index f3195f4c18969ed2ad1e4b185b2125ac2c15f782..5f9ecef5e60ff4fda27d6a42771c043b83e9dc8e 100644 --- a/client/src/pages/admin/dashboard/components/NumberOfRegions.tsx +++ b/client/src/pages/admin/dashboard/components/NumberOfRegions.tsx @@ -3,6 +3,8 @@ import React, { useEffect } from 'react' import { getCities } from '../../../../actions/cities' import { useAppDispatch, useAppSelector } from '../../../../hooks' +/** Shows how many regions is on the system */ + const NumberOfRegions: React.FC = () => { const regions = useAppSelector((state) => state.statistics.regions) const dispatch = useAppDispatch() diff --git a/client/src/pages/admin/dashboard/components/NumberOfUsers.tsx b/client/src/pages/admin/dashboard/components/NumberOfUsers.tsx index 0e75cf1b1069814801fadc010f30ac393dfe3b25..91291e689529c8c65a54ed63c7ae77ce489ebe2f 100644 --- a/client/src/pages/admin/dashboard/components/NumberOfUsers.tsx +++ b/client/src/pages/admin/dashboard/components/NumberOfUsers.tsx @@ -3,6 +3,8 @@ import React, { useEffect } from 'react' import { getSearchUsers } from '../../../../actions/searchUser' import { useAppDispatch, useAppSelector } from '../../../../hooks' +/** Shows how many users are on the system */ + const NumberOfUsers: React.FC = () => { const usersTotal = useAppSelector((state) => state.statistics.users) const dispatch = useAppDispatch() diff --git a/client/src/pages/admin/regions/AddRegion.tsx b/client/src/pages/admin/regions/AddRegion.tsx index b00961384f73246365e60023af04ed7567adf311..10cb22bc5c4b7527b05d8dfeb9b0f748dd6f0c65 100644 --- a/client/src/pages/admin/regions/AddRegion.tsx +++ b/client/src/pages/admin/regions/AddRegion.tsx @@ -30,6 +30,7 @@ const useStyles = makeStyles((theme: Theme) => type formType = FormModel<AddCityModel> +/** add a region form with some constraints. */ const schema: Yup.SchemaOf<formType> = Yup.object({ model: Yup.object() .shape({ @@ -78,6 +79,7 @@ const AddRegion: React.FC = (props: any) => { <FormControl className={classes.margin}> <Grid container={true}> <TextField + data-testid="regionTextField" className={classes.margin} helperText={formik.touched.model?.name ? formik.errors.model?.name : ''} error={Boolean(formik.touched.model?.name && formik.errors.model?.name)} @@ -85,8 +87,9 @@ const AddRegion: React.FC = (props: any) => { onBlur={formik.handleBlur} name="model.name" label="Region" - ></TextField> + /> <AddButton + data-testid="regionSubmitButton" style={{ backgroundColor: '#4caf50', color: '#fcfcfc' }} className={classes.button} color="default" diff --git a/client/src/pages/admin/regions/Regions.tsx b/client/src/pages/admin/regions/Regions.tsx index 436da6f69429ca6711452d7babf477f8da593eee..5e2531bb44b6e2e6d7d6a3ce65941ecdc03a3ee3 100644 --- a/client/src/pages/admin/regions/Regions.tsx +++ b/client/src/pages/admin/regions/Regions.tsx @@ -14,6 +14,9 @@ import { getCities } from '../../../actions/cities' import { useAppDispatch, useAppSelector } from '../../../hooks' import { RemoveMenuItem, TopBar } from '../styledComp' import AddRegion from './AddRegion' + +/** shows all the regions in a list */ + const useStyles = makeStyles((theme: Theme) => createStyles({ table: { @@ -96,7 +99,7 @@ const RegionManager: React.FC = (props: any) => { <TableRow key={row.name}> <TableCell scope="row">{row.name}</TableCell> <TableCell align="right"> - <Button onClick={(event) => handleClick(event, row.id)}> + <Button onClick={(event) => handleClick(event, row.id)} data-testid={row.name}> <MoreHorizIcon /> </Button> </TableCell> @@ -107,7 +110,9 @@ const RegionManager: React.FC = (props: any) => { {(!cities || cities.length === 0) && <Typography>Inga regioner hittades</Typography>} </TableContainer> <Menu id="simple-menu" anchorEl={anchorEl} keepMounted open={Boolean(anchorEl)} onClose={handleClose}> - <RemoveMenuItem onClick={handleDeleteCity}>Ta bort</RemoveMenuItem> + <RemoveMenuItem onClick={handleDeleteCity} data-testid="removeRegionButton"> + Ta bort + </RemoveMenuItem> </Menu> </div> ) diff --git a/client/src/pages/admin/users/AddUser.tsx b/client/src/pages/admin/users/AddUser.tsx index 9f1511eac460c661ebd35dc995f6606e66080359..63549edcd3180033e897c8bfb3924e6a691225bd 100644 --- a/client/src/pages/admin/users/AddUser.tsx +++ b/client/src/pages/admin/users/AddUser.tsx @@ -1,3 +1,5 @@ +/** Add a user component */ + import { Button, FormControl, InputLabel, MenuItem, Popover, TextField } from '@material-ui/core' import PersonAddIcon from '@material-ui/icons/PersonAdd' import { Alert, AlertTitle } from '@material-ui/lab' @@ -16,6 +18,7 @@ type formType = FormModel<AddUserModel> const noRoleSelected = 'Välj roll' const noCitySelected = 'Välj stad' +/** Form when adding a user with some constraints */ const userSchema: Yup.SchemaOf<formType> = Yup.object({ model: Yup.object() .shape({ @@ -84,6 +87,7 @@ const AddUser: React.FC = (props: any) => { return ( <div> <AddButton + data-testid="addUserButton" style={{ backgroundColor: '#4caf50', color: '#fcfcfc' }} color="default" variant="contained" @@ -111,6 +115,7 @@ const AddUser: React.FC = (props: any) => { {(formik) => ( <AddForm onSubmit={formik.handleSubmit}> <TextField + data-testid="addUserEmail" label="Email" name="model.email" helperText={formik.touched.model?.email ? formik.errors.model?.email : ''} @@ -120,6 +125,7 @@ const AddUser: React.FC = (props: any) => { margin="normal" /> <TextField + data-testid="addUserPassword" label="Lösenord" name="model.password" helperText={formik.touched.model?.password ? formik.errors.model?.password : ''} @@ -129,6 +135,7 @@ const AddUser: React.FC = (props: any) => { margin="normal" /> <TextField + data-testid="addUserName" label="Namn" name="model.name" helperText={formik.touched.model?.name ? formik.errors.model?.name : ''} @@ -143,6 +150,7 @@ const AddUser: React.FC = (props: any) => { </InputLabel> <TextField select + data-testid="userCitySelect" name="model.city" id="standard-select-currency" value={selectedCity ? selectedCity.name : noCitySelected} @@ -157,7 +165,12 @@ const AddUser: React.FC = (props: any) => { </MenuItem> {cities && cities.map((city) => ( - <MenuItem key={city.name} value={city.name} onClick={() => setSelectedCity(city)}> + <MenuItem + key={city.name} + value={city.name} + onClick={() => setSelectedCity(city)} + data-testid={city.name} + > {city.name} </MenuItem> ))} @@ -170,6 +183,7 @@ const AddUser: React.FC = (props: any) => { </InputLabel> <TextField select + data-testid="userRoleSelect" name="model.role" id="standard-select-currency" value={selectedRole ? selectedRole.name : noRoleSelected} @@ -184,7 +198,12 @@ const AddUser: React.FC = (props: any) => { </MenuItem> {roles && roles.map((role) => ( - <MenuItem key={role.name} value={role.name} onClick={() => setSelectedRole(role)}> + <MenuItem + key={role.name} + value={role.name} + onClick={() => setSelectedRole(role)} + data-testid={role.name} + > {role.name} </MenuItem> ))} @@ -193,6 +212,7 @@ const AddUser: React.FC = (props: any) => { <Button type="submit" + data-testid="addUserSubmit" fullWidth variant="contained" color="secondary" diff --git a/client/src/pages/admin/users/EditUser.tsx b/client/src/pages/admin/users/EditUser.tsx index 9b4a5d1bd6aecd92a03f44221c2cb40d40e2ab70..5de773fe7a58ed8c0f252cda20f844c801819b32 100644 --- a/client/src/pages/admin/users/EditUser.tsx +++ b/client/src/pages/admin/users/EditUser.tsx @@ -142,7 +142,7 @@ const EditUser = ({ user }: UserIdProps) => { } await axios .put('/api/users/' + user.id, req) - .then((res) => { + .then(() => { setAnchorEl(null) dispatch(getSearchUsers()) }) @@ -167,7 +167,7 @@ const EditUser = ({ user }: UserIdProps) => { } return ( <div> - <Button onClick={handleClick}> + <Button onClick={handleClick} data-testid={`more-${user.email}`}> <MoreHorizIcon /> </Button> <Popover @@ -289,6 +289,7 @@ const EditUser = ({ user }: UserIdProps) => { Ändra </Button> <Button + data-testid="removeUser" onClick={handleVerifyDelete} className={classes.deleteButton} fullWidth @@ -313,7 +314,7 @@ const EditUser = ({ user }: UserIdProps) => { <Button autoFocus onClick={handleClose} color="primary"> Avbryt </Button> - <Button onClick={handleDeleteUsers} color="primary" autoFocus> + <Button data-testid="acceptRemoveUser" onClick={handleDeleteUsers} color="primary" autoFocus> Ta bort </Button> </DialogActions> diff --git a/client/src/pages/admin/users/ResponsiveDialog.tsx b/client/src/pages/admin/users/ResponsiveDialog.tsx deleted file mode 100644 index e29a6786826e854bebf888be5b7cd904e0ec1063..0000000000000000000000000000000000000000 --- a/client/src/pages/admin/users/ResponsiveDialog.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from 'react'; -import Button from '@material-ui/core/Button'; -import Dialog from '@material-ui/core/Dialog'; -import DialogActions from '@material-ui/core/DialogActions'; -import DialogContent from '@material-ui/core/DialogContent'; -import DialogContentText from '@material-ui/core/DialogContentText'; -import DialogTitle from '@material-ui/core/DialogTitle'; -import useMediaQuery from '@material-ui/core/useMediaQuery'; -import { useTheme } from '@material-ui/core/styles'; - -export default function ResponsiveDialog() { - const [open, setOpen] = React.useState(false); - const theme = useTheme(); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); - - const handleClickOpen = () => { - setOpen(true); - }; - - const handleClose = () => { - setOpen(false); - }; - - return ( - <div> - <Button variant="outlined" color="primary" onClick={handleClickOpen}> - Open responsive dialog - </Button> - <Dialog - fullScreen={fullScreen} - open={open} - onClose={handleClose} - aria-labelledby="responsive-dialog-title" - > - <DialogTitle id="responsive-dialog-title">{"Use Google's location service?"}</DialogTitle> - <DialogContent> - <DialogContentText> - Let Google help apps determine location. This means sending anonymous location data to - Google, even when no apps are running. - </DialogContentText> - </DialogContent> - <DialogActions> - <Button autoFocus onClick={handleClose} color="primary"> - Disagree - </Button> - <Button onClick={handleClose} color="primary" autoFocus> - Agree - </Button> - </DialogActions> - </Dialog> - </div> - ); -} \ No newline at end of file diff --git a/client/src/pages/login/LoginPage.tsx b/client/src/pages/login/LoginPage.tsx index 8f8e967c435af9c2611bc9ac3c1365b114a7666a..34c9c5ae6c5344666dc383d7f2df7586ecc13e10 100644 --- a/client/src/pages/login/LoginPage.tsx +++ b/client/src/pages/login/LoginPage.tsx @@ -1,3 +1,7 @@ +/** This is the login page, it contains two child components, one is + * to log in as an admin, the other is to connect to a competition using a code + */ + import { AppBar, Tab, Tabs } from '@material-ui/core' import React from 'react' import AdminLogin from './components/AdminLogin' diff --git a/client/src/pages/login/components/AdminLogin.tsx b/client/src/pages/login/components/AdminLogin.tsx index 964fb8abd148a9a0735206e9dbe3288cbe72ea3a..c33f338da69a6d2f0d1733a6d5bb777461cdefe4 100644 --- a/client/src/pages/login/components/AdminLogin.tsx +++ b/client/src/pages/login/components/AdminLogin.tsx @@ -1,3 +1,5 @@ +/** Component that handles the log in when a user is an admin */ + import { Button, TextField, Typography } from '@material-ui/core' import { Alert, AlertTitle } from '@material-ui/lab' import { Formik, FormikHelpers } from 'formik' @@ -55,6 +57,7 @@ const AdminLogin: React.FC = () => { <TextField label="Email Adress" name="model.email" + data-testid="email" helperText={formik.touched.model?.email ? formik.errors.model?.email : ''} error={Boolean(formik.touched.model?.email && formik.errors.model?.email)} onChange={formik.handleChange} @@ -65,6 +68,7 @@ const AdminLogin: React.FC = () => { label="Lösenord" name="model.password" type="password" + data-testid="password" helperText={formik.touched.model?.password ? formik.errors.model?.password : ''} error={Boolean(formik.touched.model?.password && formik.errors.model?.password)} onChange={formik.handleChange} @@ -73,6 +77,7 @@ const AdminLogin: React.FC = () => { /> <Button type="submit" + data-testid="submit" fullWidth variant="contained" color="secondary" diff --git a/client/src/pages/login/components/CompetitionLogin.tsx b/client/src/pages/login/components/CompetitionLogin.tsx index 8dbbee33b6962c5bcd21f346ee594b224719a9a7..444634740fa361ad6deb1493c6673427f5fb8717 100644 --- a/client/src/pages/login/components/CompetitionLogin.tsx +++ b/client/src/pages/login/components/CompetitionLogin.tsx @@ -1,3 +1,5 @@ +/** Component that handles the log in when a user connects to a competition through a code */ + import { Button, TextField, Typography } from '@material-ui/core' import { Alert, AlertTitle } from '@material-ui/lab' import { Formik } from 'formik' @@ -38,7 +40,7 @@ const CompetitionLogin: React.FC = () => { const handleCompetitionSubmit = async (values: CompetitionLoginFormModel) => { dispatch(loginCompetition(values.model.code, history, true)) } - + return ( <Formik initialValues={competitionInitialValues} diff --git a/client/src/pages/presentationEditor/components/BackgroundImageSelect.test.tsx b/client/src/pages/presentationEditor/components/BackgroundImageSelect.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..17705308993ad89a8f7175549e9af3edd82e8010 --- /dev/null +++ b/client/src/pages/presentationEditor/components/BackgroundImageSelect.test.tsx @@ -0,0 +1,16 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { BrowserRouter } from 'react-router-dom' +import store from '../../../store' +import BackgroundImageSelect from './BackgroundImageSelect' + +it('renders background image select', () => { + render( + <BrowserRouter> + <Provider store={store}> + <BackgroundImageSelect variant="competition" /> + </Provider> + </BrowserRouter> + ) +}) diff --git a/client/src/pages/presentationEditor/components/CheckboxComponent.tsx b/client/src/pages/presentationEditor/components/CheckboxComponent.tsx deleted file mode 100644 index cb264712b0b4b57ef7938278471bc812085370cf..0000000000000000000000000000000000000000 --- a/client/src/pages/presentationEditor/components/CheckboxComponent.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Checkbox } from '@material-ui/core' -import React, { useState } from 'react' -import { Rnd } from 'react-rnd' -import { Component } from '../../../interfaces/ApiModels' -import { Position } from '../../../interfaces/Components' - -type CheckboxComponentProps = { - component: Component -} - -const CheckboxComponent = ({ component }: CheckboxComponentProps) => { - const [currentPos, setCurrentPos] = useState<Position>({ x: component.x, y: component.y }) - return ( - <Rnd - bounds="parent" - onDragStop={(e, d) => { - setCurrentPos({ x: d.x, y: d.y }) - }} - position={{ x: currentPos.x, y: currentPos.y }} - > - <Checkbox - disableRipple - style={{ - transform: 'scale(3)', - }} - /> - </Rnd> - ) -} - -export default CheckboxComponent diff --git a/client/src/pages/presentationEditor/components/QuestionComponentDisplay.tsx b/client/src/pages/presentationEditor/components/QuestionComponentDisplay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dd8e2fa85a8e2562595c85c7068b0073f744092a --- /dev/null +++ b/client/src/pages/presentationEditor/components/QuestionComponentDisplay.tsx @@ -0,0 +1,84 @@ +import { Card, Divider, ListItem, Typography } from '@material-ui/core' +import React from 'react' +import { useAppSelector } from '../../../hooks' +import AnswerMultiple from './answerComponents/AnswerMultiple' +import AnswerSingle from './answerComponents/AnswerSingle' +import AnswerText from './answerComponents/AnswerText' +import { Center } from './styled' + +type QuestionComponentProps = { + variant: 'editor' | 'presentation' +} + +const QuestionComponentDisplay = ({ variant }: QuestionComponentProps) => { + const activeSlide = useAppSelector((state) => { + if (variant === 'editor') + return state.editor.competition.slides.find((slide) => slide.id === state.editor.activeSlideId) + return state.presentation.competition.slides.find((slide) => slide.id === state.presentation.slide?.id) + }) + + const timer = activeSlide?.timer + const total_score = activeSlide?.questions[0].total_score + const questionName = activeSlide?.questions[0].name + + const questionTypeId = activeSlide?.questions[0].type_id + const questionTypeName = useAppSelector( + (state) => state.types.questionTypes.find((qType) => qType.id === questionTypeId)?.name + ) + + const getAlternatives = () => { + switch (questionTypeName) { + case 'Text': + if (activeSlide) { + return <AnswerText activeSlide={activeSlide} competitionId={activeSlide.competition_id.toString()} /> + } + return + + case 'Practical': + return + + case 'Multiple': + if (activeSlide) { + return ( + <AnswerMultiple + variant={variant} + activeSlide={activeSlide} + competitionId={activeSlide.competition_id.toString()} + /> + ) + } + return + + case 'Single': + if (activeSlide) { + return ( + <AnswerSingle + variant={variant} + activeSlide={activeSlide} + competitionId={activeSlide.competition_id.toString()} + /> + ) + } + return + + default: + break + } + } + + return ( + <Card style={{ maxHeight: '100%', overflowY: 'auto' }}> + <ListItem> + <Center style={{ justifyContent: 'space-evenly' }}> + <Typography>Poäng: {total_score}</Typography> + <Typography>{questionName}</Typography> + <Typography>Timer: {timer}</Typography> + </Center> + </ListItem> + <Divider /> + {getAlternatives()} + </Card> + ) +} + +export default QuestionComponentDisplay diff --git a/client/src/pages/presentationEditor/components/RndComponent.test.tsx b/client/src/pages/presentationEditor/components/RndComponent.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b024ab3f14d82f8fb55bf336938a2fa17f911b15 --- /dev/null +++ b/client/src/pages/presentationEditor/components/RndComponent.test.tsx @@ -0,0 +1,17 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { BrowserRouter } from 'react-router-dom' +import { Component } from '../../../interfaces/ApiModels' +import store from '../../../store' +import RndComponent from './RndComponent' + +it('renders rnd component', () => { + render( + <BrowserRouter> + <Provider store={store}> + <RndComponent component={{ id: 2, x: 0, w: 15, h: 15 } as Component} width={50} height={50} scale={123} /> + </Provider> + </BrowserRouter> + ) +}) diff --git a/client/src/pages/presentationEditor/components/RndComponent.tsx b/client/src/pages/presentationEditor/components/RndComponent.tsx index a134ee14c9ac268159b6ae0013350012e638dc65..4c09280042bd038fee1e3adc4b5bf3b7aef8ef6a 100644 --- a/client/src/pages/presentationEditor/components/RndComponent.tsx +++ b/client/src/pages/presentationEditor/components/RndComponent.tsx @@ -9,6 +9,7 @@ import { Component, ImageComponent, TextComponent } from '../../../interfaces/Ap import { Position, Size } from '../../../interfaces/Components' import { RemoveMenuItem } from '../../admin/styledComp' import ImageComponentDisplay from './ImageComponentDisplay' +import QuestionComponentDisplay from './QuestionComponentDisplay' import { HoverContainer } from './styled' import TextComponentDisplay from './TextComponentDisplay' //import NestedMenuItem from 'material-ui-nested-menu-item' @@ -126,6 +127,12 @@ const RndComponent = ({ component, width, height, scale }: RndComponentProps) => /> </HoverContainer> ) + case ComponentTypes.Question: + return ( + <HoverContainer hover={hover}> + <QuestionComponentDisplay variant="editor" /> + </HoverContainer> + ) default: break } diff --git a/client/src/pages/presentationEditor/components/SlideDisplay.tsx b/client/src/pages/presentationEditor/components/SlideDisplay.tsx index aef4ca7c48d29bbfc47cac8f29b214ec6866f360..e6b60f290784627d9c5446428062f2e1042cacef 100644 --- a/client/src/pages/presentationEditor/components/SlideDisplay.tsx +++ b/client/src/pages/presentationEditor/components/SlideDisplay.tsx @@ -77,15 +77,7 @@ const SlideDisplay = ({ variant, activeViewTypeId }: SlideDisplayProps) => { scale={scale} /> ) - return ( - <PresentationComponent - height={height} - width={width} - key={component.id} - component={component} - scale={scale} - /> - ) + return <PresentationComponent key={component.id} component={component} scale={scale} /> })} </SlideEditorPaper> </SlideEditorContainerRatio> diff --git a/client/src/pages/presentationEditor/components/SlideSettings.tsx b/client/src/pages/presentationEditor/components/SlideSettings.tsx index d48b4565712d043d52d3b1e93e3633ec6b927d38..029187f49e06fcffbb147a82fc7e754ec09e3a0f 100644 --- a/client/src/pages/presentationEditor/components/SlideSettings.tsx +++ b/client/src/pages/presentationEditor/components/SlideSettings.tsx @@ -1,18 +1,19 @@ /* This file compiles and renders the right hand slide settings bar, under the tab "SIDA". */ -import { Divider, List, ListItem, ListItemText, TextField, Typography } from '@material-ui/core' -import React, { useState } from 'react' +import { Divider } from '@material-ui/core' +import React from 'react' import { useParams } from 'react-router-dom' import { useAppSelector } from '../../../hooks' +import BackgroundImageSelect from './BackgroundImageSelect' +import Images from './slideSettingsComponents/Images' import Instructions from './slideSettingsComponents/Instructions' import MultipleChoiceAlternatives from './slideSettingsComponents/MultipleChoiceAlternatives' +import QuestionSettings from './slideSettingsComponents/QuestionSettings' +import SingleChoiceAlternatives from './slideSettingsComponents/SingleChoiceAlternatives' import SlideType from './slideSettingsComponents/SlideType' -import { Center, ImportedImage, SettingsList, PanelContainer } from './styled' -import Timer from './slideSettingsComponents/Timer' -import Images from './slideSettingsComponents/Images' import Texts from './slideSettingsComponents/Texts' -import QuestionSettings from './slideSettingsComponents/QuestionSettings' -import BackgroundImageSelect from './BackgroundImageSelect' +import Timer from './slideSettingsComponents/Timer' +import { PanelContainer, SettingsList } from './styled' interface CompetitionParams { competitionId: string @@ -36,19 +37,21 @@ const SlideSettings: React.FC = () => { </SettingsList> {activeSlide?.questions[0] && <QuestionSettings activeSlide={activeSlide} competitionId={competitionId} />} + { - // Choose answer alternatives depending on the slide type + // Choose answer alternatives, depending on the slide type } - {activeSlide?.questions[0]?.type_id === 1 && ( - <Instructions activeSlide={activeSlide} competitionId={competitionId} /> - )} - {activeSlide?.questions[0]?.type_id === 2 && ( + {(activeSlide?.questions[0]?.type_id === 1 || activeSlide?.questions[0]?.type_id === 2) && ( <Instructions activeSlide={activeSlide} competitionId={competitionId} /> )} {activeSlide?.questions[0]?.type_id === 3 && ( <MultipleChoiceAlternatives activeSlide={activeSlide} competitionId={competitionId} /> )} + {activeSlide?.questions[0]?.type_id === 4 && ( + <SingleChoiceAlternatives activeSlide={activeSlide} competitionId={competitionId} /> + )} + {activeSlide && ( <Texts activeViewTypeId={activeViewTypeId} activeSlide={activeSlide} competitionId={competitionId} /> )} diff --git a/client/src/pages/presentationEditor/components/TextComponentEdit.test.tsx b/client/src/pages/presentationEditor/components/TextComponentEdit.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..655b21bfed8ac33c433bd6b152736a344aeaf02b --- /dev/null +++ b/client/src/pages/presentationEditor/components/TextComponentEdit.test.tsx @@ -0,0 +1,17 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { BrowserRouter } from 'react-router-dom' +import { TextComponent } from '../../../interfaces/ApiModels' +import store from '../../../store' +import TextComponentEdit from './TextComponentEdit' + +it('renders text component edit', () => { + render( + <BrowserRouter> + <Provider store={store}> + <TextComponentEdit component={{ id: 2, text: 'testtext' } as TextComponent} /> + </Provider> + </BrowserRouter> + ) +}) diff --git a/client/src/pages/presentationEditor/components/answerComponents/AnswerMultiple.tsx b/client/src/pages/presentationEditor/components/answerComponents/AnswerMultiple.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7a2b1c59acf0277197d6126a7aba5b81a8799a37 --- /dev/null +++ b/client/src/pages/presentationEditor/components/answerComponents/AnswerMultiple.tsx @@ -0,0 +1,106 @@ +import { Checkbox, ListItem, ListItemText, Typography, withStyles } from '@material-ui/core' +import { CheckboxProps } from '@material-ui/core/Checkbox' +import { green, grey } from '@material-ui/core/colors' +import axios from 'axios' +import React from 'react' +import { getEditorCompetition } from '../../../../actions/editor' +import { getPresentationCompetition } from '../../../../actions/presentation' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { QuestionAlternative } from '../../../../interfaces/ApiModels' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { Center } from '../styled' + +type AnswerMultipleProps = { + variant: 'editor' | 'presentation' + activeSlide: RichSlide | undefined + competitionId: string +} + +const AnswerMultiple = ({ variant, activeSlide, competitionId }: AnswerMultipleProps) => { + const dispatch = useAppDispatch() + const teamId = useAppSelector((state) => state.competitionLogin.data?.team_id) + const team = useAppSelector((state) => state.presentation.competition.teams.find((team) => team.id === teamId)) + const answer = team?.question_answers.find((answer) => answer.question_id === activeSlide?.questions[0].id) + + const decideChecked = (alternative: QuestionAlternative) => { + const teamAnswer = team?.question_answers.find((answer) => answer.answer === alternative.text)?.answer + if (alternative.text === teamAnswer) return true + else return false + } + + const updateAnswer = async (alternative: QuestionAlternative) => { + // TODO: fix. Make list of alternatives and delete & post instead of put to allow multiple boxes checked. + if (activeSlide) { + if (team?.question_answers.find((answer) => answer.question_id === activeSlide.questions[0].id)) { + if (answer?.answer === alternative.text) { + // Uncheck checkbox + deleteAnswer() + } else { + // Check another box + // TODO + } + } else { + // Check first checkbox + await axios + .post(`/api/competitions/${competitionId}/teams/${teamId}/answers`, { + answer: alternative.text, + score: 0, + question_id: activeSlide.questions[0].id, + }) + .then(() => { + if (variant === 'editor') { + dispatch(getEditorCompetition(competitionId)) + } else { + dispatch(getPresentationCompetition(competitionId)) + } + }) + .catch(console.log) + } + } + } + + const deleteAnswer = async () => { + await axios + .delete(`/api/competitions/${competitionId}/teams/${teamId}/answers`) // TODO: fix + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + + const GreenCheckbox = withStyles({ + root: { + color: grey[900], + '&$checked': { + color: green[600], + }, + }, + checked: {}, + })((props: CheckboxProps) => <Checkbox color="default" {...props} />) + + return ( + <div> + <ListItem divider> + <Center> + <ListItemText primary="Välj ett eller flera svar:" /> + </Center> + </ListItem> + {activeSlide && + activeSlide.questions[0] && + activeSlide.questions[0].alternatives && + activeSlide.questions[0].alternatives.map((alt) => ( + <div key={alt.id}> + <ListItem divider> + { + //<GreenCheckbox checked={checkbox} onChange={(event) => updateAnswer(alt, event.target.checked)} /> + } + <GreenCheckbox checked={decideChecked(alt)} onChange={() => updateAnswer(alt)} /> + <Typography style={{ wordBreak: 'break-all' }}>{alt.text}</Typography> + </ListItem> + </div> + ))} + </div> + ) +} + +export default AnswerMultiple diff --git a/client/src/pages/presentationEditor/components/answerComponents/AnswerSingle.tsx b/client/src/pages/presentationEditor/components/answerComponents/AnswerSingle.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8c2fad17395e9c602a7108a72301739064ab4533 --- /dev/null +++ b/client/src/pages/presentationEditor/components/answerComponents/AnswerSingle.tsx @@ -0,0 +1,132 @@ +import { Checkbox, ListItem, ListItemText, Typography, withStyles } from '@material-ui/core' +import { CheckboxProps } from '@material-ui/core/Checkbox' +import { green, grey } from '@material-ui/core/colors' +import RadioButtonCheckedIcon from '@material-ui/icons/RadioButtonCheckedOutlined' +import RadioButtonUncheckedIcon from '@material-ui/icons/RadioButtonUncheckedOutlined' +import axios from 'axios' +import React from 'react' +import { getEditorCompetition } from '../../../../actions/editor' +import { getPresentationCompetition } from '../../../../actions/presentation' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { QuestionAlternative } from '../../../../interfaces/ApiModels' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { Center, Clickable } from '../styled' + +type AnswerSingleProps = { + variant: 'editor' | 'presentation' + activeSlide: RichSlide | undefined + competitionId: string +} + +const AnswerSingle = ({ variant, activeSlide, competitionId }: AnswerSingleProps) => { + const dispatch = useAppDispatch() + const teamId = useAppSelector((state) => state.competitionLogin.data?.team_id) + const team = useAppSelector((state) => { + if (variant === 'editor') return state.editor.competition.teams.find((team) => team.id === teamId) + return state.presentation.competition.teams.find((team) => team.id === teamId) + }) + const answerId = team?.question_answers.find((answer) => answer.question_id === activeSlide?.questions[0].id)?.id + + const decideChecked = (alternative: QuestionAlternative) => { + const teamAnswer = team?.question_answers.find((answer) => answer.answer === alternative.text)?.answer + if (teamAnswer) return true + else return false + } + + const updateAnswer = async (alternative: QuestionAlternative) => { + if (activeSlide) { + // TODO: ignore API calls when an answer is already checked + if (team?.question_answers[0]) { + await axios + .put(`/api/competitions/${competitionId}/teams/${teamId}/answers/${answerId}`, { + answer: alternative.text, + }) + .then(() => { + if (variant === 'editor') { + dispatch(getEditorCompetition(competitionId)) + } else { + dispatch(getPresentationCompetition(competitionId)) + } + }) + .catch(console.log) + } else { + await axios + .post(`/api/competitions/${competitionId}/teams/${teamId}/answers`, { + answer: alternative.text, + score: 0, + question_id: activeSlide.questions[0].id, + }) + .then(() => { + if (variant === 'editor') { + dispatch(getEditorCompetition(competitionId)) + } else { + dispatch(getPresentationCompetition(competitionId)) + } + }) + .catch(console.log) + } + } + } + + const deleteAnswer = async () => { + await axios + .delete(`/api/competitions/${competitionId}/teams/${teamId}/answers`) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + + const GreenCheckbox = withStyles({ + root: { + color: grey[900], + '&$checked': { + color: green[600], + }, + }, + checked: {}, + })((props: CheckboxProps) => <Checkbox color="default" {...props} />) + + const renderRadioButton = (alt: QuestionAlternative) => { + if (variant === 'presentation') { + if (decideChecked(alt)) { + return ( + <Clickable> + <RadioButtonCheckedIcon onClick={() => updateAnswer(alt)} /> + </Clickable> + ) + } else { + return ( + <Clickable> + <RadioButtonUncheckedIcon onClick={() => updateAnswer(alt)} /> + </Clickable> + ) + } + } else { + return <RadioButtonUncheckedIcon onClick={() => updateAnswer(alt)} /> + } + } + + return ( + <div> + <ListItem divider> + <Center> + <ListItemText primary="Välj ett svar:" /> + </Center> + </ListItem> + {activeSlide && + activeSlide.questions[0] && + activeSlide.questions[0].alternatives && + activeSlide.questions[0].alternatives.map((alt) => ( + <div key={alt.id}> + <ListItem divider> + {renderRadioButton(alt)} + <Typography style={{ wordBreak: 'break-all' }}>{alt.text}</Typography> + </ListItem> + </div> + ))} + </div> + ) +} + +export default AnswerSingle diff --git a/client/src/pages/presentationEditor/components/answerComponents/AnswerText.tsx b/client/src/pages/presentationEditor/components/answerComponents/AnswerText.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e54b0dd2fe56f3ebf96b65fa8a05e6af4467da9f --- /dev/null +++ b/client/src/pages/presentationEditor/components/answerComponents/AnswerText.tsx @@ -0,0 +1,80 @@ +import { ListItem, ListItemText, TextField } from '@material-ui/core' +import axios from 'axios' +import React from 'react' +import { getEditorCompetition } from '../../../../actions/editor' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { Center } from '../styled' +import { AnswerTextFieldContainer } from './styled' + +type AnswerTextProps = { + activeSlide: RichSlide | undefined + competitionId: string +} + +const AnswerText = ({ activeSlide, competitionId }: AnswerTextProps) => { + const [timerHandle, setTimerHandle] = React.useState<number | undefined>(undefined) + const dispatch = useAppDispatch() + const teamId = useAppSelector((state) => state.competitionLogin.data?.team_id) + const team = useAppSelector((state) => state.presentation.competition.teams.find((team) => team.id === teamId)) + const answerId = team?.question_answers.find((answer) => answer.question_id === activeSlide?.questions[0].id)?.id + const onAnswerChange = (answer: string) => { + if (timerHandle) { + clearTimeout(timerHandle) + setTimerHandle(undefined) + } + //Only updates answer 100ms after last input was made + setTimerHandle(window.setTimeout(() => updateAnswer(answer), 100)) + } + + const updateAnswer = async (answer: string) => { + if (activeSlide && team) { + if (team?.question_answers.find((answer) => answer.question_id === activeSlide.questions[0].id)) { + await axios + .put(`/api/competitions/${competitionId}/teams/${teamId}/answers/${answerId}`, { + answer, + }) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } else { + await axios + .post(`/api/competitions/${competitionId}/teams/${teamId}/answers`, { + answer, + score: 0, + question_id: activeSlide.questions[0].id, + }) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + } + + return ( + <AnswerTextFieldContainer> + <ListItem divider> + <Center> + <ListItemText primary="Skriv ditt svar nedan" /> + </Center> + </ListItem> + <ListItem style={{ height: '100%' }}> + <TextField + disabled={team === undefined} + defaultValue={ + team?.question_answers.find((questionAnswer) => questionAnswer.id === answerId)?.answer || 'Svar...' + } + style={{ height: '100%' }} + variant="outlined" + fullWidth={true} + multiline + onChange={(event) => onAnswerChange(event.target.value)} + /> + </ListItem> + </AnswerTextFieldContainer> + ) +} + +export default AnswerText diff --git a/client/src/pages/presentationEditor/components/answerComponents/styled.tsx b/client/src/pages/presentationEditor/components/answerComponents/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9140e3c2776f5bda3d2f317740bb4bbb67f657ac --- /dev/null +++ b/client/src/pages/presentationEditor/components/answerComponents/styled.tsx @@ -0,0 +1,5 @@ +import styled from 'styled-components' + +export const AnswerTextFieldContainer = styled.div` + height: calc(100% - 90px); +` diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.test.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..917fa0c8642a308460e7fcc007c01ba1e91f5ad9 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import store from '../../../../store' +import Images from './Images' + +it('renders images', () => { + render( + <Provider store={store}> + <Images activeSlide={{ id: 5 } as RichSlide} activeViewTypeId={5} competitionId="1" /> + </Provider> + ) +}) diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.tsx index 49f41e7f87fa6c53dac59704540ca3c186ef708b..60c68fb2aa287620547b6b6df38a8ed9da211893 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.tsx @@ -1,14 +1,14 @@ /* This file handles creating and removing image components, and uploading and removing image files from the server. */ -import { ListItem, ListItemText, Typography } from '@material-ui/core' +import { ListItem, ListItemText } from '@material-ui/core' import CloseIcon from '@material-ui/icons/Close' -import React from 'react' -import { Center, HiddenInput, SettingsList, AddImageButton, ImportedImage, AddButton } from '../styled' import axios from 'axios' +import React from 'react' import { getEditorCompetition } from '../../../../actions/editor' -import { RichSlide } from '../../../../interfaces/ApiRichModels' -import { ImageComponent, Media } from '../../../../interfaces/ApiModels' import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { ImageComponent, Media } from '../../../../interfaces/ApiModels' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { AddButton, AddImageButton, Center, HiddenInput, ImportedImage, SettingsList } from '../styled' type ImagesProps = { activeViewTypeId: number diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.test.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fe745c839cc2bd9a59f07e4e97cbb85a90c19e98 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import store from '../../../../store' +import Instructions from './Instructions' + +it('renders instructions', () => { + render( + <Provider store={store}> + <Instructions activeSlide={{ id: 5 } as RichSlide} competitionId="1" /> + </Provider> + ) +}) diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.tsx index 99b4b1d8d118e65595b38077e8fd15afa103ad05..de34bc205b37cc60d6c086388e4c4d0995933bcd 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.tsx @@ -23,7 +23,7 @@ const Instructions = ({ activeSlide, competitionId }: InstructionsProps) => { //Only updates 250ms after last input was made to not spam setTimerHandle( window.setTimeout(async () => { - if (activeSlide && activeSlide.questions[0]) { + if (activeSlide && activeSlide.questions?.[0]) { await axios // TODO: Implement instructions field in question and add put API .put( @@ -56,7 +56,7 @@ const Instructions = ({ activeSlide, competitionId }: InstructionsProps) => { <TextField multiline id="outlined-basic" - defaultValue={activeSlide.questions[0].correcting_instructions} + defaultValue={activeSlide.questions?.[0].correcting_instructions} onChange={updateInstructionsText} variant="outlined" fullWidth={true} diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.test.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..75131257642df47d260ec795025e1c8bee759a6a --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import store from '../../../../store' +import MultipleChoiceAlternatives from './MultipleChoiceAlternatives' + +it('renders multiple choice alternatives', () => { + render( + <Provider store={store}> + <MultipleChoiceAlternatives activeSlide={{ id: 5 } as RichSlide} competitionId="1" /> + </Provider> + ) +}) diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.tsx index 58053995856730b22a16d4401af17da5628ab87a..cf803d3a14588d4a172f6bc452ce4cb07dda6e20 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.tsx @@ -34,7 +34,7 @@ const MultipleChoiceAlternatives = ({ activeSlide, competitionId }: MultipleChoi } const updateAlternativeValue = async (alternative: QuestionAlternative) => { - if (activeSlide && activeSlide.questions[0]) { + if (activeSlide && activeSlide.questions?.[0]) { let newValue: number if (alternative.value === 0) { newValue = 1 @@ -52,7 +52,7 @@ const MultipleChoiceAlternatives = ({ activeSlide, competitionId }: MultipleChoi } const updateAlternativeText = async (alternative_id: number, newText: string) => { - if (activeSlide && activeSlide.questions[0]) { + if (activeSlide && activeSlide.questions?.[0]) { await axios .put( `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}`, @@ -66,7 +66,7 @@ const MultipleChoiceAlternatives = ({ activeSlide, competitionId }: MultipleChoi } const addAlternative = async () => { - if (activeSlide && activeSlide.questions[0]) { + if (activeSlide && activeSlide.questions?.[0]) { await axios .post( `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives`, @@ -80,7 +80,7 @@ const MultipleChoiceAlternatives = ({ activeSlide, competitionId }: MultipleChoi } const handleCloseAnswerClick = async (alternative_id: number) => { - if (activeSlide && activeSlide.questions[0]) { + if (activeSlide && activeSlide.questions?.[0]) { await axios .delete( `/api/competitions/${competitionId}/slides/${activeSlideId}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}` @@ -102,25 +102,22 @@ const MultipleChoiceAlternatives = ({ activeSlide, competitionId }: MultipleChoi /> </Center> </ListItem> - {activeSlide && - activeSlide.questions[0] && - activeSlide.questions[0].alternatives && - activeSlide.questions[0].alternatives.map((alt) => ( - <div key={alt.id}> - <ListItem divider> - <AlternativeTextField - id="outlined-basic" - defaultValue={alt.text} - onChange={(event) => updateAlternativeText(alt.id, event.target.value)} - variant="outlined" - /> - <GreenCheckbox checked={numberToBool(alt.value)} onChange={() => updateAlternativeValue(alt)} /> - <Clickable> - <CloseIcon onClick={() => handleCloseAnswerClick(alt.id)} /> - </Clickable> - </ListItem> - </div> - ))} + {activeSlide?.questions?.[0]?.alternatives?.map((alt) => ( + <div key={alt.id}> + <ListItem divider> + <AlternativeTextField + id="outlined-basic" + defaultValue={alt.text} + onChange={(event) => updateAlternativeText(alt.id, event.target.value)} + variant="outlined" + /> + <GreenCheckbox checked={numberToBool(alt.value)} onChange={() => updateAlternativeValue(alt)} /> + <Clickable> + <CloseIcon onClick={() => handleCloseAnswerClick(alt.id)} /> + </Clickable> + </ListItem> + </div> + ))} <ListItem button onClick={addAlternative}> <Center> <AddButton variant="button">Lägg till svarsalternativ</AddButton> diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.test.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c9f13bcdd7e7b76d0e90de61cfc2a86c52dca194 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import store from '../../../../store' +import QuestionSettings from './QuestionSettings' + +it('renders question settings', () => { + render( + <Provider store={store}> + <QuestionSettings activeSlide={{ id: 5 } as RichSlide} competitionId="1" /> + </Provider> + ) +}) diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx index e917afbd19f1aac2d7256c1f5913d8a63f323ee1..f714fe2343e427a281d4e08aac50b895257d6424 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx @@ -18,7 +18,7 @@ const QuestionSettings = ({ activeSlide, competitionId }: QuestionSettingsProps) updateTitle: boolean, event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement> ) => { - if (activeSlide && activeSlide.questions[0]) { + if (activeSlide && activeSlide.questions?.[0]) { if (updateTitle) { await axios .put(`/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}`, { @@ -44,7 +44,7 @@ const QuestionSettings = ({ activeSlide, competitionId }: QuestionSettingsProps) const [score, setScore] = useState<number | undefined>(0) useEffect(() => { - setScore(activeSlide?.questions[0]?.total_score) + setScore(activeSlide?.questions?.[0]?.total_score) }, [activeSlide]) return ( @@ -74,7 +74,7 @@ const QuestionSettings = ({ activeSlide, competitionId }: QuestionSettingsProps) label="Poäng" type="number" InputProps={{ inputProps: { min: 0 } }} - value={score} + value={score || 0} onChange={(event) => updateQuestion(false, event)} /> </Center> diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/SingleChoiceAlternatives.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/SingleChoiceAlternatives.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6e38c39fe443b5c1e78810cb5731690870f8f614 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/SingleChoiceAlternatives.tsx @@ -0,0 +1,131 @@ +import { ListItem, ListItemText } from '@material-ui/core' +import CloseIcon from '@material-ui/icons/Close' +import RadioButtonCheckedIcon from '@material-ui/icons/RadioButtonCheckedOutlined' +import RadioButtonUncheckedIcon from '@material-ui/icons/RadioButtonUncheckedOutlined' +import axios from 'axios' +import React from 'react' +import { getEditorCompetition } from '../../../../actions/editor' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { QuestionAlternative } from '../../../../interfaces/ApiModels' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { AddButton, AlternativeTextField, Center, Clickable, SettingsList } from '../styled' + +type SingleChoiceAlternativeProps = { + activeSlide: RichSlide + competitionId: string +} + +const SingleChoiceAlternatives = ({ activeSlide, competitionId }: SingleChoiceAlternativeProps) => { + const dispatch = useAppDispatch() + const activeSlideId = useAppSelector((state) => state.editor.activeSlideId) + + const updateAlternativeValue = async (alternative: QuestionAlternative) => { + if (activeSlide && activeSlide.questions[0]) { + // Remove check from previously checked alternative + const previousCheckedAltId = activeSlide.questions[0].alternatives.find((alt) => alt.value === 1)?.id + if (previousCheckedAltId !== alternative.id) { + if (previousCheckedAltId) { + axios.put( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${previousCheckedAltId}`, + { value: 0 } + ) + } + // Set new checked alternative + await axios + .put( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative.id}`, + { value: 1 } + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + } + + const updateAlternativeText = async (alternative_id: number, newText: string) => { + if (activeSlide && activeSlide.questions[0]) { + await axios + .put( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}`, + { text: newText } + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + + const addAlternative = async () => { + if (activeSlide && activeSlide.questions[0]) { + await axios + .post( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives`, + { text: '', value: 0 } + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + + const handleCloseAnswerClick = async (alternative_id: number) => { + if (activeSlide && activeSlide.questions[0]) { + await axios + .delete( + `/api/competitions/${competitionId}/slides/${activeSlideId}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}` + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + + const renderRadioButton = (alt: QuestionAlternative) => { + if (alt.value) return <RadioButtonCheckedIcon onClick={() => updateAlternativeValue(alt)} /> + else return <RadioButtonUncheckedIcon onClick={() => updateAlternativeValue(alt)} /> + } + + return ( + <SettingsList> + <ListItem divider> + <Center> + <ListItemText + primary="Svarsalternativ" + secondary="(Fyll i cirkeln höger om textfältet för att markera korrekt svar)" + /> + </Center> + </ListItem> + {activeSlide && + activeSlide.questions[0] && + activeSlide.questions[0].alternatives && + activeSlide.questions[0].alternatives.map((alt) => ( + <div key={alt.id}> + <ListItem divider> + <AlternativeTextField + id="outlined-basic" + defaultValue={alt.text} + onChange={(event) => updateAlternativeText(alt.id, event.target.value)} + variant="outlined" + /> + <Clickable>{renderRadioButton(alt)}</Clickable> + <Clickable> + <CloseIcon onClick={() => handleCloseAnswerClick(alt.id)} /> + </Clickable> + </ListItem> + </div> + ))} + <ListItem button onClick={addAlternative}> + <Center> + <AddButton variant="button">Lägg till svarsalternativ</AddButton> + </Center> + </ListItem> + </SettingsList> + ) +} + +export default SingleChoiceAlternatives diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.test.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b0b44c6439cf8d2f2aa3d4dd8eb94deb0ed3ef0e --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import store from '../../../../store' +import SlideType from './SlideType' + +it('renders slidetype', () => { + render( + <Provider store={store}> + <SlideType activeSlide={{ id: 5 } as RichSlide} competitionId="1" /> + </Provider> + ) +}) diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx index bc251b91e092c05457db1b64bb8bbe566d76dd55..8fbae43816a55a2be1f1dcc838afffd9f3cf0b9e 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx @@ -15,7 +15,7 @@ import { import axios from 'axios' import React, { useState } from 'react' import { getEditorCompetition } from '../../../../actions/editor' -import { useAppDispatch } from '../../../../hooks' +import { useAppDispatch, useAppSelector } from '../../../../hooks' import { RichSlide } from '../../../../interfaces/ApiRichModels' import { Center, FirstItem } from '../styled' @@ -30,6 +30,11 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { // For "slide type" dialog const [selectedSlideType, setSelectedSlideType] = useState(0) const [slideTypeDialog, setSlideTypeDialog] = useState(false) + const components = useAppSelector( + (state) => state.editor.competition.slides.find((slide) => slide.id === state.editor.activeSlideId)?.components + ) + const questionComponentId = components?.find((qCompId) => qCompId.type_id === 3)?.id + const openSlideTypeDialog = (type_id: number) => { setSelectedSlideType(type_id) setSlideTypeDialog(true) @@ -41,7 +46,8 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { const updateSlideType = async () => { closeSlideTypeDialog() if (activeSlide) { - if (activeSlide.questions[0] && activeSlide.questions[0].type_id !== selectedSlideType) { + if (activeSlide.questions?.[0] && activeSlide.questions[0].type_id !== selectedSlideType) { + deleteQuestionComponent(questionComponentId) if (selectedSlideType === 0) { // Change slide type from a question type to information await axios @@ -67,6 +73,7 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { }) .then(() => { dispatch(getEditorCompetition(competitionId)) + createQuestionComponent() }) .catch(console.log) } @@ -80,17 +87,44 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { }) .then(() => { dispatch(getEditorCompetition(competitionId)) + createQuestionComponent() }) .catch(console.log) } } } + + const createQuestionComponent = () => { + axios + .post(`/api/competitions/${competitionId}/slides/${activeSlide.id}/components`, { + x: 0, + y: 0, + w: 400, + h: 250, + type_id: 3, + view_type_id: 1, + question_id: activeSlide.questions[0].id, + }) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + + const deleteQuestionComponent = (componentId: number | undefined) => { + if (componentId) { + axios + .delete(`/api/competitions/${competitionId}/slides/${activeSlide.id}/components/${componentId}`) + .catch(console.log) + } + } + return ( <FirstItem> <ListItem> <FormControl fullWidth variant="outlined"> <InputLabel>Sidtyp</InputLabel> - <Select fullWidth={true} value={activeSlide?.questions[0]?.type_id || 0} label="Sidtyp"> + <Select fullWidth={true} value={activeSlide?.questions?.[0]?.type_id || 0} label="Sidtyp"> <MenuItem value={0}> <Typography variant="button" onClick={() => openSlideTypeDialog(0)}> Informationssida @@ -108,7 +142,12 @@ const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { </MenuItem> <MenuItem value={3}> <Typography variant="button" onClick={() => openSlideTypeDialog(3)}> - Flervalsfråga + Kryssfråga + </Typography> + </MenuItem> + <MenuItem value={4}> + <Typography variant="button" onClick={() => openSlideTypeDialog(4)}> + Alternativfråga </Typography> </MenuItem> </Select> diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Texts.test.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Texts.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..edd4a89f28c440abb20e406de84fb0121647c718 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Texts.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import store from '../../../../store' +import Texts from './Texts' + +it('renders texts', () => { + render( + <Provider store={store}> + <Texts activeSlide={{ id: 5 } as RichSlide} activeViewTypeId={5} competitionId="1" /> + </Provider> + ) +}) diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Timer.test.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Timer.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5ef0d2ed43af3fae53ad94816027d97091340a83 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Timer.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import store from '../../../../store' +import Timer from './Timer' + +it('renders timer', () => { + render( + <Provider store={store}> + <Timer activeSlide={{ id: 5 } as RichSlide} competitionId="1" /> + </Provider> + ) +}) diff --git a/client/src/pages/presentationEditor/components/styled.tsx b/client/src/pages/presentationEditor/components/styled.tsx index 605972d2c4ee798388f9437f373cbabeab777d3c..31e40d51de8a4ecb45fbddf1cda9b7e96d4151f5 100644 --- a/client/src/pages/presentationEditor/components/styled.tsx +++ b/client/src/pages/presentationEditor/components/styled.tsx @@ -1,16 +1,4 @@ -import { - FormControl, - List, - Tab, - TextField, - Typography, - Button, - Card, - ListItem, - Select, - InputLabel, - ListItemText, -} from '@material-ui/core' +import { Button, Card, List, ListItemText, Tab, TextField, Typography } from '@material-ui/core' import styled from 'styled-components' export const SettingsTab = styled(Tab)` @@ -148,3 +136,7 @@ export const HoverContainer = styled.div<HoverContainerProps>` export const ImageNameText = styled(ListItemText)` word-break: break-all; ` + +export const QuestionComponent = styled.div` + outline-style: double; +` diff --git a/client/src/pages/views/OperatorViewPage.tsx b/client/src/pages/views/OperatorViewPage.tsx index 5b75d2d2f505f05d18e797d8ef039a9a06b2f067..36aa64d23d16363e2f842a3958bdc30d309faaa1 100644 --- a/client/src/pages/views/OperatorViewPage.tsx +++ b/client/src/pages/views/OperatorViewPage.tsx @@ -27,6 +27,7 @@ import axios from 'axios' import React, { useEffect } from 'react' import { useHistory } from 'react-router-dom' import { useAppSelector } from '../../hooks' +import { RichTeam } from '../../interfaces/ApiRichModels' import { socketConnect, socketEndPresentation, @@ -59,7 +60,6 @@ import { * TODO: * - Instead of copying code for others to join the competition, copy URL. * - * - Make code popup less code by using .map instead * * - Fix scoreboard * @@ -113,14 +113,13 @@ const OperatorViewPage: React.FC = () => { useEffect(() => { socketConnect() - socketSetSlide // Behövs denna? + socketSetSlide handleOpenCodes() - setTimeout(startCompetition, 1000) // Ghetto, wait for everything to load - // console.log(id) + setTimeout(startCompetition, 1000) // Wait for socket to connect }, []) + /** Handles the browsers back button and if pressed cancels the ongoing competition */ window.onpopstate = () => { - //Handle browser back arrow alert('Tävlingen avslutas för alla') endCompetition() } @@ -136,11 +135,12 @@ const OperatorViewPage: React.FC = () => { } const startCompetition = () => { - socketStartPresentation() + socketStartPresentation() // Calls the socket to start competition console.log('started competition for') console.log(competitionId) } + /** Making sure the user wants to exit the competition by displaying a dialog box */ const handleVerifyExit = () => { setOpen(true) } @@ -155,7 +155,7 @@ const OperatorViewPage: React.FC = () => { setOpen(false) socketEndPresentation() history.push('/admin/tävlingshanterare') - window.location.reload(false) // TODO: fix this ugly hack, we "need" to refresh site to be able to run the competition correctly again + window.location.reload(false) // TODO: fix this, we "need" to refresh site to be able to run the competition correctly again } const getCodes = async () => { @@ -205,6 +205,15 @@ const OperatorViewPage: React.FC = () => { return typeName } + /** Sums the scores for the teams. */ + const addScore = (team: RichTeam) => { + let totalScore = 0 + for (let j = 0; j < team.question_answers.length; j++) { + totalScore = totalScore + team.question_answers[j].score + } + return totalScore + } + return ( <OperatorContainer> <Dialog @@ -296,31 +305,6 @@ const OperatorViewPage: React.FC = () => { </OperatorButton> </Tooltip> - {/* - // Manual start button - <Tooltip title="Start Presentation" arrow> - <OperatorButton onClick={startCompetition} variant="contained"> - start - </OperatorButton> - </Tooltip> - - - // This creates a join button, but Operator should not join others, others should join Operator - <Tooltip title="Join Presentation" arrow> - <OperatorButton onClick={socketJoinPresentation} variant="contained"> - <GroupAddIcon fontSize="large" /> - </OperatorButton> - </Tooltip> - - - // This creates another end button, it might not be needed since we already have one - <Tooltip title="End Presentation" arrow> - <OperatorButton onClick={socketEndPresentation} variant="contained"> - <CancelIcon fontSize="large" /> - </OperatorButton> - </Tooltip> - */} - <Tooltip title="Starta Timer" arrow> <OperatorButton onClick={socketStartTimer} variant="contained"> <TimerIcon fontSize="large" /> @@ -364,7 +348,7 @@ const OperatorViewPage: React.FC = () => { {teams && teams.map((team) => ( <ListItem key={team.id}> - {team.name} score: {'666'} + {team.name} score:{addScore(team)} </ListItem> ))} </List> diff --git a/client/src/pages/views/components/PresentationComponent.tsx b/client/src/pages/views/components/PresentationComponent.tsx index a41f7912469256a6e946522790f4f8203f8da60f..0a688dc1c1bec3373cbf7a4e782b57fdab58d411 100644 --- a/client/src/pages/views/components/PresentationComponent.tsx +++ b/client/src/pages/views/components/PresentationComponent.tsx @@ -1,21 +1,17 @@ -import { Typography } from '@material-ui/core' import React from 'react' import { Rnd } from 'react-rnd' import { ComponentTypes } from '../../../enum/ComponentTypes' -import { useAppSelector } from '../../../hooks' import { Component, ImageComponent, TextComponent } from '../../../interfaces/ApiModels' import ImageComponentDisplay from '../../presentationEditor/components/ImageComponentDisplay' +import QuestionComponentDisplay from '../../presentationEditor/components/QuestionComponentDisplay' import TextComponentDisplay from '../../presentationEditor/components/TextComponentDisplay' -import { SlideContainer } from './styled' type PresentationComponentProps = { component: Component - width: number - height: number scale: number } -const PresentationComponent = ({ component, width, height, scale }: PresentationComponentProps) => { +const PresentationComponent = ({ component, scale }: PresentationComponentProps) => { const renderInnerComponent = () => { switch (component.type_id) { case ComponentTypes.Text: @@ -28,6 +24,8 @@ const PresentationComponent = ({ component, width, height, scale }: Presentation component={component as ImageComponent} /> ) + case ComponentTypes.Question: + return <QuestionComponentDisplay variant="presentation" /> default: break } diff --git a/client/src/pages/views/components/SocketTest.tsx b/client/src/pages/views/components/SocketTest.tsx deleted file mode 100644 index 01a0a6f29b51d9a194cb397fdc73c720202e0c1c..0000000000000000000000000000000000000000 --- a/client/src/pages/views/components/SocketTest.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React, { useEffect } from 'react' -import { connect } from 'react-redux' -import { useAppDispatch } from '../../../hooks' -import { - socketConnect, - socketEndPresentation, - socketJoinPresentation, - socketSetSlideNext, - socketSetSlidePrev, - socketStartPresentation, - socketStartTimer, -} from '../../../sockets' - -const mapStateToProps = (state: any) => { - return { - slide_order: state.presentation.slide.order, - } -} - -const mapDispatchToProps = (dispatch: any) => { - return { - // tickTimer: () => dispatch(tickTimer(1)), - } -} - -const SocketTest: React.FC = (props: any) => { - const dispatch = useAppDispatch() - - useEffect(() => { - socketConnect() - // dispatch(getPresentationCompetition('1')) // TODO: Use ID of item_code gotten from auth/login/<code> api call - // dispatch(getPresentationTeams('1')) // TODO: Use ID of item_code gotten from auth/login/<code> api call - }, []) - - return ( - <> - <button onClick={socketStartPresentation}>Start presentation</button> - <button onClick={socketJoinPresentation}>Join presentation</button> - <button onClick={socketEndPresentation}>End presentation</button> - <button onClick={socketSetSlidePrev}>Prev slide</button> - <button onClick={socketSetSlideNext}>Next slide</button> - <button onClick={socketStartTimer}>Start timer</button> - <div>Current slide: {props.slide_order}</div> - {/* <div>Timer: {props.timer.value}</div> - <div>Enabled: {props.timer.enabled.toString()}</div> - <button onClick={syncTimer}>Sync</button> - <button onClick={() => dispatch(setTimer(5))}>5 Sec</button> - <button - onClick={() => { - dispatch(setTimer(5)) - dispatch(setTimerEnabled(true)) - syncTimer() - }} - > - Sync and 5 sec - </button> */} - </> - ) -} - -export default connect(mapStateToProps, mapDispatchToProps)(SocketTest) diff --git a/client/src/reducers/allReducers.ts b/client/src/reducers/allReducers.ts index 90cb24144612c5a45f611ad99871c54c51969bbd..038b172e3e308af71d5762ade8a644b67d7992d2 100644 --- a/client/src/reducers/allReducers.ts +++ b/client/src/reducers/allReducers.ts @@ -5,7 +5,6 @@ import citiesReducer from './citiesReducer' import competitionLoginReducer from './competitionLoginReducer' import competitionsReducer from './competitionsReducer' import editorReducer from './editorReducer' -import mediaReducer from './mediaReducer' import presentationReducer from './presentationReducer' import rolesReducer from './rolesReducer' import searchUserReducer from './searchUserReducer' @@ -25,7 +24,6 @@ const allReducers = combineReducers({ roles: rolesReducer, searchUsers: searchUserReducer, types: typesReducer, - media: mediaReducer, statistics: statisticsReducer, competitionLogin: competitionLoginReducer, }) diff --git a/client/src/reducers/citiesReducer.ts b/client/src/reducers/citiesReducer.ts index 7f5555b3eb002559df3952dfbac31d178ffd6271..4fbc5a1cc559fe11994b41fed5073dde304472aa 100644 --- a/client/src/reducers/citiesReducer.ts +++ b/client/src/reducers/citiesReducer.ts @@ -2,11 +2,14 @@ import { AnyAction } from 'redux' import Types from '../actions/types' import { City } from '../interfaces/ApiModels' +// Define a type for the city state interface CityState { cities: City[] total: number count: number } + +// Define initial values for the city state const initialState: CityState = { cities: [], total: 0, diff --git a/client/src/reducers/competitionLoginReducer.ts b/client/src/reducers/competitionLoginReducer.ts index 72e5e22ee81aa98b735f670a581ad0c39a810ea9..d06f8e3029f11298dfe905bfd71041d0a06bf8b9 100644 --- a/client/src/reducers/competitionLoginReducer.ts +++ b/client/src/reducers/competitionLoginReducer.ts @@ -1,16 +1,18 @@ import { AnyAction } from 'redux' import Types from '../actions/types' +// Define a type for the competition login data interface CompetitionLoginData { competition_id: number team_id: number | null view: string } - +// Define a type for UI error interface UIError { message: string } +// Define a type for the competition login state interface CompetitionLoginState { loading: boolean errors: null | UIError @@ -19,6 +21,7 @@ interface CompetitionLoginState { initialized: boolean } +// Define the initial values for the competition login state const initialState: CompetitionLoginState = { loading: false, errors: null, diff --git a/client/src/reducers/competitionsReducer.ts b/client/src/reducers/competitionsReducer.ts index bb788da5439874f8f9963dfbb756a222012697e6..8a99b29bfcea7f10ab7e6b83393b76bc16756fa5 100644 --- a/client/src/reducers/competitionsReducer.ts +++ b/client/src/reducers/competitionsReducer.ts @@ -10,6 +10,7 @@ interface CompetitionState { filterParams: CompetitionFilterParams } +// Define the initial values for the competition state const initialState: CompetitionState = { competitions: [], total: 0, diff --git a/client/src/reducers/mediaReducer.ts b/client/src/reducers/mediaReducer.ts deleted file mode 100644 index ad5f3b46547f2e9a20135537ff814b196ca22331..0000000000000000000000000000000000000000 --- a/client/src/reducers/mediaReducer.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { AnyAction } from 'redux' -import Types from '../actions/types' - -interface MediaState { - id: number - filename: string - mediatype_id: number - user_id: number -} -const initialState: MediaState = { - id: 0, - filename: '', - mediatype_id: 1, - user_id: 0, -} - -export default function (state = initialState, action: AnyAction) { - switch (action.type) { - case Types.SET_MEDIA_ID: - return { ...state, id: action.payload as number } - case Types.SET_MEDIA_FILENAME: - return { - ...state, - filename: action.payload as string, - } - case Types.SET_MEDIA_TYPE_ID: - return { - ...state, - mediatype_id: action.payload as number, - } - case Types.SET_MEDIA_USER_ID: - return { - ...state, - user_id: action.payload as number, - } - default: - return state - } -} diff --git a/client/src/reducers/presentationReducer.ts b/client/src/reducers/presentationReducer.ts index b0c4791e9ab1f0ef05c931caa1fc4e9fe8268917..578b0a279c824210892be87bd69e0f62b74d7c89 100644 --- a/client/src/reducers/presentationReducer.ts +++ b/client/src/reducers/presentationReducer.ts @@ -1,9 +1,10 @@ import { AnyAction } from 'redux' import Types from '../actions/types' -import { Slide, Team } from '../interfaces/ApiModels' +import { Slide } from '../interfaces/ApiModels' import { Timer } from '../interfaces/Timer' import { RichCompetition } from './../interfaces/ApiRichModels' +// Define a type for the presentation state interface PresentationState { competition: RichCompetition slide: Slide @@ -11,6 +12,7 @@ interface PresentationState { timer: Timer } +// Define the initial values for the presentation state const initialState: PresentationState = { competition: { name: '', diff --git a/client/src/reducers/rolesReducer.ts b/client/src/reducers/rolesReducer.ts index 5028ae04cb13a4b1bf44536cd42bf3f8935b268c..8fc1465ae10f50dbf25d52b0f013c72647f5321a 100644 --- a/client/src/reducers/rolesReducer.ts +++ b/client/src/reducers/rolesReducer.ts @@ -2,9 +2,12 @@ import { AnyAction } from 'redux' import Types from '../actions/types' import { Role } from '../interfaces/ApiModels' +// Define a type for the role state interface RoleState { roles: Role[] } + +// Define the initial values for the role state const initialState: RoleState = { roles: [], } diff --git a/client/src/reducers/searchUserReducer.ts b/client/src/reducers/searchUserReducer.ts index e0c1250683ae273318a4bcd6e4a3f5ae5f6324bd..38269dfb604ddd5072293f8c101088012ce8f7f5 100644 --- a/client/src/reducers/searchUserReducer.ts +++ b/client/src/reducers/searchUserReducer.ts @@ -3,6 +3,7 @@ import Types from '../actions/types' import { User } from '../interfaces/ApiModels' import { UserFilterParams } from '../interfaces/FilterParams' +// Define a type for the search user state interface SearchUserState { users: User[] total: number @@ -10,6 +11,7 @@ interface SearchUserState { filterParams: UserFilterParams } +// Define the initial values for the search user state const initialState: SearchUserState = { users: [], total: 0, diff --git a/client/src/reducers/statisticsReducer.ts b/client/src/reducers/statisticsReducer.ts index 78a06e1157f6428c10ea17073afbdf46cf8609e5..bc957ccb97f2fde20eca5d44bf818650a7a01eb7 100644 --- a/client/src/reducers/statisticsReducer.ts +++ b/client/src/reducers/statisticsReducer.ts @@ -1,12 +1,14 @@ import { AnyAction } from 'redux' import Types from '../actions/types' +// Define a type for the statistics state interface StatisticsState { users: number competitions: number regions: number } +// Define the initial values for the statistics state const initialState: StatisticsState = { users: 0, competitions: 0, diff --git a/client/src/reducers/typesReducer.ts b/client/src/reducers/typesReducer.ts index 3540ef86fbd4a921738d896b2b0bebb14b3216e0..10ea1c63b3f9f8f2b47f997d1036f71bc51450ac 100644 --- a/client/src/reducers/typesReducer.ts +++ b/client/src/reducers/typesReducer.ts @@ -2,12 +2,14 @@ import { AnyAction } from 'redux' import Types from '../actions/types' import { ComponentType, MediaType, QuestionType, ViewType } from '../interfaces/ApiModels' +// Define a type for the Types state interface TypesState { componentTypes: ComponentType[] viewTypes: ViewType[] questionTypes: QuestionType[] mediaTypes: MediaType[] } +// Define the initial values for the types state const initialState: TypesState = { componentTypes: [], viewTypes: [], diff --git a/client/src/reducers/uiReducer.ts b/client/src/reducers/uiReducer.ts index 4d06d1e298bab88cbfe2f41a54284a2557660a33..350f7b8e8aaa08752252343a95f4703770d2bb77 100644 --- a/client/src/reducers/uiReducer.ts +++ b/client/src/reducers/uiReducer.ts @@ -1,15 +1,18 @@ import { AnyAction } from 'redux' import Types from '../actions/types' +// Define a type for the UI error interface UIError { message: string } +// Define a type for the UI state interface UIState { loading: boolean errors: null | UIError } +// Define the initial values for the UI state const initialState: UIState = { loading: false, errors: null, diff --git a/client/src/reducers/userReducer.ts b/client/src/reducers/userReducer.ts index 91c056d3f0a55383fed0a0e40d0e94240d14a947..6b4f985b43a80a8f2cd91213a6b4c938f6543560 100644 --- a/client/src/reducers/userReducer.ts +++ b/client/src/reducers/userReducer.ts @@ -1,6 +1,7 @@ import { AnyAction } from 'redux' import Types from '../actions/types' +// Define a type for users info interface UserInfo { name: string email: string @@ -9,12 +10,14 @@ interface UserInfo { id: number } +// Define a type for the users state interface UserState { authenticated: boolean userInfo: UserInfo | null loading: boolean } +// Define the initial values for the users state const initialState: UserState = { authenticated: false, loading: false, diff --git a/client/src/reportWebVitals.ts b/client/src/reportWebVitals.ts deleted file mode 100644 index a832dfa7c24162e95463f27f3687de39a3c311bb..0000000000000000000000000000000000000000 --- a/client/src/reportWebVitals.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ReportHandler } from 'web-vitals' - -const reportWebVitals: () => void = (onPerfEntry?: ReportHandler) => { - if (onPerfEntry && onPerfEntry instanceof Function) { - import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { - getCLS(onPerfEntry) - getFID(onPerfEntry) - getFCP(onPerfEntry) - getLCP(onPerfEntry) - getTTFB(onPerfEntry) - }) - } -} - -export default reportWebVitals diff --git a/client/src/utils/checkAuthenticationCompetition.test.ts b/client/src/utils/checkAuthenticationCompetition.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d0fdd90aea38f613a7a1b1347085a2201d793a6 --- /dev/null +++ b/client/src/utils/checkAuthenticationCompetition.test.ts @@ -0,0 +1,79 @@ +import mockedAxios from 'axios' +import Types from '../actions/types' +import store from '../store' +import { CheckAuthenticationCompetition } from './checkAuthenticationCompetition' + +it('dispatches correct actions when auth token is ok', async () => { + const compRes = { data: { id: 3, slides: [{ id: 2 }] } } + ;(mockedAxios.get as jest.Mock).mockImplementation(() => { + return Promise.resolve(compRes) + }) + ;(mockedAxios.post as jest.Mock).mockImplementation(() => { + return Promise.resolve({ data: {} }) + }) + const spy = jest.spyOn(store, 'dispatch') + const decodedToken = { + iat: 1620216181, + exp: 32514436993, + user_claims: { competition_id: 123123, team_id: 321321, view: 'Participant', code: 'ABCDEF' }, + } + + const testToken = + 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjMyNTE0NDM2OTkzLCJ1c2VyX2NsYWltcyI6eyJjb21wZXRpdGlvbl9pZCI6MTIzMTIzLCJ0ZWFtX2lkIjozMjEzMjEsInZpZXciOiJQYXJ0aWNpcGFudCIsImNvZGUiOiJBQkNERUYifX0.1gPRJcjn3xuPOcgUUffMngIQDoDtxS9RZczcbdyyaaA' + localStorage.setItem('competitionToken', testToken) + await CheckAuthenticationCompetition() + expect(spy).toBeCalledWith({ + type: Types.SET_COMPETITION_LOGIN_DATA, + payload: { + competition_id: decodedToken.user_claims.competition_id, + team_id: decodedToken.user_claims.team_id, + view: decodedToken.user_claims.view, + }, + }) + expect(spy).toBeCalledWith({ type: Types.SET_PRESENTATION_CODE, payload: decodedToken.user_claims.code }) + expect(spy).toBeCalledWith({ + type: Types.SET_PRESENTATION_COMPETITION, + payload: compRes.data, + }) + expect(spy).toBeCalledTimes(4) +}) + +it('dispatches correct actions when getting user data fails', async () => { + console.log = jest.fn() + ;(mockedAxios.get as jest.Mock).mockImplementation(() => { + return Promise.reject(new Error('failed getting user data')) + }) + ;(mockedAxios.post as jest.Mock).mockImplementation(() => { + return Promise.resolve({ data: {} }) + }) + const spy = jest.spyOn(store, 'dispatch') + const testToken = + 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjMyNTE0NDM2OTkzLCJ1c2VyX2NsYWltcyI6eyJjb21wZXRpdGlvbl9pZCI6MTIzMTIzLCJ0ZWFtX2lkIjozMjEzMjEsInZpZXciOiJQYXJ0aWNpcGFudCIsImNvZGUiOiJBQkNERUYifX0.1gPRJcjn3xuPOcgUUffMngIQDoDtxS9RZczcbdyyaaA' + localStorage.setItem('competitionToken', testToken) + await CheckAuthenticationCompetition() + expect(spy).toBeCalledWith({ type: Types.SET_COMPETITION_LOGIN_UNAUTHENTICATED }) + expect(spy).toBeCalledTimes(1) + expect(console.log).toHaveBeenCalled() +}) + +it('dispatches no actions when no token exists', async () => { + ;(mockedAxios.post as jest.Mock).mockImplementation(() => { + return Promise.resolve({ data: {} }) + }) + const spy = jest.spyOn(store, 'dispatch') + await CheckAuthenticationCompetition() + expect(spy).not.toBeCalled() +}) + +it('dispatches correct actions when token is expired', async () => { + ;(mockedAxios.post as jest.Mock).mockImplementation(() => { + return Promise.resolve({ data: {} }) + }) + const testToken = + 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2MjAyMjE1OTgsImV4cCI6OTU3NTMzNTk4LCJhdWQiOiJ3d3cuZXhhbXBsZS5jb20iLCJzdWIiOiJqcm9ja2V0QGV4YW1wbGUuY29tIn0.uFXtkAsf-cTlKrTIdZ3E-gXnHkzS08iPrhS8iNCGV2E' + localStorage.setItem('competitionToken', testToken) + const spy = jest.spyOn(store, 'dispatch') + await CheckAuthenticationCompetition() + expect(spy).toBeCalledWith({ type: Types.SET_COMPETITION_LOGIN_UNAUTHENTICATED }) + expect(spy).toBeCalledTimes(1) +}) diff --git a/client/src/utils/checkAuthenticationCompetition.ts b/client/src/utils/checkAuthenticationCompetition.ts index 9877b674214fc5fae2899148fd9e57c9a0a3bc28..4d542dc34b5cb78cca7dbf134638a8b2649c48de 100644 --- a/client/src/utils/checkAuthenticationCompetition.ts +++ b/client/src/utils/checkAuthenticationCompetition.ts @@ -17,7 +17,7 @@ export const CheckAuthenticationCompetition = async () => { axios.defaults.headers.common['Authorization'] = authToken await axios .get('/api/auth/test') - .then((res) => { + .then(() => { store.dispatch({ type: Types.SET_COMPETITION_LOGIN_DATA, payload: { diff --git a/client/src/utils/renderSlideIcon.test.tsx b/client/src/utils/renderSlideIcon.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ed82c2efcd8b880538a98176acead1b7221104b5 --- /dev/null +++ b/client/src/utils/renderSlideIcon.test.tsx @@ -0,0 +1,63 @@ +import BuildOutlinedIcon from '@material-ui/icons/BuildOutlined' +import CheckBoxOutlinedIcon from '@material-ui/icons/CheckBoxOutlined' +import CreateOutlinedIcon from '@material-ui/icons/CreateOutlined' +import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined' +import RadioButtonCheckedIcon from '@material-ui/icons/RadioButtonChecked' +import { shallow } from 'enzyme' +import { RichSlide } from '../interfaces/ApiRichModels' +import { renderSlideIcon } from './renderSlideIcon' + +it('returns CreateOutlinedIcon correctly ', async () => { + const testSlide = { questions: [{ id: 5, type_id: 1 }] } as RichSlide + const icon = renderSlideIcon(testSlide) + expect(icon).toBeDefined() + if (icon) { + const actualResult = shallow(icon) + const expectedResult = shallow(<CreateOutlinedIcon />) + expect(actualResult).toEqual(expectedResult) + } +}) + +it('returns BuildOutlinedIcon correctly ', async () => { + const testSlide = { questions: [{ id: 5, type_id: 2 }] } as RichSlide + const icon = renderSlideIcon(testSlide) + expect(icon).toBeDefined() + if (icon) { + const actualResult = shallow(icon) + const expectedResult = shallow(<BuildOutlinedIcon />) + expect(actualResult).toEqual(expectedResult) + } +}) + +it('returns DnsOutlinedIcon correctly ', async () => { + const testSlide = { questions: [{ id: 5, type_id: 3 }] } as RichSlide + const icon = renderSlideIcon(testSlide) + expect(icon).toBeDefined() + if (icon) { + const actualResult = shallow(icon) + const expectedResult = shallow(<CheckBoxOutlinedIcon />) + expect(actualResult).toEqual(expectedResult) + } +}) + +it('returns DnsOutlinedIcon correctly ', async () => { + const testSlide = { questions: [{ id: 5, type_id: 4 }] } as RichSlide + const icon = renderSlideIcon(testSlide) + expect(icon).toBeDefined() + if (icon) { + const actualResult = shallow(icon) + const expectedResult = shallow(<RadioButtonCheckedIcon />) + expect(actualResult).toEqual(expectedResult) + } +}) + +it('defaults to InfoOutlinedIcon', async () => { + const testSlide = {} as RichSlide + const icon = renderSlideIcon(testSlide) + expect(icon).toBeDefined() + if (icon) { + const actualResult = shallow(icon) + const expectedResult = shallow(<InfoOutlinedIcon />) + expect(actualResult).toEqual(expectedResult) + } +}) diff --git a/client/src/utils/renderSlideIcon.tsx b/client/src/utils/renderSlideIcon.tsx index ba1eafcb8052469dd8b627b95d2eee518bfd5fe8..22f6405abbc8574ae17bf86dd8b745ba98e3b4cf 100644 --- a/client/src/utils/renderSlideIcon.tsx +++ b/client/src/utils/renderSlideIcon.tsx @@ -1,9 +1,10 @@ -import { RichSlide } from '../interfaces/ApiRichModels' -import React from 'react' import BuildOutlinedIcon from '@material-ui/icons/BuildOutlined' +import CheckBoxOutlinedIcon from '@material-ui/icons/CheckBoxOutlined' import CreateOutlinedIcon from '@material-ui/icons/CreateOutlined' -import DnsOutlinedIcon from '@material-ui/icons/DnsOutlined' import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined' +import RadioButtonCheckedIcon from '@material-ui/icons/RadioButtonChecked' +import React from 'react' +import { RichSlide } from '../interfaces/ApiRichModels' export const renderSlideIcon = (slide: RichSlide) => { if (slide.questions && slide.questions[0] && slide.questions[0].type_id) { @@ -13,7 +14,9 @@ export const renderSlideIcon = (slide: RichSlide) => { case 2: return <BuildOutlinedIcon /> // practical qustion case 3: - return <DnsOutlinedIcon /> // multiple choice question + return <CheckBoxOutlinedIcon /> // multiple choice question + case 4: + return <RadioButtonCheckedIcon /> // single choice question } } else { return <InfoOutlinedIcon /> // information slide diff --git a/server/app/apis/alternatives.py b/server/app/apis/alternatives.py index 94f6d6b1725106a3cf5994570e4d3d5829ba2dbe..1827b358769441fd5d4748fabacf4221573c3346 100644 --- a/server/app/apis/alternatives.py +++ b/server/app/apis/alternatives.py @@ -1,10 +1,14 @@ +""" +All API calls concerning question alternatives. +Default route: /api/competitions/<competition_id>/slides/<slide_id>/questions/<question_id>/alternatives +""" + import app.core.http_codes as codes import app.database.controller as dbc from app.apis import item_response, list_response, protect_route from app.core.dto import QuestionAlternativeDTO -from flask_restx import Resource -from flask_restx import reqparse from app.core.parsers import sentinel +from flask_restx import Resource, reqparse api = QuestionAlternativeDTO.api schema = QuestionAlternativeDTO.schema @@ -24,11 +28,22 @@ alternative_parser_edit.add_argument("value", type=int, default=sentinel, locati class QuestionAlternativeList(Resource): @protect_route(allowed_roles=["*"], allowed_views=["*"]) def get(self, competition_id, slide_id, question_id): - items = dbc.get.question_alternative_list(competition_id, slide_id, question_id) + """ Gets the all question alternatives to the specified question. """ + + items = dbc.get.question_alternative_list( + competition_id, + slide_id, + question_id, + ) return list_response(list_schema.dump(items)) @protect_route(allowed_roles=["*"]) def post(self, competition_id, slide_id, question_id): + """ + Posts a new question alternative to the specified + question using the provided arguments. + """ + args = alternative_parser_add.parse_args(strict=True) item = dbc.add.question_alternative(**args, question_id=question_id) return item_response(schema.dump(item)) @@ -39,18 +54,41 @@ class QuestionAlternativeList(Resource): class QuestionAlternatives(Resource): @protect_route(allowed_roles=["*"], allowed_views=["*"]) def get(self, competition_id, slide_id, question_id, alternative_id): - items = dbc.get.question_alternative(competition_id, slide_id, question_id, alternative_id) + """ Gets the specified question alternative. """ + + items = dbc.get.question_alternative( + competition_id, + slide_id, + question_id, + alternative_id, + ) return item_response(schema.dump(items)) @protect_route(allowed_roles=["*"]) def put(self, competition_id, slide_id, question_id, alternative_id): + """ + Edits the specified question alternative using the provided arguments. + """ + args = alternative_parser_edit.parse_args(strict=True) - item = dbc.get.question_alternative(competition_id, slide_id, question_id, alternative_id) + item = dbc.get.question_alternative( + competition_id, + slide_id, + question_id, + alternative_id, + ) item = dbc.edit.default(item, **args) return item_response(schema.dump(item)) @protect_route(allowed_roles=["*"]) def delete(self, competition_id, slide_id, question_id, alternative_id): - item = dbc.get.question_alternative(competition_id, slide_id, question_id, alternative_id) + """ Deletes the specified question alternative. """ + + item = dbc.get.question_alternative( + competition_id, + slide_id, + question_id, + alternative_id, + ) dbc.delete.default(item) return {}, codes.NO_CONTENT diff --git a/server/app/apis/answers.py b/server/app/apis/answers.py index 6d0490a9328af2b903522fbeefe5224dcc645e06..c1ccd68b94d8d86faa0c214b5248670db260a73d 100644 --- a/server/app/apis/answers.py +++ b/server/app/apis/answers.py @@ -1,9 +1,13 @@ +""" +All API calls concerning question answers. +Default route: /api/competitions/<competition_id>/teams/<team_id>/answers +""" + import app.database.controller as dbc from app.apis import item_response, list_response, protect_route from app.core.dto import QuestionAnswerDTO -from flask_restx import Resource -from flask_restx import reqparse from app.core.parsers import sentinel +from flask_restx import Resource, reqparse api = QuestionAnswerDTO.api schema = QuestionAnswerDTO.schema @@ -24,11 +28,18 @@ answer_parser_edit.add_argument("score", type=int, default=sentinel, location="j class QuestionAnswerList(Resource): @protect_route(allowed_roles=["*"], allowed_views=["*"]) def get(self, competition_id, team_id): + """ Gets all question answers that the specified team has given. """ + items = dbc.get.question_answer_list(competition_id, team_id) return list_response(list_schema.dump(items)) @protect_route(allowed_roles=["*"], allowed_views=["*"]) def post(self, competition_id, team_id): + """ + Posts a new question answer to the specified + question using the provided arguments. + """ + args = answer_parser_add.parse_args(strict=True) item = dbc.add.question_answer(**args, team_id=team_id) return item_response(schema.dump(item)) @@ -39,12 +50,19 @@ class QuestionAnswerList(Resource): class QuestionAnswers(Resource): @protect_route(allowed_roles=["*"], allowed_views=["*"]) def get(self, competition_id, team_id, answer_id): + """ Gets the specified question answer. """ + item = dbc.get.question_answer(competition_id, team_id, answer_id) return item_response(schema.dump(item)) @protect_route(allowed_roles=["*"], allowed_views=["*"]) def put(self, competition_id, team_id, answer_id): + """ Edits the specified question answer with the provided arguments. """ + args = answer_parser_edit.parse_args(strict=True) item = dbc.get.question_answer(competition_id, team_id, answer_id) item = dbc.edit.default(item, **args) return item_response(schema.dump(item)) + + # No need to delete an answer. It only needs to be deleted + # together with the question or the team. diff --git a/server/app/apis/auth.py b/server/app/apis/auth.py index bf9eeefde781f3fcacfd4bdafade8829db24acaa..e6a5345bd0346eb9959a94bb7d28f3a9e15f0097 100644 --- a/server/app/apis/auth.py +++ b/server/app/apis/auth.py @@ -1,4 +1,9 @@ -from datetime import timedelta +""" +All API calls concerning question answers. +Default route: /api/auth +""" + +from datetime import datetime, timedelta import app.core.http_codes as codes import app.database.controller as dbc @@ -6,7 +11,8 @@ from app.apis import item_response, protect_route, text_response from app.core import sockets from app.core.codes import verify_code from app.core.dto import AuthDTO -from app.database.models import Whitelist +from app.database.models import User, Whitelist +from flask import current_app from flask_jwt_extended import create_access_token, get_jti, get_raw_jwt from flask_jwt_extended.utils import get_jti from flask_restx import Resource, inputs, reqparse @@ -26,12 +32,19 @@ create_user_parser.add_argument("role_id", type=int, required=True, location="js login_code_parser = reqparse.RequestParser() login_code_parser.add_argument("code", type=str, required=True, location="json") +USER_LOGIN_LOCKED_ATTEMPTS = current_app.config["USER_LOGIN_LOCKED_ATTEMPTS"] +USER_LOGIN_LOCKED_EXPIRES = current_app.config["USER_LOGIN_LOCKED_EXPIRES"] + def get_user_claims(item_user): + """ Gets user details for jwt-token. """ + return {"role": item_user.role.name, "city_id": item_user.city_id} def get_code_claims(item_code): + """ Gets code details for jwt-token. """ + return { "view": item_code.view_type.name, "competition_id": item_code.competition_id, @@ -44,6 +57,8 @@ def get_code_claims(item_code): class AuthSignup(Resource): @protect_route(allowed_roles=["Admin"], allowed_views=["*"]) def get(self): + """ Tests that the user is an admin. """ + return "ok" @@ -51,12 +66,16 @@ class AuthSignup(Resource): class AuthSignup(Resource): @protect_route(allowed_roles=["Admin"]) def post(self): + """ Creates a new user if the user does not already exist. """ + args = create_user_parser.parse_args(strict=True) email = args.get("email") + # Check if email is already used if dbc.get.user_exists(email): api.abort(codes.BAD_REQUEST, "User already exists") + # Add user item_user = dbc.add.user(**args) return item_response(schema.dump(item_user)) @@ -66,28 +85,65 @@ class AuthSignup(Resource): class AuthDelete(Resource): @protect_route(allowed_roles=["Admin"]) def delete(self, user_id): - item_user = dbc.get.user(user_id) + """ Deletes the specified user and adds their token to the blacklist. """ + + item_user = dbc.get.one(User, user_id) + + # Blacklist all the whitelisted tokens + # in use for the user that will be deleted dbc.delete.whitelist_to_blacklist(Whitelist.user_id == user_id) - dbc.delete.default(item_user) + # Delete user + dbc.delete.default(item_user) return text_response(f"User {user_id} deleted") @api.route("/login") class AuthLogin(Resource): def post(self): + """ Logs in the specified user and creates a jwt-token. """ + args = login_parser.parse_args(strict=True) email = args.get("email") password = args.get("password") + item_user = dbc.get.user_by_email(email) - if not item_user or not item_user.is_correct_password(password): + # Login with unkown email + if not item_user: api.abort(codes.UNAUTHORIZED, "Invalid email or password") + # Login with existing email but with wrong password + if not item_user.is_correct_password(password): + # Increase the login attempts every time the user tries to login with wrong password + item_user.login_attempts += 1 + + # Lock the user out for some time + if item_user.login_attempts == USER_LOGIN_LOCKED_ATTEMPTS: + item_user.locked = datetime.now() + USER_LOGIN_LOCKED_EXPIRES + + dbc.utils.commit() + api.abort(codes.UNAUTHORIZED, "Invalid email or password") + + # Otherwise if login was successful but the user is locked + if item_user.locked: + # Check if locked is greater than now + if item_user.locked > datetime.now(): + api.abort(codes.UNAUTHORIZED, f"Try again in {item_user.locked} hours.") + else: + item_user.locked = None + + # If everything else was successful, set login_attempts to 0 + item_user.login_attempts = 0 + dbc.utils.commit() + + # Create the jwt with user.id as the identifier access_token = create_access_token(item_user.id, user_claims=get_user_claims(item_user)) - # refresh_token = create_refresh_token(item_user.id) + # Login response includes the id and jwt for the user response = {"id": item_user.id, "access_token": access_token} + + # Whitelist the created jwt dbc.add.whitelist(get_jti(access_token), item_user.id) return response @@ -95,9 +151,12 @@ class AuthLogin(Resource): @api.route("/login/code") class AuthLoginCode(Resource): def post(self): + """ Logs in using the provided competition code. """ + args = login_code_parser.parse_args() code = args["code"] + # Check so the code string is valid if not verify_code(code): api.abort(codes.UNAUTHORIZED, "Invalid code") @@ -107,10 +166,12 @@ class AuthLoginCode(Resource): if item_code.competition_id not in sockets.presentations: api.abort(codes.UNAUTHORIZED, "Competition not active") + # Create jwt that is only valid for 8 hours access_token = create_access_token( item_code.id, user_claims=get_code_claims(item_code), expires_delta=timedelta(hours=8) ) + # Whitelist the created jwt dbc.add.whitelist(get_jti(access_token), competition_id=item_code.competition_id) response = { "competition_id": item_code.competition_id, @@ -125,9 +186,16 @@ class AuthLoginCode(Resource): class AuthLogout(Resource): @protect_route(allowed_roles=["*"], allowed_views=["*"]) def post(self): + """ Logs out. """ + jti = get_raw_jwt()["jti"] + + # Blacklist the token so the user cannot access the api anymore dbc.add.blacklist(jti) + + # Remove the the token from the whitelist since it's blacklisted now Whitelist.query.filter(Whitelist.jti == jti).delete() + dbc.utils.commit() return text_response("Logout") diff --git a/server/app/apis/codes.py b/server/app/apis/codes.py index 6409109ef875efc13a5c12040a100b28822ed6ae..45f9578280e82c306ff716a646505dbffd62be3b 100644 --- a/server/app/apis/codes.py +++ b/server/app/apis/codes.py @@ -1,3 +1,8 @@ +""" +All API calls concerning competition codes. +Default route: /api/competitions/<competition_id>/codes +""" + import app.database.controller as dbc from app.apis import item_response, list_response, protect_route from app.core.dto import CodeDTO @@ -14,6 +19,8 @@ list_schema = CodeDTO.list_schema class CodesList(Resource): @protect_route(allowed_roles=["*"], allowed_views=["Operator"]) def get(self, competition_id): + """ Gets the all competition codes. """ + items = dbc.get.code_list(competition_id) return list_response(list_schema.dump(items), len(items)) @@ -23,6 +30,8 @@ class CodesList(Resource): class CodesById(Resource): @protect_route(allowed_roles=["*"]) def put(self, competition_id, code_id): + """ Generates a new competition code. """ + item = dbc.get.one(Code, code_id) item.code = dbc.utils.generate_unique_code() dbc.utils.commit_and_refresh(item) diff --git a/server/app/apis/competitions.py b/server/app/apis/competitions.py index bfd06fac35c811fe276787b3027cad5d0d1f99a9..2d381425c9e5d3f41bba347128aab7b659bf8885 100644 --- a/server/app/apis/competitions.py +++ b/server/app/apis/competitions.py @@ -1,10 +1,14 @@ +""" +All API calls concerning competitions. +Default route: /api/competitions +""" + import app.database.controller as dbc from app.apis import item_response, list_response, protect_route from app.core.dto import CompetitionDTO -from app.database.models import Competition -from flask_restx import Resource -from flask_restx import reqparse from app.core.parsers import search_parser, sentinel +from app.database.models import Competition +from flask_restx import Resource, reqparse api = CompetitionDTO.api schema = CompetitionDTO.schema @@ -32,6 +36,8 @@ competition_parser_search.add_argument("city_id", type=int, default=sentinel, lo class CompetitionsList(Resource): @protect_route(allowed_roles=["*"]) def post(self): + """ Posts a new competition. """ + args = competition_parser_add.parse_args(strict=True) # Add competition @@ -47,12 +53,16 @@ class CompetitionsList(Resource): class Competitions(Resource): @protect_route(allowed_roles=["*"], allowed_views=["*"]) def get(self, competition_id): + """ Gets the specified competition. """ + item = dbc.get.competition(competition_id) return item_response(rich_schema.dump(item)) @protect_route(allowed_roles=["*"]) def put(self, competition_id): + """ Edits the specified competition with the specified arguments. """ + args = competition_parser_edit.parse_args(strict=True) item = dbc.get.one(Competition, competition_id) item = dbc.edit.default(item, **args) @@ -61,6 +71,8 @@ class Competitions(Resource): @protect_route(allowed_roles=["*"]) def delete(self, competition_id): + """ Deletes the specified competition. """ + item = dbc.get.one(Competition, competition_id) dbc.delete.competition(item) @@ -71,6 +83,8 @@ class Competitions(Resource): class CompetitionSearch(Resource): @protect_route(allowed_roles=["*"]) def get(self): + """ Finds a specific competition based on the provided arguments. """ + args = competition_parser_search.parse_args(strict=True) items, total = dbc.search.competition(**args) return list_response(list_schema.dump(items), total) @@ -81,6 +95,8 @@ class CompetitionSearch(Resource): class SlidesOrder(Resource): @protect_route(allowed_roles=["*"]) def post(self, competition_id): + """ Creates a deep copy of the specified competition. """ + item_competition = dbc.get.competition(competition_id) item_competition_copy = dbc.copy.competition(item_competition) diff --git a/server/app/apis/components.py b/server/app/apis/components.py index 38227a46d90fe2afb5ea4a2fc7924412661c5259..39e1932824f6e6ab103f773402bb1c3feb331e43 100644 --- a/server/app/apis/components.py +++ b/server/app/apis/components.py @@ -1,17 +1,21 @@ +""" +All API calls concerning competitions. +Default route: /api/competitions/<competition_id>/slides/<slide_id>/components +""" + import app.core.http_codes as codes import app.database.controller as dbc from app.apis import item_response, list_response, protect_route from app.core.dto import ComponentDTO -from flask_restx import Resource -from flask_restx import reqparse from app.core.parsers import sentinel +from flask_restx import Resource, reqparse api = ComponentDTO.api schema = ComponentDTO.schema list_schema = ComponentDTO.list_schema component_parser_add = reqparse.RequestParser() -component_parser_add.add_argument("x", type=str, default=0, location="json") +component_parser_add.add_argument("x", type=int, default=0, location="json") component_parser_add.add_argument("y", type=int, default=0, location="json") component_parser_add.add_argument("w", type=int, default=1, location="json") component_parser_add.add_argument("h", type=int, default=1, location="json") @@ -22,7 +26,7 @@ component_parser_add.add_argument("media_id", type=int, default=None, location=" component_parser_add.add_argument("question_id", type=int, default=None, location="json") component_parser_edit = reqparse.RequestParser() -component_parser_edit.add_argument("x", type=str, default=sentinel, location="json") +component_parser_edit.add_argument("x", type=int, default=sentinel, location="json") component_parser_edit.add_argument("y", type=int, default=sentinel, location="json") component_parser_edit.add_argument("w", type=int, default=sentinel, location="json") component_parser_edit.add_argument("h", type=int, default=sentinel, location="json") @@ -31,16 +35,39 @@ component_parser_edit.add_argument("media_id", type=int, default=sentinel, locat component_parser_edit.add_argument("question_id", type=int, default=sentinel, location="json") +@api.route("") +@api.param("competition_id, slide_id") +class ComponentList(Resource): + @protect_route(allowed_roles=["*"], allowed_views=["*"]) + def get(self, competition_id, slide_id): + """ Gets all components in the specified slide and competition. """ + + items = dbc.get.component_list(competition_id, slide_id) + return list_response(list_schema.dump(items)) + + @protect_route(allowed_roles=["*"]) + def post(self, competition_id, slide_id): + """ Posts a new component to the specified slide. """ + + args = component_parser_add.parse_args() + item = dbc.add.component(slide_id=slide_id, **args) + return item_response(schema.dump(item)) + + @api.route("/<component_id>") @api.param("competition_id, slide_id, component_id") class ComponentByID(Resource): @protect_route(allowed_roles=["*"], allowed_views=["*"]) def get(self, competition_id, slide_id, component_id): + """ Gets the specified component. """ + item = dbc.get.component(competition_id, slide_id, component_id) return item_response(schema.dump(item)) @protect_route(allowed_roles=["*"]) def put(self, competition_id, slide_id, component_id): + """ Edits the specified component using the provided arguments. """ + args = component_parser_edit.parse_args(strict=True) item = dbc.get.component(competition_id, slide_id, component_id) args_without_sentinel = {key: value for key, value in args.items() if value is not sentinel} @@ -49,6 +76,8 @@ class ComponentByID(Resource): @protect_route(allowed_roles=["*"]) def delete(self, competition_id, slide_id, component_id): + """ Deletes the specified component. """ + item = dbc.get.component(competition_id, slide_id, component_id) dbc.delete.component(item) return {}, codes.NO_CONTENT @@ -59,21 +88,12 @@ class ComponentByID(Resource): class ComponentList(Resource): @protect_route(allowed_roles=["*"]) def post(self, competition_id, slide_id, component_id, view_type_id): - item_component = dbc.get.component(competition_id, slide_id, component_id) - item = dbc.copy.component(item_component, slide_id, view_type_id) - return item_response(schema.dump(item)) - - -@api.route("") -@api.param("competition_id, slide_id") -class ComponentList(Resource): - @protect_route(allowed_roles=["*"], allowed_views=["*"]) - def get(self, competition_id, slide_id): - items = dbc.get.component_list(competition_id, slide_id) - return list_response(list_schema.dump(items)) + """ Creates a deep copy of the specified component. """ - @protect_route(allowed_roles=["*"]) - def post(self, competition_id, slide_id): - args = component_parser_add.parse_args() - item = dbc.add.component(slide_id=slide_id, **args) + item_component = dbc.get.component( + competition_id, + slide_id, + component_id, + ) + item = dbc.copy.component(item_component, slide_id, view_type_id) return item_response(schema.dump(item)) diff --git a/server/app/apis/media.py b/server/app/apis/media.py index 49d20608840e320ef3d73d9df848748e6da33617..c59589a60afb5944fdd45fea0b230187d8d8da8b 100644 --- a/server/app/apis/media.py +++ b/server/app/apis/media.py @@ -1,16 +1,20 @@ +""" +All API calls concerning media. +Default route: /api/media +""" + +import app.core.files as files import app.core.http_codes as codes import app.database.controller as dbc from app.apis import item_response, list_response, protect_route from app.core.dto import MediaDTO -from app.core.parsers import search_parser +from app.core.parsers import search_parser, sentinel from app.database.models import Media from flask import request from flask_jwt_extended import get_jwt_identity from flask_restx import Resource from flask_uploads import UploadNotAllowed from sqlalchemy import exc -import app.core.files as files -from app.core.parsers import sentinel api = MediaDTO.api image_set = MediaDTO.image_set @@ -25,12 +29,16 @@ media_parser_search.add_argument("filename", type=str, default=sentinel, locatio class ImageList(Resource): @protect_route(allowed_roles=["*"]) def get(self): + """ Gets a list of all images with the specified filename. """ + args = media_parser_search.parse_args(strict=True) items, total = dbc.search.image(**args) return list_response(list_schema.dump(items), total) @protect_route(allowed_roles=["*"]) def post(self): + """ Posts the specified image. """ + if "image" not in request.files: api.abort(codes.BAD_REQUEST, "Missing image in request.files") try: @@ -51,11 +59,15 @@ class ImageList(Resource): class ImageList(Resource): @protect_route(allowed_roles=["*"], allowed_views=["*"]) def get(self, ID): + """ Gets the specified image. """ + item = dbc.get.one(Media, ID) return item_response(schema.dump(item)) @protect_route(allowed_roles=["*"]) def delete(self, ID): + """ Deletes the specified image. """ + item = dbc.get.one(Media, ID) try: files.delete_image_and_thumbnail(item.filename) diff --git a/server/app/apis/misc.py b/server/app/apis/misc.py index a9069f6dd916af90b764b6703f83bb227fbcc2a4..552a0ad0c4848c33a08bcb63ccc3e870acd6af48 100644 --- a/server/app/apis/misc.py +++ b/server/app/apis/misc.py @@ -1,10 +1,14 @@ +""" +All misc API calls. +Default route: /api/misc +""" + import app.database.controller as dbc from app.apis import list_response, protect_route from app.core import http_codes from app.core.dto import MiscDTO from app.database.models import City, Competition, ComponentType, MediaType, QuestionType, Role, User, ViewType from flask_restx import Resource, reqparse -from flask_restx import reqparse api = MiscDTO.api @@ -24,6 +28,8 @@ name_parser.add_argument("name", type=str, required=True, location="json") @api.route("/types") class TypesList(Resource): def get(self): + """ Gets a list of all types. """ + result = {} result["media_types"] = media_type_schema.dump(dbc.get.all(MediaType)) result["component_types"] = component_type_schema.dump(dbc.get.all(ComponentType)) @@ -36,6 +42,8 @@ class TypesList(Resource): class RoleList(Resource): @protect_route(allowed_roles=["*"]) def get(self): + """ Gets a list of all roles. """ + items = dbc.get.all(Role) return list_response(role_schema.dump(items)) @@ -44,11 +52,15 @@ class RoleList(Resource): class CitiesList(Resource): @protect_route(allowed_roles=["*"]) def get(self): + """ Gets a list of all cities. """ + items = dbc.get.all(City) return list_response(city_schema.dump(items)) @protect_route(allowed_roles=["Admin"]) def post(self): + """ Posts the specified city. """ + args = name_parser.parse_args(strict=True) dbc.add.city(args["name"]) items = dbc.get.all(City) @@ -60,6 +72,8 @@ class CitiesList(Resource): class Cities(Resource): @protect_route(allowed_roles=["Admin"]) def put(self, ID): + """ Edits the specified city with the provided arguments. """ + item = dbc.get.one(City, ID) args = name_parser.parse_args(strict=True) item.name = args["name"] @@ -69,6 +83,8 @@ class Cities(Resource): @protect_route(allowed_roles=["Admin"]) def delete(self, ID): + """ Deletes the specified city. """ + item = dbc.get.one(City, ID) dbc.delete.default(item) items = dbc.get.all(City) @@ -79,6 +95,8 @@ class Cities(Resource): class Statistics(Resource): @protect_route(allowed_roles=["*"]) def get(self): + """ Gets statistics. """ + user_count = User.query.count() competition_count = Competition.query.count() region_count = City.query.count() diff --git a/server/app/apis/questions.py b/server/app/apis/questions.py index 94df2a36456baf6e8a492250944dc4156b5aa21f..6ba32382fa524180c76eef35bd02a68382e552c6 100644 --- a/server/app/apis/questions.py +++ b/server/app/apis/questions.py @@ -1,10 +1,14 @@ +""" +All API calls concerning question answers. +Default route: /api/competitions/<competition_id> +""" + import app.core.http_codes as codes import app.database.controller as dbc from app.apis import item_response, list_response, protect_route from app.core.dto import QuestionDTO -from flask_restx import Resource -from flask_restx import reqparse from app.core.parsers import sentinel +from flask_restx import Resource, reqparse api = QuestionDTO.api schema = QuestionDTO.schema @@ -28,6 +32,8 @@ question_parser_edit.add_argument("correcting_instructions", type=str, default=s class QuestionList(Resource): @protect_route(allowed_roles=["*"]) def get(self, competition_id): + """ Gets all questions in the specified competition. """ + items = dbc.get.question_list_for_competition(competition_id) return list_response(list_schema.dump(items)) @@ -37,11 +43,15 @@ class QuestionList(Resource): class QuestionListForSlide(Resource): @protect_route(allowed_roles=["*"]) def get(self, competition_id, slide_id): + """ Gets all questions in the specified competition and slide. """ + items = dbc.get.question_list(competition_id, slide_id) return list_response(list_schema.dump(items)) @protect_route(allowed_roles=["*"]) def post(self, competition_id, slide_id): + """ Posts a new question to the specified slide using the provided arguments. """ + args = question_parser_add.parse_args(strict=True) item = dbc.add.question(slide_id=slide_id, **args) return item_response(schema.dump(item)) @@ -52,11 +62,17 @@ class QuestionListForSlide(Resource): class QuestionById(Resource): @protect_route(allowed_roles=["*"]) def get(self, competition_id, slide_id, question_id): + """ + Gets the specified question using the specified competition and slide. + """ + item_question = dbc.get.question(competition_id, slide_id, question_id) return item_response(schema.dump(item_question)) @protect_route(allowed_roles=["*"]) def put(self, competition_id, slide_id, question_id): + """ Edits the specified question with the provided arguments. """ + args = question_parser_edit.parse_args(strict=True) item_question = dbc.get.question(competition_id, slide_id, question_id) @@ -66,6 +82,8 @@ class QuestionById(Resource): @protect_route(allowed_roles=["*"]) def delete(self, competition_id, slide_id, question_id): + """ Deletes the specified question. """ + item_question = dbc.get.question(competition_id, slide_id, question_id) dbc.delete.question(item_question) return {}, codes.NO_CONTENT diff --git a/server/app/apis/slides.py b/server/app/apis/slides.py index e0edcf6de100dbeb27658f036713a9cbd631b876..7f322d5231f063eecd287248928d85f945469a25 100644 --- a/server/app/apis/slides.py +++ b/server/app/apis/slides.py @@ -1,3 +1,8 @@ +""" +All API calls concerning question alternatives. +Default route: /api/competitions/<competition_id>/slides +""" + import app.core.http_codes as codes import app.database.controller as dbc from app.apis import item_response, list_response, protect_route @@ -24,25 +29,33 @@ slide_parser_edit.add_argument("background_image_id", default=sentinel, type=int class SlidesList(Resource): @protect_route(allowed_roles=["*"]) def get(self, competition_id): + """ Gets all slides from the specified competition. """ + items = dbc.get.slide_list(competition_id) return list_response(list_schema.dump(items)) @protect_route(allowed_roles=["*"]) def post(self, competition_id): + """ Posts a new slide to the specified competition. """ + item_slide = dbc.add.slide(competition_id) return item_response(schema.dump(item_slide)) @api.route("/<slide_id>") -@api.param("competition_id,slide_id") +@api.param("competition_id, slide_id") class Slides(Resource): @protect_route(allowed_roles=["*"]) def get(self, competition_id, slide_id): + """ Gets the specified slide. """ + item_slide = dbc.get.slide(competition_id, slide_id) return item_response(schema.dump(item_slide)) @protect_route(allowed_roles=["*"]) def put(self, competition_id, slide_id): + """ Edits the specified slide using the provided arguments. """ + args = slide_parser_edit.parse_args(strict=True) item_slide = dbc.get.slide(competition_id, slide_id) @@ -52,6 +65,8 @@ class Slides(Resource): @protect_route(allowed_roles=["*"]) def delete(self, competition_id, slide_id): + """ Deletes the specified slide. """ + item_slide = dbc.get.slide(competition_id, slide_id) dbc.delete.slide(item_slide) @@ -59,10 +74,12 @@ class Slides(Resource): @api.route("/<slide_id>/order") -@api.param("competition_id,slide_id") +@api.param("competition_id, slide_id") class SlideOrder(Resource): @protect_route(allowed_roles=["*"]) def put(self, competition_id, slide_id): + """ Edits the specified slide order using the provided arguments. """ + args = slide_parser_edit.parse_args(strict=True) new_order = args.get("order") @@ -84,8 +101,9 @@ class SlideOrder(Resource): class SlideCopy(Resource): @protect_route(allowed_roles=["*"]) def post(self, competition_id, slide_id): - item_slide = dbc.get.slide(competition_id, slide_id) + """ Creates a deep copy of the specified slide. """ + item_slide = dbc.get.slide(competition_id, slide_id) item_slide_copy = dbc.copy.slide(item_slide) return item_response(schema.dump(item_slide_copy)) diff --git a/server/app/apis/teams.py b/server/app/apis/teams.py index 71ca715d55d21de75f07db4241e5ef0bb14e1050..913deeb789340939c3dcb37f4a3edefbd7d06e95 100644 --- a/server/app/apis/teams.py +++ b/server/app/apis/teams.py @@ -1,9 +1,14 @@ +""" +All API calls concerning question alternatives. +Default route: /api/competitions/<competition_id>/teams +""" + import app.core.http_codes as codes import app.database.controller as dbc from app.apis import item_response, list_response, protect_route from app.core.dto import TeamDTO -from flask_restx import Resource, reqparse from app.core.parsers import sentinel +from flask_restx import Resource, reqparse api = TeamDTO.api schema = TeamDTO.schema @@ -21,11 +26,15 @@ team_parser_edit.add_argument("name", type=str, default=sentinel, location="json class TeamsList(Resource): @protect_route(allowed_roles=["*"]) def get(self, competition_id): + """ Gets all teams to the specified competition. """ + items = dbc.get.team_list(competition_id) return list_response(list_schema.dump(items)) @protect_route(allowed_roles=["*"]) def post(self, competition_id): + """ Posts a new team to the specified competition. """ + args = team_parser_add.parse_args(strict=True) item_team = dbc.add.team(args["name"], competition_id) return item_response(schema.dump(item_team)) @@ -36,18 +45,15 @@ class TeamsList(Resource): class Teams(Resource): @protect_route(allowed_roles=["*"]) def get(self, competition_id, team_id): + """ Gets the specified team. """ + item = dbc.get.team(competition_id, team_id) return item_response(schema.dump(item)) - @protect_route(allowed_roles=["*"]) - def delete(self, competition_id, team_id): - item_team = dbc.get.team(competition_id, team_id) - - dbc.delete.team(item_team) - return {}, codes.NO_CONTENT - @protect_route(allowed_roles=["*"]) def put(self, competition_id, team_id): + """ Edits the specified team using the provided arguments. """ + args = team_parser_edit.parse_args(strict=True) name = args.get("name") @@ -55,3 +61,12 @@ class Teams(Resource): item_team = dbc.edit.default(item_team, name=name, competition_id=competition_id) return item_response(schema.dump(item_team)) + + @protect_route(allowed_roles=["*"]) + def delete(self, competition_id, team_id): + """ Deletes the specified team. """ + + item_team = dbc.get.team(competition_id, team_id) + + dbc.delete.team(item_team) + return {}, codes.NO_CONTENT diff --git a/server/app/apis/users.py b/server/app/apis/users.py index dc26ac5b64e9a4270215374de84f3c71cae66f9e..bf393a89abcc0f85d4885e8333afab1e0df8b56f 100644 --- a/server/app/apis/users.py +++ b/server/app/apis/users.py @@ -1,11 +1,16 @@ +""" +All API calls concerning question alternatives. +Default route: /api/users +""" + import app.core.http_codes as codes import app.database.controller as dbc from app.apis import item_response, list_response, protect_route from app.core.dto import UserDTO -from flask_jwt_extended import get_jwt_identity -from flask_restx import Resource -from flask_restx import inputs, reqparse from app.core.parsers import search_parser, sentinel +from app.database.models import User +from flask_jwt_extended import get_jwt_identity +from flask_restx import Resource, inputs, reqparse api = UserDTO.api schema = UserDTO.schema @@ -25,13 +30,14 @@ user_search_parser.add_argument("role_id", type=int, default=sentinel, location= def _edit_user(item_user, args): + """ Edits a user using the provided arguments. """ + email = args.get("email") name = args.get("name") if email: if dbc.get.user_exists(email): api.abort(codes.BAD_REQUEST, "Email is already in use") - if name: args["name"] = args["name"].title() @@ -42,13 +48,17 @@ def _edit_user(item_user, args): class UsersList(Resource): @protect_route(allowed_roles=["*"]) def get(self): - item = dbc.get.user(get_jwt_identity()) + """ Gets all users. """ + + item = dbc.get.one(User, get_jwt_identity()) return item_response(schema.dump(item)) @protect_route(allowed_roles=["*"]) def put(self): + """ Posts a new user using the specified arguments. """ + args = user_parser_edit.parse_args(strict=True) - item = dbc.get.user(get_jwt_identity()) + item = dbc.get.one(User, get_jwt_identity()) item = _edit_user(item, args) return item_response(schema.dump(item)) @@ -58,13 +68,17 @@ class UsersList(Resource): class Users(Resource): @protect_route(allowed_roles=["*"]) def get(self, ID): - item = dbc.get.user(ID) + """ Gets the specified user. """ + + item = dbc.get.one(User, ID) return item_response(schema.dump(item)) @protect_route(allowed_roles=["Admin"]) def put(self, ID): + """ Edits the specified team using the provided arguments. """ + args = user_parser_edit.parse_args(strict=True) - item = dbc.get.user(ID) + item = dbc.get.one(User, ID) item = _edit_user(item, args) return item_response(schema.dump(item)) @@ -73,6 +87,8 @@ class Users(Resource): class UserSearch(Resource): @protect_route(allowed_roles=["*"]) def get(self): + """ Finds a specific user based on the provided arguments. """ + args = user_search_parser.parse_args(strict=True) items, total = dbc.search.user(**args) return list_response(list_schema.dump(items), total) diff --git a/server/app/database/controller/add.py b/server/app/database/controller/add.py index 5f9aec28f9043523b1f41f1d3b07b56c619c760b..d0adf5a00e9bdbd610b4892837edc345e3dc20be 100644 --- a/server/app/database/controller/add.py +++ b/server/app/database/controller/add.py @@ -29,13 +29,12 @@ from app.database.models import ( ViewType, Whitelist, ) +from app.database.types import ID_IMAGE_COMPONENT, ID_QUESTION_COMPONENT, ID_TEXT_COMPONENT +from flask import current_app from flask.globals import current_app from flask_restx import abort from PIL import Image from sqlalchemy import exc -from flask import current_app - -from app.database.types import ID_IMAGE_COMPONENT, ID_QUESTION_COMPONENT, ID_TEXT_COMPONENT def db_add(item): @@ -50,25 +49,34 @@ def db_add(item): except (exc.SQLAlchemyError, exc.DBAPIError): db.session.rollback() # SQL errors such as item already exists - abort(codes.INTERNAL_SERVER_ERROR, f"Item of type {type(item)} could not be created") + abort( + codes.INTERNAL_SERVER_ERROR, + f"Item of type {type(item)} could not be created", + ) except: db.session.rollback() # Catching other errors - abort(codes.INTERNAL_SERVER_ERROR, f"Something went wrong when creating {type(item)}") + abort( + codes.INTERNAL_SERVER_ERROR, + f"Something went wrong when creating {type(item)}", + ) return item def component(type_id, slide_id, view_type_id, x=0, y=0, w=0, h=0, **data): """ - Adds a component to the slide at the specified coordinates with the - provided size and data . + Adds a component to the slide at the specified + coordinates with the provided size and data. """ if type_id == 2: # 2 is image item_image = get.one(Media, data["media_id"]) filename = item_image.filename - path = os.path.join(current_app.config["UPLOADED_PHOTOS_DEST"], filename) + path = os.path.join( + current_app.config["UPLOADED_PHOTOS_DEST"], + filename, + ) with Image.open(path) as im: h = im.height w = im.width @@ -80,13 +88,19 @@ def component(type_id, slide_id, view_type_id, x=0, y=0, w=0, h=0, **data): h *= ratio if type_id == ID_TEXT_COMPONENT: - item = db_add(TextComponent(slide_id, type_id, view_type_id, x, y, w, h)) + item = db_add( + TextComponent(slide_id, type_id, view_type_id, x, y, w, h), + ) item.text = data.get("text") elif type_id == ID_IMAGE_COMPONENT: - item = db_add(ImageComponent(slide_id, type_id, view_type_id, x, y, w, h)) + item = db_add( + ImageComponent(slide_id, type_id, view_type_id, x, y, w, h), + ) item.media_id = data.get("media_id") elif type_id == ID_QUESTION_COMPONENT: - item = db_add(QuestionComponent(slide_id, type_id, view_type_id, x, y, w, h)) + item = db_add( + QuestionComponent(slide_id, type_id, view_type_id, x, y, w, h), + ) item.question_id = data.get("question_id") else: abort(codes.BAD_REQUEST, f"Invalid type_id{type_id}") @@ -123,7 +137,7 @@ def slide(competition_id): item_slide = db_add(Slide(order, competition_id)) # Add default question - question(f"Fråga {item_slide.order + 1}", 10, 0, item_slide.id) + question(f"Fråga {item_slide.order + 1}", 10, 1, item_slide.id) item_slide = utils.refresh(item_slide) return item_slide @@ -259,8 +273,18 @@ def question(name, total_score, type_id, slide_id, correcting_instructions=None) def question_alternative(text, value, question_id): + """ + Adds a question alternative to the specified + question using the provided arguments. + """ + return db_add(QuestionAlternative(text, value, question_id)) def question_answer(answer, score, question_id, team_id): + """ + Adds a question answer to the specified team + and question using the provided arguments. + """ + return db_add(QuestionAnswer(answer, score, question_id, team_id)) diff --git a/server/app/database/controller/copy.py b/server/app/database/controller/copy.py index 48dab2db79c33cb1ca819a59f0e934ab23dcc191..dd8073342ecfb0460e54783e70e50c0e194d437a 100644 --- a/server/app/database/controller/copy.py +++ b/server/app/database/controller/copy.py @@ -8,7 +8,9 @@ from app.database.types import ID_IMAGE_COMPONENT, ID_QUESTION_COMPONENT, ID_TEX def _alternative(item_old, question_id): - """Internal function. Makes a copy of the provided question alternative""" + """ + Internal function. Makes a copy of the provided question alternative. + """ return add.question_alternative(item_old.text, item_old.value, question_id) @@ -73,7 +75,7 @@ def component(item_component, slide_id_new, view_type_id): def slide(item_slide_old): """ Deep copies a slide to the same competition. - Does not copy team, question answers. + Does not copy team and question answers. """ item_competition = get.competition(item_slide_old.competition_id) @@ -98,7 +100,6 @@ def slide_to_competition(item_slide_old, item_competition): for item_component in item_slide_old.components: _component(item_component, item_slide_new) - for item_question in item_slide_old.questions: _question(item_question, item_slide_new.id) @@ -123,7 +124,7 @@ def competition(item_competition_old): item_competition_old.city_id, item_competition_old.font, ) - # TODO: Add background image + item_competition_new.background_image_id = item_competition_old.background_image_id for item_slide in item_competition_old.slides: diff --git a/server/app/database/controller/delete.py b/server/app/database/controller/delete.py index b0b36fbaf79843eac05bd4340d0a633a61e325d0..65737cb69d3ace8af493d71d245805b8f4869446 100644 --- a/server/app/database/controller/delete.py +++ b/server/app/database/controller/delete.py @@ -11,19 +11,25 @@ from flask_restx import abort def default(item): """ Deletes item and commits. """ + try: db.session.delete(item) db.session.commit() except: db.session.rollback() - abort(codes.INTERNAL_SERVER_ERROR, f"Item of type {type(item)} could not be deleted") + abort( + codes.INTERNAL_SERVER_ERROR, + f"Item of type {type(item)} could not be deleted", + ) def whitelist_to_blacklist(filters): """ - Remove whitelist by condition(filters) and insert those into blacklist - Example: When delete user all whitelisted tokens for that user should be blacklisted + Remove whitelist by condition(filters) and insert those into blacklist. + Example: When delete user all whitelisted tokens for that user should + be blacklisted. """ + whitelist = Whitelist.query.filter(filters).all() for item in whitelist: dbc.add.blacklist(item.jti) @@ -43,7 +49,6 @@ def _slide(item_slide): for item_question in item_slide.questions: question(item_question) - for item_component in item_slide.components: default(item_component) @@ -85,6 +90,7 @@ def question(item_question): question_answers(item_question_answer) for item_alternative in item_question.alternatives: alternatives(item_alternative) + default(item_question) diff --git a/server/app/database/controller/get.py b/server/app/database/controller/get.py index b6701ca5579940c9e4545d5542f41cf4b5b431fc..abdb9e15d1d815d80a48d7e80256e6595c7d7620 100644 --- a/server/app/database/controller/get.py +++ b/server/app/database/controller/get.py @@ -2,7 +2,6 @@ This file contains functionality to get data from the database. """ -from sqlalchemy.orm.util import with_polymorphic from app.core import db from app.core import http_codes as codes from app.database.models import ( @@ -18,11 +17,12 @@ from app.database.models import ( TextComponent, User, ) -from sqlalchemy.orm import joinedload, subqueryload +from sqlalchemy.orm import joinedload +from sqlalchemy.orm.util import with_polymorphic def all(db_type): - """ Gets lazy db-item in the provided table. """ + """ Gets a list of all lazy db-items in the provided table. """ return db_type.query.all() @@ -43,7 +43,10 @@ def code_by_code(code): def code_list(competition_id): - """ Gets a list of all code objects associated with a the provided competition. """ + """ + Gets a list of all code objects associated with the provided competition. + """ + # team_view_id = 1 join_competition = Competition.id == Code.competition_id filters = Competition.id == competition_id @@ -57,20 +60,18 @@ def user_exists(email): return User.query.filter(User.email == email).count() > 0 -def user(user_id): - """ Gets the user object associated with the provided id. """ - - return User.query.filter(User.id == user_id).first_extended() - - def user_by_email(email): """ Gets the user object associated with the provided email. """ + return User.query.filter(User.email == email).first_extended(error_code=codes.UNAUTHORIZED) ### Slides ### def slide(competition_id, slide_id): - """ Gets the slide object associated with the provided id and order. """ + """ + Gets the slide object associated with the provided competition and slide. + """ + join_competition = Competition.id == Slide.competition_id filters = (Competition.id == competition_id) & (Slide.id == slide_id) @@ -78,7 +79,10 @@ def slide(competition_id, slide_id): def slide_list(competition_id): - """ Gets a list of all slide objects associated with a the provided competition. """ + """ + Gets a list of all slide objects associated with the provided competition. + """ + join_competition = Competition.id == Slide.competition_id filters = Competition.id == competition_id @@ -91,15 +95,10 @@ def slide_count(competition_id): return Slide.query.filter(Slide.competition_id == competition_id).count() -def slide_count(competition_id): - """ Gets the number of slides in the provided competition. """ - - return Slide.query.filter(Slide.competition_id == competition_id).count() - - ### Teams ### def team(competition_id, team_id): - """ Gets the team object associated with the provided id and competition id. """ + """ Gets the team object associated with the competition and team. """ + join_competition = Competition.id == Team.competition_id filters = (Competition.id == competition_id) & (Team.id == team_id) @@ -107,19 +106,22 @@ def team(competition_id, team_id): def team_list(competition_id): - """ Gets a list of all team objects associated with a the provided competition. """ + """ + Gets a list of all team objects associated with the provided competition. + """ join_competition = Competition.id == Team.competition_id filters = Competition.id == competition_id return Team.query.join(Competition, join_competition).filter(filters).all() - return Team.query.join(Competition, join_competition).filter(filters).all() - ### Questions ### def question(competition_id, slide_id, question_id): - """ Gets the question object associated with the provided id, slide order and competition id. """ + """ + Gets the question object associated with the + provided, competition, slide and question. + """ join_competition = Competition.id == Slide.competition_id join_slide = Slide.id == Question.slide_id @@ -129,7 +131,10 @@ def question(competition_id, slide_id, question_id): def question_list(competition_id, slide_id): - """ Gets a list of all question objects associated with a the provided competition and slide. """ + """ + Gets a list of all question objects associated + with the provided competition and slide. + """ join_competition = Competition.id == Slide.competition_id join_slide = Slide.id == Question.slide_id @@ -139,7 +144,10 @@ def question_list(competition_id, slide_id): def question_list_for_competition(competition_id): - """ Gets a list of all question objects associated with a the provided competition. """ + """ + Gets a list of all question objects associated + with the provided competition. + """ join_competition = Competition.id == Slide.competition_id join_slide = Slide.id == Question.slide_id @@ -149,8 +157,16 @@ def question_list_for_competition(competition_id): ### Question Alternative ### -def question_alternative(competition_id, slide_id, question_id, alternative_id): - """ Get question alternative for a given question based on its competition and slide and ID. """ +def question_alternative( + competition_id, + slide_id, + question_id, + alternative_id, +): + """ + Get a question alternative for a given question + based on its competition, slide and question. + """ join_competition = Competition.id == Slide.competition_id join_slide = Slide.id == Question.slide_id @@ -172,7 +188,11 @@ def question_alternative(competition_id, slide_id, question_id, alternative_id): def question_alternative_list(competition_id, slide_id, question_id): - """ Get all question alternatives for a given question based on its competition and slide. """ + """ + Get a list of all question alternative objects for a + given question based on its competition and slide. + """ + join_competition = Competition.id == Slide.competition_id join_slide = Slide.id == Question.slide_id join_question = Question.id == QuestionAlternative.question_id @@ -186,18 +206,13 @@ def question_alternative_list(competition_id, slide_id, question_id): .all() ) - return ( - QuestionAlternative.query.join(Competition, join_competition) - .join(Slide, join_slide) - .join(Question, join_question) - .filter(filters) - .all() - ) - ### Question Answers ### def question_answer(competition_id, team_id, answer_id): - """ Get question answer for a given team based on its competition and ID. """ + """ + Get question answer for a given team based on its competition. + """ + join_competition = Competition.id == Team.competition_id join_team = Team.id == QuestionAnswer.team_id filters = (Competition.id == competition_id) & (Team.id == team_id) & (QuestionAnswer.id == answer_id) @@ -207,7 +222,10 @@ def question_answer(competition_id, team_id, answer_id): def question_answer_list(competition_id, team_id): - """ Get question answer for a given team based on its competition. """ + """ + Get a list of question answers for a given team based on its competition. + """ + join_competition = Competition.id == Team.competition_id join_team = Team.id == QuestionAnswer.team_id filters = (Competition.id == competition_id) & (Team.id == team_id) @@ -216,7 +234,10 @@ def question_answer_list(competition_id, team_id): ### Components ### def component(competition_id, slide_id, component_id): - """ Gets a list of all component objects associated with a the provided competition id and slide order. """ + """ + Gets a component object associated with + the provided competition id and slide order. + """ join_competition = Competition.id == Slide.competition_id join_slide = Slide.id == Component.slide_id @@ -233,7 +254,10 @@ def component(competition_id, slide_id, component_id): def component_list(competition_id, slide_id): - """ Gets a list of all component objects associated with a the provided competition id and slide order. """ + """ + Gets a list of all component objects associated with + the provided competition and slide. + """ join_competition = Competition.id == Slide.competition_id join_slide = Slide.id == Component.slide_id @@ -243,7 +267,8 @@ def component_list(competition_id, slide_id): ### Competitions ### def competition(competition_id): - """ Get Competition and all it's sub-entities """ + """ Get Competition and all it's sub-entities. """ + os1 = joinedload(Competition.slides).joinedload(Slide.components) os2 = joinedload(Competition.slides).joinedload(Slide.questions).joinedload(Question.alternatives) ot = joinedload(Competition.teams).joinedload(Team.question_answers) diff --git a/server/app/database/controller/utils.py b/server/app/database/controller/utils.py index 2d2e5701e62ffaf0db886f3fe4d12d799a77c756..70127a7302d91d3a725af4a7ae496dd3b1eba419 100644 --- a/server/app/database/controller/utils.py +++ b/server/app/database/controller/utils.py @@ -70,6 +70,7 @@ def generate_unique_code(): def refresh(item): """ Refreshes the provided item. """ + try: db.session.refresh(item) except Exception as e: @@ -79,7 +80,8 @@ def refresh(item): def commit(): - """ Commits. """ + """ Commits to the database. """ + try: db.session.commit() except Exception as e: diff --git a/server/app/database/models.py b/server/app/database/models.py index a59335d9f815a8a8b88d39cb9acb1697d8b824b8..97eb6097c2403cf4d32d01106e2ae906d9d788d5 100644 --- a/server/app/database/models.py +++ b/server/app/database/models.py @@ -5,9 +5,8 @@ each other. """ from app.core import bcrypt, db -from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property - from app.database.types import ID_IMAGE_COMPONENT, ID_QUESTION_COMPONENT, ID_TEXT_COMPONENT +from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property STRING_SIZE = 254 @@ -62,8 +61,9 @@ class User(db.Model): _password = db.Column(db.LargeBinary(60), nullable=False) authenticated = db.Column(db.Boolean, default=False) - # twoAuthConfirmed = db.Column(db.Boolean, default=True) - # twoAuthCode = db.Column(db.String(STRING_SIZE), nullable=True) + + login_attempts = db.Column(db.Integer, nullable=False, default=0) + locked = db.Column(db.DateTime(timezone=True), nullable=True, default=None) role_id = db.Column(db.Integer, db.ForeignKey("role.id"), nullable=False) city_id = db.Column(db.Integer, db.ForeignKey("city.id"), nullable=False) @@ -201,8 +201,8 @@ class QuestionAnswer(db.Model): question_id = db.Column(db.Integer, db.ForeignKey("question.id"), nullable=False) team_id = db.Column(db.Integer, db.ForeignKey("team.id"), nullable=False) - def __init__(self, data, score, question_id, team_id): - self.data = data + def __init__(self, answer, score, question_id, team_id): + self.answer = answer self.score = score self.question_id = question_id self.team_id = team_id diff --git a/server/configmodule.py b/server/configmodule.py index 93d21cbe84a5847ed4927a4d2b3d002b487d1f69..e38c4e04cf1b90ffff4edbe0cbcffffb347c5b4a 100644 --- a/server/configmodule.py +++ b/server/configmodule.py @@ -17,6 +17,8 @@ class Config: THUMBNAIL_SIZE = (120, 120) SECRET_KEY = os.urandom(24) SQLALCHEMY_ECHO = False + USER_LOGIN_LOCKED_ATTEMPTS = 12 + USER_LOGIN_LOCKED_EXPIRES = timedelta(hours=3) class DevelopmentConfig(Config): @@ -34,6 +36,8 @@ class DevelopmentConfig(Config): class TestingConfig(Config): TESTING = True SQLALCHEMY_DATABASE_URI = "sqlite:///test.db" + USER_LOGIN_LOCKED_ATTEMPTS = 4 + USER_LOGIN_LOCKED_EXPIRES = timedelta(seconds=4) class ProductionConfig(Config): diff --git a/server/populate.py b/server/populate.py index 92183b6cc87f8f2e5377fb934c3ff14219920a7f..9981f0c02f430d56c88738a9b67e89d5fc17af89 100644 --- a/server/populate.py +++ b/server/populate.py @@ -11,8 +11,8 @@ from app.database.models import City, QuestionType, Role def _add_items(): media_types = ["Image", "Video"] - question_types = ["Boolean", "Multiple", "Text"] - component_types = ["Text", "Image"] + question_types = ["Text", "Practical", "Multiple", "Single"] + component_types = ["Text", "Image", "Question"] view_types = ["Team", "Judge", "Audience", "Operator"] roles = ["Admin", "Editor"] @@ -43,8 +43,8 @@ def _add_items(): city_id = City.query.filter(City.name == "Linköping").one().id # Add users - dbc.add.user("admin@test.se", "password", admin_id, city_id) - dbc.add.user("test@test.se", "password", editor_id, city_id) + dbc.add.user("admin@test.se", "password", admin_id, city_id, "Admina Denfina") + dbc.add.user("test@test.se", "password", editor_id, city_id, "Test Osteron") question_types_items = dbc.get.all(QuestionType) @@ -105,6 +105,14 @@ def _add_items(): for name in teams: dbc.add.team(f"{name}{i}", item_comp.id) + # question_answer(answer, score, question_id, team_id) + dbc.add.question_answer("ett svar som ger 2p", 2, 1, 1) + dbc.add.question_answer("ett svar som ger 10p", 10, 2, 1) + dbc.add.question_answer("ett svar som ger 6p", 6, 3, 1) + + dbc.add.question_answer("ett svar som ger 2p", 2, 1, 2) + dbc.add.question_answer("ett svar som ger 3p", 3, 1, 3) + if __name__ == "__main__": app, _ = create_app("configmodule.DevelopmentConfig") diff --git a/server/tests/test_app.py b/server/tests/test_app.py index 2152a6d4c7236f57d099f50dccec3a090ce94c5d..880a41bd82a3b6cac8795a3cf61eaed6569dbadb 100644 --- a/server/tests/test_app.py +++ b/server/tests/test_app.py @@ -2,15 +2,37 @@ This file tests the api function calls. """ +import time + import app.core.http_codes as codes -from app.database.controller.add import competition -from app.database.models import Slide +import pytest from app.core import sockets from tests import app, client, db from tests.test_helpers import add_default_values, change_order_test, delete, get, post, put +# @pytest.mark.skip(reason="Takes long time") +def test_locked_api(client): + add_default_values() + + # Login in with default user but wrong password until blocked + for i in range(4): + response, body = post(client, "/api/auth/login", {"email": "test@test.se", "password": "password1"}) + assert response.status_code == codes.UNAUTHORIZED + + # Login with right password, user should be locked + response, body = post(client, "/api/auth/login", {"email": "test@test.se", "password": "password"}) + assert response.status_code == codes.UNAUTHORIZED + + # Sleep for 4 secounds + time.sleep(4) + + # Check so the user is no longer locked + response, body = post(client, "/api/auth/login", {"email": "test@test.se", "password": "password"}) + assert response.status_code == codes.OK + + def test_misc_api(client): add_default_values() @@ -125,6 +147,10 @@ def test_auth_and_user_api(client): assert response.status_code == codes.OK headers = {"Authorization": "Bearer " + body["access_token"]} + # Login in with default user but wrong password + response, body = post(client, "/api/auth/login", {"email": "test@test.se", "password": "password1"}) + assert response.status_code == codes.UNAUTHORIZED + # Create user register_data = {"email": "test1@test.se", "password": "abc123", "role_id": 2, "city_id": 1} response, body = post(client, "/api/auth/signup", register_data, headers) @@ -211,7 +237,6 @@ def test_auth_and_user_api(client): assert response.status_code == codes.OK # TODO: Check if current users jwt (jti) is in blacklist after logging out - response, body = get(client, "/api/users", headers=headers) assert response.status_code == codes.UNAUTHORIZED @@ -479,4 +504,4 @@ def test_authorization(client): # Also get antoher teams answers response, body = get(client, f"/api/competitions/{competition_id}/teams/{team_id+1}/answers", headers=headers) - assert response.status_code == codes.OK \ No newline at end of file + assert response.status_code == codes.OK diff --git a/server/tests/test_helpers.py b/server/tests/test_helpers.py index 4f46622a977acad828b192b0e8aa510851133f82..7d5625385f01f81cc326bca9d5114322ad5f52e0 100644 --- a/server/tests/test_helpers.py +++ b/server/tests/test_helpers.py @@ -49,8 +49,8 @@ def add_default_values(): db.session.add(Code("111111", 1, item_competition.id, item_team1.id)) # Team db.session.add(Code("222222", 2, item_competition.id)) # Judge - dbc.add.QuestionAnswer("hej", 5, item_question.id, item_team1) - dbc.add.QuestionAnswer("då", 5, item_question.id, item_team2) + dbc.add.question_answer("hej", 5, item_question.id, item_team1.id) + dbc.add.question_answer("då", 5, item_question.id, item_team2.id) db.session.commit()