[TypeScript][Express] Try React 2

masanori_msl

Masui Masanori

Posted on August 13, 2021

[TypeScript][Express] Try React 2

Intro

This time, I will try get and upload data to the Express application.

Environments

  • Node.js ver.16.6.1
  • create-react-app ver.4.0.3
  • React ver.17.0.2
  • react-router-dom ver.5.2.0
  • TypeScript ver.4.3.5
  • ESLint ver.7.30.0

Load data on page loading

I can use "useEffect" for loading data on page loading.

[Server] bookService.ts

import { autoInjectable } from "tsyringe";
import { Connection, QueryRunner } from "typeorm";
import { ActionResult } from "../application.type";
import { DataContext } from "../data/dataContext";
import { Author } from "../entities/author";
import { Book } from "../entities/book";
import { Genre } from "../entities/genre";

@autoInjectable()
export class BookService {
    public constructor(private context: DataContext) {
    }
...
    public async getBookById(id: number): Promise<Book|null> {
        const connection = await this.context.getConnection();
        const result = await connection.getRepository(Book)
            .createQueryBuilder('book')
            .innerJoinAndSelect('book.genre', 'genre')
            .innerJoinAndSelect('book.author', 'author')
            .where('book.id = :id', { id })
            .getOne();
        return (result == null)? null: result;
    }
    public async getGenres(): Promise<readonly Genre[]> {
        const connection = await this.context.getConnection();
        return await connection.getRepository(Genre)
            .createQueryBuilder('genre')
            .getMany();
    }
    public async getAuthors(): Promise<readonly Author[]> {
        const connection = await this.context.getConnection();
        return await connection.getRepository(Author)
            .createQueryBuilder('author')
            .getMany();
    }
...
}
Enter fullscreen mode Exit fullscreen mode

[Server] index.ts

import "reflect-metadata";
import express from 'express';
import cors from 'cors';
import { container } from 'tsyringe';
import { BookService } from "./books/bookService";

const port = 3099;
const app = express();
const allowlist = ['http://localhost:3000', 'http://localhost:3099']
const corsOptionsDelegate: cors.CorsOptionsDelegate<any> = (req, callback) => {
  const corsOptions = (allowlist.indexOf(req.header('Origin')) !== -1)? { origin: true }: { origin: false };
  callback(null, corsOptions);
};
// To receive JSON value from client-side
app.use(express.json());
app.use(express.static('clients/public'));
...
app.get('/genres', cors(corsOptionsDelegate), async (req, res) => {
  const books = container.resolve(BookService);
  res.json(await books.getGenres());
});
app.get('/authors', cors(corsOptionsDelegate), async (req, res) => {
  const books = container.resolve(BookService);
  res.json(await books.getAuthors());
});
app.listen(port, () => {
    console.log(`Example app listening at http://localhost:${port}`)
});
Enter fullscreen mode Exit fullscreen mode

[Client] booksGetter.ts

import { Author } from "../models/author";
import { Book } from "../models/book";
import { Genre } from "../models/genre";

export async function getBookById(id: number): Promise<Book|null> {
    return await fetch(`http://localhost:3099/book/${id}`, {
        method: 'GET',
        mode: 'cors',
    })
    .then(response => response.json())
    .then(json => {
        console.log(json);

        return JSON.parse(JSON.stringify(json));
    })
    .catch(err => console.error(err));
}
export async function getAllGenres(): Promise<Genre[]> {
    return await fetch('http://localhost:3099/genres', {
        method: 'GET',
        mode: 'cors',
    })
    .then(response => response.json())
    .then(json => {        
        return JSON.parse(JSON.stringify(json));
    })
    .catch(err => {
        console.error(err);
        return [];
    });
}
export async function getExistedAuthors(): Promise<Author[]> {
    return await fetch('http://localhost:3099/authors', {
        method: 'GET',
        mode: 'cors',
    })
    .then(response => response.json())
    .then(json => {
        return JSON.parse(JSON.stringify(json));
    })
    .catch(err => {
        console.error(err);
        return [];
    });
}
Enter fullscreen mode Exit fullscreen mode

[Client] EditBooks.tsx

import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import * as bookGetter from '../books/booksGetter';
import * as bookUploader from '../books/booksUploader';
import { Author } from '../models/author';
import { Genre } from '../models/genre';

