Add prettier for redaktions-app

This commit is contained in:
Christoph Lienhard 2021-01-09 12:32:17 +01:00
parent 5e219089f6
commit 77ef07d9ff
Signed by: christoph.lienhard
GPG key ID: 6B98870DDC270884
61 changed files with 2487 additions and 1414 deletions

View file

@ -0,0 +1,3 @@
# Ignore artifacts:
build
coverage

View file

@ -0,0 +1 @@
{}

View file

@ -9,18 +9,18 @@ The app is written in typescript and react and uses apollo to query the backend
### Setup ### Setup
* Install `npm` - Install `npm`
* In this directory run `npm ci` to install all dependencies according to the package.json and package-lock.json. - In this directory run `npm ci` to install all dependencies according to the package.json and package-lock.json.
### Develop locally ### Develop locally
* In the parent directory run - In the parent directory run
```shell script ```shell script
docker-compose up docker-compose up
``` ```
which will start the whole setup including this app in a dockerfile. which will start the whole setup including this app in a dockerfile.
However, rebuilding and restarting this image can be cumbersome and is not necessary in the development setup. However, rebuilding and restarting this image can be cumbersome and is not necessary in the development setup.
* Instead run - Instead run
```shell script ```shell script
npm start npm start
``` ```

View file

@ -3817,6 +3817,59 @@
"restore-cursor": "^3.1.0" "restore-cursor": "^3.1.0"
} }
}, },
"cli-truncate": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz",
"integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==",
"dev": true,
"requires": {
"slice-ansi": "^3.0.0",
"string-width": "^4.2.0"
},
"dependencies": {
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"astral-regex": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
"integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
"dev": true
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"slice-ansi": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz",
"integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==",
"dev": true,
"requires": {
"ansi-styles": "^4.0.0",
"astral-regex": "^2.0.0",
"is-fullwidth-code-point": "^3.0.0"
}
}
}
},
"cli-width": { "cli-width": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz",
@ -4597,6 +4650,12 @@
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
"integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=" "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU="
}, },
"dedent": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz",
"integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=",
"dev": true
},
"deep-equal": { "deep-equal": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz",
@ -5085,6 +5144,23 @@
} }
} }
}, },
"enquirer": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz",
"integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==",
"dev": true,
"requires": {
"ansi-colors": "^4.1.1"
},
"dependencies": {
"ansi-colors": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz",
"integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==",
"dev": true
}
}
},
"entities": { "entities": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz",
@ -6892,6 +6968,12 @@
"resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
"integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=" "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM="
}, },
"human-signals": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz",
"integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==",
"dev": true
},
"husky": { "husky": {
"version": "4.3.6", "version": "4.3.6",
"resolved": "https://registry.npmjs.org/husky/-/husky-4.3.6.tgz", "resolved": "https://registry.npmjs.org/husky/-/husky-4.3.6.tgz",
@ -8851,6 +8933,340 @@
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz",
"integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=" "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA="
}, },
"lint-staged": {
"version": "10.5.3",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-10.5.3.tgz",
"integrity": "sha512-TanwFfuqUBLufxCc3RUtFEkFraSPNR3WzWcGF39R3f2J7S9+iF9W0KTVLfSy09lYGmZS5NDCxjNvhGMSJyFCWg==",
"dev": true,
"requires": {
"chalk": "^4.1.0",
"cli-truncate": "^2.1.0",
"commander": "^6.2.0",
"cosmiconfig": "^7.0.0",
"debug": "^4.2.0",
"dedent": "^0.7.0",
"enquirer": "^2.3.6",
"execa": "^4.1.0",
"listr2": "^3.2.2",
"log-symbols": "^4.0.0",
"micromatch": "^4.0.2",
"normalize-path": "^3.0.0",
"please-upgrade-node": "^3.2.0",
"string-argv": "0.3.1",
"stringify-object": "^3.3.0"
},
"dependencies": {
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"dev": true,
"requires": {
"fill-range": "^7.0.1"
}
},
"chalk": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
"dev": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"commander": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
"integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==",
"dev": true
},
"cosmiconfig": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz",
"integrity": "sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA==",
"dev": true,
"requires": {
"@types/parse-json": "^4.0.0",
"import-fresh": "^3.2.1",
"parse-json": "^5.0.0",
"path-type": "^4.0.0",
"yaml": "^1.10.0"
}
},
"cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"dev": true,
"requires": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
}
},
"execa": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz",
"integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==",
"dev": true,
"requires": {
"cross-spawn": "^7.0.0",
"get-stream": "^5.0.0",
"human-signals": "^1.1.1",
"is-stream": "^2.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^4.0.0",
"onetime": "^5.1.0",
"signal-exit": "^3.0.2",
"strip-final-newline": "^2.0.0"
}
},
"fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"dev": true,
"requires": {
"to-regex-range": "^5.0.1"
}
},
"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==",
"dev": true,
"requires": {
"pump": "^3.0.0"
}
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true
},
"import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
"integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
"dev": true,
"requires": {
"parent-module": "^1.0.0",
"resolve-from": "^4.0.0"
}
},
"is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true
},
"is-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz",
"integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==",
"dev": true
},
"micromatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz",
"integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==",
"dev": true,
"requires": {
"braces": "^3.0.1",
"picomatch": "^2.0.5"
}
},
"normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true
},
"npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
"dev": true,
"requires": {
"path-key": "^3.0.0"
}
},
"parse-json": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.1.0.tgz",
"integrity": "sha512-+mi/lmVVNKFNVyLXV31ERiy2CY5E1/F6QtJFEzoChPRwwngMNXRDQ9GJ5WdE2Z2P4AujsOi0/+2qHID68KwfIQ==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.0.0",
"error-ex": "^1.3.1",
"json-parse-even-better-errors": "^2.3.0",
"lines-and-columns": "^1.1.6"
}
},
"path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true
},
"path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true
},
"resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true
},
"shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"requires": {
"shebang-regex": "^3.0.0"
}
},
"shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"requires": {
"has-flag": "^4.0.0"
}
},
"to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"requires": {
"is-number": "^7.0.0"
}
},
"which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"requires": {
"isexe": "^2.0.0"
}
}
}
},
"listr2": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-3.2.3.tgz",
"integrity": "sha512-vUb80S2dSUi8YxXahO8/I/s29GqnOL8ozgHVLjfWQXa03BNEeS1TpBLjh2ruaqq5ufx46BRGvfymdBSuoXET5w==",
"dev": true,
"requires": {
"chalk": "^4.1.0",
"cli-truncate": "^2.1.0",
"figures": "^3.2.0",
"indent-string": "^4.0.0",
"log-update": "^4.0.0",
"p-map": "^4.0.0",
"rxjs": "^6.6.3",
"through": "^2.3.8"
},
"dependencies": {
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"chalk": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
"dev": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true
},
"p-map": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
"integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
"dev": true,
"requires": {
"aggregate-error": "^3.0.0"
}
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"requires": {
"has-flag": "^4.0.0"
}
}
}
},
"load-json-file": { "load-json-file": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz",
@ -8984,6 +9400,141 @@
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
"integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=" "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M="
}, },
"log-symbols": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.0.0.tgz",
"integrity": "sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA==",
"dev": true,
"requires": {
"chalk": "^4.0.0"
},
"dependencies": {
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"chalk": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
"dev": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"requires": {
"has-flag": "^4.0.0"
}
}
}
},
"log-update": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz",
"integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==",
"dev": true,
"requires": {
"ansi-escapes": "^4.3.0",
"cli-cursor": "^3.1.0",
"slice-ansi": "^4.0.0",
"wrap-ansi": "^6.2.0"
},
"dependencies": {
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"astral-regex": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
"integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
"dev": true
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"slice-ansi": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
"integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
"dev": true,
"requires": {
"ansi-styles": "^4.0.0",
"astral-regex": "^2.0.0",
"is-fullwidth-code-point": "^3.0.0"
}
},
"strip-ansi": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
"integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
"dev": true,
"requires": {
"ansi-regex": "^5.0.0"
}
},
"wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"requires": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
}
}
}
},
"loglevel": { "loglevel": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.7.0.tgz", "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.7.0.tgz",
@ -11248,6 +11799,12 @@
"resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz", "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz",
"integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=" "integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw="
}, },
"prettier": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.2.1.tgz",
"integrity": "sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==",
"dev": true
},
"pretty-bytes": { "pretty-bytes": {
"version": "5.4.1", "version": "5.4.1",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.4.1.tgz", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.4.1.tgz",
@ -13155,6 +13712,12 @@
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz",
"integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=" "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM="
}, },
"string-argv": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz",
"integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==",
"dev": true
},
"string-length": { "string-length": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-2.0.0.tgz", "resolved": "https://registry.npmjs.org/string-length/-/string-length-2.0.0.tgz",
@ -13316,6 +13879,12 @@
"resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
"integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=" "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8="
}, },
"strip-final-newline": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"dev": true
},
"strip-indent": { "strip-indent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",

View file

@ -25,7 +25,9 @@
"@types/react-dom": "^16.9.8", "@types/react-dom": "^16.9.8",
"@types/react-router-dom": "^5.1.5", "@types/react-router-dom": "^5.1.5",
"husky": "^4.3.6", "husky": "^4.3.6",
"jest-environment-jsdom-sixteen": "^1.0.3" "jest-environment-jsdom-sixteen": "^1.0.3",
"lint-staged": "^10.5.3",
"prettier": "2.2.1"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
@ -38,9 +40,13 @@
}, },
"husky": { "husky": {
"hooks": { "hooks": {
"pre-commit": "npm test" "pre-commit": "lint-staged && npm test"
} }
}, },
"lint-staged": {
"**/*": "prettier --write --ignore-unknown",
"*.{js,css,md}": "prettier --write"
},
"browserslist": { "browserslist": {
"production": [ "production": [
">0.2%", ">0.2%",

View file

@ -8,7 +8,6 @@
<meta <meta
name="description" name="description"
content="App zum Erstellen von Fragen für den Candymat" content="App zum Erstellen von Fragen für den Candymat"
/> />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!-- <!--

View file

@ -1,22 +1,27 @@
import './App.css'; import "./App.css";
import React from "react"; import React from "react";
import Main from "./components/Main"; import Main from "./components/Main";
import {Redirect, Route, Switch} from "react-router-dom"; import { Redirect, Route, Switch } from "react-router-dom";
import SignIn from "./components/SignIn"; import SignIn from "./components/SignIn";
import SignUp from "./components/SignUp"; import SignUp from "./components/SignUp";
function App() { function App() {
return ( return (
<Switch> <Switch>
<PrivateRoute exact path={"/"}><Main /></PrivateRoute> <PrivateRoute exact path={"/"}>
<NotLoggedInOnlyRoute path={"/login"}><SignIn /></NotLoggedInOnlyRoute> <Main />
<NotLoggedInOnlyRoute path={"/signup"}><SignUp /></NotLoggedInOnlyRoute> </PrivateRoute>
<NotLoggedInOnlyRoute path={"/login"}>
<SignIn />
</NotLoggedInOnlyRoute>
<NotLoggedInOnlyRoute path={"/signup"}>
<SignUp />
</NotLoggedInOnlyRoute>
</Switch> </Switch>
) );
} }
export const isLoggedIn = () => !!localStorage.getItem("token") export const isLoggedIn = () => !!localStorage.getItem("token");
// @ts-ignore // @ts-ignore
function PrivateRoute({ children, ...rest }) { function PrivateRoute({ children, ...rest }) {
@ -30,7 +35,7 @@ function PrivateRoute({ children, ...rest }) {
<Redirect <Redirect
to={{ to={{
pathname: "/login", pathname: "/login",
state: { from: location } state: { from: location },
}} }}
/> />
) )

View file

@ -1,20 +1,21 @@
import {ApolloClient, createHttpLink, InMemoryCache} from "@apollo/client"; import { ApolloClient, createHttpLink, InMemoryCache } from "@apollo/client";
import {setContext} from "@apollo/client/link/context"; import { setContext } from "@apollo/client/link/context";
import {getRawJsonWebToken} from "../jwt/jwt"; import { getRawJsonWebToken } from "../jwt/jwt";
const httpLink = createHttpLink({ const httpLink = createHttpLink({
uri: 'http://localhost:5433/graphql', uri: "http://localhost:5433/graphql",
}); });
const authLink = setContext((_, { headers }) => { const authLink = setContext((_, { headers }) => {
const token = getRawJsonWebToken(); const token = getRawJsonWebToken();
return token ? { return token
headers: { ? {
...headers, headers: {
authorization: `Bearer ${token}`, ...headers,
} authorization: `Bearer ${token}`,
} : headers },
}
: headers;
}); });
export const client = new ApolloClient({ export const client = new ApolloClient({

View file

@ -1,113 +1,151 @@
import {ApolloCache, FetchResult, gql, Reference, StoreObject} from "@apollo/client"; import {
import {FullAnswerFragment, FullAnswerResponse, QuestionAnswerResponse} from "../queries/answer"; ApolloCache,
import {CandidatePosition} from "../../components/CandidatePositionLegend"; FetchResult,
gql,
Reference,
StoreObject,
} from "@apollo/client";
import {
FullAnswerFragment,
FullAnswerResponse,
QuestionAnswerResponse,
} from "../queries/answer";
import { CandidatePosition } from "../../components/CandidatePositionLegend";
export const EDIT_ANSWER = gql` export const EDIT_ANSWER = gql`
mutation UpdateAnswer($id: ID!, $position: Int, $text: String) { mutation UpdateAnswer($id: ID!, $position: Int, $text: String) {
updateAnswer(input: {id: $id, answerPatch: {position: $position, text: $text}}) { updateAnswer(
input: { id: $id, answerPatch: { position: $position, text: $text } }
) {
answer { answer {
...FullAnswerFragment ...FullAnswerFragment
} }
} }
} }
${FullAnswerFragment} ${FullAnswerFragment}
` `;
export interface EditAnswerResponse { export interface EditAnswerResponse {
updateAnswer: EditAnswerPayload | null updateAnswer: EditAnswerPayload | null;
} }
export interface EditAnswerPayload { export interface EditAnswerPayload {
answer: FullAnswerResponse, answer: FullAnswerResponse;
__typename: "UpdateAnswerPayload", __typename: "UpdateAnswerPayload";
} }
export interface EditAnswerVariables { export interface EditAnswerVariables {
id: string, id: string;
position?: CandidatePosition, position?: CandidatePosition;
text?: string | null, text?: string | null;
} }
export const ADD_ANSWER = gql` export const ADD_ANSWER = gql`
mutation AddAnswer($questionRowId: Int!, $personRowId: Int!, $position: Int!, $text: String) { mutation AddAnswer(
createAnswer(input: {answer: {questionRowId: $questionRowId, personRowId: $personRowId, position: $position, text: $text }}) { $questionRowId: Int!
$personRowId: Int!
$position: Int!
$text: String
) {
createAnswer(
input: {
answer: {
questionRowId: $questionRowId
personRowId: $personRowId
position: $position
text: $text
}
}
) {
answer { answer {
...FullAnswerFragment ...FullAnswerFragment
} }
} }
} }
${FullAnswerFragment} ${FullAnswerFragment}
` `;
export interface AddAnswerResponse { export interface AddAnswerResponse {
createAnswer: AddAnswerPayload | null, createAnswer: AddAnswerPayload | null;
} }
export interface AddAnswerPayload { export interface AddAnswerPayload {
answer: FullAnswerResponse, answer: FullAnswerResponse;
__typename: "CreateAnswerPayload", __typename: "CreateAnswerPayload";
} }
export interface AddAnswerVariables { export interface AddAnswerVariables {
questionRowId: number, questionRowId: number;
personRowId: number;
position: CandidatePosition;
text?: string | null;
}
const matchesStoreFieldName = (
storeFieldName: string,
personRowId: number, personRowId: number,
position: CandidatePosition, questionRowId: number
text?: string | null, ): boolean => {
} const fullName = `answerByQuestionRowIdAndPersonRowId({"personRowId":${personRowId},"questionRowId":${questionRowId}})`;
return fullName === storeFieldName;
};
const matchesStoreFieldName = (storeFieldName: string, personRowId: number, questionRowId: number): boolean => {
const fullName = `answerByQuestionRowIdAndPersonRowId({"personRowId":${personRowId},"questionRowId":${questionRowId}})`
return fullName === storeFieldName
}
interface NodesCacheRefs { interface NodesCacheRefs {
nodes: Array<Reference | StoreObject> nodes: Array<Reference | StoreObject>;
} }
const addAnswerToQuestion = (cache: ApolloCache<AddAnswerResponse>, question: QuestionAnswerResponse, newAnswerRef: Reference) => { const addAnswerToQuestion = (
cache: ApolloCache<AddAnswerResponse>,
question: QuestionAnswerResponse,
newAnswerRef: Reference
) => {
cache.modify({ cache.modify({
id: cache.identify({...question}), id: cache.identify({ ...question }),
fields: { fields: {
answersByQuestionRowId: (answerRefs: NodesCacheRefs = {nodes: []}): NodesCacheRefs => { answersByQuestionRowId: (
console.log(answerRefs) answerRefs: NodesCacheRefs = { nodes: [] }
return {nodes: [...answerRefs.nodes, newAnswerRef]} ): NodesCacheRefs => {
console.log(answerRefs);
return { nodes: [...answerRefs.nodes, newAnswerRef] };
}, },
} },
}); });
} };
const addAnswerToRootField = ( const addAnswerToRootField = (
cache: ApolloCache<AddAnswerResponse>, newAnswerRef: Reference, personRowId: number, questionRowId: number cache: ApolloCache<AddAnswerResponse>,
newAnswerRef: Reference,
personRowId: number,
questionRowId: number
) => { ) => {
cache.modify({ cache.modify({
fields: { fields: {
answerByQuestionRowIdAndPersonRowId: ( answerByQuestionRowIdAndPersonRowId: (
answerRefs: Reference | StoreObject | null = null, answerRefs: Reference | StoreObject | null = null,
{storeFieldName} { storeFieldName }
): Reference | StoreObject | void => { ): Reference | StoreObject | void => {
if (matchesStoreFieldName(storeFieldName, personRowId, questionRowId)) { if (matchesStoreFieldName(storeFieldName, personRowId, questionRowId)) {
return newAnswerRef return newAnswerRef;
} }
}, },
} },
}); });
} };
const writeAnswerToCache = ( const writeAnswerToCache = (
cache: ApolloCache<AddAnswerResponse>, cache: ApolloCache<AddAnswerResponse>,
answer: FullAnswerResponse, answer: FullAnswerResponse
): Reference | undefined => { ): Reference | undefined => {
return cache.writeFragment<FullAnswerResponse>({ return cache.writeFragment<FullAnswerResponse>({
data: answer, data: answer,
fragment: FullAnswerFragment, fragment: FullAnswerFragment,
fragmentName: "FullAnswerFragment", fragmentName: "FullAnswerFragment",
}); });
} };
export const updateCacheAfterAddingAnswer = ( export const updateCacheAfterAddingAnswer = (
cache: ApolloCache<AddAnswerResponse>, cache: ApolloCache<AddAnswerResponse>,
{data}: FetchResult<AddAnswerResponse>, { data }: FetchResult<AddAnswerResponse>,
question: QuestionAnswerResponse question: QuestionAnswerResponse
) => { ) => {
const answer = data?.createAnswer?.answer; const answer = data?.createAnswer?.answer;
@ -115,7 +153,12 @@ export const updateCacheAfterAddingAnswer = (
const newAnswerRef = writeAnswerToCache(cache, answer); const newAnswerRef = writeAnswerToCache(cache, answer);
if (newAnswerRef) { if (newAnswerRef) {
addAnswerToQuestion(cache, question, newAnswerRef); addAnswerToQuestion(cache, question, newAnswerRef);
addAnswerToRootField(cache, newAnswerRef, answer.personRowId, answer.questionRowId); addAnswerToRootField(
cache,
newAnswerRef,
answer.personRowId,
answer.questionRowId
);
} }
} }
} };

View file

@ -1,32 +1,47 @@
import {MockedResponse} from "@apollo/client/testing"; import { MockedResponse } from "@apollo/client/testing";
import { import {
ADD_CATEGORY, AddCategoryResponse, ADD_CATEGORY,
AddCategoryVariables, DELETE_CATEGORY, DeleteCategoryPayload, DeleteCategoryResponse, DeleteCategoryVariables, AddCategoryResponse,
EDIT_CATEGORY, EditCategoryPayload, AddCategoryVariables,
DELETE_CATEGORY,
DeleteCategoryPayload,
DeleteCategoryResponse,
DeleteCategoryVariables,
EDIT_CATEGORY,
EditCategoryPayload,
EditCategoryResponse, EditCategoryResponse,
EditCategoryVariables EditCategoryVariables,
} from "./category"; } from "./category";
import {BasicCategoryResponse} from "../queries/category"; import { BasicCategoryResponse } from "../queries/category";
import {categoryNodesMock} from "../queries/category.mock"; import { categoryNodesMock } from "../queries/category.mock";
const editCategoryVariables: EditCategoryVariables = { const editCategoryVariables: EditCategoryVariables = {
id: 'c1', id: "c1",
title: 'New title for Category 1', title: "New title for Category 1",
description: 'Further information for C1', description: "Further information for C1",
}; };
const getEditedCategoryMock = (): EditCategoryPayload | null => { const getEditedCategoryMock = (): EditCategoryPayload | null => {
const originalCategory = categoryNodesMock.find(c => c.id === editCategoryVariables.id) const originalCategory = categoryNodesMock.find(
return originalCategory ? { (c) => c.id === editCategoryVariables.id
category: { );
...originalCategory, return originalCategory
title: editCategoryVariables.title === undefined ? originalCategory.title : editCategoryVariables.title, ? {
description: editCategoryVariables.description === undefined ? originalCategory.description : null, category: {
}, ...originalCategory,
__typename: "UpdateCategoryPayload", title:
} : null editCategoryVariables.title === undefined
} ? originalCategory.title
: editCategoryVariables.title,
description:
editCategoryVariables.description === undefined
? originalCategory.description
: null,
},
__typename: "UpdateCategoryPayload",
}
: null;
};
export const editCategoryMock: Array<MockedResponse<EditCategoryResponse>> = [ export const editCategoryMock: Array<MockedResponse<EditCategoryResponse>> = [
{ {
@ -37,13 +52,13 @@ export const editCategoryMock: Array<MockedResponse<EditCategoryResponse>> = [
result: { result: {
data: { data: {
updateCategory: getEditedCategoryMock(), updateCategory: getEditedCategoryMock(),
} },
}, },
}, },
] ];
const addCategoryVariables: AddCategoryVariables = { const addCategoryVariables: AddCategoryVariables = {
title: 'New category', title: "New category",
description: "", description: "",
}; };
@ -52,8 +67,8 @@ const addedCategoryMock: BasicCategoryResponse = {
rowId: 3, rowId: 3,
title: addCategoryVariables.title as string, title: addCategoryVariables.title as string,
description: addCategoryVariables.description as string, description: addCategoryVariables.description as string,
__typename: "Category" __typename: "Category",
} };
export const addCategoryMock: Array<MockedResponse<AddCategoryResponse>> = [ export const addCategoryMock: Array<MockedResponse<AddCategoryResponse>> = [
{ {
@ -66,25 +81,31 @@ export const addCategoryMock: Array<MockedResponse<AddCategoryResponse>> = [
createCategory: { createCategory: {
category: addedCategoryMock, category: addedCategoryMock,
__typename: "CreateCategoryPayload", __typename: "CreateCategoryPayload",
} },
} },
}, },
}, },
] ];
const deleteCategoryVariables: DeleteCategoryVariables = { const deleteCategoryVariables: DeleteCategoryVariables = {
id: 'c2' id: "c2",
}; };
const getDeletedCategoryMock = (): DeleteCategoryPayload | null => { const getDeletedCategoryMock = (): DeleteCategoryPayload | null => {
const categoryToBeDeleted = categoryNodesMock.find(q => q.id === deleteCategoryVariables.id) const categoryToBeDeleted = categoryNodesMock.find(
return categoryToBeDeleted ? { (q) => q.id === deleteCategoryVariables.id
category: categoryToBeDeleted, );
__typename: "DeleteCategoryPayload", return categoryToBeDeleted
} : null ? {
} category: categoryToBeDeleted,
__typename: "DeleteCategoryPayload",
}
: null;
};
export const deleteCategoryMock: Array<MockedResponse<DeleteCategoryResponse>> = [ export const deleteCategoryMock: Array<
MockedResponse<DeleteCategoryResponse>
> = [
{ {
request: { request: {
query: DELETE_CATEGORY, query: DELETE_CATEGORY,
@ -93,8 +114,7 @@ export const deleteCategoryMock: Array<MockedResponse<DeleteCategoryResponse>> =
result: { result: {
data: { data: {
deleteCategory: getDeletedCategoryMock(), deleteCategory: getDeletedCategoryMock(),
} },
}, },
}, },
] ];

View file

@ -1,55 +1,65 @@
import {gql} from "@apollo/client"; import { gql } from "@apollo/client";
import {BasicCategoryFragment, BasicCategoryResponse} from "../queries/category"; import {
BasicCategoryFragment,
BasicCategoryResponse,
} from "../queries/category";
export const EDIT_CATEGORY = gql` export const EDIT_CATEGORY = gql`
mutation UpdateCategory($id: ID!, $title: String, $description: String) { mutation UpdateCategory($id: ID!, $title: String, $description: String) {
updateCategory(input: {id: $id, categoryPatch: {description: $description, title: $title}}) { updateCategory(
input: {
id: $id
categoryPatch: { description: $description, title: $title }
}
) {
category { category {
...BasicCategoryFragment ...BasicCategoryFragment
} }
} }
} }
${BasicCategoryFragment} ${BasicCategoryFragment}
` `;
export interface EditCategoryResponse { export interface EditCategoryResponse {
updateCategory: EditCategoryPayload | null updateCategory: EditCategoryPayload | null;
} }
export interface EditCategoryPayload { export interface EditCategoryPayload {
category: BasicCategoryResponse, category: BasicCategoryResponse;
__typename: "UpdateCategoryPayload", __typename: "UpdateCategoryPayload";
} }
export interface EditCategoryVariables { export interface EditCategoryVariables {
id: string, id: string;
title?: string, title?: string;
description?: string | null, description?: string | null;
} }
export const ADD_CATEGORY = gql` export const ADD_CATEGORY = gql`
mutation AddCategory($title: String!, $description: String) { mutation AddCategory($title: String!, $description: String) {
createCategory(input: {category: {title: $title, description: $description}}) { createCategory(
input: { category: { title: $title, description: $description } }
) {
category { category {
...BasicCategoryFragment ...BasicCategoryFragment
} }
} }
} }
${BasicCategoryFragment} ${BasicCategoryFragment}
` `;
export interface AddCategoryResponse { export interface AddCategoryResponse {
createCategory: AddCategoryPayload | null, createCategory: AddCategoryPayload | null;
} }
export interface AddCategoryPayload { export interface AddCategoryPayload {
category: BasicCategoryResponse, category: BasicCategoryResponse;
__typename: "CreateCategoryPayload", __typename: "CreateCategoryPayload";
} }
export interface AddCategoryVariables { export interface AddCategoryVariables {
title: string, title: string;
description?: string | null, description?: string | null;
} }
export const DELETE_CATEGORY = gql` export const DELETE_CATEGORY = gql`
@ -61,17 +71,17 @@ export const DELETE_CATEGORY = gql`
} }
} }
${BasicCategoryFragment} ${BasicCategoryFragment}
` `;
export interface DeleteCategoryResponse { export interface DeleteCategoryResponse {
deleteCategory: DeleteCategoryPayload | null deleteCategory: DeleteCategoryPayload | null;
} }
export interface DeleteCategoryPayload { export interface DeleteCategoryPayload {
category: BasicCategoryResponse, category: BasicCategoryResponse;
__typename: "DeleteCategoryPayload", __typename: "DeleteCategoryPayload";
} }
export interface DeleteCategoryVariables { export interface DeleteCategoryVariables {
id: string, id: string;
} }

View file

@ -1,5 +1,5 @@
import {MockedResponse} from "@apollo/client/testing"; import { MockedResponse } from "@apollo/client/testing";
import {LOGIN, LoginResponse} from "./login"; import { LOGIN, LoginResponse } from "./login";
export const loginMock: Array<MockedResponse<LoginResponse>> = [ export const loginMock: Array<MockedResponse<LoginResponse>> = [
{ {
@ -8,14 +8,14 @@ export const loginMock: Array<MockedResponse<LoginResponse>> = [
variables: { variables: {
email: "test@email.com", email: "test@email.com",
password: "password", password: "password",
} },
}, },
result: { result: {
data: { data: {
authenticate: { authenticate: {
jwtToken: "123" jwtToken: "123",
} },
} },
}, },
}, },
{ {
@ -24,14 +24,14 @@ export const loginMock: Array<MockedResponse<LoginResponse>> = [
variables: { variables: {
email: "test@email.com", email: "test@email.com",
password: "wrong-password", password: "wrong-password",
} },
}, },
result: { result: {
data: { data: {
authenticate: { authenticate: {
jwtToken: undefined jwtToken: undefined,
} },
} },
}, },
} },
] ];

View file

@ -1,19 +1,20 @@
import {gql} from "@apollo/client"; import { gql } from "@apollo/client";
export const LOGIN = gql` export const LOGIN = gql`
mutation Login($email: String!, $password: String!) { mutation Login($email: String!, $password: String!) {
authenticate(input: {email: $email, password: $password}) { authenticate(input: { email: $email, password: $password }) {
jwtToken jwtToken
} }
}` }
`;
export interface LoginVariables { export interface LoginVariables {
email: string, email: string;
password: string password: string;
} }
export interface LoginResponse { export interface LoginResponse {
authenticate: { authenticate: {
jwtToken?: string jwtToken?: string;
} };
} }

View file

@ -1,37 +1,55 @@
import {MockedResponse} from "@apollo/client/testing"; import { MockedResponse } from "@apollo/client/testing";
import { import {
ADD_QUESTION, AddQuestionResponse, ADD_QUESTION,
AddQuestionVariables, DELETE_QUESTION, DeleteQuestionPayload, DeleteQuestionResponse, DeleteQuestionVariables, AddQuestionResponse,
EDIT_QUESTION, EditQuestionPayload, AddQuestionVariables,
DELETE_QUESTION,
DeleteQuestionPayload,
DeleteQuestionResponse,
DeleteQuestionVariables,
EDIT_QUESTION,
EditQuestionPayload,
EditQuestionResponse, EditQuestionResponse,
EditQuestionVariables EditQuestionVariables,
} from "./question"; } from "./question";
import {BasicQuestionResponse} from "../queries/question"; import { BasicQuestionResponse } from "../queries/question";
import {questionNodesMock} from "../queries/question.mock"; import { questionNodesMock } from "../queries/question.mock";
import {categoryNodesMock} from "../queries/category.mock"; import { categoryNodesMock } from "../queries/category.mock";
const editQuestionVariables: EditQuestionVariables = { const editQuestionVariables: EditQuestionVariables = {
id: 'q1', id: "q1",
title: 'New title for Question 1?', title: "New title for Question 1?",
description: 'Further information for Q1', description: "Further information for Q1",
categoryRowId: 1, categoryRowId: 1,
}; };
const getEditedQuestionMock = (): EditQuestionPayload | null => { const getEditedQuestionMock = (): EditQuestionPayload | null => {
const originalQuestion = questionNodesMock.find(q => q.id === editQuestionVariables.id) const originalQuestion = questionNodesMock.find(
return originalQuestion ? { (q) => q.id === editQuestionVariables.id
question: { );
...originalQuestion, return originalQuestion
title: editQuestionVariables.title === undefined ? originalQuestion.title : editQuestionVariables.title, ? {
description: editQuestionVariables.description === undefined ? originalQuestion.description : null, question: {
categoryByCategoryRowId: editQuestionVariables.categoryRowId === undefined ...originalQuestion,
? originalQuestion.categoryByCategoryRowId title:
: categoryNodesMock.find(c => c.rowId === editQuestionVariables.categoryRowId) || null, editQuestionVariables.title === undefined
}, ? originalQuestion.title
__typename: "UpdateQuestionPayload", : editQuestionVariables.title,
} : null description:
} editQuestionVariables.description === undefined
? originalQuestion.description
: null,
categoryByCategoryRowId:
editQuestionVariables.categoryRowId === undefined
? originalQuestion.categoryByCategoryRowId
: categoryNodesMock.find(
(c) => c.rowId === editQuestionVariables.categoryRowId
) || null,
},
__typename: "UpdateQuestionPayload",
}
: null;
};
export const editQuestionMock: Array<MockedResponse<EditQuestionResponse>> = [ export const editQuestionMock: Array<MockedResponse<EditQuestionResponse>> = [
{ {
@ -42,13 +60,13 @@ export const editQuestionMock: Array<MockedResponse<EditQuestionResponse>> = [
result: { result: {
data: { data: {
updateQuestion: getEditedQuestionMock(), updateQuestion: getEditedQuestionMock(),
} },
}, },
}, },
] ];
const addQuestionVariables: AddQuestionVariables = { const addQuestionVariables: AddQuestionVariables = {
title: 'New question?', title: "New question?",
description: "", description: "",
categoryRowId: null, categoryRowId: null,
}; };
@ -58,9 +76,12 @@ const addedQuestionMock: BasicQuestionResponse = {
rowId: 4, rowId: 4,
title: addQuestionVariables.title as string, title: addQuestionVariables.title as string,
description: addQuestionVariables.description as string, description: addQuestionVariables.description as string,
categoryByCategoryRowId: categoryNodesMock.find(c => c.rowId === editQuestionVariables.categoryRowId) || null, categoryByCategoryRowId:
__typename: "Question" categoryNodesMock.find(
} (c) => c.rowId === editQuestionVariables.categoryRowId
) || null,
__typename: "Question",
};
export const addQuestionMock: Array<MockedResponse<AddQuestionResponse>> = [ export const addQuestionMock: Array<MockedResponse<AddQuestionResponse>> = [
{ {
@ -73,25 +94,31 @@ export const addQuestionMock: Array<MockedResponse<AddQuestionResponse>> = [
createQuestion: { createQuestion: {
question: addedQuestionMock, question: addedQuestionMock,
__typename: "CreateQuestionPayload", __typename: "CreateQuestionPayload",
} },
} },
}, },
}, },
] ];
const deleteQuestionVariables: DeleteQuestionVariables = { const deleteQuestionVariables: DeleteQuestionVariables = {
id: 'q2' id: "q2",
}; };
const getDeletedQuestionMock = (): DeleteQuestionPayload | null => { const getDeletedQuestionMock = (): DeleteQuestionPayload | null => {
const questionToBeDeleted = questionNodesMock.find(q => q.id === deleteQuestionVariables.id) const questionToBeDeleted = questionNodesMock.find(
return questionToBeDeleted ? { (q) => q.id === deleteQuestionVariables.id
question: questionToBeDeleted, );
__typename: "DeleteQuestionPayload", return questionToBeDeleted
} : null ? {
} question: questionToBeDeleted,
__typename: "DeleteQuestionPayload",
}
: null;
};
export const deleteQuestionMock: Array<MockedResponse<DeleteQuestionResponse>> = [ export const deleteQuestionMock: Array<
MockedResponse<DeleteQuestionResponse>
> = [
{ {
request: { request: {
query: DELETE_QUESTION, query: DELETE_QUESTION,
@ -100,7 +127,7 @@ export const deleteQuestionMock: Array<MockedResponse<DeleteQuestionResponse>> =
result: { result: {
data: { data: {
deleteQuestion: getDeletedQuestionMock(), deleteQuestion: getDeletedQuestionMock(),
} },
}, },
}, },
] ];

View file

@ -1,57 +1,86 @@
import {gql} from "@apollo/client"; import { gql } from "@apollo/client";
import {BasicQuestionFragment, BasicQuestionResponse} from "../queries/question"; import {
BasicQuestionFragment,
BasicQuestionResponse,
} from "../queries/question";
export const EDIT_QUESTION = gql` export const EDIT_QUESTION = gql`
mutation UpdateQuestion($id: ID!, $title: String, $description: String, $categoryRowId: Int) { mutation UpdateQuestion(
updateQuestion(input: {id: $id, questionPatch: {categoryRowId: $categoryRowId, description: $description, title: $title}}) { $id: ID!
$title: String
$description: String
$categoryRowId: Int
) {
updateQuestion(
input: {
id: $id
questionPatch: {
categoryRowId: $categoryRowId
description: $description
title: $title
}
}
) {
question { question {
...BasicQuestionFragment ...BasicQuestionFragment
} }
} }
} }
${BasicQuestionFragment} ${BasicQuestionFragment}
` `;
export interface EditQuestionResponse { export interface EditQuestionResponse {
updateQuestion: EditQuestionPayload | null, updateQuestion: EditQuestionPayload | null;
} }
export interface EditQuestionPayload { export interface EditQuestionPayload {
question: BasicQuestionResponse, question: BasicQuestionResponse;
__typename: "UpdateQuestionPayload", __typename: "UpdateQuestionPayload";
} }
export interface EditQuestionVariables { export interface EditQuestionVariables {
id: string, id: string;
title?: string, title?: string;
description?: string | null, description?: string | null;
categoryRowId?: number | null, categoryRowId?: number | null;
} }
export const ADD_QUESTION = gql` export const ADD_QUESTION = gql`
mutation AddQuestion($title: String!, $description: String, $categoryRowId: Int) { mutation AddQuestion(
createQuestion(input: {question: {title: $title, categoryRowId: $categoryRowId, description: $description}}) { $title: String!
$description: String
$categoryRowId: Int
) {
createQuestion(
input: {
question: {
title: $title
categoryRowId: $categoryRowId
description: $description
}
}
) {
question { question {
...BasicQuestionFragment ...BasicQuestionFragment
} }
} }
} }
${BasicQuestionFragment} ${BasicQuestionFragment}
` `;
export interface AddQuestionResponse { export interface AddQuestionResponse {
createQuestion: AddQuestionPayload | null createQuestion: AddQuestionPayload | null;
} }
export interface AddQuestionPayload { export interface AddQuestionPayload {
question: BasicQuestionResponse, question: BasicQuestionResponse;
__typename: "CreateQuestionPayload", __typename: "CreateQuestionPayload";
} }
export interface AddQuestionVariables { export interface AddQuestionVariables {
title: string, title: string;
description?: string | null, description?: string | null;
categoryRowId?: number | null categoryRowId?: number | null;
} }
export const DELETE_QUESTION = gql` export const DELETE_QUESTION = gql`
@ -63,19 +92,17 @@ export const DELETE_QUESTION = gql`
} }
} }
${BasicQuestionFragment} ${BasicQuestionFragment}
` `;
export interface DeleteQuestionResponse { export interface DeleteQuestionResponse {
deleteQuestion: DeleteQuestionPayload | null, deleteQuestion: DeleteQuestionPayload | null;
} }
export interface DeleteQuestionPayload { export interface DeleteQuestionPayload {
question: BasicQuestionResponse, question: BasicQuestionResponse;
__typename: "DeleteQuestionPayload", __typename: "DeleteQuestionPayload";
} }
export interface DeleteQuestionVariables { export interface DeleteQuestionVariables {
id: string, id: string;
} }

View file

@ -1,26 +1,38 @@
import {gql} from "@apollo/client"; import { gql } from "@apollo/client";
export const SIGN_UP = gql` export const SIGN_UP = gql`
mutation CreateAccount($firstName: String!, $lastName: String!, $email: String!, $password: String!) { mutation CreateAccount(
registerPerson(input: {firstName: $firstName, lastName: $lastName, email: $email, password: $password}) { $firstName: String!
person { $lastName: String!
id $email: String!
} $password: String!
} ) {
registerPerson(
input: {
firstName: $firstName
lastName: $lastName
email: $email
password: $password
}
) {
person {
id
}
} }
` }
`;
export interface SignUpVariables { export interface SignUpVariables {
firstName: string, firstName: string;
lastName: string, lastName: string;
email: string, email: string;
password: string, password: string;
} }
export interface SignUpResponse { export interface SignUpResponse {
registerPerson: { registerPerson: {
person: { person: {
id: string id: string;
} };
} };
} }

View file

@ -1,6 +1,6 @@
import {gql} from "@apollo/client"; import { gql } from "@apollo/client";
import {BasicQuestionFragment, BasicQuestionResponse} from "./question"; import { BasicQuestionFragment, BasicQuestionResponse } from "./question";
import {CandidatePosition} from "../../components/CandidatePositionLegend"; import { CandidatePosition } from "../../components/CandidatePositionLegend";
export const FullAnswerFragment = gql` export const FullAnswerFragment = gql`
fragment FullAnswerFragment on Answer { fragment FullAnswerFragment on Answer {
@ -10,33 +10,36 @@ export const FullAnswerFragment = gql`
questionRowId questionRowId
personRowId personRowId
} }
` `;
export interface FullAnswerResponse { export interface FullAnswerResponse {
id: string, id: string;
text: string | null, text: string | null;
position: CandidatePosition, position: CandidatePosition;
questionRowId: number, questionRowId: number;
personRowId: number, personRowId: number;
__typename: "Answer", __typename: "Answer";
} }
export const GET_ANSWER_BY_QUESTION_AND_PERSON = gql` export const GET_ANSWER_BY_QUESTION_AND_PERSON = gql`
query GetAnswerByQuestionAndPerson($questionRowId: Int!, $personRowId: Int!) { query GetAnswerByQuestionAndPerson($questionRowId: Int!, $personRowId: Int!) {
answerByQuestionRowIdAndPersonRowId(personRowId: $personRowId, questionRowId: $questionRowId) { answerByQuestionRowIdAndPersonRowId(
personRowId: $personRowId
questionRowId: $questionRowId
) {
...FullAnswerFragment ...FullAnswerFragment
} }
} }
${FullAnswerFragment} ${FullAnswerFragment}
` `;
export interface GetAnswerByQuestionAndPersonResponse { export interface GetAnswerByQuestionAndPersonResponse {
answerByQuestionRowIdAndPersonRowId: FullAnswerResponse | null, answerByQuestionRowIdAndPersonRowId: FullAnswerResponse | null;
} }
export interface GetAnswerByQuestionAndPersonVariables { export interface GetAnswerByQuestionAndPersonVariables {
personRowId: number, personRowId: number;
questionRowId: number, questionRowId: number;
} }
export const AnswerPositionFragment = gql` export const AnswerPositionFragment = gql`
@ -44,12 +47,12 @@ export const AnswerPositionFragment = gql`
id id
position position
} }
` `;
export interface AnswerPositionResponse { export interface AnswerPositionResponse {
id: string, id: string;
position: CandidatePosition, position: CandidatePosition;
__typename: "Answer", __typename: "Answer";
} }
export const QuestionAnswerFragment = gql` export const QuestionAnswerFragment = gql`
@ -63,13 +66,13 @@ export const QuestionAnswerFragment = gql`
} }
${BasicQuestionFragment} ${BasicQuestionFragment}
${AnswerPositionFragment} ${AnswerPositionFragment}
` `;
export interface QuestionAnswerResponse extends BasicQuestionResponse { export interface QuestionAnswerResponse extends BasicQuestionResponse {
answersByQuestionRowId: { answersByQuestionRowId: {
nodes: Array<AnswerPositionResponse> nodes: Array<AnswerPositionResponse>;
__typename: "AnswersConnection", __typename: "AnswersConnection";
}, };
} }
export const GET_ALL_QUESTION_ANSWERS = gql` export const GET_ALL_QUESTION_ANSWERS = gql`
@ -81,15 +84,15 @@ export const GET_ALL_QUESTION_ANSWERS = gql`
} }
} }
${QuestionAnswerFragment} ${QuestionAnswerFragment}
` `;
export interface GetAllQuestionAnswersResponse { export interface GetAllQuestionAnswersResponse {
allQuestions: { allQuestions: {
nodes: Array<QuestionAnswerResponse>, nodes: Array<QuestionAnswerResponse>;
__typename: "QuestionsConnection", __typename: "QuestionsConnection";
} };
} }
export interface GetAllQuestionAnswersVariables { export interface GetAllQuestionAnswersVariables {
personRowId?: number | null, personRowId?: number | null;
} }

