Add db edit feature for admin

This commit is contained in:
pikiou 2022-05-24 15:30:15 +02:00
parent df33d3a951
commit da643df6a6
34 changed files with 593 additions and 276 deletions

View File

@ -0,0 +1,34 @@
import { FC, memo } from "react"
import { useSelector } from "react-redux"
import withUserConnected from "../../utils/withUserConnected"
import { fetchVolunteerListIfNeed, selectVolunteerList } from "../../store/volunteerList"
import withUserRole from "../../utils/withUserRole"
import ROLES from "../../utils/roles.constants"
import MemberEdit from "./MemberEdit"
import useAction from "../../utils/useAction"
import { fetchVolunteerSetIfNeed } from "../../store/volunteerSet"
import { Volunteer } from "../../services/volunteers"
import styles from "./styles.module.scss"
const DbEdit: FC = (): JSX.Element => {
const volunteers = useSelector(selectVolunteerList)
const saveVolunteer = useAction(fetchVolunteerSetIfNeed)
if (!volunteers) {
return <>No member found</>
}
return (
<ul className={styles.list}>
{volunteers.map((volunteer: Volunteer) => (
<MemberEdit
key={volunteer.id}
saveVolunteer={saveVolunteer}
volunteer={volunteer}
/>
))}
</ul>
)
}
export default withUserRole(ROLES.ADMIN, memo(withUserConnected(DbEdit)))
export const fetchFor = [fetchVolunteerListIfNeed]

View File

@ -0,0 +1,31 @@
import { FC, memo, useCallback } from "react"
import { useSelector } from "react-redux"
import withUserConnected from "../../utils/withUserConnected"
import withUserRole from "../../utils/withUserRole"
import ROLES from "../../utils/roles.constants"
import { fetchGameDetailsUpdate } from "../../store/gameDetailsUpdate"
import { selectUserJwtToken } from "../../store/auth"
import useAction from "../../utils/useAction"
import styles from "./styles.module.scss"
import FormButton from "../Form/FormButton/FormButton"
const GameDetailsUpdate: FC = (): JSX.Element => {
const jwtToken = useSelector(selectUserJwtToken)
const save = useAction(fetchGameDetailsUpdate)
const onSubmit = useCallback(async () => {
await save(jwtToken)
}, [save, jwtToken])
return (
<>
<div className={styles.formButtons}>
<FormButton onClick={onSubmit}>Ok, noté</FormButton>
</div>
</>
)
}
export default withUserRole(ROLES.ADMIN, memo(withUserConnected(GameDetailsUpdate)))
export const fetchFor = [fetchGameDetailsUpdate]

View File

@ -0,0 +1,117 @@
import { isFinite } from "lodash"
import { FC, memo, useState } from "react"
import withUserConnected from "../../utils/withUserConnected"
import withUserRole from "../../utils/withUserRole"
import ROLES from "../../utils/roles.constants"
import { Volunteer } from "../../services/volunteers"
import styles from "./styles.module.scss"
import { toastError } from "../../store/utils"
interface Props {
volunteer: Volunteer
saveVolunteer: (newVolunteer: Partial<Volunteer>) => void
}
const MemberEdit: FC<Props> = ({ volunteer, saveVolunteer }): JSX.Element => {
const [localVolunteer, setLocalVolunteer] = useState(volunteer)
const stringDispatch =
(propName: string) =>
(e: React.ChangeEvent<HTMLInputElement>): void => {
saveVolunteer({ id: localVolunteer.id, [propName]: e.target.value })
setLocalVolunteer({ ...localVolunteer, [propName]: e.target.value })
}
function stringInput(id: string, value: string): JSX.Element {
return (
<div key={id} className={styles.inputContainer}>
<span className={styles.inputDesc}>{id}</span>
<br />
<input
type="text"
id={id}
value={value}
onChange={stringDispatch(id)}
className={styles.stringInput}
/>
</div>
)
}
const numberDispatch =
(propName: string) =>
(e: React.ChangeEvent<HTMLInputElement>): void => {
const value: number = +e.target.value
if (!isFinite(value)) {
toastError("Should be a number")
return
}
saveVolunteer({ id: localVolunteer.id, [propName]: +value })
setLocalVolunteer({ ...localVolunteer, [propName]: +value })
}
function numberInput(id: string, value: number): JSX.Element {
return (
<div key={id} className={styles.inputContainer}>
<span className={styles.inputDesc}>{id}</span>
<br />
<input
type="text"
id={id}
value={value}
onChange={numberDispatch(id)}
className={styles.numberInput}
/>
</div>
)
}
const booleanDispatch =
(propName: string) =>
(e: React.ChangeEvent<HTMLInputElement>): void => {
const value: boolean = e.target.value !== "0" && e.target.value !== ""
saveVolunteer({ id: localVolunteer.id, [propName]: value })
setLocalVolunteer({ ...localVolunteer, [propName]: value })
}
function booleanInput(id: string, value: boolean): JSX.Element {
return (
<div key={id} className={styles.inputContainer}>
<span className={styles.inputDesc}>{id}</span>
<br />
<input
type="text"
id={id}
value={value ? "X" : ""}
onChange={booleanDispatch(id)}
className={styles.booleanInput}
/>
</div>
)
}
const volunteerDefault = new Volunteer()
const typeHandler: { [id: string]: (id: string, value: any) => JSX.Element } = {
string: stringInput,
number: numberInput,
boolean: booleanInput,
}
const keys = Object.keys(volunteerDefault) as (keyof Volunteer)[]
return (
<li className={styles.item} key={volunteer.id}>
{keys.map((key) => {
const valueType = typeof volunteerDefault[key]
const value = localVolunteer[key]
return (
typeHandler[valueType as string]?.(key, value as any) ||
stringInput(key, value as any)
)
})}
</li>
)
}
export default withUserRole(ROLES.ADMIN, memo(withUserConnected(MemberEdit)))
export const fetchFor = []

