TodoApp.tsx
import React, { Component, MouseEvent, ChangeEvent } from 'react';
import { isRecord } from './record';
import { Item, parseItems } from './item';
// State of the app is the list of items and the text that the user is typing
// into the new item field.
type TodoState = {
items: Item[] | undefined; // to-do items or undefined if still loading
newName: string; // mirrors text in the field to add a new name
};
// Enable debug logging of events in this class.
const DEBUG: boolean = false;
// Top-level application that lets the user add/complete items on a todo list
export class TodoApp extends Component<{}, TodoState> {
constructor(props: {}) {
super(props);
this.state = {items: undefined, newName: ""};
}
componentDidMount = (): void => {
this.doRefreshTimeout(); // initiate a fetch to update our list of items
};
render = (): JSX.Element => {
if (DEBUG) console.debug("rendering")
// Return a UI with all the items and elements that allow them to add a new
// item with a name of their choice.
return (
<div>
<h2>To-Do List</h2>
{this.renderItems()}
<p className="instructions">Check the item to mark it completed.</p>
<p className="more-instructions">New item:
<input type="text" className="new-item"
value={this.state.newName}
onChange={this.doNewNameChange} />
<button type="button" className="btn btn-link"
onClick={this.doAddClick}>Add</button>
</p>
</div>);
}
// Returns a div for each item in the todo list, with each div containing a
// label with the name of the item and a checkbox for marking it completed.
renderItems = (): JSX.Element => {
if (this.state.items === undefined) {
return <p>Loading To-Do list...</p>;
} else {
const items : JSX.Element[] = [];
for (const [index, item] of this.state.items.entries()) {
if (item.completed) {
items.push(
<div className="form-check" key={index}>
<input className="form-check-input" type="checkbox"
id={"check" + index} checked={true} readOnly={true} />
<label className="form-check-label completed" htmlFor={"check" + index}>
{item.name}
</label>
</div>);
} else {
items.push(
<div className="form-check" key={index}>
<input className="form-check-input" type="checkbox"
id={"check" + index} checked={false}
onChange={evt => this.doItemClick(evt, index)} />
<label className="form-check-label" htmlFor={"check" + index}>
{item.name}
</label>
</div>);
}
}
return <div>{items}</div>;
}
};
// Called to refresh our list of items from the server.
doRefreshTimeout = (): void => {
fetch("/api/list")
.then(this.doListResp)
.catch(() => this.doListError("failed to connect to server"));
};
// Called with the response from a request to /api/list
doListResp = (res: Response): void => {
if (res.status === 200) {
res.json().then(this.doListJson)
.catch(() => this.doListError("200 response is not valid JSON"));
} else if (res.status === 400) {
res.text().then(this.doListError)
.catch(() => this.doListError("400 response is not text"));
} else {
this.doListError(`bad status code ${res.status}`);
}
};
// Called with the JSON response from /api/list
doListJson = (val: unknown): void => {
if (!isRecord(val)) {
console.error("bad data from /list: not a record", val)
return;
}
if (DEBUG) console.log("updating list from fetch response");
const items = parseItems(val.items);
if (items !== undefined)
this.setState({items: items});
};
// Called when we fail trying to refresh the items from the server.
doListError = (msg: string): void => {
console.error(`Error fetching /list: ${msg}`);
};
// Called when the user checks the box next to an uncompleted item. The
// second parameter is the index of that item in the list.
doItemClick = (_: ChangeEvent<HTMLInputElement>, index: number): void => {
if (this.state.items === undefined)
throw new Error('impossible: items is undefined');
const item = this.state.items[index];
if (DEBUG) console.log(`marking item ${item.name} as completed`);
const body = {name: item.name};
fetch("/api/complete", {
method: "POST", body: JSON.stringify(body),
headers: {"Content-Type": "application/json"} })
.then((res) => this.doCompleteResp(res, index))
.catch(() => this.doCompleteError("failed to connect to server"))
};
// Called when the server confirms that the item was completed.
doCompleteResp = (res: Response, index: number): void => {
if (res.status === 200) {
res.json().then((data) => this.doCompleteJson(data, index))
.catch(() => this.doCompleteError("200 response is not valid JSON"));
} else if (res.status === 400) {
res.text().then(this.doCompleteError)
.catch(() => this.doCompleteError("400 response is not text"));
} else {
this.doCompleteError(`bad status code ${res.status}`);
}
};
// Called with the JSON response from /api/complete
doCompleteJson = (data: unknown, index: number): void => {
if (!isRecord(data)) {
console.error("bad data from /complete: not a record", data)
return;
}
// Nothing useful in the response itself...
if (this.state.items === undefined)
throw new Error('impossible: items is undefined');
// Note: we cannot mutate the list. We must create a new one.
const item = this.state.items[index];
const items = this.state.items.slice(0, index) // 0 .. index-1
.concat([{name: item.name, completed: true}])
.concat(this.state.items.slice(index + 1)); // index+1 ..
this.setState({items: items});
// Refresh our list after this item has been removed.
setTimeout(this.doRefreshTimeout, 5100);
};
// Called when we fail trying to complete an item
doCompleteError = (msg: string): void => {
console.error(`Error fetching /complete: ${msg}`);
};
// Called when the user clicks on the button to add the new item.
doAddClick = (_: MouseEvent<HTMLButtonElement>): void => {
// Ignore the request if the user hasn't entered a name.
// (Would be better to disable the button in this case.)
const name = this.state.newName.trim();
if (name.length == 0)
return;
fetch("/api/add", {
method: "POST", body: JSON.stringify({name}),
headers: {"Content-Type": "application/json"} })
.then(this.doAddResp)
.catch(() => this.doAddError("failed to connect to server"));
};
// Called when the server confirms that the item was added.
doAddResp = (res: Response): void => {
if (res.status === 200) {
res.json().then(this.doAddJson)
.catch(() => this.doAddError("200 response is not valid JSON"));
} else if (res.status === 400) {
res.text().then(this.doAddError)
.catch(() => this.doAddError("400 response is not text"));
} else {
this.doAddError(`bad status code ${res.status}`);
}
};
// Called with the JSON response from /api/add
doAddJson = (data: unknown): void => {
if (!isRecord(data)) {
console.error("bad data from /add: not a record", data);
return;
}
if (typeof data.name !== 'string') {
console.error("bad data from /add: name is not a string", data);
return;
}
if (DEBUG) console.log(`adding new item ${data.name}`);
if (this.state.items === undefined)
throw new Error('impossible: items is undefined');
// Already know what was added, but now we know add was made.
const items = this.state.items.concat(
[ {name: data.name, completed: false} ]);
this.setState({items: items, newName: ""}); // also clear input box
};
// Called when we fail trying to add an item
doAddError = (msg: string): void => {
console.error(`Error fetching /add: ${msg}`);
};
// Called each time the text in the new item name field is changed.
doNewNameChange = (evt: ChangeEvent<HTMLInputElement>): void => {
if (DEBUG) console.log(`changeing new name to ${evt.target.value}`);
this.setState({newName: evt.target.value});
};
}
item.ts
import { isRecord } from './record';
// Description of an individual item in the To-Do list
export type Item = {
name: string;
completed: boolean;
};
/**
* Parses unknown data into an array of Items. Will log an error and return
* undefined if it is not an array of Items.
* @param val unknown data to parse into an array of Items
* @return Item[] if val is an array of Items and undefined otherwise
*/
export const parseItems = (val: unknown): undefined | Item[] => {
if (!Array.isArray(val)) {
console.error("not an array", val);
return undefined;
}
const items: Item[] = [];
for (const item of val) {
if (!isRecord(item)) {
console.error("item is not a record", val);
return undefined;
} else if (typeof item.name !== 'string') {
console.error("item.name is missing or invalid", item.name);
return undefined;
} else if (typeof item.completed !== 'boolean') {
console.error("item.completed is missing or invalid", item.completed);
return undefined;
} else {
items.push({name: item.name, completed: item.completed});
}
}
return items;
};
Full Code