Merge pull request 'feature/#11' (#19) from feature/#11 into develop

Reviewed-on: Netzbegruenung/candymat#19
This commit is contained in:
Christoph Lienhard 2020-12-30 22:54:26 +01:00
commit b3492174b5
42 changed files with 2249 additions and 206 deletions

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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 ...');

View file

@ -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');

View file

@ -59,7 +59,8 @@ services:
"--jwt-secret", $JWT_SECRET,
"--watch",
"--retry-on-init-fail",
"--enhance-graphiql"
"--enhance-graphiql",
"--classic-ids",
]
networks:
- frontend

View file

@ -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",

View file

@ -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%",

View file

@ -19,4 +19,5 @@ const authLink = setContext((_, { headers }) => {
export const client = new ApolloClient({
cache: new InMemoryCache(),
link: authLink.concat(httpLink),
connectToDevTools: true,
});

View file

@ -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<MockedResponse<EditCategoryResponse>> = [
{
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<MockedResponse<AddCategoryResponse>> = [
{
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<MockedResponse<DeleteCategoryResponse>> = [
{
request: {
query: DELETE_CATEGORY,
variables: deleteCategoryVariables,
},
result: {
data: {
deleteCategory: getDeletedCategoryMock(),
}
},
},
]

View file

@ -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,
}

View file

@ -0,0 +1,37 @@
import {MockedResponse} from "@apollo/client/testing";
import {LOGIN, LoginResponse} from "./login";
export const loginMock: Array<MockedResponse<LoginResponse>> = [
{
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
}
}
},
}
]

View file

@ -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
}
}

View file

@ -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<MockedResponse<EditQuestionResponse>> = [
{
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<MockedResponse<AddQuestionResponse>> = [
{
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<MockedResponse<DeleteQuestionResponse>> = [
{
request: {
query: DELETE_QUESTION,
variables: deleteQuestionVariables,
},
result: {
data: {
deleteQuestion: getDeletedQuestionMock(),
}
},
},
]

View file

@ -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,
}

View file

@ -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
}
}
}

View file

@ -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<BasicCategoryResponse> = [
{
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<MockedResponse<GetAllCategoriesResponse>> = [
{
request: {
query: GET_ALL_CATEGORIES,
},
result: {
data: {
allCategories: {
nodes: categoryNodesMock,
__typename: "CategoriesConnection",
},
},
},
},
]
export const getCategoryByIdMock: Array<MockedResponse<GetCategoryByIdResponse>> = [...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,
},
},
}
]

View file

@ -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<BasicCategoryResponse>,
__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,
}

View file

@ -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<BasicQuestionResponse> = [{
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<MockedResponse<GetAllQuestionsResponse>> = [
{
request: {
query: GET_ALL_QUESTIONS,
},
result: {
data: {
allQuestions: {
nodes: questionNodesMock,
__typename: "QuestionsConnection",
}
}
},
},
]
export const getQuestionByIdMock: Array<MockedResponse<GetQuestionByIdResponse>> = [
...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,
},
},
}
]

View file

@ -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<BasicQuestionResponse>,
__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,
}

View file

@ -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 (
<div className={classes.root}>
<Accordion>
<AccordionSummary
expandIcon={<ExpandMoreIcon/>}
aria-controls="panel1c-content"
id="panel1c-header"
>
<div className={classes.heading}>
<Typography>{props.title}</Typography>
</div>
<div className={classes.secondaryHeading}>
<Typography>{props.subTitle}</Typography>
</div>
</AccordionSummary>
<AccordionDetails className={classes.details}>
<Typography color="textSecondary" style={{whiteSpace: "pre-line"}}>
{props.description}
</Typography>
</AccordionDetails>
<Divider/>
<AccordionActions>
<IconButton data-testid="edit-icon-button" size={"small"} aria-label="edit" onClick={props.onEditButtonClick}>
<EditIcon titleAccess="Anpassen"/>
</IconButton>
<IconButton data-testid="delete-icon-button" size={"small"} aria-label="delete" onClick={props.onDeleteButtonClick}>
<DeleteIcon titleAccess="Löschen"/>
</IconButton>
</AccordionActions>
</Accordion>
</div>
)
}

