read24 - Admin UI: Editing Books

August 08, 2020

Roger Ngo

Okay, okay before I go any further, there needs to be some clean up. I need to be more disciplined here. I’ve been meaning to delete the memory connector for a while now. I hardly ever use it. The code has deviated so far from what a the SQL connector is capable of, so now it has become quite useless unless I take care, and update the whole connector to be in parity with the SQL connector. Eh, too much work for now, so I’ll just get rid of it. Besides, I can always reimplement it, and make it better than what it ever was should I happen to need it again. (Foreshadowing?)

The connector.ts file also needs to load the MySqlDb connector by default.

DatabaseConnector = MySqlDb;
console.log('Using the MySQL database connector.');

Doing this also breaks our old /debug/db route, as it was easy to just return back a serialized version of the in-memory database before. For now, I will just get rid of it. Now that there is MySQL, I can simply just query the development database for the data I need to look for.

Now, recalling, I had made a route PUT /admin/quiz/import to be the main route for handling updating book, and general quiz information. This route needs some updating. Not by much though. What it should do is just be able to recognize whether or not, the user is wanting to delete a particular quiz question, or choice of a question.

To make this change in the code, the payload from the request body should have questions, and choices with a delete property to indicate that the user wants to delete a specific question, or choice. So for example, when iterating through questions in the PUT /admin/quiz/import route handler, a check for the delete flag can be made to determine whether or not to delete, or update a question:

if(q.delete)
    await question.delete()
else
    await question.update();

For choices, it might not be as obvious, but I need to make sure choices get deleted by default if the question is deleted:

if (q.delete || c.delete)
    await choice.delete();
else
    await choice.update();

The front-end interfaces must be updated now to include id, and delete properties, which are optional as the QuestionItem, and ChoiceItem types are also shared with the AddBook page.

export interface QuestionItem {
    id?: number;
    content: string;
    choices: Map<string, ChoiceItem>;
    delete?: boolean;
}

export interface ChoiceItem {
    id?: number;
    content: string;
    answer: string;
    delete?: boolean;
};

export interface BookItem {
    id?: number;
    title: string;
    fiction: string;
    year: string;
    author: string;
    publisher: string;
    genre: number;
    isbn: string;
    lexile: number;
    wordCount: number;
};

Okay now it is time to make some big front-end changes. For now, I’ll create a new page called EditBook.tsx. Just stub a component to return something.‌

In index.tsx I want to add a route to EditBook.

<Route path="/book/edit/:id" component={EditBook} />

How this will work is that an id URL parameter will be passed into the EditBook page. Then I can use this ID to invoke the GET /admin/quiz/book/:bookId endpoint to retrieve the book and quiz information.

This means that ListBooks needs to also know about this new route. When building the table to list the books, the Edit button should navigate to the EditBook page for a book with that particular id. So instead of using a button component, I can use the react-router Link component:

<table className="table">
    <thead>
        <tr>
            <th>Title</th>
            <th>Author</th>
            <th>Year</th>
            <th>Lexile</th>
            <th>Total Questions</th>
            <th>Actions</th>
        </tr>
    </thead>
    <tbody>
        {
            bookData.map((b: any) =>
                <tr key={b.id}>
                    <td>{b.title}</td>
                    <td>{b.author}</td>
                    <td>{b.year}</td>
                    <td>{b.lexile}</td>
                    <td>{b.totalQuestions}</td>
                    <td>
                        <Link to={`/book/edit/${b.id}`} className="btn btn-primary">Edit</Link>
                    </td>
                </tr>
            )
        }
    </tbody>
</table>

Thankfully the btn btn-primary CSS class from Bootstrap will automatically style this link as a button.

The EditBook page more or less shares the same rendering code as AddBook. I’ll take the opportunity now, and move all the JSX code relating to editing the book itself into a new component, the BookForm.

import React, { SyntheticEvent } from 'react';
import TextField from './TextField';
import SelectBox from './SelectBox';
import { BookItem } from '../common/types';

export enum BookFormAction {
    Add = 0,
    Edit = 1
};

interface BookFormProps {
    formAction: BookFormAction;
    book: BookItem;
    onBookChange: (e: SyntheticEvent) => void;
};

