auction.ts
import { isRecord } from "./record";
// Description of an individual auction
// RI: minBid, maxBid >= 0
export type Auction = {
readonly name: string,
readonly description: string,
readonly seller: string,
readonly endTime: number, // ms since epoch
readonly maxBid: number,
readonly maxBidder: string
};
/**
* Parses unknown data into an Auction. Will log an error and return undefined
* if it is not a valid Auction.
* @param val unknown data to parse into an Auction
* @return Auction if val is a valid Auction and undefined otherwise
*/
export const parseAuction = (val: unknown): undefined | Auction => {
if (!isRecord(val)) {
console.error("not an auction", val)
return undefined;
}
if (typeof val.name !== "string") {
console.error("not an auction: missing 'name'", val)
return undefined;
}
if (typeof val.description !== "string") {
console.error("not an auction: missing 'description'", val)
return undefined;
}
if (typeof val.seller !== "string") {
console.error("not an auction: missing 'seller'", val)
return undefined;
}
if (typeof val.maxBid !== "number" || val.maxBid < 0 || isNaN(val.maxBid)) {
console.error("not an auction: missing or invalid 'maxBid'", val)
return undefined;
}
if (typeof val.maxBidder !== "string") {
console.error("not an auction: missing or invalid 'maxBidder'", val)
return undefined;
}
if (typeof val.endTime !== "number" || val.endTime < 0 || isNaN(val.endTime)) {
console.error("not an auction: missing or invalid 'endTime'", val)
return undefined;
}
return {
name: val.name, description: val.description, seller: val.seller, maxBid:
val.maxBid, maxBidder: val.maxBidder, endTime: val.endTime
};
};
App.tsx
import React, { Component } from 'react';
import { AuctionList } from './AuctionList';
import { AuctionDetails } from './AuctionDetails';
import { NewAuction } from './NewAuction';
// Indicates which page to show. If it is the details page, the argument
// includes the specific auction to show the details of.
type Page = "list" | "new" | {kind: "details", name: string};
// RI: If page is "details", then index is a valid index into auctions array.
type AppState = {page: Page};
// Whether to show debugging information in the console.
const DEBUG: boolean = true;
// Top-level component that displays the appropriate page.
export class App extends Component<{}, AppState> {
constructor(props: {}) {
super(props);
this.state = {page: "list"};
}
render = (): JSX.Element => {
if (this.state.page === "list") {
if (DEBUG) console.debug("rendering list page");
return <AuctionList onNewClick={this.doNewClick}
onAuctionClick={this.doAuctionClick}/>;
} else if (this.state.page === "new") {
if (DEBUG) console.debug("rendering add page");
return <NewAuction onBackClick={this.doBackClick}/>;
} else { // details
if (DEBUG) console.debug(`rendering details page for "${this.state.page.name}"`);
return <AuctionDetails name={this.state.page.name}
onBackClick={this.doBackClick}/>;
}
};
doNewClick = (): void => {
if (DEBUG) console.debug("set state to new");
this.setState({page: "new"});
};
doAuctionClick = (name: string): void => {
if (DEBUG) console.debug(`set state to details for auction ${name}`);
this.setState({page: {kind: "details", name}});
};
doBackClick = (): void => {
if (DEBUG) console.debug("set state to list");
this.setState({page: "list"});
};
}
AuctionList.tsx
import React, { Component, MouseEvent } from 'react';
import { Auction, parseAuction } from './auction';
import { isRecord } from './record';
type ListProps = {
onNewClick: () => void,
onAuctionClick: (name: string) => void
};
type ListState = {
now: number, // current time when rendering
auctions: Auction[] | undefined,
};
// Shows the list of all the auctions.
export class AuctionList extends Component<ListProps, ListState> {
constructor(props: ListProps) {
super(props);
this.state = {now: Date.now(), auctions: undefined};
}
componentDidMount = (): void => {
this.doRefreshClick();
}
componentDidUpdate = (prevProps: ListProps): void => {
if (prevProps !== this.props) {
this.setState({now: Date.now()}); // Force a refresh
}
};
render = (): JSX.Element => {
return (
<div>
<h2>Current Auctions</h2>
{this.renderAuctions()}
<button type="button" onClick={this.doRefreshClick}>Refresh</button>
<button type="button" onClick={this.doNewClick}>New Auction</button>
</div>);
};
renderAuctions = (): JSX.Element => {
if (this.state.auctions === undefined) {
return <p>Loading auction list...</p>;
} else {
const auctions: JSX.Element[] = [];
for (const auction of this.state.auctions) {
const min = (auction.endTime - this.state.now) / 60 / 1000;
const desc = (min < 0) ? "" :
<span> – {Math.round(min)} minutes remaining</span>;
auctions.push(
<li key={auction.name}>
<a href="#" onClick={(evt) => this.doAuctionClick(evt, auction.name)}>{auction.name}</a>
{desc}
</li>);
}
return <ul>{auctions}</ul>;
}
};
doListResp = (resp: Response): void => {
if (resp.status === 200) {
resp.json().then(this.doListJson)
.catch(() => this.doListError("200 response is not JSON"));
} else if (resp.status === 400) {
resp.text().then(this.doListError)
.catch(() => this.doListError("400 response is not text"));
} else {
this.doListError(`bad status code from /api/list: ${resp.status}`);
}
};
doListJson = (data: unknown): void => {
if (!isRecord(data)) {
console.error("bad data from /api/list: not a record", data);
return;
}
if (!Array.isArray(data.auctions)) {
console.error("bad data from /api/list: auctions is not an array", data);
return;
}
const auctions: Auction[] = [];
for (const val of data.auctions) {
const auction = parseAuction(val);
if (auction === undefined)
return;
auctions.push(auction);
}
this.setState({auctions, now: Date.now()}); // fix time also
};
doListError = (msg: string): void => {
console.error(`Error fetching /api/list: ${msg}`);
};
doRefreshClick = (): void => {
fetch("/api/list").then(this.doListResp)
.catch(() => this.doListError("failed to connect to server"));
};
doNewClick = (_evt: MouseEvent<HTMLButtonElement>): void => {
this.props.onNewClick(); // tell the parent to show the new auction page
};
doAuctionClick = (evt: MouseEvent<HTMLAnchorElement>, name: string): void => {
evt.preventDefault();
this.props.onAuctionClick(name);
};
}
NewAuction.tsx
import React, { Component, ChangeEvent, MouseEvent } from 'react';
import { isRecord } from './record';
type NewAuctionProps = {
onBackClick: () => void
};
type NewAuctionState = {
name: string,
description: string,
seller: string,
minutes: string,
minBid: string
error: string
};
// Allows the user to create a new auction.
export class NewAuction extends Component<NewAuctionProps, NewAuctionState> {
constructor(props: NewAuctionProps) {
super(props);
this.state = {name: "", description: "", seller: "", minutes: "60",
minBid: "1", error: ""};
}
render = (): JSX.Element => {
return (
<div>
<h2>New Auction</h2>
<div>
<label htmlFor="name">Item Name:</label>
<input id="name" type="text" value={this.state.name}
onChange={this.doNameChange}></input>
</div>
<div>
<label htmlFor="description">Description:</label>
<input id="description" type="text" value={this.state.description}
onChange={this.doDescChange}></input>
</div>
<div>
<label htmlFor="seller">Seller Name:</label>
<input id="seller" type="text" value={this.state.seller}
onChange={this.doSellerChange}></input>
</div>
<div>
<label htmlFor="minutes">Minutes:</label>
<input id="minutes" type="number" min="1" value={this.state.minutes}
onChange={this.doMinutesChange}></input>
</div>
<div>
<label htmlFor="minBid">Minimum Bid:</label>
<input id="minBid" type="number" min="1" value={this.state.minBid}
onChange={this.doMinBidChange}></input>
</div>
<button type="button" onClick={this.doStartClick}>Start</button>
<button type="button" onClick={this.doBackClick}>Back</button>
{this.renderError()}
</div>);
};
renderError = (): JSX.Element => {
if (this.state.error.length === 0) {
return <div></div>;
} else {
const style = {width: '300px', backgroundColor: 'rgb(246,194,192)',
border: '1px solid rgb(137,66,61)', borderRadius: '5px', padding: '5px' };
return (<div style={{marginTop: '15px'}}>
<span style={style}><b>Error</b>: {this.state.error}</span>
</div>);
}
};
doNameChange = (evt: ChangeEvent<HTMLInputElement>): void => {
this.setState({name: evt.target.value, error: ""});
};
doDescChange = (evt: ChangeEvent<HTMLInputElement>): void => {
this.setState({description: evt.target.value, error: ""});
};
doSellerChange = (evt: ChangeEvent<HTMLInputElement>): void => {
this.setState({seller: evt.target.value, error: ""});
};
doMinutesChange = (evt: ChangeEvent<HTMLInputElement>): void => {
this.setState({minutes: evt.target.value, error: ""});
};
doMinBidChange = (evt: ChangeEvent<HTMLInputElement>): void => {
this.setState({minBid: evt.target.value, error: ""});
};
doStartClick = (_: MouseEvent<HTMLButtonElement>): void => {
// Verify that the user entered all required information.
if (this.state.name.trim().length === 0 ||
this.state.description.trim().length === 0 ||
this.state.seller.trim().length === 0 ||
this.state.minutes.trim().length === 0 ||
this.state.minBid.trim().length === 0) {
this.setState({error: "a required field is missing."});
return;
}
// Verify that minutes is a number.
const minutes = parseFloat(this.state.minutes);
if (isNaN(minutes) || minutes < 1 || Math.floor(minutes) !== minutes) {
this.setState({error: "minutes is not a positive integer"});
return;
}
// Ignore this request if the minutes or minBid are not numbers.
const minBid = parseFloat(this.state.minBid);
if (isNaN(minBid) || minBid < 1 || Math.floor(minBid) !== minBid) {
this.setState({error: "min bid is not a positive integer"});
return;
}
// Ask the app to start this auction (adding it to the list).
const args = { name: this.state.name,
description: this.state.description, seller: this.state.seller,
minBid: minBid, minutes: minutes };
fetch("/api/add", {
method: "POST", body: JSON.stringify(args),
headers: {"Content-Type": "application/json"} })
.then(this.doAddResp)
.catch(() => this.doAddError("failed to connect to server"));
};
doAddResp = (resp: Response): void => {
if (resp.status === 200) {
resp.json().then(this.doAddJson)
.catch(() => this.doAddError("200 response is not JSON"));
} else if (resp.status === 400) {
resp.text().then(this.doAddError)
.catch(() => this.doAddError("400 response is not text"));
} else {
this.doAddError(`bad status code from /api/add: ${resp.status}`);
}
};
doAddJson = (data: unknown): void => {
if (!isRecord(data)) {
console.error("bad data from /api/add: not a record", data);
return;
}
this.props.onBackClick(); // show the updated list
};
doAddError = (msg: string): void => {
this.setState({error: msg})
};
doBackClick = (_: MouseEvent<HTMLButtonElement>): void => {
this.props.onBackClick(); // tell the parent this was clicked
};
}
AuctionDetails.tsx
import React, { Component, ChangeEvent, MouseEvent } from 'react';
import { Auction, parseAuction } from './auction';
import { isRecord } from './record';
type DetailsProps = {
name: string,
onBackClick: () => void,
};
type DetailsState = {
now: number,
auction: Auction | undefined,
bidder: string,
amount: string,
error: string
};
// Shows an individual auction and allows bidding (if ongoing).
export class AuctionDetails extends Component<DetailsProps, DetailsState> {
constructor(props: DetailsProps) {
super(props);
this.state = {now: Date.now(), auction: undefined,
bidder: "", amount: "", error: ""};
}
componentDidMount = (): void => {
this.doRefreshClick();
};
render = (): JSX.Element => {
if (this.state.auction === undefined) {
return <p>Loading auction "{this.props.name}"...</p>
} else {
if (this.state.auction.endTime <= this.state.now) {
return this.renderCompleted(this.state.auction);
} else {
return this.renderOngoing(this.state.auction);
}
}
};
renderCompleted = (auction: Auction): JSX.Element => {
return (
<div>
<h2>{auction.name}</h2>
<p>{auction.description}</p>
<p>Winning Bid: {auction.maxBid} (by {auction.maxBidder})</p>
</div>);
};
renderOngoing = (auction: Auction): JSX.Element => {
const min = Math.round((auction.endTime - this.state.now) / 60 / 100) / 10;
return (
<div>
<h2>{auction.name}</h2>
<p>{auction.description}</p>
<p><i>Bidding ends in {min} minutes...</i></p>
<p>Current Bid: ${auction.maxBid} by {auction.maxBidder}</p>
<div>
<label htmlFor="bidder">Name:</label>
<input type="text" id="bidder" value={this.state.bidder}
onChange={this.doBidderChange}></input>
</div>
<div>
<label htmlFor="amount">Amount:</label>
<input type="number" min={auction.maxBid + 1}
id="amount" value={this.state.amount}
onChange={this.doAmountChange}></input>
</div>
<button type="button" onClick={this.doBidClick}>Bid</button>
<button type="button" onClick={this.doRefreshClick}>Refresh</button>
<button type="button" onClick={this.doDoneClick}>Done</button>
{this.renderError()}
</div>);
};
renderError = (): JSX.Element => {
if (this.state.error.length === 0) {
return <div></div>;
} else {
const style = {width: '300px', backgroundColor: 'rgb(246,194,192)',
border: '1px solid rgb(137,66,61)', borderRadius: '5px', padding: '5px' };
return (<div style={{marginTop: '15px'}}>
<span style={style}><b>Error</b>: {this.state.error}</span>
</div>);
}
};
doRefreshClick = (): void => {
const args = {name: this.props.name};
fetch("/api/get", {
method: "POST", body: JSON.stringify(args),
headers: {"Content-Type": "application/json"} })
.then(this.doGetResp)
.catch(() => this.doGetError("failed to connect to server"));
};
doGetResp = (res: Response): void => {
if (res.status === 200) {
res.json().then(this.doGetJson)
.catch(() => this.doGetError("200 res is not JSON"));
} else if (res.status === 400) {
res.text().then(this.doGetError)
.catch(() => this.doGetError("400 response is not text"));
} else {
this.doGetError(`bad status code from /api/refersh: ${res.status}`);
}
};
doGetJson = (data: unknown): void => {
if (!isRecord(data)) {
console.error("bad data from /api/refresh: not a record", data);
return;
}
this.doAuctionChange(data);
}
// Shared helper to update the state with the new auction.
doAuctionChange = (data: {auction?: unknown}): void => {
const auction = parseAuction(data.auction);
if (auction !== undefined) {
// If the current bid is too small, let's also fix that.
const amount = parseFloat(this.state.amount);
if (isNaN(amount) || amount < auction.maxBid + 1) {
this.setState({auction, now: Date.now(), error: "",
amount: '' + (auction.maxBid + 1)});
} else {
this.setState({auction, now: Date.now(), error: ""});
}
} else {
console.error("auction from /api/fresh did not parse", data.auction)
}
};
doGetError = (msg: string): void => {
console.error(`Error fetching /api/refresh: ${msg}`);
};
doBidderChange = (evt: ChangeEvent<HTMLInputElement>): void => {
this.setState({bidder: evt.target.value, error: ""});
};
doAmountChange = (evt: ChangeEvent<HTMLInputElement>): void => {
this.setState({amount: evt.target.value, error: ""});
};
doBidClick = (_: MouseEvent<HTMLButtonElement>): void => {
if (this.state.auction === undefined)
throw new Error("impossible");
// Verify that the user entered all required information.
if (this.state.bidder.trim().length === 0 ||
this.state.amount.trim().length === 0) {
this.setState({error: "a required field is missing."});
return;
}
// Verify that amount is a positive integer.
const amount = parseFloat(this.state.amount);
if (isNaN(amount) || Math.floor(amount) !== amount) {
this.setState({error: "amount is not an integer"});
return;
}
// Verify that amount is bigger than the current bid.
if (amount < this.state.auction.maxBid + 1) {
this.setState({error: "amount is not bigger than current bid"});
return;
}
const args = {name: this.props.name, bidder: this.state.bidder, amount};
fetch("/api/bid", {
method: "POST", body: JSON.stringify(args),
headers: {"Content-Type": "application/json"} })
.then(this.doBidResp)
.catch(() => this.doBidError("failed to connect to server"));
};
doBidResp = (res: Response): void => {
if (res.status === 200) {
res.json().then(this.doBidJson)
.catch(() => this.doBidError("200 response is not JSON"));
} else if (res.status === 400) {
res.text().then(this.doBidError)
.catch(() => this.doBidError("400 response is not text"));
} else {
this.doBidError(`bad status code from /api/bid: ${res.status}`);
}
};
doBidJson = (data: unknown): void => {
if (this.state.auction === undefined)
throw new Error("impossible");
if (!isRecord(data)) {
console.error("bad data from /api/bid: not a record", data);
return;
}
this.doAuctionChange(data);
};
doBidError = (msg: string): void => {
console.error(`Error fetching /api/bid: ${msg}`);
};
doDoneClick = (_: MouseEvent<HTMLButtonElement>): void => {
this.props.onBackClick(); // tell the parent to show the full list again
};
}
Full Code