diff --git a/backend/sql/02_create-user_tables.sql b/backend/sql/02_create-user_tables.sql index ce17a7a..9881748 100644 --- a/backend/sql/02_create-user_tables.sql +++ b/backend/sql/02_create-user_tables.sql @@ -1,12 +1,12 @@ -- create table for users create table candymat_data.person ( - id serial primary key, + row_id serial primary key, first_name character varying(200) check (first_name <> ''), last_name character varying(200) check (last_name <> ''), about character varying(2000), - created_at timestamp default now(), - role candymat_data.role not null default 'candymat_person' + created_at timestamp default now(), + role candymat_data.role not null default 'candymat_person' ); grant select, update, delete on table candymat_data.person to candymat_person; -- the following is only necessary as long as anonymous should be able to view candidates and editors @@ -15,16 +15,16 @@ grant select on table candymat_data.person to candymat_anonymous; -- create table for accounts create table candymat_data_privat.person_account ( - person_id integer primary key references candymat_data.person (id) on delete cascade, + person_row_id integer primary key references candymat_data.person (row_id) on delete cascade, email character varying(320) not null unique check (email ~* '^.+@.+\..+$'), password_hash character varying(256) not null ); alter table candymat_data.person enable row level security; create policy update_person on candymat_data.person for update to candymat_person - with check (id = nullif(current_setting('jwt.claims.person_id', true), '')::integer); + with check (row_id = nullif(current_setting('jwt.claims.person_row_id', true), '')::integer); create policy delete_person on candymat_data.person for delete to candymat_person - using (id = nullif(current_setting('jwt.claims.person_id', true), '')::integer); + using (row_id = nullif(current_setting('jwt.claims.person_row_id', true), '')::integer); -- The following enables viewing candidates and editors information for every person. -- This may be changed to only enable registered (and verified) persons. create policy select_person_public diff --git a/backend/sql/03_create_content_tables.sql b/backend/sql/03_create_content_tables.sql index 9383ce4..68eb78c 100644 --- a/backend/sql/03_create_content_tables.sql +++ b/backend/sql/03_create_content_tables.sql @@ -1,39 +1,39 @@ -- create table for categories create table candymat_data.category ( - id serial primary key, - title character varying(300) UNIQUE NOT NULL, - description character varying(5000) + row_id serial primary key, + title character varying(300) UNIQUE NOT NULL check ( title <> '' ), + description character varying(15000) ); grant select on table candymat_data.category to candymat_person; -- the following line is only necessary as long as the candymat should be publicly accessible grant select on table candymat_data.category to candymat_anonymous; grant insert, update, delete on table candymat_data.category to candymat_editor; -grant usage on sequence candymat_data.category_id_seq to candymat_editor; +grant usage on sequence candymat_data.category_row_id_seq to candymat_editor; -- create table for questions create table candymat_data.question ( - id serial primary key, - category_id integer REFERENCES candymat_data.category (id) ON UPDATE CASCADE ON DELETE SET NULL, - text character varying(3000) NOT NULL, - description character varying(5000) + row_id serial primary key, + category_row_id integer REFERENCES candymat_data.category (row_id) ON UPDATE CASCADE ON DELETE SET NULL, + title character varying(3000) UNIQUE NOT NULL check ( title <> '' ), + description character varying(15000) ); grant select on table candymat_data.question to candymat_person; -- the following line is only necessary as long as the candymat should be publicly accessible grant select on table candymat_data.question to candymat_anonymous; grant insert, update, delete on table candymat_data.question to candymat_editor; -grant usage on sequence candymat_data.question_id_seq to candymat_editor; +grant usage on sequence candymat_data.question_row_id_seq to candymat_editor; -- create table for answers create table candymat_data.answer ( - question_id integer REFERENCES candymat_data.question (id) ON UPDATE CASCADE ON DELETE CASCADE, - person_id integer REFERENCES candymat_data.person (id) ON UPDATE CASCADE ON DELETE CASCADE, - position integer NOT NULL, - text character varying(5000), - created_at timestamp default now(), - primary key (question_id, person_id) + question_row_id integer REFERENCES candymat_data.question (row_id) ON UPDATE CASCADE ON DELETE CASCADE, + person_row_id integer REFERENCES candymat_data.person (row_id) ON UPDATE CASCADE ON DELETE CASCADE, + position integer NOT NULL, + text character varying(15000), + created_at timestamp default now(), + primary key (question_row_id, person_row_id) ); grant select on table candymat_data.answer to candymat_person; -- the following line is only necessary as long as the candymat should be publicly accessible @@ -43,7 +43,7 @@ grant insert, update, delete on table candymat_data.answer to candymat_candidate alter table candymat_data.answer enable row level security; create policy change_answer on candymat_data.answer to candymat_candidate - using (person_id = nullif(current_setting('jwt.claims.person_id', true), '')::integer); + using (person_row_id = nullif(current_setting('jwt.claims.person_row_id', true), '')::integer); create policy select_answer on candymat_data.answer for select diff --git a/backend/sql/04_setup_authentication.sql b/backend/sql/04_setup_authentication.sql index fa744ca..e6bb725 100644 --- a/backend/sql/04_setup_authentication.sql +++ b/backend/sql/04_setup_authentication.sql @@ -1,16 +1,19 @@ create extension if not exists "pgcrypto"; -- Define JWT claim structure -create type candymat_data.jwt_token as ( - role text, - person_id integer, - exp bigint +create type candymat_data.jwt_token as +( + role text, + person_row_id integer, + exp bigint ); -create function candymat_data.current_person() returns candymat_data.person as $$ - select * - from candymat_data.person - where id = nullif(current_setting('jwt.claims.person_id', true), '')::integer +create function candymat_data.current_person( +) returns candymat_data.person as +$$ +select * +from candymat_data.person +where row_id = nullif(current_setting('jwt.claims.person_row_id', true), '')::integer $$ language sql stable; grant execute on function candymat_data.current_person() to candymat_person; @@ -37,8 +40,8 @@ begin values ($1, $2) returning * into person; - insert into candymat_data_privat.person_account (person_id, email, password_hash) - values (person.id, $3, crypt($4, gen_salt('bf'))); + insert into candymat_data_privat.person_account (person_row_id, email, password_hash) + values (person.row_id, $3, crypt($4, gen_salt('bf'))); return person; end ; @@ -48,9 +51,10 @@ $$ language plpgsql strict grant execute on function candymat_data.register_person(text, text, text, text) to candymat_anonymous; create function candymat_data.authenticate( - email text, - password text -) returns candymat_data.jwt_token as $$ + email text, + password text +) returns candymat_data.jwt_token as +$$ declare account candymat_data_privat.person_account; declare person candymat_data.person; @@ -63,30 +67,39 @@ begin select p.* into person from candymat_data.person as p - where p.id = account.person_id; + where p.row_id = account.person_row_id; if account.password_hash = crypt(password, account.password_hash) then - return (person.role, account.person_id, + return (person.role, account.person_row_id, extract(epoch from (now() + interval '2 days')))::candymat_data.jwt_token; else return null; end if; end; -$$ language plpgsql strict security definer; +$$ language plpgsql strict + security definer; grant execute on function candymat_data.authenticate(text, text) to candymat_anonymous, candymat_person; create function candymat_data.change_role( - person_id integer, - new_role candymat_data.role -) returns table(first_name text, last_name text, role candymat_data.role) as $$ + person_row_id integer, + new_role candymat_data.role +) + returns table + ( + first_name text, + last_name text, + role candymat_data.role + ) +as +$$ begin update candymat_data.person set role = new_role - where candymat_data.person.id = $1; + where candymat_data.person.row_id = $1; return query select candymat_data.person.first_name::text, candymat_data.person.last_name::text, new_role from candymat_data.person - where person.id = person_id; + where person.row_id = person_row_id; end; $$ language plpgsql; grant execute on function candymat_data.change_role(integer, candymat_data.role) to candymat_editor; diff --git a/backend/sql/test_02_add_questions.sql b/backend/sql/test_02_add_questions.sql index dc55509..283952d 100644 --- a/backend/sql/test_02_add_questions.sql +++ b/backend/sql/test_02_add_questions.sql @@ -3,7 +3,7 @@ insert into candymat_data.category (title, description) values insert into candymat_data.category (title, description) values ('Sonstiges', ''); -insert into candymat_data.question (category_id, text, description) values +insert into candymat_data.question (category_row_id, title, description) values (1, 'Was sagen Sie zur 10H Regel?', 'In Bayern dürfen Windräder nur ...'); -insert into candymat_data.question (category_id, text, description) values +insert into candymat_data.question (category_row_id, title, description) values (2, 'Umgehungsstraße XY?', 'Zur Entlastung der Hauptstraße ...'); diff --git a/backend/sql/test_03_add_answers.sql b/backend/sql/test_03_add_answers.sql index 7439f5f..1162a32 100644 --- a/backend/sql/test_03_add_answers.sql +++ b/backend/sql/test_03_add_answers.sql @@ -1,9 +1,9 @@ -insert into candymat_data.answer (question_id, person_id, position, text) values - (1, 2, 2, 'bin dagegen'); -insert into candymat_data.answer (question_id, person_id, position, text) values -(2, 2, 0, 'bin dafür'); +insert into candymat_data.answer (question_row_id, person_row_id, position, text) +values (1, 2, 2, 'bin dagegen'); +insert into candymat_data.answer (question_row_id, person_row_id, position, text) +values (2, 2, 0, 'bin dafür'); -insert into candymat_data.answer (question_id, person_id, position, text) values -(1, 3, 1, 'mir egal'); -insert into candymat_data.answer (question_id, person_id, position, text) values -(2, 3, 3, 'keine lust mehr'); +insert into candymat_data.answer (question_row_id, person_row_id, position, text) +values (1, 3, 1, 'mir egal'); +insert into candymat_data.answer (question_row_id, person_row_id, position, text) +values (2, 3, 3, 'keine lust mehr'); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 41034c4..075fe9f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -59,7 +59,8 @@ services: "--jwt-secret", $JWT_SECRET, "--watch", "--retry-on-init-fail", - "--enhance-graphiql" + "--enhance-graphiql", + "--classic-ids", ] networks: - frontend diff --git a/redaktions-app/package-lock.json b/redaktions-app/package-lock.json index 619b7e9..2e4318a 100644 --- a/redaktions-app/package-lock.json +++ b/redaktions-app/package-lock.json @@ -5,20 +5,21 @@ "requires": true, "dependencies": { "@apollo/client": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@apollo/client/-/client-3.1.3.tgz", - "integrity": "sha512-zXMiaj+dX0sgXIwEV5d/PI6B8SZT2bqlKNjZWcEXRY7NjESF5J3nd4v8KOsrhHe+A3YhNv63tIl35Sq7uf41Pg==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-3.3.6.tgz", + "integrity": "sha512-XSm/STyNS8aHdDigLLACKNMHwI0qaQmEHWHtTP+jHe/E1wZRnn66VZMMgwKLy2V4uHISHfmiZ4KpUKDPeJAKqg==", "requires": { + "@graphql-typed-document-node/core": "^3.0.0", "@types/zen-observable": "^0.8.0", "@wry/context": "^0.5.2", - "@wry/equality": "^0.2.0", + "@wry/equality": "^0.3.0", "fast-json-stable-stringify": "^2.0.0", "graphql-tag": "^2.11.0", "hoist-non-react-statics": "^3.3.2", - "optimism": "^0.12.1", + "optimism": "^0.13.1", "prop-types": "^15.7.2", - "symbol-observable": "^1.2.0", - "ts-invariant": "^0.4.4", + "symbol-observable": "^2.0.0", + "ts-invariant": "^0.6.0", "tslib": "^1.10.0", "zen-observable": "^0.8.14" } @@ -1171,6 +1172,11 @@ "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==" }, + "@graphql-typed-document-node/core": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.1.0.tgz", + "integrity": "sha512-wYn6r8zVZyQJ6rQaALBEln5B1pzxb9shV5Ef97kTvn6yVGrqyXVnDqnU24MXnFubR+rZjBY9NWuxX3FB2sTsjg==" + }, "@hapi/address": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-2.1.4.tgz", @@ -2066,6 +2072,11 @@ "@types/jest": "*" } }, + "@types/ungap__global-this": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@types/ungap__global-this/-/ungap__global-this-0.3.1.tgz", + "integrity": "sha512-+/DsiV4CxXl6ZWefwHZDXSe1Slitz21tom38qPCaG0DYCS1NnDPIQDTKcmQ/tvK/edJUKkmuIDBJbmKDiB0r/g==" + }, "@types/yargs": { "version": "13.0.10", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.10.tgz", @@ -2080,9 +2091,9 @@ "integrity": "sha512-FA/BWv8t8ZWJ+gEOnLLd8ygxH/2UFbAvgEonyfN6yWGLKc7zVjbpl2Y4CTjid9h2RfgPP6SEt6uHwEOply00yw==" }, "@types/zen-observable": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.0.tgz", - "integrity": "sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg==" + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.2.tgz", + "integrity": "sha512-HrCIVMLjE1MOozVoD86622S7aunluLb2PJdPfb3nYiEtohm8mIB/vyv0Fd37AdeMFrTUQXEunw78YloMA3Qilg==" }, "@typescript-eslint/eslint-plugin": { "version": "2.34.0", @@ -2138,6 +2149,11 @@ } } }, + "@ungap/global-this": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@ungap/global-this/-/global-this-0.4.3.tgz", + "integrity": "sha512-MuHEpDBurNVeD6mV9xBcAN2wfTwuaFQhHuhWkJuXmyVJ5P5sBCw+nnFpdfb0tAvgWkfefWCsAoAsh7MTUr3LPg==" + }, "@webassemblyjs/ast": { "version": "1.8.5", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.8.5.tgz", @@ -2297,19 +2313,33 @@ } }, "@wry/context": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.5.2.tgz", - "integrity": "sha512-B/JLuRZ/vbEKHRUiGj6xiMojST1kHhu4WcreLfNN7q9DqQFrb97cWgf/kiYsPSUCAMVN0HzfFc8XjJdzgZzfjw==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.5.3.tgz", + "integrity": "sha512-n0uKHiWpf2ArHhmcHcUsKA+Dj0gtye/h56VmsDcoMRuK/ZPFeHKi8ck5L/ftqtF12ZbQR9l8xMPV7y+xybaRDA==", "requires": { - "tslib": "^1.9.3" + "tslib": "^1.14.1" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + } } }, "@wry/equality": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.2.0.tgz", - "integrity": "sha512-Y4d+WH6hs+KZJUC8YKLYGarjGekBrhslDbf/R20oV+AakHPINSitHfDRQz3EGcEWc1luXYNUvMhawWtZVWNGvQ==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.3.1.tgz", + "integrity": "sha512-8/Ftr3jUZ4EXhACfSwPIfNsE8V6WKesdjp+Dxi78Bej6qlasAxiz0/F8j0miACRj9CL4vC5Y5FsfwwEYAuhWbg==", "requires": { - "tslib": "^1.9.3" + "tslib": "^1.14.1" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + } } }, "@xtuc/ieee754": { @@ -3924,6 +3954,12 @@ "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=" }, + "compare-versions": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz", + "integrity": "sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==", + "dev": true + }, "component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", @@ -6109,6 +6145,15 @@ "locate-path": "^3.0.0" } }, + "find-versions": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-3.2.0.tgz", + "integrity": "sha512-P8WRou2S+oe222TOCHitLy8zj+SIsVJh52VP4lvXkaFVnOFFdoWv1H1Jjvel1aI6NCFOAaeAVm8qrI0odiLcww==", + "dev": true, + "requires": { + "semver-regex": "^2.0.0" + } + }, "flat-cache": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-2.0.1.tgz", @@ -6847,6 +6892,171 @@ "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=" }, + "husky": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/husky/-/husky-4.3.6.tgz", + "integrity": "sha512-o6UjVI8xtlWRL5395iWq9LKDyp/9TE7XMOTvIpEVzW638UcGxTmV5cfel6fsk/jbZSTlvfGVJf2svFtybcIZag==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "ci-info": "^2.0.0", + "compare-versions": "^3.6.0", + "cosmiconfig": "^7.0.0", + "find-versions": "^3.2.0", + "opencollective-postinstall": "^2.0.2", + "pkg-dir": "^4.2.0", + "please-upgrade-node": "^3.2.0", + "slash": "^3.0.0", + "which-pm-runs": "^1.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "cosmiconfig": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz", + "integrity": "sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA==", + "dev": true, + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "parse-json": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.1.0.tgz", + "integrity": "sha512-+mi/lmVVNKFNVyLXV31ERiy2CY5E1/F6QtJFEzoChPRwwngMNXRDQ9GJ5WdE2Z2P4AujsOi0/+2qHID68KwfIQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + } + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "hyphenate-style-name": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", @@ -9423,6 +9633,15 @@ "sort-keys": "^1.0.0" } }, + "notistack": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/notistack/-/notistack-1.0.3.tgz", + "integrity": "sha512-bRGF/eg2qNQ8BwagPLkHiqrz+W00PYtGY5Xl33I0Of1BTm7arksZO1JxssPTlti0qw127CxuWxm637ipn0eZ9g==", + "requires": { + "clsx": "^1.1.0", + "hoist-non-react-statics": "^3.3.0" + } + }, "npm-run-path": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", @@ -9704,6 +9923,12 @@ } } }, + "opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "dev": true + }, "opn": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/opn/-/opn-5.5.0.tgz", @@ -9713,9 +9938,9 @@ } }, "optimism": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.12.1.tgz", - "integrity": "sha512-t8I7HM1dw0SECitBYAqFOVHoBAHEQBTeKjIL9y9ImHzAVkdyPK4ifTgM4VJRDtTUY4r/u5Eqxs4XcGPHaoPkeQ==", + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.13.2.tgz", + "integrity": "sha512-kJkpDUEs/Rp8HsAYYlDzyvQHlT6YZa95P+2GGNR8p/VvsIkt6NilAk7oeTvMRKCq7BeclB7+bmdIexog2859GQ==", "requires": { "@wry/context": "^0.5.2" } @@ -10035,6 +10260,15 @@ "find-up": "^3.0.0" } }, + "please-upgrade-node": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", + "integrity": "sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==", + "dev": true, + "requires": { + "semver-compare": "^1.0.0" + } + }, "pn": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz", @@ -12228,6 +12462,18 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" }, + "semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=", + "dev": true + }, + "semver-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-2.0.0.tgz", + "integrity": "sha512-mUdIBBvdn0PLOeP3TEkMH7HHeUP3GjsXCwKarjv/kGmUFOYg1VqEemKhoQpWMu6X2I8kHeuVdGibLGkVK+/5Qw==", + "dev": true + }, "send": { "version": "0.17.1", "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", @@ -13161,9 +13407,9 @@ } }, "symbol-observable": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", - "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==" + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-2.0.3.tgz", + "integrity": "sha512-sQV7phh2WCYAn81oAkakC5qjq2Ml0g8ozqz03wOGnx9dDlG1de6yrF+0RAzSJD8fPUow3PTSMf2SAbOGxb93BA==" }, "symbol-tree": { "version": "3.2.4", @@ -13487,10 +13733,12 @@ } }, "ts-invariant": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.4.4.tgz", - "integrity": "sha512-uEtWkFM/sdZvRNNDL3Ehu4WVpwaulhwQszV8mrtcdeE8nN00BV9mAmQ88RkrBhFgl9gMgvjJLAQcZbnPXI9mlA==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.6.0.tgz", + "integrity": "sha512-caoafsfgb8QxdrKzFfjKt627m4i8KTtfAiji0DYJfWI4A/S9ORNNpzYuD9br64kyKFgxn9UNaLLbSupam84mCA==", "requires": { + "@types/ungap__global-this": "^0.3.1", + "@ungap/global-this": "^0.4.2", "tslib": "^1.9.3" } }, @@ -14426,6 +14674,12 @@ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=" }, + "which-pm-runs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.0.0.tgz", + "integrity": "sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs=", + "dev": true + }, "word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", diff --git a/redaktions-app/package.json b/redaktions-app/package.json index 9d509ae..3ca69e5 100644 --- a/redaktions-app/package.json +++ b/redaktions-app/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { - "@apollo/client": "^3.1.3", + "@apollo/client": "^3.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.57", @@ -13,7 +13,8 @@ "react-dom": "^16.13.1", "react-router-dom": "^5.2.0", "react-scripts": "^3.4.4", - "typescript": "^3.8" + "typescript": "^3.8", + "notistack": "^1.0.3" }, "devDependencies": { "@testing-library/jest-dom": "^5.11.4", @@ -23,17 +24,23 @@ "@types/react": "^16.9.46", "@types/react-dom": "^16.9.8", "@types/react-router-dom": "^5.1.5", + "husky": "^4.3.6", "jest-environment-jsdom-sixteen": "^1.0.3" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", - "test": "react-scripts test --env=jest-environment-jsdom-sixteen", + "test": "react-scripts test --env=jest-environment-jsdom-sixteen --watchAll=false", "eject": "react-scripts eject" }, "eslintConfig": { "extends": "react-app" }, + "husky": { + "hooks": { + "pre-commit": "npm test" + } + }, "browserslist": { "production": [ ">0.2%", diff --git a/redaktions-app/src/backend-connection/helper.ts b/redaktions-app/src/backend/helper.ts similarity index 95% rename from redaktions-app/src/backend-connection/helper.ts rename to redaktions-app/src/backend/helper.ts index 910edca..16e7bbe 100644 --- a/redaktions-app/src/backend-connection/helper.ts +++ b/redaktions-app/src/backend/helper.ts @@ -19,4 +19,5 @@ const authLink = setContext((_, { headers }) => { export const client = new ApolloClient({ cache: new InMemoryCache(), link: authLink.concat(httpLink), + connectToDevTools: true, }); diff --git a/redaktions-app/src/backend/mutations/category.mock.ts b/redaktions-app/src/backend/mutations/category.mock.ts new file mode 100644 index 0000000..f8e4386 --- /dev/null +++ b/redaktions-app/src/backend/mutations/category.mock.ts @@ -0,0 +1,100 @@ +import {MockedResponse} from "@apollo/client/testing"; +import { + ADD_CATEGORY, AddCategoryResponse, + AddCategoryVariables, DELETE_CATEGORY, DeleteCategoryPayload, DeleteCategoryResponse, DeleteCategoryVariables, + EDIT_CATEGORY, EditCategoryPayload, + EditCategoryResponse, + EditCategoryVariables +} from "./category"; +import {BasicCategoryResponse} from "../queries/category"; +import {categoryNodesMock} from "../queries/category.mock"; + + +const editCategoryVariables: EditCategoryVariables = { + id: 'c1', + title: 'New title for Category 1', + description: 'Further information for C1', +}; + +const getEditedCategoryMock = (): EditCategoryPayload | null => { + const originalCategory = categoryNodesMock.find(c => c.id === editCategoryVariables.id) + return originalCategory ? { + category: { + ...originalCategory, + title: editCategoryVariables.title === undefined ? originalCategory.title : editCategoryVariables.title, + description: editCategoryVariables.description === undefined ? originalCategory.description : null, + }, + __typename: "UpdateCategoryPayload", + } : null +} + +export const editCategoryMock: Array> = [ + { + request: { + query: EDIT_CATEGORY, + variables: editCategoryVariables, + }, + result: { + data: { + updateCategory: getEditedCategoryMock(), + } + }, + }, +] + +const addCategoryVariables: AddCategoryVariables = { + title: 'New category', + description: "", +}; + +const addedCategoryMock: BasicCategoryResponse = { + id: `newC`, + rowId: 3, + title: addCategoryVariables.title as string, + description: addCategoryVariables.description as string, + __typename: "Category" +} + +export const addCategoryMock: Array> = [ + { + request: { + query: ADD_CATEGORY, + variables: addCategoryVariables, + }, + result: { + data: { + createCategory: { + category: addedCategoryMock, + __typename: "CreateCategoryPayload", + } + } + }, + }, +] + +const deleteCategoryVariables: DeleteCategoryVariables = { + id: 'c2' +}; + +const getDeletedCategoryMock = (): DeleteCategoryPayload | null => { + const categoryToBeDeleted = categoryNodesMock.find(q => q.id === deleteCategoryVariables.id) + return categoryToBeDeleted ? { + category: categoryToBeDeleted, + __typename: "DeleteCategoryPayload", + } : null +} + +export const deleteCategoryMock: Array> = [ + { + request: { + query: DELETE_CATEGORY, + variables: deleteCategoryVariables, + }, + result: { + data: { + deleteCategory: getDeletedCategoryMock(), + } + }, + }, +] + diff --git a/redaktions-app/src/backend/mutations/category.ts b/redaktions-app/src/backend/mutations/category.ts new file mode 100644 index 0000000..61a84e4 --- /dev/null +++ b/redaktions-app/src/backend/mutations/category.ts @@ -0,0 +1,77 @@ +import {gql} from "@apollo/client"; +import {BasicCategoryFragment, BasicCategoryResponse} from "../queries/category"; + +export const EDIT_CATEGORY = gql` + mutation UpdateCategory($id: ID!, $title: String, $description: String) { + updateCategory(input: {id: $id, categoryPatch: {description: $description, title: $title}}) { + category { + ...BasicCategoryFragment + } + } + } + ${BasicCategoryFragment} +` + +export interface EditCategoryResponse { + updateCategory: EditCategoryPayload | null +} + +export interface EditCategoryPayload { + category: BasicCategoryResponse, + __typename: "UpdateCategoryPayload", +} + +export interface EditCategoryVariables { + id: string, + title?: string, + description?: string | null, +} + +export const ADD_CATEGORY = gql` + mutation AddCategory($title: String!, $description: String) { + createCategory(input: {category: {title: $title, description: $description}}) { + category { + ...BasicCategoryFragment + } + } + } + ${BasicCategoryFragment} +` + +export interface AddCategoryResponse { + createCategory: AddCategoryPayload | null, +} + +export interface AddCategoryPayload { + category: BasicCategoryResponse, + __typename: "CreateCategoryPayload", +} + +export interface AddCategoryVariables { + title: string, + description?: string | null, +} + +export const DELETE_CATEGORY = gql` + mutation DeleteCategory($id: ID!) { + deleteCategory(input: { id: $id }) { + category { + ...BasicCategoryFragment + } + } + } + ${BasicCategoryFragment} +` + +export interface DeleteCategoryResponse { + deleteCategory: DeleteCategoryPayload | null +} + +export interface DeleteCategoryPayload { + category: BasicCategoryResponse, + __typename: "DeleteCategoryPayload", +} + +export interface DeleteCategoryVariables { + id: string, +} diff --git a/redaktions-app/src/backend/mutations/login.mock.ts b/redaktions-app/src/backend/mutations/login.mock.ts new file mode 100644 index 0000000..9f5d150 --- /dev/null +++ b/redaktions-app/src/backend/mutations/login.mock.ts @@ -0,0 +1,37 @@ +import {MockedResponse} from "@apollo/client/testing"; +import {LOGIN, LoginResponse} from "./login"; + +export const loginMock: Array> = [ + { + request: { + query: LOGIN, + variables: { + email: "test@email.com", + password: "password", + } + }, + result: { + data: { + authenticate: { + jwtToken: "123" + } + } + }, + }, + { + request: { + query: LOGIN, + variables: { + email: "test@email.com", + password: "wrong-password", + } + }, + result: { + data: { + authenticate: { + jwtToken: undefined + } + } + }, + } +] diff --git a/redaktions-app/src/backend/mutations/login.ts b/redaktions-app/src/backend/mutations/login.ts new file mode 100644 index 0000000..0c4da09 --- /dev/null +++ b/redaktions-app/src/backend/mutations/login.ts @@ -0,0 +1,19 @@ +import {gql} from "@apollo/client"; + +export const LOGIN = gql` + mutation Login($email: String!, $password: String!) { + authenticate(input: {email: $email, password: $password}) { + jwtToken + } + }` + +export interface LoginVariables { + email: string, + password: string +} + +export interface LoginResponse { + authenticate: { + jwtToken?: string + } +} diff --git a/redaktions-app/src/backend/mutations/question.mock.ts b/redaktions-app/src/backend/mutations/question.mock.ts new file mode 100644 index 0000000..deab89d --- /dev/null +++ b/redaktions-app/src/backend/mutations/question.mock.ts @@ -0,0 +1,106 @@ +import {MockedResponse} from "@apollo/client/testing"; +import { + ADD_QUESTION, AddQuestionResponse, + AddQuestionVariables, DELETE_QUESTION, DeleteQuestionPayload, DeleteQuestionResponse, DeleteQuestionVariables, + EDIT_QUESTION, EditQuestionPayload, + EditQuestionResponse, + EditQuestionVariables +} from "./question"; +import {BasicQuestionResponse} from "../queries/question"; +import {questionNodesMock} from "../queries/question.mock"; +import {categoryNodesMock} from "../queries/category.mock"; + + +const editQuestionVariables: EditQuestionVariables = { + id: 'q1', + title: 'New title for Question 1?', + description: 'Further information for Q1', + categoryRowId: 1, +}; + +const getEditedQuestionMock = (): EditQuestionPayload | null => { + const originalQuestion = questionNodesMock.find(q => q.id === editQuestionVariables.id) + return originalQuestion ? { + question: { + ...originalQuestion, + title: editQuestionVariables.title === undefined ? originalQuestion.title : editQuestionVariables.title, + description: editQuestionVariables.description === undefined ? originalQuestion.description : null, + categoryByCategoryRowId: editQuestionVariables.categoryRowId === undefined + ? originalQuestion.categoryByCategoryRowId + : categoryNodesMock.find(c => c.rowId === editQuestionVariables.categoryRowId) || null, + }, + __typename: "UpdateQuestionPayload", + } : null +} + +export const editQuestionMock: Array> = [ + { + request: { + query: EDIT_QUESTION, + variables: editQuestionVariables, + }, + result: { + data: { + updateQuestion: getEditedQuestionMock(), + } + }, + }, +] + +const addQuestionVariables: AddQuestionVariables = { + title: 'New question?', + description: "", + categoryRowId: null, +}; + +const addedQuestionMock: BasicQuestionResponse = { + id: `newQ`, + title: addQuestionVariables.title as string, + description: addQuestionVariables.description as string, + categoryByCategoryRowId: categoryNodesMock.find(c => c.rowId === editQuestionVariables.categoryRowId) || null, + __typename: "Question" +} + +export const addQuestionMock: Array> = [ + { + request: { + query: ADD_QUESTION, + variables: addQuestionVariables, + }, + result: { + data: { + createQuestion: { + question: addedQuestionMock, + __typename: "CreateQuestionPayload", + } + } + }, + }, +] + +const deleteQuestionVariables: DeleteQuestionVariables = { + id: 'q2' +}; + +const getDeletedQuestionMock = (): DeleteQuestionPayload | null => { + const questionToBeDeleted = questionNodesMock.find(q => q.id === deleteQuestionVariables.id) + return questionToBeDeleted ? { + question: questionToBeDeleted, + __typename: "DeleteQuestionPayload", + } : null +} + +export const deleteQuestionMock: Array> = [ + { + request: { + query: DELETE_QUESTION, + variables: deleteQuestionVariables, + }, + result: { + data: { + deleteQuestion: getDeletedQuestionMock(), + } + }, + }, +] + diff --git a/redaktions-app/src/backend/mutations/question.ts b/redaktions-app/src/backend/mutations/question.ts new file mode 100644 index 0000000..ab4853c --- /dev/null +++ b/redaktions-app/src/backend/mutations/question.ts @@ -0,0 +1,81 @@ +import {gql} from "@apollo/client"; +import {BasicQuestionFragment, BasicQuestionResponse} from "../queries/question"; + +export const EDIT_QUESTION = gql` + mutation UpdateQuestion($id: ID!, $title: String, $description: String, $categoryRowId: Int) { + updateQuestion(input: {id: $id, questionPatch: {categoryRowId: $categoryRowId, description: $description, title: $title}}) { + question { + ...BasicQuestionFragment + } + } + } + ${BasicQuestionFragment} +` + +export interface EditQuestionResponse { + updateQuestion: EditQuestionPayload | null, +} + +export interface EditQuestionPayload { + question: BasicQuestionResponse, + __typename: "UpdateQuestionPayload", +} + +export interface EditQuestionVariables { + id: string, + title?: string, + description?: string | null, + categoryRowId?: number | null, +} + +export const ADD_QUESTION = gql` + mutation AddQuestion($title: String!, $description: String, $categoryRowId: Int) { + createQuestion(input: {question: {title: $title, categoryRowId: $categoryRowId, description: $description}}) { + question { + ...BasicQuestionFragment + } + } + } + ${BasicQuestionFragment} +` + +export interface AddQuestionResponse { + createQuestion: AddQuestionPayload | null +} + +export interface AddQuestionPayload { + question: BasicQuestionResponse, + __typename: "CreateQuestionPayload", +} + +export interface AddQuestionVariables { + title: string, + description?: string | null, + categoryRowId?: number | null +} + +export const DELETE_QUESTION = gql` + mutation DeleteQuestion($id: ID!) { + deleteQuestion(input: { id: $id }) { + question { + ...BasicQuestionFragment + } + } + } + ${BasicQuestionFragment} +` + +export interface DeleteQuestionResponse { + deleteQuestion: DeleteQuestionPayload | null, +} + +export interface DeleteQuestionPayload { + question: BasicQuestionResponse, + __typename: "DeleteQuestionPayload", +} + +export interface DeleteQuestionVariables { + id: string, +} + + diff --git a/redaktions-app/src/backend/mutations/signUp.ts b/redaktions-app/src/backend/mutations/signUp.ts new file mode 100644 index 0000000..5bc624f --- /dev/null +++ b/redaktions-app/src/backend/mutations/signUp.ts @@ -0,0 +1,26 @@ +import {gql} from "@apollo/client"; + +export const SIGN_UP = gql` + mutation CreateAccount($firstName: String!, $lastName: String!, $email: String!, $password: String!) { + registerPerson(input: {firstName: $firstName, lastName: $lastName, email: $email, password: $password}) { + person { + id + } + } + } +` + +export interface SignUpVariables { + firstName: string, + lastName: string, + email: string, + password: string, +} + +export interface SignUpResponse { + registerPerson: { + person: { + id: string + } + } +} diff --git a/redaktions-app/src/backend/queries/category.mock.ts b/redaktions-app/src/backend/queries/category.mock.ts new file mode 100644 index 0000000..1145b5d --- /dev/null +++ b/redaktions-app/src/backend/queries/category.mock.ts @@ -0,0 +1,68 @@ +import {MockedResponse} from "@apollo/client/testing"; +import { + BasicCategoryResponse, + GET_ALL_CATEGORIES, + GET_CATEGORY_BY_ID, + GetAllCategoriesResponse, + GetCategoryByIdResponse +} from "./category"; + + +export const categoryNodesMock: Array = [ + { + id: "c1", + rowId: 1, + title: "Category 1", + description: "Further information for C1", + __typename: "Category" + }, { + id: "c2", + rowId: 2, + title: "Category 2", + description: "Further information for C2", + __typename: "Category" + }]; + +export const getAllCategoriesMock: Array> = [ + { + request: { + query: GET_ALL_CATEGORIES, + }, + result: { + data: { + allCategories: { + nodes: categoryNodesMock, + __typename: "CategoriesConnection", + }, + }, + }, + }, +] + +export const getCategoryByIdMock: Array> = [...categoryNodesMock.map(c => ({ + request: { + query: GET_CATEGORY_BY_ID, + variables: { + id: c.id, + }, + }, + result: { + data: { + category: c, + }, + }, +})), + { + request: { + query: GET_CATEGORY_BY_ID, + variables: { + id: "", + }, + }, + result: { + data: { + category: null, + }, + }, + } +] diff --git a/redaktions-app/src/backend/queries/category.ts b/redaktions-app/src/backend/queries/category.ts new file mode 100644 index 0000000..30757e0 --- /dev/null +++ b/redaktions-app/src/backend/queries/category.ts @@ -0,0 +1,53 @@ +import {gql} from "@apollo/client"; + +export const BasicCategoryFragment = gql` + fragment BasicCategoryFragment on Category { + id + rowId + title + description + } +` + +export interface BasicCategoryResponse { + id: string, + rowId: number, + title: string, + description: string | null, + __typename: "Category", +} + +export const GET_ALL_CATEGORIES = gql` + query AllCategories { + allCategories { + nodes { + ...BasicCategoryFragment + } + } + } + ${BasicCategoryFragment} +` + +export interface GetAllCategoriesResponse { + allCategories: { + nodes: Array, + __typename: "CategoriesConnection", + } +} + +export const GET_CATEGORY_BY_ID = gql` + query GetCategoryById($id:ID!) { + category(id: $id) { + ...BasicCategoryFragment + } + } + ${BasicCategoryFragment} +` + +export interface GetCategoryByIdResponse { + category: BasicCategoryResponse | null, +} + +export interface GetCategoryByIdVariables { + id: string, +} diff --git a/redaktions-app/src/backend/queries/question.mock.ts b/redaktions-app/src/backend/queries/question.mock.ts new file mode 100644 index 0000000..672d248 --- /dev/null +++ b/redaktions-app/src/backend/queries/question.mock.ts @@ -0,0 +1,83 @@ +import {MockedResponse} from "@apollo/client/testing"; +import { + BasicQuestionResponse, + GET_ALL_QUESTIONS, + GET_QUESTION_BY_ID, + GetAllQuestionsResponse, + GetQuestionByIdResponse +} from "./question"; + + +export const questionNodesMock: Array = [{ + id: "q1", + title: "Question 1?", + description: "Further information for Q1", + categoryByCategoryRowId: { + id: "c1", + rowId: 1, + title: "Category 1", + __typename: "Category" + }, + __typename: "Question", +}, + { + id: "q2", + title: "Question 2?", + description: "Further information for Q2", + categoryByCategoryRowId: null, + __typename: "Question", + }, + { + id: "q3", + title: "Question 3?", + description: null, + categoryByCategoryRowId: null, + __typename: "Question", + } +]; + +export const getAllQuestionsMock: Array> = [ + { + request: { + query: GET_ALL_QUESTIONS, + }, + result: { + data: { + allQuestions: { + nodes: questionNodesMock, + __typename: "QuestionsConnection", + } + } + }, + }, +] + +export const getQuestionByIdMock: Array> = [ + ...questionNodesMock.map(q => ({ + request: { + query: GET_QUESTION_BY_ID, + variables: { + id: q.id, + }, + }, + result: { + data: { + question: q, + }, + }, + })), + { + request: { + query: GET_QUESTION_BY_ID, + variables: { + id: "", + }, + }, + result: { + data: { + question: null, + }, + }, + } +] + diff --git a/redaktions-app/src/backend/queries/question.ts b/redaktions-app/src/backend/queries/question.ts new file mode 100644 index 0000000..5026870 --- /dev/null +++ b/redaktions-app/src/backend/queries/question.ts @@ -0,0 +1,71 @@ +import {gql} from "@apollo/client"; + +const QuestionCategoryFragment = gql` + fragment QuestionCategoryFragment on Category { + id + rowId + title + } +` + +interface GetQuestionsCategoryResponse { + id: string, + rowId: number, + title: string, + __typename: "Category", +} + +export const BasicQuestionFragment = gql` + fragment BasicQuestionFragment on Question { + id + title + description + categoryByCategoryRowId { + ...QuestionCategoryFragment + } + } + ${QuestionCategoryFragment} +` + +export interface BasicQuestionResponse { + id: string, + title: string, + description: string | null, + categoryByCategoryRowId: GetQuestionsCategoryResponse | null, + __typename: "Question", +} + +export const GET_ALL_QUESTIONS = gql` + query AllQuestions { + allQuestions { + nodes { + ...BasicQuestionFragment + } + } + } + ${BasicQuestionFragment} +` + +export interface GetAllQuestionsResponse { + allQuestions: { + nodes: Array, + __typename: "QuestionsConnection", + } +} + +export const GET_QUESTION_BY_ID = gql` + query GetQuestionById($id:ID!) { + question(id: $id) { + ...BasicQuestionFragment + } + } + ${BasicQuestionFragment} +` + +export interface GetQuestionByIdResponse { + question: BasicQuestionResponse | null, +} + +export interface GetQuestionByIdVariables { + id: string, +} diff --git a/redaktions-app/src/components/AccordionWithEdit.tsx b/redaktions-app/src/components/AccordionWithEdit.tsx new file mode 100644 index 0000000..0cfb477 --- /dev/null +++ b/redaktions-app/src/components/AccordionWithEdit.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import {createStyles, makeStyles, Theme} from '@material-ui/core/styles'; +import Accordion from '@material-ui/core/Accordion'; +import AccordionDetails from '@material-ui/core/AccordionDetails'; +import AccordionSummary from '@material-ui/core/AccordionSummary'; +import AccordionActions from '@material-ui/core/AccordionActions'; +import DeleteIcon from '@material-ui/icons/Delete'; +import EditIcon from '@material-ui/icons/Edit'; +import Typography from '@material-ui/core/Typography'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import Divider from '@material-ui/core/Divider'; +import {IconButton} from '@material-ui/core'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + width: '100%', + marginBottom: theme.spacing(1) + }, + heading: { + fontSize: theme.typography.pxToRem(15), + flexGrow: 1, + }, + secondaryHeading: { + fontSize: theme.typography.pxToRem(15), + color: theme.palette.text.secondary, + }, + details: { + alignItems: 'center', + }, + }), +); + +interface AccordionWithEditProps { + key: string, + title: string, + description: string | null, + subTitle?: string | null, + onEditButtonClick?(): void, + onDeleteButtonClick?(): void, +} + +export default function AccordionWithEdit(props: AccordionWithEditProps) { + const classes = useStyles(); + + return ( +
+ + } + aria-controls="panel1c-content" + id="panel1c-header" + > +
+ {props.title} +
+
+ {props.subTitle} +
+
+ + + {props.description} + + + + + + + + + + + +
+
+ ) +} diff --git a/redaktions-app/src/components/AddCard.tsx b/redaktions-app/src/components/AddCard.tsx new file mode 100644 index 0000000..9a87f56 --- /dev/null +++ b/redaktions-app/src/components/AddCard.tsx @@ -0,0 +1,37 @@ +import {Card, CardActionArea, CardContent} from "@material-ui/core"; +import React from "react"; +import {makeStyles} from "@material-ui/core/styles"; +import AddIcon from '@material-ui/icons/Add'; + +const useStyles = makeStyles((theme) => ({ + root: { + width: '100%', + marginBottom: theme.spacing(1), + }, + addCardContent: { + padding: 0, + }, + addCardIcon: { + display: 'block', + margin: 'auto', + padding: 12, + }, +})); + +interface AddCardProps { + handleClick?(): void +} + +export default function AddCard(props: AddCardProps) { + const classes = useStyles(); + + return ( + + + + + + + + ) +} diff --git a/redaktions-app/src/components/ButtonWithSpinner.tsx b/redaktions-app/src/components/ButtonWithSpinner.tsx index 0b53aac..4a479d8 100644 --- a/redaktions-app/src/components/ButtonWithSpinner.tsx +++ b/redaktions-app/src/components/ButtonWithSpinner.tsx @@ -27,10 +27,11 @@ const useStyles = makeStyles((theme: Theme) => interface ButtonWithSpinnerProps { children: string, - handleClick?: () => void, - loading: boolean + onClick?: () => void, + loading?: boolean type?: "button" | "submit", fullWidth?: boolean, + autoFocus?: boolean } export default function ButtonWithSpinner(props: ButtonWithSpinnerProps) { @@ -45,7 +46,8 @@ export default function ButtonWithSpinner(props: ButtonWithSpinnerProps) { fullWidth={!!props.fullWidth} type={props.type} disabled={props.loading} - onClick={props.handleClick} + onClick={props.onClick} + autoFocus={props.autoFocus} > {props.children} diff --git a/redaktions-app/src/components/CategoryList.tsx b/redaktions-app/src/components/CategoryList.tsx new file mode 100644 index 0000000..2689222 --- /dev/null +++ b/redaktions-app/src/components/CategoryList.tsx @@ -0,0 +1,60 @@ +import {Paper, Typography} from "@material-ui/core"; +import React from "react"; +import {makeStyles} from "@material-ui/core/styles"; +import {useQuery} from "@apollo/client"; +import AddCard from "./AddCard"; +import AccordionWithEdit from "./AccordionWithEdit"; +import {BasicCategoryResponse, GET_ALL_CATEGORIES, GetAllCategoriesResponse} from "../backend/queries/category"; +import DialogChangeCategory, {dialogChangeCategoryId, dialogChangeCategoryOpen} from "./DialogChangeCategory"; +import DialogDeleteCategory, { + dialogDeleteCategoryId, + dialogDeleteCategoryOpen, + dialogDeleteCategoryTitle +} from "./DialogDeleteCategory"; + +const useStyles = makeStyles((theme) => ({ + root: { + width: '100%', + padding: theme.spacing(1), + marginBottom: theme.spacing(3), + }, +})); + +export default function CategoryList() { + const categories = useQuery(GET_ALL_CATEGORIES).data?.allCategories.nodes; + const classes = useStyles(); + + const handleAddClick = () => { + dialogChangeCategoryId("") + dialogChangeCategoryOpen(true) + } + + const handleEditButtonClick = (category: BasicCategoryResponse) => { + dialogChangeCategoryId(category.id); + dialogChangeCategoryOpen(true) + }; + + const handleDeleteButtonClick = (category: BasicCategoryResponse) => { + dialogDeleteCategoryTitle(category.title); + dialogDeleteCategoryId(category.id); + dialogDeleteCategoryOpen(true); + } + + return ( + + Kategorien + {categories?.map(category => handleEditButtonClick(category)} + onDeleteButtonClick={() => handleDeleteButtonClick(category)} + /> + )} + + + + + ) +} + diff --git a/redaktions-app/src/components/CategorySelectionMenu.tsx b/redaktions-app/src/components/CategorySelectionMenu.tsx new file mode 100644 index 0000000..3bedff5 --- /dev/null +++ b/redaktions-app/src/components/CategorySelectionMenu.tsx @@ -0,0 +1,36 @@ +import React, {ChangeEvent} from 'react'; +import {FormControl, InputLabel, MenuItem, Select} from "@material-ui/core"; +import {BasicCategoryResponse} from "../backend/queries/category"; + + +interface CategorySelectionMenuProps { + selectedCategoryId: number | null + categories?: Array, + + handleCategoryChange(categoryId: number | null): void +} + +export default function CategorySelectionMenu(props: CategorySelectionMenuProps) { + const onCategoryIdChange = (e: ChangeEvent<{ name?: string, value: unknown }>) => { + const newValue = e.target.value === -1 ? null : e.target.value as number; + props.handleCategoryChange(newValue); + } + + return ( + + Kategorie + + + ); +} diff --git a/redaktions-app/src/components/DialogActionBar.tsx b/redaktions-app/src/components/DialogActionBar.tsx new file mode 100644 index 0000000..1888c82 --- /dev/null +++ b/redaktions-app/src/components/DialogActionBar.tsx @@ -0,0 +1,23 @@ +import {Button, DialogActions} from "@material-ui/core"; +import ButtonWithSpinner from "./ButtonWithSpinner"; +import React from "react"; + +interface DialogSimpleActionProps { + confirmButtonText?: string, + loading?: boolean, + + onClose(): void, + + onConfirmButtonClick(): void, +} + +export function DialogActionBar(props: DialogSimpleActionProps) { + return + + + {props.confirmButtonText || "Ok"} + + ; +} diff --git a/redaktions-app/src/components/DialogChangeCategory.tsx b/redaktions-app/src/components/DialogChangeCategory.tsx new file mode 100644 index 0000000..467fc02 --- /dev/null +++ b/redaktions-app/src/components/DialogChangeCategory.tsx @@ -0,0 +1,123 @@ +import React, {useState} from 'react'; +import Dialog from '@material-ui/core/Dialog'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import {DialogActionBar} from "./DialogActionBar"; +import {DialogTitleAndDetails} from "./DialogTitleAndDetails"; +import {makeVar, useMutation, useQuery, useReactiveVar} from "@apollo/client"; +import {useSnackbar} from "notistack"; +import { + BasicCategoryFragment, + BasicCategoryResponse, + GET_CATEGORY_BY_ID, + GetCategoryByIdResponse, + GetCategoryByIdVariables +} from "../backend/queries/category"; +import { + ADD_CATEGORY, + AddCategoryResponse, + AddCategoryVariables, + EDIT_CATEGORY, + EditCategoryResponse, + EditCategoryVariables +} from "../backend/mutations/category"; + + +export const dialogChangeCategoryId = makeVar(""); +export const dialogChangeCategoryOpen = makeVar(false); + +export default function DialogChangeCategory() { + const [addMode, setAddMode] = useState(true); + const [title, setTitle] = useState(""); + const [details, setDetails] = useState(""); + const categoryId = useReactiveVar(dialogChangeCategoryId); + const open = useReactiveVar(dialogChangeCategoryOpen); + const {enqueueSnackbar} = useSnackbar(); + useQuery(GET_CATEGORY_BY_ID, { + variables: { + id: categoryId, + }, + onCompleted: (data => { + setAddMode(!data.category && !categoryId) + setTitle(data.category?.title || ""); + setDetails(data.category?.description || "") + }) + }); + const [editCategory, {loading: editLoading}] = useMutation(EDIT_CATEGORY, { + onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}), + onCompleted: (response) => { + if (response.updateCategory) { + enqueueSnackbar("Kategorie erfolgreich geändert.", {variant: "success"}) + dialogChangeCategoryOpen(false); + } else { + enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"}) + } + } + }); + const [addCategory, {loading: addLoading}] = useMutation(ADD_CATEGORY, { + onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}), + onCompleted: (response) => { + if (response.createCategory) { + enqueueSnackbar("Kategorie erfolgreich hinzugefügt.", {variant: "success"}) + dialogChangeCategoryOpen(false); + } else { + enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"}) + } + }, + update: (cache, {data}) => { + cache.modify({ + fields: { + allCategories(existingCategories = {nodes: []}) { + const newCategoryRef = cache.writeFragment({ + data: data?.createCategory?.category, + fragment: BasicCategoryFragment, + fragmentName: "BasicCategoryFragment", + }); + return {nodes: [...existingCategories.nodes, newCategoryRef]}; + } + } + }); + } + }); + + const handleConfirmButtonClick = () => { + if (addMode) { + addCategory({ + variables: { + title, + description: details, + } + }) + } else { + editCategory({ + variables: { + id: categoryId, + title: title, + description: details, + } + }) + } + } + + return ( + dialogChangeCategoryOpen(false)} aria-labelledby="form-dialog-title"> + + {addMode ? "Neue Kategorie erstellen" : "Kategorie bearbeiten"} + + + setTitle(newTitle)} + onDetailsChange={newDetails => setDetails(newDetails)} + /> + + dialogChangeCategoryOpen(false)} + onConfirmButtonClick={handleConfirmButtonClick} + confirmButtonText={addMode ? "Erstellen" : "Speichern"} + loading={addMode ? addLoading : editLoading} + /> + + ); +} diff --git a/redaktions-app/src/components/DialogChangeQuestion.tsx b/redaktions-app/src/components/DialogChangeQuestion.tsx new file mode 100644 index 0000000..a60934c --- /dev/null +++ b/redaktions-app/src/components/DialogChangeQuestion.tsx @@ -0,0 +1,135 @@ +import React, {useState} from 'react'; +import Dialog from '@material-ui/core/Dialog'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import {DialogActionBar} from "./DialogActionBar"; +import {DialogTitleAndDetails} from "./DialogTitleAndDetails"; +import {makeVar, useMutation, useQuery, useReactiveVar} from "@apollo/client"; +import {useSnackbar} from "notistack"; +import { + ADD_QUESTION, + AddQuestionResponse, + AddQuestionVariables, + EDIT_QUESTION, + EditQuestionResponse, + EditQuestionVariables +} from "../backend/mutations/question"; +import { + BasicQuestionFragment, + BasicQuestionResponse, + GET_QUESTION_BY_ID, + GetQuestionByIdResponse, + GetQuestionByIdVariables +} from "../backend/queries/question"; +import CategorySelectionMenu from "./CategorySelectionMenu"; +import {GET_ALL_CATEGORIES, GetAllCategoriesResponse} from "../backend/queries/category"; + +export const dialogChangeQuestionId = makeVar(""); +export const dialogChangeQuestionOpen = makeVar(false); + +export default function DialogChangeQuestion() { + const [addMode, setAddMode] = useState(true); + const [title, setTitle] = useState(""); + const [details, setDetails] = useState(""); + const [categoryRowId, setCategoryRowId] = useState(null); + const questionId = useReactiveVar(dialogChangeQuestionId); + const open = useReactiveVar(dialogChangeQuestionOpen); + const {enqueueSnackbar} = useSnackbar(); + useQuery(GET_QUESTION_BY_ID, { + variables: { + id: questionId, + }, + onCompleted: (data => { + setAddMode(!data.question && !questionId) + setTitle(data.question?.title || ""); + setDetails(data.question?.description || ""); + setCategoryRowId(data.question?.categoryByCategoryRowId?.rowId || null) + }) + }) + const categories = useQuery(GET_ALL_CATEGORIES).data?.allCategories.nodes; + + const [editQuestion, {loading: editLoading}] = useMutation(EDIT_QUESTION, { + onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}), + onCompleted: (response) => { + if (response.updateQuestion) { + enqueueSnackbar("Frage erfolgreich geändert.", {variant: "success"}) + dialogChangeQuestionOpen(false); + } else { + enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"}) + } + } + }); + const [addQuestion, {loading: addLoading}] = useMutation(ADD_QUESTION, { + onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}), + onCompleted: (response) => { + if (response.createQuestion) { + enqueueSnackbar("Frage erfolgreich hinzugefügt.", {variant: "success"}) + dialogChangeQuestionOpen(false); + } else { + enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"}) + } + }, + update: (cache, {data}) => { + cache.modify({ + fields: { + allQuestions(existingQuestions = {nodes: []}) { + const newQuestionRef = cache.writeFragment({ + data: data?.createQuestion?.question, + fragment: BasicQuestionFragment, + fragmentName: "BasicQuestionFragment", + }); + return {nodes: [...existingQuestions.nodes, newQuestionRef]}; + } + } + }); + } + }); + + const handleConfirmButtonClick = () => { + if (addMode) { + addQuestion({ + variables: { + title, + description: details, + categoryRowId: categoryRowId, + } + }) + } else { + editQuestion({ + variables: { + id: questionId, + title: title, + description: details, + categoryRowId: categoryRowId, + } + }) + } + } + + return ( + dialogChangeQuestionOpen(false)} aria-labelledby="form-dialog-title"> + + {addMode ? "Neue Frage erstellen" : "Frage bearbeiten"} + + + setTitle(newTitle)} + onDetailsChange={newDetails => setDetails(newDetails)} + /> + setCategoryRowId(categoryId)} + /> + + dialogChangeQuestionOpen(false)} + onConfirmButtonClick={handleConfirmButtonClick} + confirmButtonText={addMode ? "Erstellen" : "Speichern"} + loading={addMode ? addLoading : editLoading} + /> + + ); +} diff --git a/redaktions-app/src/components/DialogDeleteCategory.tsx b/redaktions-app/src/components/DialogDeleteCategory.tsx new file mode 100644 index 0000000..daa16ba --- /dev/null +++ b/redaktions-app/src/components/DialogDeleteCategory.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import {makeVar, Reference, useMutation, useReactiveVar} from "@apollo/client"; +import DialogSimple from "./DialogSimple"; +import {useSnackbar} from "notistack"; +import {DELETE_CATEGORY, DeleteCategoryResponse, DeleteCategoryVariables} from "../backend/mutations/category"; + + +export const dialogDeleteCategoryId = makeVar(""); +export const dialogDeleteCategoryTitle = makeVar(""); +export const dialogDeleteCategoryOpen = makeVar(false); + +export default function DialogDeleteCategory() { + const {enqueueSnackbar} = useSnackbar(); + const [deleteCategory, {loading}] = useMutation(DELETE_CATEGORY, { + onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}), + onCompleted: (response) => { + if (response.deleteCategory) { + enqueueSnackbar("Kategorie erfolgreich gelöscht.", {variant: "success"}) + dialogDeleteCategoryOpen(false); + } else { + enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"}) + } + }, + update: (cache, {data}) => { + const idToRemove = data?.deleteCategory?.category.id; + cache.modify({ + fields: { + allCategories(existingCategoriesRef: { nodes: Array } = {nodes: []}, {readField}) { + console.log("existingCategory: ", existingCategoriesRef) + return {nodes: existingCategoriesRef.nodes.filter(categoryRef => readField('id', categoryRef) !== idToRemove)}; + } + } + }); + } + }); + + const open = useReactiveVar(dialogDeleteCategoryOpen); + const title = useReactiveVar(dialogDeleteCategoryTitle); + const id = useReactiveVar(dialogDeleteCategoryId); + + const handleConfirmButtonClick = () => { + deleteCategory({ + variables: { + id + } + }) + } + + return ( + dialogDeleteCategoryOpen(false)} + loading={loading} + /> + ); +} + diff --git a/redaktions-app/src/components/DialogDeleteQuestion.tsx b/redaktions-app/src/components/DialogDeleteQuestion.tsx new file mode 100644 index 0000000..7338012 --- /dev/null +++ b/redaktions-app/src/components/DialogDeleteQuestion.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import {makeVar, Reference, useMutation, useReactiveVar} from "@apollo/client"; +import DialogSimple from "./DialogSimple"; +import {DELETE_QUESTION, DeleteQuestionResponse, DeleteQuestionVariables} from "../backend/mutations/question"; +import {useSnackbar} from "notistack"; + + +export const dialogDeleteQuestionId = makeVar(""); +export const dialogDeleteQuestionTitle = makeVar(""); +export const dialogDeleteQuestionOpen = makeVar(false); + +export default function DialogDeleteQuestion() { + const {enqueueSnackbar} = useSnackbar(); + const [deleteQuestion, {loading}] = useMutation(DELETE_QUESTION, { + onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}), + onCompleted: (response) => { + if (response.deleteQuestion) { + enqueueSnackbar("Frage erfolgreich gelöscht.", {variant: "success"}) + dialogDeleteQuestionOpen(false); + } else { + enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"}) + } + }, + update: (cache, {data}) => { + const idToRemove = data?.deleteQuestion?.question.id; + cache.modify({ + fields: { + allQuestions(existingQuestionsRef: { nodes: Array } = {nodes: []}, {readField}) { + return {nodes: existingQuestionsRef.nodes.filter(questionRef => readField('id', questionRef) !== idToRemove)}; + } + } + }); + } + }); + + const open = useReactiveVar(dialogDeleteQuestionOpen); + const title = useReactiveVar(dialogDeleteQuestionTitle); + const id = useReactiveVar(dialogDeleteQuestionId); + + const handleConfirmButtonClick = () => { + deleteQuestion({ + variables: { + id + } + }) + } + + return ( + dialogDeleteQuestionOpen(false)} + loading={loading} + /> + ); +} + diff --git a/redaktions-app/src/components/DialogSimple.tsx b/redaktions-app/src/components/DialogSimple.tsx new file mode 100644 index 0000000..2d67a67 --- /dev/null +++ b/redaktions-app/src/components/DialogSimple.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import Dialog from '@material-ui/core/Dialog'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import {DialogContentText} from "@material-ui/core"; +import {DialogActionBar} from "./DialogActionBar"; + + +interface DialogSimpleProps { + open: boolean, + title: string, + confirmButtonText: string, + description: string, + loading?: boolean, + + onConfirmButtonClick(): void, + + onClose(): void, +} + +export default function DialogSimple(props: DialogSimpleProps) { + return ( + + {props.title} + + + {props.description} + + + + + ); +} + diff --git a/redaktions-app/src/components/DialogTitleAndDetails.tsx b/redaktions-app/src/components/DialogTitleAndDetails.tsx new file mode 100644 index 0000000..2fbf7eb --- /dev/null +++ b/redaktions-app/src/components/DialogTitleAndDetails.tsx @@ -0,0 +1,47 @@ +import {makeStyles} from "@material-ui/core/styles"; +import React from "react"; +import TextField from "@material-ui/core/TextField"; + +const useStyles = makeStyles((theme) => ({ + textField: { + marginBottom: theme.spacing(2), + } +})); + +interface DialogTitleAndDetailsProps { + title: string, + details?: string | null, + + onTitleChange(newTitle: string): void, + + onDetailsChange(newDetails: string): void, +} + +export function DialogTitleAndDetails(props: DialogTitleAndDetailsProps) { + const classes = useStyles(); + + return ( + + props.onTitleChange(e.target.value)} + /> + props.onDetailsChange(e.target.value)} + /> + + ) +} diff --git a/redaktions-app/src/components/Main.tsx b/redaktions-app/src/components/Main.tsx index aea5cdc..5a13d4e 100644 --- a/redaktions-app/src/components/Main.tsx +++ b/redaktions-app/src/components/Main.tsx @@ -1,9 +1,10 @@ import CustomAppBar from "./CustomAppBar"; -import {Container, Paper} from "@material-ui/core"; +import {Container} from "@material-ui/core"; import React from "react"; -import clsx from "clsx"; import {makeStyles} from "@material-ui/core/styles"; import {Copyright} from "./Copyright"; +import QuestionList from "./QuestionList"; +import CategoryList from "./CategoryList"; const useStyles = makeStyles((theme) => ({ appBarSpacer: theme.mixins.toolbar, @@ -24,26 +25,18 @@ const useStyles = makeStyles((theme) => ({ overflow: 'auto', flexDirection: 'column', }, - fixedHeight: { - height: 240, - }, })); function Main() { const classes = useStyles(); - const fixedHeightPaper = clsx(classes.paper, classes.fixedHeight); return (
- - blablablubb - - - blablaj - + +
diff --git a/redaktions-app/src/components/QuestionList.tsx b/redaktions-app/src/components/QuestionList.tsx new file mode 100644 index 0000000..69cdb90 --- /dev/null +++ b/redaktions-app/src/components/QuestionList.tsx @@ -0,0 +1,61 @@ +import {Paper, Typography} from "@material-ui/core"; +import React from "react"; +import {makeStyles} from "@material-ui/core/styles"; +import {useQuery} from "@apollo/client"; +import AddCard from "./AddCard"; +import AccordionWithEdit from "./AccordionWithEdit"; +import {BasicQuestionResponse, GET_ALL_QUESTIONS, GetAllQuestionsResponse} from "../backend/queries/question"; +import DialogChangeQuestion, {dialogChangeQuestionId, dialogChangeQuestionOpen} from "./DialogChangeQuestion"; +import DialogDeleteQuestion, { + dialogDeleteQuestionId, + dialogDeleteQuestionOpen, + dialogDeleteQuestionTitle +} from "./DialogDeleteQuestion"; + +const useStyles = makeStyles((theme) => ({ + root: { + width: '100%', + padding: theme.spacing(1), + marginBottom: theme.spacing(3), + }, +})); + +export default function QuestionList() { + const questions = useQuery(GET_ALL_QUESTIONS).data?.allQuestions.nodes; + const classes = useStyles(); + + const handleAddButtonClick = () => { + dialogChangeQuestionId("") + dialogChangeQuestionOpen(true) + } + + const handleEditButtonClick = (question: BasicQuestionResponse) => { + dialogChangeQuestionId(question.id) + dialogChangeQuestionOpen(true) + }; + + const handleDeleteButtonClick = (question: BasicQuestionResponse) => { + dialogDeleteQuestionTitle(question.title); + dialogDeleteQuestionId(question.id); + dialogDeleteQuestionOpen(true); + } + + return ( + + Fragen + {questions?.map(question => handleEditButtonClick(question)} + onDeleteButtonClick={() => handleDeleteButtonClick(question)} + /> + )} + + + + + ) +} + diff --git a/redaktions-app/src/components/SignIn.tsx b/redaktions-app/src/components/SignIn.tsx index cd21ede..b22c0be 100644 --- a/redaktions-app/src/components/SignIn.tsx +++ b/redaktions-app/src/components/SignIn.tsx @@ -12,9 +12,10 @@ import {Link, useHistory, useLocation} from 'react-router-dom'; import Typography from '@material-ui/core/Typography'; import {makeStyles} from '@material-ui/core/styles'; import Container from '@material-ui/core/Container'; -import {gql, useMutation} from "@apollo/client"; +import {useMutation} from "@apollo/client"; import ButtonWithSpinner from "./ButtonWithSpinner"; import {Copyright} from "./Copyright"; +import {LOGIN, LoginResponse, LoginVariables} from "../backend/mutations/login"; const useStyles = makeStyles((theme) => ({ paper: { @@ -47,8 +48,8 @@ export default function SignIn() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); - const [login, {loading}] = useMutation( - loginMutation, + const [login, {loading}] = useMutation( + LOGIN, { onCompleted(data) { if (data.authenticate.jwtToken) { @@ -152,24 +153,6 @@ export default function SignIn() { ); } -export const loginMutation = gql` - mutation Login($email: String!, $password: String!) { - authenticate(input: {email: $email, password: $password}) { - jwtToken - } - }` - -interface LoginMutationVariables { - email: string, - password: string -} - -export interface LoginMutationResponse { - authenticate: { - jwtToken: string | null - } -} - diff --git a/redaktions-app/src/components/SignUp.tsx b/redaktions-app/src/components/SignUp.tsx index 2b8028a..5c7a197 100644 --- a/redaktions-app/src/components/SignUp.tsx +++ b/redaktions-app/src/components/SignUp.tsx @@ -10,10 +10,11 @@ import Typography from '@material-ui/core/Typography'; import {makeStyles} from '@material-ui/core/styles'; import Container from '@material-ui/core/Container'; import {Copyright} from "./Copyright"; -import {gql, useMutation} from "@apollo/client"; +import {useMutation} from "@apollo/client"; import ButtonWithSpinner from "./ButtonWithSpinner"; import {errorHandler, SignUpError} from "./SignUpErrorHandler"; import {Alert} from "@material-ui/lab"; +import {SIGN_UP, SignUpResponse, SignUpVariables} from "../backend/mutations/signUp"; const useStyles = makeStyles((theme) => ({ @@ -49,8 +50,8 @@ export default function SignUp() { const [lastName, setLastName] = useState(""); const [error, setError] = useState(undefined) const history = useHistory(); - const [createAccount, {loading}] = useMutation( - registerMutation, + const [createAccount, {loading}] = useMutation( + SIGN_UP, { onCompleted() { history.push("/login?recent-sign-up-success=true") @@ -199,28 +200,3 @@ export default function SignUp() { ); } -const registerMutation = gql` - mutation CreateAccount($firstName: String!, $lastName: String!, $email: String!, $password: String!) { - registerPerson(input: {firstName: $firstName, lastName: $lastName, email: $email, password: $password}) { - person { - nodeId - } - } - } -` - -interface RegisterMutationVariables { - firstName: string, - lastName: string, - email: string, - password: string, -} - -interface RegisterMutationResponse { - registerPerson: { - person: { - nodeId: string - } - } -} - diff --git a/redaktions-app/src/index.tsx b/redaktions-app/src/index.tsx index 474955b..e8746cc 100644 --- a/redaktions-app/src/index.tsx +++ b/redaktions-app/src/index.tsx @@ -4,13 +4,16 @@ import './index.css'; import App from './App'; import * as serviceWorker from './serviceWorker'; import {ApolloProvider} from "@apollo/client"; -import {client} from "./backend-connection/helper"; +import {client} from "./backend/helper"; import {BrowserRouter as Router} from "react-router-dom"; +import {SnackbarProvider} from "notistack"; ReactDOM.render( - + + + , document.getElementById('root') diff --git a/redaktions-app/src/integration-tests/app-routing.integration.test.tsx b/redaktions-app/src/integration-tests/app-routing.integration.test.tsx index 780e14d..d33781e 100644 --- a/redaktions-app/src/integration-tests/app-routing.integration.test.tsx +++ b/redaktions-app/src/integration-tests/app-routing.integration.test.tsx @@ -3,20 +3,31 @@ import {render, screen} from '@testing-library/react' import {MockedProvider} from '@apollo/client/testing'; import {MemoryRouter} from 'react-router-dom'; import App from "../App"; +import {SnackbarProvider} from "notistack"; + +const renderAppAtUrl = (path: string) => render( + + + + + + + +); beforeEach(() => localStorage.clear()) describe('The root path /', () => { - test('renders user\'s home page if they are logged in',() => { + test('renders user\'s home page if they are logged in', () => { localStorage.setItem("token", "asdfasdfasdf") - render(); + renderAppAtUrl("/"); expect(() => screen.getByLabelText(/current user/)).not.toThrow() }); test('redirects to login page if user not logged in', () => { - render(); + renderAppAtUrl("/"); const emailField = screen.getByRole('textbox', {name: 'Email Address'}); const passwordField = screen.getByLabelText(/Password/); @@ -27,7 +38,7 @@ describe('The root path /', () => { describe('The /login path', () => { test('renders the signin page if the user is not logged in', () => { - render(); + renderAppAtUrl("/login"); const emailField = screen.getByRole('textbox', {name: 'Email Address'}); const passwordField = screen.getByLabelText(/Password/); @@ -37,7 +48,7 @@ describe('The /login path', () => { test('redirects to root / and the user\'s home page if the user is logged in', () => { localStorage.setItem("token", "asdfasdfasdf") - render(); + renderAppAtUrl("/login"); expect(() => screen.getByLabelText(/current user/)).not.toThrow() }); @@ -45,7 +56,7 @@ describe('The /login path', () => { describe('The /signup path', () => { test('renders the signup page if the user is not logged in', () => { - render(); + renderAppAtUrl("/signup"); expect(() => screen.getByRole('textbox', {name: 'Email Address'})).not.toThrow() expect(() => screen.getByLabelText(/Password/)).not.toThrow() @@ -55,7 +66,7 @@ describe('The /signup path', () => { test('redirects to root / and the user\'s home page if the user is logged in', () => { localStorage.setItem("token", "asdfasdfasdf") - render(); + renderAppAtUrl("/signup"); expect(() => screen.getByLabelText(/current user/)).not.toThrow() }); diff --git a/redaktions-app/src/integration-tests/category-list.integration.test.tsx b/redaktions-app/src/integration-tests/category-list.integration.test.tsx new file mode 100644 index 0000000..1cf8848 --- /dev/null +++ b/redaktions-app/src/integration-tests/category-list.integration.test.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import {fireEvent, render, screen, waitFor} from '@testing-library/react' +import {MockedProvider, MockedResponse} from '@apollo/client/testing'; +import {MemoryRouter} from 'react-router-dom'; +import CategoryList from "../components/CategoryList"; +import {SnackbarProvider} from "notistack"; +import {categoryNodesMock, getAllCategoriesMock, getCategoryByIdMock} from "../backend/queries/category.mock"; +import {addCategoryMock, deleteCategoryMock, editCategoryMock} from "../backend/mutations/category.mock"; +import {expandAccordionAndGetIconButtons, queryAllAddIconButtons, queryAllEditIconButtons} from "./test-helper"; + + +describe('The CategoryList', () => { + test('displays the existing categories, but not the details of it', async () => { + renderCategoryList(); + + const categoryCards = await waitForInitialCategoriesToRender() + categoryCards.forEach(card => { + expect(card.innerHTML).toMatch(/Category [1-2]/) + }) + expect(queryAllEditIconButtons()).toHaveLength(0) + }); + + test('enables toggling details on each category', async () => { + renderCategoryList(); + + // Initial state: Every category card is not expanded + const categoryCards = await waitForInitialCategoriesToRender() + + // Expand first category card + await expandAccordionAndGetIconButtons(categoryCards[0]) + + // Shrink first category card again + fireEvent.click(categoryCards[0]) + await waitFor(() => { + expect(queryAllEditIconButtons()).toHaveLength(0) + }); + }); + + test('enables editing a category title', async () => { + renderCategoryList(editCategoryMock); + + const categoryCards = await waitForInitialCategoriesToRender(); + const {editIconButton} = await expandAccordionAndGetIconButtons(categoryCards[0]); + + // open edit dialog + expect(screen.queryByText(/Kategorie bearbeiten/)).toBeNull(); + fireEvent.click(editIconButton); + await waitFor(() => { + expect(screen.queryByText(/Kategorie bearbeiten/)).not.toBeNull(); + }) + + // change category title + const categoryTitleField = screen.getByDisplayValue(/Category 1/); + fireEvent.change(categoryTitleField, {target: {value: "New title for Category 1"}}); + await waitFor(() => { + expect(screen.queryByDisplayValue(/New title for /)).not.toBeNull(); + }) + + // call backend and assert apollo cache update + const confirmButton = screen.getByRole("button", {name: /Speichern/}); + fireEvent.click(confirmButton); + await waitFor(() => { + expect(screen.queryByText(/Kategorie bearbeiten/)).toBeNull(); + expect(screen.queryByText(/New title for Category 1/)).not.toBeNull() + }) + }); + + test('enables adding a category', async () => { + renderCategoryList(addCategoryMock); + await waitForInitialCategoriesToRender(); + + // open add dialog + const dialogIdentifier = /Neue Kategorie erstellen/; + expect(screen.queryByText(dialogIdentifier)).toBeNull(); + const addButton = queryAllAddIconButtons()[0]; + fireEvent.click(addButton); + await waitFor(() => { + expect(screen.queryByText(dialogIdentifier)).not.toBeNull(); + }) + + // change category title + const categoryTitleField = screen.getByLabelText(/Zusammenfassung/); + fireEvent.change(categoryTitleField, {target: {value: "New category"}}); + await waitFor(() => { + expect(screen.queryByDisplayValue(/New category/)).not.toBeNull(); + }) + + // call backend and assert apollo cache update + const confirmButton = screen.getByRole("button", {name: /Erstellen/}); + fireEvent.click(confirmButton); + await waitFor(() => { + expect(screen.queryByText(dialogIdentifier)).toBeNull(); + expect(screen.queryByText(/New category/)).not.toBeNull() + }) + }); + + test('enables deleting a category', async () => { + renderCategoryList(deleteCategoryMock); + + const categoryCards = await waitForInitialCategoriesToRender(); + expect(screen.queryByText(/Category 2/)).not.toBeNull(); + const {deleteIconButton} = await expandAccordionAndGetIconButtons(categoryCards[1]); + + // open delete confirmation dialog + expect(screen.queryByText(/Kategorie löschen/)).toBeNull(); + fireEvent.click(deleteIconButton); + await waitFor(() => { + expect(screen.queryByText(/Kategorie löschen/)).not.toBeNull(); + }) + + // call backend and assert apollo cache update + const confirmButton = screen.getByRole("button", {name: /Löschen/}); + fireEvent.click(confirmButton); + await waitFor(() => { + expect(screen.queryByText(/Kategorie löschen/)).toBeNull(); + expect(screen.queryByText(/Category 2/)).toBeNull(); + }) + }); +}); + +function renderCategoryList(additionalMocks?: Array) { + const initialMocks = [...getAllCategoriesMock, ...getCategoryByIdMock]; + const allMocks = additionalMocks ? [...initialMocks, ...additionalMocks] : initialMocks + return render( + + + + + + + + ); +} + +const waitForInitialCategoriesToRender = async (): Promise> => { + const numberOfCategoriesInMockQuery = categoryNodesMock.length; + let categoryCards: Array = []; + await waitFor(() => { + categoryCards = screen.queryAllByRole("button", {name: /Category [1-2]/}) + expect(categoryCards.length).toEqual(numberOfCategoriesInMockQuery); + }); + return categoryCards; +} + diff --git a/redaktions-app/src/integration-tests/question-list.integration.test.tsx b/redaktions-app/src/integration-tests/question-list.integration.test.tsx new file mode 100644 index 0000000..c33d6bf --- /dev/null +++ b/redaktions-app/src/integration-tests/question-list.integration.test.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import {fireEvent, render, screen, waitFor} from '@testing-library/react' +import {MockedProvider, MockedResponse} from '@apollo/client/testing'; +import {MemoryRouter} from 'react-router-dom'; +import QuestionList from "../components/QuestionList"; +import {SnackbarProvider} from "notistack"; +import {getAllQuestionsMock, getQuestionByIdMock, questionNodesMock} from "../backend/queries/question.mock"; +import {getAllCategoriesMock} from "../backend/queries/category.mock"; +import {addQuestionMock, deleteQuestionMock, editQuestionMock} from "../backend/mutations/question.mock"; +import { + expandAccordionAndGetIconButtons, + queryAllAddIconButtons, + queryAllEditIconButtons +} from "./test-helper"; + + +describe('The QuestionList', () => { + test('displays the existing questions, but not the details of it', async () => { + renderQuestionList(); + + const questionCards = await waitForInitialQuestionsToRender() + questionCards.forEach(card => { + expect(card.innerHTML).toMatch(/Question [1-3]\?/) + }) + expect(questionCards[0].innerHTML).toMatch(/Category 1/); + expect(queryAllEditIconButtons()).toHaveLength(0) + }); + + test('enables toggling details on each question', async () => { + renderQuestionList(); + + // Initial state: Every question card is not expanded + const questionCards = await waitForInitialQuestionsToRender() + + // Expand first question card + await expandAccordionAndGetIconButtons(questionCards[0]) + + // Shrink first question card again + fireEvent.click(questionCards[0]) + await waitFor(() => { + expect(queryAllEditIconButtons()).toHaveLength(0) + }); + }); + + test('enables editing a question title', async () => { + renderQuestionList(editQuestionMock); + + const questionCards = await waitForInitialQuestionsToRender(); + const {editIconButton} = await expandAccordionAndGetIconButtons(questionCards[0]); + + // open edit dialog + expect(screen.queryByText(/Frage bearbeiten/)).toBeNull(); + fireEvent.click(editIconButton); + await waitFor(() => { + expect(screen.queryByText(/Frage bearbeiten/)).not.toBeNull(); + }) + + // change question title + const questionTitleField = screen.getByDisplayValue(/Question 1/); + fireEvent.change(questionTitleField, {target: {value: "New title for Question 1?"}}); + await waitFor(() => { + expect(screen.queryByDisplayValue(/New title for /)).not.toBeNull(); + }) + + // call backend and assert apollo cache update + const confirmButton = screen.getByRole("button", {name: /Speichern/}); + fireEvent.click(confirmButton); + await waitFor(() => { + expect(screen.queryByText(/Frage bearbeiten/)).toBeNull(); + expect(screen.queryByText(/New title for Question 1/)).not.toBeNull() + }) + }); + + test('enables adding a question', async () => { + renderQuestionList(addQuestionMock); + await waitForInitialQuestionsToRender(); + + // open add dialog + const dialogIdentifier = /Neue Frage erstellen/; + expect(screen.queryByText(dialogIdentifier)).toBeNull(); + const addButton = queryAllAddIconButtons()[0]; + fireEvent.click(addButton); + await waitFor(() => { + expect(screen.queryByText(dialogIdentifier)).not.toBeNull(); + }) + + // change question title + const questionTitleField = screen.getByLabelText(/Zusammenfassung/); + fireEvent.change(questionTitleField, {target: {value: "New question?"}}); + await waitFor(() => { + expect(screen.queryByDisplayValue(/New question/)).not.toBeNull(); + }) + + // call backend and assert apollo cache update + const confirmButton = screen.getByRole("button", {name: /Erstellen/}); + fireEvent.click(confirmButton); + await waitFor(() => { + expect(screen.queryByText(dialogIdentifier)).toBeNull(); + expect(screen.queryByText(/New question/)).not.toBeNull() + }) + }); + + test('enables deleting a question', async () => { + renderQuestionList(deleteQuestionMock); + + const questionCards = await waitForInitialQuestionsToRender(); + expect(screen.queryByText(/Question 2/)).not.toBeNull(); + const {deleteIconButton} = await expandAccordionAndGetIconButtons(questionCards[1]); + + // open delete confirmation dialog + expect(screen.queryByText(/Frage löschen/)).toBeNull(); + fireEvent.click(deleteIconButton); + await waitFor(() => { + expect(screen.queryByText(/Frage löschen/)).not.toBeNull(); + }) + + // call backend and assert apollo cache update + const confirmButton = screen.getByRole("button", {name: /Löschen/}); + fireEvent.click(confirmButton); + await waitFor(() => { + expect(screen.queryByText(/Frage löschen/)).toBeNull(); + expect(screen.queryByText(/Question 2/)).toBeNull(); + }) + }); +}); + +function renderQuestionList(additionalMocks?: Array) { + const initialMocks = [...getAllQuestionsMock, ...getQuestionByIdMock, ...getAllCategoriesMock]; + const allMocks = additionalMocks ? [...initialMocks, ...additionalMocks] : initialMocks + return render( + + + + + + + + ); +} + +const waitForInitialQuestionsToRender = async (): Promise> => { + const numberOfQuestionsInMockQuery = questionNodesMock.length; + let questionCards: Array = []; + await waitFor(() => { + questionCards = screen.queryAllByRole("button", {name: /Question [1-3]\?/}) + expect(questionCards.length).toEqual(numberOfQuestionsInMockQuery); + }); + return questionCards; +} diff --git a/redaktions-app/src/integration-tests/sign-in.integration.test.tsx b/redaktions-app/src/integration-tests/sign-in.integration.test.tsx index a5d4803..a6c0d92 100644 --- a/redaktions-app/src/integration-tests/sign-in.integration.test.tsx +++ b/redaktions-app/src/integration-tests/sign-in.integration.test.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import SignIn, {loginMutation, LoginMutationResponse} from "../components/SignIn"; +import SignIn from "../components/SignIn"; import {fireEvent, render, screen, waitFor} from '@testing-library/react' -import {MockedProvider, MockedResponse} from '@apollo/client/testing'; +import {MockedProvider} from '@apollo/client/testing'; import {MemoryRouter} from 'react-router-dom'; +import {loginMock} from "../backend/mutations/login.mock"; const mockHistoryReplace = jest.fn(); @@ -13,46 +14,11 @@ jest.mock('react-router-dom', () => ({ }), })); -const mocks: Array> = [ - { - request: { - query: loginMutation, - variables: { - email: "test@email.com", - password: "password", - } - }, - result: { - data: { - authenticate: { - jwtToken: "123" - } - } - }, - }, - { - request: { - query: loginMutation, - variables: { - email: "test@email.com", - password: "wrong-password", - } - }, - result: { - data: { - authenticate: { - jwtToken: null - } - } - }, - } -] - describe('SignIn page', () => { beforeEach(() => mockHistoryReplace.mockReset()) test('initial state', () => { - render(); + render(); // it renders empty email and passsword fields const emailField = screen.getByRole('textbox', {name: 'Email Address'}); @@ -68,11 +34,11 @@ describe('SignIn page', () => { }); test('successful login', async () => { - render(); + render(); const emailField = screen.getByRole('textbox', {name: 'Email Address'}); const passwordField = screen.getByLabelText(/Password/); - const button = screen.getByRole('button'); + const button = screen.getByRole('button', {name: /sign in/i}); // fill out and submit form fireEvent.change(emailField, {target: {value: 'test@email.com'}}); @@ -81,20 +47,12 @@ describe('SignIn page', () => { fireEvent.click(button); await waitFor(() => { - // it hides form elements - expect(button).not.toBeInTheDocument(); - expect(emailField).not.toBeInTheDocument(); - expect(passwordField).not.toBeInTheDocument(); - - // it displays success text and email address - const loggedInText = screen.getByText(/Success/); - expect(loggedInText).toBeInTheDocument(); expect(mockHistoryReplace).toHaveBeenCalledWith("/"); }); }); test('error login', async () => { - render(); + render(); const emailField = screen.getByRole('textbox', {name: 'Email Address'}); const passwordField = screen.getByLabelText(/Password/); diff --git a/redaktions-app/src/integration-tests/test-helper.tsx b/redaktions-app/src/integration-tests/test-helper.tsx new file mode 100644 index 0000000..c2d705d --- /dev/null +++ b/redaktions-app/src/integration-tests/test-helper.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import {fireEvent, queryAllByRole, render, screen, waitFor} from '@testing-library/react' +import EditIcon from '@material-ui/icons/Edit'; +import DeleteIcon from '@material-ui/icons/Delete'; +import AddIcon from '@material-ui/icons/Add'; + + +const memoizedGetIconPath = (icon: JSX.Element) => { + let cache: { path?: string } = {}; + return (): string => { + if (cache?.path) { + return cache.path + } else { + const {container} = render(icon) + const path = container.innerHTML.match(//)?.[1] + if (!path) { + throw `Could not get path of MUI ${icon.type.displayName}` + } + cache.path = path + return path + } + } +} + +const getEditIconPath = memoizedGetIconPath() +const getDeleteIconPath = memoizedGetIconPath() +const getAddIconPath = memoizedGetIconPath() + +// sorry, I found no better way to find a specific icon button... +export const queryAllEditIconButtons = (container?: HTMLElement): Array => { + return (container ? queryAllByRole(container, "button") : screen.queryAllByRole("button")) + .filter(button => button.innerHTML.includes("svg") && button.innerHTML.includes(getEditIconPath())); +} + +// sorry, I found no better way to find a specific icon button... +const queryAllDeleteIconButtons = (container?: HTMLElement): Array => { + return (container ? queryAllByRole(container, "button") : screen.queryAllByRole("button")) + .filter(button => button.innerHTML.includes("svg") && button.innerHTML.includes(getDeleteIconPath())); +} + +// sorry, I found no better way to find a specific icon button... +export const queryAllAddIconButtons = (container?: HTMLElement): Array => { + return (container ? queryAllByRole(container, "button") : screen.queryAllByRole("button")) + .filter(button => button.innerHTML.includes("svg") && button.innerHTML.includes(getAddIconPath())); +} + +export const expandAccordionAndGetIconButtons = async (accordion: HTMLElement): Promise<{ editIconButton: HTMLElement, deleteIconButton: HTMLElement }> => { + let editIconsButtons = queryAllDeleteIconButtons(); + let deleteIconsButtons = queryAllEditIconButtons(); + expect(editIconsButtons).toHaveLength(0); + expect(deleteIconsButtons).toHaveLength(0); + fireEvent.click(accordion); + await waitFor(() => { + editIconsButtons = queryAllEditIconButtons(); + deleteIconsButtons = queryAllDeleteIconButtons(); + expect(editIconsButtons).toHaveLength(1); + expect(deleteIconsButtons).toHaveLength(1); + }) + return { + editIconButton: editIconsButtons[0], + deleteIconButton: deleteIconsButtons[0] + }; +}