Approving and Rejecting Job Offers With React and FastAPI

 | UPDATED

undraw svg icon

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.

Up And Running With FastAPI

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:

redux/cleanings.js
// ...other code
export 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 code
Actions.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.

redux/auth.js
import initialState from "./initialState"
import apiClient from "../services/apiClient"
import { Actions as cleaningActions } from "./cleanings"
import axios from "axios"
// ...other code
Actions.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:

redux/cleanings.js
import initialState from "./initialState"
import { REQUEST_LOG_USER_OUT } from "./auth"
import apiClient from "../services/apiClient"
// ...other code
export 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:

redux/offers.js
import initialState from "./initialState"
import { REQUEST_LOG_USER_OUT } from "./auth"
import apiClient from "../services/apiClient"
// ...other code
export 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.

CleaningJobView.js
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 code
function 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.

redux/offers.js
// ...other code
export 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 code
Actions.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.

redux/offers.js
// ...other code
function 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:

CleaningJobView.js
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:

requirements.txt
# app
fastapi==0.62.0
uvicorn==0.11.3
pydantic==1.7.3
email-validator==1.1.1
python-multipart==0.0.5
# db
databases[postgresql]==0.4.2
SQLAlchemy==1.3.16
alembic==1.4.2
# auth
pyjwt==2.0.1
passlib[bcrypt]==1.7.2
# dev
pytest==6.2.2
pytest-asyncio==0.14.0
httpx==0.16.1
asgi-lifespan==1.0.1

Then go ahead and rebuild the container with docker-compose up --build.

Sit on that for a while and wait for it to finish.

Populating Offers

With that out of the way, our main task is to add a populate_offer method to 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.

repositories/offers.py
from typing import List, Union
from databases import Database
from app.db.repositories.base import BaseRepository
from app.db.repositories.users import UsersRepository
from app.models.cleaning import CleaningInDB
from app.models.user import UserInDB
from app.models.offer import OfferCreate, OfferUpdate, OfferInDB, OfferPublic
# ...other code
class 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.

Fortunately, this time around things seem to be in order! With all the tests still passing, we can be relatively confident that no additional changes need to be made to support our refactor.

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/CleaningJobOffersTable
touch 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.

CleaningJobOffersTable.js
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 the columns 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 the username of the user making the offer. We also make this field sortable. For mobile screens, we only show the user's username.
  • created_at - We use the moment.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 an EuiHealth 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 pass false to the hasActions prop in the EuiBasicTable 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.

components/index.js
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:

CleaningJobView.js
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.

phresh-offers-for-cleaning-table

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.

redux/offers.js
// ...other code
export 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 code
export 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 code
Actions.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.

CleaningJobView.js
// ...other code
function 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.

Github Repo

All code up to this point can be found here: