all repos — caroster @ e70dda0ec966d182cfaf626d43aea6888cdb7634

[Octree] Group carpool to your event https://caroster.io

feat: ✨ Manage user's list of vehicles


#219
Simon Mulquin simon@octree.ch
Fri, 11 Feb 2022 15:15:10 +0000
commit

e70dda0ec966d182cfaf626d43aea6888cdb7634

parent

c80bb0829b4be7e9ff1daf853d1ae4fd18fc9253

M frontend/containers/NewTravelDialog/index.tsxfrontend/containers/NewTravelDialog/index.tsx

@@ -1,4 +1,4 @@

-import {useState, forwardRef, useMemo} from 'react'; +import {useState, forwardRef, useMemo, useEffect} from 'react'; import {makeStyles} from '@material-ui/core/styles'; import Dialog from '@material-ui/core/Dialog'; import DialogActions from '@material-ui/core/DialogActions';

@@ -16,7 +16,7 @@ import useEventStore from '../../stores/useEventStore';

import useActions from './useActions'; import useProfile from '../../hooks/useProfile'; -const NewTravelDialog = ({open, toggle}) => { +const NewTravelDialog = ({context, toggle}) => { const {t} = useTranslation(); const classes = useStyles(); const {user} = useProfile();

@@ -38,6 +38,14 @@ const [phone, setPhone] = useState('');

const [details, setDetails] = useState(''); const canCreate = !!name && !!seats; + useEffect(() => { + if (context.vehicle) { + setName(context.vehicle.name); + setSeats(context.vehicle.seats); + setPhone(context.vehicle.phone_number); + } + }, [context.vehicle]); + const onCreate = async e => { if (e.preventDefault) e.preventDefault();

@@ -50,11 +58,26 @@ vehicle: {

name, seats, phone_number: phone, - ...(user ? {user: user.id} : {}), }, }; - await actions.createTravel(travel); - toggle(); + if (context.vehicle && user) { + // The authenticated user choose an existing vehicle and assign it to the travel + await actions.createTravel({ + ...travel, + vehicle: context.vehicle, + }); + } else if (user) { + // The autenticated user create a vehicle and assign it to the travel + await actions.createTravel({ + ...travel, + vehicle: {...travel.vehicle, user: user.id, created_by: user.id}, + }); + } else { + // The anonymous user create a vehicle and assign it to the travel + await actions.createTravel(travel); + } + + toggle({opened: false}); // Clear states setName('');

@@ -68,9 +91,9 @@

return ( <Dialog fullWidth - maxWidth="sm" - open={open} - onClose={toggle} + maxWidth="xs" + open={context?.opened} + onClose={() => toggle({opened: false})} TransitionComponent={Transition} > <form onSubmit={onCreate}>

@@ -103,6 +126,7 @@ label={t('travel.creation.name')}

fullWidth helperText=" " value={name} + disabled={!!context.vehicle} onChange={e => setName(e.target.value)} name="name" id="NewTravelName"

@@ -112,6 +136,7 @@ label={t('travel.creation.phone')}

fullWidth helperText=" " value={phone} + disabled={!!context.vehicle} onChange={e => setPhone(e.target.value)} name="phone" id="NewTravelPhone"

@@ -146,6 +171,7 @@ {t('travel.creation.seats')}

</Typography> <Slider value={seats} + disabled={!!context.vehicle} onChange={(e, value) => setSeats(value)} step={1} marks={MARKS}

@@ -160,7 +186,7 @@ <DialogActions>

<Button color="primary" id="NewTravelCancel" - onClick={toggle} + onClick={() => toggle({opened: false})} tabIndex={-1} > {t('generic.cancel')}
M frontend/containers/NewTravelDialog/useActions.tsfrontend/containers/NewTravelDialog/useActions.ts

@@ -7,7 +7,10 @@ Event,

EventByUuidDocument, useCreateTravelMutation, useCreateVehicleMutation, + FindUserVehiclesDocument, + Vehicle, } from '../../generated/graphql'; +import useProfile from '../../hooks/useProfile'; interface Props { event: Event;

@@ -16,6 +19,7 @@

const useActions = (props: Props) => { const {event} = props; const {t} = useTranslation(); + const {user} = useProfile(); const addToast = useToastsStore(s => s.addToast); const {addToEvent} = useAddToEvents(); const [createVehicleMutation] = useCreateVehicleMutation();

@@ -28,19 +32,14 @@ const departure = moment(

`${moment(date).format('YYYY-MM-DD')} ${moment(time).format('HH:mm')}`, 'YYYY-MM-DD HH:mm' ).toISOString(); - const {data: {createVehicle} = {}} = await createVehicleMutation({ - variables: { - vehicle, - }, - }); - await createTravelMutation({ + const makeTravelMutationParams = (travelVehicle: Vehicle) => ({ variables: { travel: { ...travel, departure, event: event.id, - vehicle: createVehicle?.vehicle?.id, + vehicle: travelVehicle?.id, }, }, refetchQueries: [

@@ -52,6 +51,38 @@ },

}, ], }); + + if (vehicle.id) { + // The authenticated user chooses an existing vehicle and assign it to the travel + await createTravelMutation(makeTravelMutationParams(vehicle)); + } else if (user) { + // The autenticated user creates a vehicle and assign it to the travel + const {data: {createVehicle} = {}} = await createVehicleMutation({ + variables: { + vehicle, + }, + refetchQueries: [ + { + query: FindUserVehiclesDocument, + variables: {userId: user.id}, + }, + ], + }); + + const params = makeTravelMutationParams( + createVehicle.vehicle as Vehicle + ); + await createTravelMutation(params); + } else { + // The anonymous user creates a vehicle and assign it to the travel + const {data: {createVehicle} = {}} = await createVehicleMutation({ + variables: { + vehicle, + }, + }); + + await createTravelMutation(makeTravelMutationParams(createVehicle?.vehicle as Vehicle)); + } addToEvent(event.id); addToast(t('travel.creation.created'));
M frontend/containers/PassengersList/Passenger.tsxfrontend/containers/PassengersList/Passenger.tsx

@@ -6,6 +6,8 @@ import Icon from '@material-ui/core/Icon';

import {makeStyles} from '@material-ui/core/styles'; import {useTranslation} from 'react-i18next'; import {ComponentPassengerPassenger} from '../../generated/graphql'; +import useProfile from '../../hooks/useProfile'; +import Chip from '@material-ui/core/Chip'; interface Props { passenger?: ComponentPassengerPassenger;

@@ -17,14 +19,16 @@ const Passenger = (props: Props) => {

const {passenger, button, isVehicle} = props; const {t} = useTranslation(); const classes = useStyles(); + const {user} = useProfile(); + const isUser = user && user.id === passenger?.user?.id; const showLocation = isVehicle ? false : passenger.location if (passenger) { return ( <> <ListItemText - primary={passenger.name} + primary={<>{passenger.name}{isUser && <Chip className={classes.me} label={t('generic.me')} variant="outlined" />}</>} secondary={showLocation} /> {button}

@@ -52,6 +56,9 @@ const useStyles = makeStyles(theme => ({

empty: { color: theme.palette.text.secondary, }, + me: { + marginLeft: theme.spacing(2), + } })); export default Passenger;
M frontend/containers/PassengersList/index.tsxfrontend/containers/PassengersList/index.tsx

@@ -18,15 +18,8 @@ onClick?: (passengerId: string) => void;

} const PassengersList = (props: Props) => { - const { - passengers, - places, - Button, - onClick, - onPress, - disabled, - isVehicle, - } = props; + const {passengers, places, Button, onClick, onPress, disabled, isVehicle} = + props; const classes = useStyles(); let list = passengers;

@@ -43,6 +36,7 @@ <List disablePadding>

{!!list && list.map((passenger, index) => ( <ListItem + className={classes.passenger} key={index} disabled={disabled} button={!!onPress}

@@ -53,9 +47,7 @@ key={index}

passenger={passenger} isVehicle={isVehicle} button={ - <Button - onClick={() => onClick && onClick(passenger.id)} - /> + <Button onClick={() => onClick && onClick(passenger.id)} /> } /> </ListItem>

@@ -68,6 +60,9 @@

const useStyles = makeStyles(theme => ({ container: { padding: theme.spacing(0, 0, 1, 0), + }, + passenger: { + paddingRight: theme.spacing(12), }, }));
M frontend/containers/TravelColumns/index.tsxfrontend/containers/TravelColumns/index.tsx

@@ -5,7 +5,6 @@ import Slider from 'react-slick';

import {useTranslation} from 'react-i18next'; import { Travel as TravelType, - useUpdateTravelMutation, } from '../../generated/graphql'; import useEventStore from '../../stores/useEventStore'; import useTourStore from '../../stores/useTourStore';
A frontend/containers/VehicleChoiceDialog/VehicleItem.tsx

@@ -0,0 +1,105 @@

+import Typography from '@material-ui/core/Typography'; +import ListItem from '@material-ui/core/ListItem'; +import Box from '@material-ui/core/Box'; +import Button from '@material-ui/core/Button'; +import {makeStyles} from '@material-ui/core/styles'; +import {useTranslation} from 'react-i18next'; +import { + VehicleFieldsFragment, + useUpdateVehicleMutation, + FindUserVehiclesDocument, +} from '../../generated/graphql'; +import useProfile from '../../hooks/useProfile'; + +interface Props { + vehicle: VehicleFieldsFragment; + select: () => void; +} + +const VehicleItem = ({vehicle, select}: Props) => { + const {t} = useTranslation(); + const classes = useStyles(); + const {user} = useProfile(); + const [unlinkUserCar] = useUpdateVehicleMutation({ + variables: { + id: vehicle.id, + vehicleUpdate: { + user: null, + }, + }, + refetchQueries: [ + {query: FindUserVehiclesDocument, variables: {userId: user.id}}, + ], + }); + + return ( + <ListItem className={classes.item}> + <Box> + <Typography variant="overline" className={classes.label}> + {t('travel.vehicle.name')} + </Typography> + <Button color="primary" variant="text" onClick={() => unlinkUserCar()}> + {t('generic.delete')} + </Button> + </Box> + <Typography variant="body1" className={classes.section}> + {vehicle.name} + </Typography> + <Typography variant="overline" className={classes.label}> + {t('travel.vehicle.seats_number')} + </Typography> + <Typography variant="body1" className={classes.section}> + {vehicle.seats} + </Typography> + <Button + className={classes.select} + fullWidth + color="primary" + variant="contained" + onClick={select} + > + {t('generic.select')} + </Button> + </ListItem> + ); +}; + +const useStyles = makeStyles(theme => ({ + item: { + display: 'block', + padding: theme.spacing(2, 3), + }, + section: { + maxWidth: '75%', + marginBottom: theme.spacing(1), + }, + label: { + fontWeight: 'bold', + opacity: 0.6, + marginRight: theme.spacing(2), + }, + delete: { + color: theme.palette.error.main, + position: 'absolute', + right: theme.spacing(2), + '& > span': { + '&::after': { + content: '""', + display: 'block', + position: 'absolute', + width: `calc(100% - ${theme.spacing(2)}px)`, + height: theme.spacing(0.25), + bottom: theme.spacing(1), + left: theme.spacing(1), + backgroundColor: theme.palette.error.light, + }, + }, + }, + select: { + display: 'block', + maxWidth: '300px', + margin: '0 auto', + }, +})); + +export default VehicleItem;
M frontend/containers/VehicleChoiceDialog/index.tsxfrontend/containers/VehicleChoiceDialog/index.tsx

@@ -1,34 +1,80 @@

-import {forwardRef} from 'react'; +import {forwardRef, Fragment} from 'react'; import {makeStyles} from '@material-ui/core/styles'; import Dialog from '@material-ui/core/Dialog'; import DialogActions from '@material-ui/core/DialogActions'; import DialogContent from '@material-ui/core/DialogContent'; import DialogTitle from '@material-ui/core/DialogTitle'; import Button from '@material-ui/core/Button'; +import List from '@material-ui/core/List'; +import Container from '@material-ui/core/Container'; +import Divider from '@material-ui/core/Divider'; import Slide from '@material-ui/core/Slide'; import {useTranslation} from 'react-i18next'; +import VehicleItem from './VehicleItem'; +import Typography from '@material-ui/core/Typography'; +import {VehicleFieldsFragment} from '../../generated/graphql'; -const VehicleChoiceDialog = ({open, toggle, toggleNewTravel}) => { +interface Props { + open: boolean; + toggle: () => void; + toggleNewTravel: ({ + opened, + vehicle, + }: { + opened: boolean; + vehicle?: VehicleFieldsFragment; + }) => void; + vehicles: Array<VehicleFieldsFragment>; +} + +const VehicleChoiceDialog = ({ + open, + toggle, + toggleNewTravel, + vehicles, +}: Props) => { const {t} = useTranslation(); const classes = useStyles(); return ( <Dialog fullWidth - maxWidth="sm" + maxWidth="xs" open={open} onClose={toggle} TransitionComponent={Transition} > <DialogTitle>{t('travel.vehicle.title')}</DialogTitle> - <DialogContent dividers></DialogContent> + <DialogContent dividers className={classes.content}> + {(vehicles && vehicles.length != 0 && ( + <List> + {vehicles.map((vehicle, index, {length}) => ( + <Fragment key={index}> + <VehicleItem + vehicle={vehicle} + select={() => { + toggleNewTravel({vehicle, opened: true}); + toggle(); + }} + /> + {index + 1 < length && <Divider />} + </Fragment> + ))} + </List> + )) || ( + <Container> + <Typography>{t('travel.vehicle.empty')}</Typography> + </Container> + )} + </DialogContent> <DialogActions className={classes.actions}> <Button + className={classes.new} color="primary" - variant="outlined" fullWidth + variant="outlined" onClick={() => { - toggleNewTravel(); + toggleNewTravel({opened: true}); toggle(); }} >

@@ -45,7 +91,13 @@ });

const useStyles = makeStyles(theme => ({ actions: { - padding: theme.spacing(2, 3), + justifyContent: 'center', + }, + content: { + padding: 0, + }, + new: { + maxWidth: '300px', }, }));
M frontend/containers/WaitingList/TravelDialog.tsxfrontend/containers/WaitingList/TravelDialog.tsx

@@ -117,7 +117,7 @@ },

}, listItem: { display: 'flex', - justifyContent: 'space-between', + justifyContent: 'left', [theme.breakpoints.down('sm')]: { display: 'block', textAlign: 'center',
M frontend/containers/WaitingList/index.tsxfrontend/containers/WaitingList/index.tsx

@@ -39,17 +39,24 @@ const [removingPassenger, setRemovingPassenger] = useState(null);

const [addingPassenger, setAddingPassenger] = useState(null); const travels = event?.travels?.length > 0 ? event.travels.slice().sort(sortTravels) : []; - const {addPassengerToTravel, removePassengerFromWaitingList} = usePassengersActions(); + const { + addPassengerToTravel, + removePassengerFromWaitingList, + } = usePassengersActions(); const availability = useMemo(() => { if (!travels) return; return travels.reduce((count, {vehicle, passengers = []}) => { - if (!passengers) return count + vehicle.seats; + if (!vehicle) return 0; + else if (!passengers) return count + vehicle.seats; return count + vehicle.seats - passengers.length; }, 0); }, [travels]); - const removePassengerFromWaitingListFallBack = useCallback(removePassengerFromWaitingList, [event]); + const removePassengerFromWaitingListFallBack = useCallback( + removePassengerFromWaitingList, + [event] + ); const selectTravel = useCallback( async travel => {

@@ -73,7 +80,11 @@ onError,

onSucceed: () => { setAddingPassenger(null); slideToTravel(travel.id); - addToast(t('passenger.success.added_to_car', {name: addingPassenger.name})); + addToast( + t('passenger.success.added_to_car', { + name: addingPassenger.name, + }) + ); }, }), });
M frontend/generated/graphql.tsxfrontend/generated/graphql.tsx

@@ -2334,6 +2334,24 @@ { __typename?: 'Vehicle' }

& Pick<Vehicle, 'id' | 'name' | 'seats' | 'phone_number'> ); +export type FindUserVehiclesQueryVariables = Exact<{ [key: string]: never; }>; + + +export type FindUserVehiclesQuery = ( + { __typename?: 'Query' } + & { me?: Maybe<( + { __typename?: 'UsersPermissionsMe' } + & Pick<UsersPermissionsMe, 'id' | 'username'> + & { profile?: Maybe<( + { __typename?: 'UsersPermissionsUser' } + & { vehicles?: Maybe<Array<Maybe<( + { __typename?: 'Vehicle' } + & VehicleFieldsFragment + )>>> } + )> } + )> } +); + export type CreateVehicleMutationVariables = Exact<{ vehicle: VehicleInput; }>;

@@ -2947,6 +2965,44 @@ }

export type UpdateMeMutationHookResult = ReturnType<typeof useUpdateMeMutation>; export type UpdateMeMutationResult = Apollo.MutationResult<UpdateMeMutation>; export type UpdateMeMutationOptions = Apollo.BaseMutationOptions<UpdateMeMutation, UpdateMeMutationVariables>; +export const FindUserVehiclesDocument = gql` + query findUserVehicles { + me { + id + username + profile { + vehicles { + ...VehicleFields + } + } + } +} + ${VehicleFieldsFragmentDoc}`; + +/** + * __useFindUserVehiclesQuery__ + * + * To run a query within a React component, call `useFindUserVehiclesQuery` and pass it any options that fit your needs. + * When your component renders, `useFindUserVehiclesQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useFindUserVehiclesQuery({ + * variables: { + * }, + * }); + */ +export function useFindUserVehiclesQuery(baseOptions?: Apollo.QueryHookOptions<FindUserVehiclesQuery, FindUserVehiclesQueryVariables>) { + return Apollo.useQuery<FindUserVehiclesQuery, FindUserVehiclesQueryVariables>(FindUserVehiclesDocument, baseOptions); + } +export function useFindUserVehiclesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<FindUserVehiclesQuery, FindUserVehiclesQueryVariables>) { + return Apollo.useLazyQuery<FindUserVehiclesQuery, FindUserVehiclesQueryVariables>(FindUserVehiclesDocument, baseOptions); + } +export type FindUserVehiclesQueryHookResult = ReturnType<typeof useFindUserVehiclesQuery>; +export type FindUserVehiclesLazyQueryHookResult = ReturnType<typeof useFindUserVehiclesLazyQuery>; +export type FindUserVehiclesQueryResult = Apollo.QueryResult<FindUserVehiclesQuery, FindUserVehiclesQueryVariables>; export const CreateVehicleDocument = gql` mutation createVehicle($vehicle: VehicleInput!) { createVehicle(input: {data: $vehicle}) {
M frontend/graphql/vehicle.gqlfrontend/graphql/vehicle.gql

@@ -5,6 +5,18 @@ seats

phone_number } +query findUserVehicles { + me { + id + username + profile { + vehicles { + ...VehicleFields + } + } + } +} + mutation createVehicle($vehicle: VehicleInput!) { createVehicle(input: {data: $vehicle}) { vehicle {
M frontend/locales/en.jsonfrontend/locales/en.json

@@ -1,10 +1,13 @@

{ "generic": { + "me": "Me", "loading": "Loading ...", "close": "Close", "create": "Create", + "delete": "Delete", "cancel": "Cancel", "remove": "Remove", + "select": "Select", "save": "Save", "confirm": "Confirm", "clear": "Clear",

@@ -143,7 +146,11 @@ "removed": "The car has been removed"

}, "vehicle": { "add": "Add a new vehicle", - "title": "My Vehicles" + "title": "My Vehicles", + "name": "Name of the vehicle", + "license_plate": "License plate", + "seats_number": "Seats number", + "empty": "There is no vehicle assigned to you. Click the button bellow in order to create one." }, "passengers": { "empty": "Available seat",
M frontend/locales/fr.jsonfrontend/locales/fr.json

@@ -1,10 +1,13 @@

{ "generic": { + "me": "Moi", "loading": "Chargement...", "close": "Fermer", "create": "Créer", + "delete": "Supprimer", "cancel": "Annuler", "remove": "Supprimer", + "select": "Selectionner", "save": "Enregistrer", "confirm": "Confirmer", "clear": "Effacer",

@@ -143,7 +146,11 @@ "removed": "La voiture a été supprimée"

}, "vehicle": { "add": "Ajouter un nouveau véhicule", - "title": "Mes véhicules" + "title": "Mes véhicules", + "name": "Nom du véhicule", + "license_plate": "Plaque d'immatriculation", + "seats_number": "Nombre de places", + "empty": "Vous n'avez aucun véhicule assigné. Utilisez le bouton ci-dessous pour en créer un." }, "passengers": { "empty": "Place disponible",
M frontend/pages/e/[uuid].tsxfrontend/pages/e/[uuid].tsx

@@ -20,9 +20,9 @@ Event as EventType,

useEventByUuidQuery, EventByUuidDocument, EditEventInput, + useFindUserVehiclesQuery, } from '../../generated/graphql'; import ErrorPage from '../_error'; -import AddTravel from '../../containers/TravelColumns/AddTravel'; import useProfile from '../../hooks/useProfile'; import Fab from '../../containers/Fab';

@@ -45,13 +45,17 @@ const {eventUUID} = props;

const classes = useStyles(); const {t} = useTranslation(); const {user} = useProfile(); + const { + data: {me: {profile: {vehicles = []} = {}} = {}} = {}, + loading + } = useFindUserVehiclesQuery(); const addToast = useToastStore(s => s.addToast); const setEvent = useEventStore(s => s.setEvent); const eventUpdate = useEventStore(s => s.event); const setIsEditing = useEventStore(s => s.setIsEditing); const [updateEvent] = useUpdateEventMutation(); const [isAddToMyEvent, setIsAddToMyEvent] = useState(false); - const [openNewTravel, toggleNewTravel] = useReducer(i => !i, false); + const [openNewTravelContext, toggleNewTravel] = useState({opened: false}); const [openVehicleChoice, toggleVehicleChoice] = useReducer(i => !i, false); const {data: {eventByUUID: event} = {}} = useEventByUuidQuery({ pollInterval: POLL_INTERVAL,

@@ -93,7 +97,12 @@ return true;

} }; - if (!event) return <Loading />; + const addTravelClickHandler = + user && vehicles?.length != 0 + ? toggleVehicleChoice + : () => toggleNewTravel({opened: true}); + + if (!event || loading) return <Loading />; return ( <Layout

@@ -107,21 +116,25 @@ onAdd={setIsAddToMyEvent}

onSave={onSave} onShare={onShare} /> - <TravelColumns toggle={toggleVehicleChoice} /> + <TravelColumns toggle={addTravelClickHandler} /> <Box className={classes.bottomRight}> <Fab - onClick={(user ? toggleVehicleChoice : toggleNewTravel)} + onClick={addTravelClickHandler} aria-label="add-car" color="primary" > {t('travel.creation.title')} </Fab> </Box> - <NewTravelDialog open={openNewTravel} toggle={toggleNewTravel} /> + <NewTravelDialog + context={openNewTravelContext} + toggle={() => toggleNewTravel({opened: false})} + /> <VehicleChoiceDialog open={openVehicleChoice} toggle={toggleVehicleChoice} toggleNewTravel={toggleNewTravel} + vehicles={vehicles} /> <AddToMyEventDialog event={event}

@@ -162,6 +175,9 @@ position: 'absolute',

bottom: theme.spacing(1), right: theme.spacing(6), width: 200, + [theme.breakpoints.down('sm')]: { + right: theme.spacing(1), + }, }, }));
M frontend/stores/useEventStore.tsfrontend/stores/useEventStore.ts

@@ -11,7 +11,7 @@ };

const useEventStore = create<State>((set, get) => ({ event: null, - setEvent: event => set({event}), + setEvent: event => set({event: formatEvent(event)}), setEventUpdate: eventUpdate => { const event = get().event; set({event: {...event, ...eventUpdate}});

@@ -19,5 +19,10 @@ },

isEditing: false, setIsEditing: isEditing => set({isEditing}), })); + +const formatEvent = (event: Event): Event => { + const travels = event.travels?.filter(travel => !!travel.vehicle); + return {...event, travels}; +}; export default useEventStore;
M frontend/theme.jsfrontend/theme.js

@@ -8,6 +8,10 @@ },

secondary: { main: '#FFEB3B', }, + error: { + light: '#efbcc4', + main: '#d4485e' + }, background: { default: '#F4F4FF', },