Update frontend
continuous-integration/drone/push Build is failing Details

This commit is contained in:
Dominic Grimm 2022-11-12 22:50:06 +01:00
parent ff2b884d42
commit 2ed278683b
No known key found for this signature in database
GPG Key ID: 6F294212DEAAC530
59 changed files with 828 additions and 2835 deletions

View File

@ -63,8 +63,9 @@ module Backend
@status = Status::JWTError
else
pp! payload
if @user = Db::User.find(payload.context.user)
if payload.iss != "Mentorenwahl" || payload.vrs != Backend::VERSION
@status = Status::JWTError
elsif @user = Db::User.find(payload.context.user)
@admin = user.not_nil!.admin
@role = user.not_nil!.role.to_api
@external =

View File

@ -48,7 +48,7 @@ module Backend
@[GraphQL::Field]
# Creates user
def create_user(context : Context, input : UserCreateInput, check_ldap : Bool = true) : User
def create_user(context : Context, input : UserCreateInput, check_ldap : Bool = true) : User?
context.admin!
raise Errors::LdapUserDoesNotExist.new if check_ldap && begin
@ -64,7 +64,7 @@ module Backend
@[GraphQL::Field]
# Deletes user by ID
def delete_user(context : Context, id : Int32) : Int32
def delete_user(context : Context, id : Int32) : Int32?
context.admin!
user = Db::User.find!(id)
@ -75,7 +75,7 @@ module Backend
@[GraphQL::Field]
# Starts assignment job of mentors to students
def assign_students(context : Context) : Bool
def assign_students(context : Context) : Bool?
context.admin!
Worker::Jobs::AssignStudentsJob.new.enqueue
@ -92,6 +92,15 @@ module Backend
# Teacher.new(teacher)
# end
@[GraphQL::Field]
def register_teacher(context : Context, input : TeacherInput) : Teacher?
context.teacher!(false)
raise Errors::InvalidPermissions.new if context.user.not_nil!.teacher
teacher = Db::Teacher.create!(user_id: context.user.not_nil!.id, max_students: input.max_students)
Teacher.new(teacher)
end
# @[GraphQL::Field]
# # Deletes teacher by ID
# def delete_teacher(context : Context, id : Int32) : Int32
@ -148,7 +157,7 @@ module Backend
@[GraphQL::Field]
# Creates vote for authenticated user's student
def create_vote(context : Context, input : VoteCreateInput) : Vote
def create_vote(context : Context, input : VoteCreateInput) : Vote?
context.student!
raise Errors::DuplicateTeachers.new if input.teacher_ids.uniq.size != input.teacher_ids.size

View File

@ -33,7 +33,7 @@ module Backend
@[GraphQL::Field]
# Current authenticated user
def me(context : Context) : User
def me(context : Context) : User?
context.authenticated!
User.new(context.user.not_nil!)
@ -41,7 +41,7 @@ module Backend
@[GraphQL::Field]
# User by ID
def user(context : Context, id : Int32) : User
def user(context : Context, id : Int32) : User?
context.admin!
User.from_id(id)
@ -49,14 +49,14 @@ module Backend
@[GraphQL::Field]
# All users
def users(context : Context) : Array(User)
def users(context : Context) : Array(User)?
context.admin!
Db::User.query.map { |user| User.new(user) }
end
@[GraphQL::Field]
def user_by_username(context : Context, username : String) : User
def user_by_username(context : Context, username : String) : User?
context.admin!
User.new(Db::User.query.find { var(:username) == username }.not_nil!)
@ -64,7 +64,7 @@ module Backend
@[GraphQL::Field]
# All admins
def admins(context : Context) : Array(User)
def admins(context : Context) : Array(User)?
context.admin!
Db::User.query.where(admin: true).map { |user| User.new(user) }
@ -84,7 +84,7 @@ module Backend
@[GraphQL::Field]
# Student by ID
def student(context : Context, id : Int32) : Student
def student(context : Context, id : Int32) : Student?
context.admin!
Student.new(Db::Student.find!(id))
@ -92,7 +92,7 @@ module Backend
@[GraphQL::Field]
# All students
def students(context : Context) : Array(Student)
def students(context : Context) : Array(Student)?
context.admin!
Db::Student.query.map { |student| Student.new(student) }
@ -100,7 +100,7 @@ module Backend
@[GraphQL::Field]
# Vote by ID
def vote(context : Context, id : Int32) : Vote
def vote(context : Context, id : Int32) : Vote?
context.admin!
Vote.new(Db::Vote.find!(id))
@ -108,7 +108,7 @@ module Backend
@[GraphQL::Field]
# All votes
def votes(context : Context) : Array(Vote)
def votes(context : Context) : Array(Vote)?
context.admin!
Db::Vote.query.map { |vote| Vote.new(vote) }
@ -116,7 +116,7 @@ module Backend
@[GraphQL::Field]
# All students voted
def all_students_voted(context : Context) : Bool
def all_students_voted(context : Context) : Bool?
context.admin!
votes = Db::Vote.query.count
@ -135,7 +135,7 @@ module Backend
@[GraphQL::Field]
# Teacher vote by ID
def teacher_vote(context : Context, id : Int32) : TeacherVote
def teacher_vote(context : Context, id : Int32) : TeacherVote?
context.admin!
TeacherVote.new(Db::TeacherVote.find!(id))
@ -143,7 +143,7 @@ module Backend
@[GraphQL::Field]
# All teacher votes
def teacher_votes(context : Context) : Array(TeacherVote)
def teacher_votes(context : Context) : Array(TeacherVote)?
context.admin!
Db::TeacherVote.query.map { |vote| TeacherVote.new(vote) }

