Merge pull request 'join_gamenight' (#8) from join_gamenight into main

Reviewed-on: #8
This commit is contained in:
Roflin 2022-06-03 19:47:02 +02:00
commit 5ace39d820
9 changed files with 337 additions and 116 deletions

View File

@ -31,6 +31,8 @@ struct ApiResponse {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
gamenights: Option<Vec<GamenightOutput>>, gamenights: Option<Vec<GamenightOutput>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
gamenight: Option<GamenightOutput>,
#[serde(skip_serializing_if = "Option::is_none")]
games: Option<Vec<Game>>, games: Option<Vec<Game>>,
} }
@ -43,6 +45,7 @@ impl ApiResponse {
message: None, message: None,
user: None, user: None,
gamenights: None, gamenights: None,
gamenight: None,
games: None, games: None,
}; };
@ -52,6 +55,7 @@ impl ApiResponse {
message: Some(Cow::Owned(message)), message: Some(Cow::Owned(message)),
user: None, user: None,
gamenights: None, gamenights: None,
gamenight: None,
games: None, games: None,
} }
} }
@ -65,16 +69,29 @@ impl ApiResponse {
jwt: jwt, jwt: jwt,
}), }),
gamenights: None, gamenights: None,
gamenight: None,
games: None, games: None,
} }
} }
fn gamenight_response(gamenights: Vec<GamenightOutput>) -> Self { fn gamenights_response(gamenights: Vec<GamenightOutput>) -> Self {
Self { Self {
result: Self::SUCCES_RESULT, result: Self::SUCCES_RESULT,
message: None, message: None,
user: None, user: None,
gamenights: Some(gamenights), gamenights: Some(gamenights),
gamenight: None,
games: None,
}
}
fn gamenight_response(gamenight: GamenightOutput) -> Self {
Self {
result: Self::SUCCES_RESULT,
message: None,
user: None,
gamenights: None,
gamenight: Some(gamenight),
games: None, games: None,
} }
} }
@ -85,6 +102,7 @@ impl ApiResponse {
message: None, message: None,
user: None, user: None,
gamenights: None, gamenights: None,
gamenight: None,
games: Some(games), games: Some(games),
} }
} }
@ -140,6 +158,63 @@ pub struct GamenightOutput {
participants: Vec<User>, participants: Vec<User>,
} }
#[derive(Debug, Serialize, Deserialize)]
pub struct GamenightUpdate {
action: String
}
#[patch("/gamenights/<gamenight_id>", format = "application/json", data = "<patch_json>")]
pub async fn patch_gamenight(conn: DbConn, user: User, gamenight_id: String, patch_json: Json<GamenightUpdate>) -> ApiResponseVariant {
let uuid = Uuid::parse_str(&gamenight_id).unwrap();
let patch = patch_json.into_inner();
match patch.action.as_str() {
"RemoveParticipant" => {
let entry = GamenightParticipantsEntry {
gamenight_id: uuid,
user_id: user.id
};
match remove_participant(&conn, entry).await {
Ok(_) => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)),
Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string())))
}
}
"AddParticipant" => {
let entry = GamenightParticipantsEntry {
gamenight_id: uuid,
user_id: user.id
};
match add_participant(&conn, entry).await {
Ok(_) => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)),
Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string())))
}
}
_ => ApiResponseVariant::Value(json!(ApiResponse::SUCCES))
}
}
#[get("/gamenights/<gamenight_id>")]
pub async fn gamenight(conn: DbConn, _user: User, gamenight_id: String) -> ApiResponseVariant {
let uuid = Uuid::parse_str(&gamenight_id).unwrap();
let gamenight = match get_gamenight(&conn, uuid).await {
Ok(result) => result,
Err(err) => return ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
};
let games = match get_games_of_gamenight(&conn, uuid).await {
Ok(result) => result,
Err(err) => return ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
};
let participants = match load_participants(&conn, uuid).await {
Ok(result) => result,
Err(err) => return ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
};
let gamenight_output = GamenightOutput {
gamenight: gamenight,
game_list: games,
participants: participants
};
return ApiResponseVariant::Value(json!(ApiResponse::gamenight_response(gamenight_output)))
}
#[get("/gamenights")] #[get("/gamenights")]
pub async fn gamenights(conn: DbConn, _user: User) -> ApiResponseVariant { pub async fn gamenights(conn: DbConn, _user: User) -> ApiResponseVariant {
let gamenights = match get_all_gamenights(&conn).await { let gamenights = match get_all_gamenights(&conn).await {
@ -164,7 +239,7 @@ pub async fn gamenights(conn: DbConn, _user: User) -> ApiResponseVariant {
.collect(); .collect();
match game_results { match game_results {
Ok(result) => ApiResponseVariant::Value(json!(ApiResponse::gamenight_response(result))), Ok(result) => ApiResponseVariant::Value(json!(ApiResponse::gamenights_response(result))),
Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))), Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
} }
} }
@ -220,7 +295,7 @@ pub async fn gamenights_post_json(
gamenight_id: gamenight_id, gamenight_id: gamenight_id,
user_id: user.id, user_id: user.id,
}; };
match insert_participant(&conn, participant).await { match add_participant(&conn, participant).await {
Ok(_) => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)), Ok(_) => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)),
Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))), Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
} }
@ -375,7 +450,7 @@ pub async fn post_participants(
_user: User, _user: User,
entry_json: Json<GamenightParticipantsEntry>, entry_json: Json<GamenightParticipantsEntry>,
) -> ApiResponseVariant { ) -> ApiResponseVariant {
match insert_participant(&conn, entry_json.into_inner()).await { match add_participant(&conn, entry_json.into_inner()).await {
Ok(_) => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)), Ok(_) => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)),
Err(error) => ApiResponseVariant::Value(json!(ApiResponse::error(error.to_string()))), Err(error) => ApiResponseVariant::Value(json!(ApiResponse::error(error.to_string()))),
} }

