Exercises

  1. Simple Component
  2. Master Details
  3. Data Binding
  4. Service
  5. Router
  6. Forms

Simple component (1/7)

  1. Let's assume we're in the src directory.
  2. Create a new interface Book in
    books/Book.ts
  3. The Book interface has the id (number) as well as the authors and the title (both strings) properties.
  4. Create a new component BookDetails in:
    books/components/BookDetails/BookDetails.tsx

Simple component (2/7)

  1. Export a new function BookDetails of type FunctionComponent.
  2. Import the FunctionComponent dependency:
    import {FunctionComponent} from 'react'

Simple component (3/7)

  1. Apply 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'
        });
    }
                            
  2. Add the BookDetails render function:
    
    export const BookDetails: FunctionComponent = () => {    
          return null;
      }
                            

Simple component (4/7)

  1. Implement the BookDetails render function based on this Bootstrap example.
  2. Render the Book's authors and title properties. Instead of <input> use <p> with form-control-plaintext CSS class.
  3. According to these layout rules wrap the form into a <div> container.

Simple component (5/7)

  1. Add a styled component module in
    book/components/BookDetails/BookDetails.css.ts
  2. Add a CSS property for styled Label: the font weight is bold for all labels within the container
  3. Import the styled Label (just added) in the BookDetails component:
    import { Label } from './BookDetails.css.ts';
  4. Use it instead of build-in label element

Simple component (6/7)

  1. Import the BookDetails component in the App component:
    import { BookDetails } from './book/components/BookDetails/BookDetails';
  2. Refactor the App class component into the function one:
    
    export const App = () => <BookDetails>;
                            
  3. To the App styled module add the Container component having the top-margin of 20px.
  4. Wrap the <BookDetails/> into the styled <Container>

Simple component (7/7)

  1. Add tests for the BookDetails component in:
    books/components/BookDetails/BookDetails.test.tsx
  2. Implement test cases checking if the state was set (1), the authors (2) as well as the title (3) are rendered and the deep component test (4).
  3. Make the App component's tests pass.

Master Details (1/8)

  1. Go to the BookDetails (class) component. Instead of having the state's currentBook pass it as the book property.
  2. To do this create the Props interface and add the book property to it.
  3. Refactor the BookDetails into a function accepting parameter:
    export const BookDetails = (props: Props) => (...);
  4. Don't forget to refactor tests :)

Master Details (2/8)

  1. Create a new component BookOverview in:
    books/components/BookOverview/BookOverview.tsx
  2. Export an empty interface Props.
  3. Design state of the component: the books (an array of books) and the selectedBook (may be a book or null).

Master Details (3/8)

  1. Apply the 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);
    }
                            
  2. Implement the isBookSelected function:
    
    function isBookSelected(book: Book): boolean {
      // check if the book is the selected one
    }
                            

Master Details (4/8)

  1. Implement the selectBook function:
    
    function selectBook(book: Book): void {
      // set the book as the selected one
    }
                            
  2. Initialize the state's books with some values. Use the useEffect hook for that:
    
    useEffect(() => {
      // set the state's books to some dummy values
    }, []);
                            

Master Details (5/8)

Add the 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>
  );
                    

Master Details (6/8)

  1. Add rendering of the master table based on this MaterialUI example. The master table has three columns: '#', 'Authors' and 'Title'.
  2. Iterate over the state's books adding the table's rows. Use the Array.prototype.map method.
  3. Each row, i.e. the <TableRow> element, has the key property set to the current book's ID.
  4. Make use of the method implemented before, if the current book is the selected one

Master Details (7/8)

  1. Moreover, each <TableRow> element handles the click event setting the current book as the selected one.
  2. The master table is ready.
  3. Now replace the placeholder for details with the <BookDetails> component passing the selectedBook to it. Be careful: if the selectedBook is not set, nothing is rendered.

Master Details (8/8)

  1. Create tests for the BookOverview component in:
    books/components/BookOverview/BookOverview.test.tsx
  2. The test cases should check (among others) if the master table is rendered as well as if the details are rendered upon a click on a row.
  3. Update the App component so that the BookOverview (instead of BookDetails) element is rendered.

