Move all frontend files to frontend directory

This commit is contained in:
Ismo Mehdi
2024-01-17 15:34:34 +02:00
220 changed files with 36056 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
name: Release to production
on:
push:
branches:
- master
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build image
id: build-image
uses: redhat-actions/buildah-build@v2
with:
image: ohtuilmo-frontend
tags: production ${{ github.sha }}
containerfiles: |
./Dockerfile
- name: Push to quay.io
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-image.outputs.image }}
tags: ${{ steps.build-image.outputs.tags }}
registry: quay.io/toska
username: toska+github
password: ${{ secrets.QUAY_IO_TOKEN }}
View File
+12
View File
@@ -0,0 +1,12 @@
# Only include files that we need for "npm install",
# "react-scripts build" and "serve"!
*
!public/
!src/
# react-scripts build runs linter, include eslint configs
!.eslintignore
!.eslintrc.js
!package*.json
# nice-to-haves I guess?
!LICENCE
!README.md
+1
View File
@@ -0,0 +1 @@
build
+44
View File
@@ -0,0 +1,44 @@
module.exports = {
"env": {
"es6": true,
"node": true,
"browser": true
},
"extends": "eslint:recommended",
"parser": "babel-eslint",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 2018,
"sourceType": "module"
},
"plugins": [
"react"
],
"rules": {
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error" ,
"indent": [
"error",
2
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"never"
],
"eqeqeq": "error",
"no-trailing-spaces": "error",
"object-curly-spacing": [
"error", "always"
],
"arrow-spacing": [
"error", { "before": true, "after": true }
],
"no-console" : 0
}
}
+24
View File
@@ -0,0 +1,24 @@
**/cypress/screenshots
**/cypress/videos
# dependencies
/node_modules
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env
+7
View File
@@ -0,0 +1,7 @@
{
"eslintIntegration": true,
"arrowParens": "always",
"semi": false,
"singleQuote": true,
"printWidth": 80
}
+35
View File
@@ -0,0 +1,35 @@
language: node_js
node_js:
- '8'
addons:
apt:
packages:
- libgconf-2-4
install:
- npm run e2e:install
before_script:
- npm run e2e:setup
script:
- npm run e2e:run
after_script:
- npm run e2e:teardown
deploy:
provider: script
script: './deploy_dockerhub.sh'
on:
branch: master
cache:
# Use $HOME/.npm explicitly. Travis has a "cache: npm" setting as well, but it
# only caches the npm cache if the "script" is "npm ci". We run "npm ci" in
# our Dockerfile. We also need to cache $HOME/.npm so that "npx cypress" is
# cached.
directories:
- '$HOME/.npm'
- '$HOME/.cache' # should cache Cypress?
+30
View File
@@ -0,0 +1,30 @@
# specify base image
FROM registry.access.redhat.com/ubi8/nodejs-10
ENV TZ="Europe/Helsinki"
WORKDIR /opt/app-root/src
# Install serve to run the app - put this first as this can be cached very well.
RUN npm install -g serve@10.1.2
# Copy package files separately to have them in their own cacheable layer
# and to invalidate the "RUN npm install" cache layer if the files have changed.
#
# Run "npm install" before copying src over so that if the packages remain the
# same, we can re-use the layer cache from our previous build --> no need to
# install a gigantic amount of dependencies.
COPY package*.json .
# Run "ci" instead of "install" to make sure we get exact deps we think we're
# getting. Downside is that dev deps are downloaded as well.
# As "ci" uses the $HOME/.npm package cache, we can tell Travis to cache that.
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
# Set entrypoint to serve the app we just built
CMD serve -s build -p 3000
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 ohtuprojekti-ilmo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+259
View File
@@ -0,0 +1,259 @@
[![Build Status](https://travis-ci.org/UniversityOfHelsinkiCS/ohtuilmo-frontend.svg?branch=master)](https://travis-ci.org/UniversityOfHelsinkiCS/ohtuilmo-frontend)
# ohtuilmo
Registration, administration and review tool for University of Helsinki's software engineering project course.
- [Backend](https://github.com/UniversityOfHelsinkiCS/ohtuilmo-backend)
## Table of Contents
* [Development](#development)
* [Local development](#local-development)
* [Test users](#test-users)
* [Accessing the database](#accessing-the-database)
* [Directly with psql](#directly-with-psql)
* [With the dbscript](#with-the-dbscript)
* [Writing and running tests locally](#writing-and-running-tests-locally)
* [Writing tests](#writing-tests)
* [If you're writing a frontend-only feature which works with the Docker image in DockerHub](#if-youre-writing-a-frontend-only-feature-which-works-with-the-docker-image-in-dockerhub)
* [If you're writing a feature that touches both the frontend and backend](#if-youre-writing-a-feature-that-touches-both-the-frontend-and-backend)
* [Running end-to-end tests](#running-end-to-end-tests)
* [CI, Docker](#ci-docker)
* [Available tags](#available-tags)
* [Deployment: staging and production](#deployment-staging-and-production)
* [Design compromises and things we didn't have time to fix](#design-compromises-and-things-we-didnt-have-time-to-fix)
* [Docker cheatsheet](#docker-cheatsheet)
## Development
### Local development
1. Install docker, docker-compose and node.
2. Clone this repo and the [backend](https://github.com/ohtuprojekti-ilmo/ohtuilmo-backend) repo under the same directory.
```
ohtuprojekti-ilmo/
├── ohtuilmo-backend/
└── ohtuilmo-frontend/
```
3. Copy the `docker-compose.sample.yml` file into this project root directory, rename it to `docker-compose.yml`.
- Notice that the docker-compose file specifies `ohtuilmo-backend` and `ohtuilmo-frontend` as the directory names for backend's and frontend's `build` dir
```
ohtuprojekti-ilmo/
├── ohtuilmo-backend/
├── ohtuilmo-frontend/
└── docker-compose.yml
```
4. Run `npm install` in both backend's and frontend's directory
5. Start the database with `docker-compose up -d db`
- If the command fails, saying that port 5432 is already bound, change or remove the host port in the docker-compose file:
```yml
db:
# ...
ports:
- '5432:5432'
# ↑
# └ host port
```
6. Create, migrate and seed the database
```sh
$ cd ohtuilmo-backend
$ npm run db:recreate
```
7. Start the backend and frontend in watch mode
- backend: `npm run watch`
- frontend: `npm start`
### Test users
| Username | Password |
|---------------|-----------------|
| testertester | testertester123 |
| testertester2 | testertester123 |
| testertester3 | testertester123 |
### Accessing the database
#### Directly with psql
By default, the `docker-compose.sample.yml` file exposes the PostgreSQL 10.6 server on your localhost port 5432. You can directly connect to the database with the PostgreSQL client `psql`.
- MacOS: `brew install postgres`
- Linux: `sudo apt-get install postgresql-client`
- Windows: good luck
```sh
$ psql postgres://postgres@localhost:5432/dev_postgres
# ↑ ↑ ↑
# db username ┘ host port ┘ └ db name, depends on
# backend's NODE_ENV
```
The name of the database (here `dev_postgres`) depends on backend's `NODE_ENV`. As to why it's `postgres`, see [_Design compromises and things we didn't have time to fix_](#design-compromises-and-things-we-didnt-have-time-to-fix).
| NODE_ENV | db created by `npm run db:recreate` |
|-------------|-------------------------------------|
| production | postgres |
| development | dev_postgres |
#### With the dbscript
The dbscript was originally written to compare the schema created by Sequelize to that created by the migrations. (diff files created by `drop-init-sequelize` and `drop-init-migrations`). However, it's also a handy tool to run SQL commands in the container. Note: the current version expects the db to be exposed on host port 5432.
Copy the `dbscript.sh` to the project root directory (same directory as `docker-compose.yml`), chmod +x it if not already, and run it. Make sure your current working directory is the same directory.
- ```
ohtuprojekti-ilmo/
├── ohtuilmo-backend/
├── ohtuilmo-frontend/
├── dbscript.sh
└── docker-compose.yml
```
Help:
```
user:~/ohtuprojekti-ilmo$ ./dbscript
./dbscript.sh [COMMAND]
where COMMAND is one of the following:
drop-init-sequelize Backs up db, clears database, then
initializes it by running backend and
allowing Sequelize to sync().
drop-init-migrations Backs up db, clears database, then
initializes it by running the db:migrate.
drop-init-both Backs up db, clears database, then
initializes it by first running db:migrate,
then allows Sequelize to sync().
nuke-db Backs up db, then DROPs the database and
CREATEs it.
dump-db Backs up the db to file.
run-file [filename] Runs a PSQL file on the database. Use to
restore database backups.
run <SQL> Run an SQL statement on the db.
```
After everything is running, try this for example:
```sh
$ ./dbscript.sh run '\d users'
$ ./dbscript.sh run 'select * from users'
```
To change the name of the database (by default `dev_postgres`), edit the script.
### Writing and running tests locally
This project uses [Cypress](https://www.cypress.io/) for browser testing.
The frontend's `e2e/` directory contains the test specs and a custom docker-compose file (`docker-compose.e2e.yml`) which can be used to set up an environment which matches the actual production environment. See [_Running end-to-end tests_](#running-end-to-end-tests).
#### Writing tests
The test specs can be found in `e2e/cypress/integration`.
#### If you're writing a frontend-only feature which works with the Docker image in DockerHub
In this case, you can just start the backend using the DockerHub image.
1. Start `db` and `backend` with docker-compose normally, no need to specifically use the `.e2e.yml` file.
2. Check which port the backend is running on
- In frontend's `package.json`, set `"proxy": "http://localhost:<PORT>"` to the correct port so that the frontend's /api calls get proxied to the backend
- Probably easiest if you use the port found in the file as the host port in docker-compose.
- Don't commit this change, it's not needed in production and it changes per developer really.
3. Start the frontend with `npm start`
4. Run Cypress. Check which port the frontend is running on, pass it to Cypress before opening it
- There's [multiple ways](https://docs.cypress.io/guides/guides/environment-variables.html#Setting) to pass this information to Cypress, the easiest for you probably is to do:
```sh
CYPRESS_baseUrl=http://localhost:<PORT> npm run cypress:open
```
or
```
npm run cypress:open -- --env CYPRESS_baseUrl=http://localhost:<PORT>
```
The `baseUrl` lets Cypress know which host to direct its requests when we do `cy.visit('/')`. Alternatively
#### If you're writing a feature that touches both the frontend and backend
Same instructions as above but you only start `db` with docker-compose.
### Running end-to-end tests
The full E2E test suite can be run locally, but notice that it builds the frontend image from the frontend directory, meaning that you can't interactively edit your code and the tests.
Run:
- `npm run e2e:install`
- `npm run e2e:setup`
- `npm run e2e:run or npm run e2e:open`
- `npm run e2e:teardown`
The E2E tests set up nginx, backend, frontend and the database with docker-compose, then run the Cypress tests using nginx's port.
## CI, Docker
When pushing to a branch, Travis runs the Cypress e2e tests. When running in CI, Cypress screenshots of failing tests and video of all tests. These are submitted to the Cypress Dashboard. The project is public in the dashboard, but the dashboard still requires login (just login with Github and it'll work).
Travis will build the frontend image, and pull all other images.
![](e2e/e2e-arch.png)
- Travis:
- [Frontend](https://travis-ci.org/ohtuprojekti-ilmo/ohtuilmo-frontend)
- [Backend](https://travis-ci.org/ohtuprojekti-ilmo/ohtuilmo-backend)
- [Cypress Dashboard](https://dashboard.cypress.io/#/projects/27w3df/runs)
After a commit has been pushed to master and Travis has cleared the tests, it will build and push a new Docker image to DockerHub. These images are used in staging and production via `docker-compose pull`.
- DockerHub
- [Frontend](https://hub.docker.com/r/ohtuprojektiilmo/ohtufront)
- [Backend](https://hub.docker.com/r/ohtuprojektiilmo/ohtufront)
### Available tags
For both frontend and backend, the `latest` tag is built from the latest commit on master which has passed the Travis tests. Additionally, each of these builds are tagged with the short commit hash, allowing manual rollbacks in production by changing the tag in the docker-compose file.
## Deployment: staging and production
The staging and production servers run on the University of Helsinki's CS department's servers. The production server is public at `https://study.cs.helsinki.fi/projekti`, but the staging server is behind the CS department's internal network's firewall. Connect via melkki or melkinkari etc.
Both services are behind a reverse proxy (nginx). It proxies `/projekti` to the frontend and `/projekti/api` to the backend. See the [`e2e-nginx` image's nginx.conf](https://github.com/ohtuprojekti-ilmo/e2e-nginx/blob/master/nginx.conf) for a sample.
Assuming you've been given proper ssh access, you can deploy new images from DockerHub with the `deploy.sh` script found in the correct user's home directory.
`deploy.sh`:
```sh
#!/bin/bash
set -e
# Pull latest images from DockerHub
docker-compose pull frontend backend
# Make sure db is up
docker-compose up -d db
# Stop frontend and backend so we can restart with new images
docker-compose stop frontend backend
# Run possible migrations
docker-compose run --entrypoint 'npm run db:migrate' backend
# Bring services back uo
docker-compose up -d backend frontend
```
## Design compromises and things we didn't have time to fix
See [issues marked with tag `design-compromise`](https://github.com/ohtuprojekti-ilmo/ohtuilmo-frontend/issues?q=is%3Aissue+is%3Aopen+label%3Adesign-compromise).
## Docker cheatsheet
[Docker cheatsheet](https://github.com/jexniemi/Docker-cheat-page/wiki)
+9
View File
@@ -0,0 +1,9 @@
{
"fixturesFolder": "e2e/cypress/fixtures",
"integrationFolder": "e2e/cypress/integration",
"pluginsFile": "e2e/cypress/plugins/index.js",
"screenshotsFolder": "e2e/cypress/screenshots",
"supportFile": "e2e/cypress/support/index.js",
"videosFolder": "e2e/cypress/videos",
"projectId": "27w3df"
}
+213
View File
@@ -0,0 +1,213 @@
#!/bin/bash
set -e
dco=docker-compose
# If NODE_ENV=production, change this to "postgres"
DB_NAME="dev_postgres"
# The current directory's name, should match docker-compose's project name
# if we're in the same dir as the docker-compose file
PROJECT_NAME="$(basename $PWD | tr '[:upper:]' '[:lower:]')"
DB_CONTAINER="${PROJECT_NAME}_db_1"
timestamp() {
date -u +"%Y-%m-%dT%H-%M-%S"
}
# $1 = suffix for dump file
backupDb() {
# assume db is up
local SUFFIX=$1
local TIMESTAMP="$(timestamp)"
local DUMP_FILENAME="dbdo_${TIMESTAMP}_${SUFFIX}.psql"
docker exec "${DB_CONTAINER}" sh -c "pg_dump postgresql://postgres@localhost:5432/${DB_NAME} > /data/${DUMP_FILENAME}"
docker cp "${DB_CONTAINER}:/data/${DUMP_FILENAME}" "./${DUMP_FILENAME}"
echo $DUMP_FILENAME
}
clearDb() {
# Connect to the template1 database to run the commands because we can't
# drop "postgres" if we're connected to "postgres"
local PSQL_CMD="psql postgresql://postgres@localhost:5432/template1 -c 'DROP DATABASE ${DB_NAME}'\
&& psql postgresql://postgres@localhost:5432/template1 -c 'CREATE DATABASE ${DB_NAME}'"
docker exec "${DB_CONTAINER}" sh -c "$PSQL_CMD"
}
dumpDb() {
echo "Dumped db to file $(backupDb dump)"
}
waitSequelize() {
# god sequelize is so slow. If you figure out a better solution than
# waiting, be my guest to nuke this.
echo -ne '[ ]\r'
echo -ne '[> ]\r'
sleep 1
echo -ne '[-> ]\r'
sleep 1
echo -ne '[--> ]\r'
sleep 1
echo -ne '[---> ]\r'
sleep 1
echo -ne '[----> ]\r'
sleep 1
echo -ne '[-----> ]\r'
sleep 1
echo -ne '[-------->]\r'
echo -ne '[*********]\r'
sleep 1
echo -ne '\n'
}
initWithSequelize() {
echo 'Making sure db is up'
$dco up -d db
echo 'Stopping backend...'
$dco stop backend
echo 'Stopped, backing up db'
echo "Backed up db to file $(backupDb pre-sequelize)"
echo 'Clearing db'
clearDb
echo 'Bringing backend back up'
$dco up -d backend
waitSequelize
echo 'Probably up now...'
echo "Freshly initialized db dumped to $(backupDb post-sequelize)"
$dco stop backend
}
initWithMigrations() {
echo 'Making sure db is up'
$dco up -d db
echo 'Stopping backend...'
$dco stop backend
echo 'Stopped, backing up db'
echo "Backed up db to file $(backupDb pre-migrations)"
echo 'Clearing db'
clearDb
echo 'Running migrations'
$dco run --rm --entrypoint 'npm run db:migrate' backend
echo "Freshly initialized db dumped to $(backupDb post-migrations)"
}
initMigrationsThenSequelize() {
echo 'Making sure db is up'
$dco up -d db
echo 'Stopping backend...'
$dco stop backend
echo 'Stopped, backing up db'
echo "Backed up db to file $(pre-both)"
echo 'Clearing db'
clearDb
echo 'Running migrations'
$dco run --rm --entrypoint 'npm run db:migrate' backend
echo 'Migrations ran, starting backend to let Sequelize init'
$dco up -d backend
echo 'Up, waiting 9s until it runs sequelize.sync()'
waitSequelize
echo 'Probably up now...'
echo "Freshly initialized db dumped to $(backupDb post-both)"
$dco stop backend
}
nukeDb() {
echo "Backed up db to file $(backupDb pre-nuke)"
echo 'Nuking db'
clearDb
echo 'Database postgres has been cleared.'
}
runFileDb() {
local DUMP_FILENAME=$1
echo "Running commands from file: $DUMP_FILENAME"
$dco up -d db
local CONTAINER_SQL_FILE="/data/restore_$(timestamp).psql"
docker cp "${DUMP_FILENAME}" "${DB_CONTAINER}:${CONTAINER_SQL_FILE}"
local PSQL_CMD="psql postgresql://postgres@localhost:5432/${DB_NAME} < ${CONTAINER_SQL_FILE}"
docker exec "${DB_CONTAINER}" sh -c "$PSQL_CMD"
docker exec "${DB_CONTAINER}" sh -c "rm '${CONTAINER_SQL_FILE}'"
echo "Commands ran."
}
run_sql() {
local CMD="$1"
local SQL_FILE="$(mktemp)"
# Write sql to file so that we don't need to deal with escaping ' or "
echo "${CMD}" > "${SQL_FILE}"
local CONTAINER_SQL_FILE="/tmp/$(basename "${SQL_FILE}")"
# Write sql payload to container
docker cp "${SQL_FILE}" "${DB_CONTAINER}:${CONTAINER_SQL_FILE}"
# exec it
docker exec "${DB_CONTAINER}" sh -c "psql postgresql://postgres@localhost:5432/${DB_NAME} < ${CONTAINER_SQL_FILE}"
# clean up
docker exec "${DB_CONTAINER}" sh -c "rm ${CONTAINER_SQL_FILE}"
rm "${SQL_FILE}"
}
printHelp() {
echo "$0 [COMMAND]"
echo 'where COMMAND is one of the following:'
echo ' drop-init-sequelize Backs up db, clears database, then'
echo ' initializes it by running backend and'
echo ' allowing Sequelize to sync().'
echo ''
echo ' drop-init-migrations Backs up db, clears database, then'
echo ' initializes it by running the db:migrate.'
echo ''
echo ' drop-init-both Backs up db, clears database, then'
echo ' initializes it by first running db:migrate,'
echo ' then allows Sequelize to sync().'
echo ''
echo ' nuke-db Backs up db, then DROPs the database and'
echo ' CREATEs it.'
echo ''
echo ' dump-db Backs up the db to file.'
echo ''
echo ' run-file [filename] Runs a PSQL file on the database. Use to'
echo ' restore database backups.'
echo ''
echo ' run <SQL> Run an SQL statement on the db.'
echo ''
}
if [ "$1" == 'drop-init-sequelize' ]; then
initWithSequelize
elif [ "$1" == 'drop-init-migrations' ]; then
initWithMigrations
elif [ "$1" == 'drop-init-both' ]; then
initMigrationsThenSequelize
elif [ "$1" == 'nuke-db' ]; then
nukeDb
elif [ "$1" == 'dump-db' ]; then
dumpDb
elif [ "$1" == 'run-file' ]; then
if [ "$2" == '' ]; then
printHelp
exit 1
fi
runFileDb "$2"
elif [ "$1" == 'run' ]; then
if [ "$2" == '' ]; then
printHelp
exit 1
fi
run_sql "$2"
else
printHelp
fi
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
set -e
# Deployment script used by Travis (.travis.yml) to push master's image to
# DockerHub after successful merge
#
# DON'T RUN MANUALLY UNLESS YOU KNOW WHAT YOU'RE DOING! -Joona
DOCKER_IMAGE_REPO=ohtuprojektiilmo/ohtufront
DOCKER_TAG="$(git rev-parse --short ${TRAVIS_COMMIT})"
echo 'Logging in with Docker'
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
echo "Building $DOCKER_IMAGE_REPO:$DOCKER_TAG"
docker build -t "$DOCKER_IMAGE_REPO:$DOCKER_TAG" .
docker tag "$DOCKER_IMAGE_REPO:$DOCKER_TAG" "$DOCKER_IMAGE_REPO:latest"
echo 'Pushing to dockerhub'
docker push "$DOCKER_IMAGE_REPO:$DOCKER_TAG"
docker push "$DOCKER_IMAGE_REPO:latest"
echo 'Deployed!'
+37
View File
@@ -0,0 +1,37 @@
version: '2'
services:
frontend:
# you can put your local image instead of ohtuprojektiilmo/ohtufront if you want
image: ohtuprojektiilmo/ohtufront
build: './ohtuilmo-frontend'
ports:
- '7000:3000'
depends_on:
- backend
- db
backend:
image: ohtuprojektiilmo/ohtuback
build: './ohtuilmo-backend'
restart: always
ports:
- '7001:3001'
depends_on:
- db
environment:
- NODE_ENV=production
- PORT=3001
- SECRET=yoursecrethere
db:
image: postgres:10.6
restart: always
ports:
- '5432:5432'
environment:
PGDATA: /data/test
volumes:
- pg-data:/data/test
volumes:
pg-data:
+29
View File
@@ -0,0 +1,29 @@
export const TEST_USER = {
username: 'testertester',
password: 'testertester123',
headers: {
uid: 'testertester',
schacpersonaluniquecode:
'urn:schac:personalUniqueCode:int:studentID:helsinki.fi:012345678'
}
}
export const TEST_USER2 = {
username: 'testertester3',
password: 'testertester123',
headers: {
uid: 'testertester3',
schacpersonaluniquecode:
'urn:schac:personalUniqueCode:int:studentID:helsinki.fi:012345698'
}
}
export const TEST_ADMIN = {
username: 'testertester2',
password: 'testertester123',
headers: {
uid: 'testertester2',
schacpersonaluniquecode:
'urn:schac:personalUniqueCode:int:studentID:helsinki.fi:012345688'
}
}
@@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}
@@ -0,0 +1,112 @@
describe('Answering peer review', () => {
before(() => {
cy.loginAsAdmin()
cy.deleteReviewQuestions()
cy.deleteAllGroups()
cy.deleteAllPeerReviews()
cy.createGroup({
name: 'The group of the groups',
topicId: 1,
configurationId: 1,
instructorId: null,
studentIds: ['012345678', '012345698']
})
cy.createReviewQuestionSet('Super nice review questions', [
{
type: 'info',
header: 'This is info',
description: 'Just fill the form'
},
{
header: 'Previous experiene in software developement',
description: 'How many hours?',
type: 'number'
},
{
header: 'Without option?',
description: '',
type: 'number'
},
{
header: 'Ok and with option',
description: 'Choose a radio button you want',
type: 'radio',
options: ['Cant say', 'Not at all', 'Little', 'Decent', 'Good', 'Super']
}
])
cy.setPeerReviewOneActive(
'Konfiguraatio 1',
1,
'Super nice review questions'
)
})
it('peer review is open', () => {
cy.loginAsRegisteredUser()
cy.visit('/peerreview')
cy.get('.peer-review-container').contains('This is info')
})
it('shows an error if none of the fields is filled', () => {
cy.loginAsRegisteredUser()
cy.visit('/peerreview')
cy.contains('Submit').click()
cy.contains('You must answer all questions')
})
it('shows an error if only one of the fields is filled', () => {
cy.loginAsRegisteredUser()
cy.visit('/peerreview')
cy.get(
'[data-cy="input_number_Previous experiene in software developement"]'
)
.type('{backspace}')
.type('123')
cy.contains('Submit').click()
cy.contains('You must answer all questions')
})
it('shows an error if not all of the radio button questions is answered', () => {
cy.loginAsRegisteredUser()
cy.visit('/peerreview')
cy.get(
'[data-cy="input_number_Previous experiene in software developement"]'
)
.type('{backspace}')
.type('123')
cy.get('[data-cy="input_number_Without option?"]')
.type('{backspace}')
.type('123')
cy.get('[name="Ok and with optionTimo *Teppo Tellervo Testaaja"]')
.eq(3)
.click()
cy.contains('Submit').click()
cy.contains('You must answer all questions')
})
it('shows a submit confimation when all field and butotns are filled properly', () => {
cy.loginAsRegisteredUser()
cy.visit('/peerreview')
cy.get(
'[data-cy="input_number_Previous experiene in software developement"]'
)
.type('{backspace}')
.type('123')
cy.get('[data-cy="input_number_Without option?"]')
.type('{backspace}')
.type('123')
cy.get('[name="Ok and with optionTimo *Teppo Tellervo Testaaja"]')
.eq(2)
.click()
cy.get('[name="Ok and with optionDonald John Trump"]')
.eq(5)
.click()
cy.contains('Submit').click()
cy.contains('Peer review saved!')
})
after(() => {
cy.deleteAllGroups()
cy.deleteAllPeerReviews()
})
})
@@ -0,0 +1,132 @@
const initTests = () => {
cy.deleteCustomerReviewQuestions()
cy.createCustomerReviewQuestionSet('Asiakkaan arviosetti 1', [
{
type: 'text',
header: 'Mitä mieltä olit tykittelystä?',
description: 'Vastaa lyhyesti 5000 merkillä'
},
{
type: 'oneliner',
header: 'Minne spämmi?',
description: 'Max 7 päivässä.'
},
{
type: 'number',
header: 'Monta tuntia viikossa olit yhteydessä tiimiin?'
},
{
type: 'range',
header: 'Minkä arvosanan antaisit tiimille?',
description: 'Pienet epätykittelyt on ihan ok',
options: ['1', '2', '3', '4', '5']
}
])
cy.createGroup({
name: 'Tykittelijät',
topicId: 1,
configurationId: 1,
instructorId: '012345698',
studentIds: ['012345678', '012345688']
})
cy.setCustomerReviewQuestionSetToConfiguration(1)
}
const submitCustomerReview = () => {
cy.get('[data-cy="submit-customer-review-button"]').click()
}
const answerTextInput = (text) => {
cy.get('[data-cy="textInput-Mitä mieltä olit tykittelystä?"]').within(() => {
cy.get('textarea').type(text)
})
}
const answerOnelinerInput = (text) => {
cy.get('[data-cy="onelinerInput-Minne spämmi?"]').within(() => {
cy.get('input').type(text)
})
}
const answerNumberInput = (number) => {
cy.get(
'[data-cy="numberInput-Monta tuntia viikossa olit yhteydessä tiimiin?"]'
)
.clear()
.type(number)
}
const answerRangeInput = (number) => {
cy.get('[data-cy="rangeInput-Minkä arvosanan antaisit tiimille?"]').within(
() => {
cy.get('input')
.eq(number - 1)
.click()
}
)
}
const expectNotification = (text) => {
cy.get('.notification').should('be.text', text)
}
describe('Customer review page', () => {
before(() => {
initTests()
})
beforeEach(() => {
cy.deleteCustomerReviews()
cy.visit('/customer-review/eec0neeT0jo0ae9F')
})
it('requires text fields to be filled', () => {
submitCustomerReview()
// submit not successfull, still on same page
cy.url().should('contain', '/customer-review/eec0neeT0jo0ae9F')
cy.contains('Customer review')
})
it('shows error when text fields are under 5 characters long', () => {
answerTextInput('foo')
answerOnelinerInput('spammiosoite')
submitCustomerReview()
expectNotification('Text answers must be over 5 characters long.')
})
it('shows error when oneliner fields are under 5 characters long', () => {
answerTextInput(
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce at.'
)
answerOnelinerInput('spam')
submitCustomerReview()
expectNotification('Short text answers must be over 5 characters long.')
})
it('shows error when text fields are filled but there are other unfilled fields', () => {
answerTextInput(
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce at.'
)
answerOnelinerInput('spammiosoite')
submitCustomerReview()
expectNotification('You must answer all questions')
})
it('submits customer review when all fields are filled', () => {
answerTextInput(
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce at.'
)
answerNumberInput(1337)
answerRangeInput(3)
answerOnelinerInput('spammittanne@helsinki.foo')
submitCustomerReview()
expectNotification('Review saved!')
cy.contains('Thank you for the review')
})
after(() => {
cy.deleteAllGroups()
cy.deleteCustomerReviews()
})
})
@@ -0,0 +1,282 @@
/* global cy describe beforeEach it */
// cy.type supports keystrokes e.g. {enter} sends enter key -> need to escape {
const escapeJsonForCypressType = (json) => json.replace(/\{/g, '{{}')
const submitCreationForm = (name, questionsJson) => {
cy.get('.question-set-form .question-set-form__name').type(name)
cy.get('.question-set-form .question-set-form__questions').type(
escapeJsonForCypressType(questionsJson)
)
cy.get('.question-set-form button[type="submit"]').click()
}
const createQuestionSet = (name, questions) => {
submitCreationForm(name, JSON.stringify(questions))
}
const editQuestionSet = (questionSetItemSelector) => {
cy.get(questionSetItemSelector)
.find('.question-set-item-controls__menu-button')
.click()
cy.get('#question-set-item-controls__menu')
.find('.question-set-item-controls__edit-button')
.click()
}
const replaceEditingQuestionSetName = (questionSetItemSelector, newName) => {
cy.get(questionSetItemSelector)
.find('.question-set-form__name')
.clear()
.type(newName)
}
const replaceEditingQuestionSetJson = (questionSetItemSelector, json) => {
cy.get(questionSetItemSelector)
.find('.question-set-form__questions')
.clear()
.type(escapeJsonForCypressType(json))
}
const replaceEditingQuestionSet = (questionSetItemSelector, newQuestions) => {
replaceEditingQuestionSetJson(
questionSetItemSelector,
JSON.stringify(newQuestions)
)
}
const clearEditingQuestionSetInputs = (selector) => {
cy.get(selector)
.find('.question-set-form__name')
.clear()
cy.get(selector)
.find('.question-set-form__questions')
.clear()
}
const clearEditingQuestionSetName = (selector) => {
cy.get(selector)
.find('.question-set-form__name')
.clear()
}
const clearEditingQuestionSetQuestions = (selector) => {
cy.get(selector)
.find('.question-set-form__questions')
.clear()
}
const saveEditingQuestionSet = (questionSetItemSelector) => {
cy.get(questionSetItemSelector)
.find('.question-set-item-editor__save-button')
.click()
}
const cancelEditingQuestionSet = (selector) => {
cy.get(selector)
.find('.question-set-item-editor__cancel-button')
.click()
}
describe('Customer review questions page', () => {
beforeEach(() => {
cy.loginAsAdmin()
cy.deleteCustomerReviewQuestions()
})
describe('Customer review question set creation', () => {
beforeEach(() => {
cy.visit('/administration/customer-review-questions')
})
it('creates a new customer review question set', () => {
createQuestionSet('creview1000', [
{
type: 'text',
header: 'Mitä mieltä tykittelystä?',
description: 'Vastaa lyhyesti 1000 sanan verran'
}
])
cy.get('.notification').contains(
'Created new customer review question set "creview1000"'
)
cy.get(
'.question-set-item__content .registration-questions-table-row'
).should('have.length', 1)
cy.get('.question-set-item .question-set-item__title').should(
'have.text',
'creview1000'
)
cy.get('.question-set-item .question-set-item__content').contains(
'Mitä mieltä tykittelystä?'
)
cy.get('.question-set-item .question-set-item__content').contains(
'Vastaa lyhyesti 1000 sanan verran'
)
cy.get('.question-set-item .question-set-item__content').contains('text')
cy.get('.customer-review-question-set-list')
.find('.question-set-item')
.should('have.length', 1)
})
it('creates questions with options', () => {
createQuestionSet('creview2000', [
{
type: 'range',
header: 'This is header',
description: 'This is description',
options: ['pwned', 'owned', 'pk\'d', 'noob', 'elite', '1337']
}
])
cy.get(
'.question-set-item__content .registration-questions-table-row'
).should('have.length', 1)
cy.get('.question-set-item__content').contains('This is header')
cy.get('.question-set-item__content').contains('This is description')
cy.get('.question-set-item__content').contains('range')
cy.get('.question-set-item__content').contains('pk\'d')
cy.get('.question-set-item__content').contains('1337')
})
it('does not allow creation if name is duplicate', () => {
createQuestionSet('creview3000', [
{
type: 'text',
header: 'Just a text field plz?',
description: 'Right?'
}
])
createQuestionSet('creview3000', [
{
type: 'text',
header: 'Just a text field plz?',
description: 'Right?'
}
])
cy.get('.notification').contains('name already in use')
cy.get('.customer-review-question-set-list')
.find('.question-set-item')
.should('have.length', 1)
})
it('does not allow creation if JSON is malformed', () => {
submitCreationForm('creview4000', '.-a,1:"#123-')
cy.get('.question-set-form').contains('Field contains invalid JSON')
cy.get('.customer-review-question-set-list')
.find('.question-set-item')
.should('have.length', 0)
})
})
describe('Customer review question set editing', () => {
beforeEach(() => {
cy.createCustomerReviewQuestionSet('creview5000', [
{ type: 'text', header: 'foo', description: 'bar' }
])
cy.visit('/administration/customer-review-questions')
editQuestionSet('.question-set-item')
})
it('does not save changes if name is empty', () => {
clearEditingQuestionSetName('.question-set-item-editor')
// "required" attr should prevent submit now
saveEditingQuestionSet('.question-set-item-editor')
// since the submit was prevented, the component is still
// .question-set-item-editor and not .question-set-item
cy.get('.question-set-item-editor')
})
it('does not save changes if questions is empty', () => {
clearEditingQuestionSetQuestions('.question-set-item-editor')
// "required" attr should prevent submit now
saveEditingQuestionSet('.question-set-item-editor')
// since the submit was prevented, the component is still
// .question-set-item-editor and not .question-set-item
cy.get('.question-set-item-editor')
})
it('does not save changes if both name and questions are empty', () => {
clearEditingQuestionSetInputs('.question-set-item-editor')
// "required" attr should prevent submit now
saveEditingQuestionSet('.question-set-item-editor')
// since the submit was prevented, the component is still
// .question-set-item-editor and not .question-set-item
cy.get('.question-set-item-editor')
})
it('shows error if new JSON is invalid', () => {
replaceEditingQuestionSetJson(
'.question-set-item-editor',
'..-12.31-23.1-23.asdasc'
)
saveEditingQuestionSet('.question-set-item-editor')
cy.get('.question-set-form').contains('Field contains invalid JSON')
})
it('shows error if new JSON is not an array', () => {
replaceEditingQuestionSetJson('.question-set-item-editor', '{"foo":123}')
saveEditingQuestionSet('.question-set-item-editor')
cy.get('.question-set-form').contains('Questions should be an array')
})
it('edits name without changing questions', () => {
replaceEditingQuestionSetName('.question-set-item-editor', 'creview1337')
saveEditingQuestionSet('.question-set-item-editor')
cy.get('.question-set-item__title').should('have.text', 'creview1337')
cy.get(
'.question-set-item__content .registration-questions-table-row'
).should('have.length', 1)
cy.get('.question-set-item__content').contains('foo')
cy.get('.question-set-item__content').contains('bar')
cy.get('.question-set-item__content').contains('text')
})
it('edits questions without changing name', () => {
replaceEditingQuestionSet('.question-set-item-editor', [
{ header: 'baz', type: 'range' }
])
saveEditingQuestionSet('.question-set-item-editor')
cy.get('.question-set-item__title').should('have.text', 'creview5000')
cy.get(
'.question-set-item__content .registration-questions-table-row'
).should('have.length', 1)
cy.get('.question-set-item__content').contains('baz')
cy.get('.question-set-item__content').contains('range')
})
it('does not modify set if cancel is clicked', () => {
replaceEditingQuestionSetName('.question-set-item-editor', 'creview7321')
replaceEditingQuestionSet('.question-set-item-editor', [
{ header: 'baz!', type: 'range' }
])
cancelEditingQuestionSet('.question-set-item-editor')
cy.get('.question-set-item__title').should('have.text', 'creview5000')
cy.get(
'.question-set-item__content .registration-questions-table-row'
).should('have.length', 1)
cy.get('.question-set-item__content').contains('foo')
cy.get('.question-set-item__content').contains('bar')
cy.get('.question-set-item__content').contains('text')
})
it('edits both name and questions correctly', () => {
replaceEditingQuestionSetName('.question-set-item-editor', 'creview5001')
replaceEditingQuestionSet('.question-set-item-editor', [
{ header: 'asd?', type: 'text' },
{ header: 'das!', type: 'range' }
])
saveEditingQuestionSet('.question-set-item-editor')
cy.get('.question-set-item__title').should('have.text', 'creview5001')
cy.get(
'.question-set-item__content .registration-questions-table-row'
).should('have.length', 2)
cy.get('.question-set-item__content').contains('asd?')
cy.get('.question-set-item__content').contains('range')
})
})
})
@@ -0,0 +1,251 @@
const getEmailTemplate = (name) =>
cy.get(`.email-template[data-cy-template="${name}"]`)
const findTemplateTextarea = (templateTitle, templateLanguage) =>
getEmailTemplate(templateTitle).find(
`.email-template__field--${templateLanguage} textarea`
)
// cy.type supports keystrokes e.g. {enter} sends enter key -> need to escape {
const escapeCurlyBracesForCypress = (text) => text.replace(/\{/g, '{{}')
const writeToTemplate = (templateTitle, templateLanguage, text) =>
findTemplateTextarea(templateTitle, templateLanguage).type(
escapeCurlyBracesForCypress(text)
)
const clickSave = () => cy.get('.email-templates-form__submit-button').click()
const visitEmailTemplatesPage = () =>
cy.visit('/administration/email-templates')
describe('Email configuration page', () => {
beforeEach(() => {
cy.loginAsAdmin()
cy.deleteAllEmailTemplates()
})
describe('updating templates', () => {
beforeEach(() => {
visitEmailTemplatesPage()
})
it('shows empty fields when no configurations have been made', () => {
findTemplateTextarea('Topic proposal accepted', 'finnish').should(
'be.empty'
)
findTemplateTextarea('Topic proposal accepted', 'english').should(
'be.empty'
)
findTemplateTextarea('Topic proposal rejected', 'finnish').should(
'be.empty'
)
findTemplateTextarea('Topic proposal rejected', 'english').should(
'be.empty'
)
findTemplateTextarea('Customer review link', 'finnish').should('be.empty')
findTemplateTextarea('Customer review link', 'english').should('be.empty')
})
it('updates only topic proposal accepted (finnish) successfully', () => {
const templateText = 'Hei,\n\nProjekti "{{topicName}}" hyväksyttiin.'
writeToTemplate('Topic proposal accepted', 'finnish', templateText)
clickSave()
visitEmailTemplatesPage()
findTemplateTextarea('Topic proposal accepted', 'finnish').should(
'have.value',
templateText
)
findTemplateTextarea('Topic proposal accepted', 'english').should(
'be.empty'
)
findTemplateTextarea('Topic proposal rejected', 'finnish').should(
'be.empty'
)
findTemplateTextarea('Topic proposal rejected', 'english').should(
'be.empty'
)
findTemplateTextarea('Customer review link', 'finnish').should('be.empty')
findTemplateTextarea('Customer review link', 'english').should('be.empty')
})
it('updates only topic proposal accepted (english) successfully', () => {
const templateText = 'Hello,\n\nProject "{{topicName}}" was accepted.'
writeToTemplate('Topic proposal accepted', 'english', templateText)
clickSave()
visitEmailTemplatesPage()
findTemplateTextarea('Topic proposal accepted', 'english').should(
'have.value',
templateText
)
findTemplateTextarea('Topic proposal accepted', 'finnish').should(
'be.empty'
)
findTemplateTextarea('Topic proposal rejected', 'finnish').should(
'be.empty'
)
findTemplateTextarea('Topic proposal rejected', 'english').should(
'be.empty'
)
findTemplateTextarea('Customer review link', 'finnish').should('be.empty')
findTemplateTextarea('Customer review link', 'english').should('be.empty')
})
it('updates only topic proposal rejected (finnish) successfully', () => {
const templateText = 'Hei,\n\nProjektia "{{topicName}}" ei hyväksytty.'
writeToTemplate('Topic proposal rejected', 'finnish', templateText)
clickSave()
visitEmailTemplatesPage()
findTemplateTextarea('Topic proposal rejected', 'finnish').should(
'have.value',
templateText
)
findTemplateTextarea('Topic proposal accepted', 'finnish').should(
'be.empty'
)
findTemplateTextarea('Topic proposal accepted', 'english').should(
'be.empty'
)
findTemplateTextarea('Topic proposal rejected', 'english').should(
'be.empty'
)
findTemplateTextarea('Customer review link', 'finnish').should('be.empty')
findTemplateTextarea('Customer review link', 'english').should('be.empty')
})
it('updates only topic proposal rejected (english) successfully', () => {
const templateText = 'Hello,\n\nProject "{{topicName}}" was not accepted.'
writeToTemplate('Topic proposal rejected', 'english', templateText)
clickSave()
visitEmailTemplatesPage()
findTemplateTextarea('Topic proposal rejected', 'english').should(
'have.value',
templateText
)
findTemplateTextarea('Topic proposal accepted', 'finnish').should(
'be.empty'
)
findTemplateTextarea('Topic proposal accepted', 'english').should(
'be.empty'
)
findTemplateTextarea('Topic proposal rejected', 'finnish').should(
'be.empty'
)
findTemplateTextarea('Customer review link', 'finnish').should('be.empty')
findTemplateTextarea('Customer review link', 'english').should('be.empty')
})
it('updates only customer review link (finnish) successfully', () => {
const templateText =
'Hei, vastaathan loppuarviointiin osoitteessa {{secretLink}}'
writeToTemplate('Customer review link', 'finnish', templateText)
clickSave()
visitEmailTemplatesPage()
findTemplateTextarea('Customer review link', 'finnish').should(
'have.value',
templateText
)
findTemplateTextarea('Topic proposal accepted', 'finnish').should(
'be.empty'
)
findTemplateTextarea('Topic proposal accepted', 'english').should(
'be.empty'
)
findTemplateTextarea('Topic proposal rejected', 'finnish').should(
'be.empty'
)
findTemplateTextarea('Topic proposal rejected', 'english').should(
'be.empty'
)
findTemplateTextarea('Customer review link', 'english').should('be.empty')
})
it('updates only customer review link (english) successfully', () => {
const templateText =
'Hello, please answer the final review at {{secretLink}}'
writeToTemplate('Customer review link', 'english', templateText)
clickSave()
visitEmailTemplatesPage()
findTemplateTextarea('Customer review link', 'english').should(
'have.value',
templateText
)
findTemplateTextarea('Topic proposal accepted', 'finnish').should(
'be.empty'
)
findTemplateTextarea('Topic proposal accepted', 'english').should(
'be.empty'
)
findTemplateTextarea('Topic proposal rejected', 'finnish').should(
'be.empty'
)
findTemplateTextarea('Topic proposal rejected', 'english').should(
'be.empty'
)
findTemplateTextarea('Customer review link', 'finnish').should('be.empty')
})
it('simultaneously updates all email templates successfully', () => {
const texts = [
[
'Topic proposal accepted',
'finnish',
'Hei,\n\nProjekti "{{topicName}}" hyväksyttiin.'
],
[
'Topic proposal accepted',
'english',
'Hello,\n\nProject "{{topicName}}" was accepted.'
],
[
'Topic proposal rejected',
'finnish',
'Hei,\n\nProjektia "{{topicName}}" ei hyväksytty.'
],
[
'Topic proposal rejected',
'english',
'Hello,\n\nProject "{{topicName}}" was not accepted.'
],
[
'Customer review link',
'finnish',
'Hei, vastaathan loppuarviointiin osoitteessa {{secretLink}}'
],
[
'Customer review link',
'english',
'Hello, please answer the final review at {{secretLink}}'
]
]
texts.forEach(([templateTitle, templateLanguage, text]) => {
writeToTemplate(templateTitle, templateLanguage, text)
})
clickSave()
visitEmailTemplatesPage()
texts.forEach(([templateTitle, templateLanguage, text]) => {
findTemplateTextarea(templateTitle, templateLanguage).contains(text)
})
})
})
})
@@ -0,0 +1,151 @@
describe('Group Management Page', () => {
beforeEach(() => {
cy.clearLocalStorage()
cy.loginAsAdmin()
cy.visit('/administration/groups')
})
describe('Creating a group', () => {
it('is successful when all necessary details are provided', () => {
cy.get('.configuration-selector').click()
cy.get('.configuration-1').click()
cy.get('.create-group-form-topic__selector').click()
cy.get('.topic-menu-item')
.contains('Aihe A')
.click()
cy.get('.create-group-form__name')
.clear()
.type('Ryhmä A')
cy.get('.create-group-form__student-input').type('012345678, 012345688')
cy.get('.create-group-form__instructor').click()
cy.get('.create-group-form__instructor')
.find('input')
.type('Angela', { force: true })
cy.get('.create-group-form__instructor').find(
'.create-group-form__instructor__option'
)
cy.get('.create-group-form__instructor')
.find('input')
.type('{enter}', { force: true })
cy.get('.create-group-submit').click()
cy.get('.notification').should('have.text', 'Group saved!')
})
})
describe('Viewing a group', () => {
beforeEach(() => {
cy.get('.configuration-selector').click()
cy.get('.configuration-1').click()
})
it('deletes a student', () => {
cy.get('[data-cy=delete-student-button]')
.eq(1)
.click()
cy.get('.group-students')
.eq(0)
.should('not.contain', '012345688')
cy.get('.notification').should('have.text', 'Student deleted!')
})
it('deletes an instructor', () => {
cy.get('[data-cy=delete-instructor-button]')
.eq(0)
.click()
cy.get('.group-instructor')
.eq(0)
.should('not.contain', '012345698')
cy.get('.group-instructor')
.eq(0)
.should('contain', 'No instructor assigned')
cy.get('.notification').should('have.text', 'Instructor deleted!')
})
})
describe('Editing a group', () => {
beforeEach(() => {
cy.get('.configuration-selector').click()
cy.get('.configuration-1').click()
cy.get('[data-cy=edit-group-button]')
.eq(0)
.click()
cy.get('.notification')
.should('contain', 'Editing for group')
.should('contain', 'enabled!')
})
it('changes group name', () => {
cy.get('[data-cy=edit-group-name-field]')
.clear()
.type('Ryhmä B')
cy.get('[data-cy=edit-group-save-button]').click()
cy.get('.notification').should('have.text', 'Group updated!')
cy.get('.group-name')
.eq(0)
.should('contain', 'Ryhmä B')
})
/*
it('changes topic', () => {
cy.get('.edit-group-form-topic__selector')
.should('be.visible')
.click()
cy.get('.topic-menu-item')
.should('be.visible')
.within(() => {
cy.wait(500)
cy.contains('Aihe B').click()
})
cy.get('[data-cy=edit-group-name-field]')
.clear()
.type('Ryhmä B')
cy.get('[data-cy=edit-group-save-button]').click()
cy.get('.notification').should('have.text', 'Group updated!')
cy.get('.group-topic')
.eq(0)
.should('contain', 'Aihe B')
})
*/
it('changes instructor', () => {
cy.get('.edit-group-instructor').click()
cy.get('.edit-group-instructor')
.find('input')
.type('{backspace}', { force: true })
cy.get('.edit-group-instructor')
.find('input')
.type('Testaaja', { force: true })
cy.get('.edit-group-instructor__option')
cy.get('.edit-group-instructor')
.find('input')
.type('{enter}', { force: true })
cy.get('[data-cy=edit-group-save-button]').click()
cy.get('.notification').should('have.text', 'Group updated!')
cy.get('.group-instructor')
.eq(0)
.should('contain', '012345678')
})
it('adds new student', () => {
cy.get('[data-cy=edit-students-input]').type(', 012345698')
cy.get('[data-cy=edit-group-save-button]').click()
cy.get('.notification').should('have.text', 'Group updated!')
cy.get('.group-students').should('contain', '012345698')
})
it('deletes a group', () => {
cy.get('[data-cy=edit-group-delete-button]').click()
cy.get('.notification').should('have.text', 'Group deleted!')
})
})
})
@@ -0,0 +1,286 @@
const initTests = () => {
cy.createGroup({
name: 'Tykittelijät',
topicId: 1,
configurationId: 1,
instructorId: '012345688',
studentIds: ['012345678', '012345698']
})
cy.createGroup({
name: 'Kämmäilijät',
topicId: 2,
configurationId: 2,
instructorId: '012345688',
studentIds: ['012345678', '012345698']
})
const konf1vastaukset1 = {
peerReviews: [
{
user_id: '012345678',
configuration_id: 1,
review_round: 1,
answer_sheet: [
{
id: 1,
type: 'number',
answer: '3',
questionHeader: ' Anna tekninen arvosana.'
},
{
id: 2,
type: 'text',
answer:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce at.',
questionHeader: 'Mitä kaikkea teknistä tää jäbä osas tehdä?'
},
{
id: 3,
type: 'number',
answer: '2',
questionHeader: 'Kurssin arvosana'
},
{
id: 4,
type: 'radio',
peers: {
'Donald John Trump': 3,
'Timo *Teppo Tellervo Testaaja': 1
},
questionHeader: 'Miten perustelet tämän arvosanan?'
}
]
},
{
user_id: '012345698',
configuration_id: 1,
review_round: 1,
answer_sheet: [
{
id: 1,
type: 'number',
answer: '4',
questionHeader: ' Anna tekninen arvosana.'
},
{
id: 2,
type: 'text',
answer:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce at.',
questionHeader: 'Mitä kaikkea teknistä tää jäbä osas tehdä?'
},
{
id: 3,
type: 'number',
answer: '3',
questionHeader: 'Kurssin arvosana'
},
{
id: 4,
type: 'radio',
peers: {
'Donald John Trump': 2,
'Timo *Teppo Tellervo Testaaja': 1
},
questionHeader: 'Miten perustelet tämän arvosanan?'
}
]
}
]
}
const konf1vastaukset2 = {
peerReviews: [
{
user_id: '012345678',
configuration_id: 1,
review_round: 2,
answer_sheet: [
{
id: 1,
type: 'number',
answer: '3',
questionHeader: ' Anna tekninen arvosana.'
},
{
id: 2,
type: 'text',
answer:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce at.',
questionHeader: 'Mitä kaikkea teknistä tää jäbä osas tehdä?'
},
{
id: 3,
type: 'number',
answer: '2',
questionHeader: 'Kurssin arvosana'
},
{
id: 4,
type: 'radio',
peers: {
'Donald John Trump': 4,
'Timo *Teppo Tellervo Testaaja': 4
},
questionHeader: 'Miten perustelet tämän arvosanan?'
}
]
},
{
user_id: '012345698',
configuration_id: 1,
review_round: 2,
answer_sheet: [
{
id: 1,
type: 'number',
answer: '4',
questionHeader: ' Anna tekninen arvosana.'
},
{
id: 2,
type: 'text',
answer:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce at.',
questionHeader: 'Mitä kaikkea teknistä tää jäbä osas tehdä?'
},
{
id: 3,
type: 'number',
answer: '5',
questionHeader: 'Kurssin arvosana'
},
{
id: 4,
type: 'radio',
peers: {
'Donald John Trump': 5,
'Timo *Teppo Tellervo Testaaja': 5
},
questionHeader: 'Miten perustelet tämän arvosanan?'
}
]
}
]
}
const konf2vastaukset1 = {
peerReviews: [
{
user_id: '012345678',
configuration_id: 2,
review_round: 1,
answer_sheet: [
{
id: 1,
type: 'number',
answer: '0',
questionHeader: ' Anna tekninen arvosana.'
},
{
id: 2,
type: 'text',
answer:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce at.',
questionHeader: 'Mitä kaikkea teknistä tää jäbä osas tehdä?'
},
{
id: 3,
type: 'number',
answer: '0',
questionHeader: 'Kurssin arvosana'
},
{
id: 4,
type: 'radio',
peers: {
'Donald John Trump': 0,
'Timo *Teppo Tellervo Testaaja': 0
},
questionHeader: 'Miten perustelet tämän arvosanan?'
}
]
},
{
user_id: '012345698',
configuration_id: 2,
review_round: 1,
answer_sheet: [
{
id: 1,
type: 'number',
answer: '0',
questionHeader: ' Anna tekninen arvosana.'
},
{
id: 2,
type: 'text',
answer:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce at.',
questionHeader: 'Mitä kaikkea teknistä tää jäbä osas tehdä?'
},
{
id: 3,
type: 'number',
answer: '0',
questionHeader: 'Kurssin arvosana'
},
{
id: 4,
type: 'radio',
peers: {
'Donald John Trump': 0,
'Timo *Teppo Tellervo Testaaja': 0
},
questionHeader: 'Miten perustelet tämän arvosanan?'
}
]
}
]
}
cy.createPeerReviews(konf1vastaukset1)
cy.createPeerReviews(konf1vastaukset2)
cy.createPeerReviews(konf2vastaukset1)
}
describe('Instructor review page', () => {
before(() => {
initTests()
})
beforeEach(() => {
cy.loginAsAdmin()
cy.visit('/instructorpage')
})
it('Starting testing', () => {
// submit not successfull, still on same page
cy.url().should('contain', '/instructorpage')
cy.contains('Tykittelijät')
cy.contains('2.50')
})
it('Change configuration twice', () => {
// submit not successfull, still on same page
cy.get('[data-cy=configuration-selector]').click()
cy.get('.configuration-menu-item')
.contains('Konfiguraatio 2')
.click()
cy.contains('Kämmäilijät')
cy.contains(
'This group hasn\'t answered to the second peer review round yet.'
)
cy.get('[data-cy=configuration-selector]').click()
cy.get('.configuration-menu-item')
.contains('Konfiguraatio 1')
.click()
cy.contains('4.50')
})
after(() => {
cy.deleteAllGroups()
cy.deleteAllPeerReviews()
})
})
@@ -0,0 +1,103 @@
const initTests = () => {
cy.createGroup({
name: 'Tykittelijät',
topicId: 1,
configurationId: 1,
instructorId: '012345688',
studentIds: ['012345678', '012345698']
})
}
const submitInstructorReview = () => {
cy.get('[data-cy="submit-instructor-review-button"]').click()
}
const answerTextInput = (text, index) => {
cy.get(`[data-cy="textInput- Osasko jaba tykitella. user:${index}"]`).within(
() => {
cy.get('textarea').type(text)
}
)
}
const answerNumberInput = (number, index) => {
cy.get(`[data-cy="numberInput- Anna tekninen arvosana. user:${index}"]`)
.clear()
.type(number)
}
const expectNotification = (text) => {
cy.get('.notification').should('be.text', text)
}
describe.skip('Instructor review page', () => {
before(() => {
initTests()
})
beforeEach(() => {
cy.loginAsAdmin()
cy.deleteInstructorReviews()
cy.visit('/instructorreviewpage')
})
it('requires text fields to be filled', () => {
submitInstructorReview()
// submit not successfull, still on same page
cy.url().should('contain', '/instructorreviewpage')
expectNotification('You must answer all questions')
})
it('shows error when text fields are under 5 characters long', () => {
answerTextInput('foo', 0)
submitInstructorReview()
expectNotification('Text answers must be over 5 characters long.')
})
it('shows error when text fields are filled but there are other unfilled fields', () => {
answerTextInput(
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce at.',
0
)
submitInstructorReview()
expectNotification('You must answer all questions')
})
it('shows error when number fields are filled but are higher than 5', () => {
answerTextInput(
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce at.',
0
)
answerNumberInput(6, 0)
submitInstructorReview()
expectNotification('Grade can not be over 5.')
})
it('shows error when number fields are filled but are lower than 5', () => {
answerTextInput(
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce at.',
0
)
answerNumberInput(-1, 0)
submitInstructorReview()
expectNotification('Number answer can not be negative')
})
it('submits instructor review when all fields are filled', () => {
answerTextInput(
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce at.',
0
)
answerTextInput(
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce at.',
1
)
answerNumberInput(5, 0)
answerNumberInput(0, 1)
submitInstructorReview()
expectNotification('Instructor review saved!')
cy.contains('You have reviewed every group you are instructing.')
})
after(() => {
cy.deleteAllGroups()
})
})
@@ -0,0 +1,247 @@
const assertIsOnLandingPage = () => {
cy.get('[data-cy=registrationlink]').should('be.visible')
}
const assertIsOnLoginPage = () => {
cy.url().should('include', '/login')
cy.contains('Software engineering project')
cy.get('.loginpage-header').should('have.text', 'Login')
}
const assertIsOnRegistrationDetailsPage = () => {
cy.url().should('include', '/registrationdetails')
cy.contains('Registration details')
cy.get('.registration-details-container').should('be.visible')
}
const assertIsAlreadyRegistered = () => {
cy.url().should('include', '/register')
cy.contains('You have already registered to current project')
}
describe.skip('Page access and redirect tests', () => {
describe('Page access without authentication', () => {
it('/administration/configuration redirects user to login page', () => {
cy.visit('/administration/configuration')
assertIsOnLoginPage()
})
it('/administration/customer-review-questions redirects user to login page', () => {
cy.visit('/administration/customer-review-questions')
assertIsOnLoginPage()
})
it('/administration/groups redirects user to login page', () => {
cy.visit('/administration/groups')
assertIsOnLoginPage()
})
it('/administration/participants redirects user to login page', () => {
cy.visit('/administration/participants')
assertIsOnLoginPage()
})
it('/administration/peer-review-questions redirects user to login page', () => {
cy.visit('/administration/peer-review-questions')
assertIsOnLoginPage()
})
it('/administration/registration-questions redirects user to login page', () => {
cy.visit('/administration/registration-questions')
assertIsOnLoginPage()
})
it('/administration/registrationmanagement redirects user to login page', () => {
cy.visit('/administration/registrationmanagement')
assertIsOnLoginPage()
})
it('/administration/email-templates redirects user to login page', () => {
cy.visit('/administration/email-templates')
assertIsOnLoginPage()
})
it('/peerreview redirects user to login page', () => {
cy.visit('/peerreview')
assertIsOnLoginPage()
})
it('/registrationdetails redirects user to login page', () => {
cy.visit('/registrationdetails')
assertIsOnLoginPage()
})
it('/register redirects user to login page', () => {
cy.visit('/register')
assertIsOnLoginPage()
})
it('/topics redirects user to login page', () => {
cy.visit('/topics')
assertIsOnLoginPage()
})
})
describe.skip('Page access for user', () => {
beforeEach(() => {
cy.loginAsUnregisteredUser()
cy.visit('/')
})
it('/administration/configuration redirects user to landing page', () => {
cy.visit('/administration/configuration')
assertIsOnLandingPage()
})
it('/administration/customer-review-questions redirects user to landing page', () => {
cy.visit('/administration/customer-review-questions')
assertIsOnLandingPage()
})
it('/administration/groups redirects user to landing page', () => {
cy.visit('/administration/groups')
assertIsOnLandingPage()
})
it('/administration/participants redirects user to landing page', () => {
cy.visit('/administration/participants')
assertIsOnLandingPage()
})
it('/administration/peer-review-questions redirects user to landing page', () => {
cy.visit('/administration/peer-review-questions')
assertIsOnLandingPage()
})
it('/administration/registration-questions redirects user to landing page', () => {
cy.visit('/administration/registration-questions')
assertIsOnLandingPage()
})
it('/administration/registrationmanagement redirects user to landing page', () => {
cy.visit('/administration/registrationmanagement')
assertIsOnLandingPage()
})
it('/administration/email-templates redirects user to login page', () => {
cy.visit('/administration/email-templates')
assertIsOnLandingPage()
})
it('/topics redirects user to landing page', () => {
cy.visit('/topics')
assertIsOnLandingPage()
})
it('renders /peerreview when visited', () => {
cy.visit('/peerreview')
cy.url().should('include', '/peerreview')
cy.contains('You are not currently assigned to any group!')
})
it('renders /register when visited', () => {
cy.get('[data-cy=registrationlink]').click()
cy.url().should('contain', '/register')
cy.get('.registration-form').should('be.visible')
})
})
describe.skip('Page access for registered user', () => {
beforeEach(() => {
cy.loginAsRegisteredUser()
cy.visit('/')
})
it('/login redirects to /registrationdetails', () => {
cy.visit('/login')
assertIsOnRegistrationDetailsPage()
})
it('/register shows "already registered" message', () => {
cy.visit('/register')
assertIsAlreadyRegistered()
})
})
describe.skip('Page access for admin', () => {
beforeEach(() => {
cy.loginAsAdmin()
cy.visit('/')
})
it('renders /administration/configuration when visited', () => {
cy.get('.nav-menu-button').click()
cy.get('.configuration-menu-item').click()
cy.url().should('contain', '/administration/configuration')
cy.contains('Change configuration')
})
it('renders /administration/customer-review-questions when visited', () => {
cy.visit('/administration/customer-review-questions')
cy.contains('Configure customer review questions')
cy.contains('Create question set')
})
it('renders /administration/groups when visited', () => {
cy.get('.nav-menu-button').click()
cy.get('.group-management-menu-item').click()
cy.url().should('contain', '/administration/groups')
cy.contains('Group Management')
})
it('renders /administration/participants when visited', () => {
cy.visit('/administration/participants')
cy.url().should('contain', '/administration/participants')
cy.get('.participants-container').should('be.visible')
})
it('renders /administration/peer-review-questions when visited', () => {
cy.visit('/administration/peer-review-questions')
cy.url().should('contain', '/administration/peer-review-questions')
cy.get('.peer-review-questions-page').should('be.visible')
})
it('renders /administration/registration-questions when visited', () => {
cy.visit('/administration/registration-questions')
cy.url().should('contain', '/administration/registration-questions')
cy.get('.registration-questions-page').should('be.visible')
})
it('renders /administration/registrationmanagement when visited', () => {
cy.get('.nav-menu-button').click()
cy.get('.registration-management-menu-item').click()
cy.url().should('contain', '/administration/registrationmanagement')
cy.contains('Registration and review management')
})
it('renders /administration/email-templates when visited', () => {
cy.get('.nav-menu-button').click()
cy.get('.email-templates-menu-item').click()
cy.url().should('contain', '/administration/email-templates')
cy.get('.email-templates-page').should('be.visible')
})
it('renders /topics when visited', () => {
cy.get('.nav-menu-button').click()
cy.get('.topics-menu-item').click()
cy.url().should('contain', '/topics')
cy.get('.topics-container').should('be.visible')
})
})
describe.skip('404 handler', () => {
it('shows a 404 not found page when entering an url that is not found', () => {
cy.visit('/amksfmkafg-qfq435tefds')
cy.get('.not-found-page').contains('Page not found')
})
it('redirects to / when clicking the return link', () => {
cy.visit('/amksfmkafg-qfq435tefds')
cy.get('.not-found-page')
.find('[data-cy="return-link"]')
.click()
assertIsOnLoginPage()
})
})
})
@@ -0,0 +1,236 @@
/* global cy describe beforeEach it */
// cy.type supports keystrokes e.g. {enter} sends enter key -> need to escape {
const escapeJsonForCypressType = (json) => json.replace(/\{/g, '{{}')
const submitCreationForm = (name, questionsJson) => {
cy.get('.question-set-form .question-set-form__name').type(name)
cy.get('.question-set-form .question-set-form__questions').type(
escapeJsonForCypressType(questionsJson)
)
cy.get('.question-set-form button[type="submit"]').click()
}
const createQuestionSet = (name, questions) => {
submitCreationForm(name, JSON.stringify(questions))
}
const editQuestionSet = (questionSetItemSelector) => {
cy.get(questionSetItemSelector)
.find('.question-set-item-controls__menu-button')
.click()
cy.get('#question-set-item-controls__menu')
.find('.question-set-item-controls__edit-button')
.click()
}
const replaceEditingQuestionSetName = (questionSetItemSelector, newName) => {
cy.get(questionSetItemSelector)
.find('.question-set-form__name')
.clear()
.type(newName)
}
const replaceEditingQuestionSetJson = (questionSetItemSelector, json) => {
cy.get(questionSetItemSelector)
.find('.question-set-form__questions')
.clear()
.type(escapeJsonForCypressType(json))
}
const replaceEditingQuestionSet = (questionSetItemSelector, newQuestions) => {
replaceEditingQuestionSetJson(
questionSetItemSelector,
JSON.stringify(newQuestions)
)
}
const clearEditingQuestionSetInputs = (selector) => {
cy.get(selector)
.find('.question-set-form__name')
.clear()
cy.get(selector)
.find('.question-set-form__questions')
.clear()
}
const saveEditingQuestionSet = (questionSetItemSelector) => {
cy.get(questionSetItemSelector)
.find('.question-set-item-editor__save-button')
.click()
}
const cancelEditingQuestionSet = (selector) => {
cy.get(selector)
.find('.question-set-item-editor__cancel-button')
.click()
}
describe('Registration-questions page', () => {
beforeEach(() => {
cy.loginAsAdmin()
cy.deleteRegistrationQuestions()
})
describe('Registration question set creation', () => {
beforeEach(() => {
cy.visit('/administration/registration-questions')
})
it('creates a new registration question set', () => {
createQuestionSet('k2000', [{ question: 'foo?', type: 'scale' }])
cy.get('.notification').contains(
'Created new registration question set "k2000"'
)
cy.get(
'.question-set-item__content .registration-questions-table-row'
).should('have.length', 1)
cy.get('.question-set-item .question-set-item__title').should(
'have.text',
'k2000'
)
cy.get('.question-set-item .question-set-item__content').contains('foo?')
cy.get('.question-set-item .question-set-item__content').contains('scale')
cy.get('.registration-question-set-list')
.find('.question-set-item')
.should('have.length', 1)
})
it('creates questions of type scale, text and <empty>', () => {
createQuestionSet('k2000', [
{
question: 'This is a title'
},
{
question: 'Rate your yeet 1-5',
type: 'scale'
},
{ question: 'Your life story plz', type: 'text' }
])
cy.get(
'.question-set-item__content .registration-questions-table-row'
).should('have.length', 3)
cy.get('.question-set-item__content').contains('This is a title')
cy.get('.question-set-item__content').contains('Rate your yeet 1-5')
cy.get('.question-set-item__content').contains('scale')
cy.get('.question-set-item__content').contains('Your life story plz')
cy.get('.question-set-item__content').contains('text')
})
it('does not allow creation if name is duplicate', () => {
createQuestionSet('k2000', [{ question: 'foo?', type: 'scale' }])
createQuestionSet('k2000', [{ question: 'foo?', type: 'scale' }])
cy.get('.notification').contains('name already in use')
cy.get('.registration-question-set-list')
.find('.question-set-item')
.should('have.length', 1)
})
it('does not allow creation if JSON is malformed', () => {
submitCreationForm('k3000', '.-a,1:"#123-')
cy.get('.question-set-form').contains('Field contains invalid JSON')
cy.get('.registration-question-set-list')
.find('.question-set-item')
.should('have.length', 0)
})
})
describe('Registration question set editing', () => {
beforeEach(() => {
cy.createRegistrationQuestionSet('k2000', [
{ question: 'foo?', type: 'text' }
])
cy.visit('/administration/registration-questions')
})
it('does not save changes if both name and questions are empty', () => {
editQuestionSet('.question-set-item')
clearEditingQuestionSetInputs('.question-set-item-editor')
// "required" attr should prevent submit now
saveEditingQuestionSet('.question-set-item-editor')
// since the submit was prevented, the component is still
// .question-set-item-editor and not .question-set-item
cy.get('.question-set-item-editor')
})
it('shows error if new JSON is invalid', () => {
editQuestionSet('.question-set-item')
replaceEditingQuestionSetJson(
'.question-set-item-editor',
'..-12.31-23.1-23.asdasc'
)
saveEditingQuestionSet('.question-set-item-editor')
cy.get('.question-set-form').contains('Field contains invalid JSON')
})
it('shows error if new JSON is not an array', () => {
editQuestionSet('.question-set-item')
replaceEditingQuestionSetJson('.question-set-item-editor', '{"foo":123}')
saveEditingQuestionSet('.question-set-item-editor')
cy.get('.question-set-form').contains('Questions should be an array')
})
it('edits name without changing questions', () => {
editQuestionSet('.question-set-item')
replaceEditingQuestionSetName('.question-set-item-editor', 'k2021')
saveEditingQuestionSet('.question-set-item-editor')
cy.get('.question-set-item__title').should('have.text', 'k2021')
cy.get(
'.question-set-item__content .registration-questions-table-row'
).should('have.length', 1)
cy.get('.question-set-item__content').contains('foo?')
})
it('edits questions without changing name', () => {
editQuestionSet('.question-set-item')
replaceEditingQuestionSet('.question-set-item-editor', [
{ question: 'foo!', type: 'text' }
])
saveEditingQuestionSet('.question-set-item-editor')
cy.get('.question-set-item__title').should('have.text', 'k2000')
cy.get(
'.question-set-item__content .registration-questions-table-row'
).should('have.length', 1)
cy.get('.question-set-item__content').contains('foo!')
cy.get('.question-set-item__content').contains('text')
})
it('does not modify set if cancel is clicked', () => {
editQuestionSet('.question-set-item')
replaceEditingQuestionSetName('.question-set-item-editor', 'foobar')
replaceEditingQuestionSet('.question-set-item-editor', [
{ question: 'foo!', type: 'text' }
])
cancelEditingQuestionSet('.question-set-item-editor')
cy.get('.question-set-item__title').should('have.text', 'k2000')
cy.get(
'.question-set-item__content .registration-questions-table-row'
).should('have.length', 1)
cy.get('.question-set-item__content').contains('foo?')
cy.get('.question-set-item__content').contains('text')
})
it('edits both name and questions correctly', () => {
editQuestionSet('.question-set-item')
replaceEditingQuestionSetName('.question-set-item-editor', 'k5555')
replaceEditingQuestionSet('.question-set-item-editor', [
{ question: 'this is a title' },
{ question: 'this is a scale', type: 'scale' }
])
saveEditingQuestionSet('.question-set-item-editor')
cy.get('.question-set-item__title').should('have.text', 'k5555')
cy.get(
'.question-set-item__content .registration-questions-table-row'
).should('have.length', 2)
cy.get('.question-set-item__content').contains('this is a title')
cy.get('.question-set-item__content').contains('this is a scale')
})
})
})
@@ -0,0 +1,339 @@
/* global cy describe beforeEach it */
// cy.type supports keystrokes e.g. {enter} sends enter key -> need to escape {
const escapeJsonForCypressType = (json) => json.replace(/\{/g, '{{}')
const submitCreationForm = (name, questionsJson) => {
cy.get('.question-set-form .question-set-form__name').type(name)
cy.get('.question-set-form .question-set-form__questions').type(
escapeJsonForCypressType(questionsJson)
)
cy.get('.question-set-form button[type="submit"]').click()
}
const createQuestionSet = (name, questions) => {
submitCreationForm(name, JSON.stringify(questions))
}
const editQuestionSet = (questionSetItemSelector) => {
cy.get(questionSetItemSelector)
.find('.question-set-item-controls__menu-button')
.click()
cy.get('#question-set-item-controls__menu')
.find('.question-set-item-controls__edit-button')
.click()
}
const replaceEditingQuestionSetName = (questionSetItemSelector, newName) => {
cy.get(questionSetItemSelector)
.find('.question-set-form__name')
.clear()
.type(newName)
}
const replaceEditingQuestionSetJson = (questionSetItemSelector, json) => {
cy.get(questionSetItemSelector)
.find('.question-set-form__questions')
.clear()
.type(escapeJsonForCypressType(json))
}
const replaceEditingQuestionSet = (questionSetItemSelector, newQuestions) => {
replaceEditingQuestionSetJson(
questionSetItemSelector,
JSON.stringify(newQuestions)
)
}
const clearEditingQuestionSetInputs = (selector) => {
cy.get(selector)
.find('.question-set-form__name')
.clear()
cy.get(selector)
.find('.question-set-form__questions')
.clear()
}
const saveEditingQuestionSet = (questionSetItemSelector) => {
cy.get(questionSetItemSelector)
.find('.question-set-item-editor__save-button')
.click()
}
const cancelEditingQuestionSet = (selector) => {
cy.get(selector)
.find('.question-set-item-editor__cancel-button')
.click()
}
describe('Review-questions page', () => {
beforeEach(() => {
cy.loginAsAdmin()
cy.deleteReviewQuestions()
})
describe('Review question set creation', () => {
beforeEach(() => {
cy.visit('/administration/peer-review-questions')
})
it('creates a new review question set', () => {
createQuestionSet('No options tester 1', [
{
type: 'radio',
header: 'Questions without options?',
description: 'Generate options 1 to 5'
}
])
cy.get('.notification').contains(
'Created new peer review question set "No options tester 1"'
)
cy.get(
'.question-set-item__content .registration-questions-table-row'
).should('have.length', 1)
cy.get('.question-set-item .question-set-item__title').should(
'have.text',
'No options tester 1'
)
cy.get('.question-set-item .question-set-item__content').contains(
'Generate options 1 to 5'
)
cy.get('.question-set-item .question-set-item__content').contains('radio')
cy.get('.peer-review-question-set-list')
.find('.question-set-item')
.should('have.length', 1)
})
it('creates questions set with options', () => {
createQuestionSet('With options tester', [
{
header: 'Do we have a right header here?',
description: 'I hope we do have a descirption also',
type: 'radio',
options: [
'Dont know',
'Not at all',
'Little',
'Decent',
'Much',
'Super'
]
}
])
cy.get(
'.question-set-item__content .registration-questions-table-row'
).should('have.length', 1)
cy.get('.question-set-item__content').contains(
'Do we have a right header here?'
)
cy.get('.question-set-item__content').contains(
'I hope we do have a descirption also'
)
cy.get('.question-set-item__content').contains('radio')
cy.get('.question-set-item__content').contains('Dont know')
cy.get('.question-set-item__content').contains('Super')
})
it('does not allow creation if name is duplicate', () => {
createQuestionSet('Info tester', [
{
type: 'info',
header: 'Just a text field plz?',
description: 'Right?'
}
])
createQuestionSet('Info tester', [
{
type: 'info',
header: 'Just a text field plz?',
description: 'Right?'
}
])
cy.get('.notification').contains('name already in use')
cy.get('.peer-review-question-set-list')
.find('.question-set-item')
.should('have.length', 1)
})
it('does not allow creation if JSON is malformed', () => {
submitCreationForm('Malformed json', '.-a,1:"#123-')
cy.get('.question-set-form').contains('Field contains invalid JSON')
cy.get('.peer-review-question-set-list')
.find('.question-set-item')
.should('have.length', 0)
})
})
describe('Review question set editing', () => {
beforeEach(() => {
cy.visit('/administration/peer-review-questions')
})
it('does not save changes if both name and questions are empty', () => {
createQuestionSet('With options tester', [
{
header: 'Do we have a right header here?',
description: 'I hope we do have a descirption also',
type: 'radio',
options: [
'Dont know',
'Not at all',
'Little',
'Decent',
'Much',
'Super'
]
}
])
editQuestionSet('.question-set-item')
clearEditingQuestionSetInputs('.question-set-item-editor')
// "required" attr should prevent submit now
saveEditingQuestionSet('.question-set-item-editor')
// since the submit was prevented, the component is still
// .question-set-item-editor and not .question-set-item
cy.get('.question-set-item-editor')
})
it('shows error if new JSON is invalid', () => {
createQuestionSet('No options tester', [
{
header: 'Do we have a right header here?',
description: 'I hope we do have a descirption also',
type: 'radio'
}
])
editQuestionSet('.question-set-item')
replaceEditingQuestionSetJson(
'.question-set-item-editor',
'..-12.31-23.1-23.asdasc'
)
saveEditingQuestionSet('.question-set-item-editor')
cy.get('.question-set-form').contains('Field contains invalid JSON')
})
it('shows error if new JSON is not an array', () => {
createQuestionSet('Number question tester', [
{
header: 'Do we have a right header here?',
description: 'I hope we do have a descirption also',
type: 'number'
}
])
editQuestionSet('.question-set-item')
replaceEditingQuestionSetJson('.question-set-item-editor', '{"foo":123}')
saveEditingQuestionSet('.question-set-item-editor')
cy.get('.question-set-form').contains('Questions should be an array')
})
it('edits name without changing questions', () => {
createQuestionSet('With options tester', [
{
header: 'Do we have a right header here?',
description: 'I hope we do have a descirption also',
type: 'radio',
options: [
'Dont know',
'Not at all',
'Little',
'Decent',
'Much',
'Super'
]
}
])
editQuestionSet('.question-set-item')
replaceEditingQuestionSetName('.question-set-item-editor', 'New name')
saveEditingQuestionSet('.question-set-item-editor')
cy.get('.question-set-item__title').should('have.text', 'New name')
cy.get(
'.question-set-item__content .registration-questions-table-row'
).should('have.length', 1)
cy.get('.question-set-item__content').contains('Little')
})
it('edits questions without changing name', () => {
createQuestionSet('Number question tester', [
{
header: 'Do we have a right header here?',
description: 'I hope we do have a descirption also',
type: 'number'
}
])
editQuestionSet('.question-set-item')
replaceEditingQuestionSet('.question-set-item-editor', [
{
header: 'Nice, edited question',
description: 'Some edited questions',
type: 'radio'
}
])
saveEditingQuestionSet('.question-set-item-editor')
cy.get('.question-set-item__title').should(
'have.text',
'Number question tester'
)
cy.get(
'.question-set-item__content .registration-questions-table-row'
).should('have.length', 1)
cy.get('.question-set-item__content').contains('radio')
})
it('does not modify set if cancel is clicked', () => {
createQuestionSet('Info tester', [
{
header: 'Info tester',
description: 'This is info tester',
type: 'info'
}
])
editQuestionSet('.question-set-item')
replaceEditingQuestionSetName('.question-set-item-editor', 'foobar')
replaceEditingQuestionSet('.question-set-item-editor', [
{
header: 'This could be edited but will be canceled..',
description: 'Is this real?',
type: 'info'
}
])
cancelEditingQuestionSet('.question-set-item-editor')
cy.get('.question-set-item__title').should('have.text', 'Info tester')
cy.get(
'.question-set-item__content .registration-questions-table-row'
).should('have.length', 1)
cy.get('.question-set-item__content').contains('tester')
cy.get('.question-set-item__content').contains('info')
})
it('edits both name and questions correctly', () => {
createQuestionSet('Info tester', [
{
header: 'Info tester',
description: 'This is info tester',
type: 'info'
}
])
editQuestionSet('.question-set-item')
replaceEditingQuestionSetName('.question-set-item-editor', 'New name 2')
replaceEditingQuestionSet('.question-set-item-editor', [
{
header: 'Radio tester edited',
description: 'This is now radio tester',
type: 'radio'
}
])
saveEditingQuestionSet('.question-set-item-editor')
cy.get('.question-set-item__title').should('have.text', 'New name 2')
cy.get(
'.question-set-item__content .registration-questions-table-row'
).should('have.length', 1)
cy.get('.question-set-item__content').contains('Radio tester edited')
cy.get('.question-set-item__content').contains('radio')
})
})
})
@@ -0,0 +1,46 @@
describe('Review-questions page', () => {
beforeEach(() => {
cy.loginAsAdmin()
cy.visit('/administration/configuration')
cy.deleteReviewQuestions()
cy.createReviewQuestionSet('Super nice review questions', [
{
type: 'info',
header: 'This is info',
description: 'Just fill the form'
},
{
header: 'Previous experiene in software developemnt',
description: 'How many hours?',
type: 'number'
},
{
header: 'Without option?',
description: '',
type: 'number'
},
{
header: 'Ok and with option',
description: 'Choose a radio button you want',
type: 'radio',
options: ['Cant say', 'Not at all', 'Little', 'Decent', 'Good', 'Super']
}
])
})
it('is on adminstration page', () => {
cy.get('.admin-page-container').contains('Change configuration')
})
it('new review question set is visible for round 1', () => {
cy.get('[data-cy=expansion-review-questions-1]').click()
cy.get('[data-cy=select-review-questions-1]').click()
cy.get('[data-cy=menu-item-review-questions-1]').click()
cy.get('.admin-page-container').contains('Super nice review questions')
})
it('new review question is shown when it is selected', () => {
cy.get('[data-cy=expansion-review-questions-1]').click()
cy.get('[data-cy=select-review-questions-1]').click()
cy.get('[data-cy=menu-item-review-questions-1]').click()
cy.get('.admin-page-container').contains('This is info')
})
})
@@ -0,0 +1,302 @@
/* globals cy describe beforeEach it expect */
const visitTopicsPage = (visitArgs) => cy.visit('/topics', visitArgs)
const findTopicActiveCheckbox = (topicName) =>
cy
.get(`[data-cy-topic-name="${topicName}"]`)
.find('[data-cy="toggle-active"]')
const getSendAcceptButton = (topicName) =>
cy
.get(`[data-cy-topic-name="${topicName}"]`)
.find('[data-cy="send-accept-mail"]')
const getSendRejectButton = (topicName) =>
cy
.get(`[data-cy-topic-name="${topicName}"]`)
.find('[data-cy="send-reject-mail"]')
const getSendReviewLinkButton = (topicName) =>
cy
.get(`[data-cy-topic-name="${topicName}"]`)
.find('[data-cy="send-customer-review-link-email"]')
const clickSendAcceptEmail = (topicName) =>
getSendAcceptButton(topicName).click()
const clickSendRejectEmail = (topicName) =>
getSendRejectButton(topicName).click()
const clickSendReviewLinkEmail = (topicName) =>
getSendReviewLinkButton(topicName).click()
/** @param {'finnish' | 'english'} language */
const clickEmailLanguage = (language) =>
cy
.get('[data-cy="email-language-menu"]')
.find(`[data-cy-send-mail-lang="${language}"]`)
.click()
describe('Topic list page', () => {
beforeEach(() => {
cy.loginAsAdmin()
})
describe('topics', () => {
const toggleTestTopicName = 'Aihe C'
beforeEach(() => {
visitTopicsPage()
})
it('renders the topic names correctly', () => {
const topicTitles = ['Aihe A', 'Aihe B']
for (const topicTitle of topicTitles) {
cy.get(`[data-cy-topic-name="${topicTitle}"]`)
.find('.topic-table-row__topic-title')
.should('have.text', topicTitle)
}
})
it('renders the customer names and emails correctly', () => {
cy.get('[data-cy-topic-name="Aihe A"]')
.find('.topic-table-row__customer')
.contains('Aasiakas')
cy.get('[data-cy-topic-name="Aihe A"]')
.find('.topic-table-row__customer')
.contains('aasia@kas')
})
it('toggles topic active state correctly', () => {
// get initial checked state
findTopicActiveCheckbox(toggleTestTopicName).then(($input) => {
const desiredState = $input.prop('checked')
? 'not.be.checked'
: 'be.checked'
// toggle active
findTopicActiveCheckbox(toggleTestTopicName).click()
// did it change state?
findTopicActiveCheckbox(toggleTestTopicName).should(desiredState)
visitTopicsPage()
// still in correct state after refresh?
findTopicActiveCheckbox(toggleTestTopicName).should(desiredState)
})
})
})
describe('emails', () => {
// from seed "page-access-seeds"
const topicName = 'Aihe A'
const topicSecretId = 'eec0neeT0jo0ae9F'
const emailAddress = 'aasia@kas'
const secretReviewLink = `https://study.cs.helsinki.fi/projekti/customer-review/${topicSecretId}`
beforeEach(() => {
cy.deleteSentEmails()
cy.updateAllEmailTemplates({
topicAccepted: {
finnish: 'Projekti {{topicName}} hyväksytty.',
english: 'Project {{topicName}} was accepted.'
},
topicRejected: {
finnish: 'Projekti {{topicName}} hylätty.',
english: 'Project {{topicName}} was rejected.'
},
customerReviewLink: {
finnish:
'Arviointi on nyt auki projektille {{topicName}} osoitteessa {{secretLink}}',
english:
'Review is now open for project {{topicName}}, go to {{secretLink}}'
}
})
visitTopicsPage({
// spy window.confirm for email preview tests
onBeforeLoad(win) {
cy.spy(win, 'confirm')
}
})
})
it('shows email languages after clicking accept', () => {
clickSendAcceptEmail(topicName)
cy.get('[data-cy="email-language-menu"]')
.should('be.visible')
.and('have.descendants', '[data-cy-send-mail-lang="finnish"]')
.and('have.descendants', '[data-cy-send-mail-lang="english"]')
})
it('shows email languages after clicking reject', () => {
clickSendRejectEmail(topicName)
cy.get('[data-cy="email-language-menu"]')
.should('be.visible')
.and('have.descendants', '[data-cy-send-mail-lang="finnish"]')
.and('have.descendants', '[data-cy-send-mail-lang="english"]')
})
it('shows email languages after clicking send customer review link', () => {
clickSendReviewLinkEmail(topicName)
cy.get('[data-cy="email-language-menu"]')
.should('be.visible')
.and('have.descendants', '[data-cy-send-mail-lang="finnish"]')
.and('have.descendants', '[data-cy-send-mail-lang="english"]')
})
it('shows a success popup after sending an email', () => {
clickSendAcceptEmail(topicName)
clickEmailLanguage('finnish')
cy.get('.notification').contains('Email sent!')
})
it('shows a success popup after re-sending an email', () => {
clickSendAcceptEmail(topicName)
clickEmailLanguage('finnish')
cy.get('.notification').contains('Email sent!')
clickSendAcceptEmail(topicName)
clickEmailLanguage('finnish')
cy.get('.notification').contains('Email sent!')
})
describe('accept/reject buttons', () => {
it('changes accept button text to "resend accept" after sending', () => {
getSendAcceptButton(topicName).should('have.text', 'Accept')
clickSendAcceptEmail(topicName)
clickEmailLanguage('finnish')
getSendAcceptButton(topicName).should('have.text', 'Resend Accept')
})
it('changes reject button text to "resend reject" after sending', () => {
getSendRejectButton(topicName).should('have.text', 'Reject')
clickSendRejectEmail(topicName)
clickEmailLanguage('finnish')
getSendRejectButton(topicName).should('have.text', 'Resend Reject')
})
it('does not change the buttons of another topic after sending accept and reject', () => {
getSendAcceptButton('Aihe B').should('have.text', 'Accept')
getSendRejectButton('Aihe B').should('have.text', 'Reject')
clickSendAcceptEmail('Aihe A')
clickEmailLanguage('english')
clickSendRejectEmail('Aihe A')
clickEmailLanguage('english')
getSendAcceptButton('Aihe B').should('have.text', 'Accept')
getSendRejectButton('Aihe B').should('have.text', 'Reject')
})
})
describe('customer review button', () => {
it('changes send link text to send reminder after sending', () => {
getSendReviewLinkButton(topicName).should('have.text', 'Send link')
clickSendReviewLinkEmail(topicName)
clickEmailLanguage('finnish')
getSendReviewLinkButton(topicName).should('have.text', 'Send reminder')
})
it('does not change the buttons of another topic after sending link', () => {
getSendReviewLinkButton('Aihe B').should('have.text', 'Send link')
clickSendReviewLinkEmail('Aihe A')
clickEmailLanguage('english')
getSendReviewLinkButton('Aihe B').should('have.text', 'Send link')
})
})
describe('email preview', () => {
it('shows the correctly rendered email confirm for topic accepted (finnish) template', () => {
clickSendAcceptEmail(topicName)
clickEmailLanguage('finnish')
cy.window()
.its('confirm')
.should((confirmSpy) => {
expect(confirmSpy).to.be.calledOnce
expect(confirmSpy.getCall(0).args[0]).to.contain(
`Projekti ${topicName} hyväksytty.`
)
})
})
it('shows the correctly rendered email confirm for topic accepted (english) template', () => {
clickSendAcceptEmail(topicName)
clickEmailLanguage('english')
cy.window()
.its('confirm')
.should((confirmSpy) => {
expect(confirmSpy).to.be.calledOnce
expect(confirmSpy.getCall(0).args[0]).to.contain(
`Project ${topicName} was accepted.`
)
})
})
it('shows the correctly rendered email confirm for topic rejected (finnish) template', () => {
clickSendRejectEmail(topicName)
clickEmailLanguage('finnish')
cy.window()
.its('confirm')
.should((confirmSpy) => {
expect(confirmSpy).to.be.calledOnce
expect(confirmSpy.getCall(0).args[0]).to.contain(
`Projekti ${topicName} hylätty.`
)
})
})
it('shows the correctly rendered email confirm for topic rejected (english) template', () => {
clickSendRejectEmail(topicName)
clickEmailLanguage('english')
cy.window()
.its('confirm')
.should((confirmSpy) => {
expect(confirmSpy).to.be.calledOnce
expect(confirmSpy.getCall(0).args[0]).to.contain(
`Project ${topicName} was rejected.`
)
})
})
it('shows the correctly rendered email confirm for customer review link (finnish)', () => {
clickSendReviewLinkEmail(topicName)
clickEmailLanguage('finnish')
cy.window()
.its('confirm')
.should((confirmSpy) => {
expect(confirmSpy).to.be.calledOnce
expect(confirmSpy.getCall(0).args[0]).to.contain(
`Arviointi on nyt auki projektille ${topicName} osoitteessa ${secretReviewLink}`
)
})
})
it('shows the correctly rendered email confirm for customer review link (english)', () => {
clickSendReviewLinkEmail(topicName)
clickEmailLanguage('english')
cy.window()
.its('confirm')
.should((confirmSpy) => {
expect(confirmSpy).to.be.calledOnce
expect(confirmSpy.getCall(0).args[0]).to.contain(
`Review is now open for project ${topicName}, go to ${secretReviewLink}`
)
})
})
})
})
})
@@ -0,0 +1,227 @@
const mockAnswers = [
{
type: 'info',
header: 'Ohje',
description:
'Ohje: Anna avovastauksiin vapaamuotoiset perustelut arvosanaehdotuksille. Voit käyttääapuna vinkkeinä annettuna esimerkkejä asioista, joihin voi kiinnittää huomiota. Voit myöskertoa vapaasti muista mielestäsi olennaisista asioista. Arvioi jokainen ryhmäläinen. Kirjoitaitseäsi koskevat perustelut erityisen huolellisesti. Huomaa, että saadakseen kurssistaarvosanaksi 5 ei jokaisen osa alueen tarvitse olla täydellinen. On luontevaa, että ryhmässäkontribuutioden painopisteet jakautuvat eri tavoin ja tämä on täysin ok myös tällä kurssilla.'
},
{
id: 1,
type: 'number',
answer: 5,
questionHeader: 'Grade (scale 0-5) '
},
{
id: 2,
type: 'text',
answer:
'Your satisfaction to the resulting product compared to your intial expectations:Your satisfaction to the resulting product compared to your intial expectations:',
questionHeader:
'Your satisfaction to the resulting product compared to your intial expectations:'
},
{
id: 3,
type: 'text',
answer:
'Your satisfaction to the resulting product compared to your intial expectations:Your satisfaction to the resulting product compared to your intial expectations:',
questionHeader: 'The quality of the product and fit for the purpose:'
},
{
id: 4,
type: 'text',
answer:
'Your satisfaction to the resulting product compared to your intial expectations:Your satisfaction to the resulting product compared to your intial expectations:',
questionHeader:
'What was the technical skill level of the team compared to your expectations?'
},
{
id: 5,
type: 'oneliner',
answer: 'spammyspam@spam.sp',
questionHeader: 'Your email adderess?'
}
]
const getAll = (...aliases) => {
const values = []
for (const alias of aliases) {
cy.get(alias).then((value) => values.push(value))
}
return cy.wrap(values)
}
describe('Customer review is shown', () => {
let locals = {}
before(() => {
cy.loginAsAdmin()
cy.createConfiguration({
name: 'Konfiguraatio A',
content: '',
registration_question_set_id: null,
review_question_set_1_id: null,
review_question_set_2_id: null,
customer_review_question_set_id: null
}).as('conf1')
cy.createConfiguration({
name: 'Conf 2',
content: '',
registration_question_set_id: null,
review_question_set_1_id: null,
review_question_set_2_id: null,
customer_review_question_set_id: null
}).as('conf2')
cy.get('@conf1').then((configuration1) => {
cy.createTopic(
{
title: 'Konfiguraatio A Topic A',
customerName: 'Super nice customer',
email: 'asiakas@cee',
description: 'Decription..',
environment: 'Webbi',
specialRequests: '',
additionalInfo: ''
},
configuration1.id
).as('conf1_topic1')
cy.get('@conf1_topic1').then((topic1) => {
cy.createGroupHack({
name: 'Group of Konfiguraatio A Topic A',
topicId: topic1.id,
configurationId: configuration1.id,
instructorId: null,
studentIds: ['012345678', '012345698']
}).as('conf1_topic1_group')
})
})
cy.get('@conf2').then((configuration2) => {
cy.createTopic(
{
title: 'Conf 2 Topic A',
customerName: 'Super nice customer',
email: 'asiakas@cee',
description: 'Decription..',
environment: 'Webbi',
specialRequests: '',
additionalInfo: ''
},
configuration2.id
).as('conf2_topic1')
cy.createTopic(
{
title: 'Conf 2 Topic B',
customerName: 'Super mad customer',
email: 'aasiakas@bee',
description: 'Decription..',
environment: 'Webbi',
specialRequests: '',
additionalInfo: ''
},
configuration2.id
).as('conf2_topic2')
getAll('@conf2_topic1', '@conf2_topic2').spread((topic1, topic2) => {
cy.createGroupHack({
name: 'Group of Conf 2 Topic A',
topicId: topic1.id,
configurationId: configuration2.id,
instructorId: null,
studentIds: ['012345678', '012345698']
}).as('conf2_topic1_group')
cy.createGroupHack({
name: 'Group of Conf 2 Topic B',
topicId: topic2.id,
configurationId: configuration2.id,
instructorId: null,
studentIds: ['012345678', '012345698']
}).as('conf2_topic2_group')
})
})
// don't create review for @conf2_topic2_group
getAll('@conf1_topic1_group', '@conf2_topic1_group').each((group) => {
cy.createCustomerReview({
answer_sheet: mockAnswers,
group_id: group.id,
topic_id: group.topicId,
configuration_id: group.configurationId
})
})
const aliases = [
'conf1',
'conf2',
'conf1_topic1',
'conf1_topic1_group',
'conf2_topic1',
'conf2_topic2',
'conf2_topic1_group',
'conf2_topic2_group'
]
for (const alias of aliases) {
cy.get('@' + alias).then((value) => {
locals[alias] = value
})
}
})
it('admin can visit customer reviews page', () => {
cy.loginAsAdmin()
cy.visit('/adminstration/customer-reviews')
cy.contains('Customer reviews')
})
it('all configurations are shown', () => {
cy.loginAsAdmin()
cy.visit('/adminstration/customer-reviews')
cy.contains('Customer reviews')
cy.get('[data-cy="customer-reviews-select"]').click()
cy.get('[data-cy="all-configurations"]').click()
const {
conf1_topic1_group,
conf2_topic1_group,
conf2_topic2_group
} = locals
cy.contains(conf1_topic1_group.name)
cy.contains(conf2_topic1_group.name)
cy.contains(conf2_topic2_group.name)
})
it('customer reviews for configuration 1 are shown', () => {
cy.loginAsAdmin()
cy.visit('/adminstration/customer-reviews')
cy.get('[data-cy="customer-reviews-select"]').click()
cy.get('[data-cy="Konfiguraatio A"]').click()
const { conf1_topic1_group } = locals
cy.contains(conf1_topic1_group.name)
})
it('customer reviews for configuration 2 are shown', () => {
cy.loginAsAdmin()
cy.visit('/adminstration/customer-reviews')
cy.get('[data-cy="customer-reviews-select"]').click()
cy.get('[data-cy="Conf 2"]').click()
const { conf2_topic1_group, conf2_topic2_group } = locals
cy.contains(conf2_topic1_group.name)
cy.contains('No review submitted for group ' + conf2_topic2_group.name)
})
after(() => {
const { conf1, conf2 } = locals
cy.deleteConfiguration(conf1.id)
cy.deleteConfiguration(conf2.id)
})
})
+17
View File
@@ -0,0 +1,17 @@
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}
+25
View File
@@ -0,0 +1,25 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add("login", (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This is will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
+711
View File
@@ -0,0 +1,711 @@
/* globals cy Cypress */
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
import { TEST_USER, TEST_USER2, TEST_ADMIN } from '../common'
const postLogin = (user) => {
const { headers } = user
return cy.request({
url: '/api/login',
method: 'POST',
headers
})
}
const loginAsUser = (user) => {
postLogin(user).then((res) => {
const userData = res.body
window.localStorage.setItem('loggedInUser', JSON.stringify(userData))
})
}
Cypress.Commands.add('loginAsUnregisteredUser', () => {
loginAsUser(TEST_USER)
})
Cypress.Commands.add('loginAsRegisteredUser', () => {
loginAsUser(TEST_USER2)
})
Cypress.Commands.add('loginAsAdmin', () => {
loginAsUser(TEST_ADMIN)
})
const withLoggedAdminToken = (fn) => {
postLogin(TEST_ADMIN).then((res) => {
const { token } = res.body
fn(token)
})
}
const withLoggedAdminTokenSuperHack = () => {
return postLogin(TEST_ADMIN).then((res) => res.body.token)
}
Cypress.Commands.add('deleteRegistrationQuestions', () => {
withLoggedAdminToken((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
cy.request({
url: '/api/registrationQuestions',
method: 'GET',
headers: authHeaders
}).then((res) => {
const { questionSets } = res.body
for (const set of questionSets) {
cy.request({
url: `/api/registrationQuestions/${set.id}`,
method: 'DELETE',
headers: authHeaders
})
}
})
})
})
Cypress.Commands.add('createRegistrationQuestionSet', (name, questions) => {
withLoggedAdminToken((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
cy.request({
url: '/api/registrationQuestions',
method: 'POST',
headers: authHeaders,
body: {
name,
questions
}
})
})
})
Cypress.Commands.add('deleteReviewQuestions', () => {
withLoggedAdminToken((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
cy.request({
url: '/api/reviewQuestions',
method: 'GET',
headers: authHeaders
}).then((res) => {
const { questionSets } = res.body
for (const set of questionSets) {
cy.request({
url: `/api/reviewQuestions/${set.id}`,
method: 'DELETE',
headers: authHeaders
})
}
})
})
})
Cypress.Commands.add('createReviewQuestionSet', (name, questions) => {
withLoggedAdminToken((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
cy.request({
url: '/api/reviewQuestions',
method: 'POST',
headers: authHeaders,
body: {
name,
questions
}
})
})
})
Cypress.Commands.add('createGroup', (groupData) => {
withLoggedAdminToken((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
const {
name,
topicId,
configurationId,
instructorId,
studentIds
} = groupData
cy.request({
url: '/api/groups',
method: 'POST',
headers: authHeaders,
body: {
name,
topicId,
configurationId,
instructorId,
studentIds
}
})
})
})
Cypress.Commands.add('deleteAllGroups', () => {
withLoggedAdminToken((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
cy.request({
url: '/api/groups',
method: 'GET',
headers: authHeaders
}).then((res) => {
const allGroups = res.body
for (const group of allGroups) {
cy.request({
url: `/api/groups/${group.id}`,
method: 'DELETE',
headers: authHeaders
})
}
})
})
})
Cypress.Commands.add(
'createNewTopic',
(newTopicName, customerName, topicDescription) => {
withLoggedAdminToken((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
cy.request({
url: '/api/topics',
method: 'POST',
headers: authHeaders,
active: true,
body: {
content: {
email: 'asiakas@asiakas.com',
title: newTopicName,
description: topicDescription,
environment: 'Web',
customerName: customerName,
additionalInfo: '',
specialRequests: ''
}
}
})
})
}
)
Cypress.Commands.add('setTopicActive', (topicId) => {
withLoggedAdminToken((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
cy.request({
url: `api/topics/${topicId}`,
method: 'PUT',
headers: authHeaders,
body: {
topic: {
active: true
}
}
})
})
})
Cypress.Commands.add(
'setPeerReviewOneActive',
(configurationName, configurationId, questionSetName) => {
withLoggedAdminToken((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
findReviewQuestionId(authHeaders, questionSetName).then((setId) => {
cy.request({
url: `api/configurations/${configurationId}`,
method: 'PUT',
headers: authHeaders,
body: {
name: configurationName,
review_question_set_1_id: setId
}
})
cy.request({
url: '/api/registrationManagement',
method: 'POST',
headers: authHeaders,
body: {
registrationManagement: {
peer_review_conf: 1,
peer_review_open: true,
peer_review_round: 1,
project_registration_conf: 1,
project_registration_open: true,
project_registration_message: '',
project_registration_info:
'Project registration will be open until DD.MM.2019.',
topic_registration_conf: 1,
topic_registration_open: true,
topic_registration_message: ''
}
}
})
})
})
}
)
Cypress.Commands.add('deleteAllPeerReviews', () => {
withLoggedAdminToken((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
cy.request({
url: '/api/peerreview/all',
method: 'GET',
headers: authHeaders
}).then((res) => {
const allReviews = res.body
for (const review of allReviews) {
cy.request({
url: `/api/peerreview/${review.id}`,
method: 'DELETE',
headers: authHeaders
})
}
})
})
})
const findReviewQuestionId = (authHeaders, questionSetName) => {
return cy
.request({
url: '/api/reviewQuestions',
method: 'GET',
headers: authHeaders
})
.then((res) => {
const { questionSets } = res.body
for (const set of questionSets) {
if (set.name === questionSetName) {
return set.id
}
}
})
}
Cypress.Commands.add('deleteAllEmailTemplates', () => {
withLoggedAdminToken((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
cy.request({
url: '/api/email/templates',
method: 'DELETE',
headers: authHeaders
})
})
})
const defaultEmailTemplates = () => ({
topicAccepted: { finnish: '', english: '' },
topicRejected: { finnish: '', english: '' }
})
Cypress.Commands.add(
'updateEmailTemplate',
(templateName, templateLanguage, text) => {
withLoggedAdminToken((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
const body = defaultEmailTemplates()
body[templateName][templateLanguage] = text
cy.request({
url: '/api/email/templates',
method: 'POST',
headers: authHeaders,
body
})
})
}
)
Cypress.Commands.add('updateAllEmailTemplates', (body) => {
withLoggedAdminToken((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
cy.request({
url: '/api/email/templates',
method: 'POST',
headers: authHeaders,
body
})
})
})
Cypress.Commands.add('deleteSentEmails', () => {
withLoggedAdminToken((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
cy.request({
url: '/api/email/sent-emails',
method: 'DELETE',
headers: authHeaders
})
})
})
Cypress.Commands.add('deleteCustomerReviewQuestions', () => {
withLoggedAdminToken((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
cy.request({
url: '/api/customerReviewQuestions',
method: 'GET',
headers: authHeaders
}).then((res) => {
const { questionSets } = res.body
for (const set of questionSets) {
cy.request({
url: `/api/customerReviewQuestions/${set.id}`,
method: 'DELETE',
headers: authHeaders
})
}
})
})
})
Cypress.Commands.add('createCustomerReviewQuestionSet', (name, questions) => {
withLoggedAdminToken((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
cy.request({
url: '/api/customerReviewQuestions',
method: 'POST',
headers: authHeaders,
body: {
name,
questions
}
})
})
})
Cypress.Commands.add(
'setCustomerReviewQuestionSetToConfiguration',
(configurationId) => {
withLoggedAdminToken((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
cy.request({
url: '/api/customerReviewQuestions',
method: 'GET',
headers: authHeaders
}).then((res) => {
const questionSet = res.body.questionSets[0]
cy.request({
url: `/api/configurations/${configurationId}`,
method: 'PUT',
headers: authHeaders,
body: {
content: null,
customer_review_question_set_id: questionSet.id,
name: 'Konfiguraatio 1',
registration_question_set_id: null,
review_question_set_1_id: null,
review_question_set_2_id: null
}
})
})
})
}
)
Cypress.Commands.add('deleteCustomerReviews', () => {
withLoggedAdminToken((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
cy.request({
url: '/api/customerReview/',
method: 'GET',
headers: authHeaders
}).then((res) => {
const { reviews } = res.body
if (reviews) {
}
for (const review of reviews) {
cy.request({
url: `/api/customerReview/${review.id}`,
method: 'DELETE',
headers: authHeaders
})
}
})
})
})
Cypress.Commands.add('deleteInstructorReviews', () => {
withLoggedAdminToken((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
cy.request({
url: '/api/instructorReview/',
method: 'GET',
headers: authHeaders
}).then((res) => {
const { reviews } = res.body
if (reviews) {
}
for (const review of reviews) {
cy.request({
url: `/api/instructorReview/${review.id}`,
method: 'DELETE',
headers: authHeaders
})
}
})
})
})
Cypress.Commands.add('createPeerReviews', (peerReviews) => {
withLoggedAdminToken((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
cy.request({
url: '/api/peerreview/insertTestData',
method: 'POST',
headers: authHeaders,
body: peerReviews
})
})
})
Cypress.Commands.add('createConfiguration', (configurationData) => {
return withLoggedAdminTokenSuperHack().then((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
const {
name,
content,
registration_question_set_id,
review_question_set_1_id,
review_question_set_2_id,
customer_review_question_set_id
} = configurationData
return cy
.request({
url: '/api/configurations',
method: 'POST',
headers: authHeaders,
body: {
name,
content,
registration_question_set_id,
review_question_set_1_id,
review_question_set_2_id,
customer_review_question_set_id
}
})
.then((res) => res.body.configuration)
})
})
Cypress.Commands.add('createTopic', (topicContent, topicConfigurationId) => {
return withLoggedAdminTokenSuperHack().then((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
return cy
.request({
url: '/api/topics',
method: 'POST',
headers: authHeaders,
body: {
content: topicContent,
configuration_id: topicConfigurationId
}
})
.then((res) => res.body.topic)
})
})
Cypress.Commands.add('deleteConfiguration', (configurationId) => {
withLoggedAdminTokenSuperHack().then((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
cy.request({
url: `/api/configurations/${configurationId}`,
method: 'DELETE',
headers: authHeaders
})
})
})
Cypress.Commands.add('deleteCustomerReview', (customerReviewId) => {
withLoggedAdminTokenSuperHack().then((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
cy.request({
url: `/api/customerreviews/delete/${customerReviewId}`,
method: 'DELETE',
headers: authHeaders
})
})
})
Cypress.Commands.add('createCustomerReview', (customerReview) => {
withLoggedAdminTokenSuperHack().then((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
//const { answerSheet, group_id, topic_id, configuration_id } = customerReview
cy.request({
url: '/api/customerReview',
method: 'POST',
headers: authHeaders,
body: {
customerReview
}
})
})
})
Cypress.Commands.add('createGroupHack', (groupData) => {
return withLoggedAdminTokenSuperHack().then((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
const {
name,
topicId,
configurationId,
instructorId,
studentIds
} = groupData
return cy
.request({
url: '/api/groups',
method: 'POST',
headers: authHeaders,
body: {
name,
topicId,
configurationId,
instructorId,
studentIds
}
})
.then((res) => res.body)
})
})
Cypress.Commands.add('deleteCustomerReview', (customerReviewId) => {
withLoggedAdminToken().then((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
cy.request({
url: `/api/customerreviews/delete/${customerReviewId}`,
method: 'DELETE',
headers: authHeaders
})
})
})
Cypress.Commands.add('createCustomerReview', (customerReview) => {
withLoggedAdminTokenSuperHack().then((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
//const { answerSheet, group_id, topic_id, configuration_id } = customerReview
cy.request({
url: '/api/customerReview',
method: 'POST',
headers: authHeaders,
body: {
customerReview
}
})
})
})
Cypress.Commands.add('createGroupHack', (groupData) => {
return withLoggedAdminTokenSuperHack().then((token) => {
const authHeaders = {
Authorization: 'Bearer ' + token
}
const {
name,
topicId,
configurationId,
instructorId,
studentIds
} = groupData
return cy
.request({
url: '/api/groups',
method: 'POST',
headers: authHeaders,
body: {
name,
topicId,
configurationId,
instructorId,
studentIds
}
})
.then((res) => res.body)
})
})
+46
View File
@@ -0,0 +1,46 @@
version: '2'
services:
nginx:
image: ohtuprojektiilmo/e2e-nginx
ports:
- '3000:80'
restart: always
depends_on:
- frontend
- backend
frontend:
image: ohtuprojektiilmo/ohtufront
build: '.'
expose:
- '3000'
depends_on:
- backend
- db
backend:
image: ohtuprojektiilmo/ohtuback
restart: always
expose:
- '3001'
depends_on:
- db
environment:
- NODE_ENV=production
- PORT=3001
- SECRET=yoursecrethere
- EMAIL_ENABLED=false
db:
image: postgres:10.6
restart: always
expose:
- '5432'
environment:
PGDATA: /data/test
volumes:
- pg-data:/data/test
# don't set volumes so that we have a fresh db every time
volumes:
pg-data:
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+128
View File
@@ -0,0 +1,128 @@
#!/bin/bash
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
dco='docker-compose -f e2e/docker-compose.e2e.yml'
# Exit if someone is trying to run this script inside the e2e directory instead
# of the repository root. We need to be in the repo root to build our image!
#
# There's no real reason why the cypress folder and all related things
# **couldn't** be in the repo root, but I find this structure to be cleaner.
# Just like "src" etc.
# /JH
if [ "$(basename "${PWD}")" == "e2e" ]; then
echo -e "${RED}ERROR${NC} Current working directory is '$(basename "${PWD}")'!"
echo -e " Script requires working directory to be repository root."
echo ''
echo " (you should probably do 'cd ..')"
echo ''
exit 1
fi
function printProgress() {
local STAGE=$1
local TEXT=$2
echo -e "${GREEN}${STAGE}${NC} ${TEXT}"
}
function install() {
# PREPARE IMAGES
printProgress 'INSTALL (0/3)' 'Pulling images for E2E tests...'
$dco pull db backend nginx
# build frontend image from current directory, travis will build the latest
# version for testing
printProgress 'INSTALL (1/3)' 'Pulled images, building frontend'
docker build --pull --cache-from ohtuprojektiilmo/ohtufront --tag ohtuprojektiilmo/ohtufront .
printProgress 'INSTALL (2/3)' 'Installing Cypress globally...'
npm i -g cypress@^3.1.5
printProgress 'INSTALL (3/3)' 'Cypress installed, installation complete.'
}
function setup() {
# Bring up services for tests
printProgress 'SETUP (0/4)' 'Preparing services for E2E tests. Starting database...'
$dco up -d db
printProgress 'SETUP (1/4)' 'Database is up, running migrations'
$dco run --entrypoint 'npm run db:migrate' --rm backend
printProgress 'SETUP (2/4)' 'Migrations ran, running seeds'
$dco run --entrypoint 'npm run db:seed:all' --rm backend
printProgress 'SETUP (3/4)' 'Seeds ran, starting backend, frontend and nginx'
$dco up -d backend frontend nginx
printProgress 'SETUP (4/4)' 'Infrastructure is ready!'
}
function teardown() {
printProgress 'TEARDOWN (0/1)' 'Tearing down infra with docker-compose down'
$dco down -v
printProgress 'TEARDOWN (1/1)' 'Teardown complete!'
}
function run() {
if [ "${CI}" == 'true' ]; then
# Record runs to the Cypress Dashboard if we're in CI
# Assume CYPRESS_RECORD_KEY has been set in travis
CYPRESS_baseUrl=http://localhost:3000/projekti npm run cypress:run -- --record
else
# probably running locally
CYPRESS_baseUrl=http://localhost:3000/projekti npm run cypress:run
fi
}
function open() {
CYPRESS_baseUrl=http://localhost:3000/projekti npm run cypress:open
}
if [ "$1" == 'run' ]; then
run
elif [ "$1" == 'open' ]; then
open
elif [ "$1" == 'install' ]; then
install
elif [ "$1" == 'setup' ]; then
setup
elif [ "$1" == 'teardown' ]; then
teardown
else
echo "Run ohtuilmo-project's end-to-end (E2E) tests."
echo ''
echo 'USAGE:'
echo " $0 <run|open|install|setup|teardown>"
echo ''
echo ' "install" and "setup" must be run before "run" or "open".'
echo ' Port 3000 must be available for binding.'
echo ''
echo 'Commands:'
echo ''
echo ' run'
echo ' Runs the Cypress tests in CI mode.'
echo ''
echo ' open'
echo ' Opens the Cypress GUI.'
echo ''
echo ' install'
echo ' Pulls required images with docker-compose and'
echo ' builds the frontend service from the current'
echo ' directory.'
echo ''
echo ' setup'
echo ' Brings up the docker-compose network so that the tests can be'
echo ' run.'
echo ''
echo ' teardown'
echo ' Tears down the docker-compose network.'
echo ''
exit 1
fi
+19365
View File
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
{
"name": "ohtuilmo-frontend",
"version": "0.1.0",
"private": true,
"homepage": "http://svm-45.cs.helsinki.fi/projekti",
"proxy": "http://localhost:3001",
"dependencies": {
"@babel/runtime": "^7.3.1",
"@material-ui/core": "^3.6.0",
"@material-ui/icons": "^3.0.1",
"axios": "^0.18.0",
"babel-eslint": "9.0.0",
"eslint": "5.6.0",
"eslint-plugin-react": "^7.11.1",
"history": "^4.7.2",
"prop-types": "^15.6.2",
"react": "^16.8.1",
"react-dom": "^16.8.1",
"react-drag-list": "^1.1.0",
"react-markdown": "^4.0.3",
"react-redux": "^5.1.1",
"react-router-dom": "^4.3.1",
"react-scripts": "^2.1.3",
"react-select": "^2.4.1",
"redux": "^4.0.1",
"redux-thunk": "^2.3.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"eject": "react-scripts eject",
"lint": "eslint src/",
"e2e:install": "./e2e/run_cypress_e2e.sh install",
"e2e:setup": "./e2e/run_cypress_e2e.sh setup",
"e2e:run": "./e2e/run_cypress_e2e.sh run",
"e2e:open": "./e2e/run_cypress_e2e.sh open",
"e2e:teardown": "./e2e/run_cypress_e2e.sh teardown",
"cypress:open": "npx cypress open -P ./",
"cypress:run": "npx cypress run -P ./"
},
"devDependencies": {
"husky": "^1.2.0"
},
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 256 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icon-css-fi" viewBox="0 0 640 480">
<!-- https://github.com/lipis/flag-icon-css -->
<path fill="#fff" d="M0 0h640v480H0z"/>
<path fill="#003580" d="M0 174.5h640v131H0z"/>
<path fill="#003580" d="M175.5 0h130.9v480h-131z"/>
</svg>

After

Width:  |  Height:  |  Size: 286 B

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icon-css-gb" viewBox="0 0 640 480">
<!-- https://github.com/lipis/flag-icon-css -->
<defs>
<clipPath id="a">
<path fill-opacity=".7" d="M-85.3 0h682.6v512H-85.3z"/>
</clipPath>
</defs>
<g clip-path="url(#a)" transform="translate(80) scale(.94)">
<g stroke-width="1pt">
<path fill="#012169" d="M-256 0H768v512H-256z"/>
<path fill="#fff" d="M-256 0v57.2L653.5 512H768v-57.2L-141.5 0H-256zM768 0v57.2L-141.5 512H-256v-57.2L653.5 0H768z"/>
<path fill="#fff" d="M170.7 0v512h170.6V0H170.7zM-256 170.7v170.6H768V170.7H-256z"/>
<path fill="#c8102e" d="M-256 204.8v102.4H768V204.8H-256zM204.8 0v512h102.4V0H204.8zM-256 512L85.3 341.3h76.4L-179.7 512H-256zm0-512L85.3 170.7H9L-256 38.2V0zm606.4 170.7L691.7 0H768L426.7 170.7h-76.3zM768 512L426.7 341.3H503l265 132.5V512z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 886 B

+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png" />
<link
href="https://fonts.googleapis.com/css?family=Roboto:300"
rel="stylesheet"
/>
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Software engineering project</title>
<link rel="shortcut icon" type="image/png" href="/favicon.png" />
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
#app-wrapper {
display: flex;
flex-direction: column;
}
#app-content {
padding: 40px 35px;
}
@media only screen and (max-width: 600px) {
#app-content {
padding: 20px 15px;
}
}
+252
View File
@@ -0,0 +1,252 @@
import React, { Component } from 'react'
import { connect } from 'react-redux'
import { Router, Route, Switch, Link } from 'react-router-dom'
import { createBrowserHistory } from 'history'
import './App.css'
// Components
import ConfigurationPage from './components/ConfigurationPage'
import LandingPage from './components/LandingPage'
import TopicFormPage from './components/TopicFormPage'
import TopicListPage from './components/TopicListPage'
import ViewTopicPage from './components/ViewTopicPage'
import RegistrationPage from './components/RegistrationPage'
import ParticipantsPage from './components/ParticipantsPage'
import NavigationBar from './components/common/NavigationBar'
import Notification from './components/common/Notification'
import LoadingSpinner from './components/common/LoadingSpinner'
import RegistrationQuestionsPage from './components/RegistrationQuestionsPage'
import PeerReviewQuestionsPage from './components/PeerReviewQuestionsPage'
import CustomerReviewQuestionsPage from './components/CustomerReviewQuestionsPage'
import RegistrationManagementPage from './components/RegistrationManagementPage'
import RegistrationDetailsPage from './components/RegistrationDetailsPage'
import GroupManagementPage from './components/GroupManagementPage'
import PeerReviewPage from './components/PeerReviewPage'
import EmailTemplatesPage from './components/EmailTemplatesPage'
import InstructorPage from './components/InstructorPage'
import CustomerReviewPage from './components/CustomerReviewPage'
import InstructorReviewPage from './components/InstructorReviewPage'
import ViewCustomerReviewsPage from './components/ViewCustomerReviewsPage'
import Registrations from './components/Registrations'
import InstructorReviews from './components/InstructorReviews'
// Actions
import appActions from './reducers/actions/appActions'
import * as notificationActions from './reducers/actions/notificationActions'
import loginPageActions from './reducers/actions/loginPageActions'
import registrationmanagementActions from './reducers/actions/registrationManagementActions'
import registrationActions from './reducers/actions/registrationActions'
import peerReviewPageActions from './reducers/actions/peerReviewPageActions'
import * as userActions from './reducers/actions/userActions'
// Protected routes
import {
AdminRoute,
LoginRoute,
InstructorRoute
} from '../src/utils/protectedRoutes'
import loginService from './services/login'
const history = createBrowserHistory({ basename: process.env.PUBLIC_URL })
const NotFound = () => (
<div className="not-found-page">
<h1>Page not found</h1>
<Link data-cy="return-link" to="/">
Return to the home page
</Link>
</div>
)
class App extends Component {
constructor(props) {
super(props)
this.logout = this.logout.bind(this)
}
componentDidMount() {
this.props.updateIsLoading(true)
console.log(window.location.href)
if (!window.location.href.includes('customer-review/')){
this.props.loginUser()
}
this.fetchRegistrationManagement()
this.props.updateIsLoading(false)
setInterval(() => {
console.log('timeout')
if (!window.location.href.includes('customer-review/')){
loginService.login()
}
}, 60 * 1000)
}
fetchRegistrationManagement = async () => {
try {
await this.props.fetchRegistrationManagement()
} catch (e) {
console.log('error happened', e)
this.props.setError(
'Error fetching registration management configuration',
5000
)
}
}
logout() {
this.props.updateIsLoading(true)
this.props.logoutUser()
this.props.clearRegistrations()
this.props.updateIsLoading(false)
}
render() {
let loadingSpinner
if (this.props.isLoading) {
loadingSpinner = <LoadingSpinner />
}
return (
<Router history={history}>
<div id="app-wrapper">
<NavigationBar logout={this.logout} />
<Notification />
<div id="app-content">
{loadingSpinner}
<Switch>
<Route path="/login" render={() => null} />
<LoginRoute exact path="/" render={() => <LandingPage />} />
<AdminRoute
exact
path="/topics"
render={() => <TopicListPage />}
/>
<Route
exact
path="/topics/create"
render={() => <TopicFormPage />}
/>
<Route
exact
path="/topics/:id"
render={(props) => <ViewTopicPage {...props} />}
/>
<AdminRoute
exact
path="/administration/configuration"
render={() => <ConfigurationPage />}
/>
<AdminRoute
exact
path="/administration/participants"
render={() => <ParticipantsPage />}
/>
<AdminRoute
exact
path="/administration/customer-review-questions"
render={() => <CustomerReviewQuestionsPage />}
/>
<AdminRoute
exact
path="/administration/peer-review-questions"
render={() => <PeerReviewQuestionsPage />}
/>
<AdminRoute
exact
path="/administration/registration-questions"
render={() => <RegistrationQuestionsPage />}
/>
<AdminRoute
exact
path="/administration/groups"
render={() => <GroupManagementPage />}
/>
<AdminRoute
exact
path="/administration/email-templates"
render={() => <EmailTemplatesPage />}
/>
<AdminRoute
exact
path="/administration/registrations"
render={() => <Registrations />}
/>
<AdminRoute
exact
path="/administration/reviews"
render={() => <InstructorReviews />}
/>
<Route
exact
path="/customer-review/:id"
render={(props) => <CustomerReviewPage {...props} />}
/>
<LoginRoute
exact
path="/register"
user={this.props.user}
render={() => <RegistrationPage />}
/>
<LoginRoute
exact
path="/peerreview"
user={this.props.user}
render={() => <PeerReviewPage />}
/>
<AdminRoute
exact
path="/administration/registrationmanagement"
render={() => <RegistrationManagementPage />}
/>
<InstructorRoute
exact
path="/instructorpage"
render={() => <InstructorPage />}
/>
<InstructorRoute
exact
path="/instructorreviewpage"
render={() => <InstructorReviewPage />}
/>
<InstructorRoute
path="/adminstration/customer-reviews"
render={() => <ViewCustomerReviewsPage />}
/>
<LoginRoute
exact
path="/registrationdetails"
render={() => <RegistrationDetailsPage />}
/>
<Route component={NotFound} />
</Switch>
</div>
</div>
</Router>
)
}
}
const mapStateToProps = (state) => {
return {
isLoading: state.app.isLoading,
user: state.user
}
}
const mapDispatchToProps = {
setError: notificationActions.setError,
...loginPageActions,
...appActions,
fetchRegistrationManagement:
registrationmanagementActions.fetchRegistrationManagement,
clearRegistrations: registrationActions.clearRegistrations,
...peerReviewPageActions,
logoutUser: userActions.logoutUser,
loginUser: userActions.loginUser
}
const ConnectedApp = connect(mapStateToProps, mapDispatchToProps)(App)
export default ConnectedApp
@@ -0,0 +1,421 @@
import React from 'react'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import './TopicFormPage.css'
// MUI
import Select from '@material-ui/core/Select'
import MenuItem from '@material-ui/core/MenuItem'
import TextField from '@material-ui/core/TextField'
import Button from '@material-ui/core/Button'
import ExpansionPanel from '@material-ui/core/ExpansionPanel'
import ExpansionPanelSummary from '@material-ui/core/ExpansionPanelSummary'
import ExpansionPanelDetails from '@material-ui/core/ExpansionPanelDetails'
import ExpansionPanelActions from '@material-ui/core/ExpansionPanelActions'
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
import Divider from '@material-ui/core/Divider'
// Service
import configurationService from '../services/configuration'
import registrationQuestionSetService from '../services/registrationQuestionSet'
import reviewQuestionSetService from '../services/peerReviewQuestionSet'
import customerReviewQuestionService from '../services/customerReviewQuestionSet'
// Actions
import configurationPageActions from '../reducers/actions/configurationPageActions'
import * as notificationActions from '../reducers/actions/notificationActions'
import questionsFormPageActions from '../reducers/actions/questionsFormPageActions'
import RegistrationQuestionsTable from './RegistrationQuestionsTable'
import PeerReviewQuestionsTable from './PeerReviewQuestionsTable'
class ConfigurationPage extends React.Component {
async componentDidMount() {
this.setQuestions()
if (this.props.configurations.length === 0) {
await this.props.fetchConfigurations()
}
}
setQuestions = async () => {
try {
const registrationQuestions = await registrationQuestionSetService.getAll()
this.props.setRegistrationQuestions(registrationQuestions)
const reviewQuestions = await reviewQuestionSetService.getAll()
this.props.setReviewQuestions(reviewQuestions)
const customerReviewQuestions = await customerReviewQuestionService.getAll()
this.props.setCustomerReviewQuestions(customerReviewQuestions)
} catch (e) {
console.log('error happened', e)
this.props.setError('Error fetching question sets', 5000)
}
}
handleConfigurationChange = (event) => {
if (event.target.value === 'new') {
this.props.selectNewConfig()
this.props.updateNewStatus(true)
} else {
this.props.updateSelectedConfig(event.target.value)
this.props.updateConfigForm(event.target.value)
this.props.updateNewStatus(false)
}
}
handleQuestionSetChange = (event) => {
if (event.target.name === 'registration') {
this.props.updateSelectedRegistrationQuestions(event.target.value)
} else if (event.target.name === 'review1') {
this.props.updateSelectedReviewQuestions1(event.target.value)
} else if (event.target.name === 'review2') {
this.props.updateSelectedReviewQuestions2(event.target.value)
} else if (event.target.name === 'customer-review') {
this.props.updateSelectedCustomerReviewQuestions(event.target.value)
}
}
saveNewConfig = async (event) => {
event.preventDefault()
const configuration = { ...this.props.form }
try {
const response = await configurationService.create(configuration)
this.props.setConfigurations([
...this.props.configurations,
response.configuration
])
this.props.updateSelectedConfig(response.configuration)
this.props.updateNewStatus(false)
this.props.setSuccess('New configuration saved', 5000)
} catch (e) {
console.log(e)
this.props.setError('Error saving new configuration', 5000)
}
}
updateConfig = async (event) => {
event.preventDefault()
try {
const configuration = { ...this.props.form }
const response = await configurationService.update(
configuration,
this.props.selectedConfig.id
)
this.props.updateConfigurations(response.configuration)
this.props.updateSelectedConfig(response.configuration)
this.props.updateConfigForm(response.configuration)
this.props.setSuccess('Configuration updated', 5000)
} catch (e) {
console.log(e)
this.props.setError('Error saving edits to configuration', 5000)
}
}
goToAddRegistrationQuestions = () => {
this.props.history.push('/administration/registration-questions')
}
goToAddReviewQuestions = () => {
this.props.history.push('/administration/peer-review-questions')
}
goToAddCustomerReviewQuestions = () => {
this.props.history.push('/administration/customer-review-questions')
}
render() {
return (
<div className="admin-page-container">
<h3>Change configuration</h3>
<Select
value={this.props.selectedConfig ? this.props.selectedConfig : 'new'}
onChange={this.handleConfigurationChange}
>
{this.props.configurations.map((item) => (
<MenuItem key={item.id} value={item}>
{item.name}
</MenuItem>
))}
<MenuItem value="new">New</MenuItem>
</Select>
<div>
<TextField
label="Nimi"
required
margin="normal"
value={this.props.form.name}
onChange={(e) => this.props.updateConfigName(e.target.value)}
/>
</div>
<h3>Questions</h3>
<div style={{ paddingBottom: 10 }}>
<ExpansionPanel data-cy="expansion-registration-questions">
<ExpansionPanelSummary expandIcon={<ExpandMoreIcon />}>
<div
style={{
width: '100%',
display: 'flex',
flexDirection: 'row',
alignItems: 'center'
}}
>
<p>Registration questions</p>
</div>
</ExpansionPanelSummary>
<ExpansionPanelDetails>
<div>
<Divider />
{this.props.selectedRegister && (
<RegistrationQuestionsTable
questions={this.props.selectedRegister.questions}
/>
)}
</div>
</ExpansionPanelDetails>
<ExpansionPanelActions>
<Select
name="registration"
value={
this.props.selectedRegister
? this.props.selectedRegister
: 'default'
}
onChange={this.handleQuestionSetChange}
>
<MenuItem value="default" disabled>
Pick registration questions
</MenuItem>
{this.props.allRegistrationQuestions.map((item) => (
<MenuItem key={item.id} value={item}>
{item.name}
</MenuItem>
))}
</Select>
<Button
style={{ marginRight: '10px', height: '40px' }}
color="primary"
variant="contained"
onClick={this.goToAddRegistrationQuestions}
>
Configure
</Button>
</ExpansionPanelActions>
</ExpansionPanel>
<ExpansionPanel data-cy="expansion-review-questions-1">
<ExpansionPanelSummary expandIcon={<ExpandMoreIcon />}>
<div
style={{
width: '100%',
display: 'flex',
flexDirection: 'row',
alignItems: 'center'
}}
>
<p>Peer review questions 1</p>
</div>
</ExpansionPanelSummary>
<ExpansionPanelDetails>
<div>
<Divider />
{this.props.selectedReview1 && (
<PeerReviewQuestionsTable
questions={this.props.selectedReview1.questions}
/>
)}
</div>
</ExpansionPanelDetails>
<ExpansionPanelActions>
<Select
data-cy="select-review-questions-1"
name="review1"
value={
this.props.selectedReview1
? this.props.selectedReview1
: 'default'
}
onChange={this.handleQuestionSetChange}
>
<MenuItem value="default" disabled>
Pick review 1 questions
</MenuItem>
{this.props.allReviewQuestions.map((item) => (
<MenuItem
key={item.id}
value={item}
data-cy="menu-item-review-questions-1"
>
{item.name}
</MenuItem>
))}
</Select>
<Button
style={{ marginRight: '10px', height: '40px' }}
color="primary"
variant="contained"
onClick={this.goToAddReviewQuestions}
>
Configure
</Button>
</ExpansionPanelActions>
</ExpansionPanel>
<ExpansionPanel data-cy="expansion-review-questions-2">
<ExpansionPanelSummary expandIcon={<ExpandMoreIcon />}>
<div
style={{
width: '100%',
display: 'flex',
flexDirection: 'row',
alignItems: 'center'
}}
>
<p>Peer review questions 2</p>
</div>
</ExpansionPanelSummary>
<ExpansionPanelDetails>
<div>
<Divider />
{this.props.selectedReview2 && (
<PeerReviewQuestionsTable
questions={this.props.selectedReview2.questions}
/>
)}
</div>
</ExpansionPanelDetails>
<ExpansionPanelActions>
<Select
name="review2"
value={
this.props.selectedReview2
? this.props.selectedReview2
: 'default'
}
onChange={this.handleQuestionSetChange}
>
<MenuItem value="default" disabled>
Pick review 2 questions
</MenuItem>
{this.props.allReviewQuestions.map((item) => (
<MenuItem key={item.id} value={item}>
{item.name}
</MenuItem>
))}
</Select>
<Button
style={{ marginRight: '10px', height: '40px' }}
color="primary"
variant="contained"
onClick={this.goToAddReviewQuestions}
>
Configure
</Button>
</ExpansionPanelActions>
</ExpansionPanel>
<ExpansionPanel data-cy="expansion-customer-review-questions">
<ExpansionPanelSummary expandIcon={<ExpandMoreIcon />}>
<div
style={{
width: '100%',
display: 'flex',
flexDirection: 'row',
alignItems: 'center'
}}
>
<p>Customer review questions</p>
</div>
</ExpansionPanelSummary>
<ExpansionPanelDetails>
<div>
<Divider />
{this.props.selectedCustomerReview && (
<PeerReviewQuestionsTable
questions={this.props.selectedCustomerReview.questions}
/>
)}
</div>
</ExpansionPanelDetails>
<ExpansionPanelActions>
<Select
name="customer-review"
value={
this.props.selectedCustomerReview
? this.props.selectedCustomerReview
: 'default'
}
onChange={this.handleQuestionSetChange}
>
<MenuItem value="default" disabled>
Pick customer review questions
</MenuItem>
{this.props.allCustomerReviewQuestions.map((item) => (
<MenuItem key={item.id} value={item}>
{item.name}
</MenuItem>
))}
</Select>
<Button
style={{ marginRight: '10px', height: '40px' }}
color="primary"
variant="contained"
onClick={this.goToAddCustomerReviewQuestions}
>
Configure
</Button>
</ExpansionPanelActions>
</ExpansionPanel>
</div>
<Button
color="primary"
variant="contained"
onClick={this.props.isNew ? this.saveNewConfig : this.updateConfig}
>
Save
</Button>
</div>
)
}
}
const mapStateToProps = (state) => {
const {
configurations,
selectedConfig,
allRegistrationQuestions,
allReviewQuestions,
allCustomerReviewQuestions,
selectedRegister,
selectedReview1,
selectedReview2,
selectedCustomerReview,
form,
isNew
} = state.configurationPage
return {
configurations,
selectedConfig,
allRegistrationQuestions,
allReviewQuestions,
allCustomerReviewQuestions,
selectedRegister,
selectedReview1,
selectedReview2,
selectedCustomerReview,
form,
isNew
}
}
const mapDispatchToProps = {
...configurationPageActions,
...questionsFormPageActions,
setError: notificationActions.setError,
setSuccess: notificationActions.setSuccess,
fetchConfigurations: configurationPageActions.fetchConfigurations
}
const ConnectedconfigurationPage = connect(
mapStateToProps,
mapDispatchToProps
)(ConfigurationPage)
export default withRouter(ConnectedconfigurationPage)
@@ -0,0 +1,20 @@
.customer-review-container {
max-width: 900px;
margin: 0 auto;
}
.customer-review-container__main {
padding: 1em 2em 2em 2em;
}
.customer-review-box {
padding: 1em 0 2em 0;
}
.customer-review-box:not(:last-of-type) {
border-bottom: 1px solid #eee;
}
.customer-review-container__h1 {
text-align: center;
}
@@ -0,0 +1,353 @@
import React from 'react'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import './CustomerReviewPage.css'
import Typography from '@material-ui/core/Typography'
import TextField from '@material-ui/core/TextField'
import Button from '@material-ui/core/Button'
import Paper from '@material-ui/core/Paper'
import Radio from '@material-ui/core/Radio'
import FormControlLabel from '@material-ui/core/FormControlLabel'
import RadioGroup from '@material-ui/core/RadioGroup'
import * as notificationActions from '../../reducers/actions/notificationActions'
import customerReviewPageActions from '../../reducers/actions/customerReviewPageActions'
import customerReviewService from '../../services/customerReview'
/**
* @typedef {{value: any, onValueChange: function, options?: object}} InputComponentProps
* @typedef {(props: InputComponentProps) => any} InputComponent
*/
const passEventValueTo = (callback) => (e) => callback(e.target.value)
const withParseInt = (callback) => (value) => callback(parseInt(value, 10))
/** @type {InputComponent} */
const TextInput = ({ value, onValueChange, ...textFieldProps }) => (
<TextField
{...textFieldProps}
value={value}
rows="8"
fullWidth
multiline
variant="outlined"
onChange={passEventValueTo(onValueChange)}
/>
)
/** @type {InputComponent} */
const OnelinerInput = ({ value, onValueChange, ...textFieldProps }) => (
<TextField
{...textFieldProps}
value={value}
variant="outlined"
onChange={passEventValueTo(onValueChange)}
/>
)
/** @type {InputComponent} */
const NumberInput = ({ value, onValueChange, ...inputProps }) => (
<input
{...inputProps}
type="number"
value={value}
style={{ fontSize: 'inherit', lineHeight: '2em' }}
variant="outlined"
onChange={passEventValueTo(withParseInt(onValueChange))}
/>
)
const defaultOptions = ['1', '2', '3', '4', '5']
/** @type {InputComponent} */
const RangeInput = ({ value, onValueChange, options, ...inputProps }) => {
const makeRadio = () => <Radio color="primary" />
const inputs = (options || defaultOptions).map((option) => (
<FormControlLabel
key={option}
value={`${option}`}
control={makeRadio()}
label={`${option}`}
labelPlacement="top"
/>
))
return (
<RadioGroup
{...inputProps}
value={`${value}`}
onChange={passEventValueTo(onValueChange)}
row
>
{inputs}
</RadioGroup>
)
}
/**
* Gets the input component for the specified question type, or returns
* undefined if none found.
* @returns {InputComponent | undefined}
*/
const getQuestionInputComponent = (type) => {
const typeToComponent = {
text: TextInput,
number: NumberInput,
range: RangeInput,
oneliner: OnelinerInput
}
return typeToComponent[type]
}
const Question = ({ question, answer, onAnswerChange }) => {
const InputComponent = getQuestionInputComponent(question.type)
const input = InputComponent && (
<InputComponent
value={answer}
onValueChange={onAnswerChange}
options={question.options}
required
data-cy={`${question.type}Input-${question.header}`}
/>
)
return (
<div className="customer-review-box">
<Typography variant="h6" className="customer-review-box__header">
{question.header}
</Typography>
<p>{question.description}</p>
{input}
</div>
)
}
const Questions = ({ questions, answerSheet, onAnswerUpdate }) => {
return (
<div className="customer-review-questions">
{questions.map((question, questionId) => {
const currentAnswer = answerSheet[questionId].answer
const onAnswerChange = (answer) => onAnswerUpdate(answer, questionId)
return (
<Question
key={questionId}
question={question}
answer={currentAnswer}
onAnswerChange={onAnswerChange}
/>
)
})}
</div>
)
}
class CustomerReviewPage extends React.Component {
async componentDidMount() {
const id = this.props.match.params.id
try {
const group = await customerReviewService.getDataForReview(id)
if (group) {
this.props.setReview(group.hasAnswered)
this.props.setGroupName(group.groupName)
this.props.setGroupId(group.groupId)
this.props.setTopicId(group.topicId)
this.props.setConfiguration(group.configuration)
const reviewQuestionSet = await customerReviewService.getReviewQuestions(
group.configuration
)
this.props.setQuestions(reviewQuestionSet.questions)
this.fetchCustomerReviewQuestions(this.props.questionObject)
} else {
this.props.setNoGroup(true)
}
} catch (e) {
console.log(e)
this.props.setError('Some error happened')
}
}
async fetchCustomerReviewQuestions(questionObject) {
const initializeNumberAnswer = (question, questionId) => {
return {
type: 'number',
questionHeader: question.header,
id: questionId,
answer: 0
}
}
const initializeTextAnswer = (question, questionId) => {
return {
type: 'text',
questionHeader: question.header,
id: questionId,
answer: ''
}
}
const initializeOnelinerAnswer = (question, questionId) => {
return {
type: 'oneliner',
questionHeader: question.header,
id: questionId,
answer: ''
}
}
const initializeRangeAnswer = (question, questionId) => ({
type: 'range',
questionHeader: question.header,
questionOptions: question.options,
id: questionId,
answer: null
})
const initializers = {
number: initializeNumberAnswer,
text: initializeTextAnswer,
range: initializeRangeAnswer,
oneliner: initializeOnelinerAnswer
}
const tempAnswerSheet = questionObject.map((question, questionID) => {
const initializer = initializers[question.type]
return initializer ? initializer(question, questionID) : question
})
this.props.initializeAnswerSheet(tempAnswerSheet)
}
handleSubmit = async (event) => {
event.preventDefault()
const answer = window.confirm(
'Answers can not be changed after submitting. Continue?'
)
if (!answer) return
try {
await customerReviewService.create({
customerReview: {
answer_sheet: this.props.answerSheet,
group_id: this.props.groupId,
topic_id: this.props.topicId,
configuration_id: this.props.configuration
}
})
this.props.setSuccess('Review saved!')
this.props.setReview(true)
} catch (e) {
console.log('error happened', e)
this.props.setError(e.response.data.error)
}
}
render() {
const {
answerSheet,
updateAnswer,
isInitializing,
hasReviewed,
questionObject,
groupName,
noGroup
} = this.props
if (isInitializing) {
return (
<div className="customer-review-container">
<h1 className="customer-review-container__h1">Loading!</h1>
</div>
)
}
if (hasReviewed) {
return (
<div className="customer-review-container">
<h1 className="customer-review-container__h1">
Thank you for the review
</h1>
</div>
)
}
if (noGroup) {
return (
<div className="customer-review-container">
<h1 className="customer-review-container__h1">
No group assigned for topic!
</h1>
</div>
)
} else {
return (
<div className="customer-review-container">
<h1 className="customer-review-container__h1">Customer review</h1>
<h1 className="customer-review-container__h1">{groupName}</h1>
<Paper elevation={1} className="customer-review-container__main">
<form onSubmit={this.handleSubmit}>
<Questions
questions={questionObject}
answerSheet={answerSheet}
onAnswerUpdate={updateAnswer}
/>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Button
margin-right="auto"
margin-left="auto"
variant="contained"
color="primary"
type="submit"
data-cy="submit-customer-review-button"
>
Submit
</Button>
</div>
</form>
</Paper>
</div>
)
}
}
}
const mapStateToProps = (state) => {
return {
groups: state.groupPage.groups,
answerSheet: state.customerReviewPage.answerSheet,
isInitializing: state.customerReviewPage.isInitializing,
hasReviewed: state.customerReviewPage.hasReviewed,
questionObject: state.customerReviewPage.questions,
groupName: state.customerReviewPage.groupName,
groupId: state.customerReviewPage.groupId,
topicId: state.customerReviewPage.topicId,
configuration: state.customerReviewPage.configuration,
noGroup: state.customerReviewPage.noGroup
}
}
const mapDispatchToProps = {
updateAnswer: customerReviewPageActions.updateAnswer,
initializeAnswerSheet: customerReviewPageActions.initializeAnswerSheet,
setLoading: customerReviewPageActions.setLoading,
setReview: customerReviewPageActions.setReview,
setQuestions: customerReviewPageActions.setQuestions,
setGroupName: customerReviewPageActions.setGroupName,
setGroupId: customerReviewPageActions.setGroupId,
setTopicId: customerReviewPageActions.setTopicId,
setConfiguration: customerReviewPageActions.setConfiguration,
setNoGroup: customerReviewPageActions.setNoGroup,
setError: notificationActions.setError,
setSuccess: notificationActions.setSuccess
}
const ConnectedCustomerReviewPage = connect(
mapStateToProps,
mapDispatchToProps
)(CustomerReviewPage)
export default withRouter(ConnectedCustomerReviewPage)
@@ -0,0 +1,2 @@
import CustomerReviewPage from './CustomerReviewPage'
export default CustomerReviewPage
@@ -0,0 +1,60 @@
import React from 'react'
import { connect } from 'react-redux'
import Button from '@material-ui/core/Button'
import { createCustomerReviewQuestionSet } from '../../reducers/actions/customerReviewQuestionsPageActions'
import {
setError,
setSuccess
} from '../../reducers/actions/notificationActions'
import QuestionSetForm from '../QuestionSetForm'
const isValidationError = (err) => err.response && err.response.status === 400
const CreateCustomerReviewQuestionSet = ({
onCreateRequested,
onSuccess,
onError
}) => {
const handleError = (err) => {
console.error('Error while creating customer review question set', err)
if (isValidationError(err)) {
onError(
`An error occurred while creating customer review question set: ${
err.response.data.error
}`,
5000
)
} else {
onError('Some error happened', 3000)
}
}
const handleSubmit = async (name, questionsJson) => {
try {
await onCreateRequested(name, JSON.parse(questionsJson))
onSuccess(`Created new customer review question set "${name}"`)
} catch (err) {
handleError(err)
}
}
const submitButton = (
<Button type="submit" variant="contained" color="primary">
Create
</Button>
)
return <QuestionSetForm onSubmit={handleSubmit} controls={submitButton} />
}
const mapDispatchToProps = {
onCreateRequested: createCustomerReviewQuestionSet,
onSuccess: setSuccess,
onError: setError
}
export default connect(
null,
mapDispatchToProps
)(CreateCustomerReviewQuestionSet)
@@ -0,0 +1,6 @@
.customer-review-question-set-list {
list-style: none;
margin: 0;
padding: 0;
}
@@ -0,0 +1,26 @@
import React from 'react'
import EditableCustomerReviewQuestionSet from './EditableCustomerReviewQuestionSet'
import './CustomerReviewQuestionSetList.css'
const compareQuestionSetCreatedAtDesc = (a, b) =>
b.createdAt.localeCompare(a.createdAt)
const RegistrationQuestionSetList = ({ questionSets, onQuestionSetUpdate }) => {
const byCreatedAtDesc = [...questionSets].sort(
compareQuestionSetCreatedAtDesc
)
return (
<ul className="customer-review-question-set-list">
{byCreatedAtDesc.map((set) => (
<EditableCustomerReviewQuestionSet
key={set.id}
questionSet={set}
onEditSave={onQuestionSetUpdate}
/>
))}
</ul>
)
}
export default RegistrationQuestionSetList
@@ -0,0 +1,7 @@
.customer-review-questions-page__container {
max-width: 1000px;
}
.customer-review-questions-page__create-form {
padding: 0.5rem 1rem 1rem 1rem;
}
@@ -0,0 +1,111 @@
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import Paper from '@material-ui/core/Paper'
import {
fetchCustomerReviewQuestionSets,
updateCustomerReviewQuestionSet
} from '../../reducers/actions/customerReviewQuestionsPageActions'
import {
setError,
setSuccess
} from '../../reducers/actions/notificationActions'
import { peerReviewQuestionSetShape } from '../common/sharedPropTypes'
import CustomerReviewQuestionSetList from './CustomerReviewQuestionSetList'
import CreateCustomerReviewQuestionSet from './CreateCustomerReviewQuestionSet'
import './CustomerReviewQuestionsPage.css'
const isValidationError = (e) => e.response && e.response.status === 400
class CustomerReviewQuestionsPage extends React.Component {
async componentDidMount() {
try {
await this.props.fetchCustomerReviewQuestionSets()
} catch (err) {
this.handleError(err)
}
}
handleError = (e) => {
const { setError } = this.props
console.error('error happened', e, e.response)
if (isValidationError(e)) {
// was error 400, server should have responded { "error": "..." }
setError(`An error occurred: "${e.response.data.error}"!`, 5000)
} else {
setError('Some error happened', 3000)
}
}
handleSuccess = (message) => {
this.props.setSuccess(message, 3000)
}
handleQuestionSetUpdate = async (updatedQuestionSet) => {
try {
await this.props.updateCustomerReviewQuestionSet(updatedQuestionSet)
this.handleSuccess(`Updated question set "${updatedQuestionSet.name}"!`)
} catch (err) {
this.handleError(err)
}
}
render() {
const { questionSets } = this.props
return (
<div className="customer-review-questions-page">
<h1>Configure customer review questions</h1>
<div className="customer-review-questions-page__container">
<section style={{ marginBottom: '2rem' }}>
<h2>Create question set</h2>
<Paper
depth={1}
className="customer-review-questions-page__create-form"
>
<CreateCustomerReviewQuestionSet />
</Paper>
</section>
<section>
<h2>Question sets</h2>
<CustomerReviewQuestionSetList
questionSets={questionSets}
onQuestionSetUpdate={this.handleQuestionSetUpdate}
/>
</section>
</div>
</div>
)
}
}
CustomerReviewQuestionsPage.propTypes = {
questionSets: PropTypes.arrayOf(peerReviewQuestionSetShape),
history: PropTypes.any,
setError: PropTypes.func,
setSuccess: PropTypes.func,
fetchCustomerReviewQuestionSets: PropTypes.func,
updateCustomerReviewQuestionSet: PropTypes.func
}
const mapStateToProps = (state) => ({
questionSets: state.customerReviewQuestionsPage.questionSets
})
const mapDispatchToProps = {
fetchCustomerReviewQuestionSets,
updateCustomerReviewQuestionSet,
setError,
setSuccess
}
const ConnectedCustomerReviewQuestionsPage = connect(
mapStateToProps,
mapDispatchToProps
)(CustomerReviewQuestionsPage)
export default withRouter(ConnectedCustomerReviewQuestionsPage)
@@ -0,0 +1,50 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { peerReviewQuestionSetShape } from '../common/sharedPropTypes'
import QuestionSetItem from '../QuestionSetItem'
import QuestionSetItemEditor from '../QuestionSetItemEditor'
import PeerReviewQuestionsTable from '../PeerReviewQuestionsTable'
const EditableRegistrationQuestionSet = ({ questionSet, onEditSave }) => {
const [isEditing, setIsEditing] = useState(false)
const handleEditorSave = (name, questionsJson) => {
const questions = JSON.parse(questionsJson)
const updatedItem = {
...questionSet,
name,
questions
}
onEditSave(updatedItem)
setIsEditing(false)
}
const { name, questions } = questionSet
const renderNormalItem = () => (
<QuestionSetItem title={name} onEditClicked={() => setIsEditing(true)}>
<PeerReviewQuestionsTable questions={questions} />
</QuestionSetItem>
)
const renderEditingItem = () => (
<QuestionSetItemEditor
initialName={name}
initialQuestionsJson={JSON.stringify(questions, null, 2)}
onSave={handleEditorSave}
onCancel={() => setIsEditing(false)}
/>
)
return isEditing ? renderEditingItem() : renderNormalItem()
}
EditableRegistrationQuestionSet.propTypes = {
questionSet: peerReviewQuestionSetShape,
onEditSave: PropTypes.func
}
export default EditableRegistrationQuestionSet
@@ -0,0 +1,2 @@
import CustomerReviewQuestionsPage from './CustomerReviewQuestionsPage'
export default CustomerReviewQuestionsPage
@@ -0,0 +1,16 @@
.email-template__fields {
display: flex;
}
.email-template__field {
flex: 50%;
}
.email-template__field:not(:last-of-type) {
margin-right: 10px;
}
.email-template__replacements {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier,
monospace;
}
@@ -0,0 +1,81 @@
import React from 'react'
import PropTypes from 'prop-types'
import TextField from '@material-ui/core/TextField'
import './EmailTemplate.css'
import { templateShape } from './commonPropTypes'
import { classes } from '../util'
const TemplateTextField = ({ className, ...props }) => (
<TextField
className={classes('template-text-field', className)}
fullWidth
multiline
rows={10}
margin="normal"
variant="outlined"
{...props}
/>
)
const EmailTemplate = ({
name,
availableReplacements,
template,
onTemplateEdited,
className
}) => {
const { finnish, english } = template
const createHandleTemplateEdited = (language) => (e) => {
const newTemplate = {
...template,
[language]: e.target.value
}
onTemplateEdited(newTemplate)
}
const helperText = (
<span>
Available parameters:{' '}
<span className="email-template__replacements">
{availableReplacements.join(', ')}
</span>
</span>
)
return (
<div
className={classes('email-template', className)}
data-cy-template={name}
>
<h3 className="email-template__title">{name}</h3>
<div className="email-template__fields">
<TemplateTextField
label="Finnish"
value={finnish}
onChange={createHandleTemplateEdited('finnish')}
helperText={helperText}
className="email-template__field email-template__field--finnish"
/>
<TemplateTextField
label="English"
value={english}
onChange={createHandleTemplateEdited('english')}
helperText={helperText}
className="email-template__field email-template__field--english"
/>
</div>
</div>
)
}
EmailTemplate.propTypes = {
name: PropTypes.string.isRequired,
template: templateShape.isRequired,
availableReplacements: PropTypes.arrayOf(PropTypes.string).isRequired,
onTemplateEdited: PropTypes.func.isRequired
}
export default EmailTemplate
@@ -0,0 +1,80 @@
import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import Button from '@material-ui/core/Button'
import { templatesShape } from './commonPropTypes'
import EmailTemplate from './EmailTemplate'
const topicAcceptRejectReplacements = ['{{topicName}}']
const customerReviewLinkReplacements = ['{{topicName}}', '{{secretLink}}']
const EmailTemplatesForm = ({ initialTemplates, disabled, onSave }) => {
const [templates, setTemplates] = useState(initialTemplates)
// Edit templates in local state and update redux state only when admin
// commits changes with Save.
// Use effect below to make sure useState updates if the templates in redux
// state were to change.
useEffect(() => {
setTemplates(initialTemplates)
}, [initialTemplates])
const createHandleTemplateEdited = (templateName) => (editedTemplate) => {
setTemplates({
...templates,
[templateName]: editedTemplate
})
}
const handleSubmit = (e) => {
e.preventDefault()
onSave(templates)
}
const { topicAccepted, topicRejected, customerReviewLink } = templates
return (
<form className="email-templates-form" onSubmit={handleSubmit}>
<div>
<EmailTemplate
name="Topic proposal accepted"
template={topicAccepted}
availableReplacements={topicAcceptRejectReplacements}
onTemplateEdited={createHandleTemplateEdited('topicAccepted')}
/>
<EmailTemplate
name="Topic proposal rejected"
template={topicRejected}
availableReplacements={topicAcceptRejectReplacements}
onTemplateEdited={createHandleTemplateEdited('topicRejected')}
/>
<EmailTemplate
name="Customer review link"
template={customerReviewLink}
availableReplacements={customerReviewLinkReplacements}
onTemplateEdited={createHandleTemplateEdited('customerReviewLink')}
/>
</div>
<hr />
<Button
disabled={disabled}
className="email-templates-form__submit-button"
type="submit"
variant="contained"
color="primary"
>
Save
</Button>
</form>
)
}
EmailTemplatesForm.propTypes = {
onSave: PropTypes.func.isRequired,
initialTemplates: templatesShape.isRequired,
disabled: PropTypes.bool
}
export default EmailTemplatesForm
@@ -0,0 +1,14 @@
.email-templates-page__container {
position: relative;
padding: 20px;
}
.email-templates-page__loading-cover {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
background: rgba(250, 250, 250, 0.6);
}
@@ -0,0 +1,85 @@
import React from 'react'
import { withRouter } from 'react-router-dom'
import { connect } from 'react-redux'
import PropTypes from 'prop-types'
import Paper from '@material-ui/core/Paper'
import emailTemplatesActions from '../../reducers/actions/emailTemplatesActions'
import * as notificationActions from '../../reducers/actions/notificationActions'
import './EmailTemplatesPage.css'
import { templatesShape } from './commonPropTypes'
import EmailTemplatesForm from './EmailTemplatesForm'
import LoadingCover from '../common/LoadingCover'
class EmailTemplatesPage extends React.Component {
async componentDidMount() {
try {
await this.props.fetchEmailTemplates()
} catch (e) {
console.error(e)
this.props.setError('Failed to fetch email templates')
}
}
handleTemplatesSave = async (newTemplates) => {
try {
await this.props.updateEmailTemplates(newTemplates)
this.props.setSuccess('Email templates saved!')
} catch (e) {
this.props.setError('Failed to update email templates')
}
}
render() {
const { templates, isLoading } = this.props
return (
<div className="email-templates-page">
<h1>Configure email templates</h1>
<Paper className="email-templates-page__container" elevation={1}>
{isLoading && (
<LoadingCover className="email-templates-page__loading-cover" />
)}
<EmailTemplatesForm
initialTemplates={templates}
disabled={isLoading}
onSave={this.handleTemplatesSave}
/>
</Paper>
</div>
)
}
}
EmailTemplatesPage.propTypes = {
history: PropTypes.shape({
push: PropTypes.func
}).isRequired,
fetchEmailTemplates: PropTypes.func.isRequired,
updateEmailTemplates: PropTypes.func.isRequired,
setError: PropTypes.func.isRequired,
setSuccess: PropTypes.func.isRequired,
templates: templatesShape,
isLoading: PropTypes.bool
}
const mapStateToProps = (state) => ({
templates: state.emailTemplates.templates,
isLoading: state.emailTemplates.isLoading
})
const mapDispatchToProps = {
fetchEmailTemplates: emailTemplatesActions.fetchEmailTemplates,
updateEmailTemplates: emailTemplatesActions.updateEmailTemplates,
setError: notificationActions.setError,
setSuccess: notificationActions.setSuccess
}
export default withRouter(
connect(
mapStateToProps,
mapDispatchToProps
)(EmailTemplatesPage)
)
@@ -0,0 +1,12 @@
import PropTypes from 'prop-types'
export const templateShape = PropTypes.shape({
finnish: PropTypes.string,
english: PropTypes.string
})
export const templatesShape = PropTypes.shape({
topicAccepted: templateShape,
topicRejected: templateShape,
customerReviewLink: templateShape
})
@@ -0,0 +1,2 @@
import EmailTemplatesPage from './EmailTemplatesPage'
export default EmailTemplatesPage
@@ -0,0 +1,76 @@
import React from 'react'
import PropTypes from 'prop-types'
import AsyncSelect from 'react-select/lib/Async'
import autocomplete from '../../services/autocomplete'
/** @typedef {{ student_number: string, first_names: string, last_name: string }} AutocompleteResult */
const MIN_CHARS = 2
/** @param {AutocompleteResult} option */
const getOptionLabel = (option) => `${option.first_names} ${option.last_name}`
/** @param {AutocompleteResult} option */
const getOptionValue = (option) => option.student_number
const AutocompletedUserSelect = ({
className,
classNamePrefix,
selectedUser,
onSelectedUserChange,
defaultUser
}) => {
/** @param {AutocompleteResult} selectedOption */
const handleChange = (selectedOption, { action }) => {
if (action === 'clear') {
// selectedOption is null
onSelectedUserChange(null)
} else if (action === 'select-option') {
// selectedOption is one of the result objects returned by the
// autocomplete service
onSelectedUserChange(selectedOption)
}
}
const handleLoadOptions = async (inputValue) => {
if (inputValue.length < MIN_CHARS) {
return
}
return await autocomplete.findUsersByPartialName(inputValue)
}
return (
<AsyncSelect
cacheOptions
defaultOptions
isClearable
value={selectedUser}
loadOptions={handleLoadOptions}
onChange={handleChange}
getOptionValue={getOptionValue}
getOptionLabel={getOptionLabel}
defaultValue={defaultUser}
placeholder="Search by name"
className={className}
classNamePrefix={classNamePrefix}
/>
)
}
const autosuggestResultShape = PropTypes.shape({
student_number: PropTypes.string.isRequired,
first_names: PropTypes.string.isRequired,
last_name: PropTypes.string.isRequired
})
AutocompletedUserSelect.propTypes = {
className: PropTypes.string,
classNamePrefix: PropTypes.string,
selectedUser: autosuggestResultShape,
defaultUser: autosuggestResultShape,
onSelectedUserChange: PropTypes.func.isRequired
}
export default AutocompletedUserSelect
@@ -0,0 +1,46 @@
import React from 'react'
import { connect } from 'react-redux'
import Select from '@material-ui/core/Select'
import MenuItem from '@material-ui/core/MenuItem'
import groupManagementActions from '../../reducers/actions/groupManagementActions'
const ConfigurationSelect = ({
groupConfigurationID,
onConfigurationChange,
configurations
}) => {
return (
<Select
className="configuration-selector"
value={groupConfigurationID}
onChange={(e) => onConfigurationChange(e.target.value)}
>
{configurations.map((configuration) => (
<MenuItem
key={configuration.id}
value={configuration.id}
className={`configuration-${configuration.id}`}
data-cy="configuration"
>
{configuration.name}
</MenuItem>
))}
</Select>
)
}
const mapStateToPropsForm = (state) => ({
groupConfigurationID: state.groupPage.groupConfigurationID,
configurations: state.configurationPage.configurations
})
const mapDispatchToPropsForm = {
onConfigurationChange: groupManagementActions.updateGroupConfigurationID
}
export default connect(
mapStateToPropsForm,
mapDispatchToPropsForm
)(ConfigurationSelect)
@@ -0,0 +1,4 @@
.form-input__label,
.form-input__control {
padding-bottom: 1rem;
}
@@ -0,0 +1,199 @@
import React from 'react'
import { connect } from 'react-redux'
import TextField from '@material-ui/core/TextField'
import Typography from '@material-ui/core/Typography'
import Button from '@material-ui/core/Button'
import groupManagementService from '../../services/groupManagement'
import * as notificationActions from '../../reducers/actions/notificationActions'
import groupManagementActions from '../../reducers/actions/groupManagementActions'
import TopicSelect from './TopicSelect'
import AutocompletedUserSelect from './AutocompletedUserSelect'
import './GroupCreationForm.css'
const FormInput = ({ label, children }) => (
<tr>
<td className="form-input__label">
<Typography variant="caption">{label}</Typography>
</td>
<td className="form-input__control">{children}</td>
</tr>
)
const StudentInput = ({ value, onChange }) => {
return (
<div>
<TextField
value={value}
onChange={(e) => onChange(e.target.value)}
multiline
rows="8"
inputProps={{ className: 'create-group-form__student-input' }}
fullWidth={true}
/>
</div>
)
}
const NameInput = ({ value, onChange, inputProps, ...textFieldProps }) => (
<TextField
value={value}
onChange={(e) => onChange(e.target.value)}
variant="standard"
inputProps={inputProps}
fullWidth={true}
{...textFieldProps}
/>
)
const saveGroup = async (event, props) => {
event.preventDefault()
const {
groupName,
students,
groupTopicID,
groupInstructor,
groupConfigurationID
} = props
const splitStudents = students
.split(/[;, \n]/)
.filter((str) => !!str)
.map((student) => {
if (student.length < 9) {
return '0' + student.trim()
} else {
return student.trim()
}
})
try {
const createdGroup = await groupManagementService.create({
name: groupName,
topicId: groupTopicID,
configurationId: groupConfigurationID,
instructorId: groupInstructor ? groupInstructor.student_number : '',
studentIds: splitStudents
})
props.createGroupSuccsess(createdGroup)
props.onInstructorChange(null)
props.setSuccess('Group saved!')
} catch (e) {
props.setError(`Failed to save! ${e.response.data.error}`)
}
}
const GroupCreationForm = ({
groupName,
onNameChangeForm,
students,
onStudentFormChange,
groupInstructor,
onInstructorChange,
topics,
onTopicSelectChange,
groupTopicID,
groupConfigurationID,
createGroupSuccsess,
setSuccess,
setError
}) => {
const handleTopicChange = (topicid) => {
onTopicSelectChange(topicid)
const thistopic = topics.find((topic) => topic.id === topicid)
onNameChangeForm(thistopic.content.title)
}
return (
<div>
<form
onSubmit={(event) =>
saveGroup(event, {
groupName,
students,
groupTopicID,
groupInstructor,
groupConfigurationID,
createGroupSuccsess,
setSuccess,
setError,
onInstructorChange
})
}
>
<table style={{ minWidth: '400px' }}>
<tbody>
<FormInput label="Topic">
<TopicSelect
topics={topics}
onTopicSelectChange={handleTopicChange}
groupTopicID={groupTopicID}
className="create-group-form-topic__selector"
/>
</FormInput>
<FormInput label="Group name">
<NameInput
value={groupName}
onChange={onNameChangeForm}
inputProps={{
className: 'create-group-form__name'
}}
/>
</FormInput>
<FormInput label="Students">
<StudentInput value={students} onChange={onStudentFormChange} />
</FormInput>
<FormInput label="Instructor">
<AutocompletedUserSelect
className="create-group-form__instructor"
classNamePrefix="create-group-form__instructor"
selectedUser={groupInstructor}
onSelectedUserChange={onInstructorChange}
/>
</FormInput>
</tbody>
</table>
<Button
style={{ marginRight: '10px', height: '40px' }}
color="primary"
variant="contained"
type="submit"
className="create-group-submit"
>
Add group
</Button>
</form>
</div>
)
}
const mapStateToPropsForm = (state) => ({
groupName: state.groupPage.groupName,
students: state.groupPage.students,
groupTopicID: state.groupPage.groupTopicID,
groupInstructor: state.groupPage.groupInstructor,
groupConfigurationID: state.groupPage.groupConfigurationID,
topics: state.topicListPage.topics
})
const mapDispatchToPropsForm = {
onNameChangeForm: groupManagementActions.updateCreateGroupFormName,
onStudentFormChange: groupManagementActions.updateStudentsForm,
onTopicSelectChange: groupManagementActions.updateGroupTopicID,
onInstructorChange: groupManagementActions.updateGroupInstructor,
createGroupSuccsess: groupManagementActions.createGroupSuccsess,
setError: notificationActions.setError,
setSuccess: notificationActions.setSuccess
}
export default connect(
mapStateToPropsForm,
mapDispatchToPropsForm
)(GroupCreationForm)
@@ -0,0 +1,76 @@
import React from 'react'
import { connect } from 'react-redux'
import Paper from '@material-ui/core/Paper'
import * as notificationActions from '../../reducers/actions/notificationActions'
import groupManagementActions from '../../reducers/actions/groupManagementActions'
import GroupViewer from './GroupViewer'
const GroupManagementForm = ({
groups,
groupConfigurationID,
deleteFromGroupAction,
topics,
users,
deleteGroup,
setSuccess,
setError
}) => {
const filteredGroups = groups.filter(
(group) => group.configurationId === groupConfigurationID
)
return (
<div>
{filteredGroups.map((group) => {
return (
<div
key={group.id}
style={{ clear: 'both', display: 'table', padding: '15px' }}
>
<Paper>
<table>
<tbody>
<tr>
<td style={{ verticalAlign: 'top', padding: '25px' }}>
<GroupViewer
group={group}
topics={topics}
users={users}
deleteFromGroupAction={deleteFromGroupAction}
deleteGroup={deleteGroup}
setSuccess={setSuccess}
setError={setError}
/>
</td>
</tr>
</tbody>
</table>
</Paper>
</div>
)
})}
</div>
)
}
const mapStateToPropsForm = (state) => ({
groupConfigurationID: state.groupPage.groupConfigurationID,
groups: state.groupPage.groups,
topics: state.topicListPage.topics,
users: state.groupPage.users
})
const mapDispatchToPropsForm = {
deleteFromGroupAction: groupManagementActions.deleteFromGroup,
deleteGroup: groupManagementActions.deleteGroup,
setError: notificationActions.setError,
setSuccess: notificationActions.setSuccess
}
export default connect(
mapStateToPropsForm,
mapDispatchToPropsForm
)(GroupManagementForm)
@@ -0,0 +1,91 @@
import React from 'react'
import { connect } from 'react-redux'
import Paper from '@material-ui/core/Paper'
import Typography from '@material-ui/core/Typography'
import configurationService from '../../services/configuration'
import groupManagementService from '../../services/groupManagement'
import userService from '../../services/user'
import topicListPageActions from '../../reducers/actions/topicListPageActions'
import configurationPageActions from '../../reducers/actions/configurationPageActions'
import * as notificationActions from '../../reducers/actions/notificationActions'
import groupManagementActions from '../../reducers/actions/groupManagementActions'
import GroupManagementForm from './GroupManagementForm'
import ConfigurationSelect from './ConfigurationSelect'
import GroupCreationForm from './GroupCreationForm'
const ConfigurationSelectWrapper = ({ label, children }) => (
<div style={{ padding: 20 }}>
<Typography variant="caption">{label}</Typography>
{children}
</div>
)
class GroupManagementPage extends React.Component {
async componentDidMount() {
try {
const fetchedConfiguration = await configurationService.getAll()
const fetchedGroups = await groupManagementService.get()
const fetchedUsers = await userService.get()
this.props.setUsers(fetchedUsers)
this.props.setGroups(fetchedGroups)
await this.props.fetchTopics()
this.props.setConfigurations(fetchedConfiguration.configurations)
} catch (e) {
this.props.setError('Some error happened')
}
}
render() {
return (
<div>
<h2>Administration</h2>
<h3>Group Management</h3>
<ConfigurationSelectWrapper label="Select configuration">
<ConfigurationSelect props={this.props} />
</ConfigurationSelectWrapper>
<Paper elevation={2} style={{ padding: '1rem 1.5rem' }}>
<h4>Create group</h4>
<GroupCreationForm />
</Paper>
<p />
<h4>Created groups</h4>
<GroupManagementForm />
</div>
)
}
}
const mapStateToProps = (state) => ({
topics: state.topicListPage.topics,
configurations: state.configurationPage.configurations,
groups: state.groupPage.groups,
users: state.groupPage.users
})
const mapDispatchToProps = {
updateTopics: topicListPageActions.updateTopics,
setConfigurations: configurationPageActions.setConfigurations,
setGroups: groupManagementActions.setGroups,
setUsers: groupManagementActions.setUsers,
setError: notificationActions.setError,
setSuccess: notificationActions.setSuccess,
fetchTopics: topicListPageActions.fetchTopics
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(GroupManagementPage)
@@ -0,0 +1,55 @@
import React from 'react'
import SingleGroupEdit from './SingleGroupEdit'
import SingleGroupView from './SingleGroupView'
class GroupViewer extends React.Component {
constructor(props) {
super(props)
this.state = {
editMode: false
}
}
toggleEditMode = () => {
this.setState((prevState) => ({
editMode: !prevState.editMode
}))
if (this.state.editMode) {
this.props.setSuccess(
`Editing for group ${this.props.group.name} disabled!`
)
} else {
this.props.setSuccess(
`Editing for group ${this.props.group.name} enabled!`
)
}
}
render() {
if (this.state.editMode) {
return (
<SingleGroupEdit
group={this.props.group}
toggleEditMode={this.toggleEditMode}
/>
)
} else {
return (
<div>
<SingleGroupView
group={this.props.group}
topics={this.props.topics}
users={this.props.users}
deleteFromGroupAction={this.props.deleteFromGroupAction}
toggleEditMode={this.toggleEditMode}
setSuccess={this.props.setSuccess}
setError={this.props.setError}
/>
</div>
)
}
}
}
export default GroupViewer
@@ -0,0 +1,231 @@
import React from 'react'
import { connect } from 'react-redux'
import TextField from '@material-ui/core/TextField'
import Button from '@material-ui/core/Button'
import IconButton from '@material-ui/core/IconButton'
import DeleteIcon from '@material-ui/icons/Delete'
import groupManagementService from '../../services/groupManagement'
import * as notificationActions from '../../reducers/actions/notificationActions'
import groupManagementActions from '../../reducers/actions/groupManagementActions'
import AutocompletedUserSelect from './AutocompletedUserSelect'
import TopicSelect from './TopicSelect'
const updateCreatedGroup = async (event, props) => {
event.preventDefault()
const {
id,
name,
topicId,
studentIds,
instructor,
configurationId
} = props.group
const splitStudents = studentIds
.split(/[;, \n]/)
.filter((str) => !!str)
.map((student) => {
if (student.length < 9) {
return '0' + student.trim()
} else {
return student.trim()
}
})
try {
const updatedGroup = await groupManagementService.put({
id: id,
name: name,
topicId: topicId,
configurationId: configurationId,
instructorId: instructor ? instructor.student_number : '',
studentIds: splitStudents
})
props.updateExistingGroup(updatedGroup)
props.toggleEditMode()
props.setSuccess('Group updated!')
} catch (e) {
console.log(e)
props.setError(`Failed to updated group! ${e.response.data.error}`)
}
}
class SingleGroupEdit extends React.Component {
constructor(props) {
super(props)
this.state = {
id: props.group.id,
name: props.group.name,
topicId: props.group.topicId,
configurationId: props.group.configurationId,
instructor: props.users.find(
(user) => user.student_number === props.group.instructorId
),
studentIds: props.group.studentIds.join('\n')
}
}
handleNameChange = (event) => {
this.setState({ name: event.target.value })
}
handleStudentChange = (event) => {
this.setState({ studentIds: event.target.value })
}
handleInstructorChange = (newInstructor) => {
this.setState({ instructor: newInstructor })
}
handleTopicChange = (newTopicId) => {
const thistopic = this.props.topics.find((topic) => topic.id === newTopicId)
this.setState({
topicId: newTopicId,
name: thistopic.content.title
})
}
handleGroupDelete = async (e) => {
e.preventDefault()
if (!window.confirm(`Delete group ${this.state.name}?`)) {
return
}
const groupId = this.state.id
try {
await groupManagementService.del({
id: groupId
})
this.props.deleteGroup(groupId)
this.props.setSuccess('Group deleted!')
} catch (e) {
this.props.setError(`Failed to delte! ${e.response.data.error}`)
}
}
render() {
const {
group,
topics,
users,
updateExistingGroup,
toggleEditMode,
setSuccess,
setError,
clearNotifications
} = this.props
const defaultInstructor =
group.instructorId &&
users.find((user) => user.student_number === group.instructorId)
return (
<div style={{ align: 'top', minWidth: '400px' }}>
Edit group: {group.name}
<IconButton
aria-label="Delete"
onClick={this.handleGroupDelete}
data-cy="edit-group-delete-button"
>
<DeleteIcon fontSize="large" />
</IconButton>
<p>Edit group name</p>
<TextField
inputProps={{
className: `edit-group-no__${group.id}__name`,
'data-cy': 'edit-group-name-field'
}}
fullWidth={true}
value={this.state.name}
onChange={this.handleNameChange}
/>
<p>Change topic</p>
<TopicSelect
topics={topics}
onTopicSelectChange={this.handleTopicChange}
groupTopicID={this.state.topicId}
className="edit-group-form-topic__selector"
fullWidth={true}
/>
<p>Add new students</p>
<TextField
inputProps={{
className: `edit-group-no__${group.id}__students`,
'data-cy': 'edit-students-input'
}}
value={this.state.studentIds}
onChange={this.handleStudentChange}
data-cy="edit-group-students"
multiline
rows="8"
fullWidth={true}
/>
<p>Change instructor</p>
<AutocompletedUserSelect
className="edit-group-instructor"
classNamePrefix="edit-group-instructor"
selectedUser={this.state.instructor}
defaultUser={defaultInstructor}
onSelectedUserChange={this.handleInstructorChange}
fullWidth={true}
/>
<p>
<Button
style={{ marginLeft: '10px', height: '30px', float: 'right' }}
color="primary"
variant="contained"
onClick={(event) =>
updateCreatedGroup(event, {
group: this.state,
updateExistingGroup,
toggleEditMode,
setSuccess,
setError,
clearNotifications
})
}
data-cy="edit-group-save-button"
>
Save
</Button>
<Button
style={{ marginLeft: '10px', height: '30px', float: 'right' }}
color="primary"
variant="contained"
onClick={() => toggleEditMode()}
>
Cancel
</Button>
</p>
</div>
)
}
}
const mapStateToPropsForm = (state) => ({
topics: state.topicListPage.topics,
users: state.groupPage.users
})
const mapDispatchToPropsForm = {
onTopicSelectChange: groupManagementActions.updateGroupTopicID,
updateExistingGroup: groupManagementActions.updateExistingGroup,
deleteGroup: groupManagementActions.deleteGroup,
setError: notificationActions.setError,
setSuccess: notificationActions.setSuccess
}
export default connect(
mapStateToPropsForm,
mapDispatchToPropsForm
)(SingleGroupEdit)
@@ -0,0 +1,188 @@
import React from 'react'
import Button from '@material-ui/core/Button'
import IconButton from '@material-ui/core/IconButton'
import DeleteIcon from '@material-ui/icons/Delete'
import groupManagementService from '../../services/groupManagement'
const SingleGroupView = ({
group,
topics,
users,
deleteFromGroupAction,
toggleEditMode,
setSuccess,
setError
}) => {
const thisTopic = topics.filter((topic) => topic.id === group.topicId)
const topicName = thisTopic[0].content.title
const getUserNames = (user) => {
const namedUser = users.filter(
(userIterator) => userIterator.student_number === user
)[0]
return `${namedUser.first_names} ${namedUser.last_name} (${user})`
}
const getGroupInstructor = () => {
if (group.instructorId === null) {
return <div>No instructor assigned</div>
} else {
return (
<div>
{getUserNames(group.instructorId)}
<IconButton
aria-label="Delete"
onClick={(event) =>
deleteFromGroupInstructor(event, {
group,
deleteFromGroupAction,
setSuccess,
setError
})
}
data-cy="delete-instructor-button"
>
<DeleteIcon fontSize="small" />
</IconButton>
</div>
)
}
}
return (
<div style={{ align: 'top', minWidth: '400px' }}>
<div className="group-name">{group.name}</div>
<div>
<div className="group-topic">
<p>Topic</p>
{topicName}
</div>
<div className="group-students">
<p>Students</p>
{group.studentIds.map((student) => (
<div key={student}>
{getUserNames(student)}
<IconButton
aria-label="Delete"
onClick={(event) =>
deleteFromGroupStudent(event, {
group,
student,
deleteFromGroupAction,
setSuccess,
setError
})
}
data-cy="delete-student-button"
>
<DeleteIcon fontSize="small" />
</IconButton>
</div>
))}
</div>
<div className="group-instructor">
<p>Instructor</p>
{getGroupInstructor(group.id)}
</div>
</div>
<Button
style={{ marginLeft: '10px', height: '30px', float: 'right' }}
color="primary"
variant="contained"
onClick={() => toggleEditMode()}
data-cy="edit-group-button"
>
Edit
</Button>
</div>
)
}
const deleteFromGroupStudent = async (event, props) => {
event.preventDefault()
const confirm = window.confirm(
`Are you sure you want to delete student ${props.student} from a group ${
props.group.name
}? `
)
if (!confirm) {
return
}
const {
id,
name,
topicId,
studentIds,
instructorId,
configurationId
} = props.group
const removedStudents = studentIds
.map((studentmap) => studentmap.trim())
.filter((studentmap) => studentmap !== props.student)
try {
const updatedGroup = await groupManagementService.put({
id: id,
name: name,
topicId: topicId,
configurationId: configurationId,
instructorId: instructorId,
studentIds: removedStudents
})
props.deleteFromGroupAction(updatedGroup)
props.setSuccess('Student deleted!')
} catch (e) {
console.log(e)
props.setError(`Failed to deleted student! ${e.response.data.error}`)
}
}
const deleteFromGroupInstructor = async (event, props) => {
event.preventDefault()
const confirm = window.confirm(
`Are you sure you want to delete instructor ${
props.group.instructorId
} from a group ${props.group.name}? `
)
if (!confirm) {
return
}
const { id, name, topicId, studentIds, configurationId } = props.group
try {
const updatedGroup = await groupManagementService.put({
id: id,
name: name,
topicId: topicId,
configurationId: configurationId,
instructorId: '',
studentIds: studentIds
})
props.deleteFromGroupAction(updatedGroup)
props.setSuccess('Instructor deleted!')
} catch (e) {
console.log(e)
props.setError(`Failed to delete instructor! ${e.response.data.error}`)
}
}
export default SingleGroupView
@@ -0,0 +1,41 @@
import React from 'react'
import { connect } from 'react-redux'
import Select from '@material-ui/core/Select'
import MenuItem from '@material-ui/core/MenuItem'
const TopicSelect = ({
topics,
onTopicSelectChange,
groupTopicID,
className,
groupConfig
}) => {
return (
<Select
className={className}
value={groupTopicID}
onChange={(e) => onTopicSelectChange(e.target.value)}
>
{topics
.filter(
(topic) => topic.active && topic.configuration_id === groupConfig
)
.map((topic) => (
<MenuItem key={topic.id} value={topic.id} className="topic-menu-item">
{topic.content.title}
</MenuItem>
))}
</Select>
)
}
const mapStateToProps = (state) => {
return {
groupConfig: state.groupPage.groupConfigurationID
}
}
const ConnectedTopicSelect = connect(mapStateToProps)(TopicSelect)
export default ConnectedTopicSelect
@@ -0,0 +1,2 @@
import GroupManagementPage from './GroupManagementPage'
export default GroupManagementPage
@@ -0,0 +1,26 @@
.radio-header {
width: 100px;
}
.radio-infoheader {
border: 1px solid black;
}
.radio-inforow {
margin: auto;
}
.radio-button-table {
padding: 0;
}
.peer-row,
.radio-row {
margin: auto;
}
.peer-header,
.radio-header,
.radio-button {
border: 1px solid black;
margin: auto;
padding: 0;
text-align: center;
}
+368
View File
@@ -0,0 +1,368 @@
import React from 'react'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import Button from '@material-ui/core/Button'
import MenuItem from '@material-ui/core/MenuItem'
import Typography from '@material-ui/core/Typography'
import Select from '@material-ui/core/Select'
import * as notificationActions from '../reducers/actions/notificationActions'
import './InstructorPage.css'
//Services
import peerReviewService from '../services/peerReview'
import instructorPageActions from '../reducers/actions/instructorPageActions'
const GroupDetails = ({ myGroup }) => {
if (!myGroup) {
return (
<div>
<h2>There are currently no students in your group.</h2>
</div>
)
} else {
return (
<div>
{myGroup.map((member, index) => {
return (
<p key={index}>
{index + 1}. {member}
</p>
)
})}
</div>
)
}
}
const Answers = ({ answers, currentConfiguration }) => {
answers = answers.filter(
(group) => group.group.configurationId === currentConfiguration
)
return (
<div>
{answers.map((projectGroup, index) => {
return (
<div key={index}>
<hr />
<br />
<h1>{projectGroup.group.name}</h1>
<h3>Instructor: {projectGroup.group.instructorName}</h3>
<GroupDetails myGroup={projectGroup.group.studentNames} />
{projectGroup.round1Answers.length > 0 ? (
<div>
<h2>Peer review answers from the first round.</h2>
<GroupAnswers answers={projectGroup.round1Answers} students={projectGroup.group.studentNames} />
</div>
) : (
<h2>
This group hasn't answered to the first peer review round yet.
</h2>
)}
{projectGroup.round2Answers.length > 0 ? (
<div>
<h2>Peer review answers from the second round.</h2>
<GroupAnswers answers={projectGroup.round2Answers} students={projectGroup.group.studentNames}/>
</div>
) : (
<h2>
This group hasn't answered to the second peer review round yet.
</h2>
)}
<br />
</div>
)
})}
</div>
)
}
const getQuestions = (answers) => {
// The same questions are found in every answer, so just grab the answers
// from the first one
return answers[0].answer_sheet.map((question) => {
return {
type: question.type,
questionHeader: question.questionHeader
}
})
}
const Question = ({ title, children }) => (
<div>
<h3>{title}</h3>
{children}
</div>
)
const GroupAnswers = ({ answers, students }) => {
return (
<div>
{getQuestions(answers).map((question, index) => {
if (question.type === 'text' || question.type === 'number') {
return (
<Question key={index} title={question.questionHeader}>
<TextNumberAnswer answers={answers} questionNumber={index} />
</Question>
)
} else if (question.type === 'radio') {
return (
<Question key={index} title={question.questionHeader}>
<RadioAnswer answers={answers} questionNumber={index} students={students} />
</Question>
)
} else {
return null
}
})}
</div>
)
}
const TextNumberAnswer = ({ answers, questionNumber }) => {
return (
<div>
{answers.map((member, index) => {
return (
<div key={index}>
<p>{member.student.last_name}</p>
<p>{member.answer_sheet[questionNumber].answer}</p>
</div>
)
})}
</div>
)
}
const RadioAnswer = ({ answers, questionNumber, students }) => {
const peers = answers.map((member) => {
return member.student.first_names + ' ' + member.student.last_name
})
students.forEach(s => {
if (!peers.includes(s)) {
peers.push(s)
}
})
return (
<div>
<table className="radio-button-table">
<thead>
<tr className="radio-inforow">
<th />
<th colspan={peers.length} className="radio-infoheader">
Reviewers
</th>
<th />
</tr>
<tr className="radio-row">
<th />
<PeerHeaders peers={peers} />
<th className="radio-header">Mean</th>
</tr>
</thead>
<tbody>
{peers.map((member, index) => {
return (
<tr key={index}>
<th className="peer-header">
<p>{member}</p>
</th>
<PeerRows
member={member}
answers={answers}
questionNumber={questionNumber}
numberOfPeers={peers.length}
/>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
const PeerHeaders = ({ peers }) => {
return peers.map((option, optionId) => {
return (
<th className="radio-header" key={optionId}>
{option}
</th>
)
})
}
const sum = (arr) => arr.reduce((sum, value) => sum + value, 0)
const average = (arr) => sum(arr) / arr.length
const PeerRows = ({ member, answers, questionNumber, numberOfPeers }) => {
const otherPeersRatingOfMember = answers
.map((peersSubmission) => peersSubmission.answer_sheet)
.map((peersAnswerSheet) => peersAnswerSheet[questionNumber].peers[member])
const missing = []
for( let i=0; i<numberOfPeers - answers.length; i++) {
missing.push(i)
}
const averageRating = average(otherPeersRatingOfMember)
return (
<React.Fragment>
{otherPeersRatingOfMember.map((rating, index) => (
<td className="radio-button" key={`peer-row-${index}`}>
{rating}
</td>
))}
{missing.map((_, index) => (
<td className="radio-button" key={`peer-row-${index}`}>
-
</td>
))}
<td className="radio-button">{averageRating.toFixed(2)}</td>
</React.Fragment>
)
}
const DownloadButton = ({ jsonData, fileName }) => {
const data = `text/json;charset=utf-8,${encodeURIComponent(jsonData)}`
const href = `data:${data}`
return (
<Button
component="a"
href={href}
download={fileName}
variant="contained"
color="primary"
>
Download as JSON
</Button>
)
}
const ConfigurationSelectWrapper = ({ label, children }) => (
<div style={{ padding: 20 }}>
<Typography variant="caption">{label}</Typography>
{children}
</div>
)
const ConfigurationSelect = ({
currentConfiguration,
setCurrentConfiguration,
configurations
}) => {
return (
<Select
data-cy="configuration-selector"
value={currentConfiguration}
onChange={(e) => setCurrentConfiguration(e.target.value)}
>
{configurations.map((configuration) => (
<MenuItem
key={configuration.id}
className="configuration-menu-item"
value={configuration.id}
>
{configuration.name}
</MenuItem>
))}
</Select>
)
}
const getUniqueConfigurations = (groups) => {
const uniqueLookup = groups.reduce((configurations, group) => {
return {
...configurations,
[group.configurationId]: {
name: group.configurationName,
id: group.configurationId
}
}
}, {})
return Object.values(uniqueLookup)
}
class InstructorPage extends React.Component {
async componentDidMount() {
try {
const peerReviewData = await peerReviewService.getAnswersByInstructor()
const uniqueConfigurations = getUniqueConfigurations(
peerReviewData.map((data) => data.group)
)
this.props.setAnswers(peerReviewData)
this.props.setConfigurations(uniqueConfigurations)
this.props.setCurrentConfiguration(uniqueConfigurations[0].id)
} catch (e) {
console.error('error happened', e, e.response)
this.props.setError('Something is wrong... try reloading the page')
}
}
render() {
const {
answers,
currentConfiguration,
configurations,
setCurrentConfiguration
} = this.props
if (!answers || !currentConfiguration) {
return <div className="instructor-container">Loading</div>
}
return (
<div className="instructor-container">
<DownloadButton
jsonData={JSON.stringify(answers)}
fileName="peerReviews.json"
/>
<ConfigurationSelectWrapper label="Select configuration">
<ConfigurationSelect
currentConfiguration={currentConfiguration}
setCurrentConfiguration={setCurrentConfiguration}
configurations={configurations}
/>
</ConfigurationSelectWrapper>
<Answers
answers={answers}
currentConfiguration={currentConfiguration}
/>
</div>
)
}
}
const mapStateToProps = (state) => {
return {
configurations: state.instructorPage.configurations,
currentConfiguration: state.instructorPage.currentConfiguration,
answers: state.instructorPage.answers
}
}
const mapDispatchToProps = {
setConfigurations: instructorPageActions.setConfigurations,
setCurrentConfiguration: instructorPageActions.setCurrentConfiguration,
setAnswers: instructorPageActions.setAnswers,
setError: notificationActions.setError
}
const ConnectedInstructorPage = connect(
mapStateToProps,
mapDispatchToProps
)(InstructorPage)
export default withRouter(ConnectedInstructorPage)
@@ -0,0 +1,354 @@
import React from 'react'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import { getUser } from '../utils/functions'
import questionsJson from './questions/instructor_data'
//Actions
import appActions from '../reducers/actions/appActions'
import * as notificationActions from '../reducers/actions/notificationActions'
import instructorReviewPageActions from '../reducers/actions/instructorReviewPageActions.js'
//Services
import TextField from '@material-ui/core/TextField'
import groupManagementService from '../services/groupManagement'
import Select from '@material-ui/core/Select'
import MenuItem from '@material-ui/core/MenuItem'
import Button from '@material-ui/core/Button'
import Typography from '@material-ui/core/Typography'
import instructorReviewService from '../services/instructorReview'
class InstructorReviewPage extends React.Component {
async componentDidMount() {
try {
const groups = await groupManagementService.getByInstructor()
const answers = await instructorReviewService.getAllAnsweredGroupId()
const filteredGroups = groups.filter((group) => {
return !answers.includes(group.id)
})
if (filteredGroups.length === 0) {
this.props.setSubmittedReview(true)
} else if (filteredGroups.length > 0) {
this.props.setGroups(filteredGroups)
this.fetchInstructorReviewQuestions(
filteredGroups[0].students,
questionsJson,
this.props.initializeAnswerSheet
)
}
} catch (e) {
console.log('error happened', e.response)
this.props.setError('Something is wrong... try reloading the page')
}
}
async fetchInstructorReviewQuestions(
students,
questions,
initializeAnswerSheet
) {
const initializeNumberAnswer = (question, questionId) => {
return {
type: 'number',
header: question.header,
id: questionId,
answer: ''
}
}
const initializeTextAnswer = (question, questionId) => {
return {
type: 'text',
header: question.header,
id: questionId,
answer: ''
}
}
const emptyAnswerSheet = questions.questions.map((question, questionId) => {
if (question.type === 'text') {
return initializeTextAnswer(question, questionId)
} else if (question.type === 'number') {
return initializeNumberAnswer(question, questionId)
} else {
return question
}
})
const initializeStudent = (name) => {
const sheet = emptyAnswerSheet
return {
name: name,
answers: sheet
}
}
const tempAnswerSheet = students.map((student) => {
return initializeStudent(student)
})
initializeAnswerSheet(tempAnswerSheet)
}
Submit = async (event, answerSheet, groupName, groupId) => {
event.preventDefault()
const answer = window.confirm(
'Answers can not be changed after submitting. Continue?'
)
if (!answer) return
this.props.selectGroup(0)
try {
await instructorReviewService.create({
instructorReview: {
group_id: groupId,
group_name: groupName,
answer_sheet: answerSheet,
user_id: getUser().student_number
}
})
this.props.setSuccess('Instructor review saved!')
const groups = await groupManagementService.getByInstructor()
const answers = await instructorReviewService.getAllAnsweredGroupId()
const filteredGroups = groups.filter((group) => {
return !answers.includes(group.id)
})
if (filteredGroups.length === 0) {
this.props.setSubmittedReview(true)
} else if (filteredGroups.length > 0) {
this.props.setGroups(filteredGroups)
this.fetchInstructorReviewQuestions(
filteredGroups[0].students,
questionsJson,
this.props.initializeAnswerSheet
)
}
} catch (e) {
console.log('error happened', e)
this.props.setError(e.response.data.error)
}
}
render() {
const {
answerSheet,
updateAnswer,
submittedReview,
groups,
selectedGroup,
selectGroup,
initializeAnswerSheet
} = this.props
if (submittedReview === true) {
return (
<div>
<h3>You have reviewed every group you are instructing.</h3>
</div>
)
} else if (answerSheet && groups.length > 0) {
return (
<div>
<ConfigurationSelectWrapper label="Select group">
<ConfigurationSelect
selectedGroup={selectedGroup}
groups={groups}
groupSelectHandler={groupSelectHandler}
selectGroup={selectGroup}
fetchInstructorReviewQuestions={
this.fetchInstructorReviewQuestions
}
initializeAnswerSheet={initializeAnswerSheet}
/>
</ConfigurationSelectWrapper>
<h1>{groups[selectedGroup].groupName}</h1>
<Reviews answerSheet={answerSheet} updateAnswer={updateAnswer} />
<Button
margin-right="auto"
margin-left="auto"
variant="contained"
color="primary"
data-cy="submit-instructor-review-button"
onClick={(event) =>
this.Submit(
event,
answerSheet,
groups[selectedGroup].groupName,
groups[selectedGroup].id
)
}
>
Submit
</Button>
</div>
)
} else {
return (
<div>
<p>Loading.</p>
</div>
)
}
}
}
const groupSelectHandler = (
value,
selectGroup,
fetchInstructorReviewQuestions,
groups,
initializeAnswerSheet
) => {
if (
!window.confirm('Changing group will wipe current inserted information.')
) {
return
}
selectGroup(value)
fetchInstructorReviewQuestions(
groups[value].students,
questionsJson,
initializeAnswerSheet
)
}
const ConfigurationSelectWrapper = ({ label, children }) => (
<div style={{ padding: 20 }}>
<Typography variant="caption">{label}</Typography>
{children}
</div>
)
const ConfigurationSelect = ({
selectedGroup,
groupSelectHandler,
groups,
fetchInstructorReviewQuestions,
selectGroup,
initializeAnswerSheet
}) => {
return (
<Select
value={selectedGroup}
onChange={(e) =>
groupSelectHandler(
e.target.value,
selectGroup,
fetchInstructorReviewQuestions,
groups,
initializeAnswerSheet
)
}
>
{groups.map((group, index) => (
<MenuItem key={index} value={index}>
{group.groupName}
</MenuItem>
))}
</Select>
)
}
const Reviews = ({ answerSheet, updateAnswer }) => {
return answerSheet.map((student, index) => {
return (
<div key={index}>
<h1>{student.name.first_names + ' ' + student.name.last_name}</h1>
<Questions
studentAnswers={student.answers}
updateAnswer={updateAnswer}
userId={index}
/>
</div>
)
})
}
const Questions = ({ studentAnswers, updateAnswer, userId }) => {
return studentAnswers.map((question, questionId) => {
if (question.type === 'text') {
return (
<div key={questionId + userId}>
<h3>{question.header}</h3>
<p>{question.description}</p>
<TextField
value={question.answer}
rows="10"
style={{ width: 700 }}
multiline
variant="outlined"
data-cy={`textInput-${question.header} user:${userId}`}
onChange={(e) =>
textFieldHandler(e.target.value, userId, questionId, updateAnswer)
}
/>
</div>
)
} else if (question.type === 'number') {
return (
<div key={questionId + userId}>
<h3>{question.header}</h3>
<p>{question.description}</p>
<input
type="number"
value={question.answer}
data-cy={`numberInput-${question.header} user:${userId}`}
onChange={(e) =>
numberFieldHandler(
e.target.value,
userId,
questionId,
updateAnswer
)
}
/>
</div>
)
} else if (question.type === 'info') {
return (
<div key={questionId + userId}>
<h2>{question.header}</h2>
<p>{question.description}</p>
</div>
)
} else {
return (
<div key={questionId + userId}>
<p>Incorrect question type</p>
</div>
)
}
})
}
const textFieldHandler = (value, userId, questionId, updateAnswer) => {
updateAnswer(value, userId, questionId)
}
const numberFieldHandler = (value, userId, questionId, updateAnswer) => {
updateAnswer(value, userId, questionId)
}
const mapStateToProps = (state) => {
return {
answerSheet: state.instructorReviewPage.answerSheet,
submittedReview: state.instructorReviewPage.submittedReview,
groups: state.instructorReviewPage.groups,
selectedGroup: state.instructorReviewPage.selectedGroup
}
}
const mapDispatchToProps = {
...instructorReviewPageActions,
...notificationActions,
...appActions
}
const ConnectedInstructorReviewPage = connect(
mapStateToProps,
mapDispatchToProps
)(InstructorReviewPage)
export default withRouter(ConnectedInstructorReviewPage)
@@ -0,0 +1,134 @@
import React, { useEffect, useState } from 'react'
import instructorReview from '../services/instructorReview'
const Answer = ({ answer }) => {
const [visible, setVisible] = useState(false)
if (!visible) {
return (
<div>
<h2 onClick={() => setVisible(!visible)}>{answer.name.first_names} {answer.name.last_name}</h2>
</div>
)
}
const withId = (id) => answer.answers.find(a => a.id === id)
const double = answer.answers.filter(a => a.header === 'Prosessin kehittäminen').length === 2
const tech = 1
const process = 6
const adapt = 11
const group = double ? 21 : 16
const customer = double ? 26 : 21
const overall = double ? 31 : 26
return (
<div>
<h2 onClick={() => setVisible(!visible)}>{answer.name.first_names} {answer.name.last_name}</h2>
<h3>Tekninen kontribuutio</h3>
{withId(tech).answer}
<p>
<em>
{withId(tech+1).answer}
</em>
</p>
<p>
{withId(tech+2).answer}
</p>
<strong>
arvosana {withId(tech+3).answer}
</strong>
<h3>Prosessin noudattaminen</h3>
{withId(process).answer}
<p>
<em>
{withId(process+1).answer}
</em>
</p>
<p>
{withId(process+2).answer}
</p>
<strong>
arvosana {withId(process+3).answer}
</strong>
<h3>Prosessin kehittäminen</h3>
{withId(adapt).answer}
<p>
<em>
{withId(adapt+1).answer}
</em>
</p>
<p>
{withId(adapt+2).answer}
</p>
<strong>
arvosana {withId(adapt+3).answer}
</strong>
<h3>Ryhmätyöskentely</h3>
{withId(group).answer}
<p>
<em>
{withId(group+1).answer}
</em>
</p>
<p>
{withId(group+2).answer}
</p>
<strong>
arvosana {withId(group+3).answer}
</strong>
<h3>Asiakasyöskentely</h3>
{withId(customer).answer}
<p>
<em>
{withId(customer + 1).answer}
</em>
</p>
<p>
{withId(customer + 2).answer}
</p>
<strong>
arvosana {withId(customer + 3).answer}
</strong>
<h3>Koko projekti</h3>
<strong>
arvosana {withId(overall).answer}
</strong>
</div>
)
}
const Review = ({ review }) => {
const [visible, setVisible] = useState(false)
return (
<div>
<h1 onClick={() => setVisible(!visible)}>{review.group_name}</h1>
{visible && review.answer_sheet.map((answer, i) => <Answer key={i} answer={answer} />)}
</div>
)
}
const InstructorReviews = () => {
const [reviews, setReviews] = useState([])
useEffect(() => {
instructorReview.getAll().then(data => {
setReviews(data.reviews)
})
}, [])
return (
<div>
{reviews.map(data => <Review key={data.id} review={data.answer_sheet}/>)}
</div>
)
}
export default InstructorReviews
+60
View File
@@ -0,0 +1,60 @@
.landingpage-container {
-webkit-animation: fadein 1s; /* Safari, Chrome and Opera > 12.1 */
-moz-animation: fadein 1s; /* Firefox < 16 */
-ms-animation: fadein 1s; /* Internet Explorer */
-o-animation: fadein 1s; /* Opera < 12.1 */
animation: fadein 1s;
}
.landingpage-message {
color: red;
}
@keyframes fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Firefox < 16 */
@-moz-keyframes fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Safari, Chrome and Opera > 12.1 */
@-webkit-keyframes fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Internet Explorer */
@-ms-keyframes fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Opera < 12.1 */
@-o-keyframes fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
+62
View File
@@ -0,0 +1,62 @@
import React from 'react'
import { connect } from 'react-redux'
import { withRouter, Link } from 'react-router-dom'
import * as notificationActions from '../reducers/actions/notificationActions'
import './LandingPage.css'
import registrationActions from '../reducers/actions/registrationActions'
class LandingPage extends React.Component {
componentDidMount() {
this.fetchOwnregistrations()
}
fetchOwnregistrations = async () => {
try {
await this.props.fetchRegistrations()
if (this.props.ownRegistrations.length > 0) {
this.props.history.push('/registrationdetails')
}
} catch (e) {
console.log('error happened', e)
this.props.setError('Error fetching own registration... try reloading the page', 3000)
}
}
render() {
return (
<div className="landingpage-container">
<h2 className="landingpage-header">Home</h2>
{this.props.projectOpen ? (
<Link to="/register" data-cy="registrationlink">
Submit your registration
</Link>
) : (
<div className="landingpage-message">{this.props.projectMessage}</div>
)}
</div>
)
}
}
const mapStateToProps = (state) => {
return {
projectOpen: state.registrationManagement.projectRegistrationOpen,
projectMessage: state.registrationManagement.projectRegistrationMessage,
ownRegistrations: state.registrations
}
}
const mapDispatchToProps = {
fetchRegistrations: registrationActions.fetchRegistrations,
setError: notificationActions.setError,
setSuccess: notificationActions.setSuccess
}
const ConnectedLandingPage = connect(
mapStateToProps,
mapDispatchToProps
)(LandingPage)
export default withRouter(ConnectedLandingPage)
+47
View File
@@ -0,0 +1,47 @@
.loginpage-container {
max-width: 400px;
max-height: 300px;
-webkit-animation: fadein 1s; /* Safari, Chrome and Opera > 12.1 */
-moz-animation: fadein 1s; /* Firefox < 16 */
-ms-animation: fadein 1s; /* Internet Explorer */
-o-animation: fadein 1s; /* Opera < 12.1 */
animation: fadein 1s;
}
.loginpage-header {
margin-bottom: 0
}
.loginpage-information {
margin-top: 3px;
}
@keyframes fadein {
from { opacity: 0; }
to { opacity: 1; }
}
/* Firefox < 16 */
@-moz-keyframes fadein {
from { opacity: 0; }
to { opacity: 1; }
}
/* Safari, Chrome and Opera > 12.1 */
@-webkit-keyframes fadein {
from { opacity: 0; }
to { opacity: 1; }
}
/* Internet Explorer */
@-ms-keyframes fadein {
from { opacity: 0; }
to { opacity: 1; }
}
/* Opera < 12.1 */
@-o-keyframes fadein {
from { opacity: 0; }
to { opacity: 1; }
}
+112
View File
@@ -0,0 +1,112 @@
import React from 'react'
import { connect } from 'react-redux'
import Button from '@material-ui/core/Button'
import TextField from '@material-ui/core/TextField'
import appActions from '../reducers/actions/appActions'
import loginPageActions from '../reducers/actions/loginPageActions'
import * as notificationActions from '../reducers/actions/notificationActions'
import * as userActions from '../reducers/actions/userActions'
import './LoginPage.css'
class LoginPage extends React.Component {
constructor(props) {
super(props)
this.login = this.login.bind(this)
}
login = async (event) => {
this.props.updateIsLoading(true)
event.preventDefault()
let username = this.props.username
let password = this.props.password
try {
await this.props.loginUser({ username, password })
this.props.setSuccess('Login successful!', 3000)
this.props.clearForm()
this.props.updateIsLoading(false)
} catch (e) {
console.log('error happened', e.response)
this.props.updateIsLoading(false)
if (e.response) {
if (e.response.status === 400) {
this.props.setError('Username or password is missing!', 3000)
} else if (e.response.status === 401) {
this.props.setError('Username or password is incorrect!', 3000)
}
} else {
this.props.setError('Error occurred, login failed', 3000)
}
}
}
render() {
return (
<div className="loginpage-container">
<h1 className="loginpage-header">Login</h1>
<p className="loginpage-information">
University of Helsinki account required for login.
</p>
<form onSubmit={this.login} className="loginpage-form">
<div>
<TextField
className="username-field"
error={this.props.username.length > 20 ? true : false}
type="text"
name="username"
label="Username"
value={this.props.username}
onChange={(e) => this.props.updateUsername(e.target.value)}
/>
</div>
<div>
<TextField
className="password-field"
error={this.props.password.length > 20 ? true : false}
type="password"
name="password"
label="Password"
value={this.props.password}
onChange={(e) => this.props.updatePassword(e.target.value)}
/>
</div>
<Button
className="loginpage-button"
style={{ marginTop: '30px' }}
variant="outlined"
color="default"
type="submit"
>
Log in
</Button>
</form>
</div>
)
}
}
const mapStateToProps = (state) => {
return {
username: state.loginPage.username,
password: state.loginPage.password,
isLoading: state.app.isLoading
}
}
const mapDispatchToProps = {
...loginPageActions,
...appActions,
setError: notificationActions.setError,
setSuccess: notificationActions.setSuccess,
loginUser: userActions.loginUser
}
const ConnectedLoginPage = connect(
mapStateToProps,
mapDispatchToProps
)(LoginPage)
export default ConnectedLoginPage
+179
View File
@@ -0,0 +1,179 @@
import React from 'react'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import { withStyles } from '@material-ui/core/styles'
import Table from '@material-ui/core/Table'
import TableBody from '@material-ui/core/TableBody'
import TableCell from '@material-ui/core/TableCell'
import TableHead from '@material-ui/core/TableHead'
import TableRow from '@material-ui/core/TableRow'
import Paper from '@material-ui/core/Paper'
import Button from '@material-ui/core/Button'
import * as notificationActions from '../reducers/actions/notificationActions'
const styles = (theme) => ({
root: {
width: '100%',
marginTop: theme.spacing.unit * 3,
overflowX: 'auto'
},
table: {
minWidth: 700
}
})
const testRows = [
{
id: 1,
preferredTopics: [
{
topic: 'HTML #1'
},
{
topic: 'JAVA #2'
},
{
topic: 'PYTHON #1'
},
{
topic: 'JS-PRO #99'
},
{
topic: 'C #22'
}
],
questions: 'questions',
semester: '2019'
},
{
id: 2,
preferredTopics: [
{
topic: 'JAVA #1'
},
{
topic: 'JAVA #2'
},
{
topic: 'PYTHON #1'
},
{
topic: 'HTML #2'
},
{
topic: 'DOCUMENTATION PRO'
}
],
questions: 'questions',
semester: '2019'
},
{
id: 3,
preferredTopics: [
{
topic: 'HTML #1'
},
{
topic: 'JAVA #2'
},
{
topic: 'JAVA #1'
},
{
topic: 'JS-PRO #99'
},
{
topic: 'C #22'
}
],
questions: 'questions',
semester: '2019'
},
{
id: 4,
preferredTopics: [
{
topic: 'HTML #1'
},
{
topic: 'HTML #2'
},
{
topic: 'HTML #3'
},
{
topic: 'HTML #99'
},
{
topic: 'HTML #22'
}
],
questions: 'questions',
semester: '2019'
}
]
class ParticipantsPage extends React.Component {
render() {
return (
<div className="participants-container">
<div>
<Paper className="participants-paper">
<Table className="participants-table">
<TableHead>
<TableRow>
<TableCell numeric>id</TableCell>
<TableCell>Preferred Topic #1</TableCell>
<TableCell>Preferred Topic #2</TableCell>
<TableCell>Preferred Topic #3</TableCell>
<TableCell>Preferred Topic #4</TableCell>
<TableCell>Preferred Topic #5</TableCell>
<TableCell>Questions</TableCell>
<TableCell>Semester</TableCell>
</TableRow>
</TableHead>
<TableBody>
{testRows.map((row) => {
return (
<TableRow key={row.id}>
<TableCell numeric component="th" scope="row">
{row.id}
</TableCell>
<TableCell>{row.preferredTopics[0].topic}</TableCell>
<TableCell>{row.preferredTopics[1].topic}</TableCell>
<TableCell>{row.preferredTopics[2].topic}</TableCell>
<TableCell>{row.preferredTopics[3].topic}</TableCell>
<TableCell>{row.preferredTopics[4].topic}</TableCell>
<TableCell>{row.questions}</TableCell>
<TableCell>{row.semester}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Paper>
</div>
<div className="export-cvs-button">
<Button variant="outlined">Export CVS-file</Button>
</div>
</div>
)
}
}
const mapStateToProps = (state) => {
return {
state: state
}
}
const mapDispatchToProps = {
setError: notificationActions.setError
}
const ConnectedParticipantsPage = connect(
mapStateToProps,
mapDispatchToProps
)(ParticipantsPage)
export default withRouter(withStyles(styles)(ConnectedParticipantsPage))
@@ -0,0 +1,48 @@
.peer-review-box {
text-align: center;
width: 70%;
border: 2px solid black;
padding: 50px;
margin-left: auto;
margin-right: auto;
margin-top: 25px;
margin-bottom: 25px;
border-radius: 25px;
}
.peer-review-container__h1,
.peer-review-box__h3 {
text-align: center;
}
@media screen and (max-width: 650px) {
.peer-review-box {
border: 0px solid black;
padding-left: 5px;
margin-left: 5px;
}
}
.peer-review-box__radio-header {
width: 100px;
}
.peer-review-box__radio-button-table {
margin: 0px auto;
padding: 0;
}
.peer-review-box__peer-row,
.peer-review-box__radio-row {
border: 1px solid black;
margin: auto;
}
.peer-review-box__peer-header,
.peer-review-box__radio-header,
.peer-review-box__radio-button {
border: 1px solid black;
margin: auto;
padding: 0;
border-radius: 3px;
}
+392
View File
@@ -0,0 +1,392 @@
import React from 'react'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import './PeerReviewPage.css'
import { getUser } from '../utils/functions'
// Actions
import appActions from '../reducers/actions/appActions'
import * as notificationActions from '../reducers/actions/notificationActions'
import peerReviewPageActions from '../reducers/actions/peerReviewPageActions.js'
// Services
import peerReviewService from '../services/peerReview'
import groupManagementService from '../services/groupManagement'
import TextField from '@material-ui/core/TextField'
import Button from '@material-ui/core/Button'
class PeerReview extends React.Component {
async componentDidMount() {
try {
const group = await groupManagementService.getByStudent()
if (group) {
const reviewQuestionsSet = await peerReviewService.getReviewQuestions(
this.props.reviewConf,
this.props.reviewRound
)
const questionObject = { questions: reviewQuestionsSet.questions }
this.props.setQuestions(questionObject)
this.fetchPeerReviewQuestions(group.students, reviewQuestionsSet)
this.props.createPeers(group.students)
const submittedReviews = await peerReviewService.get()
this.props.setSubmittedReviews(submittedReviews)
} else {
this.props.setLoading(false)
}
} catch (e) {
console.log('error happened', e.response)
this.props.setError('Something is wrong... try reloading the page')
}
}
async fetchPeerReviewQuestions(peers, questionObject) {
const initializeRadioAnswer = (question, questionId) => {
let peerAnswers = {
type: 'radio',
id: questionId,
questionHeader: question.header,
peers: {}
}
peers.forEach(
(peer) =>
(peerAnswers.peers[peer.first_names + ' ' + peer.last_name] = null)
)
return peerAnswers
}
const initializeNumberAnswer = (question, questionId) => {
return {
type: 'number',
questionHeader: question.header,
id: questionId,
answer: 0
}
}
const initializeTextAnswer = (question, questionId) => {
return {
type: 'text',
questionHeader: question.header,
id: questionId,
answer: ''
}
}
const tempAnswerSheet = questionObject.questions.map(
(question, questionID) => {
if (question.type === 'radio') {
return initializeRadioAnswer(question, questionID)
} else if (question.type === 'text') {
return initializeTextAnswer(question, questionID)
} else if (question.type === 'number') {
return initializeNumberAnswer(question, questionID)
} else {
return question
}
}
)
this.props.initializeAnswerSheet(tempAnswerSheet)
}
Submit = async (event, answerSheet, reviewConf) => {
event.preventDefault()
const answer = window.confirm(
'Answers can not be changed after submitting. Continue?'
)
if (!answer) return
try {
const createdReview = await peerReviewService.create({
peerReview: {
answer_sheet: answerSheet,
user_id: getUser().student_number,
configuration_id: reviewConf,
review_round: this.props.reviewRound
}
})
this.props.setSubmittedReviews(
this.props.submittedReviews.concat(createdReview)
)
this.props.setSuccess('Peer review saved!')
this.props.history.push('/')
} catch (e) {
console.log('error happened', e.response)
this.props.setError(e.response.data.error)
}
}
render() {
const {
answerSheet,
updateAnswer,
peers,
groupsLoading,
submittedReviews,
isInitializing,
questionObject,
reviewConf,
reviewOpen,
reviewRound
} = this.props
if (isInitializing) {
return (
<div className="peer-review-container">
<h1 className="peer-review-container__h1">Loading!</h1>
</div>
)
} else if (!reviewOpen) {
return (
<div className="peer-review-container">
<h1 className="peer-review-container__h1">
Peer review is not currently open!
</h1>
</div>
)
} else if (groupsLoading) {
return (
<div className="peer-review-container">
<h1 className="peer-review-container__h1">
You are not currently assigned to any group!
</h1>
</div>
)
} else if (submittedReviews.length >= reviewRound) {
return (
<div className="peer-review-container">
<h1 className="peer-review-container__h1">
Peer review already submitted!
</h1>
</div>
)
} else {
return (
<div className="peer-review-container">
<h1 className="peer-review-container__h1">Peer Review</h1>
<Questions
peers={peers}
questions={questionObject.questions}
answerSheet={answerSheet}
updateAnswer={updateAnswer}
/>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Button
margin-right="auto"
margin-left="auto"
variant="contained"
color="primary"
onClick={(event) => this.Submit(event, answerSheet, reviewConf)}
>
Submit
</Button>
</div>
</div>
)
}
}
}
const Questions = ({ questions, peers, answerSheet, updateAnswer }) => {
return (
<div>
{questions.map((question, questionId) => {
return (
<Question
key={questionId}
question={question}
peers={peers}
questionId={questionId}
answerSheet={answerSheet}
updateAnswer={updateAnswer}
/>
)
})}
</div>
)
}
const Question = ({
peers,
question,
questionId,
answerSheet,
updateAnswer
}) => {
if (question.type === 'radio') {
let temp = question.options
if (question.options === undefined) {
temp = [0, 1, 2, 3, 4, 5]
}
return (
<div className="peer-review-box">
<h3 className="peer-review-box__h3">{question.header}</h3>
<table className="peer-review-box__radio-button-table">
<thead>
<tr className="peer-review-box__radio-row">
<th className="peer-review-box__radio-header" />
<OptionHeaders options={temp} />
</tr>
</thead>
<tbody>
<QuestionRows
peers={peers}
options={temp}
questionId={question.header}
answerSheet={answerSheet[questionId]}
/>
</tbody>
</table>
</div>
)
} else if (question.type === 'text') {
return (
<div className="peer-review-box">
<h3 className="peer-review-box__h3">{question.header}</h3>
<p>{question.description}</p>
<TextField
value={answerSheet[questionId].answer}
rows="4"
style={{ width: 400 }}
multiline
variant="outlined"
onChange={(e) =>
textFieldHandler(e.target.value, questionId, updateAnswer)
}
/>
</div>
)
} else if (question.type === 'number') {
return (
<div className="peer-review-box">
<h3>{question.header}</h3>
<p>{question.description}</p>
<input
type="number"
data-cy={`input_number_${question.header}`}
value={answerSheet[questionId].answer}
onChange={(e) =>
textFieldHandler(e.target.value, questionId, updateAnswer)
}
/>
</div>
)
} else if (question.type === 'info') {
return (
<div className="peer-review-box">
<h3>{question.header}</h3>
<p>{question.description}</p>
</div>
)
} else {
return (
<div>
<p>Incorrect question type</p>
</div>
)
}
}
const textFieldHandler = (value, questionId, updateAnswer) => {
updateAnswer(value, questionId)
}
const OptionHeaders = ({ options }) => {
return options.map((option, optionId) => {
return (
<th className="peer-review-box__radio-header" key={optionId}>
{option}
</th>
)
})
}
const extractCallingName = (firstNames) => {
if (firstNames.includes('*')) {
return firstNames.split('*')[1].split(' ')[0]
}
return firstNames.split(' ')[0]
}
const QuestionRows = ({ peers, options, questionId, answerSheet }) => (
<React.Fragment>
{peers.map((peer, mappiAvain) => (
<QuestionRow
key={mappiAvain}
options={options}
peerName={extractCallingName(peer.first_names) + ' ' + peer.last_name}
peerId={peer.first_names + ' ' + peer.last_name}
questionId={questionId}
answerSheet={answerSheet}
/>
))}
</React.Fragment>
)
const QuestionRow = ({
peerName,
options,
peerId,
questionId,
answerSheet
}) => {
return (
<tr className="peer-review-box__peer-row">
<th className="peer-review-box__peer-header">{peerName}</th>
{options.map((buttonId, buttonNumber) => {
return (
<th className="peer-review-box__radio-button" key={buttonId}>
<input
type="radio"
name={questionId.toString() + peerId.toString()}
onClick={() =>
radioSelectHandler(peerId, buttonNumber, answerSheet)
}
/>
</th>
)
})}
</tr>
)
}
const radioSelectHandler = (peerId, buttonId, answerSheet) => {
updateAnswers(peerId, buttonId, answerSheet)
}
const updateAnswers = (peerId, buttonId, answerSheet) => {
answerSheet.peers[peerId] = buttonId
}
const mapStateToProps = (state) => {
return {
answerSheet: state.peerReviewPage.answerSheet,
isInitializing: state.peerReviewPage.isInitializing,
peers: state.peerReviewPage.peers,
groupsLoading: state.peerReviewPage.groupsLoading,
submittedReviews: state.peerReviewPage.submittedReviews,
questionObject: state.peerReviewPage.questions,
reviewConf: state.registrationManagement.peerReviewConf,
reviewRound: state.registrationManagement.peerReviewRound,
reviewOpen: state.registrationManagement.peerReviewOpen
}
}
const mapDispatchToProps = {
...peerReviewPageActions,
...notificationActions,
...appActions
}
const ConnectedPeerReview = connect(
mapStateToProps,
mapDispatchToProps
)(PeerReview)
export default withRouter(ConnectedPeerReview)
@@ -0,0 +1,60 @@
import React from 'react'
import { connect } from 'react-redux'
import Button from '@material-ui/core/Button'
import { createPeerReviewQuestionSet } from '../../reducers/actions/peerReviewQuestionsPageActions'
import {
setError,
setSuccess
} from '../../reducers/actions/notificationActions'
import QuestionSetForm from '../QuestionSetForm'
const isValidationError = (err) => err.response && err.response.status === 400
const CreatePeerReviewQuestionSet = ({
onCreateRequested,
onSuccess,
onError
}) => {
const handleError = (err) => {
console.error('Error while creating peer review question set', err)
if (isValidationError(err)) {
onError(
`An error occurred while creating peer review question set: ${
err.response.data.error
}`,
5000
)
} else {
onError('Some error happened', 3000)
}
}
const handleSubmit = async (name, questionsJson) => {
try {
await onCreateRequested(name, JSON.parse(questionsJson))
onSuccess(`Created new peer review question set "${name}"`)
} catch (err) {
handleError(err)
}
}
const submitButton = (
<Button type="submit" variant="contained" color="primary">
Create
</Button>
)
return <QuestionSetForm onSubmit={handleSubmit} controls={submitButton} />
}
const mapDispatchToProps = {
onCreateRequested: createPeerReviewQuestionSet,
onSuccess: setSuccess,
onError: setError
}
export default connect(
null,
mapDispatchToProps
)(CreatePeerReviewQuestionSet)
@@ -0,0 +1,50 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { peerReviewQuestionSetShape } from '../common/sharedPropTypes'
import QuestionSetItem from '../QuestionSetItem'
import QuestionSetItemEditor from '../QuestionSetItemEditor'
import PeerReviewQuestionsTable from '../PeerReviewQuestionsTable'
const EditableRegistrationQuestionSet = ({ questionSet, onEditSave }) => {
const [isEditing, setIsEditing] = useState(false)
const handleEditorSave = (name, questionsJson) => {
const questions = JSON.parse(questionsJson)
const updatedItem = {
...questionSet,
name,
questions
}
onEditSave(updatedItem)
setIsEditing(false)
}
const { name, questions } = questionSet
const renderNormalItem = () => (
<QuestionSetItem title={name} onEditClicked={() => setIsEditing(true)}>
<PeerReviewQuestionsTable questions={questions} />
</QuestionSetItem>
)
const renderEditingItem = () => (
<QuestionSetItemEditor
initialName={name}
initialQuestionsJson={JSON.stringify(questions, null, 2)}
onSave={handleEditorSave}
onCancel={() => setIsEditing(false)}
/>
)
return isEditing ? renderEditingItem() : renderNormalItem()
}
EditableRegistrationQuestionSet.propTypes = {
questionSet: peerReviewQuestionSetShape,
onEditSave: PropTypes.func
}
export default EditableRegistrationQuestionSet
@@ -0,0 +1,5 @@
.peer-review-question-set-list {
list-style: none;
margin: 0;
padding: 0;
}
@@ -0,0 +1,26 @@
import React from 'react'
import EditablePeerReviewQuestionSet from './EditablePeerReviewQuestionSet'
import './PeerReviewQuestionSetList.css'
const compareQuestionSetCreatedAtDesc = (a, b) =>
b.createdAt.localeCompare(a.createdAt)
const RegistrationQuestionSetList = ({ questionSets, onQuestionSetUpdate }) => {
const byCreatedAtDesc = [...questionSets].sort(
compareQuestionSetCreatedAtDesc
)
return (
<ul className="peer-review-question-set-list">
{byCreatedAtDesc.map((set) => (
<EditablePeerReviewQuestionSet
key={set.id}
questionSet={set}
onEditSave={onQuestionSetUpdate}
/>
))}
</ul>
)
}
export default RegistrationQuestionSetList
@@ -0,0 +1,7 @@
.peer-review-questions-page__container {
max-width: 1000px;
}
.peer-review-questions-page__create-form {
padding: 0.5rem 1rem 1rem 1rem;
}
@@ -0,0 +1,111 @@
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import Paper from '@material-ui/core/Paper'
import {
fetchPeerReviewQuestionSets,
updatePeerReviewQuestionSet
} from '../../reducers/actions/peerReviewQuestionsPageActions'
import {
setError,
setSuccess
} from '../../reducers/actions/notificationActions'
import { peerReviewQuestionSetShape } from '../common/sharedPropTypes'
import PeerReviewQuestionSetList from './PeerReviewQuestionSetList'
import CreatePeerReviewQuestionSet from './CreatePeerReviewQuestionSet'
import './PeerReviewQuestionsPage.css'
const isValidationError = (e) => e.response && e.response.status === 400
class PeerReviewQuestionsPage extends React.Component {
async componentDidMount() {
try {
await this.props.fetchPeerReviewQuestionSets()
} catch (err) {
this.handleError(err)
}
}
handleError = (e) => {
const { setError } = this.props
console.error('error happened', e, e.response)
if (isValidationError(e)) {
// was error 400, server should have responded { "error": "..." }
setError(`An error occurred: "${e.response.data.error}"!`, 5000)
} else {
setError('Some error happened', 3000)
}
}
handleSuccess = (message) => {
this.props.setSuccess(message, 3000)
}
handleQuestionSetUpdate = async (updatedQuestionSet) => {
try {
await this.props.updatePeerReviewQuestionSet(updatedQuestionSet)
this.handleSuccess(`Updated question set "${updatedQuestionSet.name}"!`)
} catch (err) {
this.handleError(err)
}
}
render() {
const { questionSets } = this.props
return (
<div className="peer-review-questions-page">
<h1>Configure peer review questions</h1>
<div className="peer-review-questions-page__container">
<section style={{ marginBottom: '2rem' }}>
<h2>Create question set</h2>
<Paper
depth={1}
className="peer-review-questions-page__create-form"
>
<CreatePeerReviewQuestionSet />
</Paper>
</section>
<section>
<h2>Question sets</h2>
<PeerReviewQuestionSetList
questionSets={questionSets}
onQuestionSetUpdate={this.handleQuestionSetUpdate}
/>
</section>
</div>
</div>
)
}
}
PeerReviewQuestionsPage.propTypes = {
questionSets: PropTypes.arrayOf(peerReviewQuestionSetShape),
history: PropTypes.any,
setError: PropTypes.func,
setSuccess: PropTypes.func,
fetchPeerReviewQuestionSets: PropTypes.func,
updatePeerReviewQuestionSet: PropTypes.func
}
const mapStateToProps = (state) => ({
questionSets: state.peerReviewQuestionsPage.questionSets
})
const mapDispatchToProps = {
fetchPeerReviewQuestionSets,
updatePeerReviewQuestionSet,
setError,
setSuccess
}
const ConnectedPeerReviewQuestionsPage = connect(
mapStateToProps,
mapDispatchToProps
)(PeerReviewQuestionsPage)
export default withRouter(ConnectedPeerReviewQuestionsPage)
@@ -0,0 +1,2 @@
import PeerReviewQuestionsPage from './PeerReviewQuestionsPage'
export default PeerReviewQuestionsPage
@@ -0,0 +1,56 @@
import React from 'react'
import PropTypes from 'prop-types'
import Table from '@material-ui/core/Table'
import TableHead from '@material-ui/core/TableHead'
import TableRow from '@material-ui/core/TableRow'
import TableCell from '@material-ui/core/TableCell'
import TableBody from '@material-ui/core/TableBody'
import { peerReviewQuestionShape } from './common/sharedPropTypes'
const PeerReviewQuestionsTableRow = ({
type,
header,
description,
options
}) => (
<TableRow className="registration-questions-table-row">
<TableCell component="th" scope="row">
{header}
</TableCell>
<TableCell>{description}</TableCell>
<TableCell>{type}</TableCell>
<TableCell>{JSON.stringify(options)}</TableCell>
</TableRow>
)
const RegistrationQuestionsTable = ({ questions }) => (
<Table className="registration-questions-table">
<TableHead>
<TableRow>
<TableCell>Header</TableCell>
<TableCell>Description</TableCell>
<TableCell>Type</TableCell>
<TableCell align="right">Options</TableCell>
</TableRow>
</TableHead>
<TableBody>
{questions.map(({ type, header, description, options }) => (
<PeerReviewQuestionsTableRow
key={header}
type={type}
header={header}
description={description}
options={options}
/>
))}
</TableBody>
</Table>
)
RegistrationQuestionsTable.propTypes = {
questions: PropTypes.arrayOf(peerReviewQuestionShape)
}
export default RegistrationQuestionsTable
@@ -0,0 +1,4 @@
.question-set-form__error {
margin-left: 2rem;
color: red;
}
@@ -0,0 +1,92 @@
import React, { useState } from 'react'
import TextField from '@material-ui/core/TextField'
import './QuestionSetForm.css'
const validateQuestionsJson = (str) => {
try {
const obj = JSON.parse(str)
if (!Array.isArray(obj)) {
return { ok: false, error: 'Questions should be an array' }
}
return { ok: true }
} catch (err) {
return { ok: false, error: 'Field contains invalid JSON' }
}
}
const QuestionSetForm = ({
initialName,
initialQuestionsJson,
onSubmit,
controls
}) => {
const [name, setName] = useState(initialName || '')
const [questionsJson, setQuestionsJson] = useState(initialQuestionsJson || '')
const [questionsError, setQuestionsError] = useState('')
const clearQuestionsError = () => {
if (questionsError !== '') {
setQuestionsError('')
}
}
const handleNameChange = (e) => setName(e.target.value)
const handleQuestionsChange = (e) => {
setQuestionsJson(e.target.value)
clearQuestionsError()
}
const handleFormSubmit = (e) => {
e.preventDefault()
const { ok, error } = validateQuestionsJson(questionsJson)
if (!ok) {
setQuestionsError(error)
return
}
onSubmit(name, questionsJson)
setName('')
setQuestionsJson('')
}
return (
<form className="question-set-form" onSubmit={handleFormSubmit}>
<TextField
inputProps={{
className: 'question-set-form__name'
}}
fullWidth
required
label="Name"
margin="normal"
value={name}
onChange={handleNameChange}
/>
<TextField
error={!!questionsError}
inputProps={{
style: {
fontFamily:
'source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace',
fontSize: '0.9em'
},
className: 'question-set-form__questions'
}}
fullWidth
required
label="Questions (JSON)"
helperText={questionsError || ''}
margin="normal"
multiline
rows={10}
value={questionsJson}
onChange={handleQuestionsChange}
/>
{controls}
</form>
)
}
export default QuestionSetForm
@@ -0,0 +1,35 @@
.question-set-item {
margin-bottom: 2rem;
}
.question-set-item__name {
font-weight: normal;
}
.question-set-item__header {
padding: 0.75rem 1rem;
/* the MUI primary color is injected to the border color via style*/
border-left: 2px solid transparent;
/* CSS gurus hate him!
* Hack the header's z-index higher with this simple trick!
* (positioned elements are stacked above non-positioned [position:static])
*/
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
}
.question-set-item__title {
margin: 0; /* reset default h3 margins */
}
.question-set-item__controls {
margin-left: auto;
}
.question-set-item__content {
padding: 0 5px;
}
@@ -0,0 +1,90 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import Menu from '@material-ui/core/Menu'
import MenuItem from '@material-ui/core/MenuItem'
import Paper from '@material-ui/core/Paper'
import { withTheme } from '@material-ui/core/styles'
import IconButton from '@material-ui/core/IconButton'
import MoreVertIcon from '@material-ui/icons/MoreVert'
import './QuestionSetItem.css'
const ItemControls = ({ onEditClicked }) => {
const [anchorEl, setAnchorEl] = useState(null)
const handleClick = (e) => {
setAnchorEl(e.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
const withClose = (fn) => () => {
handleClose()
fn()
}
return (
<>
<IconButton
className="question-set-item-controls__menu-button"
aria-owns={anchorEl && 'question-set-item-controls__menu'}
aria-haspopup={true}
onClick={handleClick}
>
<MoreVertIcon />
</IconButton>
<Menu
id="question-set-item-controls__menu"
anchorEl={anchorEl}
open={!!anchorEl}
onClose={handleClose}
>
<MenuItem
className="question-set-item-controls__edit-button"
onClick={withClose(onEditClicked)}
>
Edit
</MenuItem>
</Menu>
</>
)
}
ItemControls.propTypes = {
onEditClicked: PropTypes.func
}
const QuestionSetItem = ({ title, children, onEditClicked, theme }) => {
const headerStyle = {
borderColor: theme.palette.primary.main
}
return (
<div className="question-set-item">
<Paper
elevation={2}
className="question-set-item__header"
style={headerStyle}
>
<h3 className="question-set-item__title">{title}</h3>
<div className="question-set-item__controls">
<ItemControls onEditClicked={onEditClicked} />
</div>
</Paper>
<div className="question-set-item__content">
<Paper elevation={1}>{children}</Paper>
</div>
</div>
)
}
QuestionSetItem.propTypes = {
title: PropTypes.string,
children: PropTypes.node,
onEditClicked: PropTypes.func,
theme: PropTypes.object
}
export default withTheme()(QuestionSetItem)
@@ -0,0 +1,7 @@
.question-set-item-editor {
margin-bottom: 2rem;
}
.question-set-item-editor__container {
padding: 0.5rem 1rem 1rem 1rem;
}
@@ -0,0 +1,71 @@
import React from 'react'
import PropTypes from 'prop-types'
import Button from '@material-ui/core/Button'
import Paper from '@material-ui/core/Paper'
import QuestionSetForm from './QuestionSetForm'
import './QuestionSetItemEditor.css'
const SaveButton = ({ className }) => (
<Button
className={className}
type="submit"
variant="contained"
color="primary"
>
Save changes
</Button>
)
SaveButton.propTypes = {
className: PropTypes.string
}
const CancelButton = ({ className, onClick }) => (
<Button className={className} onClick={onClick}>
Cancel
</Button>
)
CancelButton.propTypes = {
className: PropTypes.string,
onClick: PropTypes.func
}
const QuestionSetItemEditor = ({
initialName,
initialQuestionsJson,
onSave,
onCancel
}) => {
const formControls = (
<>
<SaveButton className="question-set-item-editor__save-button" />
<CancelButton
className="question-set-item-editor__cancel-button"
onClick={onCancel}
/>
</>
)
return (
<div className="question-set-item-editor">
<Paper elevation={1} className="question-set-item-editor__container">
<QuestionSetForm
initialName={initialName}
initialQuestionsJson={initialQuestionsJson}
onSubmit={onSave}
controls={formControls}
/>
</Paper>
</div>
)
}
QuestionSetItemEditor.propTypes = {
initialName: PropTypes.string,
initialQuestionsJson: PropTypes.string,
onSave: PropTypes.func,
onCancel: PropTypes.func
}
export default QuestionSetItemEditor
+83
View File
@@ -0,0 +1,83 @@
import React from 'react'
import Select from '@material-ui/core/Select'
import MenuItem from '@material-ui/core/MenuItem'
import TextField from '@material-ui/core/TextField'
import Button from '@material-ui/core/Button'
const QuestionsFrom = props => {
return (
<div>
<h1>{props.title}</h1>
<div>
<Select
value={
props.selected_question_set.old_name !== ''
? props.selected_question_set.old_name
: 'new_is_selected'
}
onChange={event =>
props.updateSelectedQuestionSet(event.target.value)
}
>
<MenuItem value="" disabled>
<em>Pick a question set</em>
</MenuItem>
{props.question_sets.map(question_set => (
<MenuItem key={question_set.id} value={question_set.name}>
{question_set.name}
</MenuItem>
))}
<MenuItem value="new_is_selected">New</MenuItem>
</Select>
</div>
<div>
<TextField
fullWidth
required
label="Name"
margin="normal"
value={props.selected_question_set.name}
onChange={e => props.updateSelectedQuestionSetName(e.target.value)}
/>
</div>
<div>
<div>
<TextField
fullWidth
required
label="Questions"
margin="normal"
multiline
rows={5}
value={props.selected_question_set.questions}
onChange={e =>
props.updateSelectedQuestionSetQuestions(e.target.value)
}
/>
</div>
<div>
<Button
type="submit"
variant="contained"
color="primary"
onClick={props.submitNewQuestionSet}
>
Create new question set
</Button>
{props.selected_question_set.old_name === '' ? null : (
<Button
type="submit"
variant="contained"
color="primary"
onClick={props.editOldQuestionSet}
>
Edit {props.selected_question_set.old_name} question set
</Button>
)}
</div>
</div>
</div>
)
}
export default QuestionsFrom
@@ -0,0 +1,22 @@
.dragndrop-container {
display: flex;
flex-direction: row;
}
.dragndrop-list {
flex: 99;
}
.dragndrop-indexes-container {
flex: 1;
height: 80px;
}
.dragndrop-index {
padding: 30px 20px 30px 20px;
max-height: 18px;
margin: 2px;
font-weight: bold;
font-family: 'Roboto', sans-serif;
color: #4d4d4d;
}

Some files were not shown because too many files have changed in this diff Show More