Part 1 takes care of setting up the backend
- managed requirements with
pip-tools
- env vars with
django-environ
- create custom user model
- wired custom user model up with admin
- adjusted settings for
- database
- custom user model
- channels
- redis connection
- the installed apps
asgi.py
needs to be adjusted for daphne- added
Makefile
A sign-up and a login API view were created since
we won't use the Django log in view (except for the admin area)
That's why we added SessionAuthentication
to the authentication options in the
project settings?
Sign-Up view http://localhost:8000/api/sign_up/ Login view: http://localhost:8000/api/log_in/
... with the beautiful DRF UI :).
TODO: Could create a schema which includes all routes etc. to simplify client generation.
The rest_framework_simplejwt
package drastically simplifies the issuance of tokens.
Sample of issued tokens decoded using jwt.io (signed using our app secret key) Reference: https://django-rest-framework-simplejwt.readthedocs.io/
Header:
{
"alg": "HS256",
"typ": "JWT"
}
Payload:
{
"token_type": "access",
"exp": 1680442365,
"iat": 1680438765,
"jti": "9376624ae47f42899f2627c5528cac9a",
"id": 1,
"username": "erkan",
"first_name": "",
"last_name": ""
}
And the payload of the refresh token:
{
"token_type": "refresh",
"exp": 1680525165,
"iat": 1680438765,
"jti": "71ff964c230b40938d0331d15147a92e",
"id": 1,
"username": "erkan",
"first_name": "",
"last_name": ""
}
This chapter added the functionality to store trips, manipulate them via the admin interface and read them all as a list or individually.
The id field was implented as UUID field since serial number don't offer much value besides giving away how many trips the app has had.
get_absolute_url
is a nice utility.
Departure and arrival location are done as simple char field. Of course, that wouldn't work in real life. It's a tutorial
Besides simply registering the model to the admin interface there is also the option to customize it: Ref: https://docs.djangoproject.com/en/4.1/ref/contrib/admin/
As usual except that I don't like fields = "__all__"
.
Always prefer to be explicit about the (public) exposure.
Since id
, created
and updated
are used internally, they were set to read only.
A read only view was created using ReadOnlyModelViewSet
.
That view set includes functionality to service GET request and serves the list of
trips and if an uuid of the trip is supplied the individual trip.
Ref: https://www.django-rest-framework.org/api-guide/viewsets/#readonlymodelviewset
The list and detail API view was bundled in new urls.py
in the trips
app and
wired up to the project using django.urls.include
.
The list or detail view is set during the wiring as an argument to as_view
urlpatterns = [
path("", TripView.as_view({"get": "list"}), name="trip_list"),
path("<uuid:trip_id>/", TripView.as_view({"get": "retrieve"}), name="trip_detail"),
]
List: http://127.0.0.1:8000/api/trip/ Detail: http://127.0.0.1:8000/api/trip/0fe29a3e-edb4-4dcd-92a7-212b2fe021d5/
Goal of this part was to set up the connectivity via the websocket protocol. The created connectivity was also protected with authentication
What would correspond to a classic django view is called consumer
.
For that reason a new module in the trips app called consumers
was created.
Since we intend to exchange JSON encoded message for our JS front end
a channels.generic.websocket.AsyncJsonWebsocketConsumer
was chosen for the consumer
super class.
Ref: https://channels.readthedocs.io/en/stable/topics/consumers.html#asyncjsonwebsocketconsumer
Async? Should work better for Python anyway (GIL).
accept
was overwritten to implement a check for authenticationdisconnect
was necessary to overwrite to call the super classes' methodreceive_json
was also necessary since it's not implemented
Further reading on async:
- https://docs.python.org/3/library/asyncio.html
- https://www.aeracode.org/2018/02/19/python-async-simplified/
- https://testdriven.io/blog/concurrency-parallelism-asyncio/
the application object in the projects asgi
module is used and expanded with
channels.routing.ProtocolTypeRouter
application = ProtocolTypeRouter(
{
"http": get_asgi_application(),
"websocket": TokenAuthMiddlewareStack(
URLRouter([path("taxi/", TaxiConsumer.as_asgi())])
),
}
)
- We are forced to use pytest because of the channels dependencies.
pytest in combo with django needs an ini with the
DJANGO_SETTINGS_MODULE
key value pair. - Instead of the database fixture, the decorator was used to permit db access.
- Since we are testing async code we use the pytest plugin for asyncio to enable the test to run.
- The
settings
fixture is used to monkeypatch ourCHANNEL_LAYERS
settings Instead of the redis layer, an in memory storage is used.
TEST_CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels.layers.InMemoryChannelLayer",
},
}
channels.testing.WebsocketCommunicator
is the equivalent to classicclient
.- It's kwargs are the
asgi
application (like with the Flask client) and - the
path
(like with a http client)
- It's kwargs are the
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
class TestWebSocket
async def test_cannot_connect_to_socket(self, settings):
settings.CHANNEL_LAYERS = TEST_CHANNEL_LAYERS
communicator = WebsocketCommunicator(application=application, path="/taxi/")
connected, _ = await communicator.connect()
assert connected is False
await communicator.disconnect()
The authentication is done via JWT token which is passed as a query parameter.
The token is read out and the "context" here called "scope" of the request is populated with the result.
The above step is done using a middleware, placed in the middleware
module of the project package.
Specifically, the get_user
method is overwritten to account for the different location of the token.
Ref: https://docs.djangoproject.com/en/4.1/topics/http/middleware/
In this part, the feature to request a trip will be implemented.
That feature requires the distinction between riders and drivers, which
is done using the Group
relation of the User
model.
A channel for drivers is implemented to listen to these trip requests.
Upon establishing a websocket at the taxi endpoint a user associated with the
driver
Group
is added to the drivers
channel.
We extended the Trip
model with rider
and driver
which both are foreign keys
to the custom User
model.
Since I specified the fields in Tripserializer
, I needed to extend the fields tuple with the
riderand
driver` model.
To prepare a more detailed response, a nested serializer was defined, which will provide more details on both the rider and the driver.
Finally, the admin page also needed to be expanded to the new fields.
We implemented a new message type in the respective test called create.trip
.
The receive_json
method works as an internal router. There, we forward the
message to the respective handler. In this case it's create_trip.
Database operations are factored out to private sync methods decorated with
database_sync_to_async
which enables database access. Sync, bc the decorator
expects to take a sync method. Maybe this would be different if worked
with an async db driver. But here it psycopg2
.
The trip is broadcast during the creation of a trip to the drivers group.
I install ipdb
for more "comfortable" debugging which works nice
with async code (and tests).
At the point I want to debug the sync/async code I added which dropped a
shell without issues.
import ipdb;ipdb.set_trace()
In tests though, we need to add the -s
flag to the pytest
command.
A snippet to print database queries via Django:
# settings.py
LOGGING = {
'version': 1,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
},
},
'loggers': {
'django.db.backends': {
'level': 'DEBUG',
},
},
'root': {
'handlers': ['console'],
}
}
Also added django-extension
package with its tools to the project.
E.g. a tool to create the application DB schema:
./manage.py graph_models -a -g -o my_project_visualized.png
The trip request is accepted by driver upon which the Trip object is updated and the driver is added to the trip group.
We added a driver specific test here but the feature was already implemented
since we didn't distinguish between the roles of a user within trips.consumers.TaxiConsumer.connect
.
The rider cancels their request after a driver accepts it
- The trip is updated to the respective status by the rider (extend
update_trip
method) - A group message is sent out
- Thereafter, the group is dissolved (and participants are removed from the group)
The server alerts all other drivers in the driver pool that someone has accepted a request.
- send a message to the drivers group with masked driver id but updated status so the UI can react
The driver periodically broadcasts their location to the rider during a trip.
- location data schema would need to be set up, assuming it's simple coordinates
- send the coordinates to the trip group
- front end receives the package via its websocket connection and renders it
- dots of location broadcast can be connected to a line, but would not store them on the DB
The server only allows a rider to request one trip at a time.
- during the processing of the
create.trip
message type we query the DB for any unfinished trips and decline the creation of a new Trip if we do find unfinished trip. - The message should include the ID of the unfinished trip so the UI can render useful advice to the rider
The rider can share their trip with another rider, who can join the trip and receive updates.
-
calls for a new "route" in
receive_json
a) other user is invited: need an identification of the invited rider- find them and send them a message about the invitation which he can decline or accept
- decline: relay a message to the host rider to that the invited rider has declined
- accept: relay a success message to both and add the invited user to the trip group
-
This requires an update to the Trip model
- we need to know whom to bill
- the ForeignKey field needs to be refactored to M2M relation
b) user can independently ask to join
- bad idea
The server only shares a trip request to drivers in a specific geographic location.
- we need to collect the location of the users (driver in this case specifically)
- we need to set radius or calculate a travel time equivalent of a radius
- with a radius around the pick-up location we can determine which drivers qualify
- the front end discards trip requests based on this
- or we create driver groups based on country, state/province/ city
If no drivers accept the request within a certain timespan, the server cancels the request and returns a message to the rider.
- backend regular job to collect trips with status requested and check the timestamp for issue a timeout.
- email message from that backend job? or how would do it within the communicator?
This part implements a more specific result of the API depending
on what group a user has. For that, get_queryset
of the
view set of the Trips view was adjusted.
This helps to keep more relevant information for the logged-in user and eases the work on the front end.
- User uploads: media files
- Route:
MEDIA_URL
- Physical Location:
MEDIA_ROOT
, can be path or e.g. Bucket, S3 etc Ref: https://docs.djangoproject.com/en/4.2/ref/settings/#media-root Ref: https://testdriven.io/blog/storing-django-static-and-media-files-on-amazon-s3/ - plugged in during development in the project
urlpatterns
withdjango.conf.urls.static.static
Wrapped the extension ofurlpatterns
with check for theDEBUG
setting - Used during running tests!
- extended the
User
model withmodels.ImageField
, where we can specificy a subdirectory via theupload_to
kwarg - Pillow used to generate an image in a test fixture
Tried to check in admin and the API for trips. But of course, the user
I was using was created before we cared about groups.
We can create the rider and driver group at the admin interface and
assign the rider
group to our user.
Todos:
- enable the user to change/recover password
- enable the user to change user profile details (which, he is allowed to change)
The learning objectives are set to be
- Scaffold a React app
- Functional React components including React Hooks
- Navigation/routing on our SPA
- Unit and E2E testing with Cypress
- Mocking Ajax with Cypress
- Implement Bootstrap CSS framework
- Forms
- Containerize both front and back end
- Build authentication for end users (sign up, log in)
package | tutorial | project |
---|---|---|
node | 19.0.0 | 19.8.1 |
npm | 8.19.2 | 9.5.1 |
yarn | 1.22.17 | 3.5.0 |
yarn
is used to trigger react-app
Refs:
Install default dependencies and create a template app:
# tdd-taxi/client
yarn create react-app client
It this part routing will be implemented and enable these "pages"/ view:
Routing is one of the key features of SPA IMO.
The tut uses react-router-dom
to implement routing.
createHashRouter
, specificaly the HashRouter
component is used
Like in the Vue tut, it is equally discouraged to use.
Instead, the createBrowserRouter
factory or BrowserRouter
component is recommended in
the docs.
While it's possible to write the router separately and then plug it in, here
it is included in the index.js
.
// src/index.js
<HashRouter>
<Routes>
<Route path="/" element={<App />} />
<Route index element={<Landing />} />
<Route path="sign-up" element={<SignUp />} />
<Route path="log-in" element={<LogIn />} />
</Routes>
</HashRouter>
Essentially, a URL path is matched to the component which to which the URL should show.
The index
keyword is used to mark a "home".
It might be confusing because the "/" route is wired to the App
component.
However, the App
component serves just as an Outlet
target. This compares to Vue
with placing the RouterView
component.
// src/App.js
function App() {
return (
<>
<Outlet />
</>
);
}
</></>
works as "synthetic" wrapper div
element.
Refs:
Outlet
: https://reactrouter.com/en/main/components/outletindex
keyword: https://reactrouter.com/en/6.10.0/route/route#indexHashRouter
: https://reactrouter.com/en/main/routers/create-hash-router<> </>
https://react.dev/reference/react/Fragment- Browser compatability: https://browsersl.ist/#q=%3E+0.2%25%2C+not+dead%2C+not+op_mini+all
Cypress uses a browser to interact with our application from the users' perspective. If the backend had been plugged in yet, it indeed would cover the complete path of the UX from the UI to the database (and back) (i.e. end to end, e2e).
First, it needs to be installed:
yarn add [email protected] --dev
The UI of Cypress can be started using:
yarn run cypress open
Configuration files are created. A browser can be chosen to do the testing in.
The baseUrl
property is set in the cypress.config.js
to the dev client base URL:
The test suit is initialized with a coverage of the navigation:
// client/cypress/navigation.cy.js
describe("Navigation", function() {
// ...
it('Can navigate to sign up from log in', function () {
cy.visit('/#/log-in');
cy.get('a').contains('Sign up').click();
cy.hash().should('eq', '#/sign-up');
});
// ...
})
As opposed to unit tests, cypress lives its own life in the root client directory
instead of being part of the e.g. the components
sub folder.
The API was matched by Vue I guess, since React is older. The test has three statements at its core
- Navigate to the test subjects page
- Find the anchor/link element, which contains a pattern and click on it
- The assertion, which checks the browser URL
The contains
method is case-sensitive but matches substrings.
yarn add bootstrap \
react-bootstrap \
react-router-bootstrap \
bootswatch
Packages used:
Package | Tutorial | Project |
---|---|---|
bootstrap | 5.2.2 | 5.2.3 |
react-bootstrap | 2.5.0 | 2.7.2 |
react-router-bootstrap | 0.26.2 | 0.26.2 |
bootswatch | 5.2.2 | 5.2.3 |
Integration of React Bootsrap components worked flawless compared to Vue3, there the wrapper library is not ready yet.
Interestingly, subelements are available via .
syntax:
<Breadcrumb>
<Breadcrumb.Item href="/#/">Home</Breadcrumb.Item>
<Breadcrumb.Item active>Log in</Breadcrumb.Item>
</Breadcrumb>
Additional context for testing is provided on certain elements via the
data-cy
attribute. I guess it will be used later. Anyway, all e2e tests passed.
Check with:
yarn run cypress open
Beautiful 😍
Ref:
- Bootstrap: https://getbootstrap.com/
- Bootrap React components: https://react-bootstrap.github.io/
- Bootstrap + Router for React: https://github.com/react-bootstrap/react-router-bootstrap
- Bootstrap themes: https://bootswatch.com/
Formik
is used as a plugin for creating and handling forms.
At this stage, validation is left out. Should be done at least on the backend.
Recall, that any group passed on would be created during sign-up in the default version
of the tutorial. (see trips.serializers.UserSerializer.validate_group
)
A selection of form related libraries can be found at https://github.com/enaqx/awesome-react#forms
yarn add formik
# @2.2.9 both in the tutorial and the project
A basic example without bootstrap:
import React from "react";
import ReactDOM from "react-dom";
import { Formik, Field, Form } from "formik";
import "./styles.css";
function App() {
return (
<div className="App">
<h1>Contact Us</h1>
<Formik
initialValues={{ name: "", email: "" }}
onSubmit={async (values) => {
await new Promise((resolve) => setTimeout(resolve, 500));
alert(JSON.stringify(values, null, 2));
}}
>
<Form>
<Field name="name" type="text" />
<Field name="email" type="email" />
<button type="submit">Submit</button>
</Form>
</Formik>
</div>
);
}
Has some validation built in and activated by default 😍 Ref: https://formik.org/
functional component
- function, can accept props or not
- return JSX
useState
Enable React to react upon a change of a variable The syntax is. When a new value is set to the variable using the setter, it will trigger a re-render with the new value taken into account.
import { useState } from 'react';
function MyComponent() {
const [age, setAge] = useState(28);
const [name, setName] = useState('Taylor');
const [todos, setTodos] = useState(() => createTodos());
// ...
This explains the redirection logic at the top of the two modified components:
function LogIn(props) {
const [isSubmitted, setSubmitted] = useState(false);
const onSubmit = (values, actions) => setSubmitted(true);
if (isSubmitted) {
return <Navigate to="/" />;
}
return (
//..
<Formik
initialValues={{
username: "",
password: "",
}}
onSubmit={onSubmit}
>
//...
)
isSubmitted
is initiallyfalse
- Clicking the submit button will forward the event to the custom
onSubmit
handler. - which uses the setter returned by
useState
to change the value ofissubmitted
- which triggers a rerender, but a persisting value for
isSubmitted
- which return the redirect in the if-clause instead of the default JSX (i.e. sign up form)
Ref: https://react.dev/reference/react/useState
Another dev dependency to handle file uploads
yarn add cypress-file-upload --dev
# 5.0.8 both in tutorial and project
which is "registered" in client/cypress/support/e2e.js
with
import "cypress-file-upload";
Which enables to interact with a file upload field: via element.attachFile("images/photo.jpg")
.
props
is described as a parameter but not accessed.React
is imported but not used- we should confirm the user that he has signed up indicate that he can now log in.
- validation in formik?
The client code was refactored. The routing logic was moved out of the index.js
into the App component within the App.js
.
Interesting: you can neste <Route></Route>
elements.
Methods and stateful variables were passt to the components during initialisation
<MyComponent thePropToPassToTheComponent={methodOrVariable}
Conditional parts within the returned JSX by the component:
If somethingEvaluatedForTruthiness
evaluates to true
the OtherTag
will get rendered.
<SomeTag>
{
somethingEvaluatedForTruthiness && (<OtherTag>Component</OtherTag>)
}
</SomeTag>
When a component takes props
as a key word, the props set during "instantiation"
will be dot-accessible.
//App.js
// App component
<LogIn logIn={logIn} />
//Login.js
function LogIn(props) {
props.logIn(values.username, values.password);
};
Otherwise, the props can be explicitly named:
// App Component
<Layout isLoggedIn={isLoggedIn} />
// Layout subcomponent
function Layout({ isLoggedIn }) {
//...
{isLoggedIn && (
<Form>
<Button type="button">Log out</Button>
</Form>
)}
//...
}
axios
is used for xhr requests.
yarn add axios
# 1.1.3 in tutorial and 1.3.5 in project
CSRF is going to be a topic for the form submit. Respective middleware is plugged in with Django apps by default.
MIDDLEWARE = [
# ...
"django.middleware.csrf.CsrfViewMiddleware",
# ...
]
Ref: CSRF in Django https://docs.djangoproject.com/en/4.1/ref/csrf/#ajax
The log in route will trigger an XHR request later for now it stubbed. Some "business" logic was implemented
- don't offer the log-in and sign up pages to logged in users
- alert on an error using bootsrap
- field specific errors were tied to the
formik
form usingactions.setFieldError
Ref: https://formik.org/docs/api/formik#setfielderror-field-string-errormsg-string--void
Additional topics:
- methods can be called in JSX within parenthesis using wrapping the call in an anonymous function
- Cypress allows environment variable in
cypress.config.js
. They can be accessed viaCypress.env("envKeyName")
- fixtures/helper function introduced for Cypress
(props)
vs({ namedProp1, namedProp2 })
introducedwindow.storage
- storage of auth token in the browser via
window.localStorage.setItem("taxi.auth", JSON.stringify(jsObject))
Only strings allowed. - reading the auth token via
window.localStorage.getItem("taxi.auth")
- log out / removal of auth token via
window.localStorage.removeItem("taxi.auth")
- storage of auth token in the browser via
- inline if/else
variableEvaluatedForTruthiness ? caseTrue : CaseFalse
This chapter adds the axios request for the sign-up page and adds highlighting and feedback for the user on the sign-up form
FormData
instance is used to handle the actual transmission via API.
Ref.: https://developer.mozilla.org/en-US/docs/Web/API/FormData
Let's see later if it plays nice.
The XHR request was placed in the sign-up component instead of the parent component bc we don't need the sign-up state in the parent in contrast to the login status.
In the tutorial, a conditional was set for the classname if the input components to highlight
the respective field using CSS. The class name is-invalid
is built in into bootsrap.
Ref.: https://getbootstrap.com/docs/5.0/forms/validation/#server-side
Additionally, Form.Control.Feedback
of bootstrap
was used to forward the error message:
<Form.Control ...>
{
'username' in errors && (
<Form.Control.Feedback
type='invalid'>
{errors.username}
</Form.Control.Feedback>
)
}
//...
The Feedback
subcomponent will inject a div
with a class attribute set to invalid
Ref.: client/node_modules/react-bootstrap/esm/Feedback.js
Alternative but worse way to highlight errors ErrorMessage
:
<Form.Contol name="username" ...>
<ErrorMessage name="username" />
A Dockerfile
as created for the server
and client
app
with hot reloading by plugging in the code on the disk
using volumes.
.dockerignore
files were added to each subproject to avoid
bloating the image with e.g. node modules and the python virtual env.
Images are not optimized for prod (cleaning of apt-get artefacts, multistage build etc.)
To start the whole project - including the auxiliary service ad docker-compose
file
was created.
container_name
directive can be used which will to avoid the default naming scheme
used by docker.
Comments in JSX:
import React from 'react'
const Component = function () {
return (
{/* comment in JSX ... not via // ;) */}
)
}
export default Component
Found that I lost code during refactoring. I miss the unit tests
Docker is cool to find undocumented system dependencies.
Found, that I didn't specify graphviz
and graphviz-dev
.
In this chapter the Cypress test cases were refactored to avoid mocking the HTTP requests. I went a step further and removed mores stubs. And it payed off by discovering some mistakes in wiring the error messages :).
For a production ready app, it would be nice to collect all validation errors before responding to the front end
Since the app uses the sign-up feature against the backend and the username has a unique constraint it was necessary to get a unique username for each test run. Faker was used for get meaningful random values.
The package changed a bit compared to the tutorial.
yarn add @faker-js/faker --dev
# 7.6.0 in the project vs 5.5.3
Ref.: https://fakerjs.dev/guide/
Cypress is uses ChaiJS, so the expect
method is available to make
e.g. non DOM assertions
https://www.chaijs.com/api/bdd/
A found few more occasions where a password for the tests were written as literals and
replaced them by using Cypress.env("credentials")
like suggested in previous chapters.
To avoid cross site attacks CORS is implemented in clients.
For Django, there is the django-cors-headers
is available.
It evaluates the request for the respective headers/cookies in a middleware.
Ref.: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
Env values can be read out via process.env.<THE_ENV_KEY>
const url = `${process.env.REACT_APP_BASE_URL}/api/sign_up/`;
Development goals in this part are going to be:
- Addding Dashboards for the Driver and Rider roles.
- Effect hooks to trigger side effects in React components.
- Work with websockets in React.
- Toast notification in React
- Integrate Google Maps API to the app
Alongside, more Cypress test will be added and refactored
In this Chapter a User Dashboard will be created for a Driver and Rider role, which will be accessible only for each specific role.
Components intended to show some user data were implemented for both groups,
the rider and driver. The routes were placed in the App
component as a subcomponent.
Breadcrumbs
were used to display the location. Quite comfortable for browsing
back and forth.
// Home > Dashboard
function Rider(props) {
// ...
return (
<>
<Breadcrumb>
<Breadcrumb.Item href="/">Home</Breadcrumb.Item>
<Breadcrumb.Item active>Dashboard</Breadcrumb.Item>
</Breadcrumb>
//..,
</>
);
}
Ref.: https://react-bootstrap.github.io/components/breadcrumb/
User details are encoded in the access token, which was stored in localStorage
.
A logic was implemented in both the ride and driver component to ensure that
the logged-in user is only able to access the page which is designed for his group.
TODO:
A user who is not logged in can still visit the page. He will just need to inject the part of the token with the user details. The details should be verified by checking against the signature (third part of the token). However, any content on that page will require a request to the backend, which will fail anyway because a valid token was never issued in the first place.
For now, there is an expected error within getUser
since JSON.parse
receives
null
as an argument.
Cypress enables reusing code e.g. as part of a setup routine.
One way this already done was the logIn
function in the authentication.cy.js
suite.
Another way to do it, is register these reusable functions in cypress/support/commands.js
const helper = () => {
// cy.doSomeThing() ...
};
Cypress.Commands.add("helper", helper);
Although, the documentation suggests to define them as part of the add
method
Cypress.Commands.add("helper", () => {
// cy.doSomeThing() ...
}
);
Both seem to work. While the last one avoids spelling errors during registering the function, the first one enables local re-usability.
Error
At some point, something didn't go as suggested by the tutorial. Cypress needs to visit the rider or driver dashboard 2x. Only on the second time, the expected result is returned. Something is wrong somewhere.
Ref.: https://docs.cypress.io/api/cypress-api/custom-commands
The r
Will ensure that we get an absolute URL or let's say not an URI using URL
object.
const photo = "/users/24/photo.jpg";
undefined
const base = "https://example.org"
undefined
new URL(photo, base).href
'https://example.org/users/24/photo.jpg'
const photoAbs = "https://example.org/users/24/photo.jpg";
undefined
new URL(photoAbs, base).href
'https://example.org/users/24/photo.jpg'
Ref.: https://developer.mozilla.org/en-US/docs/Web/API/URL/URL
Hook to rerender components on changes. The objects/vars "watched" for changes need to be specified.
import { useEffect } from 'react';
import { createConnection } from './chat';
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [serverUrl, roomId]);
}
Ref.: https://react.dev/reference/react/useEffect
Unlike in the LogIn
component we have multiple (2) components which will need
to execute calls to the backend to get trip related data.
So it makes sense to place trip related XHR logic outside a component.
That's why a TripServer
module is created.
Preferably an autogenerated client would be used. Still, that autogenerated code needs to be wired up to our app.
TODO
I miss a step to validate the incoming schema. That would provide 1) an abstraction layer from the backend and 2) ease coding since we would get autocompletion/syntax checking for free.
XHR code could be DRYed up
The stubs in Rider
and Driver
components were replaced with a combo of
TripCard
and TripMedia
.
Rider/Driver > TripCard > TripMedia
Each trip is represented as one TripMedia
object within the TripCard
component.
Card
refers to a Bootstrap component (essentially div with specific formatting).
Ref.: https://getbootstrap.com/docs/5.0/components/card/
Throughout the new (and existing code) shorthand styling directives are used in the class name HTML elements.
For example TripCard returns a Card
element with the class name mb-3
The classes are named using the format {property}{sides}-{size} for xs and {property}{sides}-{breakpoint}-{size} for sm, md, lg, xl, and xxl.
So a margin,
m - for classes that set margin
is set for the bottom of the element
b - for classes that set margin-bottom or padding-bottom
equivalent to the default.
3 - (by default) for classes that set the margin or padding to $spacer
https://getbootstrap.com/docs/5.0/utilities/spacing/
Static "snapshots" where used to mock the returned Trip response.
TODO:
- Assertions could be much more specific.
- We could test our filtering logic (
getCurrentTrips
etc.) by including some "wrong" data. - a tool to create those snapshots would be cool
In this part, a page for the ride details was implemented. The details are going to be displayed after a user clicks on a specific trip within the dashboard which summarizes all trips.
In the second part a refactoring will be done to the dashboard.
DriverDetail
and RiderDetail
components are going to be added to
the app.
client.src.services.TripService.getTrip
was previously implemented to
fetch the data of a specific trip from the backend.
The response will be returned to the *Detail
component in the
shape of:
{ response, isError: false }
The isError
property helps to separate the XHR logic from the view since
the service function already evaluated whether the data is good
to render or not.
The data of that request will be cast into the TripMedia
component and
embedded into a Card
component of react-bootstrap
.
Breadcrumbs were used as in the previous upper components.
import { LinkContainer } from 'react-router-bootstrap';
import { Breadcrumb } from 'react-bootstrap';
function DriverDetail() {
// other code here
return (
<>
{/* */}
<Breadcrumb>
<LinkContainer to='/driver'>
<Breadcrumb.Item>Dashboard</Breadcrumb.Item>
</LinkContainer>
<Breadcrumb.Item active>Trip</Breadcrumb.Item>
</Breadcrumb>
{/* */}
</>
)
}
The route to the detail page will be sth like:
http://localhost:3001/#/driver/492fb009-6178-4837-bfc3-e4822f61ae0f
The path parameter can be built into the React router by specifying the
param as a value to the path
attribute with a leading :
:
Experimenting around, I found one way to do it is to place the route into the Routes
component.
// client/src/App.js
<Routes>
{/* other routs*/}
<Route path="driver" element={<Driver />}></Route>
<Route path="driver/:tripId" element={<DriverDetail />}></Route>
</Routes>
Obviously, restating the parent route is not optimal. The next commit includes the official way.
Ref.: https://reactrouter.com/en/main/hooks/use-params Ref.: https://www.robinwieruch.de/react-router-nested-routes/
The proper way to nest routes is:
// App.js or whatever the SPA entrypoint component is
<Routes>
<Route path="users" element={ UserContainerComponent }>
<Route index element={ UserLandingOrIndexComponent } />
<Route path=":dynamicPathParamLikeSomeID" element={ UserDetailComponent } />
</Route>
</Routes>
In combination with UserContainerComponent
having:
function UserContainerComponent(props) {
return <Outlet />
}
As a result
- logic is moved from
Driver
toDriverDashboard
Driver
acts ony as a socket to "Outlet" eitherDriverDashboard
orDriverDetail
The same pattern is applied to the Rider
view and rider child views.
Todos
- create an "interface" between the code and the fetched JSON with e.g. https://ajv.js.org/guide/getting-started.html It would be nice to get code completion like e.g. if a pydantic schema is uses to define a response schema with e.g. fastapi only in JS :)
- The response schema could be pulled in from auto generated OpenAPI schema created on the django side. A complete API schema would include response/request body schemata which could then be used in the React app for validation (https://jsontypedef.com/)
This chapter focuses on the enabling a rider to request a ride.
Likewise, a driver should be able to accept a ride request.
Both features were implemented via the WebSocket protocol on the
backend. rxjs
is going to be used to provide the WS functionality
for the client code.
Interacting with the trip creation backend requires communicating via WebSockets.
To ease the work rxjs
package was added:
yarn add rxjs
# 7.5.7 in tutorial, 7.8.1 in the code
In its core, the feature relies on 1) a Formik
form to collect the data
and 2) on the websocket communication which is triggered in the onSubmit
logic.
- and 2) are placed in a new component and wired up in
App.js
with nested route. Importantly, the route to the trip request form needs to be placed above the route for the trip details view because the:tripId
parameter would match anything including our new routerequest
.
Finally, a link to the form component is placed in the navigation (and the dashboard for good measure).
The useEffect
hook is included into the DriverDashboard
component.
In that hook, the existing list of trips on the client side is updated
from messages received via the websocket.
Since a page refresh will fetch all current requested trips via XHR anyway, this change was all we needed to do.
- There was an issue with the redis URL cast from the environment variables.
I reverted the URL schema validation back to a simple string in the
settings.py
. Of course, it was meant well, but the code downstream expect that variable to be a string :))). - a test used setup code to create trip object on the database with
a hardcoded ID. I flushed the database once, made the ID 2 user unavailable.
debugged with logging settings for the docker-compose setup at the
create_trip
method before I realized the source of the error in the test code.
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'class': 'logging.StreamHandler'
},
},
'root': {
'handlers': ['console'],
'level': 'DEBUG',
},
}
Todos
-
The current lifetime of the access token is set to 60 minutes. although the refresh token is provided during login, the app just errors out if the authentication fails.
If an event is registered in the app, the validity (timestamp) of the token can be evaluated and if the access token expired, automatically trigger a refresh logic
-
currently, the pickup and drop off addresses are just strings. Some validation would be nice here, but of course it is an into tut.
-
As mention previously, we should validate that the user doing the WS request is the user in the data, who requests the trip.
-
The
rider
user has no way to verify that his ride request succeeded or failed bc no feedback message is issued. The rider dashboard also does not show requested trips. (what if he wants to cancel it?)
Last chapter implemented the feature to request a ride. The counterpart to that feature is to enable a driver to accept the request.
Each new request is listed on the drivers' dashboard. While we could place a button on the summary objects in that list the tutorial placed the button to accept a request in the detail view.
An "accept" button on the dashboard should if at all lead to the detail page and focus on the real accept button. That way, we have a chance to display additional information about the ride before a driver accepts it.
This helps to decide with more confidence, as ideally a driver can only accept one ride at a time (* without scheduling feature, current implementation is a spot market). Same restrictions apply to the rider.
Overall com overhang e.g. for cancellations might be avoided.
Technically, the solution was provided by placing an "Accept" button
on the footer of the DriverDetail
component.
DriverDetail
is also used to display started, ongoing, completed and other Trips.
So the displayed button depend on the status of the trip, which is determined
within createData
using switch-case statements.
const createData = (status) => {
switch (status) {
case 'REQUESTED':
return {
disabled: false,
message: 'Drive to pick up',
nextStatus: 'STARTED',
variant: 'primary'
};
//...
}
};
The properties of the returned object are then used to instantiate the button:
{
(boolean) && (
<Card.Footer>
<Button
disabled={data.disabled}
className={data.variant}
onClick={() => updateTripStatus(data.nextStatus)}
>{data.message}</Button>
</Card.Footer>
)
}
As a result of a click
event updateTripStatus
is fired.
It handles the state change uses the web socket utilities which
were factored out to the TripService
.
TODO
-
No validation on the update of the UI within the
DriverDetail
page. SeeTODO
inupdateTripStatus
I remember a Mantra for WebSockets which was like "Treat it as if it can fail at any step anytime"A similar issue was observed in the local updates (without actually retrieving data from the backend ) in https://testdriven.io/courses/learn-vue/saving-data-via-http-post/
On accepting a ride, the rider will be recipient of a message about the event, since he was added to the trip group by backend.
A receiving logic was implemented within the RiderDashboard
to
reflect an updated trip in the UI. This UI update is more safe
than the update on the driver side in the previous section of this chapter
since it is indeed received from the backend.
TODO
-
In the associated test to the current chapter, only a change from
REQUESTED
toIN_PROGRESS
was implemented. The other statements should be checked as well ;) -
JS really calls by design for mistakes to happen. Types/Validation on the function or external communication level would help avoid mistakes, provide IDE dev support...
To display trip updates "toast" components are used.
react-toastify
is used in this app.
yarn add react-toastify
# 9.1.1 in the tut, 9.1.2 in the project
The outlet container - ToastContainer
is placed in the root
component (such that it doesn't matter which page the user receiving
the update actually is on).
The logic to fire them is wired up in the useEffect
hooks within
the rider and driver dashboard component, bc we placed our web socket
listener there.
react-toastify
provides several preconfigured layouts.
// https://fkhadra.github.io/react-toastify/api/toast
toast.success("Hello", options);
toast.info("World", options);
toast.warn(MyComponent, options);
toast.error("Error", options);
The actual message is dynamically composed based on the
new status. updateToast
is used to isolate this logic within
the Dashboard components.
Refs.:
- https://github.com/fkhadra/react-toastify#readme
- https://fkhadra.github.io/react-toastify/introduction/
Todos
-
How can cover toasts with tests? Added an if branches to handle possible nulls and a status "REQUESTED" bc both will throw errors while the message is composed. (
driveName
can't be assembled) -
Additionally, to the pop-ups, changes could be highlighted using CSS animations?
-
error messages could be wired up to use the pop-ups as well especially the web socket calls.
Creating a trip screams to use Gmaps to visualize at least the route and this was done in chapter 9.
The API-Key needs to generated from GCP Cloud Console. https://console.cloud.google.com/google/maps-apis
Since it's best practice to limit the rights/access to the ones needed, a subset all available Google APIs was enabled.
Host restriction can also be made. TODO
- limit hosts (additional header required when maps API is called)
Since the usage of this key will incur costs, it's best kept secret.
The tut adds it into the docker-compose
file which is deadly since this
run through is under source control.
The solution was to create another .env
file and reference it in the
respective service:
services:
taxi-client:
env_file:
- ./.env.compose
environment:
- CHOKIDAR_USEPOLLING=true
- REACT_APP_BASE_URL=http://localhost:8003
- REACT_APP_WS_BASE_URL=ws://localhost:8003
Both env_file
and environment
can be specified. Vars in environment
will
overwrite values read out of .env
.
There are multiple ways to do it. The tut focused on react-google-maps/api
:
yarn add @react-google-maps/api
2.14.0 used in the tut, 2.18.1 in the project_
A Map
component was created and placed below the ride request form.
Many parts of the Map
component is predefined and can be read in the
documentation of the package.
Ref.: https://react-google-maps-api-docs.netlify.app/
The location of the user will be searched and will need the user permission. Ref.: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/geolocation
With some drop off and pick up locations, the map re-rendered itself endlessly
triggering many API calls/s.
I found a solution on stackoverflow in which the callback, triggering the API call
is limited to n
attempts.
const count = useRef(0)
const directionsCallback = (response) => {
if (response !== null && response.status === "OK" && count.current <=2) {
setResponse(response);
count.current += 1;
} else {
count.current = 0;
}
};
The counter is reset on errors. Other than that it re-renders then the value
of the input changes, since the input will start incomplete, triggering a
clueless gmaps backend on where Ber
is but will stop calling the API
once the input is completed and gmaps found a route to Berlin
;)
Ref.: https://stackoverflow.com/a/68039370/10124294
useRef
was used in the previous solution to get a stateful var.
Besides useState
another concept of stateful var is used in react called ref
.
Unlike useState
, useRef
will not trigger rerender upon change of the underlying value
of the var.
The main difference between both is : useState causes re-render, useRef does not. The common between them is, both useState and useRef can remember their data after re-renders. So if your variable is something that decides a view layer render, go with useState. Else use useRef I would suggest reading this article.
Ref.: https://stackoverflow.com/a/56456055/10124294 Ref.: https://blog.logrocket.com/usestate-vs-useref/ Ref.: https://react.dev/reference/react/useRef
- No test coverage =/
- autocomplete feature on the input elements would be very nice. In fact: industry default.