View File

@ -92,7 +92,7 @@ module Backend
in Api::Schema::UserRole::Student
Db::Student.create!(user_id: user.id)
in Api::Schema::UserRole::Teacher
Db::Teacher.create!(user_id: user.id)
# Db::Teacher.create!(user_id: user.id)
end
Worker::Jobs::CacheLdapUserJob.new(user.id).enqueue

View File

@ -18,3 +18,4 @@ Dockerfile
.gitignore
.dockerignore
vendor/
.editorconfig

25
frontend/.editorconfig Normal file
View File

@ -0,0 +1,25 @@
# Mentorenwahl: A fullstack application for assigning mentors to students based on their whishes.
# Copyright (C) 2022 Dominic Grimm
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
root = true
[*.rs]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true

View File

@ -3,6 +3,12 @@ name = "frontend"
version = "0.1.0"
edition = "2021"
[profile.release]
lto = true
codegen-units = 1
opt-level = "z"
panic = "abort"
[dependencies]
yew = "0.19.3"
wasm-logger = "0.2.0"
@ -17,3 +23,4 @@ wasm-cookies = "0.1.0"
lazy_static = "1.4.0"
const_format = "0.2.30"
yew-agent = "0.1.0"
yew-side-effect = "0.2.0"

View File

@ -26,6 +26,12 @@ WORKDIR /usr/src/public
COPY --from=builder /usr/src/frontend/dist .
RUN minify . -r -o .
FROM alpine as binaryen
RUN apk add --no-cache binaryen
WORKDIR /usr/src/public
COPY --from=public /usr/src/public .
RUN find . -name "*.wasm" -type f | xargs -I % wasm-opt % -o % -O --intrinsic-lowering -Oz
FROM nginx:alpine as runner
COPY ./nginx.conf /etc/nginx/nginx.conf
COPY --from=public /usr/src/public /var/www/html
COPY --from=binaryen /usr/src/public /var/www/html

View File

@ -0,0 +1,5 @@
mutation RegisterTeacher($maxStudents: Int!) {
registerTeacher(input: { maxStudents: $maxStudents }) {
id
}
}

View File

@ -0,0 +1,5 @@
query Config {
config {
minimumTeacherSelectionCount
}
}

View File

@ -3,8 +3,12 @@ query Me {
firstName
role
student {
vote
vote {
id
}
}
teacher {
id
}
teacher
}
}

View File

@ -0,0 +1,3 @@
query StudentsCanVote {
studentsCanVote
}

View File

@ -0,0 +1,8 @@
query Teachers {
teachers {
id
user {
name(formal: true)
}
}
}

View File

@ -3,23 +3,23 @@ type Config {
}
type Query {
admins: [User!]!
allStudentsVoted: Boolean!
admins: [User!]
allStudentsVoted: Boolean
config: Config!
me: User!
me: User
ok: Boolean!
student(id: Int!): Student!
students: [Student!]!
student(id: Int!): Student
students: [Student!]
studentsCanVote: Boolean!
teacher(id: Int!): Teacher!
teacherVote(id: Int!): TeacherVote!
teacherVotes: [TeacherVote!]!
teacherVote(id: Int!): TeacherVote
teacherVotes: [TeacherVote!]
teachers: [Teacher!]!
user(id: Int!): User!
userByUsername(username: String!): User!
users: [User!]!
vote(id: Int!): Vote!
votes: [Vote!]!
user(id: Int!): User
userByUsername(username: String!): User
users: [User!]
vote(id: Int!): Vote
votes: [Vote!]
}
type Student {
@ -74,11 +74,16 @@ type LoginPayload {
}
type Mutation {
assignStudents: Boolean!
createUser(checkLdap: Boolean! = true, input: UserCreateInput!): User!
createVote(input: VoteCreateInput!): Vote!
deleteUser(id: Int!): Int!
assignStudents: Boolean
createUser(checkLdap: Boolean! = true, input: UserCreateInput!): User
createVote(input: VoteCreateInput!): Vote
deleteUser(id: Int!): Int
login(password: String!, username: String!): LoginPayload
registerTeacher(input: TeacherInput!): Teacher
}
input TeacherInput {
maxStudents: Int!
}
input UserCreateInput {

View File

@ -1 +1,2 @@
pub mod logged_in;
pub mod teacher_registered;

View File

@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use yew_agent::{Agent, AgentLink, Context, HandlerId};
#[derive(Serialize, Deserialize, Debug)]
pub enum Request {
Registered,
}
pub struct EventBus {
link: AgentLink<Self>,
subscribers: HashSet<HandlerId>,
}
impl Agent for EventBus {
type Reach = Context<Self>;
type Message = ();
type Input = Request;
type Output = ();
fn create(link: AgentLink<Self>) -> Self {
Self {
link,
subscribers: HashSet::new(),
}
}
fn update(&mut self, _msg: Self::Message) {}
fn handle_input(&mut self, msg: Self::Input, _id: HandlerId) {
match msg {
Request::Registered => {
for sub in self.subscribers.iter() {
self.link.respond(*sub, ());
}
}
}
}
fn connected(&mut self, id: HandlerId) {
self.subscribers.insert(id);
}
fn disconnected(&mut self, id: HandlerId) {
self.subscribers.remove(&id);
}
}

View File

@ -0,0 +1,34 @@
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct GraphQLErrorsProps {
pub errors: Option<Vec<String>>,
}
pub struct GraphQLErrors;
impl Component for GraphQLErrors {
type Message = ();
type Properties = GraphQLErrorsProps;
fn create(_ctx: &Context<Self>) -> Self {
Self
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
if let Some(errors) = &ctx.props().errors {
<div>
<p style="color: red;">{ "Errors:" }</p>
<div>
{ for errors.iter().map(|e| html! { <p style="color: red;">{ e }</p> }) }
</div>
</div>
}
}
}
}
pub fn convert(errors: Option<Vec<graphql_client::Error>>) -> Option<Vec<String>> {
errors.map(|x| x.iter().map(|e| e.message.to_owned()).collect())
}

View File

@ -1 +1,2 @@
pub mod graphql_errors;
pub mod logged_in_handler;

View File

@ -12,3 +12,17 @@ lazy_static! {
.unwrap()
.to_string();
}
pub fn client(token: Option<&String>) -> Result<reqwest::Client, reqwest::Error> {
if let Some(x) = token {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
"Authorization",
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", x)).unwrap(),
);
reqwest::Client::builder().default_headers(headers).build()
} else {
Ok(reqwest::Client::new())
}
}