View file

@ -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 (
<Card className={classes.root}>
<CardActionArea onClick={props.handleClick}>
<CardContent color={"textSecondary"} className={classes.addCardContent}>
<AddIcon className={classes.addCardIcon}/>
</CardContent>
</CardActionArea>
</Card>
)
}

View file

@ -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}
</Button>

View file

@ -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<GetAllCategoriesResponse, null>(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 (
<Paper className={classes.root}>
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>Kategorien</Typography>
{categories?.map(category => <AccordionWithEdit
key={category.id}
title={category.title}
description={category.description}
onEditButtonClick={() => handleEditButtonClick(category)}
onDeleteButtonClick={() => handleDeleteButtonClick(category)}
/>
)}
<AddCard handleClick={handleAddClick}/>
<DialogChangeCategory/>
<DialogDeleteCategory/>
</Paper>
)
}

View file

@ -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<BasicCategoryResponse>,
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 (
<FormControl fullWidth variant="outlined">
<InputLabel>Kategorie</InputLabel>
<Select
value={props.selectedCategoryId ? props.selectedCategoryId : -1}
label="Kategorie"
onChange={onCategoryIdChange}
>
<MenuItem value={-1}>
<em>None</em>
</MenuItem>
{props.categories?.map(category => <MenuItem key={category.id} value={category.rowId}>
{category.title}
</MenuItem>)}
</Select>
</FormControl>
);
}

View file

@ -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 <DialogActions>
<Button onClick={props.onClose} color="primary">
Abbrechen
</Button>
<ButtonWithSpinner onClick={props.onConfirmButtonClick} autoFocus loading={props.loading}>
{props.confirmButtonText || "Ok"}
</ButtonWithSpinner>
</DialogActions>;
}

View file