export default function BookForm(props: BookFormProps) {
    const {
        formAction,
        book,
        onBookChange
    } = props;

    return (
        <div className="book-form container">
            <div className="row">
                <div className="col col-lg">
                    <h1>
                        {formAction === BookFormAction.Add ? 'Add' : 'Edit'}  a Book
                    </h1>
                </div>
            </div>
            <div className="row">
                <div className="col col-md">
                    <TextField label="Title" id="book-title" onChange={onBookChange} value={book.title} />
                </div>
                <div className="col col-md">
                    <TextField label="Author" id="book-author" onChange={onBookChange} value={book.author} />
                </div>
                <div className="col col-sm">
                    <TextField label="Year" id="book-year" onChange={onBookChange} value={book.year} />
                </div>
            </div>
            <div className="row">
                <div className="col col-md">
                    <SelectBox label="Fiction" id="book-fiction" onChange={onBookChange} options={[
                            {value: 'nonfiction', label: 'Non Fiction', selected: true},
                            {value: 'fiction', label: 'Fiction', selected: false}
                    ]} />
                </div>
                <div className="col col-md">
                    <SelectBox label="Genre" id="book-genre" onChange={onBookChange} options={[
                        {value: '0', label: 'Unknown', selected: true},
                        {value: '1', label: 'Pets', selected: false}
                    ]} />
                </div>
                <div className="col col-md">
                    <TextField label="Publisher" id="book-publisher" onChange={onBookChange} value={book.publisher} />
                </div>
            </div>
            <div className="row">
                <div className="col col-md">
                    <TextField label="ISBN" id="book-isbn" onChange={onBookChange} value={book.isbn} />
                </div>
                <div className="col col-md">
                    <TextField label="Lexile" id="book-lexile" onChange={onBookChange} value={book.lexile.toString()} />
                </div>
                <div className="col col-md">
                    <TextField label="Word Count" id="book-wordCount" onChange={onBookChange} value={book.wordCount.toString()} />
                </div>
            </div>
        </div>
    );
}

It is a simple refactor, but it does do a little in continuing to keep the code clean and understandable. Being able to understand code when reading it is super important to me.

For the rest of the EditBook page, it will be similar to AddBook but with a few differences:

For example, when editing a choice:

function onChoiceFieldChange(e: SyntheticEvent) {
    const field = (e.target as HTMLInputElement);
    const idAttr = field.attributes.getNamedItem("id");

    if(!idAttr)
        return;

    const questionKey = idAttr.value.split("-")[1];
    const choiceKey = idAttr.value.split("-")[2];

    const existingQuestion = questions.get(questionKey);
    if(!existingQuestion)
        return;

    const currChoice = existingQuestion.choices.get(choiceKey);
    if(!currChoice)
        return;

    const newChoice: ChoiceItem = {
        id: currChoice.id,
        delete: currChoice.delete,
        content: field.value,
        answer: currChoice.answer
    };

    existingQuestion.choices.set(choiceKey, newChoice);

    const newQuestions = new Map(questions);
    newQuestions.set(questionKey, existingQuestion);

    setQuestions(newQuestions);
}
useEffect(() => {
    const fetchedBook = async () => {
        const data = await (await fetch(`${API_HOST}/admin/quiz/book/${bookId}`)).json();
        const book = data.book;

        setBook({
            id: parseInt(book.id),
            title: book.title,
            fiction: book.fiction ? 'true' : 'false',
            year: book.year,
            author: book.author,
            publisher: book.publisher,
            genre: book.genre,
            isbn: book.isbn,
            lexile: book.lexile,
            wordCount: book.wordCount
        });

        const questions = data.questions;

        const newQuestions = new Map();
        for(const q of questions) {
            const newQuestion = {
                id: q.id,
                content: q.content,
                choices: new Map()
            };

            let choiceKey = 0;
            for(const c of q.choices) {
                newQuestion.choices.set(choiceKey.toString(), {
                    id: c.id,
                    content: c.content,
                    answer: c.answer
                });

                choiceKey++;
            }

            newQuestions.set(questionId.current.toString(), newQuestion);
            questionId.current++;
        }

        setQuestions(newQuestions);
    };

    fetchedBook();
}, []);

Not much else to it other than that!