Merge pull request 'feature/#11' (#19) from feature/#11 into develop
Reviewed-on: Netzbegruenung/candymat#19
This commit is contained in:
commit
b3492174b5
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 ...');
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -59,7 +59,8 @@ services:
|
|||
"--jwt-secret", $JWT_SECRET,
|
||||
"--watch",
|
||||
"--retry-on-init-fail",
|
||||
"--enhance-graphiql"
|
||||
"--enhance-graphiql",
|
||||
"--classic-ids",
|
||||
]
|
||||
networks:
|
||||
- frontend
|
||||
|
|
308
redaktions-app/package-lock.json
generated
308
redaktions-app/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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%",
|
||||
|
|
|
@ -19,4 +19,5 @@ const authLink = setContext((_, { headers }) => {
|
|||
export const client = new ApolloClient({
|
||||
cache: new InMemoryCache(),
|
||||
link: authLink.concat(httpLink),
|
||||
connectToDevTools: true,
|
||||
});
|
100
redaktions-app/src/backend/mutations/category.mock.ts
Normal file
100
redaktions-app/src/backend/mutations/category.mock.ts
Normal 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(),
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
77
redaktions-app/src/backend/mutations/category.ts
Normal file
77
redaktions-app/src/backend/mutations/category.ts
Normal 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,
|
||||
}
|
37
redaktions-app/src/backend/mutations/login.mock.ts
Normal file
37
redaktions-app/src/backend/mutations/login.mock.ts
Normal 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
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
19
redaktions-app/src/backend/mutations/login.ts
Normal file
19
redaktions-app/src/backend/mutations/login.ts
Normal 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
|
||||
}
|
||||
}
|
106
redaktions-app/src/backend/mutations/question.mock.ts
Normal file
106
redaktions-app/src/backend/mutations/question.mock.ts
Normal 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(),
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
81
redaktions-app/src/backend/mutations/question.ts
Normal file
81
redaktions-app/src/backend/mutations/question.ts
Normal 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,
|
||||
}
|
||||
|
||||
|
26
redaktions-app/src/backend/mutations/signUp.ts
Normal file
26
redaktions-app/src/backend/mutations/signUp.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
68
redaktions-app/src/backend/queries/category.mock.ts
Normal file
68
redaktions-app/src/backend/queries/category.mock.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
53
redaktions-app/src/backend/queries/category.ts
Normal file
53
redaktions-app/src/backend/queries/category.ts
Normal 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,
|
||||
}
|
83
redaktions-app/src/backend/queries/question.mock.ts
Normal file
83
redaktions-app/src/backend/queries/question.mock.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
71
redaktions-app/src/backend/queries/question.ts
Normal file
71
redaktions-app/src/backend/queries/question.ts
Normal 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,
|
||||
}
|
78
redaktions-app/src/components/AccordionWithEdit.tsx
Normal file
78
redaktions-app/src/components/AccordionWithEdit.tsx
Normal 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>
|
||||
)
|
||||
}
|
37
redaktions-app/src/components/AddCard.tsx
Normal file
37
redaktions-app/src/components/AddCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
|
|
60
redaktions-app/src/components/CategoryList.tsx
Normal file
60
redaktions-app/src/components/CategoryList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
36
redaktions-app/src/components/CategorySelectionMenu.tsx
Normal file
36
redaktions-app/src/components/CategorySelectionMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
23
redaktions-app/src/components/DialogActionBar.tsx
Normal file
23
redaktions-app/src/components/DialogActionBar.tsx
Normal 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>;
|
||||
}
|
123
redaktions-app/src/components/DialogChangeCategory.tsx
Normal file
123
redaktions-app/src/components/DialogChangeCategory.tsx
Normal 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>
|
||||
);
|
||||
}
|
135
redaktions-app/src/components/DialogChangeQuestion.tsx
Normal file
135
redaktions-app/src/components/DialogChangeQuestion.tsx
Normal 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>
|
||||
);
|
||||
}
|
61
redaktions-app/src/components/DialogDeleteCategory.tsx
Normal file
61
redaktions-app/src/components/DialogDeleteCategory.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
60
redaktions-app/src/components/DialogDeleteQuestion.tsx
Normal file
60
redaktions-app/src/components/DialogDeleteQuestion.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
43
redaktions-app/src/components/DialogSimple.tsx
Normal file
43
redaktions-app/src/components/DialogSimple.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
47
redaktions-app/src/components/DialogTitleAndDetails.tsx
Normal file
47
redaktions-app/src/components/DialogTitleAndDetails.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
|
|
61
redaktions-app/src/components/QuestionList.tsx
Normal file
61
redaktions-app/src/components/QuestionList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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()
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
|
@ -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/);
|
||||
|
|
63
redaktions-app/src/integration-tests/test-helper.tsx
Normal file
63
redaktions-app/src/integration-tests/test-helper.tsx
Normal 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]
|
||||
};
|
||||
}
|
Loading…
Reference in a new issue