const defaultAuthor = {
    id: -1,
    name: ''
};
const defaultBook = {
    id: -1,
    name: '',
    price: -1,
    authorId: defaultAuthor.id,
    author: defaultAuthor,
    genreId: -1,
    genre: {
        id: -1,
        name: ''
    },
    lastUpdateDate: new Date(),
};
export function EditBooks(): JSX.Element {
    const [book, setBook] = useState(defaultBook);
    const [genres, setGenres] = useState(new Array<Genre>());
    const [authors, setAuthors] = useState(new Array<Author>());
    const addGenres = () => {
        const contents: JSX.Element[] = [];
        for(const g of genres) {
            const key = `genre_${g.id}`;
            contents.push(<option key={key} value={g.id}>{g.name}</option>);
        }
        return contents;
    }
    useEffect(() => {        
        bookGetter.getAllGenres()
            .then(genres => setGenres(genres));
        bookGetter.getExistedAuthors()
            .then(authors => setAuthors(authors));
    }, []);
    return <div className="create_books_area">
        <h2>Create</h2>
        <div className="create_books_row">
            <div className="create_books_item_area">
                <div className="create_books_item_title">Genre</div>
                <select defaultValue={book.genreId}>
                    {addGenres()}
                </select>
            </div>
        </div>
        <div className="create_books_row">
            <div className="create_books_item_area">
                <div className="create_books_item_title">Name</div>
                <input type="text" placeholder="Name" defaultValue={book.name}></input>
            </div>
            <div className="create_books_item_area">
                <div className="create_books_item_title">Author</div>
                <input type="text" placeholder="Author" defaultValue={book.author?.name}></input>
            </div>
            <div className="create_books_item_area">
                <div className="create_books_item_title">Price</div>
                <input type="number" placeholder = "Price" defaultValue={book.price}></input>
            </div>
            <div className="create_books_item_area">
                <button onClick={() => console.log('save')}>Save</button>
            </div>
        </div>
    </div>
}
Enter fullscreen mode Exit fullscreen mode

Because I want to load data only on page loading, I set "[]" as the second argument.

One important thing is I can't use async/await in "useEffect" because it required "void" as return value.

Update value

For updating "Book" data by input data, I update states when "onChange" events are fired.

[Client] EditBooks.tsx

...
export function EditBooks(): JSX.Element {
...
    const updateBook = (propertyName: 'name'|'authorName'|'price', value: string|undefined) => {
        let updateAuthor: Author|undefined|null = null;
        switch(propertyName) {
            case 'name':
                    book.name = (value == null)? '': value;
                break;
            case 'authorName':
                    if(value == null) {
                        book.author = defaultAuthor;
                    } else {
                        updateAuthor = authors.find(a => a.name === value);
                        book.author = (updateAuthor == null)? 
                            generateNewAuthor(value): updateAuthor;
                    }
                    book.authorId = book.author.id;
                break;
            case 'price':
                book.price = getFloatValue(value);
                break;
        } 
        setBook(book);
    }
...
    return <div className="create_books_area">
        <h2>Create</h2>
        <div className="create_books_row">
            <div className="create_books_item_area">
                <div className="create_books_item_title">Genre</div>
                <select defaultValue={book.genreId.toString()} onChange={(e) => {
                    book.genreId = getIntValue(e.target.options[e.target.selectedIndex].value);
                    setBook(book);
                    }}>
                    {addGenres()}
                </select>
            </div>
        </div>
        <div className="create_books_row">
            <div className="create_books_item_area">
                <div className="create_books_item_title">Name</div>
                <input type="text" placeholder="Name" defaultValue={book.name} required
                    onChange={(e) => updateBook('name', e.target.value)}></input>
            </div>
            <div className="create_books_item_area">
                <div className="create_books_item_title">Author</div>
                <input type="text" placeholder="Author" defaultValue={book.author?.name} required
                    onChange={(e) => updateBook('authorName', e.target.value)}></input>
            </div>
            <div className="create_books_item_area">
                <div className="create_books_item_title">Price</div>
                <input type="number" placeholder = "Price" defaultValue={book.price} required
                    onChange={(e) => updateBook('price', e.target.value)}></input>
            </div>
            <div className="create_books_item_area">
                <button onClick={() => console.log('save')}>Save</button>
            </div>
        </div>
    </div>
}
function generateNewAuthor(name: string): Author {
    return {
        id: -1,
        name
    };
}
...
Enter fullscreen mode Exit fullscreen mode

One important thing is I shouldn't set value by value like below.

<select value={book.genreId.toString()} onChange={(e) => {}}></select>
Enter fullscreen mode Exit fullscreen mode

