# 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)