Add hosting form

This commit is contained in:
pikiou 2022-06-01 09:00:51 +02:00
parent f9a68d7cfe
commit 032e15d985
15 changed files with 598 additions and 0 deletions

View File

@ -0,0 +1,39 @@
import { get } from "lodash"
import { useCallback } from "react"
import { fetchVolunteerAsksSet } from "../../store/volunteerAsksSet"
import { useAskTools, addAsk, answerLaterOnProfile } from "./utils"
import ParticipationDetailsForm, {
fetchFor as fetchForParticipationDetailsForm,
} from "../VolunteerBoard/ParticipationDetailsForm/ParticipationDetailsForm"
import { useUserParticipationDetails } from "../VolunteerBoard/participationDetails.utils"
export function AskParticipationDetails(asks: JSX.Element[], id: number): void {
const { dispatch, jwtToken, volunteerAsks } = useAskTools()
const onSubmit = useCallback((): void => {
dispatch(
fetchVolunteerAsksSet(jwtToken, 0, {
hiddenAsks: [...(volunteerAsks?.hiddenAsks || []), id],
})
)
}, [dispatch, id, jwtToken, volunteerAsks?.hiddenAsks])
const [participationDetails] = useUserParticipationDetails()
const tshirtSize = get(participationDetails, "tshirtSize", "")
const food = get(participationDetails, "food", "")
const needToShow = !tshirtSize || !food
addAsk(
asks,
id,
volunteerAsks,
false,
needToShow,
<ParticipationDetailsForm afterSubmit={onSubmit}>
{answerLaterOnProfile}
</ParticipationDetailsForm>
)
}
// Fetch server-side data here
export const fetchFor = [...fetchForParticipationDetailsForm]

View File

@ -1,6 +1,8 @@
import { FC, memo } from "react"
import DayWishes from "./DayWishes/DayWishes"
import DayWishesFormModal from "./DayWishesForm/DayWishesFormModal"
import Hosting from "./Hosting/Hosting"
import HostingFormModal from "./HostingForm/HostingFormModal"
import ParticipationDetails from "./ParticipationDetails/ParticipationDetails"
import ParticipationDetailsFormModal from "./ParticipationDetailsForm/ParticipationDetailsFormModal"
import TeamWishes from "./TeamWishes/TeamWishes"
@ -8,6 +10,7 @@ import TeamWishesFormModal from "./TeamWishesForm/TeamWishesFormModal"
import withUserConnected from "../../utils/withUserConnected"
import ContentTitle from "../ui/Content/ContentTitle"
import { fetchFor as fetchForDayWishesForm } from "./DayWishesForm/DayWishesForm"
import { fetchFor as fetchForHostingForm } from "./HostingForm/HostingForm"
import { fetchFor as fetchForParticipationDetailsForm } from "./ParticipationDetailsForm/ParticipationDetailsForm"
import { fetchFor as fetchForTeamWishesForm } from "./TeamWishesForm/TeamWishesForm"
import VolunteerTeam from "./VolunteerTeam/VolunteerTeam"
@ -22,6 +25,8 @@ const Board: FC = (): JSX.Element => (
<TeamWishes />
<TeamWishesFormModal />
<VolunteerTeam />
<Hosting />
<HostingFormModal />
</>
)
@ -29,6 +34,7 @@ export default memo(withUserConnected(Board))
export const fetchFor = [
...fetchForDayWishesForm,
...fetchForHostingForm,
...fetchForParticipationDetailsForm,
...fetchForTeamWishesForm,
]

View File