View File

@ -4,6 +4,6 @@ use graphql_client::GraphQLQuery;
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/mutations/login.graphql",
response_derives = "Debug,Serialize,Deserialize"
response_derives = "Debug"
)]
pub struct Login;

View File

@ -1 +1,2 @@
pub mod login;
pub mod register_teacher;

View File

@ -0,0 +1,9 @@
use graphql_client::GraphQLQuery;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/mutations/register_teacher.graphql",
response_derives = "Debug"
)]
pub struct RegisterTeacher;

View File

@ -0,0 +1,9 @@
use graphql_client::GraphQLQuery;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/queries/config.graphql",
response_derives = "Debug"
)]
pub struct Config;

View File

@ -1,2 +1,5 @@
pub mod config;
pub mod me;
pub mod ok;
pub mod students_can_vote;
pub mod teachers;

View File

@ -0,0 +1,9 @@
use graphql_client::GraphQLQuery;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/queries/students_can_vote.graphql",
response_derives = "Debug"
)]
pub struct StudentsCanVote;

View File

@ -0,0 +1,9 @@
use graphql_client::GraphQLQuery;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/queries/teachers.graphql",
response_derives = "Debug"
)]
pub struct Teachers;

View File

@ -1,7 +1,6 @@
use yew::prelude::*;
use yew_agent::{Bridge, Bridged};
use yew_router::prelude::*;
use yew_router::scope_ext::RouterScopeExt;
use crate::agents;
use crate::routes;
@ -52,7 +51,9 @@ impl Component for LoggedIn {
}
html! {
{ for ctx.props().children.iter() }
if self.logged_in {
{ for ctx.props().children.iter() }
}
}
}
}

View File

@ -1,5 +1,7 @@
use std::rc::Rc;
use yew::prelude::*;
use yew_router::prelude::*;
use yew_side_effect::title::TitleProvider;
use frontend::routes;
@ -17,9 +19,14 @@ impl Component for App {
}
fn view(&self, _ctx: &Context<Self>) -> Html {
let format_title =
Rc::new(|m: &str| format!("{} | dergrimm.net", m)) as Rc<dyn Fn(&str) -> String>;
html! {
<BrowserRouter>
<Switch<routes::Route> render={Switch::render(routes::switch)} />
<TitleProvider default_title="Mentorenwahl | dergrimm.net" {format_title}>
<Switch<routes::Route> render={Switch::render(routes::switch)} />
</TitleProvider>
</BrowserRouter>
}
}

View File

@ -1,100 +0,0 @@
use graphql_client::reqwest::post_graphql;
use yew::prelude::*;
use crate::graphql;
pub enum Msg {
DoneFetching {
errors: Option<Vec<String>>,
data: graphql::queries::me::me::ResponseData,
},
}
#[derive(Properties, PartialEq)]
pub struct HomeProps {
pub token: String,
}
enum State {
Fetching,
Done,
}
pub struct Home {
state: State,
errors: Option<Vec<String>>,
data: Option<graphql::queries::me::me::ResponseData>,
}
impl Component for Home {
type Message = Msg;
type Properties = HomeProps;
fn create(_ctx: &Context<Self>) -> Self {
Self {
state: State::Fetching,
errors: None,
data: None,
}
}
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::DoneFetching { errors, data } => {
self.state = State::Done;
self.errors = errors;
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
match self.state {
State::Fetching => {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
"Authorization",
reqwest::header::HeaderValue::from_str(&format!(
"Bearer {}",
ctx.props().token
))
.unwrap(),
);
ctx.link().send_future(async move {
let response = post_graphql::<graphql::queries::me::Me, _>(
&reqwest::Client::builder()
.default_headers(headers)
.build()
.unwrap(),
graphql::URL.as_str(),
graphql::queries::me::me::Variables,
)
.await
.unwrap();
log::debug!("{:?}", response.data);
Msg::DoneFetching {
errors: response
.errors
.map(|x| x.iter().map(|e| e.message.to_owned()).collect()),
data: response.data.unwrap(),
}
});
html! {
<p>{ "Fetching..." }</p>
}
}
State::Done => html! {
if let Some(errors) = &self.errors {
<p>{ "Errors:" }</p>
<div>
{ for errors.iter().map(|e| html! { <p style="color: red;">{ e }</p> }) }
</div>
} else {
<h1>{ "Hey, !" }</h1>
}
},
}
}
}

View File