View file

@ -1,29 +1,32 @@
import {MockedResponse} from "@apollo/client/testing"; import { MockedResponse } from "@apollo/client/testing";
import { import {
BasicCategoryResponse, BasicCategoryResponse,
GET_ALL_CATEGORIES, GET_ALL_CATEGORIES,
GET_CATEGORY_BY_ID, GET_CATEGORY_BY_ID,
GetAllCategoriesResponse, GetAllCategoriesResponse,
GetCategoryByIdResponse GetCategoryByIdResponse,
} from "./category"; } from "./category";
export const categoryNodesMock: Array<BasicCategoryResponse> = [ export const categoryNodesMock: Array<BasicCategoryResponse> = [
{ {
id: "c1", id: "c1",
rowId: 1, rowId: 1,
title: "Category 1", title: "Category 1",
description: "Further information for C1", description: "Further information for C1",
__typename: "Category" __typename: "Category",
}, { },
{
id: "c2", id: "c2",
rowId: 2, rowId: 2,
title: "Category 2", title: "Category 2",
description: "Further information for C2", description: "Further information for C2",
__typename: "Category" __typename: "Category",
}]; },
];
export const getAllCategoriesMock: Array<MockedResponse<GetAllCategoriesResponse>> = [ export const getAllCategoriesMock: Array<
MockedResponse<GetAllCategoriesResponse>
> = [
{ {
request: { request: {
query: GET_ALL_CATEGORIES, query: GET_ALL_CATEGORIES,
@ -37,21 +40,24 @@ export const getAllCategoriesMock: Array<MockedResponse<GetAllCategoriesResponse
}, },
}, },
}, },
] ];
export const getCategoryByIdMock: Array<MockedResponse<GetCategoryByIdResponse>> = [...categoryNodesMock.map(c => ({ export const getCategoryByIdMock: Array<
request: { MockedResponse<GetCategoryByIdResponse>
query: GET_CATEGORY_BY_ID, > = [
variables: { ...categoryNodesMock.map((c) => ({
id: c.id, request: {
query: GET_CATEGORY_BY_ID,
variables: {
id: c.id,
},
}, },
}, result: {
result: { data: {
data: { category: c,
category: c, },
}, },
}, })),
})),
{ {
request: { request: {
query: GET_CATEGORY_BY_ID, query: GET_CATEGORY_BY_ID,
@ -64,5 +70,5 @@ export const getCategoryByIdMock: Array<MockedResponse<GetCategoryByIdResponse>>
category: null, category: null,
}, },
}, },
} },
] ];

View file

@ -1,4 +1,4 @@
import {gql} from "@apollo/client"; import { gql } from "@apollo/client";
export const BasicCategoryFragment = gql` export const BasicCategoryFragment = gql`
fragment BasicCategoryFragment on Category { fragment BasicCategoryFragment on Category {
@ -7,14 +7,14 @@ export const BasicCategoryFragment = gql`
title title
description description
} }
` `;
export interface BasicCategoryResponse { export interface BasicCategoryResponse {
id: string, id: string;
rowId: number, rowId: number;
title: string, title: string;
description: string | null, description: string | null;
__typename: "Category", __typename: "Category";
} }
export const GET_ALL_CATEGORIES = gql` export const GET_ALL_CATEGORIES = gql`
@ -26,28 +26,28 @@ export const GET_ALL_CATEGORIES = gql`
} }
} }
${BasicCategoryFragment} ${BasicCategoryFragment}
` `;
export interface GetAllCategoriesResponse { export interface GetAllCategoriesResponse {
allCategories: { allCategories: {
nodes: Array<BasicCategoryResponse>, nodes: Array<BasicCategoryResponse>;
__typename: "CategoriesConnection", __typename: "CategoriesConnection";
} };
} }
export const GET_CATEGORY_BY_ID = gql` export const GET_CATEGORY_BY_ID = gql`
query GetCategoryById($id:ID!) { query GetCategoryById($id: ID!) {
category(id: $id) { category(id: $id) {
...BasicCategoryFragment ...BasicCategoryFragment
} }
} }
${BasicCategoryFragment} ${BasicCategoryFragment}
` `;
export interface GetCategoryByIdResponse { export interface GetCategoryByIdResponse {
category: BasicCategoryResponse | null, category: BasicCategoryResponse | null;
} }
export interface GetCategoryByIdVariables { export interface GetCategoryByIdVariables {
id: string, id: string;
} }

View file

