src directory.Book in books/Book.tsBook interface has the id (number) as well as the authors and the title (both strings) properties.BookDetails in: books/components/BookDetails/BookDetails.tsxBookDetails 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.tsbold 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.tsxApp 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.tsxProps.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.tsxApp 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.tsBookOverview 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(); findOneto 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>