read24 - Admin UI: Adding a Book

August 01, 2020

Roger Ngo

Today I will be creating a management system to be able to add data into the read24 database in a more convenient manner. This management system is a web application called Admin UI.

Creating the Admin UI Project

Create a new directory called admin.

Then create a react project using create-react-app.

npx create-react-app admin --template typescript

Wait for some magic to happen… Now let’s install other things so that the UI can be somewhat pretty. How about… Twitter Bootstrap?

npm install --save bootstrap
npm install --save-dev @types/bootstrap

I think this is all I need for the front-end right now for this session. So, let’s get started!

We will start the backend server.

Rogers-MacBook-Pro:server rogerngo$ npm run start

> read24-server@1.0.0 start /Users/rogerngo/CodeRepo/read24/server
> npm run build && node dist/index.js


> read24-server@1.0.0 build /Users/rogerngo/CodeRepo/read24/server
> tsc

{ DATA_SOURCE: 'mysql' }
Using the mysql database
Running on: /Users/rogerngo/CodeRepo/read24/server/dist
It works!

So now, the server is running on port 3000. Now we will start the front-end project. Note that since the server is using port 3000, the front-end server will detect to use a different port. In my case it is port 3001.

So let’s review the endpoints:

API Server: localhost:3000
Frontend: localhost:3001

Before I go further into development, I want to do some clean up. CRA puts a lot of stuff in that we don’t need. Lets just keep some files

public
-- favicon.ico
-- index.html
-- manifest.json
-- robots.txt
src
-- index.css
-- index.tsx
-- react-app-env.d.ts
-- serviceWorker.ts
-- setupTests.ts

While I am at it, I know I will need a couple of other folders. These are: components, and pages and will created under the src directory.

Using Bootstrap

In index.tsx, the Bootstrap CSS module can be imported directly so it will be readily available to all components:

import '../node_modules/bootstrap/dist/css/bootstrap.min.css';

How do I know this will work? Well, I’ll create a simple landing page utilizing Bootstrap. Call it Home.tsx.

import React from 'react';

export default function Home() {
    return (
        <div className="container">
            <div className="jumbotron">
                <h1 className="display-4">read24 Administration</h1>
                <p className="lead">
                    The administration panel for read24.
                </p>
                <button className="btn btn-primary">Login</button>
            </div>
        </div>
    );
}

Here is what it looks like:

Boostrap Screenshot

Cool, that looks great so far!

First Form: Add Book

I can choose to implement from a couple of potential forms, but I think the form which can add a book and the quiz questions is a fun, and challenging one to start out with. ‌

The form itself will be quite simple in aesthetics, for now. I expect it consist of a few fields which will contain values that will be sent as data to the POST /admin/quiz/import route.

The form will first have the following fields:‌

The form is not complete with just those fields. Recalling that creating a book implicitly creates questions for that book to be built as a quiz, there must be sub-forms which can be dynamically generated and rendered onto the page.

A button called Add Question is made available to render out the fields:‌

The Add Choice button then generates another child form which renders out the fields:

There are 3 types of HTML controls I am using: text field, select box, and button. It is expected that I will need more than just a simple input control for the text field, and a select for the select box.

TextField Component

Since I am using React, I need a TextField that come with a label, onchange handler that can communicate up to the parent component that its value is being changed (through state, for example).

Here is the simple TextField component created under the components folder.

import React, { SyntheticEvent } from 'react';

interface TextFieldProps {
    id: string;
    label: string;
    placeholder?: string;
    value?: string;
    defaultValue?: string;
    onChange?: (e: SyntheticEvent) => void;
};

export default function TextField(props: TextFieldProps) {
    const {
        id,
        label,
        placeholder,
        value,
        defaultValue,
        onChange
    } = props;

    return (
        <div className="form-group">
            <label htmlFor={id}>{label}</label>
            <input 
                type="text"
                className="form-control" 
                id={id} 
                placeholder={placeholder || undefined}
                defaultValue={defaultValue || undefined}
                value={value || undefined}
                onChange={onChange}
            />
        </div>
    );
}

This TextField component can now be used in a parent component like this:

<TextField label="Title" id="book-title" onChange={onTitleChange} value={title} />

Here is a nice rendered version of it:

TextField

SelectBox Component

The next component, SelectBox is an implementation of the select control stylized with Bootstrap and made to be used in React.

More or less, it functions exactly like a regular select box and has properties that enable information to flow back to the parent component using state-lifting.

import React, { SyntheticEvent } from 'react';

interface SelectBoxProps {
    id: string;
    label: string;
    options: SelectOptionProps[];
    onChange?: (e: SyntheticEvent) => void;
};