Because the element's vlew won't be updated after changing the selected index.
So it looks like the same as default.

Get URL Paramters

For getting "Book" data on page loading, I want to get URL parameters.

To do this, I can use "useParameters" of "react-router".

[Client] App.tsx

import './App.css';
import {
  BrowserRouter as Router,
  Switch,
  Route,
  Link
} from "react-router-dom";
import { SearchBooks } from './search/SearchBooks';
import { EditBooks } from './edit/EditBooks';

function App(): JSX.Element {
  return (
    <Router>
      <div>
        <nav>
          <ul>
            <li>
              <Link to="/">Search</Link>
            </li>
            <li>
              <Link to="/edit">Edit</Link>
            </li>
          </ul>
        </nav>
        <Switch>
          <Route path="/edit/:id?">
            <EditBooks />
          </Route>
          <Route path="/">
            <SearchBooks />
          </Route>
        </Switch>
      </div>
    </Router>
  );
}
export default App;
Enter fullscreen mode Exit fullscreen mode

[Client] EditBooks.tsx

...
export function EditBooks(): JSX.Element {
...
    useEffect(() => {        
        bookGetter.getAllGenres()
            .then(genres => setGenres(genres));
        bookGetter.getExistedAuthors()
            .then(authors => setAuthors(authors));
        const id = getIntValue(params['id']);
        if(id != null) {
            bookGetter.getBookById(id)
                .then(book => 
                    (book?.id == null || book.id <= 0)? defaultBook: book)
                .then(book => setBook(book));
        }
    }, []);
...
Enter fullscreen mode Exit fullscreen mode

[Server] index.ts

...
app.get('/book/:id', async (req, res) => {
  const id = parseInt(req.params.id);
  if(id == null || isNaN(id)) {
    res.json({});
    return;
  }
  const books = container.resolve(BookService);
  const result = await books.getBookById(id);
  if(result == null) {
    res.json({});
    return;
  }
  res.json(result);
});
...
Enter fullscreen mode Exit fullscreen mode

bookService.ts

@autoInjectable()
export class BookService {
...
    public async getBookById(id: number): Promise<Book|null> {
        const connection = await this.context.getConnection();
        const result = await connection.getRepository(Book)
            .createQueryBuilder('book')
            .innerJoinAndSelect('book.genre', 'genre')
            .innerJoinAndSelect('book.author', 'author')
            .where('book.id = :id', { id })
            .getOne();
        return (result == null)? null: result;
    }
...
}
Enter fullscreen mode Exit fullscreen mode

Upload data

Because the uploading data are kept by state, I just need getting the data from the state and upload them.

[Client] EditBooks.tsx

...
export function EditBooks(): JSX.Element {
...
    const upload = async () => {
        if(book.genreId == null ||
            book.genreId <= 0) {
            book.genreId = genres[0].id;
            book.genre = genres[0];
        } else {
            const genre = genres.find(g => g.id === book.genreId);
            if(genre == null) {
                book.genreId = genres[0].id;
                book.genre = genres[0];
            } else {
                book.genreId = genre.id;
                book.genre = genre;
            }
        }
        const result = await bookUploader.upload(book);
        if(result.succeeded === true) {
            alert('OK');
        } else {
            alert(result.errorMessage);
        }
    };
...
    return <div className="create_books_area">
...
            <div className="create_books_item_area">
                <button onClick={async () => await upload()}>Save</button>
            </div>
        </div>
    </div>
}
Enter fullscreen mode Exit fullscreen mode

[Client] bookUploader.ts

import { ActionResult } from "../applications/application.type";
import { Book } from "../models/book";

export async function upload(book: Book): Promise<ActionResult> {
    return await fetch('http://localhost:3099/books', {
        method: 'POST',
        mode: 'cors',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify(book),
    })
    .then(response => response.json())
    .then(json => {
        return JSON.parse(JSON.stringify(json));
    })
    .catch(err => {
        console.error(err);
        return {
            succeeded: false,
            errorMessage: err,
        };
    });
}
Enter fullscreen mode Exit fullscreen mode

CORS error?

As same as GET request, I wrote like this for treating POST request.

[Server] index.ts

...
const allowlist = ['http://localhost:3000', 'http://localhost:3099']
const corsOptionsDelegate: cors.CorsOptionsDelegate<any> = (req, callback) => {
  const corsOptions = (allowlist.indexOf(req.header('Origin')) !== -1)? { origin: true }: { origin: false };
  callback(null, corsOptions);
};
...
app.post('/books', cors(corsOptionsDelegate), async (req, res) => {
  const book = JSON.parse(JSON.stringify(req.body));
  if(book == null) {
    res.json({
      succeeded: false,
      errorMessage: 'no targets'
    });
    return;
  }
  const books = container.resolve(BookService);
  res.json(await books.updateBook(book));
});
...
Enter fullscreen mode Exit fullscreen mode

But I got a CORS error.

Access to fetch at 'http://localhost:3099/books' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
Enter fullscreen mode Exit fullscreen mode

I don't know why. But after all, I change the code like below.

[Server] index.ts

...
const allowlist = ['http://localhost:3000', 'http://localhost:3099']
const corsOptionsDelegate: cors.CorsOptionsDelegate<any> = (req, callback) => {
  const corsOptions = (allowlist.indexOf(req.header('Origin')) !== -1)? { origin: true }: { origin: false };
  callback(null, corsOptions);
};
...
app.use(cors(corsOptionsDelegate));
...
app.post('/books', async (req, res) => {
  const book = JSON.parse(JSON.stringify(req.body));
  if(book == null) {
    res.json({
      succeeded: false,
      errorMessage: 'no targets'
    });
    return;
  }
  const books = container.resolve(BookService);
  res.json(await books.updateBook(book));
});
...
Enter fullscreen mode Exit fullscreen mode

bookService.ts

...
@autoInjectable()
export class BookService {
    public async updateBook(book: Book): Promise<ActionResult> {        
        const connection = await this.context.getConnection();
        const queryRunner = connection.createQueryRunner();
        await queryRunner.startTransaction();
        try { 
            const author = await this.updateAuthor(connection, queryRunner, book.author);
            if(author == null) {
                return {
                    succeeded: false,
                    errorMesage: 'No authors'
                };
            }
            const genre = await this.getGenreById(connection, book.genreId);
            if(genre == null) {
                return {
                    succeeded: false,
                    errorMesage: 'Failed getting genre'
                };
            }
            let result: ActionResult;
            if(book.id <= 0) {
                result = await this.createBook(queryRunner, author, genre, book);
            } else {
                result = await this.updateExistedBook(connection, queryRunner, author, genre, book);
            }
            if(result.succeeded === true) {
                await queryRunner.commitTransaction();
            }
            return result;
        } catch (err) {
            console.error(err);
            await queryRunner.rollbackTransaction();
            return {
                succeeded: false,
                errorMesage: 'something wrong'
            };
        }
    }
...
    private async updateAuthor(connection: Connection, queryRunner: QueryRunner, author: Author): Promise<Author|null> {
        if(author?.name == null ||
                author.name.length <= 0) {
            return null;
        }
        const targetAuthor = await connection.getRepository(Author)
            .createQueryBuilder('author')
            .where('author.name = :name', { name: author.name })
            .getOne();
        if(targetAuthor != null) {
            return targetAuthor;
        }
        const newAuthor = Author.create(author);
        await queryRunner.manager.save(newAuthor);
        return newAuthor;
    }
    private async getGenreById(connection: Connection, id: number): Promise<Genre|undefined> {
        return await connection.getRepository(Genre)
            .createQueryBuilder('genre')
            .where('genre.id = :id', { id })
            .getOne();
    }
    private async createBook(queryRunner: QueryRunner, 
            author: Author, genre: Genre, book: Book): Promise<ActionResult> {
        const newBook = Book.create(book, author, genre);
        console.log(newBook);

        await queryRunner.manager.save(newBook);
        return {
            succeeded: true,
            errorMesage: ''
        };
    }
    private async updateExistedBook(connection: Connection, queryRunner: QueryRunner, 
            author: Author, genre: Genre, book: Book): Promise<ActionResult> {
        const existedBook = await connection.getRepository(Book)
            .createQueryBuilder('book')
            .where('book.id = :id', { id: book.id })
            .getOne();
        if(existedBook == null) {
            return {
                succeeded: false,
                errorMesage: 'target book was not found',
            };
        }
        existedBook.update(book, author, genre);
        await queryRunner.manager.save(existedBook);
        return {
            succeeded: true,
            errorMesage: ''
        };
    }
...
}
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
masanori_msl
Masui Masanori

Posted on August 13, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

AILingo
devchallenge AILingo

November 25, 2024

[TypeScript][Express] Try React 2
typescript [TypeScript][Express] Try React 2

August 13, 2021

[TypeScript][Express] Try React
typescript [TypeScript][Express] Try React

July 17, 2021