src
directory.Book
in books/Book.ts
Book
interface has the id
(number) as well as the authors
and the title
(both strings) properties.BookDetails
in: books/components/BookDetails/BookDetails.tsx
BookDetails
of type FunctionComponent
.FunctionComponent
dependency: import {FunctionComponent} from 'react'
useState
hook:
import React, { FunctionComponent, useState } from 'react';
export const BookDetails: FunctionComponent = () => {
const [currentBook, setCurrentBook] = useState<Book>({
id: 1,
authors: 'John Example',
title: 'Example Book'
});
}
BookDetails
render function:
export const BookDetails: FunctionComponent = () => {
return null;
}
BookDetails
render function based on this Bootstrap example.Book
's authors
and title
properties. Instead of <input>
use <p>
with form-control-plaintext
CSS class.<div>
container.book/components/BookDetails/BookDetails.css.ts
bold
for all labels within the containerBookDetails
component: import { Label } from './BookDetails.css.ts';
label
elementBookDetails
component in the App
component: import { BookDetails } from './book/components/BookDetails/BookDetails';
App
class component into the function one:
export const App = () => <BookDetails>;
App
styled module add the Container
component having the top-margin
of 20px
.<BookDetails/>
into the styled <Container>
BookDetails
component in: books/components/BookDetails/BookDetails.test.tsx
App
component's tests pass.BookDetails
(class) component. Instead of having the state's currentBook
pass it as the book
property.Props
interface and add the book
property to it.BookDetails
into a function accepting parameter: export const BookDetails = (props: Props) => (...);
BookOverview
in: books/components/BookOverview/BookOverview.tsx
Props
.books
(an array of books) and the selectedBook
(may be a book or null
).useState
hook to provide state for BookOverview
:
import { FunctionComponent } from 'react';
export const BookOverview: FunctionComponent<Props> {
const [books, setBooks] = useState<Book[]>([]);
const [selectedBook, setSelectedBook] = useState<Book>(null);
}
isBookSelected
function:
function isBookSelected(book: Book): boolean {
// check if the book is the selected one
}
selectBook
function:
function selectBook(book: Book): void {
// set the book as the selected one
}
books
with some values. Use the useEffect
hook for that:
useEffect(() => {
// set the state's books to some dummy values
}, []);
BookDetails
render function initially:
return (
<Grid container spacing={2}>
<Grid item md={8}>
<TableContainer component={Paper}>
<Table>
<TableHead>
// master table books header goes here
</TableHead>
<TableBody>
// master table books data goes here
</TableBody>
</Table>
</TableContainer>
</Grid>
<Grid item md={4}>
// book details go here
</Grid>
</Grid>
);
books
adding the table's rows. Use the Array.prototype.map method.<TableRow>
element, has the key
property set to the current book's ID.
selected
one<TableRow>
element handles the click
event setting the current book as the selected one.<BookDetails>
component passing the selectedBook
to it.
Be careful: if the selectedBook
is not set, nothing is rendered.BookOverview
component in: books/components/BookOverview/BookOverview.test.tsx
App
component so that the BookOverview
(instead of BookDetails
) element is rendered.BookDetails
component stateful (again): refactor it to leverage useState
hook;
the state is just a book itself.onBookChange
optional property to the (existing) Props
interface;
onBookChange
is a function expecting a book and returning void
:
export interface Props {
book: Book;
onBookChange?: (book: Book) => void;
}
book
property:
export const BookDetails: FunctionComponent<Props> = (props) => {
const [book, setBook] = useState<Book>({...props.book}};
}
render()
function and replace the <Typography>
elements with <TextField>
ones, e.g.:
<TextField id="authors"
name="authors"
label="Authors"
variant="outlined" fullWidth
value={book.authors}
onChange={handleChange}
/>
<input>
's value
property is now bound to the state (1st direction)
and any change on the <input>
is handled by the handleChange
function which is going to update the state (2nd direction).handleChange
to be implemented we need to destruct some properties from ChangeEvent:
event: ChangeEvent<HTMLInputElement>
const { name, value } = event.currentTarget;
handleChange
function:
function handleChange = (event: ChangeEvent<HTMLInputElement>) => {
// 1. use event attributes destruction
// 2. update the state using name and value
setBook((prevBook) => ({ ...prevBook, // update code here
}
<form>
element'submit'
) to the form, just under the fields.<form>
's submit
event in the notifyOnBookChange
function:
function notifyOnBookChange = (event: SyntheticEvent) => {
// prevent the default submit event's behavior
// call back the props.onBookChange (if any)
// passing it a copy of the current state
}
return (
<form onSubmit={notifyOnBookChange}>
);
BookOverview
component. After changes we have just made, only the first click on a row renders its details. The subsequent clicks don't update the details :(key
property (setting it to the book's ID) to the <BookDetails>
element;
here you can learn why it helps.updateBook
arrow function:
const updateBook = (bookToUpdate: Book) => {
setBooks(
// update the books: apply the map method to
// books replacing the current book with the
// bookToUpdate if their IDs are equal
);
}
useBookService
in book/services/BookService.ts
This hook can be used in component and enable them to access API methods
BookService
provides a hook (like in the BookOverview
) and exposes the following API:
export class BookService {
findAll(): Promise<Book[]> {}
findOne(id: number): Promise<Book> {}
save(bookToSave: Book | BookProperties): Promise<Book> {}
saveNew: (book: BookProperties) => Promise<Book>;
}
book/services/BookService.test.ts
BookOverview
component. Add the bookService
property (of the BookService
type) to the Props
interface.bookService.findAll()
method to set the state's books in the componentDidMount()
lifecycle method.
Don't forget to make tests pass :)App
component and set the BookOverview
's bookService
property to a new BookService
instance.App.tsx
file and wrap the rendered content with the BrowserRouter
element, adding also navigation:
import { BrowserRouter as Router} from 'react-router-dom';
export const App = () => (
<Router>
<nav></nav>
...
</Router>
);
Book Overview
and New Book
; each of them should be a router's <NavLink>
to /book-app/books
and /book-app/book
respectively:
export const App = () => (
...
<nav>
<ul className="nav nav-pills">
<li className="nav-item">
<NavLink to="/book-app/books" className="nav-link"
activeClassName="active">
Book Overview</NavLink></li></ul>
...
</nav>
...
);
App.tsx
file:
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
export const AppRoutes = () => (
<Routes>
<Route path="/" element={<Navigate to="/book-app/books" replace />} />
<Route path="/book-app/books" element={<BookOverview />>} />
<Route path="/book-app/book" element={<BookDetails />>} />
<Route path="/book-app/book/:id" element={<BookDetails />>} />
</Routes/>
);
export const App = () => (
<Router>
<nav>...</nav>
<Routes/>
</Router>
);
import { useNavigate } from "react-router-dom";
useNavigate hoook
to provide routing action to the component
<TableRow
hover
key={book.id}
onClick={() => navigate(`/book-app/book/${book.id}`)}
>
import { useParams, useNavigate } from "react-router-dom";
const { id } = useParams();
findOne
to access book from database const navigateToBookList = () => navigate("/book-app/books");
const notifyOnBookChange = (bookData: BookProperties) => {
if (id) {
//update book
save({ id: +id, ...bookData }).then(navigateToBookList);
} else {
//create new book
saveNew(bookData).then(navigateToBookList);
}
};
BookDetails
component. Remove the state from it and transform it into the function component.updateTitleValue()
and updateAuthorsValue()
methods can also be removed.Prop
's onBookChange
so that it returns a Promise
instead of void
:
export interface Props {
...
onBookChange?: (book: Book | BookProperties) => Promise<any>;
}
<Formik>
element and implement its onSubmit
property:
export const BookDetails = (props: Props) => (
<div className={`${styles.form} container`}>
<Formik initialValues={props.book}
onSubmit={(values: Book | BookProperties, {setSubmitting}) => {
if (props.onBookChange) {
props.onBookChange({...values})
.then(() => setSubmitting(false));
}
}}
render={() => (...)}/>
</div>);
<Formik>
's render()
property (<Field>
elements will be implemented soon):
<Form>
<div className="form-group row">
<label htmlFor="authors" className="col-sm-4 col-form-label">Authors:</label>
<div className="col-sm-8"><Field name="authors" ... /></div>
</div>
<div className="form-group row">
<label htmlFor="title" className="col-sm-4 col-form-label">Title:</label>
<div className="col-sm-8"><Field name="title" ... /></div>
</div>
...
</div>
</Form>
BookDetailsInputComponent
:
const BookDetailsInputComponent = ({field, form: {touched, errors}, ...props}: FieldProps) => (
<div>
<input type="text"
className={`form-control ${(touched[field.name] && errors[field.name]) ? 'is-invalid' : ''}`}
{...field}
{...props} />
{touched[field.name] && errors[field.name] && <div className="invalid-feedback">{errors[field.name]}</div<}
</div>
);
function isEmptyInputValue(value: any) {
return value == null || value.length === 0;
}
function notEmptyAndMaxLengthOf(maxLength: number) {
return (value: any) => {
if (isEmptyInputValue(value)) {
return 'Please provide a value!';
} else if (value.length != null && value.length > maxLength) {
return `Please provide a value not longer than ${maxLength} characters!`;
}
}
}
<Field>
's component
and validate
properties:
<Form>
<div className="form-group row">
...
<Field name="authors" component={BookDetailsInputComponent}
validate={notEmptyAndMaxLengthOf(15)} /></div>
<div className="form-group row">
<Field name="title" component={BookDetailsInputComponent}
validate={notEmptyAndMaxLengthOf(50)} /></div>
</div>
...
</div>
</Form>