interface SelectOptionProps {
    label: string;
    value: string;
    selected?: boolean;
};

export default function SelectBox(props: SelectBoxProps) {
    const {
        id,
        label,
        onChange,
        options
    } = props;

    let key = 1;
    
    const selectedOption = options.find(o => o.selected);

    return (
        <div className="form-group">
            <label htmlFor={id}>{label}</label>
            <select id={id} className="form-control" onChange={onChange} defaultValue={selectedOption ? selectedOption.value : undefined}>
                {
                    options.map(o =>
                        <option value={o.value} defaultValue={o.selected ? o.value : undefined} key={`option-${key++}`}>
                            {o.label}
                        </option>)
                }
            </select>
        </div>
    );
}

AlertBanner Component‌

I am happy with some simple feedback after submitting the form to create a book and the quiz. This can be in the form of an alert banner which will contain a status message. Right now, I assume all requests will be successful, and have only built in rendering of a single type.

import React from 'react';

export enum AlertBannerType {
    Success = 0
};

interface AlertBannerProps {
    type: AlertBannerType;
    message: string;
    visible: boolean;
};

function getAlertBannerClassName(type: AlertBannerType) {
    switch(type) {
        case AlertBannerType.Success:
            return "alert-success";
        default:
            return "alert-primary";
    }
}

export default function AlertBanner(props: AlertBannerProps) {
    const {
        type,
        message,
        visible
    } = props;

    if (!visible)
        return null;

    let className = `alert ${getAlertBannerClassName(type)}`;

    return (
        <div className={className} role="alert">
            {message}
        </div>
    );
}

Usage of this banner with using a state variable to control visibility on the parent form may be like:

<div className="row">
    <div className="col col-12">
        <AlertBanner type={AlertBannerType.Success} visible={bannerVisible} message={"Submitted the book."} />
    </div>
</div>

On form submission, a successful request will then render out the banner with the visible effect:

Alert Component

Buttons

I can reuse the HTML button as-is without needing any special modification.

ChoiceForm

Best to work from the bottom-up, with the smallest form. As described before, a question can contain many choices. Therefore, there can be any number of ChoiceForm components rendered for a particular question.

There are 3 functions which the ChoiceForm must have:‌

  1. The content of the choice (value) must be kept up to date at by some parent/ancestor component. It does not make sense to have its value be stored in a state at this level since I need to know about the value of the TextField up at the question level, or perhaps even in the entire form level. (onFieldChange)
  2. A choice can be designated an answer to the question. This is designated by a true/false selection box. When the selection changes value, similar to content, the select change handler must communicate the changed value back up to the parent/ancestor component. (onSelectChange)
  3. Deletion of the choice must also be possible, through a button. A click handler for this button must be passed down. (onDeleteChoice)
import React, { SyntheticEvent } from 'react';
import TextField from './TextField';
import SelectBox from './SelectBox';
import './ChoiceForm.css';

interface ChoiceFormProps {
    questionKey: string;
    choiceKey: string;
    content: string;
    answer: string;
    onDeleteChoice: (e: SyntheticEvent) => void;
    onFieldChange: (e: SyntheticEvent) => void;
    onSelectChange: (e: SyntheticEvent) => void;
};

export default function ChoiceForm(props: ChoiceFormProps) {
    const {
        questionKey,
        choiceKey,
        content,
        answer,
        onDeleteChoice,
        onFieldChange,
        onSelectChange
    } = props;

    const options = [
        {value: 'false', label: 'False', selected: answer === 'false'},
        {value: 'true', label: 'True', selected: answer === 'true'}
    ];

    return (
        <div className="container choice-form-container">
            <div className="row">
                <div className="col col-lg-auto">
                    <h1>Choice</h1>
                </div>
            </div>
            <div className="row">
                <div className="col col-md-auto">
                    <TextField label="Content" id={`content-${questionKey}-${choiceKey}`} onChange={onFieldChange} defaultValue={content} />
                </div>
                <div className="col col-md-auto">
                    <SelectBox label="Is Answer?" id={`answer-${questionKey}-${choiceKey}`} onChange={onSelectChange} options={options} />
                </div>
                <div className="col col-sm-auto">
                    <div className="form-group">
                        <label>Action</label>
                        <div>
                            <button type="button" data-questionkey={questionKey} data-choicekey={choiceKey} onClick={onDeleteChoice} className="btn btn-danger">Delete</button>
                        </div>
                    </div>
                </div>
            </div>
        </div>            
    );
}

The above React JSX code will build out a form that looks similar to this:

ChoiceForm

