Approving and Rejecting Job Offers With React and FastAPI
| UPDATED
Welcome to Part 20 of Up and Running with FastAPI. If you missed part 19, you can find it here.
This series is focused on building a full-stack application with the FastAPI framework. The app allows users to post requests to have their residence cleaned, and other users can select a cleaning project for a given hourly rate.
part 1
part 2
part 3
part 4
part 5
part 6
part 7
part 8
part 9
part 10
part 11
part 12
part 13
part 14
part 15
part 16
part 17
part 18
part 19
part 20
Previously, we implemented a system that allowed users to create offers for cleaning jobs they're interested in. Those offers are stored in redux
and are shown to users who have created them. However, there's currently no way for the owner of a cleaning job to see and select from those offers. We'll be creating a mechanism to do just that in this post.
First, we're going to make some improvements to our cleanings
redux slice.
Fetching All User-Owned Cleaning Jobs
Even though we have an adequate way of determining user ownership of a given cleaning resource, that information is transient. We have to wait until the user navigates to the cleaning job's page, and when the user navigates away, we no longer have access to that data.
Let's fix that by fetching all user-owned cleaning jobs as soon as the user is authenticated. We'll store that data in redux
and refactor our components to use our new structure.
Open up the redux/cleanings.js
file and add the following:
// ...other codeexport const FETCH_ALL_USER_OWNED_CLEANING_JOBS = "@@cleanings/FETCH_ALL_USER_OWNED_CLEANING_JOBS"export const FETCH_ALL_USER_OWNED_CLEANING_JOBS_SUCCESS = "@@cleanings/FETCH_ALL_USER_OWNED_CLEANING_JOBS_SUCCESS"export const FETCH_ALL_USER_OWNED_CLEANING_JOBS_FAILURE = "@@cleanings/FETCH_ALL_USER_OWNED_CLEANING_JOBS_FAILURE"export default function cleaningsReducer(state = initialState.cleanings, action = {}) { switch (action.type) { // ...other code case FETCH_ALL_USER_OWNED_CLEANING_JOBS: return { ...state, isLoading: true, } case FETCH_ALL_USER_OWNED_CLEANING_JOBS_SUCCESS: return { ...state, isLoading: false, error: null, data: { ...state.data, ...action.data.reduce((acc, job) => { acc[job.id] = job return acc }, {}), }, } case FETCH_ALL_USER_OWNED_CLEANING_JOBS_FAILURE: return { ...state, isLoading: false, error: action.error, } default: return state }}export const Actions = {}// ...other codeActions.fetchAllUserOwnedCleaningJobs = () => { return apiClient({ url: `/cleanings/`, method: `GET`, types: { REQUEST: FETCH_ALL_USER_OWNED_CLEANING_JOBS, SUCCESS: FETCH_ALL_USER_OWNED_CLEANING_JOBS_SUCCESS, FAILURE: FETCH_ALL_USER_OWNED_CLEANING_JOBS_FAILURE, }, options: { data: {}, params: {} }, })}
Alright, let's break this down.
We create 3 new action types and use them in our new fetchAllUserOwnedCleaningJobs
action creator, as well as in our cleaningsReducer
. Any GET
request to the /api/cleanings/
endpoint returns a response containing all cleaning jobs that the currently authenticated user is the owner of. We take those jobs and index them under state.cleanings.data
according to the id of each job. This is similar to how we handle updating our redux
slice when we create a new cleaning job, only with multiple entries instead of a single one.
Now here comes the fun part.
We're going to import this new action creator into our redux/auth.js
file and dispatch it as soon as we fetch the authenticated user info from our server. Let's also refactor our fetchUserFromToken
action creator to use the apiClient
abstraction that the rest of our action creators are using.
import initialState from "./initialState"import apiClient from "../services/apiClient"import { Actions as cleaningActions } from "./cleanings"import axios from "axios"// ...other codeActions.fetchUserFromToken = () => { return (dispatch) => { return dispatch( apiClient({ url: `/users/me/`, method: `GET`, types: { REQUEST: FETCHING_USER_FROM_TOKEN, SUCCESS: FETCHING_USER_FROM_TOKEN_SUCCESS, FAILURE: FETCHING_USER_FROM_TOKEN_FAILURE, }, options: { data: {}, params: {}, }, onSuccess: (res) => { dispatch(cleaningActions.fetchAllUserOwnedCleaningJobs()) return { success: true, status: res.status, data: res.data } }, }) ) }}// ...other code
This refactor feels right for a few reasons.
First, there's no longer a need to pass the access_token
to our fetchUserFromToken
action creator occasionally. Our apiClient
service pulls the access token from localStorage
regardless, so we can remove any unnecessary logic here.
We also have utilize the onSuccess
callback of our apiClient
as a clean way to dispatch the fetchAllUserOwnedCleaningJobs
action creator as soon as the successfully authenticated user info is returned from our FastAPI backend. Now our cleanings
slice is updated with user-owned cleaning jobs as soon as they log in.
Try it out. Login with a user that owns at least one cleaning job and check the state.cleanings.data
. We should see our cleaning resources there. This is great, but there's on slight problem.
Log out again and log in with a new user.
The cleaning jobs from the previously authenticated user are still there! That's definitely not good.
Handle REQUEST_LOG_USER_OUT Action Type
One of the benefits of redux
is that any reducer can listen to any action type and handle updates accordingly. We're going to instruct each reducer to listen for the REQUEST_LOG_USER_OUT
action type and reset their slice of state.
Start with the redux/cleanings.js
file:
import initialState from "./initialState"import { REQUEST_LOG_USER_OUT } from "./auth" import apiClient from "../services/apiClient"// ...other codeexport default function cleaningsReducer(state = initialState.cleanings, action = {}) { switch (action.type) { // ...other code case REQUEST_LOG_USER_OUT: return initialState.cleanings default: return state }}// ...other code
We import the REQUEST_LOG_USER_OUT
action type from the redux/auth.js
file and tell our cleaningsReducer
to return initialState.cleanings
whenever the user logs out. That should do a sufficient job of resetting state for our cleanings
slice.
Now do the same for the redux/offers.js
file:
import initialState from "./initialState"import { REQUEST_LOG_USER_OUT } from "./auth" import apiClient from "../services/apiClient"// ...other codeexport default function offersReducer(state = initialState.offers, action = {}) { switch (action.type) { // ...other code case REQUEST_LOG_USER_OUT: return initialState.offers default: return state }}// ...other code
Much better.
Go through the same flow now. Log in with a user that owns at least one cleaning job and then log out. Afterwards, authenticate with another user and make sure that none of those cleaning jobs are still stored in redux
.
If all is right, we can now use our recent improvements to refactor how we determine user ownership in our CleaningJobsView
component.
import React from "react"import { Routes, Route, useNavigate } from "react-router-dom"import { connect, useSelector } from "react-redux" import { Actions as cleaningActions } from "../../redux/cleanings"import { Actions as offersActions } from "../../redux/offers"// ...other codefunction CleaningJobView({ user, isLoading, offersError, cleaningError, offersIsLoading, currentCleaningJob, fetchCleaningJobById, createOfferForCleaning, clearCurrentCleaningJob, fetchUserOfferForCleaningJob}) { const { cleaning_id } = useParams() const navigate = useNavigate() const userOwnsCleaningResource = useSelector( (state) => state.cleanings.data?.[cleaning_id]?.owner === user?.id ) // ...other code}// ...other code
This is a clear case where the useSelector
hook is an improvement over the connect
higher order component. To replicate the same functionality with connect
, we would need to pass the entire state.cleanings.data
object to our CleaningJobView
component and then use cleaning_id
and user
to select the correct attribute. Of course we could also use a package like reselect
to do some filtering for us, but the current approach accomplishes our goal quite cleanly.
We can be confident that our new updates are working correctly, though it's always nice to test them out. Navigate to cleaning jobs that the authenticated user does and doesn't own, and ensure that the proper functionality is exhibited.
Check it out on Code Sandbox
phresh-frontend-part-8-fetching-all-user-owned-jobs
Go ahead and open back up the redux/offers.js
file. We're going to now make sure we can display a list of offers made for any cleaning job that the currently authenticated user owns.
Fetching All Offers For A Cleaning Job
As before, we'll need 3 new action types and an action creator.
// ...other codeexport const FETCH_ALL_OFFERS_FOR_CLEANING_JOB = "@@offers/FETCH_ALL_OFFERS_FOR_CLEANING_JOB"export const FETCH_ALL_OFFERS_FOR_CLEANING_JOB_SUCCESS = "@@offers/FETCH_ALL_OFFERS_FOR_CLEANING_JOB_SUCCESS"export const FETCH_ALL_OFFERS_FOR_CLEANING_JOB_FAILURE = "@@offers/FETCH_ALL_OFFERS_FOR_CLEANING_JOB_FAILURE"// ...other codeActions.fetchAllOffersForCleaningJob = ({ cleaning_id }) => { return apiClient({ url: `/cleanings/${cleaning_id}/offers/`, method: `GET`, types: { REQUEST: FETCH_ALL_OFFERS_FOR_CLEANING_JOB, SUCCESS: FETCH_ALL_OFFERS_FOR_CLEANING_JOB_SUCCESS, FAILURE: FETCH_ALL_OFFERS_FOR_CLEANING_JOB_FAILURE }, options: { data: {}, params: {} } })}
Our fetchAllOffersForCleaningJob
action creator takes in a cleaning_id
parameter and makes an HTTP GET
request to the /cleanings/{cleaning_id}/offers/
endpoint. This endpoint should return an array of offers that have been made for this cleaning job, or an empty array if there are none.
We haven't shown our reducers code here because we're going to make a relatively substantial change to it. At the moment we already have an updateStateWithOfferForCleaning
function that handles updating redux state for a single offer. However, it's not designed to handle more than one offer, and using it multiple times for the same response seems inefficient. Let's refactor our reducer to use a new function that accepts an array of offer objects.
// ...other codefunction updateStateWithOffersForCleaning(state, offers) { const cleaningId = offers?.[0]?.cleaning_id const offersIndexedByUserId = offers?.reduce((acc, offer) => { acc[offer.user_id] = offer return acc }, {}) return { ...state, isLoading: false, error: null, data: { ...state.data, ...(cleaningId ? { [cleaningId]: { ...(state.data[cleaningId] || {}), ...offersIndexedByUserId } } : {}) } }}export default function offersReducer(state = initialState.offers, action = {}) { switch (action.type) { case CREATE_OFFER_FOR_CLEANING_JOB: return { ...state, isLoading: true } case CREATE_OFFER_FOR_CLEANING_JOB_SUCCESS: return updateStateWithOffersForCleaning(state, [action.data]) case CREATE_OFFER_FOR_CLEANING_JOB_FAILURE: return { ...state, isLoading: false, error: action.error } case FETCH_USER_OFFER_FOR_CLEANING_JOB: return { ...state, isLoading: true } case FETCH_USER_OFFER_FOR_CLEANING_JOB_SUCCESS: return updateStateWithOffersForCleaning(state, [action.data]) case FETCH_USER_OFFER_FOR_CLEANING_JOB_FAILURE: return { ...state, isLoading: false // we don't really mind if this 404s // error: action.error, } case FETCH_ALL_OFFERS_FOR_CLEANING_JOB: return { ...state, isLoading: true } case FETCH_ALL_OFFERS_FOR_CLEANING_JOB_SUCCESS: return updateStateWithOffersForCleaning(state, action.data) case FETCH_ALL_OFFERS_FOR_CLEANING_JOB_FAILURE: return { ...state, isLoading: false, error: action.error } case REQUEST_LOG_USER_OUT: return initialState.offers default: return state }}// ...other code
Wow. That's not the prettiest refactor, but it'll get the job done. The updateStateWithOffersForCleaning
function now takes in an array of offers and conditionally updates state depending on if there are any offers in the array. If there are, it grabs the cleaning_id
attribute on the first one and then uses offers.reduce
to compose an object where each offer is keyed by its user_id
attribute. If there are no offesr in the array, then cleaningId
will be undefined
and the update will default to an empty object.
In our offersReducer
, we call the updateStateWithOffersForCleaning
function in three places, passing [action.data]
when there is only a single offer returned from our FastAPI backend, and just action.data
when the response is already an array.
There are other ways to accomplish the same goal here, but we have something functional at the moment, so let's use it.
Back in our CleaningJobView
component, make the following updates:
import React from "react"import { Routes, Route, useNavigate } from "react-router-dom"import { connect, useSelector, shallowEqual } from "react-redux" import { Actions as cleaningActions } from "../../redux/cleanings"import { Actions as offersActions } from "../../redux/offers"import { EuiAvatar, EuiButtonEmpty, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiPage, EuiPageBody, EuiPageContent, EuiPageContentBody, EuiLoadingSpinner, EuiTitle} from "@elastic/eui"import { CleaningJobCard, CleaningJobEditForm, NotFoundPage, PermissionsNeeded} from "../../components"import { useParams } from "react-router-dom"import styled from "styled-components"const StyledEuiPage = styled(EuiPage)` flex: 1;`const StyledFlexGroup = styled(EuiFlexGroup)` padding: 1rem;`function CleaningJobView({ user, isLoading, offersError, cleaningError, offersIsLoading, currentCleaningJob, fetchCleaningJobById, createOfferForCleaning, clearCurrentCleaningJob, fetchUserOfferForCleaningJob, fetchAllOffersForCleaningJob, }) { const { cleaning_id } = useParams() const navigate = useNavigate() const userOwnsCleaningResource = useSelector( (state) => state.cleanings.data?.[cleaning_id]?.owner === user?.id, shallowEqual ) const allOffersForCleaningJob = useSelector( (state) => state.offers.data?.[cleaning_id], shallowEqual ) React.useEffect(() => { if (cleaning_id && user?.username) { fetchCleaningJobById({ cleaning_id }) if (userOwnsCleaningResource) { fetchAllOffersForCleaningJob({ cleaning_id }) } else { fetchUserOfferForCleaningJob({ cleaning_id, username: user.username }) } } return () => clearCurrentCleaningJob() }, [ cleaning_id, fetchCleaningJobById, clearCurrentCleaningJob, userOwnsCleaningResource, fetchUserOfferForCleaningJob, fetchAllOffersForCleaningJob, user, ]) if (isLoading) return <EuiLoadingSpinner size="xl" /> if (!currentCleaningJob) return <EuiLoadingSpinner size="xl" /> if (!currentCleaningJob?.name) return <NotFoundPage /> const editJobButton = userOwnsCleaningResource ? ( <EuiButtonIcon iconType="documentEdit" aria-label="edit" onClick={() => navigate(`edit`)} /> ) : null const goBackButton = ( <EuiButtonEmpty iconType="sortLeft" size="s" onClick={() => navigate(`/cleaning-jobs/${currentCleaningJob.id}`)} > back to job </EuiButtonEmpty> ) const viewCleaningJobElement = ( <CleaningJobCard user={user} offersError={offersError} offersIsLoading={offersIsLoading} cleaningJob={currentCleaningJob} isOwner={userOwnsCleaningResource} createOfferForCleaning={createOfferForCleaning} /> ) const editCleaningJobElement = ( <PermissionsNeeded element={<CleaningJobEditForm cleaningJob={currentCleaningJob} />} isAllowed={userOwnsCleaningResource} /> ) return ( <StyledEuiPage> <EuiPageBody component="section"> <EuiPageContent verticalPosition="center" horizontalPosition="center" paddingSize="none"> <StyledFlexGroup alignItems="center" direction="row" responsive={false}> <EuiFlexItem> <EuiFlexGroup justifyContent="flexStart" alignItems="center" direction="row" responsive={false} > <EuiFlexItem grow={false}> <EuiAvatar size="xl" name={ currentCleaningJob.owner?.profile?.full_name || currentCleaningJob.owner?.username || "Anonymous" } initialsLength={2} imageUrl={currentCleaningJob.owner?.profile?.image} /> </EuiFlexItem> <EuiFlexItem> <EuiTitle> <p>@{currentCleaningJob.owner?.username}</p> </EuiTitle> </EuiFlexItem> </EuiFlexGroup> </EuiFlexItem> <EuiFlexItem grow={false}> <Routes> <Route path="/" element={editJobButton} /> <Route path="/edit" element={goBackButton} /> </Routes> </EuiFlexItem> </StyledFlexGroup> <EuiPageContentBody> <Routes> <Route path="/" element={viewCleaningJobElement} /> <Route path="/edit" element={editCleaningJobElement} /> <Route path="*" element={<NotFoundPage />} /> </Routes> </EuiPageContentBody> </EuiPageContent> <> {allOffersForCleaningJob ? ( <span>{Object.keys(allOffersForCleaningJob).length} offers</span> ) : null} </> </EuiPageBody> </StyledEuiPage> )}export default connect( (state) => ({ user: state.auth.user, isLoading: state.cleanings.isLoading, offersIsLoading: state.offers.isLoading, offersError: state.offers.error, cleaningError: state.cleanings.cleaningsError, currentCleaningJob: state.cleanings.currentCleaningJob }), { fetchCleaningJobById: cleaningActions.fetchCleaningJobById, clearCurrentCleaningJob: cleaningActions.clearCurrentCleaningJob, fetchUserOfferForCleaningJob: offersActions.fetchUserOfferForCleaningJob, fetchAllOffersForCleaningJob: offersActions.fetchAllOffersForCleaningJob, createOfferForCleaning: offersActions.createOfferForCleaning, })(CleaningJobView)
Now we're talking.
We map the fetchAllOffersForCleaningJob
function to our component props and call it in our useEffect
hook only in the case that the user is the owner of this cleaning job. Again we employ the useSelector
hook, this time to access all offers nested under the current cleaning job's id.
As a simple check, we display the number of offers at the bottom of the page - if there are any. We'll replace that with something more complete in a bit, but this will do for now.
Check it out on Code Sandbox
phresh-frontend-part-8-fetching-all-offers-for-a-cleaning-job
If we look in redux
, we can see every offer made for a given cleaning job stored in our state tree and available for our frontend to use. Looking at those offers, we have the user_id
available to us, but not any actual information about the user themselves. We'll want to remedy that.
Before we move on to any more UI code, let's switch gears for a moment and transition to our backend. We're going to tweak a couple endpoints, add functionality to our repositories, and update a few of our essential libraries.
Upgrading Pydantic and FastAPI
We started building this application months ago, and a lot has changed in the FastAPI world since then. When we began FastAPI was on version v0.55.1
. At the time this article was written, v0.62.0
was just released. That doesn't seem like a big change, but some signficant improvements have been made to the framework. A simple look at the release history will provide some insight on this matter.
Over the same time, the pydantic
library has also seen quite a few improvements. The updates from v.1.4
to the presently latest v1.7.3
can be seen here.
Changes resulting from these version upgrades are overall very positive and will help make our code a little bit easier to reason about. It will require some modifications to our pydantic
models, but nothing that will break the way our application currently works.
Wanting to take full advantage of what the latest and greatest has to offer, we're going to rebuild our docker container with the newest versions of both.
Open up the requirements.txt
file and update it like so:
# appfastapi==0.62.0uvicorn==0.11.3pydantic==1.7.3email-validator==1.1.1python-multipart==0.0.5# dbdatabases[postgresql]==0.3.1SQLAlchemy==1.3.16alembic==1.4.2# authpyjwt==1.7.1passlib[bcrypt]==1.7.2# devpytest==5.4.2pytest-asyncio==0.12.0pytest-xdist==1.32.0httpx==0.12.1asgi-lifespan==1.0.0
Then go ahead and rebuild the container with docker-compose up --build
.
Sit on that for a while and wait for it to finish.
Refactoring Offer and Evaluation Models
Once that's good to go, open up the models/offer.py
file and update it like so:
from typing import Optionalfrom enum import Enumfrom app.models.core import DateTimeModelMixin, CoreModelfrom app.models.user import UserPublicfrom app.models.cleaning import CleaningPublicclass OfferStatus(str, Enum): accepted = "accepted" rejected = "rejected" pending = "pending" cancelled = "cancelled" completed = "completed"class OfferBase(CoreModel): user_id: Optional[int] cleaning_id: Optional[int] status: Optional[OfferStatus] = OfferStatus.pendingclass OfferCreate(OfferBase): user_id: int cleaning_id: intclass OfferUpdate(CoreModel): status: OfferStatusclass OfferInDB(DateTimeModelMixin, OfferBase): user_id: int cleaning_id: intclass OfferPublic(OfferInDB): user: Optional[UserPublic] cleaning: Optional[CleaningPublic]
Our OfferPublic
model is now much simpler and has no need for aliasing fields or setting allow_population_by_field_name
in Config
. Both the user
and cleaning
attributes no longer use a Union
type and instead are simply an Optional
public model for each type. There's definitely a reduction in complexity and makes our model more explicit.
We're also requiring user_id
and cleaning_id
fields on the OfferInDB
model, as the corresponding table has NOT NULL
constraints for both of those columns in our database.
Let's take a similar approach to the EvaluationPublic
model in our models/evaluation.py
file:
# ...other codeclass EvaluationPublic(EvaluationInDB): owner: Optional[Union[int, UserPublic]] cleaner: Optional[UserPublic] cleaning: Optional[CleaningPublic]# ...other code
Both of these changes are minor updates that will make our lives easier as we refactor our repositories and endpoints.
With that out of the way, our next task is to add a populate_offer
method in our OffersRepository
. We'll use it to make sure that when we fetch offers for a single cleaning job, each offer is populated with the profile of the user who is making the offer.
Go ahead and open up the repositories/offers.py
file.
from typing import List, Unionfrom databases import Databasefrom app.db.repositories.base import BaseRepositoryfrom app.db.repositories.users import UsersRepositoryfrom app.models.cleaning import CleaningInDBfrom app.models.user import UserInDBfrom app.models.offer import OfferCreate, OfferUpdate, OfferInDB, OfferPublic# ...other codeclass OffersRepository(BaseRepository): def __init__(self, db: Database) -> None: super().__init__(db) self.users_repo = UsersRepository(db) # ...other code async def list_offers_for_cleaning( self, *, cleaning: CleaningInDB, populate: bool = True ) -> List[Union[OfferInDB, OfferPublic]]: offer_records = await self.db.fetch_all( query=LIST_OFFERS_FOR_CLEANING_QUERY, values={"cleaning_id": cleaning.id} ) offers = [OfferInDB(**o) for o in offer_records] if populate: return [await self.populate_offer(offer=offer) for offer in offers] return offers # ...other code async def populate_offer(self, *, offer: OfferInDB) -> OfferPublic: return OfferPublic( **offer.dict(), user=await self.users_repo.get_user_by_id(user_id=offer.user_id), # could populate cleaning here as well if needed )
We've attached an instance of our UsersRepository
to our OffersRepository
and then leverage it in our populate_offer
method that simply queries the user in question and populates our offer with the result.
Then in our list_offers_for_cleaning
method, we add a populate
parameter that determines whether or not the offers should include the full profile of the user who made the offer. Otherwise, we simply return the UserInDB
model without any populated fields.
As always when we make any substantial changes to the backend, run the test suite to make sure nothing is broken.
Good thing we did! A couple of tests are no longer passing. Time to dig in a little bit and see if we can't patch these up. The errors pytest
throws for us seem to indicate that our changes to the OffersPublic
and EvaluationPublic
model are the cause of the failures. We're no longer aliasing the cleaning
, cleaner
, and user
fields, so let's update our tests to use the cleaning_id
, cleaner_id
, and user_id
attributes instead.
Starting with the tests/test_offers.py
file:
# ...other codeclass TestAcceptOffers: async def test_cleaning_owner_can_accept_offer_successfully( self, app: FastAPI, create_authorized_client: Callable, test_user2: UserInDB, test_user_list: List[UserInDB], test_cleaning_with_offers: CleaningInDB, ) -> None: selected_user = random.choice(test_user_list) authorized_client = create_authorized_client(user=test_user2) res = await authorized_client.put( app.url_path_for( "offers:accept-offer-from-user", cleaning_id=test_cleaning_with_offers.id, username=selected_user.username, ) ) assert res.status_code == status.HTTP_200_OK accepted_offer = OfferPublic(**res.json()) assert accepted_offer.status == "accepted" assert accepted_offer.user_id == selected_user.id assert accepted_offer.cleaning_id == test_cleaning_with_offers.id # ...other code async def test_accepting_one_offer_rejects_all_other_offers( self, app: FastAPI, create_authorized_client: Callable, test_user2: UserInDB, test_user_list: List[UserInDB], test_cleaning_with_offers: CleaningInDB, ) -> None: selected_user = random.choice(test_user_list) authorized_client = create_authorized_client(user=test_user2) res = await authorized_client.put( app.url_path_for( "offers:accept-offer-from-user", cleaning_id=test_cleaning_with_offers.id, username=selected_user.username, ) ) assert res.status_code == status.HTTP_200_OK res = await authorized_client.get( app.url_path_for("offers:list-offers-for-cleaning", cleaning_id=test_cleaning_with_offers.id) ) assert res.status_code == status.HTTP_200_OK offers = [OfferPublic(**o) for o in res.json()] for offer in offers: if offer.user_id == selected_user.id: assert offer.status == "accepted" else: assert offer.status == "rejected"class TestCancelOffers: async def test_user_can_cancel_offer_after_it_has_been_accepted( self, app: FastAPI, create_authorized_client: Callable, test_user3: UserInDB, test_cleaning_with_accepted_offer: CleaningInDB, ) -> None: accepted_user_client = create_authorized_client(user=test_user3) res = await accepted_user_client.put( app.url_path_for("offers:cancel-offer-from-user", cleaning_id=test_cleaning_with_accepted_offer.id) ) assert res.status_code == status.HTTP_200_OK cancelled_offer = OfferPublic(**res.json()) assert cancelled_offer.status == "cancelled" assert cancelled_offer.user_id == test_user3.id assert cancelled_offer.cleaning_id == test_cleaning_with_accepted_offer.id # ...other code
Ah, looking at our changes makes us feel a little better about this refactor. Having the user
attribute correspond to either the user_id
or the UserPublic
model itself can get confusing. Same goes for the cleaning
attribute being either the cleaning_id
or the CleaningPublic
model. It might make sense to return to the rest of our code later on and make sure it all conforms to this new and improved approach.
We'll need to update the tests/test_evaluations.py
file as well:
# ...other codeclass TestGetEvaluations: """ Test that authenticated user who is not owner or cleaner can fetch a single evaluation Test that authenticated user can fetch all of a cleaner's evaluations Test that a cleaner's evaluations comes with an aggregate """ async def test_authenticated_user_can_get_evaluation_for_cleaning( self, app: FastAPI, create_authorized_client: Callable, test_user3: UserInDB, test_user4: UserInDB, test_list_of_cleanings_with_evaluated_offer: List[CleaningInDB], ) -> None: authorized_client = create_authorized_client(user=test_user4) res = await authorized_client.get( app.url_path_for( "evaluations:get-evaluation-for-cleaner", cleaning_id=test_list_of_cleanings_with_evaluated_offer[0].id, username=test_user3.username, ) ) assert res.status_code == status.HTTP_200_OK evaluation = EvaluationPublic(**res.json()) assert evaluation.cleaning_id == test_list_of_cleanings_with_evaluated_offer[0].id assert evaluation.cleaner_id == test_user3.id assert "test headline" in evaluation.headline assert "test comment" in evaluation.comment assert evaluation.professionalism >= 0 and evaluation.professionalism <= 5 assert evaluation.completeness >= 0 and evaluation.completeness <= 5 assert evaluation.efficiency >= 0 and evaluation.efficiency <= 5 assert evaluation.overall_rating >= 0 and evaluation.overall_rating <= 5 async def test_authenticated_user_can_get_list_of_evaluations_for_cleaner( self, app: FastAPI, create_authorized_client: Callable, test_user3: UserInDB, test_user4: UserInDB, test_list_of_cleanings_with_evaluated_offer: List[CleaningInDB], ) -> None: authorized_client = create_authorized_client(user=test_user4) res = await authorized_client.get( app.url_path_for("evaluations:list-evaluations-for-cleaner", username=test_user3.username) ) assert res.status_code == status.HTTP_200_OK evaluations = [EvaluationPublic(**e) for e in res.json()] assert len(evaluations) > 1 for evaluation in evaluations: assert evaluation.cleaner_id == test_user3.id assert evaluation.overall_rating >= 0# ...other code
No major changes here either. Just conversions from evaluation.cleaning
to evaluation.cleaning_id
and evaluation.cleaner
to evaluation.cleaner_id
.
Run the tests again and they should pass.
Modifying tests can be a tedious chore, but it's essential to keeping our code in good shape so that we can be confident each new refactor isn't doing any additional damage.
Let's now put our new updates to good use. Back in the frontend, we'll develop an interface that allows the owner of a cleaning job to select from any of the offers and accept one they like.
Accepting and Rejecting Offers
The first thing we're going to do is create a new component to host the list of offers made for a given cleaning job. We'll call it CleaningJobOffersTable.js
.
mkdir src/components/CleaningJobOffersTabletouch src/components/CleaningJobOffersTable/CleaningJobOffersTable.js
And we're going to build this component using an elastic-ui
elements that we haven't seen before - EuiBasicTable
.
import React from "react"import moment from "moment"import { EuiAvatar, EuiBasicTable, EuiButton, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiHealth, EuiPanel} from "@elastic/eui"import styled from "styled-components"const Wrapper = styled(EuiPanel)` max-width: 800px; margin: 1rem auto; padding: 2rem;`const UserAvatar = styled.div` display: flex; align-items: center; & > strong { margin-left: 0.6rem; }`const StyledH3 = styled.h3` margin-bottom: 1.5rem; font-weight: bold;`const renderStatus = (status) => { const color = { accepted: "success", pending: "primary", rejected: "danger" }[status || "pending"] return <EuiHealth color={color}>{status}</EuiHealth>}const emptyOffersMessage = ( <EuiEmptyPrompt title={<h3>No offers</h3>} titleSize="xs" body={`Looks like you don't have any offers yet.`} />)const capitalize = (str) => (str ? str[0].toUpperCase() + str.slice(1) : str)export default function Table({ offers, offersIsUpdating, offersIsLoading, handleAcceptOffer }) { const columns = [ { field: `user`, name: `User`, sortable: true, truncateText: true, mobileOptions: { render: (item) => <strong>{capitalize(item.user?.username)}</strong> }, render: (user) => ( <UserAvatar> <EuiAvatar size="m" name={user?.profile?.full_name || user?.username?.toUpperCase() || "Anonymous"} initialsLength={1} imageUrl={user?.profile?.image} /> <strong>{capitalize(user?.username)}</strong> </UserAvatar> ) }, { field: "created_at", name: "Sent At", truncateText: false, mobileOptions: { // Custom renderer for mobile view only render: (item) => <>{moment(new Date(item.created_at)).format("MM-DD-YYYY")}</> }, render: (created_at) => <>{moment(new Date(created_at)).format("MMMM do, YYYY")}</> }, { field: "status", name: "Status", truncateText: false, render: (status) => <>{renderStatus(status)}</> }, { name: "Actions", actions: [ { available: ({ status }) => status === "pending", width: "100%", render: ({ user, cleaning_id }) => ( <EuiButton isLoading={offersIsUpdating || offersIsLoading} onClick={() => handleAcceptOffer({ username: user.username, cleaning_id })} color="secondary" fill > Accept Offer </EuiButton> ) } ] } ] return ( <Wrapper> <EuiFlexGroup> <EuiFlexItem> <StyledH3>Offers</StyledH3> </EuiFlexItem> </EuiFlexGroup> <EuiBasicTable items={offers} itemId="user_id" columns={columns} hasActions={false} message={offers?.length ? null : emptyOffersMessage} rowHeader="user" /> </Wrapper> )}
One of the best component offered by elastic-ui
, EuiBasicTable
is an opinionated high level component that standardizes both display and injection. At its most simple it only accepts two properties:
items
are an array of objects that should be displayed in the table - one item per row. The exact item data that will be rendered in each cell in these rows is determined by thecolumns
property.columns
defines what columns the table has and how to extract item data to display each cell in each row.
Our component accepts an array of offers and passes them to the EuiBasicTable
as the items
prop. We define a columns
array containing objects that determine how each item should be displayed. We're choosing to display 4 columns:
user
- Here we show an avatar and theusername
of the user making the offer. We also make this field sortable. For mobile screens, we only show the user'susername
.created_at
- We use themoment.js
library to render a nicely formatted timestamp of when the offer was made.status
- The current status of the offer - pending, accepted, or rejected - rendered by anEuiHealth
component with a custom color for each status.actions
- An array of items that can be used to create special columns where we define per-row, item-level actions. We're rendering custom actions here, and so we actually passfalse
to thehasActions
prop in theEuiBasicTable
for a custom display.
The itemId
prop of EuiBasicTable
indicates what item attribute should be used as a unique identifier. We also specify a custom EuiEmptyPrompt
when no offers
are present. We pass that component as the message
prop to our table.
The custom action we define is an Accept Offer
button that the owner can use to decide to select an offer from a given user.
Make sure to export the component as well.
export { default as App } from "./App/App"export { default as Carousel } from "./Carousel/Carousel"export { default as CarouselTitle } from "./CarouselTitle/CarouselTitle"export { default as CleaningJobCard } from "./CleaningJobCard/CleaningJobCard"export { default as CleaningJobCreateForm } from "./CleaningJobCreateForm/CleaningJobCreateForm"export { default as CleaningJobEditForm } from "./CleaningJobEditForm/CleaningJobEditForm"export { default as CleaningJobOffersTable } from "./CleaningJobOffersTable/CleaningJobOffersTable" export { default as CleaningJobView } from "./CleaningJobView/CleaningJobView"export { default as CleaningJobsHome } from "./CleaningJobsHome/CleaningJobsHome"export { default as CleaningJobsPage } from "./CleaningJobsPage/CleaningJobsPage"export { default as LandingPage } from "./LandingPage/LandingPage"export { default as Layout } from "./Layout/Layout"export { default as LoginForm } from "./LoginForm/LoginForm"export { default as LoginPage } from "./LoginPage/LoginPage"export { default as Navbar } from "./Navbar/Navbar"export { default as NotFoundPage } from "./NotFoundPage/NotFoundPage"export { default as PermissionsNeeded } from "./PermissionsNeeded/PermissionsNeeded"export { default as ProfilePage } from "./ProfilePage/ProfilePage"export { default as ProtectedRoute } from "./ProtectedRoute/ProtectedRoute"export { default as RegistrationForm } from "./RegistrationForm/RegistrationForm"export { default as RegistrationPage } from "./RegistrationPage/RegistrationPage"
For each cleaning job page, we'll show our offers table below the CleaningJobCard
.
Open up the CleaningJobView.js
component and update it like so:
import React from "react"import { Routes, Route, useNavigate } from "react-router-dom"import { connect, useSelector, shallowEqual } from "react-redux"import { Actions as cleaningActions } from "../../redux/cleanings"import { Actions as offersActions } from "../../redux/offers"import { EuiAvatar, EuiButtonEmpty, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiPage, EuiPageBody, EuiPageContent, EuiPageContentBody, EuiLoadingSpinner, EuiTitle} from "@elastic/eui"import { CleaningJobCard, CleaningJobEditForm, CleaningJobOffersTable, NotFoundPage, PermissionsNeeded} from "../../components"import { useParams } from "react-router-dom"import styled from "styled-components"const StyledEuiPage = styled(EuiPage)` flex: 1;`const StyledFlexGroup = styled(EuiFlexGroup)` padding: 1rem;`function CleaningJobView({ user, isLoading, offersError, cleaningError, offersIsLoading, offersIsUpdating, currentCleaningJob, fetchCleaningJobById, createOfferForCleaning, clearCurrentCleaningJob, fetchUserOfferForCleaningJob, fetchAllOffersForCleaningJob}) { const { cleaning_id } = useParams() const navigate = useNavigate() const userOwnsCleaningResource = useSelector( (state) => state.cleanings.data?.[cleaning_id]?.owner === user?.id, shallowEqual ) const allOffersForCleaningJob = useSelector( (state) => state.offers.data?.[cleaning_id], shallowEqual ) React.useEffect(() => { if (cleaning_id && user?.username) { fetchCleaningJobById({ cleaning_id }) if (userOwnsCleaningResource) { fetchAllOffersForCleaningJob({ cleaning_id }) } else { fetchUserOfferForCleaningJob({ cleaning_id, username: user.username }) } } return () => clearCurrentCleaningJob() }, [ cleaning_id, fetchCleaningJobById, clearCurrentCleaningJob, userOwnsCleaningResource, fetchUserOfferForCleaningJob, fetchAllOffersForCleaningJob, user ]) if (isLoading) return <EuiLoadingSpinner size="xl" /> if (!currentCleaningJob) return <EuiLoadingSpinner size="xl" /> if (!currentCleaningJob?.name) return <NotFoundPage /> const editJobButton = userOwnsCleaningResource ? ( <EuiButtonIcon iconType="documentEdit" aria-label="edit" onClick={() => navigate(`edit`)} /> ) : null const goBackButton = ( <EuiButtonEmpty iconType="sortLeft" size="s" onClick={() => navigate(`/cleaning-jobs/${currentCleaningJob.id}`)} > back to job </EuiButtonEmpty> ) const viewCleaningJobElement = ( <CleaningJobCard user={user} offersError={offersError} offersIsLoading={offersIsLoading} cleaningJob={currentCleaningJob} isOwner={userOwnsCleaningResource} createOfferForCleaning={createOfferForCleaning} /> ) const editCleaningJobElement = ( <PermissionsNeeded element={<CleaningJobEditForm cleaningJob={currentCleaningJob} />} isAllowed={userOwnsCleaningResource} /> ) const cleaningJobOffersTableElement = userOwnsCleaningResource ? ( <CleaningJobOffersTable offers={allOffersForCleaningJob ? Object.values(allOffersForCleaningJob) : []} offersIsUpdating={offersIsUpdating} offersIsLoading={offersIsLoading} /> ) : null return ( <StyledEuiPage> <EuiPageBody component="section"> <EuiPageContent verticalPosition="center" horizontalPosition="center" paddingSize="none"> <StyledFlexGroup alignItems="center" direction="row" responsive={false}> <EuiFlexItem> <EuiFlexGroup justifyContent="flexStart" alignItems="center" direction="row" responsive={false} > <EuiFlexItem grow={false}> <EuiAvatar size="xl" name={ currentCleaningJob.owner?.profile?.full_name || currentCleaningJob.owner?.username || "Anonymous" } initialsLength={2} imageUrl={currentCleaningJob.owner?.profile?.image} /> </EuiFlexItem> <EuiFlexItem> <EuiTitle> <p>@{currentCleaningJob.owner?.username}</p> </EuiTitle> </EuiFlexItem> </EuiFlexGroup> </EuiFlexItem> <EuiFlexItem grow={false}> <Routes> <Route path="/" element={editJobButton} /> <Route path="/edit" element={goBackButton} /> </Routes> </EuiFlexItem> </StyledFlexGroup> <EuiPageContentBody> <Routes> <Route path="/" element={viewCleaningJobElement} /> <Route path="/edit" element={editCleaningJobElement} /> <Route path="*" element={<NotFoundPage />} /> </Routes> </EuiPageContentBody> </EuiPageContent> <Routes> <Route path="/" element={cleaningJobOffersTableElement} /> </Routes> </EuiPageBody> </StyledEuiPage> )}export default connect( (state) => ({ user: state.auth.user, isLoading: state.cleanings.isLoading, offersIsLoading: state.offers.isLoading, offersIsUpdating: state.offers.isUpdating, offersError: state.offers.error, cleaningError: state.cleanings.cleaningsError, currentCleaningJob: state.cleanings.currentCleaningJob }), { fetchCleaningJobById: cleaningActions.fetchCleaningJobById, clearCurrentCleaningJob: cleaningActions.clearCurrentCleaningJob, fetchUserOfferForCleaningJob: offersActions.fetchUserOfferForCleaningJob, fetchAllOffersForCleaningJob: offersActions.fetchAllOffersForCleaningJob, createOfferForCleaning: offersActions.createOfferForCleaning })(CleaningJobView)
After importing our newly-minted CleaningJobOffersTable
component, we provide it with any offers for the current cleaning job and render it at the root path only if the user is the owner of the cleaning job.
If we navigate to the page of a cleaning job with pending offers, we should see them displayed nicely in an EuiBasicTable
.
All that's left to do is make sure that the Accept Offer
button is fully functional.
Open up the redux/offers.js
file and update it one more time.
// ...other codeexport const ACCEPT_USERS_OFFER_FOR_CLEANING_JOB = "@@offers/ACCEPT_OFFER_FROM_USER_FOR_CLEANING_JOB"export const ACCEPT_USERS_OFFER_FOR_CLEANING_JOB_SUCCESS = "@@offers/ACCEPT_OFFER_FROM_USER_FOR_CLEANING_JOB_SUCCESS"export const ACCEPT_USERS_OFFER_FOR_CLEANING_JOB_FAILURE = "@@offers/ACCEPT_OFFER_FROM_USER_FOR_CLEANING_JOB_FAILURE"// ...other codeexport default function offersReducer(state = initialState.offers, action = {}) { switch (action.type) { // ...other code case ACCEPT_USERS_OFFER_FOR_CLEANING_JOB: return { ...state, isUpdating: true, } case ACCEPT_USERS_OFFER_FOR_CLEANING_JOB_SUCCESS: return { ...state, isUpdating: false, error: null, } case ACCEPT_USERS_OFFER_FOR_CLEANING_JOB_FAILURE: return { ...state, isUpdating: false, error: action.error, } case REQUEST_LOG_USER_OUT: return initialState.offers default: return state }}// ...other codeActions.acceptUsersOfferForCleaningJob = ({ username, cleaning_id }) => { return (dispatch) => { return dispatch( apiClient({ url: `/cleanings/${cleaning_id}/offers/${username}/`, method: `PUT`, types: { REQUEST: ACCEPT_USERS_OFFER_FOR_CLEANING_JOB, SUCCESS: ACCEPT_USERS_OFFER_FOR_CLEANING_JOB_SUCCESS, FAILURE: ACCEPT_USERS_OFFER_FOR_CLEANING_JOB_FAILURE }, options: { data: {}, params: {} }, onSuccess: (res) => { dispatch(Actions.fetchAllOffersForCleaningJob({ cleaning_id })) return { success: true, status: res.status, data: res.data } } }) ) }}
Alright. More of the same going on here.
3 new action types, an acceptUsersOfferForCleaningJob
action creator, and state updates in our reducer for each action type. We use the onSuccess
callback in our action creator to fetch all the offers for the current cleaning job as soon as the user has selected one. This way, our state will be updated with offers showing the correct status.
Time to put it them to good use.
// ...other codefunction CleaningJobView({ user, isLoading, offersError, cleaningError, offersIsLoading, offersIsUpdating, currentCleaningJob, fetchCleaningJobById, createOfferForCleaning, clearCurrentCleaningJob, fetchUserOfferForCleaningJob, fetchAllOffersForCleaningJob, acceptUsersOfferForCleaningJob, }) { // ...other code const cleaningJobOffersTableElement = userOwnsCleaningResource ? ( <CleaningJobOffersTable offers={allOffersForCleaningJob ? Object.values(allOffersForCleaningJob) : []} offersIsUpdating={offersIsUpdating} offersIsLoading={offersIsLoading} handleAcceptOffer={acceptUsersOfferForCleaningJob} /> ) : null // ...other code}export default connect( (state) => ({ user: state.auth.user, isLoading: state.cleanings.isLoading, offersIsLoading: state.offers.isLoading, offersIsUpdating: state.offers.isUpdating, offersError: state.offers.error, cleaningError: state.cleanings.cleaningsError, currentCleaningJob: state.cleanings.currentCleaningJob, }), { fetchCleaningJobById: cleaningActions.fetchCleaningJobById, clearCurrentCleaningJob: cleaningActions.clearCurrentCleaningJob, fetchUserOfferForCleaningJob: offersActions.fetchUserOfferForCleaningJob, fetchAllOffersForCleaningJob: offersActions.fetchAllOffersForCleaningJob, createOfferForCleaning: offersActions.createOfferForCleaning, acceptUsersOfferForCleaningJob: offersActions.acceptUsersOfferForCleaningJob, })(CleaningJobView)
And just like that, we're in business!
Give it a whirl. Navigate to that same page and click the Accept Offer
button.
If all goes well, the UI should be updated after a short delay, and we should see the new state represented in our CleaningJobOffersTable
component. Now log out and authenticate with a user who made one of the offers. The beta badge on the CleaningJobCard
should also display either OFFER ACCEPTED
or OFFER REJECTED
depending on the user.
Check it out on Code Sandbox
phresh-frontend-part-8-accepting-and-rejecting-offers
Amazing.
Wrapping Up And Resources
We accomplished quite a bit in this post, with the majority of new code being added to our frontend repo. Our offers
redux file has filled out significantly, and we refactored our cleanings
slice to fetch all user owned cleaning jobs as soon as they're authenticated. On top of that, we made sure to purge redux
of all sensitive data whenever a user logs out.
At the same time, we upgraded our FastAPI and pydantic
libraries to their latest versions, refactored some of our models, and implemented the ability to populate offers with their respective owners. Afterwards, we fixed a few tests that were broken in the process.
Propsective cleaners can now create offers for cleaning jobs they're interested in, and the owners of those jobs can choose which offer to accept/reject. On the offers front, all that's left is implementing rescinding and cancelling offers functionality.
Before we go there, we're going to implement a "feed" page. We'll need to design a layout for prospective cleaners, showing them available jobs and any updates that we deem relevant.
- FastAPI release history
- Pydantic release history
- Elastic UI Table docs
- Elastic UI Health docs
- Elastic UI Avatar docs
- Elastic UI Panel docs