View File

@ -0,0 +1,39 @@
@import "../../theme/variables";
@import "../../theme/mixins";
.title {
padding-bottom: 10px;
font-weight: bold;
}
.list {
padding: 0;
list-style: none;
}
.item {
padding: 10px 0;
}
.formButtons {
margin-top: 10px;
padding: 5px 0;
text-align: center;
}
.inputContainer {
display: inline-block;
}
.stringInput {
width: 5em;
}
.numberInput {
width: 3em;
}
.booleanInput {
width: 1.5em;
}
.inputDesc {
font-size: small;
}

View File

@ -29,8 +29,6 @@ describe("<List />", () => {
"5": {
id: 5,
title: "6 qui prend!",
author: "Wolfgang Kramer",
editor: "(uncredited) , Design Edge , B",
playersMin: 2,
playersMax: 10,
duration: 45,

View File

@ -10,7 +10,7 @@ const KnowledgeIntro: React.FC = (): JSX.Element => (
</p>
<p>
OK signifie que tu peux expliquer le jeu, avec au maximum un coup d'oeil aux règles sur
un nombre de cartes à distribuer, ou un nombre de PV déclancheur de fin de partie.
un nombre de cartes à distribuer, ou un nombre de PV déclencheur de fin de partie.
<br />
Bof signifie que tu seras plus utile que la lecture des règles.
</p>

View File

@ -1,4 +1,4 @@
import React, { memo, useEffect, useState } from "react"
import { memo, useEffect, useState } from "react"
import { useSelector, shallowEqual } from "react-redux"
import { toast } from "react-toastify"
import _ from "lodash"
@ -11,6 +11,12 @@ import { fetchVolunteerPartialAdd } from "../../store/volunteerPartialAdd"
import FormButton from "../Form/FormButton/FormButton"
import { validEmail } from "../../utils/standardization"
import { toastError } from "../../store/utils"
import {
sendBooleanRadioboxDispatch,
sendTextareaDispatch,
sendRadioboxDispatch,
sendTextDispatch,
} from "../input.utils"
import {
fetchMiscMeetingDateListIfNeed,
selectMiscMeetingDateList,
@ -57,26 +63,6 @@ const RegisterForm = ({ dispatch }: Props): JSX.Element => {
}, [changingBackground, setChangingBackground])
const transitionClass = (i: number) => animations[changingBackground][i - 1]
const sendTextDispatch =
(dispatchSetter: React.Dispatch<React.SetStateAction<string>>) =>
(e: React.ChangeEvent<HTMLInputElement>) =>
dispatchSetter(e.target.value)
const sendTextareaDispatch =
(dispatchSetter: React.Dispatch<React.SetStateAction<string>>) =>
(e: React.ChangeEvent<HTMLTextAreaElement>) =>
dispatchSetter(e.target.value)
const sendBooleanRadioboxDispatch =
(dispatchSetter: React.Dispatch<React.SetStateAction<boolean>>, isYes: boolean) =>
(e: React.ChangeEvent<HTMLInputElement>) =>
dispatchSetter(isYes ? !!e.target.value : !e.target.value)
const sendRadioboxDispatch =
(dispatchSetter: React.Dispatch<React.SetStateAction<string>>) =>
(e: React.ChangeEvent<HTMLInputElement>) =>
dispatchSetter(e.target.value)
const onSubmit = () => {
if (!validEmail(email)) {
toastError("Cet email est invalid ><")

View File

@ -1,21 +0,0 @@
/**
* @jest-environment jsdom
*/
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { volunteerExample } from "../../../services/volunteers"
import VolunteerSet from "../index"
describe("<SetVolunteer />", () => {
it("renders", () => {
const dispatch = jest.fn()
const tree = render(
<MemoryRouter>
<VolunteerSet dispatch={dispatch} volunteer={volunteerExample} />
</MemoryRouter>
).container.firstChild
expect(tree).toMatchSnapshot()
})
})

View File

@ -1,51 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SetVolunteer /> renders 1`] = `
<section
class="VolunteerList"
>
<h2>
Modifier un volunteer
</h2>
<form>
<label
for="postFirstname"
>
Prénom:
<input
id="postFirstname"
name="postFirstname"
type="text"
value="Aupeix"
/>
</label>
<label
for="postName"
>
Nom:
<input
id="postName"
name="postName"
type="text"
value="Amélie"
/>
</label>
<label
for="postAdult"
>
Majeur:
<input
id="postAdult"
name="postAdult"
type="text"
value="1"
/>
</label>
<button
type="button"
>
Save changes
</button>
</form>
</section>
`;

View File

@ -1,88 +0,0 @@
import React, { useState, memo } from "react"
import { toast } from "react-toastify"
import { AppDispatch } from "../../store"
import { fetchVolunteerSet } from "../../store/volunteerSet"
import { Volunteer } from "../../services/volunteers"
import styles from "./styles.module.scss"
interface Props {
dispatch: AppDispatch
volunteer: Volunteer
}
const VolunteerSet = ({ dispatch, volunteer }: Props) => {
const [firstname, setFirstname] = useState(volunteer.firstname)
const [lastname, setName] = useState(volunteer.lastname)
const [adult, setAdult] = useState(volunteer.adult)
const onFirstnameChanged = (e: React.ChangeEvent<HTMLInputElement>) =>
setFirstname(e.target.value)
const onNameChanged = (e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)
const onAdultChanged = (e: React.ChangeEvent<HTMLInputElement>) => setAdult(+e.target.value)
const onSavePostClicked = () => {
if (firstname && lastname) {
dispatch(
fetchVolunteerSet({
...volunteer,
firstname,
lastname,
adult,
})
)
} else {
toast.warning("Il faut au moins préciser un prenom et un nom", {
position: "top-center",
autoClose: 6000,
hideProgressBar: true,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
})
}
}
return (
<section className={styles.VolunteerList}>
<h2>Modifier un volunteer</h2>
<form>
<label htmlFor="postFirstname">
Prénom:
<input
type="text"
id="postFirstname"
name="postFirstname"
value={firstname}
onChange={onFirstnameChanged}
/>
</label>
<label htmlFor="postName">
Nom:
<input
type="text"
id="postName"
name="postName"
value={lastname}
onChange={onNameChanged}
/>
</label>
<label htmlFor="postAdult">
Majeur:
<input
type="text"
id="postAdult"
name="postAdult"
value={adult}
onChange={onAdultChanged}
/>
</label>
<button type="button" onClick={onSavePostClicked}>
Save changes
</button>
</form>
</section>
)
}
export default memo(VolunteerSet)

View File

@ -1,17 +0,0 @@
@import "../../theme/variables";
.VolunteerList {
color: $color-white;
ul {
padding-left: 17px;
}
li {
margin-bottom: 0.5em;
}
a {
color: $color-white;
}
}

View File

@ -1,4 +1,6 @@
import AnnouncementLink from "./AnnouncementLink"
import DbEdit, { fetchFor as fetchForDbEdit } from "./Admin/DbEdit"
import GameDetailsUpdate, { fetchFor as fetchForGameDetailsUpdate } from "./Admin/GameDetailsUpdate"
import Board, { fetchFor as fetchForBoard } from "./VolunteerBoard/Board"
import DayWishesForm, {
fetchFor as fetchForDayWishesForm,
@ -20,11 +22,14 @@ import TeamWishesForm, {
} from "./VolunteerBoard/TeamWishesForm/TeamWishesForm"
import VolunteerList from "./VolunteerList"
import VolunteerInfo from "./VolunteerInfo"
import VolunteerSet from "./VolunteerSet"
import WishAdd from "./WishAdd"
export {
AnnouncementLink,
DbEdit,
fetchForDbEdit,
GameDetailsUpdate,
fetchForGameDetailsUpdate,
Board,
fetchForBoard,
BoxList,
@ -48,6 +53,5 @@ export {
fetchForTeamWishesForm,
VolunteerInfo,
VolunteerList,
VolunteerSet,
WishAdd,
}

22
src/components/input.utils.ts Executable file
View File

@ -0,0 +1,22 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import React from "react"
export const sendTextDispatch =
(dispatchSetter: React.Dispatch<React.SetStateAction<string>>) =>
(e: React.ChangeEvent<HTMLInputElement>) =>
dispatchSetter(e.target.value)
export const sendTextareaDispatch =
(dispatchSetter: React.Dispatch<React.SetStateAction<string>>) =>
(e: React.ChangeEvent<HTMLTextAreaElement>) =>
dispatchSetter(e.target.value)
export const sendBooleanRadioboxDispatch =
(dispatchSetter: React.Dispatch<React.SetStateAction<boolean>>, isYes: boolean) =>
(e: React.ChangeEvent<HTMLInputElement>) =>
dispatchSetter(isYes ? !!e.target.value : !e.target.value)
export const sendRadioboxDispatch =
(dispatchSetter: React.Dispatch<React.SetStateAction<string>>) =>
(e: React.ChangeEvent<HTMLInputElement>) =>
dispatchSetter(e.target.value)

View File

@ -0,0 +1,28 @@
import { FC, memo } from "react"
import { RouteComponentProps } from "react-router-dom"
import { useSelector } from "react-redux"
import { AppThunk } from "../../../store"
import { selectUserJwtToken } from "../../../store/auth"
import { DbEdit, fetchForDbEdit } from "../../../components"
export type Props = RouteComponentProps
const DbEditPage: FC<Props> = (): JSX.Element => {
const jwtToken = useSelector(selectUserJwtToken)
if (jwtToken === undefined) return <p>Loading...</p>
if (jwtToken) {
return (
<>
<DbEdit />
</>
)
}
return <div>Besoin d'être identifié</div>
}
// Fetch server-side data here
export const loadData = (): AppThunk[] => [...fetchForDbEdit.map((f) => f())]
export default memo(DbEditPage)

View File

@ -0,0 +1,16 @@
import loadable from "@loadable/component"
import { Loading, ErrorBoundary } from "../../../components"
import { Props, loadData } from "./DbEdit"
const DbEditPage = loadable(() => import("./DbEdit"), {
fallback: <Loading />,
})
export default (props: Props): JSX.Element => (
<ErrorBoundary>
<DbEditPage {...props} />
</ErrorBoundary>
)
export { loadData }

View File

@ -0,0 +1,29 @@
import { FC, memo } from "react"
import { RouteComponentProps } from "react-router-dom"
import { useSelector } from "react-redux"
import { AppThunk } from "../../../store"
import { selectUserJwtToken } from "../../../store/auth"
import { GameDetailsUpdate } from "../../../components"
import { fetchGameDetailsUpdateIfNeed } from "../../../store/gameDetailsUpdate"
export type Props = RouteComponentProps
const GameDetailsUpdatePage: FC<Props> = (): JSX.Element => {
const jwtToken = useSelector(selectUserJwtToken)
if (jwtToken === undefined) return <p>Loading...</p>
if (jwtToken) {
return (
<>
<GameDetailsUpdate />
</>
)
}
return <div>Besoin d'être identifié</div>
}
// Fetch server-side data here
export const loadData = (): AppThunk[] => [...[fetchGameDetailsUpdateIfNeed].map((f) => f())]
export default memo(GameDetailsUpdatePage)

View File

@ -0,0 +1,16 @@
import loadable from "@loadable/component"
import { Loading, ErrorBoundary } from "../../../components"
import { Props, loadData } from "./GameDetailsUpdate"
const GameDetailsUpdatePage = loadable(() => import("./GameDetailsUpdate"), {
fallback: <Loading />,
})
export default (props: Props): JSX.Element => (
<ErrorBoundary>
<GameDetailsUpdatePage {...props} />
</ErrorBoundary>
)
export { loadData }

View File

@ -1,6 +1,10 @@
import { RouteConfig } from "react-router-config"
import App from "../app"
import AsyncDbEdit, { loadData as loadDbEdit } from "../pages/Admin/DbEdit"
import AsyncGameDetailsUpdate, {
loadData as loadGameDetailsUpdate,
} from "../pages/Admin/GameDetailsUpdate"
import AsyncHome, { loadData as loadHomeData } from "../pages/Home"
import AsyncAnnouncements, { loadData as loadAnnouncementsData } from "../pages/Announcements"
import AsyncTeamAssignment, { loadData as loadTeamAssignmentData } from "../pages/TeamAssignment"
@ -24,6 +28,16 @@ export default [
component: AsyncHome,
loadData: loadHomeData,
},
{
path: "/edit",
component: AsyncDbEdit,
loadData: loadDbEdit,
},
{
path: "/updateGameDetails",
component: AsyncGameDetailsUpdate,
loadData: loadGameDetailsUpdate,
},
{
path: "/connaissances",
component: AsyncKnowledge,

View File

@ -1,6 +1,6 @@
// eslint-disable-next-line max-classes-per-file
import path from "path"
import _ from "lodash"
import _, { assign, pick } from "lodash"
import { promises as fs, constants } from "fs"
import { GoogleSpreadsheet, GoogleSpreadsheetWorksheet } from "google-spreadsheet"
import { SheetNames, saveLocalDb, loadLocalDb } from "./localDb"
@ -187,6 +187,33 @@ export class Sheet<
this._type = db.type as Record<keyof Element, string>
}
parseRawPartialElement(
rawPartialElement: Partial<Record<keyof Element, string>>
): Partial<Element> | undefined {
if (this._type === undefined) {
return undefined
}
// else
const rawPartialFrenchElement = _.mapValues(
this.invertedTranslation,
(englishProp: string) => (rawPartialElement as any)[englishProp]
) as Element
const rawFrenchElement = this.stringifyElement(this.frenchSpecimen, this._type)
assign(rawFrenchElement, rawPartialFrenchElement)
const frenchElement = this.parseElement(rawFrenchElement, this._type)
const element = _.mapValues(
this.translation,
(frenchProp: string) => (frenchElement as any)[frenchProp]
) as Element
const partialElement = pick(element, Object.keys(rawPartialElement))
return partialElement
}
dbSave(): void {
this.saveTimestamp = +new Date()

View File

@ -39,6 +39,12 @@ export default class ExpressAccessors<
return this.sheet as Sheet<ElementNoId, Element>
}
parseRawPartialElement(
rawPartialElement: Partial<Record<keyof Element, string>>
): Partial<Element> | undefined {
return this.sheet?.parseRawPartialElement(rawPartialElement)
}
listGet() {
return async (
_request: Request,
@ -56,6 +62,45 @@ export default class ExpressAccessors<
}
}
listSet(
custom?: (
list: Element[],
body: RequestBody,
id: number,
roles: string[]
) => Promise<CustomSetReturn<Element[]>> | CustomSetReturn<Element[]>
) {
return async (request: Request, response: Response, _next: NextFunction): Promise<void> => {
try {
const sheet = await this.getSheet()
if (!custom) {
await sheet.setList(request.body)
response.status(200)
} else {
const memberId = response?.locals?.jwt?.id || -1
const roles: string[] = response?.locals?.jwt?.roles || []
const list = (await sheet.getList()) || []
const { toDatabase, toCaller } = await custom(
list,
request.body,
memberId,
roles
)
if (toDatabase !== undefined) {
await sheet.setList(toDatabase)
}
if (toCaller !== undefined) {
response.status(200).json(toCaller)
} else {
response.status(200)
}
}
} catch (e: any) {
response.status(200).json({ error: e.message })
}
}
}
// custom can be async
get<Ret = Element>(
custom?: (

View File

@ -1,3 +1,4 @@
import { cloneDeep } from "lodash"
import ExpressAccessors from "./expressAccessors"
import { Game, GameWithoutId, translationGame } from "../../services/games"
@ -11,3 +12,21 @@ export const gameListGet = expressAccessor.listGet()
// export const gameGet = expressAccessor.get()
// export const gameAdd = expressAccessor.add()
// export const gameSet = expressAccessor.set()
export const gameDetailsUpdate = expressAccessor.listSet(async (list, _body, _id, roles) => {
if (!roles.includes("admin")) {
throw Error(
`À moins d'être admin, on ne peut pas modifier n'importe quel jeu, ${JSON.stringify(
roles
)}`
)
}
const newList = cloneDeep(list)
// TODO update game list details from BGG
return {
toDatabase: newList,
toCaller: newList,
}
})

View File

@ -1,4 +1,4 @@
import { assign, cloneDeep, keys, omit, pick } from "lodash"
import { assign, cloneDeep, omit, pick } from "lodash"
import bcrypt from "bcrypt"
import sgMail from "@sendgrid/mail"
@ -30,7 +30,30 @@ export const volunteerListGet = expressAccessor.get(async (list, _body, id) => {
}
return list
})
export const volunteerSet = expressAccessor.set()
export const volunteerSet = expressAccessor.set(async (list, body, _id, roles) => {
if (!roles.includes("admin")) {
throw Error(`À moins d'être admin, on ne peut pas modifier n'importe quel utilisateur`)
}
const newPartialVolunteer = body[0] as Partial<Record<keyof Volunteer, string>> & { id: number }
const volunteer: Volunteer | undefined = list.find((v) => v.id === newPartialVolunteer.id)
if (!volunteer) {
throw Error(`Il n'y a aucun bénévole avec cet identifiant ${newPartialVolunteer.id}`)
}
const newVolunteer: Volunteer = cloneDeep(volunteer)
const parsedPartialVolunteer = expressAccessor.parseRawPartialElement(newPartialVolunteer)
if (parsedPartialVolunteer === undefined) {
throw Error(`Erreur au parsing dans volunteerSet`)
}
assign(newVolunteer, parsedPartialVolunteer)
return {
toDatabase: newVolunteer,
toCaller: newVolunteer,
}
})
export const volunteerDiscordId = expressAccessor.get(async (list, body, id) => {
const requestedId = +body[0] || id
@ -132,7 +155,7 @@ export const volunteerLogin = expressAccessor.get<VolunteerLogin>(async (list, b
const lastForgot: { [id: string]: number } = {}
export const volunteerForgot = expressAccessor.set(async (list, bodyArray) => {
const [body] = bodyArray
const volunteer = getByEmail(list, body.email)
const volunteer: Volunteer | undefined = getByEmail(list, body.email)
if (!volunteer) {
throw Error("Il n'y a aucun bénévole avec cet email")
}
@ -199,15 +222,17 @@ export const volunteerAsksSet = expressAccessor.set(async (list, body, id) => {
}
const newVolunteer = cloneDeep(volunteer)
assign(newVolunteer, pick(notifChanges, keys(newVolunteer)))
if (notifChanges.teamWishes !== undefined) newVolunteer.hiddenAsks = notifChanges.hiddenAsks
if (notifChanges.acceptsNotifs !== undefined)
newVolunteer.acceptsNotifs = notifChanges.acceptsNotifs
if (notifChanges.pushNotifSubscription !== undefined)
newVolunteer.pushNotifSubscription = notifChanges.pushNotifSubscription
return {
toDatabase: newVolunteer,
toCaller: {
id: newVolunteer.id,
firstname: newVolunteer.firstname,
adult: newVolunteer.adult,
active: newVolunteer.active,
hiddenAsks: newVolunteer.hiddenAsks,
pushNotifSubscription: newVolunteer.pushNotifSubscription,
acceptsNotifs: newVolunteer.acceptsNotifs,
@ -223,11 +248,11 @@ export const volunteerTeamWishesSet = expressAccessor.set(async (list, body, id,
)
}
const wishes = body[1] as VolunteerTeamWishes
const volunteer = list.find((v) => v.id === requestedId)
const volunteer: Volunteer | undefined = list.find((v) => v.id === requestedId)
if (!volunteer) {
throw Error(`Il n'y a aucun bénévole avec cet identifiant ${requestedId}`)
}
const newVolunteer = cloneDeep(volunteer)
const newVolunteer: Volunteer = cloneDeep(volunteer)
if (wishes.teamWishes !== undefined) {
newVolunteer.teamWishes = wishes.teamWishes
@ -252,7 +277,7 @@ export const volunteerDayWishesSet = expressAccessor.set(async (list, body, id)
throw Error(`On ne peut acceder qu'à ses propres envies de jours`)
}
const wishes = body[1] as VolunteerDayWishes
const volunteer = list.find((v) => v.id === requestedId)
const volunteer: Volunteer | undefined = list.find((v) => v.id === requestedId)
if (!volunteer) {
throw Error(`Il n'y a aucun bénévole avec cet identifiant ${requestedId}`)
}
@ -318,10 +343,8 @@ export const volunteerParticipationDetailsSet = expressAccessor.set(async (list,
}
})
export const volunteerTeamAssignSet = expressAccessor.set(async (list, body, id) => {
const requestedId = +body[0] || id
const assigner = list.find((v) => v.id === requestedId)
if (!assigner || !assigner.roles.includes("répartiteur")) {
export const volunteerTeamAssignSet = expressAccessor.set(async (list, body, _id, roles) => {
if (!roles.includes("répartiteur")) {
throw Error(`Vous n'avez pas les droits pas assigner les équipes.`)
}

View File

@ -19,7 +19,7 @@ import certbotRouter from "../routes/certbot"
import { hasSecret, secure } from "./secure"
import { announcementListGet } from "./gsheets/announcements"
import { detailedBoxListGet } from "./gsheets/boxes"
import { gameListGet } from "./gsheets/games"
import { gameListGet, gameDetailsUpdate } from "./gsheets/games"
import { postulantAdd } from "./gsheets/postulants"
import { teamListGet } from "./gsheets/teams"
import {
@ -100,7 +100,6 @@ app.get("/VolunteerListGet", secure as RequestHandler, volunteerListGet)
// Secured APIs
app.get("/AnnouncementListGet", secure as RequestHandler, announcementListGet)
app.get("/MiscDiscordInvitationGet", secure as RequestHandler, miscDiscordInvitation)
app.post("/VolunteerSet", secure as RequestHandler, volunteerSet)
app.get("/TeamListGet", teamListGet)
app.get("/VolunteerDiscordId", secure as RequestHandler, volunteerDiscordId)
app.post("/VolunteerAsksSet", secure as RequestHandler, volunteerAsksSet)
@ -114,6 +113,10 @@ app.post("/VolunteerDayWishesSet", secure as RequestHandler, volunteerDayWishesS
app.post("/VolunteerTeamWishesSet", secure as RequestHandler, volunteerTeamWishesSet)
app.post("/VolunteerTeamAssignSet", secure as RequestHandler, volunteerTeamAssignSet)
// Admin only
app.post("/VolunteerSet", secure as RequestHandler, volunteerSet)
app.get("/GameDetailsUpdate", secure as RequestHandler, gameDetailsUpdate)
// Push notification subscription
app.post("/notifications/subscribe", notificationsSubscribe)

View File

@ -3,10 +3,6 @@ export class Game {
title = ""
author = ""
editor = ""
playersMin = 0
playersMax = 0
@ -27,8 +23,6 @@ export class Game {
export const translationGame: { [k in keyof Game]: string } = {
id: "id",
title: "titre",
author: "auteur",
editor: "editeur",
playersMin: "minJoueurs",
playersMax: "maxJoueurs",
duration: "duree",

View File

@ -7,3 +7,5 @@ export const gameListGet = serviceAccessors.listGet()
// export const gameGet = serviceAccessors.get()
// export const gameAdd = serviceAccessors.add()
// export const gameSet = serviceAccessors.set()
export const gameDetailsUpdate = serviceAccessors.securedCustomGet<[], Game[]>("DetailsUpdate")

View File

@ -163,8 +163,6 @@ export interface VolunteerDiscordId {
export interface VolunteerAsks {
id: Volunteer["id"]
firstname: Volunteer["firstname"]
adult: Volunteer["adult"]
active: Volunteer["active"]
hiddenAsks: Volunteer["hiddenAsks"]
pushNotifSubscription: Volunteer["pushNotifSubscription"]
acceptsNotifs: Volunteer["acceptsNotifs"]

View File

@ -20,7 +20,8 @@ export const volunteerDiscordIdGet = serviceAccessors.securedCustomGet<
VolunteerDiscordId
>("DiscordId")
export const volunteerPartialAdd = serviceAccessors.customPost<[Partial<Volunteer>]>("PartialAdd")
export const volunteerSet = serviceAccessors.set()
export const volunteerSet = serviceAccessors.securedCustomPost<[Partial<Volunteer>]>("Set")
export const volunteerLogin =
serviceAccessors.customPost<[{ email: string; password: string }]>("Login")

View File

@ -17,8 +17,6 @@ const mockData: Game[] = [
{
id: 5,
title: "6 qui prend!",
author: "Wolfgang Kramer",
editor: "(uncredited) , Design Edge , B",
playersMin: 2,
playersMax: 10,
duration: 45,

View File

@ -0,0 +1,50 @@
import { PayloadAction, createSlice, createEntityAdapter } from "@reduxjs/toolkit"
import { StateRequest, toastError, toastSuccess, elementListFetch } from "./utils"
import { Game } from "../services/games"
import { gameDetailsUpdate } from "../services/gamesAccessors"
import { AppState, AppThunk } from "."
const gameAdapter = createEntityAdapter<Game>()
const gameDetailsUpdateSlice = createSlice({
name: "gameDetailsUpdate",
initialState: gameAdapter.getInitialState({
readyStatus: "idle",
} as StateRequest),
reducers: {
getRequesting: (state) => {
state.readyStatus = "request"
},
getSuccess: (state, { payload }: PayloadAction<Game[]>) => {
state.readyStatus = "success"
gameAdapter.setAll(state, payload)
},
getFailure: (state, { payload }: PayloadAction<string>) => {
state.readyStatus = "failure"
state.error = payload
},
},
})
export default gameDetailsUpdateSlice.reducer
export const { getRequesting, getSuccess, getFailure } = gameDetailsUpdateSlice.actions
export const fetchGameDetailsUpdate = elementListFetch(
gameDetailsUpdate,
getRequesting,
getSuccess,
getFailure,
(error: Error) => toastError(`Erreur lors de la modification d'un bénévole: ${error.message}`),
() => toastSuccess("Bénévole modifié !")
)
const shouldFetchGameDetailsUpdate = (state: AppState) =>
state.volunteerList.readyStatus !== "success"
export const fetchGameDetailsUpdateIfNeed = (): AppThunk => (dispatch, getState) => {
const { jwt } = getState().auth
if (shouldFetchGameDetailsUpdate(getState())) return dispatch(fetchGameDetailsUpdate(jwt))
return null
}

View File

@ -5,6 +5,7 @@ import announcementList from "./announcementList"
import auth from "./auth"
import boxList from "./boxList"
import gameList from "./gameList"
import gameDetailsUpdate from "./gameDetailsUpdate"
import miscDiscordInvitation from "./miscDiscordInvitation"
import miscMeetingDateList from "./miscMeetingDateList"
import postulantAdd from "./postulantAdd"
@ -32,6 +33,7 @@ export default (history: History) => ({
auth,
boxList,
gameList,
gameDetailsUpdate,
miscDiscordInvitation,
miscMeetingDateList,
postulantAdd,

View File

@ -118,33 +118,6 @@ export function elementListFetch<Element, ServiceInput extends Array<any>>(
}
}
export function elementSet<Element>(
elementSetService: (element: Element) => Promise<{
data?: Element | undefined
error?: Error | undefined
}>,
getRequesting: ActionCreatorWithoutPayload<string>,
getSuccess: ActionCreatorWithPayload<Element, string>,
getFailure: ActionCreatorWithPayload<string, string>,
errorMessage?: (error: Error) => void,
successMessage?: () => void
): (element: Element) => AppThunk {
return (element: Element): AppThunk =>
async (dispatch) => {
dispatch(getRequesting())
const { error, data } = await elementSetService(element)
if (error) {
dispatch(getFailure(error.message))
errorMessage?.(error)
} else {
dispatch(getSuccess(data as Element))
successMessage?.()
}
}
}
export function elementValueFetch<Element>(
elementListService: () => Promise<{
data?: Element | undefined

View File

@ -1,35 +1,38 @@
import { PayloadAction, createSlice, createEntityAdapter } from "@reduxjs/toolkit"
import { PayloadAction, createSlice, createSelector } from "@reduxjs/toolkit"
import { StateRequest, toastError, toastSuccess, elementSet } from "./utils"
import { StateRequest, toastError, toastSuccess, elementFetch } from "./utils"
import { Volunteer } from "../services/volunteers"
import { volunteerSet } from "../services/volunteersAccessors"
import { AppState, AppThunk } from "."
const volunteerAdapter = createEntityAdapter<Volunteer>()
type StateVolunteer = { entity?: Volunteer } & StateRequest
export const initialState: StateVolunteer = {
readyStatus: "idle",
}
const volunteerSetSlice = createSlice({
name: "volunteerSet",
initialState: volunteerAdapter.getInitialState({
readyStatus: "idle",
} as StateRequest),
initialState,
reducers: {
getRequesting: (state) => {
state.readyStatus = "request"
},
getSuccess: (state, { payload }: PayloadAction<Volunteer>) => {
state.readyStatus = "success"
volunteerAdapter.setOne(state, payload)
},
getFailure: (state, { payload }: PayloadAction<string>) => {
state.readyStatus = "failure"
state.error = payload
},
getRequesting: (_) => ({
readyStatus: "request",
}),
getSuccess: (_, { payload }: PayloadAction<Volunteer>) => ({
readyStatus: "success",
entity: payload,
}),
getFailure: (_, { payload }: PayloadAction<string>) => ({
readyStatus: "failure",
error: payload,
}),
},
})
export default volunteerSetSlice.reducer
export const { getRequesting, getSuccess, getFailure } = volunteerSetSlice.actions
export const fetchVolunteerSet = elementSet(
export const fetchVolunteerSet = elementFetch(
volunteerSet,
getRequesting,
getSuccess,
@ -37,3 +40,20 @@ export const fetchVolunteerSet = elementSet(
(error: Error) => toastError(`Erreur lors de la modification d'un bénévole: ${error.message}`),
() => toastSuccess("Bénévole modifié !")
)
const shouldFetchVolunteerSet = (_state: AppState) => true
export const fetchVolunteerSetIfNeed =
(newPartialVolunteer: Partial<Volunteer>): AppThunk =>
(dispatch, getState) => {
const { jwt } = getState().auth
if (shouldFetchVolunteerSet(getState()))
return dispatch(fetchVolunteerSet(jwt, newPartialVolunteer))
return null
}
export const selectVolunteerSet = createSelector(
(state: AppState) => state,
(state): string | undefined => state.volunteerSet?.entity?.discordId
)

View File

@ -3,6 +3,7 @@ type rolesType = {
}
const ROLES: rolesType = {
ADMIN: "admin",
ASSIGNER: "répartiteur",
TEAMLEAD: "référent",
}

View File

@ -5,7 +5,11 @@ import { selectUserRoles } from "../store/auth"
function withUserRole<T>(requiredRole: string, Component: React.ComponentType<T>) {
return (props: T): JSX.Element => {
const roles = useSelector(selectUserRoles)
return roles.includes(requiredRole) ? <Component {...props} /> : <div />
return roles.includes(requiredRole) ? (
<Component {...props} />
) : (
<div>Missing role {requiredRole}</div>
)
}
}