@ -0,0 +1,124 @@
use graphql_client::reqwest::post_graphql;
use yew::prelude::*;
use yew_side_effect::title::Title;
use crate::components;
use crate::graphql;
pub mod student_home;
pub mod student_vote;
pub mod teacher_home;
pub mod teacher_registration;
pub enum Msg {
DoneFetching {
errors: Option<Vec<String>>,
me: graphql::queries::me::me::MeMe,
},
}
#[derive(Properties, PartialEq)]
pub struct HomeProps {
pub token: Option<String>,
}
enum State {
Fetching,
Done,
}
pub struct Home {
state: State,
errors: Option<Vec<String>>,
me: Option<graphql::queries::me::me::MeMe>,
}
impl Component for Home {
type Message = Msg;
type Properties = HomeProps;
fn create(_ctx: &Context<Self>) -> Self {
Self {
state: State::Fetching,
errors: None,
me: None,
}
}
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::DoneFetching { errors, me } => {
self.state = State::Done;
self.errors = errors;
self.me = Some(me);
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<>
<Title value="Home" />
{
match self.state {
State::Fetching => {
let client = graphql::client(ctx.props().token.as_ref()).unwrap();
ctx.link().send_future(async move {
let response = post_graphql::<graphql::queries::me::Me, _>(
&client,
graphql::URL.as_str(),
graphql::queries::me::me::Variables,
)
.await
.unwrap();
Msg::DoneFetching {
errors: components::graphql_errors::convert(response.errors),
me: response.data.unwrap().me.unwrap(),
}
});
html! {
<p>{ "Fetching..." }</p>
}
}
State::Done => {
if let Some(errors) = &self.errors {
html! {
<components::graphql_errors::GraphQLErrors errors={errors.clone()} />
}
} else {
let me = self.me.as_ref().unwrap();
html! {
<>
<h1>{ format!("Hey, {}!", me.first_name) }</h1>
<hr />
{
match me.role {
graphql::queries::me::me::UserRole::Student => html! {
<student_home::StudentHome
token={ctx.props().token.as_ref().unwrap().to_owned()}
voted={me.student.as_ref().unwrap().vote.is_some()}
/>
},
graphql::queries::me::me::UserRole::Teacher => html! {
<teacher_home::TeacherHome
token={ctx.props().token.as_ref().unwrap().to_owned()}
registered={me.teacher.is_some()}
/>
},
graphql::queries::me::me::UserRole::Other(_) => html! {}
}
}
</>
}
}
}
}
}
</>
}
}
}

View File

@ -0,0 +1,30 @@
use yew::prelude::*;
use crate::routes::home::student_vote;
#[derive(Properties, PartialEq)]
pub struct StudentHomeProps {
pub token: String,
pub voted: bool,
}
pub struct StudentHome;
impl Component for StudentHome {
type Message = ();
type Properties = StudentHomeProps;
fn create(_ctx: &Context<Self>) -> Self {
Self
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
if ctx.props().voted {
<p>{ "Alles in Ordnung." }</p>
} else {
<student_vote::StudentVote token={ctx.props().token.to_owned()} />
}
}
}
}

View File

