# Changes between end of Lecture 4 and Beginning of Lecture 5
## Bug Fix for UserEvents
I was accidentally trying to destroy an event within the user_events controller instead of a user_event. I had this:
#### app/controllers/user_events_controller.rb
```rb
def destroy
user_event = current_user.events.find(params[:id])
user_event.destroy
render json: user_event, status: :ok
end
```
Instead, I needed this:
```rb
def destroy
user_event = current_user.user_events.find(params[:id])
user_event.destroy
render json: user_event, status: :ok
end
```
## Added Frontend for Events
### EventsContainer
- load events and groups from API
- Define callbacks for interacting with API
- removeRsvpToEvent
- cancelEvent
- rsvpToEvent
- createEvent
- Render routes for EventsList and EventDetail and pass appropriate data and callbacks as props
#### client/src/components/EventsContainer.js
```js
import React, { useState, useEffect } from 'react'
import { Switch, Route } from 'react-router-dom'
import EventsList from './EventsList'
import EventDetail from './EventDetail'
function EventsContainer() {
const [events, setEvents] = useState([])
const [groups, setGroups] = useState([])
useEffect(() => {
fetch(`/events`)
.then(res => res.json())
.then(events => setEvents(events))
fetch(`/groups`)
.then(res => res.json())
.then(groups => setGroups(groups))
},[])
const removeRsvpToEvent = (eventId) => {
const event = events.find(event => event.id === eventId)
return fetch(`/user_events/${event.user_event.id}`, {
method: "DELETE"
})
.then(res => {
if (res.ok) {
// if the event is the one we just removed an rsvp
// for, set its user_event property in state to
// undefined; If not, leave the event as it is
const updatedEvents = events.map((event) => {
if (event.id === eventId) {
return {
...event,
user_event: undefined
}
} else {
return event
}
})
setEvents(updatedEvents)
}
})
}
const cancelEvent = (eventId) => {
return fetch(`/events/${eventId}`, {
method: "DELETE"
})
.then(res => {
if (res.ok) {
const updatedEvents = events.filter(event => event.id !== eventId)
setEvents(updatedEvents)
}
})
}
const rsvpToEvent = (eventId) => {
return fetch('/user_events', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
event_id: eventId
})
})
.then(res => {
if (res.ok) {
return res.json()
} else {
return res.json().then(errors => Promise.reject(errors))
}
})
.then(userEvent => {
// if the event is the one we just rsvp'd to
// add a user_event property in state and set
// it to the userEvent; if not, leave it as is
const updatedEvents = events.map((event) => {
if (event.id === eventId) {
return {
...event,
user_event: userEvent
}
} else {
return event
}
})
setEvents(updatedEvents)
})
}
const createEvent = (formData) => {
return fetch("/events", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(formData)
})
.then(res => {
if (res.ok) {
return res.json()
} else {
return res.json().then(errors => Promise.reject(errors))
}
})
.then(event => {
setEvents(events.concat(event))
})
}
return (
<div>
<Switch>
<Route
exact
path="/events"
>
<EventsList
events={events}
groups={groups}
cancelEvent={cancelEvent}
removeRsvpToEvent={removeRsvpToEvent}
rsvpToEvent={rsvpToEvent}
createEvent={createEvent}
/>
</Route>
<Route
exact
path="/events/:id"
render={({ match }) => {
return <EventDetail
eventId={match.params.id}
cancelEvent={cancelEvent}
removeRsvpToEvent={removeRsvpToEvent}
rsvpToEvent={rsvpToEvent}
/>
}}
/>
</Switch>
</div>
)
}
export default EventsContainer
```
### EventsList
- Show list of links to events with a button to submit or cancel an rsvp and a button to cancel the event (if the current user created it)
- Show a form that can be used to add a new event to a group (belonging to the logged in user)
- form has a `group_name` input that is allowed in `event_params` in the controller to pass through `.new` into the `group_name=` method in the `Event` model that will find or create a group with the given name and associated it with the event (assigning the foreign key for us)
- Note, this requires that we change the `EventsController` to allow `group_name` through in our `event_params` and also that we define `group_name=(name)` within our `Event` model:
#### app/controllers/events_controller.rb
```rb
def event_params
params.permit(:title, :description, :location, :start_time, :end_time, :group_name, :cover_image_url)
end
```
#### app/models/event.rb
```rb
def group_name=(group_name)
self.group = Group.find_or_create_by(name: group_name)
end
```
#### src/components/EventsList.js
```js
import React, { useState } from 'react'
import { Link } from 'react-router-dom'
function EventsList({ events, groups, removeRsvpToEvent, cancelEvent, rsvpToEvent, createEvent }) {
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [location, setLocation] = useState('')
const [startTime, setStartTime] = useState(now.toISOString().slice(0, 16))
const [endTime, setEndTime] = useState('')
const [groupName, setGroupName] = useState('')
const rsvpOrCancelButton = (event) => {
if (event.user_event) {
return <button onClick={() => removeRsvpToEvent(event.id)}>Cancel RSVP</button>
} else {
return <button onClick={() => rsvpToEvent(event.id)}>RSVP for event</button>
}
}
const cancelEventButton = (event) => {
if (event.user_is_creator) {
return <button onClick={() => cancelEvent(event.id)}>Cancel Event</button>
}
}
const handleSubmit = (e) => {
e.preventDefault()
createEvent({
title,
description,
location,
start_time: startTime,
end_time: endTime,
group_name: groupName
})
setTitle('')
setDescription('')
setLocation('')
setStartTime('')
setEndTime('')
setGroupName('')
}
return (
<div>
<h1>Events</h1>
{events.map(event => (
<p><Link to={`events/${event.id}`}>{event.title}</Link> --- {rsvpOrCancelButton(event)} --- {cancelEventButton(event)}</p>
))}
<h3>Add Event</h3>
<form onSubmit={handleSubmit}>
<p>
<label htmlFor="title">Title </label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
name="title"
/>
</p>
<p>
<label htmlFor="description"> Description </label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
name="description"
/>
</p>
<p>
<label htmlFor="name"> Location </label>
<input
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
name="location"
/>
</p>
<p>
<label htmlFor="start_time"> Start Time </label>
<input
type="datetime-local"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
name="start_time"
/>
</p>
<p>
<label htmlFor="end_time"> End Time </label>
<input
type="datetime-local"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
name="end_time"
/>
</p>
<p>
<label htmlfor="group_name">Group Name </label>
<input
type="text"
name="group_name"
value={groupName}
list="groups"
onChange={(e) => setGroupName(e.target.value)}
/>
<datalist id="groups">
{groups.map(group => <option>{group.name}</option>)}
</datalist>
</p>
{" "}<button type="submit">Add Event</button>
</form>
</div>
)
}
export default EventsList
```
## EventDetail
- Display details for an event including the creator, a link to the group that's hosting the event, a description, time (start and end) and a list of the attendees.
- Display buttons for rsvping to an event or canceling an existing rsvp (if logged in user has already rsvp'd)
- to support this functionality, we need to update our EventSerializer to use the current_user method to retrieve the user_event for this event
#### app/serializers/event_index_serializer.rb
- ```
class EventIndexSerializer < ActiveModel::Serializer
attributes :id, :title, :description, :location, :start_time, :end_time, :user_event, :user_is_creator, :time
def user_event
current_user.user_events.find_by(event_id: object.id)
end
def user_is_creator
current_user == object.user
end
def time
"From #{object.start_time.strftime('%A, %m/%d/%y at %I:%m %p')} to #{object.end_time.strftime('%A, %m/%d/%y at %I:%m %p')}"
end
end
```
- If our API responds with no user_event for an event, it means the current user has not rsvp'd for that event.
- If they have rsvp'd the `user_event` will include the `id` that we'll need to include in the URL of the delete request to remove the rsvp.
- We'll be displaying a button here for canceling the event if the currently logged in user is the one that created the event. The `user_is_created` attribute that we're adding to the serializer will support this functionality server side. We'll still need to ensure on the server side that only the user who created an event will be able to delete it.
#### client/src/components/EventDetail.js
- fetch details for the Event from the /events/:id show endpoint.
- we're doing this within a useCallback hook so that we can trigger the fetch both on initial load and also after the user updates their rsvp.
- display button to cancel event (only to user who created the event).
- redirect to /events upon cancellation
- display an rsvp button (cancel button to those who already have rsvp'd)
- send another fetch to the callback to update the event in state after invoking the callback for creating or deleting the user_event
- we need this currently because the piece of state for `events` in the container component is updated by invoking the appropriate callback, but the `event` piece of state in the `EventDetail` component is coming from the API upon loading the component within `useEffect` (it's not coming in as a prop from the parent that would update and trigger a re-render after the callback)
```js
import React, { useState, useEffect, useCallback } from 'react'
import { useHistory } from 'react-router-dom'
import { Link } from 'react-router-dom'
function EventDetail({ eventId, removeRsvpToEvent, rsvpToEvent, cancelEvent }) {
const [event, setEvent] = useState(null)
const history = useHistory();
const fetchEventCallback = useCallback(
() => {
fetch(`/events/${eventId}`)
.then(res => res.json())
.then(event => setEvent(event))
},
[eventId],
)
useEffect(() => {
fetchEventCallback()
}, [fetchEventCallback])
const cancelEventButton = (event) => {
if (event.user_is_creator) {
return (
<p>
<button
onClick={handleCancel}>Cancel Event</button>
</p>
)
}
}
const handleCancel = (e) => {
cancelEvent(event.id);
history.push('/events')
}
const rsvpButton = (event) => {
if (event.user_event) {
return (
<button
onClick={() => {
removeRsvpToEvent(event.id).then(() => fetchEventCallback())
}
}>
Cancel RSVP
</button >
)
} else {
return (
<button
onClick={() => {
rsvpToEvent(event.id).then(() => fetchEventCallback())
}
}>
RSVP for event
</button>
)
}
}
if(!event) { return <div></div>}
return (
<div>
<h1>{event.title}</h1>
{cancelEventButton(event)}
<small>Created by {event.creator} for <Link to={`/groups/${event.group.id}`}>{event.group.name}</Link></small>
<p>{event.description}</p>
<p>{event.time}</p>
<p>Location: {event.location}</p>
<p>{rsvpButton(event)}</p>
<ul>
{event.attendees.map(attendee => (
<li>{attendee.username}</li>
))}
</ul>
</div>
)
}
export default EventDetail
```
## Higher Level changes
I added a route and link to App.js
#### client/src/App.js
```js
import './App.css';
import GroupsContainer from './components/GroupsContainer'
import EventsContainer from './components/EventsContainer'
import { Switch, Route, NavLink, BrowserRouter as Router } from 'react-router-dom'
function App() {
return (
<div className="App">
<Router>
<NavLink to="/groups">Groups</NavLink>{" - "}
<NavLink to="/events">Events</NavLink>
<Switch>
<Route path="/groups">
<GroupsContainer />
</Route>
<Route path="/events">
<EventsContainer />
</Route>
</Switch>
</Router>
</div>
);
}
export default App;
```
I added some styling to the forms and background in App.css
#### client/src/App.css
```css
body {
background-color: #ddffaa;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Cpolygon fill='%23AE9' points='120 120 60 120 90 90 120 60 120 0 120 0 60 60 0 0 0 60 30 90 60 120 120 120 '/%3E%3C/svg%3E");
}
.App {
background-color: rgba(255,255,255, 0.95);
width: 80%;
margin: 2em auto;
padding: 2em;
min-height: 100vh;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
form input, form textarea {
width: 16rem;
padding: 0.5rem;
}
form label {
display: inline-block;
width: 6rem;
vertical-align: top;
padding-top: 0.5rem;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
```
### Here's what my serializers/controllers were updated to:
#### app/serializers/event_index_serializer.rb
```rb
class EventIndexSerializer < ActiveModel::Serializer
attributes :id, :title, :description, :location, :start_time, :end_time, :user_event, :user_is_creator, :time
def user_event
current_user.user_events.find_by(event_id: object.id)
end
def user_is_creator
current_user == object.user
end
def time
"From #{object.start_time.strftime('%A, %m/%d/%y at %I:%m %p')} to #{object.end_time.strftime('%A, %m/%d/%y at %I:%m %p')}"
end
end
```
#### app/serializers/event_serializer.rb
```rb
class EventSerializer < EventIndexSerializer
has_many :attendees
belongs_to :group, serializer: EventGroupSerializer
attribute :creator
def creator
object.user.username
end
end
```
#### app/serializers/event_group_serializer.rb
```rb
class EventGroupSerializer < ActiveModel::Serializer
attributes :id, :name
end
```
#### app/controllers/events_controller.rb
- Add includes user_events here to avoid N+1 query.
```rb
class EventsController < ApplicationController
def index
events = Event.all.includes(:user_events)
render json: events, each_serializer: EventIndexSerializer
end
def show
event = Event.find(params[:id])
render json: event
end
def create
event = current_user.created_events.new(event_params)
if event.save
render json: event, status: :created
else
render json: event.errors, status: :unprocessable_entity
end
end
def update
event = current_user.events.find(params[:id])
if event.update(event_params)
render json: event, status: :ok
else
render json: event.errors, status: :unprocessable_entity
end
end
def destroy
event = current_user.created_events.find(params[:id])
event.destroy
# we'll render the event as json in case we want to enable undo functionality from the frontend.
render json: event, status: :ok
end
private
def event_params
params.permit(:title, :description, :location, :start_time, :end_time, :group_name, :cover_image_url)
end
end
```
## Updating the way we handle groups to incorporate the current_user into the serializers to handle joining/leaving groups without the reduce on the client side
### Controllers and Serializers
#### app/controllers/groups_controller.rb
```rb
class GroupsController < ApplicationController
def index
groups = Group.all.includes(:user_groups)
render json: groups, each_serializer: GroupIndexSerializer
end
def show
render json: Group.find(params[:id])
end
def create
group = Group.new(group_params)
if group.save
render json: group, status: :created
else
render json: group.errors, status: :unprocessable_entity
end
end
private
def group_params
params.permit(:name, :location)
end
end
```
#### app/serializers/group_index_serializer.rb
```rb
class GroupIndexSerializer < ActiveModel::Serializer
attributes :id, :name, :location, :user_group
def user_group
current_user.user_groups.find_by(group_id: object.id)
end
end
```
I removed the `GroupShowSerializer` and chose to use the default `GroupSerializer` instead.
#### app/serializers/group_serializer.rb
```rb
class GroupSerializer < GroupIndexSerializer
has_many :events
has_many :members
end
```
### Frontend changes for Groups
Main change was to remove the userGroups from the `GroupsContainer` in favor of allowing the API to manage the user_group relationship between the current_user and each group we get back from our API request. This means we don't need the reduce clientside anymore.
#### client/src/components/GroupsContainer.js
Changes:
- remove `userGroups` from state
- remove fetch call to `/user_groups` in useEffect
- remove `groupsWithMembership()` function
- pass `groups` piece of state directly into `GroupsList` (instead of `groupsWithMembership()`)
- Adjust promise callbacks in `joinGroup` and `leaveGroup` to update the `groups` piece of state and the `user_group` property for the appropriate group instead of updating `userGroups` in state.
- pass the `leaveGroup` and `joinGroup` callbacks to the `GroupDetail` component as well as `GroupsList`
```js
import React, { useState, useEffect } from 'react'
import { Switch, Route } from 'react-router-dom'
import GroupsList from './GroupsList'
import GroupDetail from './GroupDetail'
function GroupsContainer() {
const [groups, setGroups] = useState([]);
useEffect(() => {
fetch("/groups")
.then(res => res.json())
.then(groups => setGroups(groups))
}, [])
const leaveGroup = (groupId) => {
let userGroupId = groups.find(group => group.id === groupId).user_group.id
return fetch(`/user_groups/${userGroupId}`, {
method: 'DELETE'
})
.then(res => {
if (res.ok) {
const updatedGroups = groups.map(group => {
if (group.id === groupId) {
return {
...group,
user_group: undefined
}
} else {
return group
}
})
setGroups(updatedGroups)
}
})
}
const joinGroup = (groupId) => {
return fetch('/user_groups', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
group_id: groupId
})
})
.then(res => {
if (res.ok) {
return res.json()
} else {
return res.json().then(errors => Promise.reject(errors))
}
})
.then(userGroup => {
const updatedGroups = groups.map(group => {
if (group.id === groupId) {
return {
...group,
user_group: userGroup
}
} else {
return group
}
})
setGroups(updatedGroups)
})
}
const createGroup = (formData) => {
return fetch("/groups", {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
})
.then(res => {
if (res.ok) {
return res.json()
} else {
return res.json().then(errors => Promise.reject(errors))
}
})
.then(group => {
setGroups(groups.concat(group))
})
}
return (
<div>
<Switch>
<Route
exact
path="/groups"
>
<GroupsList
groups={groups}
leaveGroup={leaveGroup}
joinGroup={joinGroup}
createGroup={createGroup}
/>
</Route>
<Route
exact
path="/groups/:id"
render={({ match }) => {
return (
<GroupDetail
groupId={match.params.id}
leaveGroup={leaveGroup}
joinGroup={joinGroup}
/>
)
}}
/>
</Switch>
</div>
)
}
export default GroupsContainer
```
#### client/src/components/GroupsList.js
Changes
- on line 9 replaced
```js
if (group.userGroup) {
```
with
```js
if (group.user_group) {
```
and that was it!
#### client/src/components/GroupDetail.js
Changes:
- import { Link } from react router.
- Accept `leaveGroup` and `joinGroup` callbacks as props
- Add `useCallback` hook to wrap fetch to groups in `fetchGroupCallback`
- this callback will be used both with `useEffect` and also within promise callbacks that are triggered when the callbacks to join/leave a group resolve (both callbacks return promises)
- add a `leaveOrJoinButton` function that will render the correct button based on whether the `group` has a `user_group` or not.
- reorganize conditional render based on the loading status of the group
- add call to `leaveOrJoinButton` function to ensure the button appears in the UI
- Add Links to list of the groups events.
```js
import React, { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
function GroupDetail({ groupId, leaveGroup, joinGroup }) {
const [group, setGroup] = useState(null)
const fetchGroupCallback = useCallback(() => {
fetch(`/groups/${groupId}`)
.then(res => res.json())
.then(group => setGroup(group))
}, [groupId])
useEffect(() => {
fetchGroupCallback()
}, [fetchGroupCallback])
const leaveOrJoinButton = (group) => {
if (group.user_group) {
return (
<button
onClick={() => leaveGroup(group.id).then(() => fetchGroupCallback())}
>
Leave Group
</button>
)
} else {
return (
<button
onClick={() => joinGroup(group.id).then(() => fetchGroupCallback())}
>
Join Group
</button>
)
}
}
if(!group){ return <div></div>}
return (
<div>
<h1>{group.name}</h1>
{leaveOrJoinButton(group)}
<h2>Members</h2>
<ul>
{group.members?.map(member => <li>{member.username}</li>)}
</ul>
<h2>Events</h2>
<ul>
{group.events?.map((event) => <li><Link to={`/events/${event.id}`}>{event.title}</Link></li>)}
</ul>
</div>
)
}
export default GroupDetail
```
Next, I proceeded to add in the client side changes necessary to support authentication.
Check that out [here](https://hackmd.io/@dlm/phase4-lecture5-adding-authentication)