Data Binding (1/7)

  1. We are going to make the BookDetails component stateful (again): refactor it to leverage useState hook; the state is just a book itself.
  2. Add the 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;
    }
                            

Data Binding (2/7)

  1. Initialize the state with the values coming from the book property:
    
    export const BookDetails: FunctionComponent<Props> = (props) => {
      const [book, setBook] = useState<Book>({...props.book}};
    }
                            
  2. Go to the 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}
        />
                            

Data Binding (3/7)

  1. The <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).
  2. For handleChange to be implemented we need to destruct some properties from ChangeEvent:
    
                                event: ChangeEvent<HTMLInputElement>
                                const { name, value } = event.currentTarget;    
                            

Data Binding (4/7)

  1. Implement the 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
    }
                            
  2. Wrap text fields in a <form> element
  3. Add a Material UI contained button (of type 'submit') to the form, just under the fields.

Data Binding (5/7)

  1. Handle the <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}>
      );
                            
  2. Implement new test cases and make the existing ones pass :)

Data Binding (6/7)

  1. Go to the 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 :(
  2. To fix this, add the key property (setting it to the book's ID) to the <BookDetails> element; here you can learn why it helps.

Data Binding (7/7)

  1. Implement the 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
      );
    }
                            
  2. Implement new test cases and make the existing ones pass :)

Service (1/3)

  1. Create a context wrapping the main root component, context provides children component with API methods
  2. Create a hook useBookService in
    book/services/BookService.ts
    This hook can be used in component and enable them to access API methods

Service (2/3)

  1. 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>;
                                    }
                                
                            
  2. Implement the service and add test cases in:
    book/services/BookService.test.ts

Service (3/3)

  1. Go to the BookOverview component. Add the bookService property (of the BookService type) to the Props interface.
  2. Call the bookService.findAll() method to set the state's books in the componentDidMount() lifecycle method. Don't forget to make tests pass :)
  3. Go to the App component and set the BookOverview's bookService property to a new BookService instance.

Router (1/6)

Go to the 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>
                        );
                   

Router (2/6)

The navigation should contain two pills: 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>
  ...
);
                    

Router (3/6)

Add a router configuration in 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>
                            );
                        
                    

Router (4/6)

                    
                        import { useNavigate } from "react-router-dom";
                    
                
Make use of the useNavigate hoook to provide routing action to the component
on clicking of table row we use navigate function to route to specific book page
                        
                            <TableRow
                                hover
                                key={book.id}
                                onClick={() => navigate(`/book-app/book/${book.id}`)}
                            >
                        
                    

Router (5/6)

  1. Go to BookDetails Component
  2. import needed dependencies
    import { useParams, useNavigate } from "react-router-dom";
  3. use useParams hook to access book id from the url
      const { id } = useParams(); 
  4. if we can access id use the BookService method
    findOne
    to access book from database

Router (6/6)

  1. We will use useNavigate hook to specify a method to redirect user to books overview on successful save
      const navigateToBookList = () => navigate("/book-app/books"); 
  2. action for redirection
                                  
                                const notifyOnBookChange = (bookData: BookProperties) => {
                                    if (id) {
                                        //update book
                                        save({ id: +id, ...bookData }).then(navigateToBookList);
                                    } else {
                                        //create new book
                                    saveNew(bookData).then(navigateToBookList);
                                }
                              };
                                
                            

Forms (1/6)

  1. Go to the BookDetails component. Remove the state from it and transform it into the function component.
  2. Consequently, the updateTitleValue() and updateAuthorsValue() methods can also be removed.
  3. Change Prop's onBookChange so that it returns a Promise instead of void:
    
    export interface Props {
      ...
      onBookChange?: (book: Book | BookProperties) => Promise<any>;
    }
                            

Forms (2/6)

Make use of the <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>);
                    

Forms (3/6)

Implement the <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>
                    

Forms (4/6)

Implement the 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>
);
                    

Forms (5/6)

Implement validation functions:

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!`;
    }
  }
}
                    

Forms (6/6)

Add <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>