Structurally, there can be many of these rendered per question. So the HTML page may look like:

Question
-- Choice 1
-- Choice ..
-- Choice ..
-- Choice N

QuestionForm

This form is a bit more involved in that it contains the data to construct the question, and to contain all the choices that belong to it.

  1. The form should render out a number of ChoiceForm components as children for every choice item within the main QuestionForm props.
  2. The form should communicate back to the parent/ancestor component whenever the content of the question changes through an onChange handler in the TextField.
  3. The question should also be able to to be deleted through an onDeleteChoice handler that is passed from the parent/ancestor component.
  4. And finally, there should be handlers passed into the QuestionForm which deal with managing the choices: onAddChoice, onDeleteChoice, onChoiceFieldChange, and onChoiceSelectChange.
import React, { SyntheticEvent } from 'react';
import TextField from './TextField';
import ChoiceForm from './ChoiceForm';
import { ChoiceItem } from '../common/types';
import './QuestionForm.css';

interface QuestionFormProps {
    questionKey: string;
    content: string;
    choices: Map<string, ChoiceItem>;
    onChange: (e: SyntheticEvent) => void;
    onDelete: (e: SyntheticEvent) => void;
    onAddChoice: (e: SyntheticEvent) => void;
    onDeleteChoice: (e: SyntheticEvent) => void;
    onChoiceFieldChange: (e: SyntheticEvent) => void;
    onChoiceSelectChange: (e: SyntheticEvent) => void;
};

export default function QuestionForm(props: QuestionFormProps) {
    const {
        questionKey,
        content,
        choices,
        onChange,
        onDelete,
        onAddChoice,
        onDeleteChoice,
        onChoiceFieldChange,
        onChoiceSelectChange
    } = props;

    const choiceElements = [];
    for(const k of Array.from(choices.keys())) {
        const c = choices.get(k);

        if (!c)
            continue;

        choiceElements.push(
            <ChoiceForm
                key={k}
                questionKey={questionKey}
                choiceKey={k.toString()}
                content={c.content}
                answer={c.answer}
                onDeleteChoice={onDeleteChoice}
                onFieldChange={onChoiceFieldChange}
                onSelectChange={onChoiceSelectChange}
            />
        );
    }

    return (
        <div className="container question-form-container">
            <div className="row">
                <h1>Question</h1>
            </div>
            <div className="row">
                <TextField label="Content" onChange={onChange} id={questionKey} defaultValue={content} />
            </div>
            <div className="row">
                <div className="container">
                    <div className="row">
                        <div className="col-2">
                            <button type="button" data-questionkey={questionKey} className="btn btn-secondary" onClick={onAddChoice}>Add Choice</button>
                        </div>
                        <div className="col-2">
                            <button type="button" data-questionkey={questionKey} className="btn btn-danger" onClick={onDelete}>Delete Question</button>
                        </div>
                    </div>
                </div>
                <div className="row">
                    {choiceElements}
                </div>
            </div>
        </div>
    );
}

Every question with the expectation to be rendered as a QuestionForm should be passed down a key. This key serves as an identifier as to which question is being manipulated by the parent/ancestor component. This makes it easier to manage the state of the overall form. The button elements themselves have a data attribute which store the questionKey so that when the click handlers are invoked, the parent/ancestor will know exactly which question to manipulate in its overall state.

When this component is rendered, it should give a nice form like this:

QuestionForm

AddBook Component‌

The final component I will be implementing is the main component to receive information from the user about the book, and all the questions with their choices.

It is a big component as it contains all the handlers for the QuestionForm, and ChoiceForm components. ‌

The AddBook component will have the following state variables:

  1. The main state object representing the book. It is defined by this interface:
interface BookItem {
    title: string;
    fiction: string;
    year: string;
    author: string;
    publisher: string;
    genre: number;
    isbn: string;
    lexile: number;
    wordCount: number;
};
  1. A set of questions which are represented by a Map<string, QuestionItem> where QuestionItem is defined by this interface:
interface QuestionItem {
    content: string;
    choices: Map<string, ChoiceItem>;
}

And ChoiceItem:

export interface ChoiceItem {
    content: string;
    answer: string;
};
  1. Another state variable is made available to show the AlertBanner for when the form has been submitted.

For all fields which receive input relating to the book itself, a single handler will control the updating of the state variable. onBookChange manipulates the current book object, updates the fields, and sets the state with this updated object as a parameter.