View File

@ -50,6 +50,8 @@ async fn rocket() -> _ {
.mount( .mount(
"/api", "/api",
routes![ routes![
api::gamenight,
api::patch_gamenight,
api::gamenights, api::gamenights,
api::gamenights_unauthorized, api::gamenights_unauthorized,
api::gamenights_post_json, api::gamenights_post_json,

View File

@ -110,9 +110,9 @@ pub async fn insert_gamenight(
.await?) .await?)
} }
pub async fn get_gamenight(conn: &DbConn, game_id: Uuid) -> Result<Gamenight, DatabaseError> { pub async fn get_gamenight(conn: &DbConn, gamenight_id: Uuid) -> Result<Gamenight, DatabaseError> {
Ok(conn Ok(conn
.run(move |c| gamenight::table.find(game_id).first(c)) .run(move |c| gamenight::table.find(gamenight_id).first(c))
.await?) .await?)
} }
@ -190,7 +190,7 @@ pub async fn load_participants(
.await?) .await?)
} }
pub async fn insert_participant( pub async fn add_participant(
conn: &DbConn, conn: &DbConn,
participant: GamenightParticipantsEntry, participant: GamenightParticipantsEntry,
) -> Result<usize, DatabaseError> { ) -> Result<usize, DatabaseError> {
@ -198,6 +198,7 @@ pub async fn insert_participant(
.run(move |c| { .run(move |c| {
diesel::insert_into(gamenight_participants::table) diesel::insert_into(gamenight_participants::table)
.values(&participant) .values(&participant)
.on_conflict_do_nothing()
.execute(c) .execute(c)
}) })
.await?) .await?)

View File

@ -5,6 +5,8 @@ import Login from './components/Login';
import Gamenights from './components/Gamenights'; import Gamenights from './components/Gamenights';
import Gamenight from './components/Gamenight'; import Gamenight from './components/Gamenight';
import { get_gamenights, get_games, unpack_api_result, login } from './api/Api';
const localStorageUserKey = 'user'; const localStorageUserKey = 'user';
function App() { function App() {
@ -13,26 +15,16 @@ function App() {
const [gamenights, setGamenights] = useState([]); const [gamenights, setGamenights] = useState([]);
const [flashData, setFlashData] = useState({}); const [flashData, setFlashData] = useState({});
const [games, setGames] = useState([]); const [games, setGames] = useState([]);
const [activeGamenight, setActiveGamenight] = useState(null); const [activeGamenightId, setActiveGamenightId] = useState(null);
const POST_HEADER = {'Content-Type': 'application/json'};
const AUTH_HEADER = {'Authorization': `Bearer ${user?.jwt}`};
const handleLogin = (input) => { const handleLogin = (input) => {
const requestOptions = { unpack_api_result(login(input), setFlashData)
method: 'POST', .then(result => {
headers: { 'Content-Type': 'application/json' }, setUser(result.user);
body: JSON.stringify(input) localStorage.setItem(localStorageUserKey, JSON.stringify(result.user));
};
fetch('api/login', requestOptions)
.then(response => response.json())
.then(data => {
if(data.result === "Ok") {
setUser(data.user);
localStorage.setItem(localStorageUserKey, JSON.stringify(data.user));
} else {
setFlashData({
type: "Error",
message: data.message
});
}
}); });
}; };
@ -50,49 +42,21 @@ function App() {
}; };
const dismissActiveGamenight = () => { const dismissActiveGamenight = () => {
setActiveGamenight(null); setActiveGamenightId(null);
}; };
useEffect(() => { useEffect(() => {
if (user !== null) { if (user !== null) {
const requestOptions = { unpack_api_result(get_gamenights(user.jwt), setFlashData)
method: 'GET', .then(result => setGamenights(result.gamenights));
headers: { 'Authorization': `Bearer ${user.jwt}` },
};
fetch('api/gamenights', requestOptions)
.then(response => response.json())
.then(data => {
if(data.result === "Ok") {
setGamenights(data.gamenights)
} else {
setFlashData({
type: "Error",
message: data.message
});
}
});
} }
}, [user]) }, [user])
useEffect(() => { useEffect(() => {
if (user !== null) { if (user !== null) {
const requestOptions = { unpack_api_result(get_games(user.jwt), setFlashData)
method: 'GET', .then(result => setGames(result.games));
headers: { 'Authorization': `Bearer ${user.jwt}` }, }
};
fetch('api/games', requestOptions)
.then(response => response.json())
.then(data => {
if(data.result === "Ok") {
setGames(data.games)
} else {
setFlashData({
type: "Error",
message: data.message
});
}
});
}
}, [user]) }, [user])
useEffect(() => { useEffect(() => {
@ -108,7 +72,7 @@ function App() {
); );
} else { } else {
let mainview; let mainview;
if(activeGamenight === null) { if(activeGamenightId === null) {
mainview = ( mainview = (
<Gamenights <Gamenights
user={user} user={user}
@ -116,13 +80,15 @@ function App() {
setFlash={setFlash} setFlash={setFlash}
refetchGamenights={refetchGamenights} refetchGamenights={refetchGamenights}
gamenights={gamenights} gamenights={gamenights}
onSelectGamenight={(g) => setActiveGamenight(g)}/> onSelectGamenight={(g) => setActiveGamenightId(g.id)}/>
) )
} else { } else {
mainview = ( mainview = (
<Gamenight <Gamenight
gamenight={activeGamenight} gamenightId={activeGamenightId}
onDismis={dismissActiveGamenight} onDismis={dismissActiveGamenight}
setFlash={setFlash}
user={user}
/>) />)
} }

90
frontend/src/api/Api.js Normal file
View File

@ -0,0 +1,90 @@
import fetchResource from './FetchResource'
export function unpack_api_result(promise, onError) {
promise.then(result => {
if(result.result !== 'Ok') {
onError({
type: 'Error',
message: result.message
});
}
})
.catch(error => {
onError({
type: 'Error',
message: `${error.status} ${error.message}`
});
});
return promise;
}
export function get_gamenights(token) {
return fetchResource('api/gamenights', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
},
});
}
export function get_gamenight(gamenight_id, token) {
return fetchResource(`api/gamenights/${gamenight_id}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
},
});
}
export function post_gamenight(input, token) {
return fetchResource('api/gamenights', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(input)
});
}
export function patch_gamenight(gamenight_id, input, token) {
return fetchResource(`api/gamenights/${gamenight_id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(input)
})
}
export function delete_gamenight(input, token) {
return fetchResource('api/gamenights', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(input)
});
}
export function get_games(token) {
return fetchResource('api/games', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
});
}
export function login(body) {
return fetchResource('api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
}

View File

@ -0,0 +1,73 @@
const API_URL = '';
function ApiError(message, data, status) {
let response = null;
let isObject = false;
try {
response = JSON.parse(data);
isObject = true;
} catch (e) {
response = data;
}
this.response = response;
this.message = message;
this.status = status;
this.toString = function () {
return `${ this.message }\nResponse:\n${ isObject ? JSON.stringify(this.response, null, 2) : this.response }`;
};
}
const fetchResource = (path, userOptions = {}) => {
const defaultOptions = {};
const defaultHeaders = {};
const options = {
...defaultOptions,
...userOptions,
headers: {
...defaultHeaders,
...userOptions.headers,
},
};
const url = `${ API_URL }/${ path }`;
const isFile = options.body instanceof File;
if (options.body && typeof options.body === 'object' && !isFile) {
options.body = JSON.stringify(options.body);
}
let response = null;
return fetch(url, options)
.then(responseObject => {
response = responseObject;
if (response.status === 401) {
}
if (response.status < 200 || response.status >= 300) {
return response.text();
}
return response.json();
})
.then(parsedResponse => {
if (response.status < 200 || response.status >= 300) {
throw parsedResponse;
}
return parsedResponse;
})
.catch(error => {
if (response) {
throw new ApiError(`Request failed with status ${ response.status }.`, error, response.status);
} else {
throw new ApiError(error.toString(), null, 'REQUEST_FAILED');
}
});
};
export default fetchResource;

View File

@ -9,6 +9,7 @@ import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import GameAdder from './GameAdder'; import GameAdder from './GameAdder';
import { post_gamenight, unpack_api_result} from '../api/Api';
function AddGameNight(props) { function AddGameNight(props) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@ -44,32 +45,14 @@ function AddGameNight(props) {
game_list: gameList, game_list: gameList,
} }
const requestOptions = { unpack_api_result(post_gamenight(input, props.user.jwt), props.setFlash)
method: 'POST', .then(result => {
headers: { setExpanded(false);
'Content-Type': 'application/json', setGameName("");
'Authorization': `Bearer ${props.user.jwt}` setDate(null);
},
body: JSON.stringify(input)
};
fetch('api/gamenights', requestOptions)
.then(response => response.json())
.then(data => {
if(data.result !== "Ok") {
props.setFlash({
type: "Error",
message: data.message
});
} else {
setExpanded(false);
setGameName("");
setDate(null);
}
}) })
.then(() => props.refetchGamenights()) .then(() => props.refetchGamenights())
} }
}; };
if(expanded) { if(expanded) {

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import List from '@mui/material/List'; import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem'; import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
@ -6,15 +6,26 @@ import ListSubheader from '@mui/material/ListSubheader';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import moment from 'moment'; import moment from 'moment';
import {unpack_api_result, get_gamenight, patch_gamenight} from '../api/Api';
function Gamenight(props) { function Gamenight(props) {
const [dense, setDense] = useState(true); const [dense, setDense] = useState(true);
const [gamenight, setGamenight] = useState(null);
let games = props.gamenight.game_list.map(g => const fetchGamenight = () => {
if (props.user !== null) {
unpack_api_result(get_gamenight(props.gamenightId, props.user.jwt), props.setFlash)
.then(result => setGamenight(result.gamenight));
}
}
useEffect(fetchGamenight, []);
let games = gamenight?.game_list.map(g =>
( (
<ListItem> <ListItem>
<ListItemText <ListItemText
@ -24,7 +35,7 @@ function Gamenight(props) {
) )
); );
let participants = props.gamenight.participants.map(p => const participants = gamenight?.participants.map(p =>
( (
<ListItem> <ListItem>
<ListItemText <ListItemText
@ -32,7 +43,46 @@ function Gamenight(props) {
/> />
</ListItem> </ListItem>
) )
) );
const Join = () => {
const input = {
action: 'AddParticipant'
};
unpack_api_result(patch_gamenight(gamenight.id, input, props.user.jwt), props.setFlash)
.then(() => fetchGamenight());
};
const Leave = () => {
const input = {
action: 'RemoveParticipant',
};
unpack_api_result(patch_gamenight(gamenight.id, input, props.user.jwt), props.setFlash)
.then(() => fetchGamenight());
};
let join_or_leave_button;
if(gamenight?.participants.find(p => p.id === props.user.id) === undefined) {
join_or_leave_button = (
<Button
variant="outlined"
color="success"
onClick={Join}>
Join
</Button>
)
} else {
join_or_leave_button = (
<Button
variant="outlined"
color="error"
onClick={Leave}>
Leave
</Button>
)
}
return ( return (
<div> <div>
@ -43,10 +93,10 @@ function Gamenight(props) {
</IconButton> </IconButton>
<Typography type="h3"> <Typography type="h3">
{props.gamenight.name} {gamenight?.name}
</Typography> </Typography>
<Typography type="body1"> <Typography type="body1">
When: {moment(props.gamenight.datetime).format('LL HH:mm')} When: {moment(gamenight?.datetime).format('LL HH:mm')}
</Typography> </Typography>
<List <List
@ -69,6 +119,7 @@ function Gamenight(props) {
}> }>
{participants} {participants}
</List> </List>
{join_or_leave_button}
</div> </div>
) )
} }

View File

@ -9,35 +9,15 @@ import GamesIcon from '@mui/icons-material/Games';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import AddGameNight from './AddGameNight'; import AddGameNight from './AddGameNight';
import {delete_gamenight, unpack_api_result} from '../api/Api';
function Gamenights(props) { function Gamenights(props) {
const [dense, setDense] = React.useState(false); const [dense, setDense] = React.useState(false);
const DeleteGamenight = (gameId) => { const DeleteGamenight = (game_id) => {
if (props.user !== null) { if (props.user !== null) {
let input = { const input = { game_id: game_id };
game_id: gameId, unpack_api_result(delete_gamenight(input, props.user.jwt), props.setFlash)
}
const requestOptions = {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${props.user.jwt}`
},
body: JSON.stringify(input)
};
fetch('api/gamenights', requestOptions)
.then(response => response.json())
.then(data => {
if(data.result !== "Ok") {
props.setFlash({
type: "Error",
message: data.message
});
}
})
.then(() => props.refetchGamenights()); .then(() => props.refetchGamenights());
} }
} }