@ -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<string>("");
export const dialogChangeCategoryOpen = makeVar<boolean>(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<GetCategoryByIdResponse, GetCategoryByIdVariables>(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<EditCategoryResponse, EditCategoryVariables>(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<AddCategoryResponse, AddCategoryVariables>(ADD_CATEGORY, {
onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}),
onCompleted: (response) => {
if (response.createCategory) {
enqueueSnackbar("Kategorie erfolgreich hinzugefügt.", {variant: "success"})
dialogChangeCategoryOpen(false);
} else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"})
}
},
update: (cache, {data}) => {
cache.modify({
fields: {
allCategories(existingCategories = {nodes: []}) {
const newCategoryRef = cache.writeFragment<BasicCategoryResponse | undefined>({
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 (
<Dialog open={open} onClose={() => dialogChangeCategoryOpen(false)} aria-labelledby="form-dialog-title">
<DialogTitle id="form-dialog-title">
{addMode ? "Neue Kategorie erstellen" : "Kategorie bearbeiten"}
</DialogTitle>
<DialogContent>
<DialogTitleAndDetails
title={title}
details={details}
onTitleChange={newTitle => setTitle(newTitle)}
onDetailsChange={newDetails => setDetails(newDetails)}
/>
</DialogContent>
<DialogActionBar
onClose={() => dialogChangeCategoryOpen(false)}
onConfirmButtonClick={handleConfirmButtonClick}
confirmButtonText={addMode ? "Erstellen" : "Speichern"}
loading={addMode ? addLoading : editLoading}
/>
</Dialog>
);
}

View file

@ -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<string>("");
export const dialogChangeQuestionOpen = makeVar<boolean>(false);
export default function DialogChangeQuestion() {
const [addMode, setAddMode] = useState(true);
const [title, setTitle] = useState("");
const [details, setDetails] = useState("");
const [categoryRowId, setCategoryRowId] = useState<number | null>(null);
const questionId = useReactiveVar(dialogChangeQuestionId);
const open = useReactiveVar(dialogChangeQuestionOpen);
const {enqueueSnackbar} = useSnackbar();
useQuery<GetQuestionByIdResponse, GetQuestionByIdVariables>(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<GetAllCategoriesResponse, null>(GET_ALL_CATEGORIES).data?.allCategories.nodes;
const [editQuestion, {loading: editLoading}] = useMutation<EditQuestionResponse, EditQuestionVariables>(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<AddQuestionResponse, AddQuestionVariables>(ADD_QUESTION, {
onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, {variant: "error"}),
onCompleted: (response) => {
if (response.createQuestion) {
enqueueSnackbar("Frage erfolgreich hinzugefügt.", {variant: "success"})
dialogChangeQuestionOpen(false);
} else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {variant: "error"})
}
},
update: (cache, {data}) => {
cache.modify({
fields: {
allQuestions(existingQuestions = {nodes: []}) {
const newQuestionRef = cache.writeFragment<BasicQuestionResponse | undefined>({
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 (
<Dialog open={open} onClose={() => dialogChangeQuestionOpen(false)} aria-labelledby="form-dialog-title">
<DialogTitle id="form-dialog-title">
{addMode ? "Neue Frage erstellen" : "Frage bearbeiten"}
</DialogTitle>
<DialogContent>
<DialogTitleAndDetails
title={title}
details={details}
onTitleChange={newTitle => setTitle(newTitle)}
onDetailsChange={newDetails => setDetails(newDetails)}
/>
<CategorySelectionMenu
selectedCategoryId={categoryRowId}
categories={categories}
handleCategoryChange={(categoryId) => setCategoryRowId(categoryId)}
/>
</DialogContent>
<DialogActionBar
onClose={() => dialogChangeQuestionOpen(false)}
onConfirmButtonClick={handleConfirmButtonClick}
confirmButtonText={addMode ? "Erstellen" : "Speichern"}
loading={addMode ? addLoading : editLoading}
/>
</Dialog>
);
}

View file

@ -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<string>("");
export const dialogDeleteCategoryTitle = makeVar<string>("");
export const dialogDeleteCategoryOpen = makeVar<boolean>(false);
export default function DialogDeleteCategory() {
const {enqueueSnackbar} = useSnackbar();
const [deleteCategory, {loading}] = useMutation<DeleteCategoryResponse, DeleteCategoryVariables>(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<Reference> } = {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 (
<DialogSimple
open={open}
title={"Kategorie löschen?"}
description={`Möchtest du die Kategorie "${title}" wirklich löschen?`}
confirmButtonText={"Löschen"}
onConfirmButtonClick={handleConfirmButtonClick}
onClose={() => dialogDeleteCategoryOpen(false)}
loading={loading}
/>
);
}

View file

@ -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<string>("");
export const dialogDeleteQuestionTitle = makeVar<string>("");
export const dialogDeleteQuestionOpen = makeVar<boolean>(false);
export default function DialogDeleteQuestion() {
const {enqueueSnackbar} = useSnackbar();
const [deleteQuestion, {loading}] = useMutation<DeleteQuestionResponse, DeleteQuestionVariables>(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<Reference> } = {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 (
<DialogSimple
open={open}
title={"Frage löschen?"}
description={`Möchtest du die Frage "${title}" wirklich löschen?`}
confirmButtonText={"Löschen"}
onConfirmButtonClick={handleConfirmButtonClick}
onClose={() => dialogDeleteQuestionOpen(false)}
loading={loading}
/>
);
}

View file

@ -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 (
<Dialog
open={props.open}
onClose={props.onClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">{props.title}</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
{props.description}
</DialogContentText>
</DialogContent>
<DialogActionBar
confirmButtonText={props.confirmButtonText}
onClose={props.onClose}
onConfirmButtonClick={props.onConfirmButtonClick}
/>
</Dialog>
);
}

View file

@ -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 (
<React.Fragment>
<TextField
className={classes.textField}
id="title"
label="Zusammenfassung"
fullWidth
variant="outlined"
value={props.title}
onChange={e => props.onTitleChange(e.target.value)}
/>
<TextField
className={classes.textField}
multiline
rows={4}
id="description"
label="Details"
fullWidth
variant="outlined"
value={props.details}
onChange={e => props.onDetailsChange(e.target.value)}
/>
</React.Fragment>
)
}

View file

@ -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 (
<div>
<CustomAppBar/>
<main className={classes.content}>
<div className={classes.appBarSpacer}/>
<Container maxWidth="lg" className={classes.container}>
<Paper className={fixedHeightPaper}>
blablablubb
</Paper>
<Paper className={fixedHeightPaper}>
blablaj
</Paper>
<QuestionList/>
<CategoryList/>
<Copyright/>
</Container>
</main>

View file

@ -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<GetAllQuestionsResponse, null>(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 (
<Paper className={classes.root}>
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>Fragen</Typography>
{questions?.map(question => <AccordionWithEdit
key={question.id}
title={question.title}
subTitle={question.categoryByCategoryRowId?.title}
description={question.description}
onEditButtonClick={() => handleEditButtonClick(question)}
onDeleteButtonClick={() => handleDeleteButtonClick(question)}
/>
)}
<AddCard handleClick={handleAddButtonClick}/>
<DialogChangeQuestion/>
<DialogDeleteQuestion/>
</Paper>
)
}

View file

@ -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<LoginMutationResponse, LoginMutationVariables>(
loginMutation,
const [login, {loading}] = useMutation<LoginResponse, LoginVariables>(
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
}
}

View file

@ -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<SignUpError | undefined>(undefined)
const history = useHistory();
const [createAccount, {loading}] = useMutation<RegisterMutationResponse, RegisterMutationVariables>(
registerMutation,
const [createAccount, {loading}] = useMutation<SignUpResponse, SignUpVariables>(
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
}
}
}

View file

@ -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(
<ApolloProvider client={client}>
<Router>
<App/>
<SnackbarProvider maxSnack={3}>
<App/>
</SnackbarProvider>
</Router>
</ApolloProvider>,
document.getElementById('root')

View file

@ -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(
<MockedProvider>
<MemoryRouter initialEntries={[path]}>
<SnackbarProvider>
<App/>
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
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(<MockedProvider><MemoryRouter initialEntries={['/']}><App/></MemoryRouter></MockedProvider>);
renderAppAtUrl("/");
expect(() => screen.getByLabelText(/current user/)).not.toThrow()
});
test('redirects to login page if user not logged in', () => {
render(<MockedProvider><MemoryRouter initialEntries={['/']}><App/></MemoryRouter></MockedProvider>);
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(<MockedProvider><MemoryRouter initialEntries={['/login']}><App/></MemoryRouter></MockedProvider>);
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(<MockedProvider><MemoryRouter initialEntries={['/login']}><App/></MemoryRouter></MockedProvider>);
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(<MockedProvider><MemoryRouter initialEntries={['/signup']}><App/></MemoryRouter></MockedProvider>);
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(<MockedProvider><MemoryRouter initialEntries={['/signup']}><App/></MemoryRouter></MockedProvider>);
renderAppAtUrl("/signup");
expect(() => screen.getByLabelText(/current user/)).not.toThrow()
});

View file

@ -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<MockedResponse>) {
const initialMocks = [...getAllCategoriesMock, ...getCategoryByIdMock];
const allMocks = additionalMocks ? [...initialMocks, ...additionalMocks] : initialMocks
return render(
<MockedProvider mocks={allMocks}>
<MemoryRouter>
<SnackbarProvider>
<CategoryList/>
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
}
const waitForInitialCategoriesToRender = async (): Promise<Array<HTMLElement>> => {
const numberOfCategoriesInMockQuery = categoryNodesMock.length;
let categoryCards: Array<HTMLElement> = [];
await waitFor(() => {
categoryCards = screen.queryAllByRole("button", {name: /Category [1-2]/})
expect(categoryCards.length).toEqual(numberOfCategoriesInMockQuery);
});
return categoryCards;
}

View file

@ -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<MockedResponse>) {
const initialMocks = [...getAllQuestionsMock, ...getQuestionByIdMock, ...getAllCategoriesMock];
const allMocks = additionalMocks ? [...initialMocks, ...additionalMocks] : initialMocks
return render(
<MockedProvider mocks={allMocks}>
<MemoryRouter>
<SnackbarProvider>
<QuestionList/>
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
}
const waitForInitialQuestionsToRender = async (): Promise<Array<HTMLElement>> => {
const numberOfQuestionsInMockQuery = questionNodesMock.length;
let questionCards: Array<HTMLElement> = [];
await waitFor(() => {
questionCards = screen.queryAllByRole("button", {name: /Question [1-3]\?/})
expect(questionCards.length).toEqual(numberOfQuestionsInMockQuery);
});
return questionCards;
}

View file

@ -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<MockedResponse<LoginMutationResponse>> = [
{
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(<MockedProvider mocks={mocks}><MemoryRouter><SignIn/></MemoryRouter></MockedProvider>);
render(<MockedProvider mocks={loginMock}><MemoryRouter><SignIn/></MemoryRouter></MockedProvider>);
// 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(<MockedProvider mocks={mocks}><MemoryRouter><SignIn/></MemoryRouter></MockedProvider>);
render(<MockedProvider mocks={loginMock}><MemoryRouter><SignIn/></MemoryRouter></MockedProvider>);
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(<MockedProvider mocks={mocks}><MemoryRouter><SignIn/></MemoryRouter></MockedProvider>);
render(<MockedProvider mocks={loginMock}><MemoryRouter><SignIn/></MemoryRouter></MockedProvider>);
const emailField = screen.getByRole('textbox', {name: 'Email Address'});
const passwordField = screen.getByLabelText(/Password/);

View file

@ -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(/<path d="(.*)">/)?.[1]
if (!path) {
throw `Could not get path of MUI ${icon.type.displayName}`
}
cache.path = path
return path
}
}
}
const getEditIconPath = memoizedGetIconPath(<EditIcon/>)
const getDeleteIconPath = memoizedGetIconPath(<DeleteIcon/>)
const getAddIconPath = memoizedGetIconPath(<AddIcon/>)
// sorry, I found no better way to find a specific icon button...
export const queryAllEditIconButtons = (container?: HTMLElement): Array<HTMLElement> => {
return (container ? queryAllByRole(container, "button") : screen.queryAllByRole("button"))
.filter(button => button.innerHTML.includes("svg") && button.innerHTML.includes(getEditIconPath()));
}
// sorry, I found no better way to find a specific icon button...
const queryAllDeleteIconButtons = (container?: HTMLElement): Array<HTMLElement> => {
return (container ? queryAllByRole(container, "button") : screen.queryAllByRole("button"))
.filter(button => button.innerHTML.includes("svg") && button.innerHTML.includes(getDeleteIconPath()));
}
// sorry, I found no better way to find a specific icon button...
export const queryAllAddIconButtons = (container?: HTMLElement): Array<HTMLElement> => {
return (container ? queryAllByRole(container, "button") : screen.queryAllByRole("button"))
.filter(button => button.innerHTML.includes("svg") && button.innerHTML.includes(getAddIconPath()));
}
export const expandAccordionAndGetIconButtons = async (accordion: HTMLElement): Promise<{ editIconButton: HTMLElement, deleteIconButton: HTMLElement }> => {
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]
};
}