function onBookChange(e: SyntheticEvent) {
    const idAttr = (e.target as HTMLInputElement).attributes.getNamedItem('id');

    if(!idAttr)
        return;

    const id = idAttr.value;

    const newBook = {...book};
    const inputValue = (e.target as HTMLInputElement).value;
    const selectValue = (e.target as HTMLSelectElement).selectedIndex;

    switch(id) {
        case 'book-title':
            newBook.title = inputValue;
            break;
        case 'book-fiction':
            newBook.fiction = selectValue === 0 ? 'false' : 'true';
            break;
        case 'book-year':
            newBook.year = inputValue;
            break;
        case 'book-author':
            newBook.author = inputValue;
            break;
        case 'book-publisher':
            newBook.publisher = inputValue;
            break;
        case 'book-genre':
            newBook.genre = parseInt((e.target as HTMLSelectElement).options.item(selectValue)?.value || '0');
            setBook(newBook);
            break;
        case 'book-isbn':
            newBook.isbn = inputValue;
            break;
        case 'book-lexile':
            newBook.lexile = parseInt(inputValue);
            break;
        case 'book-wordCount':
            newBook.wordCount = parseInt(inputValue);
            break;
        default:
            break;
    }

    setBook(newBook);
}

For all handlers passing into the QuestionForm, they will just manipulate the Map structure of where each key to a question leads to a QuestionItem instance. For example, the onAddQuestion handler will insert a blank QuestionItem given a key:

function onAddQuestion() {
    const newQuestions = new Map<string, QuestionItem>(questions);
    newQuestions.set(questionId.current.toString(), {
        content: '',
        choices: new Map<string, ChoiceItem>()
    });
    setQuestions(newQuestions);

    questionId.current++;
}

Where questionId is a ref I have defined to maintain the latest key which should be used for the next question.

The same approach is used for deleting, and handling question content changes.

Now, for manipulating the choices, each handler must know the question key so that:

  1. Question key is used to obtain the QuestionItem.
  2. This QuestionItem is copied to a new instance.
  3. The choices property is inspected, and manipulated on the cloned QuestionItem.
  4. Then, a new copy of the Map<string, QuestionItem> structure is made, with the new QuestionItem set.
  5. This new copy is then updated as the new state variable.

Here is an example for when a choice is added. Notice that by default, the first choice added always as an answer=true value.

function onAddChoice(e: SyntheticEvent) {
    const questionKey = (e.target as HTMLButtonElement).dataset['questionkey']?.toString() || '';

    if(questionKey === '')
        return;

    const existingQuestion = questions.get(questionKey);

    if (!existingQuestion)
        return;
    
    const maxChoiceKey = (Array.from(existingQuestion.choices.keys()).length).toString();
    const currQuestion = {
        content: existingQuestion.content,
        choices: new Map(existingQuestion.choices)
    };

    if(currQuestion.choices.size === 0)
        currQuestion.choices.set(maxChoiceKey, {content: '', answer: 'true'});
    else
        currQuestion.choices.set(maxChoiceKey, {content: '', answer: 'false'});

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

    setQuestions(newQuestions);
}
QuestionForm

Form Submission Logic

Form submission logic is really straightforward. It is just serialization of data to the format in which the API endpoint /admin/quiz/import expects. The component uses the fetch method to handle this.

async function onSubmit(e: SyntheticEvent) {
    const questionData = [];
    const questionKeys = Array.from(questions.keys());

    for(const k of questionKeys) {
        questionData.push({
            content: questions.get(k)?.content,
            choices: Array.from(questions.get(k)?.choices.values() || [])
        })
    }

    const data = {
        book,
        questions: questionData
    };

    console.log(data);

    setBannerVisible(true);

    const jsonResponse = await fetch(`${API_HOST}/admin/quiz/import`, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify(data)
    });

    await jsonResponse.json();
}

Just one little thing though… Is that this POST request will fail now as it is – this is because of CORS. So let’s fix that…

Enabling CORS in the Server Project‌

If I was to use the fetch API from the Admin UI project right now, chances are, I will have failed requests relating to cross-origin resource sharing, or CORS. This is because my the Admin UI project is being served on a different origin at localhost:3001, compared to the origin of the server application localhost:3000. Therefore, due to the browser enforcing security, CORS will not work unless the server sends back the Access-Control-Allow-Origin header back in the response.‌

Thankfully it is super easy to do that for the Express server. In the server project, just install the cors package.

npm install --save cors
npm install ---save-dev @types/cors

Then in index.ts, add usage for cors.

...

import * as cors from 'cors';

const app = express();

app.use(cors());

...
...

app.listen(3000, () => ...);

Voila, now CORS is enabled for the server.

References‌

I feel like this session was filled with a lot of good material which can be further researched and studied on. I’ve put a list of good references here if you would like to read further on certain topics.‌