@ -0,0 +1,61 @@
import { FC, memo, useCallback } from "react"
import get from "lodash/get"
import styles from "./styles.module.scss"
import { useUserHosting } from "../hosting.utils"
import useAction from "../../../utils/useAction"
import { displayModal, MODAL_IDS } from "../../../store/ui"
const Hosting: FC = (): JSX.Element | null => {
const [userWishes] = useUserHosting()
const needsHosting = get(userWishes, "needsHosting", false)
const canHostCount = get(userWishes, "canHostCount", 0)
const distanceToFestival = get(userWishes, "distanceToFestival", 0)
const comment = get(userWishes, "hostingComment", "")
const execDisplayModal = useAction(displayModal)
const onEdit = useCallback(() => execDisplayModal(MODAL_IDS.HOSTING), [execDisplayModal])
return (
<div className={styles.hosting}>
<div className={styles.title}>Mon hébergement</div>
{!needsHosting && (
<div className={styles.hostingLabel}>
Je n'ai pas besoin d'un hébergement proche du festival
</div>
)}
{needsHosting && (
<div className={styles.hostingLabel}>
J'ai <b>besoin</b> d'un hébergement proche du festival
</div>
)}
{canHostCount === 0 && distanceToFestival === 0 && (
<div className={styles.hostingLabel}>
Je ne peux héberger personnes de manière utile.
</div>
)}
{canHostCount > 0 && (
<div className={styles.hostingLabel}>
Je peux héberger <b>{canHostCount} personnes</b> !
</div>
)}
{distanceToFestival > 0 && (
<div className={styles.hostingLabel}>
Je suis à <b>{distanceToFestival} minutes</b> du festival
</div>
)}
{comment && (
<div className={styles.commentLine}>
<span className={styles.commentLineTitle}>Mon commentaire :</span>
<span className={styles.commentLineText}>{comment}</span>
</div>
)}
<div className={styles.editButton}>
<button type="button" onClick={onEdit}>
Modifier
</button>
</div>
</div>
)
}
export default memo(Hosting)

View File

@ -0,0 +1,53 @@
@import "../../../theme/variables";
@import "../../../theme/mixins";
.title {
padding-bottom: 10px;
font-weight: bold;
}
.hostingLabel {
margin-right: 5px;
font-style: bold;
}
.hosting {
@include inner-content-wrapper();
position: relative;
padding-right: 90px;
}
.hostingLabel,
.commentLine {
margin-bottom: 5px;
span {
display: inline-block;
}
}
.lineEmpty {
color: $color-red;
font-style: italic;
}
.commentLineTitle {
padding-right: 5px;
}
.commentLineText {
font-style: italic;
}
.editButton {
@include vertical-center();
position: absolute;
right: 20px;
button {
color: $color-green;
font-weight: bold;
cursor: pointer;
}
}

View File

@ -0,0 +1,133 @@
import { FC, memo, ReactNode, useCallback, useEffect, useRef, useState } from "react"
import classnames from "classnames"
import get from "lodash/get"
import set from "lodash/set"
import styles from "./styles.module.scss"
import { useUserHosting } from "../hosting.utils"
import FormButton from "../../Form/FormButton/FormButton"
import { fetchVolunteerHostingSetIfNeed } from "../../../store/volunteerHostingSet"
import IgnoreButton from "../../Form/IgnoreButton/IgnoreButton"
type Props = {
children?: ReactNode | undefined
afterSubmit?: () => void | undefined
}
const HostingForm: FC<Props> = ({ children, afterSubmit }): JSX.Element => {
const [needsHosting, setNeedsHosting] = useState(false)
const canHostCountRef = useRef<HTMLInputElement | null>(null)
const distanceRef = useRef<HTMLInputElement | null>(null)
const commentRef = useRef<HTMLTextAreaElement | null>(null)
const [userWishes, saveWishes] = useUserHosting()
const onNeedsHostingChange = (e: React.ChangeEvent<HTMLInputElement>) =>
setNeedsHosting(e.target.checked)
useEffect(() => {
if (!userWishes) return
setNeedsHosting(get(userWishes, "needsHosting", false))
set(canHostCountRef, "current.value", `${get(userWishes, "canHostCount", 0)}`)
set(distanceRef, "current.value", `${get(userWishes, "distanceToFestival", 0)}`)
set(commentRef, "current.value", get(userWishes, "hostingComment", ""))
}, [commentRef, userWishes])
const onChoiceSubmit = useCallback(() => {
const canHostCount = +get(canHostCountRef, "current.value", "0")
const distanceToFestival = +get(distanceRef, "current.value", "0")
const hostingComment = get(commentRef, "current.value", "")
saveWishes(needsHosting, canHostCount, distanceToFestival, hostingComment)
if (afterSubmit) afterSubmit()
}, [needsHosting, commentRef, saveWishes, afterSubmit])
return (
<div>
<div className={styles.title}>Mes jours de présence</div>
<div className={classnames(styles.inputWrapper, styles.noBottomMargin)}>
<div className={styles.leftCol}>
<div className={styles.needsHostingTitle}>
Cela t'arrangerait-il d'avoir un hébergement proche du festival ?
</div>
</div>
<div className={styles.rightCol}>
<label className={styles.needsHostingLabel}>
<input
type="checkbox"
value="oui"
name="needsHosting"
onChange={onNeedsHostingChange}
checked={needsHosting}
/>{" "}
Oui
</label>
</div>
</div>
{needsHosting && (
<div className={classnames(styles.inputWrapper, styles.noBottomMargin)}>
<div>
Il nous serait utile de savoir à quelle temps de transport tu te trouves
pour privilégier les bénévoles qui viennent de province à ceux qui viennent
de l'autre bout de Paris.
</div>
</div>
)}
<div className={styles.inputWrapper}>
<div className={styles.leftCol}>
<div className={styles.canHostCountTitle}>
Combien de bénévoles peux-tu héberger confortablement ?
</div>
</div>
<div className={styles.rightCol}>
<input className={styles.canHostCountLabel} type="text" ref={canHostCountRef} />
</div>
</div>
<div className={styles.inputWrapper}>
<div className={styles.leftCol}>
<div className={styles.distanceToFestivalTitle}>
À combien de minutes de transport es-tu du festival ? (En voiture si tu es
en voiture, à vélo si tu as des vélos, sinon en transport en commun.)
</div>
</div>
<div className={styles.rightCol}>
<input
className={styles.distanceToFestivalLabel}
type="text"
ref={distanceRef}
/>
</div>
</div>
<div className={styles.hostingCommentWrapper}>
<label htmlFor="hosting-comment">Un commentaire, une précision ?</label>
<textarea id="hosting-comment" ref={commentRef} />
</div>
<div className={styles.buttonWrapper}>
<FormButton onClick={onChoiceSubmit}>Enregistrer</FormButton>
{children === undefined && (
<>
{" "}
<FormButton onClick={afterSubmit} type="grey">
Annuler
</FormButton>{" "}
</>
)}
{children !== undefined && (
<>
{" "}
<IgnoreButton onClick={afterSubmit} text="Ignorer">
{children}
</IgnoreButton>{" "}
</>
)}
</div>
</div>
)
}
HostingForm.defaultProps = {
children: undefined,
afterSubmit: undefined,
}
export default memo(HostingForm)
// Fetch server-side data here
export const fetchFor = [fetchVolunteerHostingSetIfNeed]