@ -0,0 +1,220 @@
use graphql_client::reqwest::post_graphql;
use std::collections::HashMap;
use yew::prelude::*;
use crate::components;
use crate::graphql;
pub enum Msg {
DoneFetchingCanVote {
errors: Option<Vec<String>>,
can_vote: bool,
},
DoneFetchingConfig {
errors: Option<Vec<String>>,
min: usize,
},
DoneFetchingTeachers {
errors: Option<Vec<String>>,
teachers: Vec<graphql::queries::teachers::teachers::TeachersTeachers>,
},
RadioSelect {
priority: usize,
teacher: i64,
},
Submit,
}
#[derive(Properties, PartialEq)]
pub struct StudentVoteProps {
pub token: String,
}
pub struct StudentVote {
fetching: bool,
can_vote: bool,
errors: Option<Vec<String>>,
min: usize,
teachers: Option<Vec<graphql::queries::teachers::teachers::TeachersTeachers>>,
votes: HashMap<usize, Option<i64>>,
}
impl Component for StudentVote {
type Message = Msg;
type Properties = StudentVoteProps;
fn create(ctx: &Context<Self>) -> Self {
let client = graphql::client(Some(&ctx.props().token)).unwrap();
ctx.link().send_future(async move {
let can_vote_response =
post_graphql::<graphql::queries::students_can_vote::StudentsCanVote, _>(
&client,
graphql::URL.as_str(),
graphql::queries::students_can_vote::students_can_vote::Variables,
)
.await
.unwrap();
Msg::DoneFetchingCanVote {
errors: components::graphql_errors::convert(can_vote_response.errors),
can_vote: can_vote_response.data.unwrap().students_can_vote,
}
});
Self {
fetching: true,
can_vote: false,
errors: None,
min: 0,
teachers: None,
votes: HashMap::new(),
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::DoneFetchingCanVote { errors, can_vote } => {
self.errors = errors;
self.can_vote = can_vote;
let client = graphql::client(Some(&ctx.props().token)).unwrap();
ctx.link().send_future(async move {
let response = post_graphql::<graphql::queries::config::Config, _>(
&client,
graphql::URL.as_str(),
graphql::queries::config::config::Variables,
)
.await
.unwrap();
Msg::DoneFetchingConfig {
errors: components::graphql_errors::convert(response.errors),
min: response
.data
.unwrap()
.config
.minimum_teacher_selection_count as usize,
}
});
true
}
Msg::DoneFetchingConfig { errors, min } => {
self.errors = errors;
self.min = min;
let client = graphql::client(Some(&ctx.props().token)).unwrap();
ctx.link().send_future(async move {
let response = post_graphql::<graphql::queries::teachers::Teachers, _>(
&client,
graphql::URL.as_str(),
graphql::queries::teachers::teachers::Variables,
)
.await
.unwrap();
Msg::DoneFetchingTeachers {
errors: components::graphql_errors::convert(response.errors),
teachers: response.data.unwrap().teachers,
}
});
true
}
Msg::DoneFetchingTeachers { errors, teachers } => {
self.fetching = false;
self.errors = errors;
self.teachers = Some(teachers);
true
}
Msg::RadioSelect { priority, teacher } => {
self.votes.insert(priority, Some(teacher));
for (p, t) in self.votes.iter_mut() {
if *p > priority && *t == Some(teacher) {
*t = None;
}
}
true
}
Msg::Submit => {
log::debug!("{:?}", self.votes);
false
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
if self.fetching {
html! {
<p>{ "Fetching..." }</p>
}
} else {
let onsubmit = ctx.link().callback(|e: FocusEvent| {
e.prevent_default();
Msg::Submit
});
let mut teachers: Vec<i64> = vec![];
html! {
<>
<h3>{ "Wähle deine Wunschmentoren aus:" }</h3>
<form {onsubmit}>
{ for (0..self.min).map(|i| {
let curr_t = self.votes.get(&i);
if let Some(te) = curr_t {
if let Some(t) = te {
teachers.push(*t);
}
}
let name = format!("mentors_{}", i);
html! {
<>
<fieldset>
<legend>{ format!("{}. Wahl", i + 1) }</legend>
{ for self.teachers.as_ref().unwrap().iter().enumerate().filter_map(|(j, t)| {
let checked = curr_t == Some(&Some(t.id));
if teachers.contains(&t.id) && !checked {
None
} else {
let id = format!("{}_{}", name, j);
let teacher_id = t.id;
let onchange = ctx.link().callback(move |_| Msg::RadioSelect { priority: i, teacher: teacher_id });
Some(html! {
<div>
<input
type="radio"
id={id.to_owned()}
name={name.to_owned()}
value={t.user.name.to_owned()}
required=true
{onchange}
{checked}
/>
<label for={id}>{ &t.user.name }</label>
</div>
})
}
}) }
</fieldset>
if i < self.min - 1 {
<br />
}
</>
}
}) }
<div>
<button type="submit">{ "Submit" }</button>
</div>
</form>
</>
}
}
}
}

View File

@ -0,0 +1,53 @@
use yew::prelude::*;
use yew_agent::{Bridge, Bridged};
use crate::agents;
use crate::routes::home::teacher_registration;
pub enum Msg {
Registered,
}
#[derive(Properties, PartialEq)]
pub struct TeacherHomeProps {
pub token: String,
pub registered: bool,
}
pub struct TeacherHome {
registered: bool,
_teacher_registered_producer: Box<dyn Bridge<agents::teacher_registered::EventBus>>,
}
impl Component for TeacherHome {
type Message = Msg;
type Properties = TeacherHomeProps;
fn create(ctx: &Context<Self>) -> Self {
Self {
registered: ctx.props().registered,
_teacher_registered_producer: agents::teacher_registered::EventBus::bridge(
ctx.link().callback(|_| Msg::Registered),
),
}
}
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::Registered => {
self.registered = true;
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
if self.registered {
<p>{ "Alles in Ordnung." }</p>
} else {
<teacher_registration::TeacherRegistration token={ctx.props().token.to_owned()} />
}
}
}
}

View File

@ -0,0 +1,106 @@
use graphql_client::reqwest::post_graphql;
use web_sys::HtmlInputElement;
use yew::prelude::*;
use yew_agent::{Dispatched, Dispatcher};
use crate::agents;
use crate::components;
use crate::graphql;
pub enum Msg {
Submit,
Registration(Option<Vec<String>>),
}
#[derive(Properties, PartialEq)]
pub struct TeacherRegistrationProps {
pub token: String,
}
pub struct TeacherRegistration {
max_students: NodeRef,
errors: Option<Vec<String>>,
teacher_registered_event_bus: Dispatcher<agents::teacher_registered::EventBus>,
}
impl Component for TeacherRegistration {
type Message = Msg;
type Properties = TeacherRegistrationProps;
fn create(_ctx: &Context<Self>) -> Self {
Self {
max_students: NodeRef::default(),
errors: None,
teacher_registered_event_bus: agents::teacher_registered::EventBus::dispatcher(),
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::Submit => {
if let Some(max_students) = self
.max_students
.cast::<HtmlInputElement>()
.map(|x| x.value_as_number() as usize)
{
let client = graphql::client(Some(&ctx.props().token)).unwrap();
ctx.link().send_future(async move {
let response = post_graphql::<
graphql::mutations::register_teacher::RegisterTeacher,
_,
>(
&client,
graphql::URL.as_str(),
graphql::mutations::register_teacher::register_teacher::Variables {
max_students: max_students as i64,
},
)
.await
.unwrap();
log::debug!("{:?}", response.data.unwrap().register_teacher);
Msg::Registration(components::graphql_errors::convert(response.errors))
});
}
false
}
Msg::Registration(errors) => {
self.errors = errors;
if self.errors.is_none() {
self.teacher_registered_event_bus
.send(agents::teacher_registered::Request::Registered);
false
} else {
true
}
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let onsubmit = ctx.link().callback(|e: FocusEvent| {
e.prevent_default();
Msg::Submit
});
html! {
<>
<form {onsubmit}>
<label for="max_students">
{ "Maximale Anzahl von Schülern:" }
<br />
<input ref={self.max_students.clone()} type="number" id="max_students" name="max_students" min=1 />
</label>
<br /><br />
<button type="submit">{ "Submit" }</button>
<components::graphql_errors::GraphQLErrors errors={self.errors.clone()} />
</form>
</>
}
}
}

View File

@ -2,7 +2,9 @@ use graphql_client::reqwest::post_graphql;
use web_sys::HtmlInputElement;
use yew::prelude::*;
use yew_router::prelude::*;
use yew_side_effect::title::Title;
use crate::components;
use crate::cookie_names;
use crate::graphql;
use crate::routes;
@ -40,7 +42,7 @@ impl Component for Login {
if !username.is_empty() && !password.is_empty() {
ctx.link().send_future(async {
let response = post_graphql::<graphql::mutations::login::Login, _>(
&reqwest::Client::new(),
&graphql::client(None).unwrap(),
graphql::URL.as_str(),
graphql::mutations::login::login::Variables { username, password },
)
@ -57,11 +59,7 @@ impl Component for Login {
)
}
Msg::Login(
response
.errors
.map(|x| x.iter().map(|e| e.message.to_owned()).collect()),
)
Msg::Login(components::graphql_errors::convert(response.errors))
});
}
}
@ -81,9 +79,7 @@ impl Component for Login {
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
let onsubmit = link.callback(|e: FocusEvent| {
let onsubmit = ctx.link().callback(|e: FocusEvent| {
e.prevent_default();
Msg::Submit
@ -91,11 +87,12 @@ impl Component for Login {
html! {
<>
<Title value="Login" />
<form {onsubmit}>
<label for="username">
{ "Benutzername:" }
<br />
<input ref={self.username.clone()} type="text" id="username" />
<input ref={self.username.clone()} type="text" id="username" name="username" />
</label>
<br />
@ -103,19 +100,15 @@ impl Component for Login {
<label for="password">
{ "Kennwort:" }
<br />
<input ref={self.password.clone()} type="password" id="password" />
<input ref={self.password.clone()} type="password" id="password" name="password" />
</label>
<br /><br />
<button type="submit">{ "Login" }</button>
</form>
if let Some(errors) = &self.errors {
<div>
{ for errors.iter().map(|e| html! { <p style="color: red;">{ e }</p> }) }
</div>
}
<components::graphql_errors::GraphQLErrors errors={self.errors.clone()} />
</form>
</>
}

View File

@ -40,7 +40,7 @@ pub fn switch(routes: &Route) -> Html {
html! {
<layouts::logged_in::LoggedIn {logged_in}>
<layouts::main::Main {logged_in}>
<home::Home token={token.unwrap()} />
<home::Home {token} />
</layouts::main::Main>
</layouts::logged_in::LoggedIn>
}

View File

@ -1,8 +1,22 @@
use yew::prelude::*;
use yew_side_effect::title::Title;
#[function_component(NotFound)]
pub fn not_found() -> Html {
html! {
<h1>{ "404" }</h1>
pub struct NotFound;
impl Component for NotFound {
type Message = ();
type Properties = ();
fn create(_ctx: &Context<Self>) -> Self {
Self
}
fn view(&self, _ctx: &Context<Self>) -> Html {
html! {
<>
<Title value="404 Not found" />
<h1>{ "404" }</h1>
</>
}
}
}

View File

@ -1,12 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
yarn-error.log
Dockerfile
.gitignore
.dockerignore

View File

@ -1,24 +0,0 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
],
plugins: ["svelte3", "@typescript-eslint"],
ignorePatterns: ["*.cjs"],
overrides: [{ files: ["*.svelte"], processor: "svelte3/svelte3" }],
settings: {
"svelte3/typescript": () => require("typescript"),
},
parserOptions: {
sourceType: "module",
ecmaVersion: 2020,
},
env: {
browser: true,
es2017: true,
node: true,
},
};

View File

@ -1,9 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
yarn-error.log

View File

@ -1 +0,0 @@
engine-strict=true

View File

@ -1 +0,0 @@
{}

View File

@ -1,22 +0,0 @@
FROM node:16-alpine as deps
WORKDIR /usr/src/frontend
COPY ./package.json ./yarn.lock ./
RUN yarn install --frozen-lockfile
FROM node:16-alpine as builder
WORKDIR /usr/src/frontend
COPY --from=deps /usr/src/frontend/package.json .
COPY --from=deps /usr/src/frontend/node_modules ./node_modules
COPY svelte.config.js tsconfig.json ./
COPY ./static ./static
COPY ./src ./src
RUN yarn build
FROM node:16-alpine as runner
WORKDIR /usr/src/frontend
COPY --from=deps /usr/src/frontend/package.json .
COPY --from=deps /usr/src/frontend/node_modules ./node_modules
COPY svelte.config.js .
COPY --from=builder /usr/src/frontend/.svelte-kit ./.svelte-kit
EXPOSE 3000
CMD [ "yarn", "preview", "--host" ]

View File

@ -1,40 +0,0 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm init svelte@next
# create a new project in my-app
npm init svelte@next my-app
```
> Note: the `@next` is temporary
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs#adapters) for your target environment.

View File

@ -1,37 +0,0 @@
{
"name": "frontend",
"version": "0.0.1",
"scripts": {
"dev": "svelte-kit dev",
"build": "svelte-kit build",
"package": "svelte-kit package",
"preview": "svelte-kit preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^1.0.0-next.86",
"@sveltejs/kit": "next",
"@types/cookie": "^0.4.1",
"@typescript-eslint/eslint-plugin": "^5.10.1",
"@typescript-eslint/parser": "^5.10.1",
"@urql/svelte": "^1.3.3",
"cookie": "^0.4.2",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-svelte3": "^3.2.1",
"graphql": "^16.3.0",
"prettier": "^2.4.1",
"prettier-plugin-svelte": "^2.4.0",
"svelte": "^3.44.0",
"svelte-check": "^2.2.6",
"svelte-forms": "^2.2.1",
"svelte-preprocess": "^4.9.4",
"tslib": "^2.3.1",
"typescript": "^4.4.3",
"typescript-cookie": "^1.0.3"
},
"type": "module"
}

View File

@ -1,21 +0,0 @@
/// <reference types="@sveltejs/kit" />
// See https://kit.svelte.dev/docs#typescript
// for information about these interfaces
declare namespace App {
interface Locals {
user: {
token?: string;
};
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface Platform {}
interface Session {
user: Locals["user"];
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface Stuff {}
}

View File

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="description" content="" />
<link rel="icon" href="%svelte.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%svelte.head%
</head>
<body>
<div>%svelte.body%</div>
</body>
</html>

View File

@ -1,31 +0,0 @@
import type { RequestEvent, ResolveOpts } from "@sveltejs/kit";
import * as cookie from "cookie";
import * as cookieNames from "$lib/cookieNames";
export async function handle(input: {
event: RequestEvent;
opts?: ResolveOpts;
resolve(event: RequestEvent, opts?: ResolveOpts): Promise<Response>;
}): Promise<Response> {
const header = input.event.request.headers.get("cookie");
const cookies = header ? cookie.parse(header) : {};
const token: string | undefined = cookies[cookieNames.TOKEN];
input.event.locals = {
...input.event.locals,
user: {
token,
},
};
return input.resolve(input.event, input.opts);
}
export async function getSession(event: RequestEvent): Promise<App.Session> {
return {
user: {
token: event.locals.user.token,
},
};
}

View File

@ -1 +0,0 @@
<h3>STUDENT!</h3>

View File

@ -1 +0,0 @@
<h3>TEACHER!</h3>

View File

@ -1,2 +0,0 @@
const BASE = "mentorenwahl_";
export const TOKEN = BASE + "token";

View File

@ -1,50 +0,0 @@
export type ID = number;
export interface Node {
id: ID;
}
export enum UserRole {
STUDENT = "Student",
TEACHER = "Teacher",
}
export interface User extends Node {
firstName: string;
lastName: string;
name: string;
username: string;
email: string;
role: UserRole;
admin: boolean;
externalId: ID;
student?: Student;
teacher?: Teacher;
}
export interface UserExternal {
user: User;
}
export interface Student extends Node, UserExternal {
vote?: Vote;
}
export interface Teacher extends Node, UserExternal {
maxStudents: number;
}
export interface Vote extends Node {
student: Student;
}
export interface TeacherVote extends Node {
teacher: Teacher;
priority: number;
}
export interface LoginPayload {
user: User;
token: string;
bearer: string;
}

View File

@ -1,47 +0,0 @@
<script lang="ts">
import { initClient } from "@urql/svelte";
import { removeCookie } from "typescript-cookie";
import { session } from "$app/stores";
import { goto } from "$app/navigation";
import * as cookieNames from "$lib/cookieNames";
initClient({
url: "/graphql",
fetchOptions() {
if ($session.user.token) {
return {
headers: {
Authorization: `Bearer ${$session.user.token}`,
},
};
}
return {};
},
});
function logout(): void {
$session.user.token = undefined;
removeCookie(cookieNames.TOKEN);
goto("/login");
}
</script>
<nav>
<ul>
<li>
{#if $session.user.token}
<button on:click={logout}>Logout</button>
{:else}
<button><a href="/login">Login</a></button>
{/if}
</li>
</ul>
</nav>
<hr />
<main>
<slot />
</main>

View File

@ -1,181 +0,0 @@
<script lang="ts" context="module">
export function load({ session }: { session: App.Session }) {
if (!session.user.token) {
return {
status: 302,
redirect: "/login",
};
}
return {
props: {
session,
},
};
}
</script>
<script lang="ts">
import { operationStore, query, gql, mutation } from "@urql/svelte";
// import * as svelteForms from "svelte-forms";
// import * as validators from "svelte-forms/validators";
import type { User } from "$lib/graphql";
import { UserRole } from "$lib/graphql";
import StudentHome from "$lib/StudentHome.svelte";
interface Data {
me: User;
}
const meStore = operationStore<Data>(gql`
query Me {
me {
firstName
role
admin
student {
id
vote {
id
}
}
teacher {
id
}
}
}
`);
query(meStore);
// const teacherRegisterFormMaxStudents = svelteForms.field("maxStudents", 0, [
// validators.required(),
// validators.min(0),
// ]);
// const teacheRegisterForm = svelteForms.form(teacherRegisterFormMaxStudents);
// interface RegisterTeacherData {
// registerTeacher: Teacher;
// }
// interface RegisterTeacherVars {
// maxStudents: number;
// }
// const registerTeacherStore = operationStore<
// RegisterTeacherData,
// RegisterTeacherVars
// >(gql`
// mutation RegisterTeacher($maxStudents: Int!) {
// registerTeacher(input: { maxStudents: $maxStudents }) {
// id
// }
// }
// `);
// const registerTeacherMutation = mutation(registerTeacherStore);
// async function registerTeacher(): Promise<void> {
// await registerTeacherMutation({
// maxStudents: $teacherRegisterFormMaxStudents.value,
// });
// if (!$registerTeacherStore.error && $registerTeacherStore.data) {
// location.reload();
// }
// }
// interface RegisterStudentData {
// registerStudent: Student;
// }
// const registerStudentStore = operationStore<RegisterStudentData>(gql`
// mutation RegisterStudent() {
// registerStudent() {
// id
// }
// }
// `);
// const registerStudentMutation = mutation(registerStudentStore);
// async function registerStudent(): Promise<void> {
// await registerStudentMutation();
// if (!$registerStudentStore.error && $registerStudentStore.data) {
// location.reload();
// }
// }
</script>
{#if $meStore.error}
<p style="color: red;">{$meStore.error.message}</p>
{:else if $meStore.fetching}
<p>Laden...</p>
{:else}
<h1>Hey {$meStore.data.me.firstName}!</h1>
<hr />
{#if $meStore.data.me.role === UserRole.TEACHER}
<!-- {#if !$meStore.data.me.teacher}
<p>Registriere dich jetzt als Lehrer:</p>
<form on:submit|preventDefault={registerTeacher}>
<label for="maxStudents">Maximale Schüler:</label>
<br />
<input
type="number"
name="maxStudents"
min="0"
bind:value={$teacherRegisterFormMaxStudents.value}
/>
<br />
<label for="skif">SKIF:</label>
<br />
<input
type="checkbox"
name="skif"
bind:checked={$teacherRegisterFormSkif.value}
/>
<br /><br />
<button type="submit" disabled={!$teacheRegisterForm.valid}
>Registrieren</button
>
</form>
{#if $registerTeacherStore.error}
<p style="color: red;">{$registerTeacherStore.error.message}</p>
{:else if $registerTeacherStore.fetching}
<p>Laden...</p>
{:else if $registerTeacherStore.data}
<p>Registrierung erfolgreich!</p>
{/if}
{/if} -->
{:else if $meStore.data.me.role === UserRole.STUDENT}
<!-- {#if !$meStore.data.me.student}
<p>Registriere dich jetzt als Schüler:</p>
<form on:submit|preventDefault={registerStudent}>
<label for="skif">SKIF:</label>
<br />
<input
type="checkbox"
name="skif"
bind:checked={$registerStudentSkif.value}
/>
<br /><br />
<button type="submit" disabled={!$registerStudentForm.valid}
>Registrieren</button
>
</form>
{#if $registerStudentStore.error}
<p style="color: red;">{$registerStudentStore.error.message}</p>
{:else if $registerStudentStore.fetching}
<p>Laden...</p>
{:else if $registerStudentStore.data}
<p>Registrierung erfolgreich!</p>
{/if}
{/if} -->
<StudentHome />
{/if}
{/if}

View File

@ -1,74 +0,0 @@
<script lang="ts">
import * as svelteForms from "svelte-forms";
import * as validators from "svelte-forms/validators";
import { setCookie, removeCookie } from "typescript-cookie";
import { operationStore, mutation, gql } from "@urql/svelte";
import { session } from "$app/stores";
import { goto } from "$app/navigation";
import * as cookieNames from "$lib/cookieNames";
import type { LoginPayload } from "$lib/graphql";
const username = svelteForms.field("username", undefined, [
validators.required(),
]);
const password = svelteForms.field("password", undefined, [
validators.required(),
]);
const loginForm = svelteForms.form(username, password);
interface Data {
login: LoginPayload;
}
interface Vars {
username: string;
password: string;
}
const loginStore = operationStore<Data, Vars>(gql`
mutation Login($username: String!, $password: String!) {
login(username: $username, password: $password) {
token
}
}
`);
const login = mutation(loginStore);
async function submit(): Promise<void> {
await login({
username: $username.value as string,
password: $password.value as string,
});
if ($loginStore.error) {
removeCookie(cookieNames.TOKEN);
} else {
$session.user.token = $loginStore.data.login.token;
setCookie(cookieNames.TOKEN, $session.user.token, {
expires: 1,
});
goto("/");
}
}
</script>
<form on:submit|preventDefault={submit}>
<label for="username">Benutzernane (Moodle):</label>
<br />
<input type="text" id="username" bind:value={$username.value} />
<br />
<label for="password">Kennwort:</label>
<br />
<input type="password" id="password" bind:value={$password.value} />
<br /><br />
<button type="submit" disabled={!$loginForm.valid}>Login</button>
<br />
{#if $loginStore.error}
<p style="color: red;">{$loginStore.error.message}</p>
{/if}
</form>

View File

@ -1,17 +0,0 @@
import adapter from "@sveltejs/adapter-auto";
import preprocess from "svelte-preprocess";
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://github.com/sveltejs/svelte-preprocess
// for more information about preprocessors
preprocess: preprocess(),
kit: {
adapter: adapter(),
},
optimizeDeps: {
exclude: ["@urql/svelte"],
},
};
export default config;

View File

@ -1,31 +0,0 @@
{
"compilerOptions": {
"moduleResolution": "node",
"module": "es2020",
"lib": ["es2020", "DOM"],
"target": "es2020",
/**
svelte-preprocess cannot figure out whether you have a value or a type, so tell TypeScript
to enforce using \`import type\` instead of \`import\` for Types.
*/
"importsNotUsedAsValues": "error",
"isolatedModules": true,
"resolveJsonModule": true,
/**
To have warnings/errors of the Svelte compiler at the correct position,
enable source maps by default.
*/
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"allowJs": true,
"checkJs": true,
"paths": {
"$lib": ["src/lib"],
"$lib/*": ["src/lib/*"]
}
},
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.ts", "src/**/*.svelte", "src/lib/cookieNames.ts"]
}

File diff suppressed because it is too large Load Diff