@ -1,26 +1,26 @@
import {MockedResponse} from "@apollo/client/testing"; import { MockedResponse } from "@apollo/client/testing";
import { import {
BasicQuestionResponse, BasicQuestionResponse,
GET_ALL_QUESTIONS, GET_ALL_QUESTIONS,
GET_QUESTION_BY_ID, GET_QUESTION_BY_ID,
GetAllQuestionsResponse, GetAllQuestionsResponse,
GetQuestionByIdResponse GetQuestionByIdResponse,
} from "./question"; } from "./question";
export const questionNodesMock: Array<BasicQuestionResponse> = [
export const questionNodesMock: Array<BasicQuestionResponse> = [{ {
id: "q1", id: "q1",
rowId: 1,
title: "Question 1?",
description: "Further information for Q1",
categoryByCategoryRowId: {
id: "c1",
rowId: 1, rowId: 1,
title: "Category 1", title: "Question 1?",
__typename: "Category" description: "Further information for Q1",
categoryByCategoryRowId: {
id: "c1",
rowId: 1,
title: "Category 1",
__typename: "Category",
},
__typename: "Question",
}, },
__typename: "Question",
},
{ {
id: "q2", id: "q2",
rowId: 2, rowId: 2,
@ -36,10 +36,12 @@ export const questionNodesMock: Array<BasicQuestionResponse> = [{
description: null, description: null,
categoryByCategoryRowId: null, categoryByCategoryRowId: null,
__typename: "Question", __typename: "Question",
} },
]; ];
export const getAllQuestionsMock: Array<MockedResponse<GetAllQuestionsResponse>> = [ export const getAllQuestionsMock: Array<
MockedResponse<GetAllQuestionsResponse>
> = [
{ {
request: { request: {
query: GET_ALL_QUESTIONS, query: GET_ALL_QUESTIONS,
@ -49,14 +51,16 @@ export const getAllQuestionsMock: Array<MockedResponse<GetAllQuestionsResponse>>
allQuestions: { allQuestions: {
nodes: questionNodesMock, nodes: questionNodesMock,
__typename: "QuestionsConnection", __typename: "QuestionsConnection",
} },
} },
}, },
}, },
] ];
export const getQuestionByIdMock: Array<MockedResponse<GetQuestionByIdResponse>> = [ export const getQuestionByIdMock: Array<
...questionNodesMock.map(q => ({ MockedResponse<GetQuestionByIdResponse>
> = [
...questionNodesMock.map((q) => ({
request: { request: {
query: GET_QUESTION_BY_ID, query: GET_QUESTION_BY_ID,
variables: { variables: {
@ -81,6 +85,5 @@ export const getQuestionByIdMock: Array<MockedResponse<GetQuestionByIdResponse>>
question: null, question: null,
}, },
}, },
} },
] ];

View file

@ -1,4 +1,4 @@
import {gql} from "@apollo/client"; import { gql } from "@apollo/client";
const QuestionCategoryFragment = gql` const QuestionCategoryFragment = gql`
fragment QuestionCategoryFragment on Category { fragment QuestionCategoryFragment on Category {
@ -6,13 +6,13 @@ const QuestionCategoryFragment = gql`
rowId rowId
title title
} }
` `;
interface GetQuestionsCategoryResponse { interface GetQuestionsCategoryResponse {
id: string, id: string;
rowId: number, rowId: number;
title: string, title: string;
__typename: "Category", __typename: "Category";
} }
export const BasicQuestionFragment = gql` export const BasicQuestionFragment = gql`
@ -26,15 +26,15 @@ export const BasicQuestionFragment = gql`
} }
} }
${QuestionCategoryFragment} ${QuestionCategoryFragment}
` `;
export interface BasicQuestionResponse { export interface BasicQuestionResponse {
id: string, id: string;
rowId: number, rowId: number;
title: string, title: string;
description: string | null, description: string | null;
categoryByCategoryRowId: GetQuestionsCategoryResponse | null, categoryByCategoryRowId: GetQuestionsCategoryResponse | null;
__typename: "Question", __typename: "Question";
} }
export const GET_ALL_QUESTIONS = gql` export const GET_ALL_QUESTIONS = gql`
@ -46,28 +46,28 @@ export const GET_ALL_QUESTIONS = gql`
} }
} }
${BasicQuestionFragment} ${BasicQuestionFragment}
` `;
export interface GetAllQuestionsResponse { export interface GetAllQuestionsResponse {
allQuestions: { allQuestions: {
nodes: Array<BasicQuestionResponse>, nodes: Array<BasicQuestionResponse>;
__typename: "QuestionsConnection", __typename: "QuestionsConnection";
} };
} }
export const GET_QUESTION_BY_ID = gql` export const GET_QUESTION_BY_ID = gql`
query GetQuestionById($id:ID!) { query GetQuestionById($id: ID!) {
question(id: $id) { question(id: $id) {
...BasicQuestionFragment ...BasicQuestionFragment
} }
} }
${BasicQuestionFragment} ${BasicQuestionFragment}
` `;
export interface GetQuestionByIdResponse { export interface GetQuestionByIdResponse {
question: BasicQuestionResponse | null, question: BasicQuestionResponse | null;
} }
export interface GetQuestionByIdVariables { export interface GetQuestionByIdVariables {
id: string, id: string;
} }

View file

@ -1,20 +1,23 @@
import React from 'react'; import React from "react";
import {createStyles, makeStyles, Theme} from '@material-ui/core/styles'; import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import Accordion from '@material-ui/core/Accordion'; import Accordion from "@material-ui/core/Accordion";
import AccordionDetails from '@material-ui/core/AccordionDetails'; import AccordionDetails from "@material-ui/core/AccordionDetails";
import AccordionSummary from '@material-ui/core/AccordionSummary'; import AccordionSummary from "@material-ui/core/AccordionSummary";
import Typography from '@material-ui/core/Typography'; import Typography from "@material-ui/core/Typography";
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
import Divider from '@material-ui/core/Divider'; import Divider from "@material-ui/core/Divider";
import {CandidatePosition, getIconForPosition} from "./CandidatePositionLegend"; import {
import {QuestionAnswerResponse} from "../backend/queries/answer"; CandidatePosition,
getIconForPosition,
} from "./CandidatePositionLegend";
import { QuestionAnswerResponse } from "../backend/queries/answer";
import EditAnswerSection from "./EditAnswerSection"; import EditAnswerSection from "./EditAnswerSection";
const useStyles = makeStyles((theme: Theme) => const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
root: { root: {
width: '100%', width: "100%",
marginBottom: theme.spacing(1) marginBottom: theme.spacing(1),
}, },
heading: { heading: {
fontSize: theme.typography.pxToRem(15), fontSize: theme.typography.pxToRem(15),
@ -25,7 +28,7 @@ const useStyles = makeStyles((theme: Theme) =>
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
}, },
details: { details: {
flexDirection: 'column', flexDirection: "column",
}, },
questionDetails: { questionDetails: {
marginBottom: theme.spacing(2), marginBottom: theme.spacing(2),
@ -33,15 +36,17 @@ const useStyles = makeStyles((theme: Theme) =>
positionIcon: { positionIcon: {
marginLeft: theme.spacing(2), marginLeft: theme.spacing(2),
}, },
}), })
); );
interface AccordionQuestionAnswerProps { interface AccordionQuestionAnswerProps {
personRowId: number, personRowId: number;
question: QuestionAnswerResponse, question: QuestionAnswerResponse;
} }
export default function AccordionQuestionAnswer(props: AccordionQuestionAnswerProps) { export default function AccordionQuestionAnswer(
props: AccordionQuestionAnswerProps
) {
const { const {
rowId: questionRowId, rowId: questionRowId,
title: questionTitle, title: questionTitle,
@ -50,14 +55,14 @@ export default function AccordionQuestionAnswer(props: AccordionQuestionAnswerPr
const position = props.question.answersByQuestionRowId.nodes[0]?.position; const position = props.question.answersByQuestionRowId.nodes[0]?.position;
const questionCategory = props.question.categoryByCategoryRowId?.title; const questionCategory = props.question.categoryByCategoryRowId?.title;
const classes = useStyles(); const classes = useStyles();
const answerPosition = position !== undefined ? position : CandidatePosition.skipped const answerPosition =
position !== undefined ? position : CandidatePosition.skipped;
return ( return (
<div className={classes.root} key={questionRowId}> <div className={classes.root} key={questionRowId}>
<Accordion> <Accordion>
<AccordionSummary <AccordionSummary
expandIcon={<ExpandMoreIcon/>} expandIcon={<ExpandMoreIcon />}
aria-controls="panel1c-content" aria-controls="panel1c-content"
id="panel1c-header" id="panel1c-header"
> >
@ -72,13 +77,20 @@ export default function AccordionQuestionAnswer(props: AccordionQuestionAnswerPr
</div> </div>
</AccordionSummary> </AccordionSummary>
<AccordionDetails className={classes.details}> <AccordionDetails className={classes.details}>
<Typography className={classes.questionDetails} color="textSecondary" style={{whiteSpace: "pre-line"}}> <Typography
className={classes.questionDetails}
color="textSecondary"
style={{ whiteSpace: "pre-line" }}
>
{questionDetails} {questionDetails}
</Typography> </Typography>
<Divider/> <Divider />
<EditAnswerSection personRowId={props.personRowId} question={props.question}/> <EditAnswerSection
personRowId={props.personRowId}
question={props.question}
/>
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
</div> </div>
) );
} }

View file

@ -1,21 +1,21 @@
import React from 'react'; import React from "react";
import {createStyles, makeStyles, Theme} from '@material-ui/core/styles'; import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import Accordion from '@material-ui/core/Accordion'; import Accordion from "@material-ui/core/Accordion";
import AccordionDetails from '@material-ui/core/AccordionDetails'; import AccordionDetails from "@material-ui/core/AccordionDetails";
import AccordionSummary from '@material-ui/core/AccordionSummary'; import AccordionSummary from "@material-ui/core/AccordionSummary";
import AccordionActions from '@material-ui/core/AccordionActions'; import AccordionActions from "@material-ui/core/AccordionActions";
import DeleteIcon from '@material-ui/icons/Delete'; import DeleteIcon from "@material-ui/icons/Delete";
import EditIcon from '@material-ui/icons/Edit'; import EditIcon from "@material-ui/icons/Edit";
import Typography from '@material-ui/core/Typography'; import Typography from "@material-ui/core/Typography";
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
import Divider from '@material-ui/core/Divider'; import Divider from "@material-ui/core/Divider";
import {IconButton} from '@material-ui/core'; import { IconButton } from "@material-ui/core";
const useStyles = makeStyles((theme: Theme) => const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
root: { root: {
width: '100%', width: "100%",
marginBottom: theme.spacing(1) marginBottom: theme.spacing(1),
}, },
heading: { heading: {
fontSize: theme.typography.pxToRem(15), fontSize: theme.typography.pxToRem(15),
@ -26,18 +26,18 @@ const useStyles = makeStyles((theme: Theme) =>
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
}, },
details: { details: {
alignItems: 'center', alignItems: "center",
}, },
}), })
); );
interface AccordionWithEditProps { interface AccordionWithEditProps {
key: string, key: string;
title: string, title: string;
description: string | null, description: string | null;
subTitle?: string | null, subTitle?: string | null;
onEditButtonClick?(): void, onEditButtonClick?(): void;
onDeleteButtonClick?(): void, onDeleteButtonClick?(): void;
} }
export default function AccordionWithEdit(props: AccordionWithEditProps) { export default function AccordionWithEdit(props: AccordionWithEditProps) {
@ -47,7 +47,7 @@ export default function AccordionWithEdit(props: AccordionWithEditProps) {
<div className={classes.root}> <div className={classes.root}>
<Accordion> <Accordion>
<AccordionSummary <AccordionSummary
expandIcon={<ExpandMoreIcon/>} expandIcon={<ExpandMoreIcon />}
aria-controls="panel1c-content" aria-controls="panel1c-content"
id="panel1c-header" id="panel1c-header"
> >
@ -59,20 +59,30 @@ export default function AccordionWithEdit(props: AccordionWithEditProps) {
</div> </div>
</AccordionSummary> </AccordionSummary>
<AccordionDetails className={classes.details}> <AccordionDetails className={classes.details}>
<Typography color="textSecondary" style={{whiteSpace: "pre-line"}}> <Typography color="textSecondary" style={{ whiteSpace: "pre-line" }}>
{props.description} {props.description}
</Typography> </Typography>
</AccordionDetails> </AccordionDetails>
<Divider/> <Divider />
<AccordionActions> <AccordionActions>
<IconButton data-testid="edit-icon-button" size={"small"} aria-label="edit" onClick={props.onEditButtonClick}> <IconButton
<EditIcon titleAccess="Anpassen"/> data-testid="edit-icon-button"
size={"small"}
aria-label="edit"
onClick={props.onEditButtonClick}
>
<EditIcon titleAccess="Anpassen" />
</IconButton> </IconButton>
<IconButton data-testid="delete-icon-button" size={"small"} aria-label="delete" onClick={props.onDeleteButtonClick}> <IconButton
<DeleteIcon titleAccess="Löschen"/> data-testid="delete-icon-button"
size={"small"}
aria-label="delete"
onClick={props.onDeleteButtonClick}
>
<DeleteIcon titleAccess="Löschen" />
</IconButton> </IconButton>
</AccordionActions> </AccordionActions>
</Accordion> </Accordion>
</div> </div>
) );
} }

View file

@ -1,25 +1,25 @@
import {Card, CardActionArea, CardContent} from "@material-ui/core"; import { Card, CardActionArea, CardContent } from "@material-ui/core";
import React from "react"; import React from "react";
import {makeStyles} from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
import AddIcon from '@material-ui/icons/Add'; import AddIcon from "@material-ui/icons/Add";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
root: { root: {
width: '100%', width: "100%",
marginBottom: theme.spacing(1), marginBottom: theme.spacing(1),
}, },
addCardContent: { addCardContent: {
padding: 0, padding: 0,
}, },
addCardIcon: { addCardIcon: {
display: 'block', display: "block",
margin: 'auto', margin: "auto",
padding: 12, padding: 12,
}, },
})); }));
interface AddCardProps { interface AddCardProps {
handleClick?(): void handleClick?(): void;
} }
export default function AddCard(props: AddCardProps) { export default function AddCard(props: AddCardProps) {
@ -29,9 +29,9 @@ export default function AddCard(props: AddCardProps) {
<Card className={classes.root}> <Card className={classes.root}>
<CardActionArea onClick={props.handleClick}> <CardActionArea onClick={props.handleClick}>
<CardContent color={"textSecondary"} className={classes.addCardContent}> <CardContent color={"textSecondary"} className={classes.addCardContent}>
<AddIcon className={classes.addCardIcon}/> <AddIcon className={classes.addCardIcon} />
</CardContent> </CardContent>
</CardActionArea> </CardActionArea>
</Card> </Card>
) );
} }

View file

@ -1,60 +1,61 @@
import React from 'react'; import React from "react";
import {createStyles, makeStyles, Theme} from '@material-ui/core/styles'; import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import CircularProgress from '@material-ui/core/CircularProgress'; import CircularProgress from "@material-ui/core/CircularProgress";
import {green} from '@material-ui/core/colors'; import { green } from "@material-ui/core/colors";
import Button from '@material-ui/core/Button'; import Button from "@material-ui/core/Button";
import {PropTypes} from "@material-ui/core"; import { PropTypes } from "@material-ui/core";
const useStyles = makeStyles((theme: Theme) => const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
wrapper: { wrapper: {
margin: theme.spacing(1), margin: theme.spacing(1),
position: 'relative', position: "relative",
}, },
buttonProgress: { buttonProgress: {
color: green[500], color: green[500],
position: 'absolute', position: "absolute",
top: '50%', top: "50%",
left: '50%', left: "50%",
marginTop: -12, marginTop: -12,
marginLeft: -12, marginLeft: -12,
}, },
button: { button: {
margin: theme.spacing(3, 0, 2), margin: theme.spacing(3, 0, 2),
}, },
}), })
); );
interface ButtonWithSpinnerProps { interface ButtonWithSpinnerProps {
children: string, children: string;
onClick?: () => void, onClick?: () => void;
loading?: boolean loading?: boolean;
type?: "button" | "submit", type?: "button" | "submit";
fullWidth?: boolean, fullWidth?: boolean;
autoFocus?: boolean, autoFocus?: boolean;
className?: string, className?: string;
color?: PropTypes.Color, color?: PropTypes.Color;
} }
export default function ButtonWithSpinner(props: ButtonWithSpinnerProps) { export default function ButtonWithSpinner(props: ButtonWithSpinnerProps) {
const classes = useStyles(); const classes = useStyles();
return ( return (
<div className={classes.wrapper}> <div className={classes.wrapper}>
<Button <Button
className={`${classes.button} ${props.className}`} className={`${classes.button} ${props.className}`}
variant="contained" variant="contained"
color={props.color || "primary"} color={props.color || "primary"}
fullWidth={!!props.fullWidth} fullWidth={!!props.fullWidth}
type={props.type} type={props.type}
disabled={props.loading} disabled={props.loading}
onClick={props.onClick} onClick={props.onClick}
autoFocus={props.autoFocus} autoFocus={props.autoFocus}
> >
{props.children} {props.children}
</Button> </Button>
{props.loading && <CircularProgress size={24} className={classes.buttonProgress} />} {props.loading && (
</div> <CircularProgress size={24} className={classes.buttonProgress} />
)}
</div>
); );
} }

View file

@ -1,11 +1,10 @@
import {Chip, SvgIconProps} from "@material-ui/core"; import { Chip, SvgIconProps } from "@material-ui/core";
import ThumbUpIcon from "@material-ui/icons/ThumbUp"; import ThumbUpIcon from "@material-ui/icons/ThumbUp";
import RadioButtonUncheckedIcon from "@material-ui/icons/RadioButtonUnchecked"; import RadioButtonUncheckedIcon from "@material-ui/icons/RadioButtonUnchecked";
import {ThumbDown} from "@material-ui/icons"; import { ThumbDown } from "@material-ui/icons";
import CloseIcon from "@material-ui/icons/Close"; import CloseIcon from "@material-ui/icons/Close";
import React from "react"; import React from "react";
import {makeStyles} from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
export enum CandidatePosition { export enum CandidatePosition {
positive, positive,
@ -18,52 +17,59 @@ export const allPositions = [
CandidatePosition.positive, CandidatePosition.positive,
CandidatePosition.neutral, CandidatePosition.neutral,
CandidatePosition.negative, CandidatePosition.negative,
CandidatePosition.skipped CandidatePosition.skipped,
] ];
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
legend: { legend: {
position: 'relative', position: "relative",
display: 'flex', display: "flex",
flexDirection: 'row', flexDirection: "row",
justifyContent: 'flex-end', justifyContent: "flex-end",
marginBottom: theme.spacing(2), marginBottom: theme.spacing(2),
}, },
chip: { chip: {
marginLeft: theme.spacing(1), marginLeft: theme.spacing(1),
} },
})); }));
export function CandidatePositionLegend() { export function CandidatePositionLegend() {
const classes = useStyles(); const classes = useStyles();
const getChip = (position: CandidatePosition, legendText: string) => { const getChip = (position: CandidatePosition, legendText: string) => {
return <Chip return (
label={legendText} <Chip
color="primary" label={legendText}
icon={getIconForPosition(position, {fontSize: "inherit"})} color="primary"
variant="outlined" icon={getIconForPosition(position, { fontSize: "inherit" })}
className={classes.chip} variant="outlined"
/>; className={classes.chip}
} />
);
};
return (<div className={classes.legend}> return (
{getChip(CandidatePosition.positive, "Ich bin dafür")} <div className={classes.legend}>
{getChip(CandidatePosition.neutral, "Neutral")} {getChip(CandidatePosition.positive, "Ich bin dafür")}
{getChip(CandidatePosition.negative, "Ich bin dagegen")} {getChip(CandidatePosition.neutral, "Neutral")}
{getChip(CandidatePosition.skipped, "Frage überspringen")} {getChip(CandidatePosition.negative, "Ich bin dagegen")}
</div>) {getChip(CandidatePosition.skipped, "Frage überspringen")}
</div>
);
} }
export const getIconForPosition = (position: CandidatePosition, props?: SvgIconProps): JSX.Element => { export const getIconForPosition = (
position: CandidatePosition,
props?: SvgIconProps
): JSX.Element => {
switch (position) { switch (position) {
case CandidatePosition.positive: case CandidatePosition.positive:
return <ThumbUpIcon {...props} /> return <ThumbUpIcon {...props} />;
case CandidatePosition.neutral: case CandidatePosition.neutral:
return <RadioButtonUncheckedIcon {...props}/> return <RadioButtonUncheckedIcon {...props} />;
case CandidatePosition.negative: case CandidatePosition.negative:
return <ThumbDown {...props} /> return <ThumbDown {...props} />;
case CandidatePosition.skipped: case CandidatePosition.skipped:
return <CloseIcon {...props} /> return <CloseIcon {...props} />;
} }
} };

View file

@ -1,60 +1,71 @@
import {Paper, Typography} from "@material-ui/core"; import { Paper, Typography } from "@material-ui/core";
import React from "react"; import React from "react";
import {makeStyles} from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
import {useQuery} from "@apollo/client"; import { useQuery } from "@apollo/client";
import AddCard from "./AddCard"; import AddCard from "./AddCard";
import AccordionWithEdit from "./AccordionWithEdit"; import AccordionWithEdit from "./AccordionWithEdit";
import {BasicCategoryResponse, GET_ALL_CATEGORIES, GetAllCategoriesResponse} from "../backend/queries/category"; import {
import DialogChangeCategory, {dialogChangeCategoryId, dialogChangeCategoryOpen} from "./DialogChangeCategory"; BasicCategoryResponse,
GET_ALL_CATEGORIES,
GetAllCategoriesResponse,
} from "../backend/queries/category";
import DialogChangeCategory, {
dialogChangeCategoryId,
dialogChangeCategoryOpen,
} from "./DialogChangeCategory";
import DialogDeleteCategory, { import DialogDeleteCategory, {
dialogDeleteCategoryId, dialogDeleteCategoryId,
dialogDeleteCategoryOpen, dialogDeleteCategoryOpen,
dialogDeleteCategoryTitle dialogDeleteCategoryTitle,
} from "./DialogDeleteCategory"; } from "./DialogDeleteCategory";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
root: { root: {
width: '100%', width: "100%",
padding: theme.spacing(1), padding: theme.spacing(1),
marginBottom: theme.spacing(3), marginBottom: theme.spacing(3),
}, },
})); }));
export default function CategoryList() { export default function CategoryList() {
const categories = useQuery<GetAllCategoriesResponse, null>(GET_ALL_CATEGORIES).data?.allCategories.nodes; const categories = useQuery<GetAllCategoriesResponse, null>(
GET_ALL_CATEGORIES
).data?.allCategories.nodes;
const classes = useStyles(); const classes = useStyles();
const handleAddClick = () => { const handleAddClick = () => {
dialogChangeCategoryId("") dialogChangeCategoryId("");
dialogChangeCategoryOpen(true) dialogChangeCategoryOpen(true);
} };
const handleEditButtonClick = (category: BasicCategoryResponse) => { const handleEditButtonClick = (category: BasicCategoryResponse) => {
dialogChangeCategoryId(category.id); dialogChangeCategoryId(category.id);
dialogChangeCategoryOpen(true) dialogChangeCategoryOpen(true);
}; };
const handleDeleteButtonClick = (category: BasicCategoryResponse) => { const handleDeleteButtonClick = (category: BasicCategoryResponse) => {
dialogDeleteCategoryTitle(category.title); dialogDeleteCategoryTitle(category.title);
dialogDeleteCategoryId(category.id); dialogDeleteCategoryId(category.id);
dialogDeleteCategoryOpen(true); dialogDeleteCategoryOpen(true);
} };
return ( return (
<Paper className={classes.root}> <Paper className={classes.root}>
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>Kategorien</Typography> <Typography component={"h2"} variant="h6" color="primary" gutterBottom>
{categories?.map(category => <AccordionWithEdit Kategorien
</Typography>
{categories?.map((category) => (
<AccordionWithEdit
key={category.id} key={category.id}
title={category.title} title={category.title}
description={category.description} description={category.description}
onEditButtonClick={() => handleEditButtonClick(category)} onEditButtonClick={() => handleEditButtonClick(category)}
onDeleteButtonClick={() => handleDeleteButtonClick(category)} onDeleteButtonClick={() => handleDeleteButtonClick(category)}
/> />
)} ))}
<AddCard handleClick={handleAddClick}/> <AddCard handleClick={handleAddClick} />
<DialogChangeCategory/> <DialogChangeCategory />
<DialogDeleteCategory/> <DialogDeleteCategory />
</Paper> </Paper>
) );
} }

View file

@ -1,20 +1,23 @@
import React, {ChangeEvent} from 'react'; import React, { ChangeEvent } from "react";
import {FormControl, InputLabel, MenuItem, Select} from "@material-ui/core"; import { FormControl, InputLabel, MenuItem, Select } from "@material-ui/core";
import {BasicCategoryResponse} from "../backend/queries/category"; import { BasicCategoryResponse } from "../backend/queries/category";
interface CategorySelectionMenuProps { interface CategorySelectionMenuProps {
selectedCategoryId: number | null selectedCategoryId: number | null;
categories?: Array<BasicCategoryResponse>, categories?: Array<BasicCategoryResponse>;
handleCategoryChange(categoryId: number | null): void handleCategoryChange(categoryId: number | null): void;
} }
export default function CategorySelectionMenu(props: CategorySelectionMenuProps) { export default function CategorySelectionMenu(
const onCategoryIdChange = (e: ChangeEvent<{ name?: string, value: unknown }>) => { props: CategorySelectionMenuProps
const newValue = e.target.value === -1 ? null : e.target.value as number; ) {
const onCategoryIdChange = (
e: ChangeEvent<{ name?: string; value: unknown }>
) => {
const newValue = e.target.value === -1 ? null : (e.target.value as number);
props.handleCategoryChange(newValue); props.handleCategoryChange(newValue);
} };
return ( return (
<FormControl fullWidth variant="outlined"> <FormControl fullWidth variant="outlined">
@ -27,9 +30,11 @@ export default function CategorySelectionMenu(props: CategorySelectionMenuProps)
<MenuItem value={-1}> <MenuItem value={-1}>
<em>None</em> <em>None</em>
</MenuItem> </MenuItem>
{props.categories?.map(category => <MenuItem key={category.id} value={category.rowId}> {props.categories?.map((category) => (
{category.title} <MenuItem key={category.id} value={category.rowId}>
</MenuItem>)} {category.title}
</MenuItem>
))}
</Select> </Select>
</FormControl> </FormControl>
); );

View file

@ -3,14 +3,14 @@ import Link from "@material-ui/core/Link";
import React from "react"; import React from "react";
export function Copyright() { export function Copyright() {
return ( return (
<Typography variant="body2" color="textSecondary" align="center"> <Typography variant="body2" color="textSecondary" align="center">
{'Copyright © '} {"Copyright © "}
<Link color="inherit" href="https://blog.netzbegruenung.de/"> <Link color="inherit" href="https://blog.netzbegruenung.de/">
Netzbegruenung e.V. Netzbegruenung e.V.
</Link>{' '} </Link>{" "}
{new Date().getFullYear()} {new Date().getFullYear()}
{'.'} {"."}
</Typography> </Typography>
); );
} }

View file

@ -1,12 +1,11 @@
import React from 'react'; import React from "react";
import AppBar from '@material-ui/core/AppBar'; import AppBar from "@material-ui/core/AppBar";
import {IconButton, MenuItem, Toolbar, Typography} from '@material-ui/core'; import { IconButton, MenuItem, Toolbar, Typography } from "@material-ui/core";
import MenuIcon from '@material-ui/icons/Menu'; import MenuIcon from "@material-ui/icons/Menu";
import Menu from '@material-ui/core/Menu'; import Menu from "@material-ui/core/Menu";
import AccountCircle from '@material-ui/icons/AccountCircle'; import AccountCircle from "@material-ui/icons/AccountCircle";
import {makeStyles} from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
import {useHistory} from 'react-router-dom'; import { useHistory } from "react-router-dom";
const useStyles = makeStyles({ const useStyles = makeStyles({
menuButton: { menuButton: {
@ -15,10 +14,10 @@ const useStyles = makeStyles({
title: { title: {
flexGrow: 1, flexGrow: 1,
}, },
}) });
function CustomAppBar() { function CustomAppBar() {
const classes = useStyles() const classes = useStyles();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
const history = useHistory(); const history = useHistory();
@ -28,52 +27,57 @@ function CustomAppBar() {
}; };
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('token') localStorage.removeItem("token");
history.push("/login") history.push("/login");
} };
const handleClose = () => { const handleClose = () => {
setAnchorEl(null); setAnchorEl(null);
}; };
return ( return (
<AppBar> <AppBar>
<Toolbar> <Toolbar>
<IconButton edge="start" className={classes.menuButton} color="inherit" aria-label="menu"> <IconButton
<MenuIcon /> edge="start"
</IconButton> className={classes.menuButton}
<Typography variant="h6" className={classes.title}> color="inherit"
Candymat aria-label="menu"
</Typography> >
<IconButton <MenuIcon />
aria-label="account of current user" </IconButton>
aria-controls="menu-appbar" <Typography variant="h6" className={classes.title}>
aria-haspopup="true" Candymat
onClick={handleMenu} </Typography>
color="inherit" <IconButton
> aria-label="account of current user"
<AccountCircle /> aria-controls="menu-appbar"
</IconButton> aria-haspopup="true"
<Menu onClick={handleMenu}
id="menu-appbar" color="inherit"
anchorEl={anchorEl} >
anchorOrigin={{ <AccountCircle />
vertical: 'top', </IconButton>
horizontal: 'right', <Menu
}} id="menu-appbar"
keepMounted anchorEl={anchorEl}
transformOrigin={{ anchorOrigin={{
vertical: 'top', vertical: "top",
horizontal: 'right', horizontal: "right",
}} }}
open={open} keepMounted
onClose={handleClose} transformOrigin={{
> vertical: "top",
<MenuItem onClick={handleClose}>Profil</MenuItem> horizontal: "right",
<MenuItem onClick={handleLogout}>Logout</MenuItem> }}
</Menu> open={open}
</Toolbar> onClose={handleClose}
</AppBar> >
); <MenuItem onClick={handleClose}>Profil</MenuItem>
<MenuItem onClick={handleLogout}>Logout</MenuItem>
</Menu>
</Toolbar>
</AppBar>
);
} }
export default CustomAppBar export default CustomAppBar;

View file

@ -1,23 +1,29 @@
import {Button, DialogActions} from "@material-ui/core"; import { Button, DialogActions } from "@material-ui/core";
import ButtonWithSpinner from "./ButtonWithSpinner"; import ButtonWithSpinner from "./ButtonWithSpinner";
import React from "react"; import React from "react";
interface DialogSimpleActionProps { interface DialogSimpleActionProps {
confirmButtonText?: string, confirmButtonText?: string;
loading?: boolean, loading?: boolean;
onClose(): void, onClose(): void;
onConfirmButtonClick(): void, onConfirmButtonClick(): void;
} }
export function DialogActionBar(props: DialogSimpleActionProps) { export function DialogActionBar(props: DialogSimpleActionProps) {
return <DialogActions> return (
<Button onClick={props.onClose} color="primary"> <DialogActions>
Abbrechen <Button onClick={props.onClose} color="primary">
</Button> Abbrechen
<ButtonWithSpinner onClick={props.onConfirmButtonClick} autoFocus loading={props.loading}> </Button>
{props.confirmButtonText || "Ok"} <ButtonWithSpinner
</ButtonWithSpinner> onClick={props.onConfirmButtonClick}
</DialogActions>; autoFocus
loading={props.loading}
>
{props.confirmButtonText || "Ok"}
</ButtonWithSpinner>
</DialogActions>
);
} }

View file

@ -1,17 +1,17 @@
import React, {useState} from 'react'; import React, { useState } from "react";
import Dialog from '@material-ui/core/Dialog'; import Dialog from "@material-ui/core/Dialog";
import DialogContent from '@material-ui/core/DialogContent'; import DialogContent from "@material-ui/core/DialogContent";
import DialogTitle from '@material-ui/core/DialogTitle'; import DialogTitle from "@material-ui/core/DialogTitle";
import {DialogActionBar} from "./DialogActionBar"; import { DialogActionBar } from "./DialogActionBar";
import {DialogTitleAndDetails} from "./DialogTitleAndDetails"; import { DialogTitleAndDetails } from "./DialogTitleAndDetails";
import {makeVar, useMutation, useQuery, useReactiveVar} from "@apollo/client"; import { makeVar, useMutation, useQuery, useReactiveVar } from "@apollo/client";
import {useSnackbar} from "notistack"; import { useSnackbar } from "notistack";
import { import {
BasicCategoryFragment, BasicCategoryFragment,
BasicCategoryResponse, BasicCategoryResponse,
GET_CATEGORY_BY_ID, GET_CATEGORY_BY_ID,
GetCategoryByIdResponse, GetCategoryByIdResponse,
GetCategoryByIdVariables GetCategoryByIdVariables,
} from "../backend/queries/category"; } from "../backend/queries/category";
import { import {
ADD_CATEGORY, ADD_CATEGORY,
@ -19,10 +19,9 @@ import {
AddCategoryVariables, AddCategoryVariables,
EDIT_CATEGORY, EDIT_CATEGORY,
EditCategoryResponse, EditCategoryResponse,
EditCategoryVariables EditCategoryVariables,
} from "../backend/mutations/category"; } from "../backend/mutations/category";
export const dialogChangeCategoryId = makeVar<string>(""); export const dialogChangeCategoryId = makeVar<string>("");
export const dialogChangeCategoryOpen = makeVar<boolean>(false); export const dialogChangeCategoryOpen = makeVar<boolean>(false);
@ -32,52 +31,77 @@ export default function DialogChangeCategory() {
const [details, setDetails] = useState(""); const [details, setDetails] = useState("");
const categoryId = useReactiveVar(dialogChangeCategoryId); const categoryId = useReactiveVar(dialogChangeCategoryId);
const open = useReactiveVar(dialogChangeCategoryOpen); const open = useReactiveVar(dialogChangeCategoryOpen);
const {enqueueSnackbar} = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
useQuery<GetCategoryByIdResponse, GetCategoryByIdVariables>(GET_CATEGORY_BY_ID, { useQuery<GetCategoryByIdResponse, GetCategoryByIdVariables>(
variables: { GET_CATEGORY_BY_ID,
id: categoryId, {
}, variables: {
onCompleted: (data => { id: categoryId,
setAddMode(!data.category && !categoryId) },
setTitle(data.category?.title || ""); onCompleted: (data) => {
setDetails(data.category?.description || "") setAddMode(!data.category && !categoryId);
}) setTitle(data.category?.title || "");
}); setDetails(data.category?.description || "");
const [editCategory, {loading: editLoading}] = useMutation<EditCategoryResponse, EditCategoryVariables>(EDIT_CATEGORY, { },
onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}), }
);
const [editCategory, { loading: editLoading }] = useMutation<
EditCategoryResponse,
EditCategoryVariables
>(EDIT_CATEGORY, {
onError: (e) =>
enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {
variant: "error",
}),
onCompleted: (response) => { onCompleted: (response) => {
if (response.updateCategory) { if (response.updateCategory) {
enqueueSnackbar("Kategorie erfolgreich geändert.", {variant: "success"}) enqueueSnackbar("Kategorie erfolgreich geändert.", {
variant: "success",
});
dialogChangeCategoryOpen(false); dialogChangeCategoryOpen(false);
} else { } else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"}) enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {
} variant: "error",
} });
});
const [addCategory, {loading: addLoading}] = useMutation<AddCategoryResponse, AddCategoryVariables>(ADD_CATEGORY, {
onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}),
onCompleted: (response) => {
if (response.createCategory) {
enqueueSnackbar("Kategorie erfolgreich hinzugefügt.", {variant: "success"})
dialogChangeCategoryOpen(false);
} else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"})
} }
}, },
update: (cache, {data}) => { });
const [addCategory, { loading: addLoading }] = useMutation<
AddCategoryResponse,
AddCategoryVariables
>(ADD_CATEGORY, {
onError: (e) =>
enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {
variant: "error",
}),
onCompleted: (response) => {
if (response.createCategory) {
enqueueSnackbar("Kategorie erfolgreich hinzugefügt.", {
variant: "success",
});
dialogChangeCategoryOpen(false);
} else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {
variant: "error",
});
}
},
update: (cache, { data }) => {
cache.modify({ cache.modify({
fields: { fields: {
allCategories(existingCategories = {nodes: []}) { allCategories(existingCategories = { nodes: [] }) {
const newCategoryRef = cache.writeFragment<BasicCategoryResponse | undefined>({ const newCategoryRef = cache.writeFragment<
BasicCategoryResponse | undefined
>({
data: data?.createCategory?.category, data: data?.createCategory?.category,
fragment: BasicCategoryFragment, fragment: BasicCategoryFragment,
fragmentName: "BasicCategoryFragment", fragmentName: "BasicCategoryFragment",
}); });
return {nodes: [...existingCategories.nodes, newCategoryRef]}; return { nodes: [...existingCategories.nodes, newCategoryRef] };
} },
} },
}); });
} },
}); });
const handleConfirmButtonClick = () => { const handleConfirmButtonClick = () => {
@ -86,21 +110,25 @@ export default function DialogChangeCategory() {
variables: { variables: {
title, title,
description: details, description: details,
} },
}) });
} else { } else {
editCategory({ editCategory({
variables: { variables: {
id: categoryId, id: categoryId,
title: title, title: title,
description: details, description: details,
} },
}) });
} }
} };
return ( return (
<Dialog open={open} onClose={() => dialogChangeCategoryOpen(false)} aria-labelledby="form-dialog-title"> <Dialog
open={open}
onClose={() => dialogChangeCategoryOpen(false)}
aria-labelledby="form-dialog-title"
>
<DialogTitle id="form-dialog-title"> <DialogTitle id="form-dialog-title">
{addMode ? "Neue Kategorie erstellen" : "Kategorie bearbeiten"} {addMode ? "Neue Kategorie erstellen" : "Kategorie bearbeiten"}
</DialogTitle> </DialogTitle>
@ -108,8 +136,8 @@ export default function DialogChangeCategory() {
<DialogTitleAndDetails <DialogTitleAndDetails
title={title} title={title}
details={details} details={details}
onTitleChange={newTitle => setTitle(newTitle)} onTitleChange={(newTitle) => setTitle(newTitle)}
onDetailsChange={newDetails => setDetails(newDetails)} onDetailsChange={(newDetails) => setDetails(newDetails)}
/> />
</DialogContent> </DialogContent>
<DialogActionBar <DialogActionBar

View file

@ -1,28 +1,31 @@
import React, {useState} from 'react'; import React, { useState } from "react";
import Dialog from '@material-ui/core/Dialog'; import Dialog from "@material-ui/core/Dialog";
import DialogContent from '@material-ui/core/DialogContent'; import DialogContent from "@material-ui/core/DialogContent";
import DialogTitle from '@material-ui/core/DialogTitle'; import DialogTitle from "@material-ui/core/DialogTitle";
import {DialogActionBar} from "./DialogActionBar"; import { DialogActionBar } from "./DialogActionBar";
import {DialogTitleAndDetails} from "./DialogTitleAndDetails"; import { DialogTitleAndDetails } from "./DialogTitleAndDetails";
import {makeVar, useMutation, useQuery, useReactiveVar} from "@apollo/client"; import { makeVar, useMutation, useQuery, useReactiveVar } from "@apollo/client";
import {useSnackbar} from "notistack"; import { useSnackbar } from "notistack";
import { import {
ADD_QUESTION, ADD_QUESTION,
AddQuestionResponse, AddQuestionResponse,
AddQuestionVariables, AddQuestionVariables,
EDIT_QUESTION, EDIT_QUESTION,
EditQuestionResponse, EditQuestionResponse,
EditQuestionVariables EditQuestionVariables,
} from "../backend/mutations/question"; } from "../backend/mutations/question";
import { import {
BasicQuestionFragment, BasicQuestionFragment,
BasicQuestionResponse, BasicQuestionResponse,
GET_QUESTION_BY_ID, GET_QUESTION_BY_ID,
GetQuestionByIdResponse, GetQuestionByIdResponse,
GetQuestionByIdVariables GetQuestionByIdVariables,
} from "../backend/queries/question"; } from "../backend/queries/question";
import CategorySelectionMenu from "./CategorySelectionMenu"; import CategorySelectionMenu from "./CategorySelectionMenu";
import {GET_ALL_CATEGORIES, GetAllCategoriesResponse} from "../backend/queries/category"; import {
GET_ALL_CATEGORIES,
GetAllCategoriesResponse,
} from "../backend/queries/category";
export const dialogChangeQuestionId = makeVar<string>(""); export const dialogChangeQuestionId = makeVar<string>("");
export const dialogChangeQuestionOpen = makeVar<boolean>(false); export const dialogChangeQuestionOpen = makeVar<boolean>(false);
@ -34,55 +37,80 @@ export default function DialogChangeQuestion() {
const [categoryRowId, setCategoryRowId] = useState<number | null>(null); const [categoryRowId, setCategoryRowId] = useState<number | null>(null);
const questionId = useReactiveVar(dialogChangeQuestionId); const questionId = useReactiveVar(dialogChangeQuestionId);
const open = useReactiveVar(dialogChangeQuestionOpen); const open = useReactiveVar(dialogChangeQuestionOpen);
const {enqueueSnackbar} = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
useQuery<GetQuestionByIdResponse, GetQuestionByIdVariables>(GET_QUESTION_BY_ID, { useQuery<GetQuestionByIdResponse, GetQuestionByIdVariables>(
variables: { GET_QUESTION_BY_ID,
id: questionId, {
}, variables: {
onCompleted: (data => { id: questionId,
setAddMode(!data.question && !questionId) },
setTitle(data.question?.title || ""); onCompleted: (data) => {
setDetails(data.question?.description || ""); setAddMode(!data.question && !questionId);
setCategoryRowId(data.question?.categoryByCategoryRowId?.rowId || null) setTitle(data.question?.title || "");
}) setDetails(data.question?.description || "");
}) setCategoryRowId(data.question?.categoryByCategoryRowId?.rowId || null);
const categories = useQuery<GetAllCategoriesResponse, null>(GET_ALL_CATEGORIES).data?.allCategories.nodes; },
}
);
const categories = useQuery<GetAllCategoriesResponse, null>(
GET_ALL_CATEGORIES
).data?.allCategories.nodes;
const [editQuestion, {loading: editLoading}] = useMutation<EditQuestionResponse, EditQuestionVariables>(EDIT_QUESTION, { const [editQuestion, { loading: editLoading }] = useMutation<
onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}), EditQuestionResponse,
EditQuestionVariables
>(EDIT_QUESTION, {
onError: (e) =>
enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {
variant: "error",
}),
onCompleted: (response) => { onCompleted: (response) => {
if (response.updateQuestion) { if (response.updateQuestion) {
enqueueSnackbar("Frage erfolgreich geändert.", {variant: "success"}) enqueueSnackbar("Frage erfolgreich geändert.", { variant: "success" });
dialogChangeQuestionOpen(false); dialogChangeQuestionOpen(false);
} else { } else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"}) enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {
} variant: "error",
} });
});
const [addQuestion, {loading: addLoading}] = useMutation<AddQuestionResponse, AddQuestionVariables>(ADD_QUESTION, {
onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}),
onCompleted: (response) => {
if (response.createQuestion) {
enqueueSnackbar("Frage erfolgreich hinzugefügt.", {variant: "success"})
dialogChangeQuestionOpen(false);
} else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"})
} }
}, },
update: (cache, {data}) => { });
const [addQuestion, { loading: addLoading }] = useMutation<
AddQuestionResponse,
AddQuestionVariables
>(ADD_QUESTION, {
onError: (e) =>
enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {
variant: "error",
}),
onCompleted: (response) => {
if (response.createQuestion) {
enqueueSnackbar("Frage erfolgreich hinzugefügt.", {
variant: "success",
});
dialogChangeQuestionOpen(false);
} else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {
variant: "error",
});
}
},
update: (cache, { data }) => {
cache.modify({ cache.modify({
fields: { fields: {
allQuestions(existingQuestions = {nodes: []}) { allQuestions(existingQuestions = { nodes: [] }) {
const newQuestionRef = cache.writeFragment<BasicQuestionResponse | undefined>({ const newQuestionRef = cache.writeFragment<
BasicQuestionResponse | undefined
>({
data: data?.createQuestion?.question, data: data?.createQuestion?.question,
fragment: BasicQuestionFragment, fragment: BasicQuestionFragment,
fragmentName: "BasicQuestionFragment", fragmentName: "BasicQuestionFragment",
}); });
return {nodes: [...existingQuestions.nodes, newQuestionRef]}; return { nodes: [...existingQuestions.nodes, newQuestionRef] };
} },
} },
}); });
} },
}); });
const handleConfirmButtonClick = () => { const handleConfirmButtonClick = () => {
@ -92,8 +120,8 @@ export default function DialogChangeQuestion() {
title, title,
description: details, description: details,
categoryRowId: categoryRowId, categoryRowId: categoryRowId,
} },
}) });
} else { } else {
editQuestion({ editQuestion({
variables: { variables: {
@ -101,13 +129,17 @@ export default function DialogChangeQuestion() {
title: title, title: title,
description: details, description: details,
categoryRowId: categoryRowId, categoryRowId: categoryRowId,
} },
}) });
} }
} };
return ( return (
<Dialog open={open} onClose={() => dialogChangeQuestionOpen(false)} aria-labelledby="form-dialog-title"> <Dialog
open={open}
onClose={() => dialogChangeQuestionOpen(false)}
aria-labelledby="form-dialog-title"
>
<DialogTitle id="form-dialog-title"> <DialogTitle id="form-dialog-title">
{addMode ? "Neue Frage erstellen" : "Frage bearbeiten"} {addMode ? "Neue Frage erstellen" : "Frage bearbeiten"}
</DialogTitle> </DialogTitle>
@ -115,8 +147,8 @@ export default function DialogChangeQuestion() {
<DialogTitleAndDetails <DialogTitleAndDetails
title={title} title={title}
details={details} details={details}
onTitleChange={newTitle => setTitle(newTitle)} onTitleChange={(newTitle) => setTitle(newTitle)}
onDetailsChange={newDetails => setDetails(newDetails)} onDetailsChange={(newDetails) => setDetails(newDetails)}
/> />
<CategorySelectionMenu <CategorySelectionMenu
selectedCategoryId={categoryRowId} selectedCategoryId={categoryRowId}

View file

@ -1,36 +1,61 @@
import React from 'react'; import React from "react";
import {makeVar, Reference, useMutation, useReactiveVar} from "@apollo/client"; import {
makeVar,
Reference,
useMutation,
useReactiveVar,
} from "@apollo/client";
import DialogSimple from "./DialogSimple"; import DialogSimple from "./DialogSimple";
import {useSnackbar} from "notistack"; import { useSnackbar } from "notistack";
import {DELETE_CATEGORY, DeleteCategoryResponse, DeleteCategoryVariables} from "../backend/mutations/category"; import {
DELETE_CATEGORY,
DeleteCategoryResponse,
DeleteCategoryVariables,
} from "../backend/mutations/category";
export const dialogDeleteCategoryId = makeVar<string>(""); export const dialogDeleteCategoryId = makeVar<string>("");
export const dialogDeleteCategoryTitle = makeVar<string>(""); export const dialogDeleteCategoryTitle = makeVar<string>("");
export const dialogDeleteCategoryOpen = makeVar<boolean>(false); export const dialogDeleteCategoryOpen = makeVar<boolean>(false);
export default function DialogDeleteCategory() { export default function DialogDeleteCategory() {
const {enqueueSnackbar} = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const [deleteCategory, {loading}] = useMutation<DeleteCategoryResponse, DeleteCategoryVariables>(DELETE_CATEGORY, { const [deleteCategory, { loading }] = useMutation<
onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}), DeleteCategoryResponse,
DeleteCategoryVariables
>(DELETE_CATEGORY, {
onError: (e) =>
enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {
variant: "error",
}),
onCompleted: (response) => { onCompleted: (response) => {
if (response.deleteCategory) { if (response.deleteCategory) {
enqueueSnackbar("Kategorie erfolgreich gelöscht.", {variant: "success"}) enqueueSnackbar("Kategorie erfolgreich gelöscht.", {
variant: "success",
});
dialogDeleteCategoryOpen(false); dialogDeleteCategoryOpen(false);
} else { } else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"}) enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {
variant: "error",
});
} }
}, },
update: (cache, {data}) => { update: (cache, { data }) => {
const idToRemove = data?.deleteCategory?.category.id; const idToRemove = data?.deleteCategory?.category.id;
cache.modify({ cache.modify({
fields: { fields: {
allCategories(existingCategoriesRef: { nodes: Array<Reference> } = {nodes: []}, {readField}) { allCategories(
return {nodes: existingCategoriesRef.nodes.filter(categoryRef => readField('id', categoryRef) !== idToRemove)}; existingCategoriesRef: { nodes: Array<Reference> } = { nodes: [] },
} { readField }
} ) {
return {
nodes: existingCategoriesRef.nodes.filter(
(categoryRef) => readField("id", categoryRef) !== idToRemove
),
};
},
},
}); });
} },
}); });
const open = useReactiveVar(dialogDeleteCategoryOpen); const open = useReactiveVar(dialogDeleteCategoryOpen);
@ -40,10 +65,10 @@ export default function DialogDeleteCategory() {
const handleConfirmButtonClick = () => { const handleConfirmButtonClick = () => {
deleteCategory({ deleteCategory({
variables: { variables: {
id id,
} },
}) });
} };
return ( return (
<DialogSimple <DialogSimple
@ -57,4 +82,3 @@ export default function DialogDeleteCategory() {
/> />
); );
} }

View file

@ -1,36 +1,59 @@
import React from 'react'; import React from "react";
import {makeVar, Reference, useMutation, useReactiveVar} from "@apollo/client"; import {
makeVar,
Reference,
useMutation,
useReactiveVar,
} from "@apollo/client";
import DialogSimple from "./DialogSimple"; import DialogSimple from "./DialogSimple";
import {DELETE_QUESTION, DeleteQuestionResponse, DeleteQuestionVariables} from "../backend/mutations/question"; import {
import {useSnackbar} from "notistack"; DELETE_QUESTION,
DeleteQuestionResponse,
DeleteQuestionVariables,
} from "../backend/mutations/question";
import { useSnackbar } from "notistack";
export const dialogDeleteQuestionId = makeVar<string>(""); export const dialogDeleteQuestionId = makeVar<string>("");
export const dialogDeleteQuestionTitle = makeVar<string>(""); export const dialogDeleteQuestionTitle = makeVar<string>("");
export const dialogDeleteQuestionOpen = makeVar<boolean>(false); export const dialogDeleteQuestionOpen = makeVar<boolean>(false);
export default function DialogDeleteQuestion() { export default function DialogDeleteQuestion() {
const {enqueueSnackbar} = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const [deleteQuestion, {loading}] = useMutation<DeleteQuestionResponse, DeleteQuestionVariables>(DELETE_QUESTION, { const [deleteQuestion, { loading }] = useMutation<
onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}), DeleteQuestionResponse,
DeleteQuestionVariables
>(DELETE_QUESTION, {
onError: (e) =>
enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {
variant: "error",
}),
onCompleted: (response) => { onCompleted: (response) => {
if (response.deleteQuestion) { if (response.deleteQuestion) {
enqueueSnackbar("Frage erfolgreich gelöscht.", {variant: "success"}) enqueueSnackbar("Frage erfolgreich gelöscht.", { variant: "success" });
dialogDeleteQuestionOpen(false); dialogDeleteQuestionOpen(false);
} else { } else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"}) enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {
variant: "error",
});
} }
}, },
update: (cache, {data}) => { update: (cache, { data }) => {
const idToRemove = data?.deleteQuestion?.question.id; const idToRemove = data?.deleteQuestion?.question.id;
cache.modify({ cache.modify({
fields: { fields: {
allQuestions(existingQuestionsRef: { nodes: Array<Reference> } = {nodes: []}, {readField}) { allQuestions(
return {nodes: existingQuestionsRef.nodes.filter(questionRef => readField('id', questionRef) !== idToRemove)}; existingQuestionsRef: { nodes: Array<Reference> } = { nodes: [] },
} { readField }
} ) {
return {
nodes: existingQuestionsRef.nodes.filter(
(questionRef) => readField("id", questionRef) !== idToRemove
),
};
},
},
}); });
} },
}); });
const open = useReactiveVar(dialogDeleteQuestionOpen); const open = useReactiveVar(dialogDeleteQuestionOpen);
@ -40,10 +63,10 @@ export default function DialogDeleteQuestion() {
const handleConfirmButtonClick = () => { const handleConfirmButtonClick = () => {
deleteQuestion({ deleteQuestion({
variables: { variables: {
id id,
} },
}) });
} };
return ( return (
<DialogSimple <DialogSimple
@ -57,4 +80,3 @@ export default function DialogDeleteQuestion() {
/> />
); );
} }

View file

@ -1,21 +1,20 @@
import React from 'react'; import React from "react";
import Dialog from '@material-ui/core/Dialog'; import Dialog from "@material-ui/core/Dialog";
import DialogContent from '@material-ui/core/DialogContent'; import DialogContent from "@material-ui/core/DialogContent";
import DialogTitle from '@material-ui/core/DialogTitle'; import DialogTitle from "@material-ui/core/DialogTitle";
import {DialogContentText} from "@material-ui/core"; import { DialogContentText } from "@material-ui/core";
import {DialogActionBar} from "./DialogActionBar"; import { DialogActionBar } from "./DialogActionBar";
interface DialogSimpleProps { interface DialogSimpleProps {
open: boolean, open: boolean;
title: string, title: string;
confirmButtonText: string, confirmButtonText: string;
description: string, description: string;
loading?: boolean, loading?: boolean;
onConfirmButtonClick(): void, onConfirmButtonClick(): void;
onClose(): void, onClose(): void;
} }
export default function DialogSimple(props: DialogSimpleProps) { export default function DialogSimple(props: DialogSimpleProps) {
@ -40,4 +39,3 @@ export default function DialogSimple(props: DialogSimpleProps) {
</Dialog> </Dialog>
); );
} }

View file

@ -1,20 +1,20 @@
import {makeStyles} from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
import React from "react"; import React from "react";
import TextField from "@material-ui/core/TextField"; import TextField from "@material-ui/core/TextField";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
textField: { textField: {
marginBottom: theme.spacing(2), marginBottom: theme.spacing(2),
} },
})); }));
interface DialogTitleAndDetailsProps { interface DialogTitleAndDetailsProps {
title: string, title: string;
details?: string | null, details?: string | null;
onTitleChange(newTitle: string): void, onTitleChange(newTitle: string): void;
onDetailsChange(newDetails: string): void, onDetailsChange(newDetails: string): void;
} }
export function DialogTitleAndDetails(props: DialogTitleAndDetailsProps) { export function DialogTitleAndDetails(props: DialogTitleAndDetailsProps) {
@ -29,7 +29,7 @@ export function DialogTitleAndDetails(props: DialogTitleAndDetailsProps) {
fullWidth fullWidth
variant="outlined" variant="outlined"
value={props.title} value={props.title}
onChange={e => props.onTitleChange(e.target.value)} onChange={(e) => props.onTitleChange(e.target.value)}
/> />
<TextField <TextField
className={classes.textField} className={classes.textField}
@ -40,8 +40,8 @@ export function DialogTitleAndDetails(props: DialogTitleAndDetailsProps) {
fullWidth fullWidth
variant="outlined" variant="outlined"
value={props.details} value={props.details}
onChange={e => props.onDetailsChange(e.target.value)} onChange={(e) => props.onDetailsChange(e.target.value)}
/> />
</React.Fragment> </React.Fragment>
) );
} }

View file

@ -1,14 +1,14 @@
import React from "react"; import React from "react";
import {CandidatePosition} from "./CandidatePositionLegend"; import { CandidatePosition } from "./CandidatePositionLegend";
import {useMutation, useQuery} from "@apollo/client"; import { useMutation, useQuery } from "@apollo/client";
import { import {
FullAnswerResponse, FullAnswerResponse,
GET_ANSWER_BY_QUESTION_AND_PERSON, GET_ANSWER_BY_QUESTION_AND_PERSON,
GetAnswerByQuestionAndPersonResponse, GetAnswerByQuestionAndPersonResponse,
GetAnswerByQuestionAndPersonVariables, GetAnswerByQuestionAndPersonVariables,
QuestionAnswerResponse QuestionAnswerResponse,
} from "../backend/queries/answer"; } from "../backend/queries/answer";
import {useSnackbar} from "notistack"; import { useSnackbar } from "notistack";
import { import {
ADD_ANSWER, ADD_ANSWER,
AddAnswerResponse, AddAnswerResponse,
@ -16,48 +16,62 @@ import {
EDIT_ANSWER, EDIT_ANSWER,
EditAnswerResponse, EditAnswerResponse,
EditAnswerVariables, EditAnswerVariables,
updateCacheAfterAddingAnswer updateCacheAfterAddingAnswer,
} from "../backend/mutations/answer"; } from "../backend/mutations/answer";
import ToggleButtonGroupAnswerPosition from "./ToggleButtonGroupAnswerPosition"; import ToggleButtonGroupAnswerPosition from "./ToggleButtonGroupAnswerPosition";
import EditAnswerText from "./EditAnswerText"; import EditAnswerText from "./EditAnswerText";
interface EditAnswerSectionProps { interface EditAnswerSectionProps {
personRowId: number, personRowId: number;
question: QuestionAnswerResponse, question: QuestionAnswerResponse;
} }
export default function EditAnswerSection(props: EditAnswerSectionProps) { export default function EditAnswerSection(props: EditAnswerSectionProps) {
const {enqueueSnackbar} = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const { const { data } = useQuery<
data, GetAnswerByQuestionAndPersonResponse,
} = useQuery<GetAnswerByQuestionAndPersonResponse, GetAnswerByQuestionAndPersonVariables>( GetAnswerByQuestionAndPersonVariables
GET_ANSWER_BY_QUESTION_AND_PERSON, >(GET_ANSWER_BY_QUESTION_AND_PERSON, {
{ variables: {
variables: { personRowId: props.personRowId,
personRowId: props.personRowId, questionRowId: props.question.rowId,
questionRowId: props.question.rowId, },
}, });
} const remoteAnswer = data?.answerByQuestionRowIdAndPersonRowId;
) const [editAnswer, { loading: editAnswerLoading }] = useMutation<
const remoteAnswer = data?.answerByQuestionRowIdAndPersonRowId; EditAnswerResponse,
const [editAnswer, {loading: editAnswerLoading}] = useMutation<EditAnswerResponse, EditAnswerVariables>(EDIT_ANSWER, { EditAnswerVariables
onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}), >(EDIT_ANSWER, {
onError: (e) =>
enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {
variant: "error",
}),
});
const [addAnswer, { loading: addAnswerLoading }] = useMutation<
AddAnswerResponse,
AddAnswerVariables
>(ADD_ANSWER, {
onError: (e) =>
enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {
variant: "error",
}),
update: (cache, fetchResult) =>
updateCacheAfterAddingAnswer(cache, fetchResult, props.question),
}); });
const [addAnswer, {loading: addAnswerLoading}] = useMutation<AddAnswerResponse, AddAnswerVariables>(ADD_ANSWER, {
onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}),
update: (cache, fetchResult) => updateCacheAfterAddingAnswer(cache, fetchResult, props.question)
})
const parsePosition = (position?: CandidatePosition): CandidatePosition => { const parsePosition = (position?: CandidatePosition): CandidatePosition => {
return position !== undefined ? position : CandidatePosition.skipped return position !== undefined ? position : CandidatePosition.skipped;
} };
const changeAnswer = async (position?: CandidatePosition, text?: string): Promise<FullAnswerResponse | undefined> => { const changeAnswer = async (
position?: CandidatePosition,
text?: string
): Promise<FullAnswerResponse | undefined> => {
if (remoteAnswer) { if (remoteAnswer) {
const optimisticResponseAnswer = { const optimisticResponseAnswer = {
...remoteAnswer, ...remoteAnswer,
...(position !== undefined && {position}), ...(position !== undefined && { position }),
...(text !== undefined && {text}), ...(text !== undefined && { text }),
} };
const response = await editAnswer({ const response = await editAnswer({
variables: { variables: {
id: remoteAnswer.id, id: remoteAnswer.id,
@ -68,10 +82,10 @@ export default function EditAnswerSection(props: EditAnswerSectionProps) {
updateAnswer: { updateAnswer: {
__typename: "UpdateAnswerPayload", __typename: "UpdateAnswerPayload",
answer: optimisticResponseAnswer, answer: optimisticResponseAnswer,
} },
} },
}); });
return response.data?.updateAnswer?.answer return response.data?.updateAnswer?.answer;
} else { } else {
const savePosition = parsePosition(position); const savePosition = parsePosition(position);
const response = await addAnswer({ const response = await addAnswer({
@ -91,33 +105,43 @@ export default function EditAnswerSection(props: EditAnswerSectionProps) {
personRowId: props.personRowId, personRowId: props.personRowId,
questionRowId: props.question.rowId, questionRowId: props.question.rowId,
__typename: "Answer", __typename: "Answer",
} },
} },
} },
}); });
return response.data?.createAnswer?.answer return response.data?.createAnswer?.answer;
} }
} };
const handleSaveText = async (text: string) => { const handleSaveText = async (text: string) => {
const newAnswer = await changeAnswer(undefined, text); const newAnswer = await changeAnswer(undefined, text);
if (newAnswer) { if (newAnswer) {
enqueueSnackbar("Antwort erfolgreich gespeichert.", {variant: "success"}) enqueueSnackbar("Antwort erfolgreich gespeichert.", {
variant: "success",
});
} else { } else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"}) enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {
variant: "error",
});
} }
} };
const handlePositionChange = async (e: React.MouseEvent<HTMLElement>, newPosition: CandidatePosition) => { const handlePositionChange = async (
e: React.MouseEvent<HTMLElement>,
newPosition: CandidatePosition
) => {
const newAnswer = await changeAnswer(newPosition); const newAnswer = await changeAnswer(newPosition);
if (!newAnswer) { if (!newAnswer) {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"}) enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {
variant: "error",
});
} }
} };
const loading = editAnswerLoading || addAnswerLoading; const loading = editAnswerLoading || addAnswerLoading;
const position = parsePosition(remoteAnswer?.position); const position = parsePosition(remoteAnswer?.position);
return remoteAnswer === undefined return remoteAnswer === undefined ? (
? <div>Antwort laden...</div> <div>Antwort laden...</div>
: <React.Fragment> ) : (
<React.Fragment>
<ToggleButtonGroupAnswerPosition <ToggleButtonGroupAnswerPosition
position={position} position={position}
onPositionChange={handlePositionChange} onPositionChange={handlePositionChange}
@ -129,5 +153,5 @@ export default function EditAnswerSection(props: EditAnswerSectionProps) {
loading={loading} loading={loading}
/> />
</React.Fragment> </React.Fragment>
; );
} }

View file

@ -1,13 +1,13 @@
import {createStyles, makeStyles, Theme} from "@material-ui/core/styles"; import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import React, {useState} from "react"; import React, { useState } from "react";
import {FormLabel} from "@material-ui/core"; import { FormLabel } from "@material-ui/core";
import TextField from "@material-ui/core/TextField"; import TextField from "@material-ui/core/TextField";
import ButtonWithSpinner from "./ButtonWithSpinner"; import ButtonWithSpinner from "./ButtonWithSpinner";
const useStyles = makeStyles((theme: Theme) => const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
button: { button: {
marginLeft: 'auto', marginLeft: "auto",
marginRight: 0, marginRight: 0,
marginTop: 0, marginTop: 0,
marginBottom: 0, marginBottom: 0,
@ -19,52 +19,54 @@ const useStyles = makeStyles((theme: Theme) =>
detailedAnswerActions: { detailedAnswerActions: {
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "row",
justifyContent: "flex-end" justifyContent: "flex-end",
}, },
}), })
); );
interface EditAnswerTextSectionProps { interface EditAnswerTextSectionProps {
remoteText: string, remoteText: string;
loading?: boolean, loading?: boolean;
onSaveClick(text: string): void, onSaveClick(text: string): void;
} }
export default function EditAnswerText(props: EditAnswerTextSectionProps) { export default function EditAnswerText(props: EditAnswerTextSectionProps) {
const classes = useStyles(); const classes = useStyles();
const [answerText, setAnswerText] = useState<string>(props.remoteText); const [answerText, setAnswerText] = useState<string>(props.remoteText);
return <div className={classes.root}> return (
<FormLabel>Detaillierte Antwort</FormLabel> <div className={classes.root}>
<TextField <FormLabel>Detaillierte Antwort</FormLabel>
multiline <TextField
rows={4} multiline
id="description" rows={4}
fullWidth id="description"
variant="outlined" fullWidth
value={answerText} variant="outlined"
onChange={e => { value={answerText}
e.preventDefault(); onChange={(e) => {
setAnswerText(e.target.value) e.preventDefault();
}} setAnswerText(e.target.value);
/> }}
<div className={classes.detailedAnswerActions}> />
<ButtonWithSpinner <div className={classes.detailedAnswerActions}>
color="default" <ButtonWithSpinner
className={classes.button} color="default"
onClick={() => setAnswerText(props.remoteText)} className={classes.button}
loading={props.loading} onClick={() => setAnswerText(props.remoteText)}
> loading={props.loading}
Zurücksetzen >
</ButtonWithSpinner> Zurücksetzen
<ButtonWithSpinner </ButtonWithSpinner>
loading={props.loading} <ButtonWithSpinner
className={classes.button} loading={props.loading}
onClick={() => props.onSaveClick(answerText)} className={classes.button}
> onClick={() => props.onSaveClick(answerText)}
Speichern >
</ButtonWithSpinner> Speichern
</ButtonWithSpinner>
</div>
</div> </div>
</div>; );
} }

View file

@ -1,19 +1,27 @@
import React from 'react'; import React from "react";
import {render, screen} from '@testing-library/react' import { render, screen } from "@testing-library/react";
import {MockedProvider} from '@apollo/client/testing'; import { MockedProvider } from "@apollo/client/testing";
import {MemoryRouter} from 'react-router-dom'; import { MemoryRouter } from "react-router-dom";
import Main from "./Main"; import Main from "./Main";
import {SnackbarProvider} from "notistack"; import { SnackbarProvider } from "notistack";
function renderMainPage() { function renderMainPage() {
render(<MockedProvider><MemoryRouter><SnackbarProvider><Main/></SnackbarProvider></MemoryRouter></MockedProvider>); render(
<MockedProvider>
<MemoryRouter>
<SnackbarProvider>
<Main />
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
} }
describe('The main page', () => { describe("The main page", () => {
test('displays the editors page if an editor is logged in', () => { test("displays the editors page if an editor is logged in", () => {
const editorToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.kxdxmDrQw0vzD4tiXPj2fu-Cr8n7aWMikxntZ1ObF6c"; const editorToken =
localStorage.setItem("token", editorToken) "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.kxdxmDrQw0vzD4tiXPj2fu-Cr8n7aWMikxntZ1ObF6c";
localStorage.setItem("token", editorToken);
renderMainPage(); renderMainPage();
// it renders question and category lists // it renders question and category lists
@ -23,9 +31,10 @@ describe('The main page', () => {
expect(categoryListHeadline).not.toBeNull(); expect(categoryListHeadline).not.toBeNull();
}); });
test('displays the candidates page if a candidate is logged in', () => { test("displays the candidates page if a candidate is logged in", () => {
const candidateToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfY2FuZGlkYXRlIiwicGVyc29uX3Jvd19pZCI6MiwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.i66MDTPVWwfAvOawY25WE9OPb5CQ9hidoUruP91ngcg"; const candidateToken =
localStorage.setItem("token", candidateToken) "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfY2FuZGlkYXRlIiwicGVyc29uX3Jvd19pZCI6MiwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.i66MDTPVWwfAvOawY25WE9OPb5CQ9hidoUruP91ngcg";
localStorage.setItem("token", candidateToken);
renderMainPage(); renderMainPage();
const questionListHeadline = screen.queryByText(/Fragen/); const questionListHeadline = screen.queryByText(/Fragen/);
@ -34,21 +43,23 @@ describe('The main page', () => {
expect(categoryListHeadline).toBeNull(); expect(categoryListHeadline).toBeNull();
}); });
test('displays the user page if an normal user is logged in', () => { test("displays the user page if an normal user is logged in", () => {
const userToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfcGVyc29uIiwicGVyc29uX3Jvd19pZCI6MywiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.RWo5USCmyn-OYjgYixq0y6qlObU9Rb0KdsxxvrtlW1o"; const userToken =
localStorage.setItem("token", userToken) "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfcGVyc29uIiwicGVyc29uX3Jvd19pZCI6MywiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.RWo5USCmyn-OYjgYixq0y6qlObU9Rb0KdsxxvrtlW1o";
localStorage.setItem("token", userToken);
renderMainPage(); renderMainPage();
const placeholder = screen.queryByText(/nichts zu sehen/); const placeholder = screen.queryByText(/nichts zu sehen/);
expect(placeholder).not.toBeNull(); expect(placeholder).not.toBeNull();
}); });
test('displays a link to the loggin page if something is wrong with the token', () => { test("displays a link to the loggin page if something is wrong with the token", () => {
const invalidToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHOYjgYixq0y6qlObU9Rb0KdsxxvrtlW1o"; const invalidToken =
localStorage.setItem("token", invalidToken) "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHOYjgYixq0y6qlObU9Rb0KdsxxvrtlW1o";
localStorage.setItem("token", invalidToken);
renderMainPage(); renderMainPage();
const placeholder = screen.queryByRole("link", {name: /Login Seite/}); const placeholder = screen.queryByRole("link", { name: /Login Seite/ });
expect(placeholder).not.toBeNull(); expect(placeholder).not.toBeNull();
}); });
}); });

View file

@ -1,24 +1,24 @@
import CustomAppBar from "./CustomAppBar"; import CustomAppBar from "./CustomAppBar";
import React from "react"; import React from "react";
import {makeStyles} from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
import {MainPageEditor} from "./MainPageEditor"; import { MainPageEditor } from "./MainPageEditor";
import {getJsonWebToken} from "../jwt/jwt"; import { getJsonWebToken } from "../jwt/jwt";
import {MainPageCandidate} from "./MainPageCandidate"; import { MainPageCandidate } from "./MainPageCandidate";
import {MainPageUser} from "./MainPageUser"; import { MainPageUser } from "./MainPageUser";
import {Link} from "react-router-dom"; import { Link } from "react-router-dom";
import {Container} from "@material-ui/core"; import { Container } from "@material-ui/core";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
appBarSpacer: theme.mixins.toolbar, appBarSpacer: theme.mixins.toolbar,
content: { content: {
flexGrow: 1, flexGrow: 1,
height: '100vh', height: "100vh",
overflow: 'auto', overflow: "auto",
}, },
invalidTokenContainer: { invalidTokenContainer: {
paddingTop: theme.spacing(4), paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4), paddingBottom: theme.spacing(4),
} },
})); }));
function Main() { function Main() {
@ -28,29 +28,33 @@ function Main() {
if (jwt) { if (jwt) {
switch (jwt.role) { switch (jwt.role) {
case "candymat_editor": case "candymat_editor":
return <MainPageEditor/>; return <MainPageEditor />;
case "candymat_candidate": case "candymat_candidate":
return <MainPageCandidate personRowId={jwt.person_row_id}/>; return <MainPageCandidate personRowId={jwt.person_row_id} />;
case "candymat_person": case "candymat_person":
return <MainPageUser/>; return <MainPageUser />;
} }
} else { } else {
localStorage.removeItem('token'); localStorage.removeItem("token");
return <Container className={classes.invalidTokenContainer}> return (
Du bist nicht eingelogged oder dein Token ist ungültig. Logge dich erneut ein.<br/> <Container className={classes.invalidTokenContainer}>
Zur <Link to={"/login"}>Login Seite</Link> Du bist nicht eingelogged oder dein Token ist ungültig. Logge dich
</Container> erneut ein.
<br />
Zur <Link to={"/login"}>Login Seite</Link>
</Container>
);
} }
} };
return ( return (
<div> <div>
<CustomAppBar/> <CustomAppBar />
<main className={classes.content}> <main className={classes.content}>
<div className={classes.appBarSpacer}/> <div className={classes.appBarSpacer} />
{getMainPage()} {getMainPage()}
</main> </main>
</div> </div>
) );
} }
export default Main; export default Main;

View file

@ -1,24 +1,24 @@
import {Container, Paper, Typography} from "@material-ui/core"; import { Container, Paper, Typography } from "@material-ui/core";
import React from "react"; import React from "react";
import {makeStyles} from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
import {useQuery} from "@apollo/client"; import { useQuery } from "@apollo/client";
import { import {
GET_ALL_QUESTION_ANSWERS, GET_ALL_QUESTION_ANSWERS,
GetAllQuestionAnswersResponse, GetAllQuestionAnswersResponse,
GetAllQuestionAnswersVariables GetAllQuestionAnswersVariables,
} from "../backend/queries/answer"; } from "../backend/queries/answer";
import {getJsonWebToken} from "../jwt/jwt"; import { getJsonWebToken } from "../jwt/jwt";
import AccordionQuestionAnswer from "./AccordionQuestionAnswer"; import AccordionQuestionAnswer from "./AccordionQuestionAnswer";
import {CandidatePositionLegend} from "./CandidatePositionLegend"; import { CandidatePositionLegend } from "./CandidatePositionLegend";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
container: { container: {
paddingTop: theme.spacing(4), paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4), paddingBottom: theme.spacing(4),
flexDirection: 'column', flexDirection: "column",
}, },
root: { root: {
width: '100%', width: "100%",
padding: theme.spacing(1), padding: theme.spacing(1),
marginBottom: theme.spacing(3), marginBottom: theme.spacing(3),
backgroundColor: theme.palette.background.default, backgroundColor: theme.palette.background.default,
@ -26,34 +26,36 @@ const useStyles = makeStyles((theme) => ({
})); }));
interface MainPageCandidateProps { interface MainPageCandidateProps {
personRowId: number, personRowId: number;
} }
export function MainPageCandidate(props: MainPageCandidateProps) { export function MainPageCandidate(props: MainPageCandidateProps) {
const personRowId = getJsonWebToken()?.person_row_id; const personRowId = getJsonWebToken()?.person_row_id;
const questionAnswers = useQuery<GetAllQuestionAnswersResponse, GetAllQuestionAnswersVariables>( const questionAnswers = useQuery<
GET_ALL_QUESTION_ANSWERS, GetAllQuestionAnswersResponse,
{ GetAllQuestionAnswersVariables
variables: { >(GET_ALL_QUESTION_ANSWERS, {
personRowId, variables: {
} personRowId,
} },
).data?.allQuestions.nodes; }).data?.allQuestions.nodes;
const classes = useStyles(); const classes = useStyles();
return ( return (
<Container maxWidth="lg" className={classes.container}> <Container maxWidth="lg" className={classes.container}>
<Paper className={classes.root}> <Paper className={classes.root}>
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>Fragen</Typography> <Typography component={"h2"} variant="h6" color="primary" gutterBottom>
<CandidatePositionLegend/> Fragen
{questionAnswers?.map(question => <AccordionQuestionAnswer </Typography>
<CandidatePositionLegend />
{questionAnswers?.map((question) => (
<AccordionQuestionAnswer
key={question.rowId} key={question.rowId}
personRowId={props.personRowId} personRowId={props.personRowId}
question={question} question={question}
/> />
)} ))}
</Paper> </Paper>
</Container> </Container>
); );
} }

View file

@ -1,15 +1,15 @@
import {Container} from "@material-ui/core"; import { Container } from "@material-ui/core";
import QuestionList from "./QuestionList"; import QuestionList from "./QuestionList";
import CategoryList from "./CategoryList"; import CategoryList from "./CategoryList";
import {Copyright} from "./Copyright"; import { Copyright } from "./Copyright";
import React from "react"; import React from "react";
import {makeStyles} from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
container: { container: {
paddingTop: theme.spacing(4), paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4), paddingBottom: theme.spacing(4),
flexDirection: 'column', flexDirection: "column",
}, },
})); }));
@ -18,9 +18,9 @@ export function MainPageEditor() {
return ( return (
<Container maxWidth="lg" className={classes.container}> <Container maxWidth="lg" className={classes.container}>
<QuestionList/> <QuestionList />
<CategoryList/> <CategoryList />
<Copyright/> <Copyright />
</Container> </Container>
); );
} }

View file

@ -1,12 +1,12 @@
import {Container} from "@material-ui/core"; import { Container } from "@material-ui/core";
import React from "react"; import React from "react";
import {makeStyles} from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
container: { container: {
paddingTop: theme.spacing(4), paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4), paddingBottom: theme.spacing(4),
flexDirection: 'column', flexDirection: "column",
}, },
})); }));

View file

@ -1,49 +1,60 @@
import {Paper, Typography} from "@material-ui/core"; import { Paper, Typography } from "@material-ui/core";
import React from "react"; import React from "react";
import {makeStyles} from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
import {useQuery} from "@apollo/client"; import { useQuery } from "@apollo/client";
import AddCard from "./AddCard"; import AddCard from "./AddCard";
import AccordionWithEdit from "./AccordionWithEdit"; import AccordionWithEdit from "./AccordionWithEdit";
import {BasicQuestionResponse, GET_ALL_QUESTIONS, GetAllQuestionsResponse} from "../backend/queries/question"; import {
import DialogChangeQuestion, {dialogChangeQuestionId, dialogChangeQuestionOpen} from "./DialogChangeQuestion"; BasicQuestionResponse,
GET_ALL_QUESTIONS,
GetAllQuestionsResponse,
} from "../backend/queries/question";
import DialogChangeQuestion, {
dialogChangeQuestionId,
dialogChangeQuestionOpen,
} from "./DialogChangeQuestion";
import DialogDeleteQuestion, { import DialogDeleteQuestion, {
dialogDeleteQuestionId, dialogDeleteQuestionId,
dialogDeleteQuestionOpen, dialogDeleteQuestionOpen,
dialogDeleteQuestionTitle dialogDeleteQuestionTitle,
} from "./DialogDeleteQuestion"; } from "./DialogDeleteQuestion";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
root: { root: {
width: '100%', width: "100%",
padding: theme.spacing(1), padding: theme.spacing(1),
marginBottom: theme.spacing(3), marginBottom: theme.spacing(3),
}, },
})); }));
export default function QuestionList() { export default function QuestionList() {
const questions = useQuery<GetAllQuestionsResponse, null>(GET_ALL_QUESTIONS).data?.allQuestions.nodes; const questions = useQuery<GetAllQuestionsResponse, null>(GET_ALL_QUESTIONS)
.data?.allQuestions.nodes;
const classes = useStyles(); const classes = useStyles();
const handleAddButtonClick = () => { const handleAddButtonClick = () => {
dialogChangeQuestionId("") dialogChangeQuestionId("");
dialogChangeQuestionOpen(true) dialogChangeQuestionOpen(true);
} };
const handleEditButtonClick = (question: BasicQuestionResponse) => { const handleEditButtonClick = (question: BasicQuestionResponse) => {
dialogChangeQuestionId(question.id) dialogChangeQuestionId(question.id);
dialogChangeQuestionOpen(true) dialogChangeQuestionOpen(true);
}; };
const handleDeleteButtonClick = (question: BasicQuestionResponse) => { const handleDeleteButtonClick = (question: BasicQuestionResponse) => {
dialogDeleteQuestionTitle(question.title); dialogDeleteQuestionTitle(question.title);
dialogDeleteQuestionId(question.id); dialogDeleteQuestionId(question.id);
dialogDeleteQuestionOpen(true); dialogDeleteQuestionOpen(true);
} };
return ( return (
<Paper className={classes.root}> <Paper className={classes.root}>
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>Fragen</Typography> <Typography component={"h2"} variant="h6" color="primary" gutterBottom>
{questions?.map(question => <AccordionWithEdit Fragen
</Typography>
{questions?.map((question) => (
<AccordionWithEdit
key={question.id} key={question.id}
title={question.title} title={question.title}
subTitle={question.categoryByCategoryRowId?.title} subTitle={question.categoryByCategoryRowId?.title}
@ -51,11 +62,10 @@ export default function QuestionList() {
onEditButtonClick={() => handleEditButtonClick(question)} onEditButtonClick={() => handleEditButtonClick(question)}
onDeleteButtonClick={() => handleDeleteButtonClick(question)} onDeleteButtonClick={() => handleDeleteButtonClick(question)}
/> />
)} ))}
<AddCard handleClick={handleAddButtonClick}/> <AddCard handleClick={handleAddButtonClick} />
<DialogChangeQuestion/> <DialogChangeQuestion />
<DialogDeleteQuestion/> <DialogDeleteQuestion />
</Paper> </Paper>
) );
} }

View file

@ -1,46 +1,49 @@
import React, {useState} from 'react'; import React, { useState } from "react";
import Avatar from '@material-ui/core/Avatar'; import Avatar from "@material-ui/core/Avatar";
import CssBaseline from '@material-ui/core/CssBaseline'; import CssBaseline from "@material-ui/core/CssBaseline";
import TextField from '@material-ui/core/TextField'; import TextField from "@material-ui/core/TextField";
import FormControlLabel from '@material-ui/core/FormControlLabel'; import FormControlLabel from "@material-ui/core/FormControlLabel";
import Checkbox from '@material-ui/core/Checkbox'; import Checkbox from "@material-ui/core/Checkbox";
import Grid from '@material-ui/core/Grid'; import Grid from "@material-ui/core/Grid";
import Box from '@material-ui/core/Box'; import Box from "@material-ui/core/Box";
import {Alert} from '@material-ui/lab'; import { Alert } from "@material-ui/lab";
import LockOutlinedIcon from '@material-ui/icons/LockOutlined'; import LockOutlinedIcon from "@material-ui/icons/LockOutlined";
import {Link, useHistory, useLocation} from 'react-router-dom'; import { Link, useHistory, useLocation } from "react-router-dom";
import Typography from '@material-ui/core/Typography'; import Typography from "@material-ui/core/Typography";
import {makeStyles} from '@material-ui/core/styles'; import { makeStyles } from "@material-ui/core/styles";
import Container from '@material-ui/core/Container'; import Container from "@material-ui/core/Container";
import {useMutation} from "@apollo/client"; import { useMutation } from "@apollo/client";
import ButtonWithSpinner from "./ButtonWithSpinner"; import ButtonWithSpinner from "./ButtonWithSpinner";
import {Copyright} from "./Copyright"; import { Copyright } from "./Copyright";
import {LOGIN, LoginResponse, LoginVariables} from "../backend/mutations/login"; import {
LOGIN,
LoginResponse,
LoginVariables,
} from "../backend/mutations/login";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
paper: { paper: {
marginTop: theme.spacing(8), marginTop: theme.spacing(8),
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
alignItems: 'center', alignItems: "center",
}, },
avatar: { avatar: {
margin: theme.spacing(1), margin: theme.spacing(1),
backgroundColor: theme.palette.secondary.main, backgroundColor: theme.palette.secondary.main,
}, },
form: { form: {
width: '100%', // Fix IE 11 issue. width: "100%", // Fix IE 11 issue.
marginTop: theme.spacing(1), marginTop: theme.spacing(1),
}, },
submit: { submit: {
margin: theme.spacing(3, 0, 2), margin: theme.spacing(3, 0, 2),
}, },
alert: { alert: {
margin: theme.spacing(1) margin: theme.spacing(1),
} },
})); }));
export default function SignIn() { export default function SignIn() {
const history = useHistory(); const history = useHistory();
const queryParams = new URLSearchParams(useLocation().search); const queryParams = new URLSearchParams(useLocation().search);
@ -48,29 +51,29 @@ export default function SignIn() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [login, {loading}] = useMutation<LoginResponse, LoginVariables>( const [login, { loading }] = useMutation<LoginResponse, LoginVariables>(
LOGIN, LOGIN,
{ {
onCompleted(data) { onCompleted(data) {
if (data.authenticate.jwtToken) { if (data.authenticate.jwtToken) {
localStorage.setItem("token", data.authenticate.jwtToken) localStorage.setItem("token", data.authenticate.jwtToken);
history.replace("/") history.replace("/");
} else { } else {
setError("Wrong username or password.") setError("Wrong username or password.");
} }
}, },
onError(e) { onError(e) {
setError(`Error while trying to log in: ${e.message}`) setError(`Error while trying to log in: ${e.message}`);
} },
} }
); );
return ( return (
<Container component="main" maxWidth="xs"> <Container component="main" maxWidth="xs">
<CssBaseline/> <CssBaseline />
<div className={classes.paper}> <div className={classes.paper}>
<Avatar className={classes.avatar}> <Avatar className={classes.avatar}>
<LockOutlinedIcon/> <LockOutlinedIcon />
</Avatar> </Avatar>
<Typography component="h1" variant="h5"> <Typography component="h1" variant="h5">
Sign in Sign in
@ -78,10 +81,12 @@ export default function SignIn() {
<form <form
className={classes.form} className={classes.form}
noValidate noValidate
onSubmit={event => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
// fixme: logging????? // fixme: logging?????
login({variables: {email: email, password: password}}).catch(error => console.log(error)) login({
variables: { email: email, password: password },
}).catch((error) => console.log(error));
}} }}
> >
<TextField <TextField
@ -118,14 +123,12 @@ export default function SignIn() {
/> />
<FormControlLabel <FormControlLabel
disabled={true} disabled={true}
control={<Checkbox value="remember" color="primary"/>} control={<Checkbox value="remember" color="primary" />}
label="Remember me" label="Remember me"
/> />
<ButtonWithSpinner <ButtonWithSpinner loading={loading} type="submit" fullWidth>
loading={loading} Sign In
type="submit" </ButtonWithSpinner>
fullWidth
>Sign In</ButtonWithSpinner>
<Grid container> <Grid container>
<Grid item xs> <Grid item xs>
{/* todo: see issue #17*/} {/* todo: see issue #17*/}
@ -134,27 +137,32 @@ export default function SignIn() {
{/*</Link>*/} {/*</Link>*/}
</Grid> </Grid>
<Grid item> <Grid item>
<Link to="/signup"> <Link to="/signup">{"Don't have an account? Sign Up"}</Link>
{"Don't have an account? Sign Up"}
</Link>
</Grid> </Grid>
</Grid> </Grid>
{queryParams.get("recent-sign-up-success") {queryParams.get("recent-sign-up-success") ? (
? <Alert className={classes.alert} severity="success" onClose={() => history.push("/login")}> <Alert
className={classes.alert}
severity="success"
onClose={() => history.push("/login")}
>
Sign-Up was successful. Log in to continue Sign-Up was successful. Log in to continue
</Alert> </Alert>
: null} ) : null}
{error ? <Alert className={classes.alert} severity="error" onClose={() => setError("")}>{error}</Alert> : null} {error ? (
<Alert
className={classes.alert}
severity="error"
onClose={() => setError("")}
>
{error}
</Alert>
) : null}
</form> </form>
</div> </div>
<Box mt={8}> <Box mt={8}>
<Copyright/> <Copyright />
</Box> </Box>
</Container> </Container>
); );
} }

View file

@ -1,46 +1,49 @@
import React, {ChangeEvent, useState} from 'react'; import React, { ChangeEvent, useState } from "react";
import Avatar from '@material-ui/core/Avatar'; import Avatar from "@material-ui/core/Avatar";
import CssBaseline from '@material-ui/core/CssBaseline'; import CssBaseline from "@material-ui/core/CssBaseline";
import TextField from '@material-ui/core/TextField'; import TextField from "@material-ui/core/TextField";
import {Link, useHistory} from 'react-router-dom'; import { Link, useHistory } from "react-router-dom";
import Grid from '@material-ui/core/Grid'; import Grid from "@material-ui/core/Grid";
import Box from '@material-ui/core/Box'; import Box from "@material-ui/core/Box";
import LockOutlinedIcon from '@material-ui/icons/LockOutlined'; import LockOutlinedIcon from "@material-ui/icons/LockOutlined";
import Typography from '@material-ui/core/Typography'; import Typography from "@material-ui/core/Typography";
import {makeStyles} from '@material-ui/core/styles'; import { makeStyles } from "@material-ui/core/styles";
import Container from '@material-ui/core/Container'; import Container from "@material-ui/core/Container";
import {Copyright} from "./Copyright"; import { Copyright } from "./Copyright";
import {useMutation} from "@apollo/client"; import { useMutation } from "@apollo/client";
import ButtonWithSpinner from "./ButtonWithSpinner"; import ButtonWithSpinner from "./ButtonWithSpinner";
import {errorHandler, SignUpError} from "./SignUpErrorHandler"; import { errorHandler, SignUpError } from "./SignUpErrorHandler";
import {Alert} from "@material-ui/lab"; import { Alert } from "@material-ui/lab";
import {SIGN_UP, SignUpResponse, SignUpVariables} from "../backend/mutations/signUp"; import {
SIGN_UP,
SignUpResponse,
SignUpVariables,
} from "../backend/mutations/signUp";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
paper: { paper: {
marginTop: theme.spacing(8), marginTop: theme.spacing(8),
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
alignItems: 'center', alignItems: "center",
}, },
avatar: { avatar: {
margin: theme.spacing(1), margin: theme.spacing(1),
backgroundColor: theme.palette.secondary.main, backgroundColor: theme.palette.secondary.main,
}, },
form: { form: {
width: '100%', // Fix IE 11 issue. width: "100%", // Fix IE 11 issue.
marginTop: theme.spacing(3), marginTop: theme.spacing(3),
}, },
submit: { submit: {
margin: theme.spacing(3, 0, 2), margin: theme.spacing(3, 0, 2),
}, },
error: { error: {
color: 'red', color: "red",
}, },
alert: { alert: {
margin: theme.spacing(1) margin: theme.spacing(1),
} },
})); }));
export default function SignUp() { export default function SignUp() {
@ -48,63 +51,70 @@ export default function SignUp() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [firstName, setFirstName] = useState(""); const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState(""); const [lastName, setLastName] = useState("");
const [error, setError] = useState<SignUpError | undefined>(undefined) const [error, setError] = useState<SignUpError | undefined>(undefined);
const history = useHistory(); const history = useHistory();
const [createAccount, {loading}] = useMutation<SignUpResponse, SignUpVariables>( const [createAccount, { loading }] = useMutation<
SIGN_UP, SignUpResponse,
{ SignUpVariables
onCompleted() { >(SIGN_UP, {
history.push("/login?recent-sign-up-success=true") onCompleted() {
}, history.push("/login?recent-sign-up-success=true");
onError(e) { },
console.error(e); onError(e) {
setPassword(""); console.error(e);
setError(errorHandler(e)) setPassword("");
} setError(errorHandler(e));
} },
); });
const classes = useStyles(); const classes = useStyles();
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => { const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
createAccount({variables: {firstName, lastName, email, password}}); createAccount({ variables: { firstName, lastName, email, password } });
} };
const onFirstNameChange = (e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { const onFirstNameChange = (
setFirstName(e.target.value) e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
setFirstName(e.target.value);
if (error?.firstNameInvalid) { if (error?.firstNameInvalid) {
setError(undefined) setError(undefined);
} }
} };
const onLastNameChange = (e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { const onLastNameChange = (
setLastName(e.target.value) e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
setLastName(e.target.value);
if (error?.lastNameInvalid) { if (error?.lastNameInvalid) {
setError(undefined) setError(undefined);
} }
} };
const onEmailChange = (e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { const onEmailChange = (
setEmail(e.target.value) e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
setEmail(e.target.value);
if (error?.emailInvalid) { if (error?.emailInvalid) {
setError(undefined) setError(undefined);
} }
} };
const onPasswordChange = (e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { const onPasswordChange = (
setPassword(e.target.value) e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
setPassword(e.target.value);
if (error?.passwordInvalid) { if (error?.passwordInvalid) {
setError(undefined) setError(undefined);
} }
} };
return ( return (
<Container component="main" maxWidth="xs"> <Container component="main" maxWidth="xs">
<CssBaseline/> <CssBaseline />
<div className={classes.paper}> <div className={classes.paper}>
<Avatar className={classes.avatar}> <Avatar className={classes.avatar}>
<LockOutlinedIcon/> <LockOutlinedIcon />
</Avatar> </Avatar>
<Typography component="h1" variant="h5"> <Typography component="h1" variant="h5">
Sign up Sign up
@ -170,33 +180,28 @@ export default function SignUp() {
/> />
</Grid> </Grid>
</Grid> </Grid>
<ButtonWithSpinner <ButtonWithSpinner loading={loading} type="submit" fullWidth>
loading={loading}
type="submit"
fullWidth
>
Sign Up Sign Up
</ButtonWithSpinner> </ButtonWithSpinner>
<Grid container justify="flex-end"> <Grid container justify="flex-end">
<Grid item> <Grid item>
<Link to="/login"> <Link to="/login">Already have an account? Sign in</Link>
Already have an account? Sign in
</Link>
</Grid> </Grid>
</Grid> </Grid>
{ {error ? (
error <Alert
? <Alert className={classes.alert} severity="error" onClose={() => setError(undefined)}> className={classes.alert}
{error.message} severity="error"
</Alert> onClose={() => setError(undefined)}
: null >
} {error.message}
</Alert>
) : null}
</form> </form>
</div> </div>
<Box mt={5}> <Box mt={5}>
<Copyright/> <Copyright />
</Box> </Box>
</Container> </Container>
); );
} }

View file

@ -1,62 +1,68 @@
import {ApolloError} from "@apollo/client"; import { ApolloError } from "@apollo/client";
export interface SignUpError { export interface SignUpError {
message: string, message: string;
emailInvalid: boolean, emailInvalid: boolean;
firstNameInvalid: boolean, firstNameInvalid: boolean;
lastNameInvalid: boolean, lastNameInvalid: boolean;
passwordInvalid: boolean passwordInvalid: boolean;
} }
const parseErrorMessage = (error: ApolloError): string => { const parseErrorMessage = (error: ApolloError): string => {
let result = "Sign-up failed because of the following reason(s): "; let result = "Sign-up failed because of the following reason(s): ";
if (isEmailAlreadyUsed(error)) { if (isEmailAlreadyUsed(error)) {
result += "The E-Mail is already in use. " result += "The E-Mail is already in use. ";
} }
if (isFirstNameInvalid(error)) { if (isFirstNameInvalid(error)) {
result += "The provided 'First Name' is invalid. " result += "The provided 'First Name' is invalid. ";
} }
if (isLastNameInvalid(error)) { if (isLastNameInvalid(error)) {
result += "The provided 'Last Name' is invalid. " result += "The provided 'Last Name' is invalid. ";
} }
if (isPasswordInvalid(error)) { if (isPasswordInvalid(error)) {
result += "The provided password is invalid. " result += "The provided password is invalid. ";
} }
return result return result;
} };
const isEmailAlreadyUsed = (error: ApolloError): boolean => { const isEmailAlreadyUsed = (error: ApolloError): boolean => {
const errorMessage = error.message.toLowerCase(); const errorMessage = error.message.toLowerCase();
return errorMessage.includes("unique-constraint") && errorMessage.includes("email"); return (
} errorMessage.includes("unique-constraint") && errorMessage.includes("email")
);
};
const isFirstNameInvalid = (error: ApolloError): boolean => { const isFirstNameInvalid = (error: ApolloError): boolean => {
const errorMessage = error.message.toLowerCase(); const errorMessage = error.message.toLowerCase();
return errorMessage.includes("invalid") && errorMessage.includes("first name"); return (
} errorMessage.includes("invalid") && errorMessage.includes("first name")
);
};
const isLastNameInvalid = (error: ApolloError): boolean => { const isLastNameInvalid = (error: ApolloError): boolean => {
const errorMessage = error.message.toLowerCase(); const errorMessage = error.message.toLowerCase();
return errorMessage.includes("invalid") && errorMessage.includes("last name"); return errorMessage.includes("invalid") && errorMessage.includes("last name");
} };
const isPasswordInvalid = (error: ApolloError): boolean => { const isPasswordInvalid = (error: ApolloError): boolean => {
const errorMessage = error.message.toLowerCase(); const errorMessage = error.message.toLowerCase();
return errorMessage.includes("invalid") && errorMessage.includes("password"); return errorMessage.includes("invalid") && errorMessage.includes("password");
} };
export const errorHandler = (error: undefined | ApolloError): undefined | SignUpError => {
return error ? {
message: parseErrorMessage(error),
emailInvalid: isEmailAlreadyUsed(error),
firstNameInvalid: isFirstNameInvalid(error),
lastNameInvalid: isLastNameInvalid(error),
passwordInvalid: isPasswordInvalid(error)
} : undefined
}
export const errorHandler = (
error: undefined | ApolloError
): undefined | SignUpError => {
return error
? {
message: parseErrorMessage(error),
emailInvalid: isEmailAlreadyUsed(error),
firstNameInvalid: isFirstNameInvalid(error),
lastNameInvalid: isLastNameInvalid(error),
passwordInvalid: isPasswordInvalid(error),
}
: undefined;
};

View file

@ -1,8 +1,12 @@
import {createStyles, makeStyles, Theme} from "@material-ui/core/styles"; import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import React from "react"; import React from "react";
import {FormLabel} from "@material-ui/core"; import { FormLabel } from "@material-ui/core";
import {ToggleButton, ToggleButtonGroup} from "@material-ui/lab"; import { ToggleButton, ToggleButtonGroup } from "@material-ui/lab";
import {allPositions, CandidatePosition, getIconForPosition} from "./CandidatePositionLegend"; import {
allPositions,
CandidatePosition,
getIconForPosition,
} from "./CandidatePositionLegend";
const useStyles = makeStyles((theme: Theme) => const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
@ -10,17 +14,22 @@ const useStyles = makeStyles((theme: Theme) =>
marginTop: theme.spacing(2), marginTop: theme.spacing(2),
marginBottom: theme.spacing(2), marginBottom: theme.spacing(2),
}, },
}), })
); );
interface ToggleButtonGroupAnswerPositionProps { interface ToggleButtonGroupAnswerPositionProps {
position: CandidatePosition, position: CandidatePosition;
loading?: boolean, loading?: boolean;
onPositionChange(e: React.MouseEvent<HTMLElement>, newPosition: CandidatePosition): void, onPositionChange(
e: React.MouseEvent<HTMLElement>,
newPosition: CandidatePosition
): void;
} }
export default function ToggleButtonGroupAnswerPosition(props: ToggleButtonGroupAnswerPositionProps) { export default function ToggleButtonGroupAnswerPosition(
props: ToggleButtonGroupAnswerPositionProps
) {
const classes = useStyles(); const classes = useStyles();
return ( return (
@ -31,14 +40,19 @@ export default function ToggleButtonGroupAnswerPosition(props: ToggleButtonGroup
exclusive exclusive
onChange={props.onPositionChange} onChange={props.onPositionChange}
> >
{allPositions.map(position => (<ToggleButton {allPositions.map((position) => (
disabled={props.loading} <ToggleButton
key={position} disabled={props.loading}
value={position} key={position}
aria-label={position.toString()} value={position}
> aria-label={position.toString()}
{getIconForPosition(position, {color: "primary", fontSize: "small"})} >
</ToggleButton>))} {getIconForPosition(position, {
color: "primary",
fontSize: "small",
})}
</ToggleButton>
))}
</ToggleButtonGroup> </ToggleButtonGroup>
</div> </div>
); );

View file

@ -1,13 +1,13 @@
body { body {
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif; sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
code { code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace; monospace;
} }

View file

@ -1,22 +1,22 @@
import React from 'react'; import React from "react";
import ReactDOM from 'react-dom'; import ReactDOM from "react-dom";
import './index.css'; import "./index.css";
import App from './App'; import App from "./App";
import * as serviceWorker from './serviceWorker'; import * as serviceWorker from "./serviceWorker";
import {ApolloProvider} from "@apollo/client"; import { ApolloProvider } from "@apollo/client";
import {client} from "./backend/helper"; import { client } from "./backend/helper";
import {BrowserRouter as Router} from "react-router-dom"; import { BrowserRouter as Router } from "react-router-dom";
import {SnackbarProvider} from "notistack"; import { SnackbarProvider } from "notistack";
ReactDOM.render( ReactDOM.render(
<ApolloProvider client={client}> <ApolloProvider client={client}>
<Router> <Router>
<SnackbarProvider maxSnack={3}> <SnackbarProvider maxSnack={3}>
<App/> <App />
</SnackbarProvider> </SnackbarProvider>
</Router> </Router>
</ApolloProvider>, </ApolloProvider>,
document.getElementById('root') document.getElementById("root")
); );
// If you want your app to work offline and load faster, you can change // If you want your app to work offline and load faster, you can change

View file

@ -1,73 +1,75 @@
import React from 'react'; import React from "react";
import {render, screen} from '@testing-library/react' import { render, screen } from "@testing-library/react";
import {MockedProvider} from '@apollo/client/testing'; import { MockedProvider } from "@apollo/client/testing";
import {MemoryRouter} from 'react-router-dom'; import { MemoryRouter } from "react-router-dom";
import App from "../App"; import App from "../App";
import {SnackbarProvider} from "notistack"; import { SnackbarProvider } from "notistack";
const renderAppAtUrl = (path: string) => render( const renderAppAtUrl = (path: string) =>
<MockedProvider> render(
<MemoryRouter initialEntries={[path]}> <MockedProvider>
<SnackbarProvider> <MemoryRouter initialEntries={[path]}>
<App/> <SnackbarProvider>
</SnackbarProvider> <App />
</MemoryRouter> </SnackbarProvider>
</MockedProvider> </MemoryRouter>
); </MockedProvider>
);
beforeEach(() => localStorage.clear()) beforeEach(() => localStorage.clear());
describe('The root path /', () => { describe("The root path /", () => {
test("renders user's home page if they are logged in", () => {
test('renders user\'s home page if they are logged in', () => { localStorage.setItem("token", "asdfasdfasdf");
localStorage.setItem("token", "asdfasdfasdf")
renderAppAtUrl("/"); renderAppAtUrl("/");
expect(() => screen.getByLabelText(/current user/)).not.toThrow() expect(() => screen.getByLabelText(/current user/)).not.toThrow();
}); });
test('redirects to login page if user not logged in', () => { test("redirects to login page if user not logged in", () => {
renderAppAtUrl("/"); renderAppAtUrl("/");
const emailField = screen.getByRole('textbox', {name: 'Email Address'}); const emailField = screen.getByRole("textbox", { name: "Email Address" });
const passwordField = screen.getByLabelText(/Password/); const passwordField = screen.getByLabelText(/Password/);
expect(emailField).toHaveValue(""); expect(emailField).toHaveValue("");
expect(passwordField).toHaveValue(""); expect(passwordField).toHaveValue("");
}); });
}); });
describe('The /login path', () => { describe("The /login path", () => {
test('renders the signin page if the user is not logged in', () => { test("renders the signin page if the user is not logged in", () => {
renderAppAtUrl("/login"); renderAppAtUrl("/login");
const emailField = screen.getByRole('textbox', {name: 'Email Address'}); const emailField = screen.getByRole("textbox", { name: "Email Address" });
const passwordField = screen.getByLabelText(/Password/); const passwordField = screen.getByLabelText(/Password/);
expect(emailField).toHaveValue(""); expect(emailField).toHaveValue("");
expect(passwordField).toHaveValue(""); expect(passwordField).toHaveValue("");
}); });
test('redirects to root / and the user\'s home page if the user is logged in', () => { test("redirects to root / and the user's home page if the user is logged in", () => {
localStorage.setItem("token", "asdfasdfasdf") localStorage.setItem("token", "asdfasdfasdf");
renderAppAtUrl("/login"); renderAppAtUrl("/login");
expect(() => screen.getByLabelText(/current user/)).not.toThrow() expect(() => screen.getByLabelText(/current user/)).not.toThrow();
}); });
}); });
describe('The /signup path', () => { describe("The /signup path", () => {
test('renders the signup page if the user is not logged in', () => { test("renders the signup page if the user is not logged in", () => {
renderAppAtUrl("/signup"); renderAppAtUrl("/signup");
expect(() => screen.getByRole('textbox', {name: 'Email Address'})).not.toThrow() expect(() =>
expect(() => screen.getByLabelText(/Password/)).not.toThrow() screen.getByRole("textbox", { name: "Email Address" })
expect(() => screen.getByLabelText(/First Name/)).not.toThrow() ).not.toThrow();
expect(() => screen.getByLabelText(/Last Name/)).not.toThrow() expect(() => screen.getByLabelText(/Password/)).not.toThrow();
expect(() => screen.getByLabelText(/First Name/)).not.toThrow();
expect(() => screen.getByLabelText(/Last Name/)).not.toThrow();
}); });
test('redirects to root / and the user\'s home page if the user is logged in', () => { test("redirects to root / and the user's home page if the user is logged in", () => {
localStorage.setItem("token", "asdfasdfasdf") localStorage.setItem("token", "asdfasdfasdf");
renderAppAtUrl("/signup"); renderAppAtUrl("/signup");
expect(() => screen.getByLabelText(/current user/)).not.toThrow() expect(() => screen.getByLabelText(/current user/)).not.toThrow();
}); });
}); });

View file

@ -1,71 +1,86 @@
import React from 'react'; import React from "react";
import {fireEvent, render, screen, waitFor} from '@testing-library/react' import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {MockedProvider, MockedResponse} from '@apollo/client/testing'; import { MockedProvider, MockedResponse } from "@apollo/client/testing";
import {MemoryRouter} from 'react-router-dom'; import { MemoryRouter } from "react-router-dom";
import CategoryList from "../components/CategoryList"; import CategoryList from "../components/CategoryList";
import {SnackbarProvider} from "notistack"; import { SnackbarProvider } from "notistack";
import {categoryNodesMock, getAllCategoriesMock, getCategoryByIdMock} from "../backend/queries/category.mock"; import {
import {addCategoryMock, deleteCategoryMock, editCategoryMock} from "../backend/mutations/category.mock"; categoryNodesMock,
import {expandAccordionAndGetIconButtons, queryAllAddIconButtons, queryAllEditIconButtons} from "./test-helper"; getAllCategoriesMock,
getCategoryByIdMock,
} from "../backend/queries/category.mock";
import {
addCategoryMock,
deleteCategoryMock,
editCategoryMock,
} from "../backend/mutations/category.mock";
import {
expandAccordionAndGetIconButtons,
queryAllAddIconButtons,
queryAllEditIconButtons,
} from "./test-helper";
describe("The CategoryList", () => {
describe('The CategoryList', () => { test("displays the existing categories, but not the details of it", async () => {
test('displays the existing categories, but not the details of it', async () => {
renderCategoryList(); renderCategoryList();
const categoryCards = await waitForInitialCategoriesToRender() const categoryCards = await waitForInitialCategoriesToRender();
categoryCards.forEach(card => { categoryCards.forEach((card) => {
expect(card.innerHTML).toMatch(/Category [1-2]/) expect(card.innerHTML).toMatch(/Category [1-2]/);
}) });
expect(queryAllEditIconButtons()).toHaveLength(0) expect(queryAllEditIconButtons()).toHaveLength(0);
}); });
test('enables toggling details on each category', async () => { test("enables toggling details on each category", async () => {
renderCategoryList(); renderCategoryList();
// Initial state: Every category card is not expanded // Initial state: Every category card is not expanded
const categoryCards = await waitForInitialCategoriesToRender() const categoryCards = await waitForInitialCategoriesToRender();
// Expand first category card // Expand first category card
await expandAccordionAndGetIconButtons(categoryCards[0]) await expandAccordionAndGetIconButtons(categoryCards[0]);
// Shrink first category card again // Shrink first category card again
fireEvent.click(categoryCards[0]) fireEvent.click(categoryCards[0]);
await waitFor(() => { await waitFor(() => {
expect(queryAllEditIconButtons()).toHaveLength(0) expect(queryAllEditIconButtons()).toHaveLength(0);
}); });
}); });
test('enables editing a category title', async () => { test("enables editing a category title", async () => {
renderCategoryList(editCategoryMock); renderCategoryList(editCategoryMock);
const categoryCards = await waitForInitialCategoriesToRender(); const categoryCards = await waitForInitialCategoriesToRender();
const {editIconButton} = await expandAccordionAndGetIconButtons(categoryCards[0]); const { editIconButton } = await expandAccordionAndGetIconButtons(
categoryCards[0]
);
// open edit dialog // open edit dialog
expect(screen.queryByText(/Kategorie bearbeiten/)).toBeNull(); expect(screen.queryByText(/Kategorie bearbeiten/)).toBeNull();
fireEvent.click(editIconButton); fireEvent.click(editIconButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(/Kategorie bearbeiten/)).not.toBeNull(); expect(screen.queryByText(/Kategorie bearbeiten/)).not.toBeNull();
}) });
// change category title // change category title
const categoryTitleField = screen.getByDisplayValue(/Category 1/); const categoryTitleField = screen.getByDisplayValue(/Category 1/);
fireEvent.change(categoryTitleField, {target: {value: "New title for Category 1"}}); fireEvent.change(categoryTitleField, {
target: { value: "New title for Category 1" },
});
await waitFor(() => { await waitFor(() => {
expect(screen.queryByDisplayValue(/New title for /)).not.toBeNull(); expect(screen.queryByDisplayValue(/New title for /)).not.toBeNull();
}) });
// call backend and assert apollo cache update // call backend and assert apollo cache update
const confirmButton = screen.getByRole("button", {name: /Speichern/}); const confirmButton = screen.getByRole("button", { name: /Speichern/ });
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(/Kategorie bearbeiten/)).toBeNull(); expect(screen.queryByText(/Kategorie bearbeiten/)).toBeNull();
expect(screen.queryByText(/New title for Category 1/)).not.toBeNull() expect(screen.queryByText(/New title for Category 1/)).not.toBeNull();
}) });
}); });
test('enables adding a category', async () => { test("enables adding a category", async () => {
renderCategoryList(addCategoryMock); renderCategoryList(addCategoryMock);
await waitForInitialCategoriesToRender(); await waitForInitialCategoriesToRender();
@ -76,69 +91,74 @@ describe('The CategoryList', () => {
fireEvent.click(addButton); fireEvent.click(addButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(dialogIdentifier)).not.toBeNull(); expect(screen.queryByText(dialogIdentifier)).not.toBeNull();
}) });
// change category title // change category title
const categoryTitleField = screen.getByLabelText(/Zusammenfassung/); const categoryTitleField = screen.getByLabelText(/Zusammenfassung/);
fireEvent.change(categoryTitleField, {target: {value: "New category"}}); fireEvent.change(categoryTitleField, { target: { value: "New category" } });
await waitFor(() => { await waitFor(() => {
expect(screen.queryByDisplayValue(/New category/)).not.toBeNull(); expect(screen.queryByDisplayValue(/New category/)).not.toBeNull();
}) });
// call backend and assert apollo cache update // call backend and assert apollo cache update
const confirmButton = screen.getByRole("button", {name: /Erstellen/}); const confirmButton = screen.getByRole("button", { name: /Erstellen/ });
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(dialogIdentifier)).toBeNull(); expect(screen.queryByText(dialogIdentifier)).toBeNull();
expect(screen.queryByText(/New category/)).not.toBeNull() expect(screen.queryByText(/New category/)).not.toBeNull();
}) });
}); });
test('enables deleting a category', async () => { test("enables deleting a category", async () => {
renderCategoryList(deleteCategoryMock); renderCategoryList(deleteCategoryMock);
const categoryCards = await waitForInitialCategoriesToRender(); const categoryCards = await waitForInitialCategoriesToRender();
expect(screen.queryByText(/Category 2/)).not.toBeNull(); expect(screen.queryByText(/Category 2/)).not.toBeNull();
const {deleteIconButton} = await expandAccordionAndGetIconButtons(categoryCards[1]); const { deleteIconButton } = await expandAccordionAndGetIconButtons(
categoryCards[1]
);
// open delete confirmation dialog // open delete confirmation dialog
expect(screen.queryByText(/Kategorie löschen/)).toBeNull(); expect(screen.queryByText(/Kategorie löschen/)).toBeNull();
fireEvent.click(deleteIconButton); fireEvent.click(deleteIconButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(/Kategorie löschen/)).not.toBeNull(); expect(screen.queryByText(/Kategorie löschen/)).not.toBeNull();
}) });
// call backend and assert apollo cache update // call backend and assert apollo cache update
const confirmButton = screen.getByRole("button", {name: /Löschen/}); const confirmButton = screen.getByRole("button", { name: /Löschen/ });
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(/Kategorie löschen/)).toBeNull(); expect(screen.queryByText(/Kategorie löschen/)).toBeNull();
expect(screen.queryByText(/Category 2/)).toBeNull(); expect(screen.queryByText(/Category 2/)).toBeNull();
}) });
}); });
}); });
function renderCategoryList(additionalMocks?: Array<MockedResponse>) { function renderCategoryList(additionalMocks?: Array<MockedResponse>) {
const initialMocks = [...getAllCategoriesMock, ...getCategoryByIdMock]; const initialMocks = [...getAllCategoriesMock, ...getCategoryByIdMock];
const allMocks = additionalMocks ? [...initialMocks, ...additionalMocks] : initialMocks const allMocks = additionalMocks
? [...initialMocks, ...additionalMocks]
: initialMocks;
return render( return render(
<MockedProvider mocks={allMocks}> <MockedProvider mocks={allMocks}>
<MemoryRouter> <MemoryRouter>
<SnackbarProvider> <SnackbarProvider>
<CategoryList/> <CategoryList />
</SnackbarProvider> </SnackbarProvider>
</MemoryRouter> </MemoryRouter>
</MockedProvider> </MockedProvider>
); );
} }
const waitForInitialCategoriesToRender = async (): Promise<Array<HTMLElement>> => { const waitForInitialCategoriesToRender = async (): Promise<
Array<HTMLElement>
> => {
const numberOfCategoriesInMockQuery = categoryNodesMock.length; const numberOfCategoriesInMockQuery = categoryNodesMock.length;
let categoryCards: Array<HTMLElement> = []; let categoryCards: Array<HTMLElement> = [];
await waitFor(() => { await waitFor(() => {
categoryCards = screen.queryAllByRole("button", {name: /Category [1-2]/}) categoryCards = screen.queryAllByRole("button", { name: /Category [1-2]/ });
expect(categoryCards.length).toEqual(numberOfCategoriesInMockQuery); expect(categoryCards.length).toEqual(numberOfCategoriesInMockQuery);
}); });
return categoryCards; return categoryCards;
} };

View file

@ -1,77 +1,88 @@
import React from 'react'; import React from "react";
import {fireEvent, render, screen, waitFor} from '@testing-library/react' import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {MockedProvider, MockedResponse} from '@apollo/client/testing'; import { MockedProvider, MockedResponse } from "@apollo/client/testing";
import {MemoryRouter} from 'react-router-dom'; import { MemoryRouter } from "react-router-dom";
import QuestionList from "../components/QuestionList"; import QuestionList from "../components/QuestionList";
import {SnackbarProvider} from "notistack"; import { SnackbarProvider } from "notistack";
import {getAllQuestionsMock, getQuestionByIdMock, questionNodesMock} from "../backend/queries/question.mock"; import {
import {getAllCategoriesMock} from "../backend/queries/category.mock"; getAllQuestionsMock,
import {addQuestionMock, deleteQuestionMock, editQuestionMock} from "../backend/mutations/question.mock"; getQuestionByIdMock,
questionNodesMock,
} from "../backend/queries/question.mock";
import { getAllCategoriesMock } from "../backend/queries/category.mock";
import {
addQuestionMock,
deleteQuestionMock,
editQuestionMock,
} from "../backend/mutations/question.mock";
import { import {
expandAccordionAndGetIconButtons, expandAccordionAndGetIconButtons,
queryAllAddIconButtons, queryAllAddIconButtons,
queryAllEditIconButtons queryAllEditIconButtons,
} from "./test-helper"; } from "./test-helper";
describe("The QuestionList", () => {
describe('The QuestionList', () => { test("displays the existing questions, but not the details of it", async () => {
test('displays the existing questions, but not the details of it', async () => {
renderQuestionList(); renderQuestionList();
const questionCards = await waitForInitialQuestionsToRender() const questionCards = await waitForInitialQuestionsToRender();
questionCards.forEach(card => { questionCards.forEach((card) => {
expect(card.innerHTML).toMatch(/Question [1-3]\?/) expect(card.innerHTML).toMatch(/Question [1-3]\?/);
}) });
expect(questionCards[0].innerHTML).toMatch(/Category 1/); expect(questionCards[0].innerHTML).toMatch(/Category 1/);
expect(queryAllEditIconButtons()).toHaveLength(0) expect(queryAllEditIconButtons()).toHaveLength(0);
}); });
test('enables toggling details on each question', async () => { test("enables toggling details on each question", async () => {
renderQuestionList(); renderQuestionList();
// Initial state: Every question card is not expanded // Initial state: Every question card is not expanded
const questionCards = await waitForInitialQuestionsToRender() const questionCards = await waitForInitialQuestionsToRender();
// Expand first question card // Expand first question card
await expandAccordionAndGetIconButtons(questionCards[0]) await expandAccordionAndGetIconButtons(questionCards[0]);
// Shrink first question card again // Shrink first question card again
fireEvent.click(questionCards[0]) fireEvent.click(questionCards[0]);
await waitFor(() => { await waitFor(() => {
expect(queryAllEditIconButtons()).toHaveLength(0) expect(queryAllEditIconButtons()).toHaveLength(0);
}); });
}); });
test('enables editing a question title', async () => { test("enables editing a question title", async () => {
renderQuestionList(editQuestionMock); renderQuestionList(editQuestionMock);
const questionCards = await waitForInitialQuestionsToRender(); const questionCards = await waitForInitialQuestionsToRender();
const {editIconButton} = await expandAccordionAndGetIconButtons(questionCards[0]); const { editIconButton } = await expandAccordionAndGetIconButtons(
questionCards[0]
);
// open edit dialog // open edit dialog
expect(screen.queryByText(/Frage bearbeiten/)).toBeNull(); expect(screen.queryByText(/Frage bearbeiten/)).toBeNull();
fireEvent.click(editIconButton); fireEvent.click(editIconButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(/Frage bearbeiten/)).not.toBeNull(); expect(screen.queryByText(/Frage bearbeiten/)).not.toBeNull();
}) });
// change question title // change question title
const questionTitleField = screen.getByDisplayValue(/Question 1/); const questionTitleField = screen.getByDisplayValue(/Question 1/);
fireEvent.change(questionTitleField, {target: {value: "New title for Question 1?"}}); fireEvent.change(questionTitleField, {
target: { value: "New title for Question 1?" },
});
await waitFor(() => { await waitFor(() => {
expect(screen.queryByDisplayValue(/New title for /)).not.toBeNull(); expect(screen.queryByDisplayValue(/New title for /)).not.toBeNull();
}) });
// call backend and assert apollo cache update // call backend and assert apollo cache update
const confirmButton = screen.getByRole("button", {name: /Speichern/}); const confirmButton = screen.getByRole("button", { name: /Speichern/ });
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(/Frage bearbeiten/)).toBeNull(); expect(screen.queryByText(/Frage bearbeiten/)).toBeNull();
expect(screen.queryByText(/New title for Question 1/)).not.toBeNull() expect(screen.queryByText(/New title for Question 1/)).not.toBeNull();
}) });
}); });
test('enables adding a question', async () => { test("enables adding a question", async () => {
renderQuestionList(addQuestionMock); renderQuestionList(addQuestionMock);
await waitForInitialQuestionsToRender(); await waitForInitialQuestionsToRender();
@ -82,68 +93,82 @@ describe('The QuestionList', () => {
fireEvent.click(addButton); fireEvent.click(addButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(dialogIdentifier)).not.toBeNull(); expect(screen.queryByText(dialogIdentifier)).not.toBeNull();
}) });
// change question title // change question title
const questionTitleField = screen.getByLabelText(/Zusammenfassung/); const questionTitleField = screen.getByLabelText(/Zusammenfassung/);
fireEvent.change(questionTitleField, {target: {value: "New question?"}}); fireEvent.change(questionTitleField, {
target: { value: "New question?" },
});
await waitFor(() => { await waitFor(() => {
expect(screen.queryByDisplayValue(/New question/)).not.toBeNull(); expect(screen.queryByDisplayValue(/New question/)).not.toBeNull();
}) });
// call backend and assert apollo cache update // call backend and assert apollo cache update
const confirmButton = screen.getByRole("button", {name: /Erstellen/}); const confirmButton = screen.getByRole("button", { name: /Erstellen/ });
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(dialogIdentifier)).toBeNull(); expect(screen.queryByText(dialogIdentifier)).toBeNull();
expect(screen.queryByText(/New question/)).not.toBeNull() expect(screen.queryByText(/New question/)).not.toBeNull();
}) });
}); });
test('enables deleting a question', async () => { test("enables deleting a question", async () => {
renderQuestionList(deleteQuestionMock); renderQuestionList(deleteQuestionMock);
const questionCards = await waitForInitialQuestionsToRender(); const questionCards = await waitForInitialQuestionsToRender();
expect(screen.queryByText(/Question 2/)).not.toBeNull(); expect(screen.queryByText(/Question 2/)).not.toBeNull();
const {deleteIconButton} = await expandAccordionAndGetIconButtons(questionCards[1]); const { deleteIconButton } = await expandAccordionAndGetIconButtons(
questionCards[1]
);
// open delete confirmation dialog // open delete confirmation dialog
expect(screen.queryByText(/Frage löschen/)).toBeNull(); expect(screen.queryByText(/Frage löschen/)).toBeNull();
fireEvent.click(deleteIconButton); fireEvent.click(deleteIconButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(/Frage löschen/)).not.toBeNull(); expect(screen.queryByText(/Frage löschen/)).not.toBeNull();
}) });
// call backend and assert apollo cache update // call backend and assert apollo cache update
const confirmButton = screen.getByRole("button", {name: /Löschen/}); const confirmButton = screen.getByRole("button", { name: /Löschen/ });
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText(/Frage löschen/)).toBeNull(); expect(screen.queryByText(/Frage löschen/)).toBeNull();
expect(screen.queryByText(/Question 2/)).toBeNull(); expect(screen.queryByText(/Question 2/)).toBeNull();
}) });
}); });
}); });
function renderQuestionList(additionalMocks?: Array<MockedResponse>) { function renderQuestionList(additionalMocks?: Array<MockedResponse>) {
const initialMocks = [...getAllQuestionsMock, ...getQuestionByIdMock, ...getAllCategoriesMock]; const initialMocks = [
const allMocks = additionalMocks ? [...initialMocks, ...additionalMocks] : initialMocks ...getAllQuestionsMock,
...getQuestionByIdMock,
...getAllCategoriesMock,
];
const allMocks = additionalMocks
? [...initialMocks, ...additionalMocks]
: initialMocks;
return render( return render(
<MockedProvider mocks={allMocks}> <MockedProvider mocks={allMocks}>
<MemoryRouter> <MemoryRouter>
<SnackbarProvider> <SnackbarProvider>
<QuestionList/> <QuestionList />
</SnackbarProvider> </SnackbarProvider>
</MemoryRouter> </MemoryRouter>
</MockedProvider> </MockedProvider>
); );
} }
const waitForInitialQuestionsToRender = async (): Promise<Array<HTMLElement>> => { const waitForInitialQuestionsToRender = async (): Promise<
Array<HTMLElement>
> => {
const numberOfQuestionsInMockQuery = questionNodesMock.length; const numberOfQuestionsInMockQuery = questionNodesMock.length;
let questionCards: Array<HTMLElement> = []; let questionCards: Array<HTMLElement> = [];
await waitFor(() => { await waitFor(() => {
questionCards = screen.queryAllByRole("button", {name: /Question [1-3]\?/}) questionCards = screen.queryAllByRole("button", {
name: /Question [1-3]\?/,
});
expect(questionCards.length).toEqual(numberOfQuestionsInMockQuery); expect(questionCards.length).toEqual(numberOfQuestionsInMockQuery);
}); });
return questionCards; return questionCards;
} };

View file

@ -1,48 +1,60 @@
import React from 'react'; import React from "react";
import SignIn from "../components/SignIn"; import SignIn from "../components/SignIn";
import {fireEvent, render, screen, waitFor} from '@testing-library/react' import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {MockedProvider} from '@apollo/client/testing'; import { MockedProvider } from "@apollo/client/testing";
import {MemoryRouter} from 'react-router-dom'; import { MemoryRouter } from "react-router-dom";
import {loginMock} from "../backend/mutations/login.mock"; import { loginMock } from "../backend/mutations/login.mock";
const mockHistoryReplace = jest.fn(); const mockHistoryReplace = jest.fn();
jest.mock('react-router-dom', () => ({ jest.mock("react-router-dom", () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual("react-router-dom"),
useHistory: () => ({ useHistory: () => ({
replace: mockHistoryReplace, replace: mockHistoryReplace,
}), }),
})); }));
describe('SignIn page', () => { describe("SignIn page", () => {
beforeEach(() => mockHistoryReplace.mockReset()) beforeEach(() => mockHistoryReplace.mockReset());
test('initial state', () => { test("initial state", () => {
render(<MockedProvider mocks={loginMock}><MemoryRouter><SignIn/></MemoryRouter></MockedProvider>); render(
<MockedProvider mocks={loginMock}>
<MemoryRouter>
<SignIn />
</MemoryRouter>
</MockedProvider>
);
// it renders empty email and passsword fields // it renders empty email and passsword fields
const emailField = screen.getByRole('textbox', {name: 'Email Address'}); const emailField = screen.getByRole("textbox", { name: "Email Address" });
expect(emailField).toHaveValue(''); expect(emailField).toHaveValue("");
const passwordField = screen.getByLabelText(/Password/); const passwordField = screen.getByLabelText(/Password/);
expect(passwordField).toHaveValue(''); expect(passwordField).toHaveValue("");
// it renders enabled submit button // it renders enabled submit button
const button = screen.getByRole('button'); const button = screen.getByRole("button");
expect(button).not.toBeDisabled(); expect(button).not.toBeDisabled();
expect(button).toHaveTextContent('Sign In'); expect(button).toHaveTextContent("Sign In");
expect(mockHistoryReplace).not.toHaveBeenCalled(); expect(mockHistoryReplace).not.toHaveBeenCalled();
}); });
test('successful login', async () => { test("successful login", async () => {
render(<MockedProvider mocks={loginMock}><MemoryRouter><SignIn/></MemoryRouter></MockedProvider>); render(
<MockedProvider mocks={loginMock}>
<MemoryRouter>
<SignIn />
</MemoryRouter>
</MockedProvider>
);
const emailField = screen.getByRole('textbox', {name: 'Email Address'}); const emailField = screen.getByRole("textbox", { name: "Email Address" });
const passwordField = screen.getByLabelText(/Password/); const passwordField = screen.getByLabelText(/Password/);
const button = screen.getByRole('button', {name: /sign in/i}); const button = screen.getByRole("button", { name: /sign in/i });
// fill out and submit form // fill out and submit form
fireEvent.change(emailField, {target: {value: 'test@email.com'}}); fireEvent.change(emailField, { target: { value: "test@email.com" } });
fireEvent.change(passwordField, {target: {value: 'password'}}); fireEvent.change(passwordField, { target: { value: "password" } });
fireEvent.click(button); fireEvent.click(button);
@ -51,22 +63,28 @@ describe('SignIn page', () => {
}); });
}); });
test('error login', async () => { test("error login", async () => {
render(<MockedProvider mocks={loginMock}><MemoryRouter><SignIn/></MemoryRouter></MockedProvider>); render(
<MockedProvider mocks={loginMock}>
<MemoryRouter>
<SignIn />
</MemoryRouter>
</MockedProvider>
);
const emailField = screen.getByRole('textbox', {name: 'Email Address'}); const emailField = screen.getByRole("textbox", { name: "Email Address" });
const passwordField = screen.getByLabelText(/Password/); const passwordField = screen.getByLabelText(/Password/);
const button = screen.getByRole('button'); const button = screen.getByRole("button");
// fill out and submit form // fill out and submit form
fireEvent.change(emailField, {target: {value: 'test@email.com'}}); fireEvent.change(emailField, { target: { value: "test@email.com" } });
fireEvent.change(passwordField, {target: {value: 'wrong-password'}}); fireEvent.change(passwordField, { target: { value: "wrong-password" } });
fireEvent.click(button); fireEvent.click(button);
await waitFor(() => { await waitFor(() => {
// it resets button // it resets button
expect(button).not.toBeDisabled(); expect(button).not.toBeDisabled();
expect(button).toHaveTextContent('Sign In'); expect(button).toHaveTextContent("Sign In");
// it displays error text // it displays error text
const errorText = screen.getByText(/Wrong username or password/); const errorText = screen.getByText(/Wrong username or password/);

View file

@ -1,50 +1,81 @@
import React from 'react'; import React from "react";
import {fireEvent, queryAllByRole, render, screen, waitFor} from '@testing-library/react' import {
import EditIcon from '@material-ui/icons/Edit'; fireEvent,
import DeleteIcon from '@material-ui/icons/Delete'; queryAllByRole,
import AddIcon from '@material-ui/icons/Add'; render,
screen,
waitFor,
} from "@testing-library/react";
import EditIcon from "@material-ui/icons/Edit";
import DeleteIcon from "@material-ui/icons/Delete";
import AddIcon from "@material-ui/icons/Add";
const memoizedGetIconPath = (icon: JSX.Element) => { const memoizedGetIconPath = (icon: JSX.Element) => {
let cache: { path?: string } = {}; let cache: { path?: string } = {};
return (): string => { return (): string => {
if (cache?.path) { if (cache?.path) {
return cache.path return cache.path;
} else { } else {
const {container} = render(icon) const { container } = render(icon);
const path = container.innerHTML.match(/<path d="(.*)">/)?.[1] const path = container.innerHTML.match(/<path d="(.*)">/)?.[1];
if (!path) { if (!path) {
throw `Could not get path of MUI ${icon.type.displayName}` throw `Could not get path of MUI ${icon.type.displayName}`;
} }
cache.path = path cache.path = path;
return path return path;
} }
} };
} };
const getEditIconPath = memoizedGetIconPath(<EditIcon/>) const getEditIconPath = memoizedGetIconPath(<EditIcon />);
const getDeleteIconPath = memoizedGetIconPath(<DeleteIcon/>) const getDeleteIconPath = memoizedGetIconPath(<DeleteIcon />);
const getAddIconPath = memoizedGetIconPath(<AddIcon/>) const getAddIconPath = memoizedGetIconPath(<AddIcon />);
// sorry, I found no better way to find a specific icon button... // sorry, I found no better way to find a specific icon button...
export const queryAllEditIconButtons = (container?: HTMLElement): Array<HTMLElement> => { export const queryAllEditIconButtons = (
return (container ? queryAllByRole(container, "button") : screen.queryAllByRole("button")) container?: HTMLElement
.filter(button => button.innerHTML.includes("svg") && button.innerHTML.includes(getEditIconPath())); ): Array<HTMLElement> => {
} return (container
? queryAllByRole(container, "button")
: screen.queryAllByRole("button")
).filter(
(button) =>
button.innerHTML.includes("svg") &&
button.innerHTML.includes(getEditIconPath())
);
};
// sorry, I found no better way to find a specific icon button... // sorry, I found no better way to find a specific icon button...
const queryAllDeleteIconButtons = (container?: HTMLElement): Array<HTMLElement> => { const queryAllDeleteIconButtons = (
return (container ? queryAllByRole(container, "button") : screen.queryAllByRole("button")) container?: HTMLElement
.filter(button => button.innerHTML.includes("svg") && button.innerHTML.includes(getDeleteIconPath())); ): Array<HTMLElement> => {
} return (container
? queryAllByRole(container, "button")
: screen.queryAllByRole("button")
).filter(
(button) =>
button.innerHTML.includes("svg") &&
button.innerHTML.includes(getDeleteIconPath())
);
};
// sorry, I found no better way to find a specific icon button... // sorry, I found no better way to find a specific icon button...
export const queryAllAddIconButtons = (container?: HTMLElement): Array<HTMLElement> => { export const queryAllAddIconButtons = (
return (container ? queryAllByRole(container, "button") : screen.queryAllByRole("button")) container?: HTMLElement
.filter(button => button.innerHTML.includes("svg") && button.innerHTML.includes(getAddIconPath())); ): Array<HTMLElement> => {
} return (container
? queryAllByRole(container, "button")
: screen.queryAllByRole("button")
).filter(
(button) =>
button.innerHTML.includes("svg") &&
button.innerHTML.includes(getAddIconPath())
);
};
export const expandAccordionAndGetIconButtons = async (accordion: HTMLElement): Promise<{ editIconButton: HTMLElement, deleteIconButton: HTMLElement }> => { export const expandAccordionAndGetIconButtons = async (
accordion: HTMLElement
): Promise<{ editIconButton: HTMLElement; deleteIconButton: HTMLElement }> => {
let editIconsButtons = queryAllDeleteIconButtons(); let editIconsButtons = queryAllDeleteIconButtons();
let deleteIconsButtons = queryAllEditIconButtons(); let deleteIconsButtons = queryAllEditIconButtons();
expect(editIconsButtons).toHaveLength(0); expect(editIconsButtons).toHaveLength(0);
@ -55,9 +86,9 @@ export const expandAccordionAndGetIconButtons = async (accordion: HTMLElement):
deleteIconsButtons = queryAllDeleteIconButtons(); deleteIconsButtons = queryAllDeleteIconButtons();
expect(editIconsButtons).toHaveLength(1); expect(editIconsButtons).toHaveLength(1);
expect(deleteIconsButtons).toHaveLength(1); expect(deleteIconsButtons).toHaveLength(1);
}) });
return { return {
editIconButton: editIconsButtons[0], editIconButton: editIconsButtons[0],
deleteIconButton: deleteIconsButtons[0] deleteIconButton: deleteIconsButtons[0],
}; };
} };

View file

@ -1,10 +1,11 @@
import {parseJwt} from "./jwt"; import { parseJwt } from "./jwt";
describe("The parseJwt function", () => { describe("The parseJwt function", () => {
test("parses a valid candymat jwt", () => { test("parses a valid candymat jwt", () => {
const validJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.6VLDBS5_HwgWIf_MKkMCuj4EVBZkbSGm87aWt5grP2M"; const validJwt =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.6VLDBS5_HwgWIf_MKkMCuj4EVBZkbSGm87aWt5grP2M";
const jwt = parseJwt(validJwt) const jwt = parseJwt(validJwt);
expect(jwt).not.toBeNull(); expect(jwt).not.toBeNull();
expect(jwt?.person_row_id).toBe(1); expect(jwt?.person_row_id).toBe(1);
@ -12,25 +13,28 @@ describe("The parseJwt function", () => {
}); });
test("returns null if role claim is invalid", () => { test("returns null if role claim is invalid", () => {
const invalidRoleClaimJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHllZGl0b3IiLCJwZXJzb25fcm93X2lkIjoxLCJleHAiOjE2MDk0MTIxNjgsImlhdCI6MTYwOTIzOTM2OCwiYXVkIjoicG9zdGdyYXBoaWxlIiwiaXNzIjoicG9zdGdyYXBoaWxlIn0._AVFTMqMkIuyrfQGTmWE-Qi-C72KCrZ3s_uVyfuEDco"; const invalidRoleClaimJwt =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHllZGl0b3IiLCJwZXJzb25fcm93X2lkIjoxLCJleHAiOjE2MDk0MTIxNjgsImlhdCI6MTYwOTIzOTM2OCwiYXVkIjoicG9zdGdyYXBoaWxlIiwiaXNzIjoicG9zdGdyYXBoaWxlIn0._AVFTMqMkIuyrfQGTmWE-Qi-C72KCrZ3s_uVyfuEDco";
const jwt = parseJwt(invalidRoleClaimJwt); const jwt = parseJwt(invalidRoleClaimJwt);
expect(jwt).toBeNull(); expect(jwt).toBeNull();
}) });
test("returns null if person_row_id is not a number", () => { test("returns null if person_row_id is not a number", () => {
const invalidRowIdClaimJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6IjEiLCJleHAiOjE2MDk0MTIxNjgsImlhdCI6MTYwOTIzOTM2OCwiYXVkIjoicG9zdGdyYXBoaWxlIiwiaXNzIjoicG9zdGdyYXBoaWxlIn0.NfXylzN44qrZA5DX0qxxU71vJ1o9gdunscnK6V193Fc"; const invalidRowIdClaimJwt =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6IjEiLCJleHAiOjE2MDk0MTIxNjgsImlhdCI6MTYwOTIzOTM2OCwiYXVkIjoicG9zdGdyYXBoaWxlIiwiaXNzIjoicG9zdGdyYXBoaWxlIn0.NfXylzN44qrZA5DX0qxxU71vJ1o9gdunscnK6V193Fc";
const jwt = parseJwt(invalidRowIdClaimJwt); const jwt = parseJwt(invalidRowIdClaimJwt);
expect(jwt).toBeNull(); expect(jwt).toBeNull();
}) });
test("returns null if token is rubish.", () => { test("returns null if token is rubish.", () => {
const brokenJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eysssssssssssssssssssss.6VLDBS5_HwgWIf_MKkMCuj4EVBZkbSGm87aWt5grP2M"; const brokenJwt =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eysssssssssssssssssssss.6VLDBS5_HwgWIf_MKkMCuj4EVBZkbSGm87aWt5grP2M";
const jwt = parseJwt(brokenJwt); const jwt = parseJwt(brokenJwt);
expect(jwt).toBeNull(); expect(jwt).toBeNull();
}) });
}); });

View file

@ -1,48 +1,49 @@
export const getRawJsonWebToken = (): string | null => { export const getRawJsonWebToken = (): string | null => {
return localStorage.getItem('token'); return localStorage.getItem("token");
} };
export const getJsonWebToken = (): JwtPayload | null => { export const getJsonWebToken = (): JwtPayload | null => {
const rawToken = getRawJsonWebToken(); const rawToken = getRawJsonWebToken();
return rawToken ? parseJwt(rawToken) : null return rawToken ? parseJwt(rawToken) : null;
} };
export const parseJwt = (token: string): JwtPayload | null => { export const parseJwt = (token: string): JwtPayload | null => {
try { try {
const base64Url = token.split('.')[1]; const base64Url = token.split(".")[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const jsonPayload = decodeURIComponent( const jsonPayload = decodeURIComponent(
atob(base64) atob(base64)
.split('') .split("")
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
.join('') .join("")
); );
const jwtPayload = JSON.parse(jsonPayload); const jwtPayload = JSON.parse(jsonPayload);
return isJwtPayloadValid(jwtPayload) ? jwtPayload : null return isJwtPayloadValid(jwtPayload) ? jwtPayload : null;
} catch { } catch {
return null return null;
} }
} };
export const isJwtPayloadValid = (jwtPayload: JwtPayload): boolean => { export const isJwtPayloadValid = (jwtPayload: JwtPayload): boolean => {
return claims.every(claim => Object.keys(jwtPayload).includes(claim)) return (
&& userRoles.includes(jwtPayload.role) claims.every((claim) => Object.keys(jwtPayload).includes(claim)) &&
&& typeof (jwtPayload.person_row_id) === 'number' userRoles.includes(jwtPayload.role) &&
&& typeof (jwtPayload.exp) === 'number' typeof jwtPayload.person_row_id === "number" &&
&& typeof (jwtPayload.iat) === 'number'; typeof jwtPayload.exp === "number" &&
} typeof jwtPayload.iat === "number"
);
};
const claims = ["role", "person_row_id", "exp", "iat", "aud", "iss"] const claims = ["role", "person_row_id", "exp", "iat", "aud", "iss"];
const userRoles = ["candymat_editor", 'candymat_candidate', 'candymat_person'] const userRoles = ["candymat_editor", "candymat_candidate", "candymat_person"];
interface JwtPayload { interface JwtPayload {
"role": UserRole, role: UserRole;
"person_row_id": number, person_row_id: number;
"exp": number, exp: number;
"iat": number, iat: number;
"aud": "postgraphile", aud: "postgraphile";
"iss": "postgraphile" iss: "postgraphile";
} }
type UserRole = "candymat_editor" | 'candymat_candidate' | 'candymat_person' type UserRole = "candymat_editor" | "candymat_candidate" | "candymat_person";

View file

@ -11,9 +11,9 @@
// opt-in, read https://bit.ly/CRA-PWA // opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean( const isLocalhost = Boolean(
window.location.hostname === 'localhost' || window.location.hostname === "localhost" ||
// [::1] is the IPv6 localhost address. // [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' || window.location.hostname === "[::1]" ||
// 127.0.0.0/8 are considered localhost for IPv4. // 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match( window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
@ -26,12 +26,9 @@ type Config = {
}; };
export function register(config?: Config) { export function register(config?: Config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
// The URL constructor is available in all browsers that support SW. // The URL constructor is available in all browsers that support SW.
const publicUrl = new URL( const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
process.env.PUBLIC_URL,
window.location.href
);
if (publicUrl.origin !== window.location.origin) { if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin // Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to // from what our page is served on. This might happen if a CDN is used to
@ -39,7 +36,7 @@ export function register(config?: Config) {
return; return;
} }
window.addEventListener('load', () => { window.addEventListener("load", () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) { if (isLocalhost) {
@ -50,8 +47,8 @@ export function register(config?: Config) {
// service worker/PWA documentation. // service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => { navigator.serviceWorker.ready.then(() => {
console.log( console.log(
'This web app is being served cache-first by a service ' + "This web app is being served cache-first by a service " +
'worker. To learn more, visit https://bit.ly/CRA-PWA' "worker. To learn more, visit https://bit.ly/CRA-PWA"
); );
}); });
} else { } else {
@ -65,21 +62,21 @@ export function register(config?: Config) {
function registerValidSW(swUrl: string, config?: Config) { function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker navigator.serviceWorker
.register(swUrl) .register(swUrl)
.then(registration => { .then((registration) => {
registration.onupdatefound = () => { registration.onupdatefound = () => {
const installingWorker = registration.installing; const installingWorker = registration.installing;
if (installingWorker == null) { if (installingWorker == null) {
return; return;
} }
installingWorker.onstatechange = () => { installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') { if (installingWorker.state === "installed") {
if (navigator.serviceWorker.controller) { if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched, // At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older // but the previous service worker will still serve the older
// content until all client tabs are closed. // content until all client tabs are closed.
console.log( console.log(
'New content is available and will be used when all ' + "New content is available and will be used when all " +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.' "tabs for this page are closed. See https://bit.ly/CRA-PWA."
); );
// Execute callback // Execute callback
@ -90,7 +87,7 @@ function registerValidSW(swUrl: string, config?: Config) {
// At this point, everything has been precached. // At this point, everything has been precached.
// It's the perfect time to display a // It's the perfect time to display a
// "Content is cached for offline use." message. // "Content is cached for offline use." message.
console.log('Content is cached for offline use.'); console.log("Content is cached for offline use.");
// Execute callback // Execute callback
if (config && config.onSuccess) { if (config && config.onSuccess) {
@ -101,25 +98,25 @@ function registerValidSW(swUrl: string, config?: Config) {
}; };
}; };
}) })
.catch(error => { .catch((error) => {
console.error('Error during service worker registration:', error); console.error("Error during service worker registration:", error);
}); });
} }
function checkValidServiceWorker(swUrl: string, config?: Config) { function checkValidServiceWorker(swUrl: string, config?: Config) {
// Check if the service worker can be found. If it can't reload the page. // Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, { fetch(swUrl, {
headers: { 'Service-Worker': 'script' } headers: { "Service-Worker": "script" },
}) })
.then(response => { .then((response) => {
// Ensure service worker exists, and that we really are getting a JS file. // Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type'); const contentType = response.headers.get("content-type");
if ( if (
response.status === 404 || response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1) (contentType != null && contentType.indexOf("javascript") === -1)
) { ) {
// No service worker found. Probably a different app. Reload the page. // No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => { navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => { registration.unregister().then(() => {
window.location.reload(); window.location.reload();
}); });
@ -131,18 +128,18 @@ function checkValidServiceWorker(swUrl: string, config?: Config) {
}) })
.catch(() => { .catch(() => {
console.log( console.log(
'No internet connection found. App is running in offline mode.' "No internet connection found. App is running in offline mode."
); );
}); });
} }
export function unregister() { export function unregister() {
if ('serviceWorker' in navigator) { if ("serviceWorker" in navigator) {
navigator.serviceWorker.ready navigator.serviceWorker.ready
.then(registration => { .then((registration) => {
registration.unregister(); registration.unregister();
}) })
.catch(error => { .catch((error) => {
console.error(error.message); console.error(error.message);
}); });
} }

View file

@ -2,4 +2,4 @@
// allows you to do things like: // allows you to do things like:
// expect(element).toHaveTextContent(/react/i) // expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom // learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect'; import "@testing-library/jest-dom/extend-expect";

View file

@ -1,11 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es5",
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
@ -19,7 +15,5 @@
"noEmit": true, "noEmit": true,
"jsx": "react" "jsx": "react"
}, },
"include": [ "include": ["src"]
"src"
]
} }