View File

@ -0,0 +1,18 @@
import { FC, memo, useCallback } from "react"
import { hideModal, MODAL_IDS } from "../../../store/ui"
import Modal from "../../Modal/Modal"
import useAction from "../../../utils/useAction"
import HostingForm from "./HostingForm"
const HostingFormModal: FC = (): JSX.Element => {
const execHideModal = useAction(hideModal)
const afterFormSubmit = useCallback(() => execHideModal(), [execHideModal])
return (
<Modal modalId={MODAL_IDS.HOSTING}>
<HostingForm afterSubmit={afterFormSubmit} />
</Modal>
)
}
export default memo(HostingFormModal)

View File

@ -0,0 +1,126 @@
@import "../../../theme/variables";
@import "../../../theme/mixins";
.title {
padding: 15px 0;
font-weight: bold;
text-align: center;
}
.inputWrapper {
margin: 25px 0;
@include desktop {
display: flex;
}
}
.noBottomMargin {
margin-bottom: 0;
}
.leftCol {
flex: 0 0 320px;
}
.rightCol {
width: 100%;
text-align: center;
}
.needsHostingTitle {
display: inline-block;
width: 320px;
margin-bottom: 10px;
}
.needsHostingLabel {
text-align: left;
display: inline-block;
margin-bottom: 10px;
width: 80px;
}
.canHostCountTitle {
display: inline-block;
width: 320px;
margin-bottom: 10px;
}
.canHostCountLabel {
text-align: left;
display: inline-block;
margin-bottom: 10px;
width: 80px;
}
.distanceToFestivalTitle {
display: inline-block;
width: 320px;
margin-bottom: 10px;
}
.distanceToFestivalLabel {
text-align: left;
display: inline-block;
margin-bottom: 10px;
width: 80px;
}
.hostingTitle {
display: inline-block;
width: 320px;
margin-bottom: 10px;
}
.hostingList {
@include clear-ul-style;
display: inline-block;
width: 204px;
text-align: center;
}
.hostingItem {
display: inline-block;
margin: 3px;
}
.hostingButton {
margin: 0;
padding: 7px 2px 6px;
border: 0;
border-radius: 0;
width: 90px;
text-align: center;
color: $color-grey-dark;
background-color: $color-grey-light;
cursor: pointer;
&.active {
color: $color-yellow;
background-color: $color-black;
}
}
.hostingCommentWrapper {
margin: 6px 0 14px;
label {
display: block;
padding: 6px 0 2px 4px;
}
textarea {
width: 100%;
height: 50px;
padding: 5px;
border: 1px solid $color-grey-light;
background-color: $color-grey-lighter;
outline: 0;
}
}
.buttonWrapper {
margin-bottom: 10px;
text-align: center;
}

View File

@ -0,0 +1,31 @@
import { shallowEqual, useSelector } from "react-redux"
import { useCallback } from "react"
import { selectUserJwtToken } from "../../store/auth"
import { AppState } from "../../store"
import { fetchVolunteerHostingSet } from "../../store/volunteerHostingSet"
import useAction from "../../utils/useAction"
export const useUserHosting = (): [any, any] => {
const save = useAction(fetchVolunteerHostingSet)
const jwtToken = useSelector(selectUserJwtToken)
const userWishes = useSelector(
(state: AppState) => state.volunteerHostingSet?.entity,
shallowEqual
)
const saveWishes = useCallback(
(needsHosting, canHostCount, distanceToFestival, hostingComment) => {
if (!userWishes) return
save(jwtToken, 0, {
id: userWishes.id,
needsHosting,
canHostCount,
distanceToFestival,
hostingComment,
})
},
[userWishes, save, jwtToken]
)
return [userWishes, saveWishes]
}

View File

@ -11,6 +11,7 @@ import {
VolunteerTeamWishes,
translationVolunteer,
VolunteerDayWishes,
VolunteerHosting,
VolunteerParticipationDetails,
VolunteerTeamAssign,
VolunteerKnowledge,
@ -324,6 +325,43 @@ export const volunteerDayWishesSet = expressAccessor.set(async (list, body, id)
}
})
export const volunteerHostingSet = expressAccessor.set(async (list, body, id) => {
const requestedId = +body[0] || id
if (requestedId !== id && requestedId !== 0) {
throw Error(`On ne peut acceder qu'à ses propres infos d'hébergement`)
}
const wishes = body[1] as VolunteerHosting
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)
if (wishes.needsHosting !== undefined) {
newVolunteer.needsHosting = wishes.needsHosting
}
if (wishes.canHostCount !== undefined) {
newVolunteer.canHostCount = wishes.canHostCount
}
if (wishes.distanceToFestival !== undefined) {
newVolunteer.distanceToFestival = wishes.distanceToFestival
}
if (wishes.hostingComment !== undefined) {
newVolunteer.hostingComment = wishes.hostingComment
}
return {
toDatabase: newVolunteer,
toCaller: {
id: newVolunteer.id,
needsHosting: newVolunteer.needsHosting,
canHostCount: newVolunteer.canHostCount,
distanceToFestival: newVolunteer.distanceToFestival,
hostingComment: newVolunteer.hostingComment,
} as VolunteerHosting,
}
})
export const volunteerParticipationDetailsSet = expressAccessor.set(async (list, body, id) => {
const requestedId = +body[0] || id
if (requestedId !== id && requestedId !== 0) {

View File

@ -26,6 +26,7 @@ import {
volunteerAsksSet,
volunteerDayWishesSet,
volunteerForgot,
volunteerHostingSet,
volunteerDiscordId,
volunteerLogin,
volunteerPartialAdd,
@ -111,6 +112,7 @@ app.post(
volunteerParticipationDetailsSet
)
app.post("/VolunteerDayWishesSet", secure as RequestHandler, volunteerDayWishesSet)
app.post("/VolunteerHostingSet", secure as RequestHandler, volunteerHostingSet)
app.post("/VolunteerTeamWishesSet", secure as RequestHandler, volunteerTeamWishesSet)
app.post("/VolunteerTeamAssignSet", secure as RequestHandler, volunteerTeamAssignSet)

View File

@ -59,6 +59,14 @@ export class Volunteer implements VolunteerPartial {
bof: number[] = []
niet: number[] = []
needsHosting = false
canHostCount = 0
distanceToFestival = 0
hostingComment = ""
}
export const translationVolunteer: { [k in keyof Volunteer]: string } = {
@ -92,6 +100,10 @@ export const translationVolunteer: { [k in keyof Volunteer]: string } = {
ok: "OK",
bof: "Bof",
niet: "Niet",
needsHosting: "besoinHébergement",
canHostCount: "nombreHébergés",
distanceToFestival: "distanceAuFestival",
hostingComment: "commentaireHébergement",
}
export class VolunteerPartial {
@ -137,6 +149,10 @@ export const volunteerExample: Volunteer = {
ok: [5, 7, 24, 26, 31, 38, 50, 52, 54, 58],
bof: [9, 12, 16, 27, 34, 35, 36],
niet: [13, 18, 19, 23, 47, 53, 59, 67],
needsHosting: false,
canHostCount: 0,
distanceToFestival: 0,
hostingComment: "",
}
export const emailRegexp =
@ -181,6 +197,14 @@ export interface VolunteerDayWishes {
dayWishesComment: Volunteer["dayWishesComment"]
}
export interface VolunteerHosting {
id: Volunteer["id"]
needsHosting: Volunteer["needsHosting"]
canHostCount: Volunteer["canHostCount"]
distanceToFestival: Volunteer["distanceToFestival"]
hostingComment: Volunteer["hostingComment"]
}
export interface VolunteerParticipationDetails {
id: Volunteer["id"]
tshirtSize: Volunteer["tshirtSize"]

View File

@ -3,6 +3,7 @@ import {
elementName,
Volunteer,
VolunteerDayWishes,
VolunteerHosting,
VolunteerAsks,
VolunteerParticipationDetails,
VolunteerTeamWishes,
@ -38,6 +39,9 @@ export const volunteerTeamWishesSet =
export const volunteerDayWishesSet =
serviceAccessors.securedCustomPost<[number, Partial<VolunteerDayWishes>]>("DayWishesSet")
export const volunteerHostingSet =
serviceAccessors.securedCustomPost<[number, Partial<VolunteerHosting>]>("HostingSet")
export const volunteerParticipationDetailsSet =
serviceAccessors.securedCustomPost<[number, Partial<VolunteerParticipationDetails>]>(
"ParticipationDetailsSet"

View File

@ -16,6 +16,7 @@ import volunteerAsksSet from "./volunteerAsksSet"
import volunteerDayWishesSet from "./volunteerDayWishesSet"
import volunteerDiscordId from "./volunteerDiscordId"
import volunteerForgot from "./volunteerForgot"
import volunteerHostingSet from "./volunteerHostingSet"
import volunteerList from "./volunteerList"
import volunteerLogin from "./volunteerLogin"
import volunteerKnowledgeSet from "./volunteerKnowledgeSet"
@ -44,6 +45,7 @@ export default (history: History) => ({
volunteerDayWishesSet,
volunteerDiscordId,
volunteerForgot,
volunteerHostingSet,
volunteerList,
volunteerLogin,
volunteerKnowledgeSet,

View File

@ -28,6 +28,7 @@ export const selectActiveModalId = createSelector(selectUiData, (ui) => ui.modal
export const MODAL_IDS = {
DAYWISHES: "DAYWISHES",
HOSTING: "HOSTING",
PARTICIPATIONDETAILS: "PARTICIPATIONDETAILS",
TEAMWISHES: "TEAMWISHES",
}

View File

@ -0,0 +1,60 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit"
import { StateRequest, toastError, elementFetch } from "./utils"
import { VolunteerHosting } from "../services/volunteers"
import { AppThunk, AppState } from "."
import { volunteerHostingSet } from "../services/volunteersAccessors"
type StateVolunteerHostingSet = { entity?: VolunteerHosting } & StateRequest
export const initialState: StateVolunteerHostingSet = {
readyStatus: "idle",
}
const volunteerHostingSetSlice = createSlice({
name: "volunteerHostingSet",
initialState,
reducers: {
getRequesting: (_) => ({
readyStatus: "request",
}),
getSuccess: (_, { payload }: PayloadAction<VolunteerHosting>) => ({
readyStatus: "success",
entity: payload,
}),
getFailure: (_, { payload }: PayloadAction<string>) => ({
readyStatus: "failure",
error: payload,
}),
},
})
export default volunteerHostingSetSlice.reducer
export const { getRequesting, getSuccess, getFailure } = volunteerHostingSetSlice.actions
export const fetchVolunteerHostingSet = elementFetch(
volunteerHostingSet,
getRequesting,
getSuccess,
getFailure,
(error: Error) =>
toastError(`Erreur lors du chargement des choix de jours de présence: ${error.message}`)
)
const shouldFetchVolunteerHostingSet = (state: AppState, id: number) =>
state.volunteerHostingSet?.readyStatus !== "success" ||
(state.volunteerHostingSet?.entity && state.volunteerHostingSet?.entity?.id !== id)
export const fetchVolunteerHostingSetIfNeed =
(id = 0, wishes: Partial<VolunteerHosting> = {}): AppThunk =>
(dispatch, getState) => {
let jwt = ""
if (!id) {
;({ jwt, id } = getState().auth)
}
if (shouldFetchVolunteerHostingSet(getState(), id))
return dispatch(fetchVolunteerHostingSet(jwt, id, wishes))
return null
}