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, NewAuctionInfo } from './NewAuction';
import { Auction, parseAuction } from './auction';
import { isRecord } from './record';
// 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", index: number};
// RI: If page is "details", then index is a valid index into auctions array.
type AppState = {page: Page, auctions: ReadonlyArray<Auction> | undefined};
// 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", auctions: []};
}
componentDidMount = (): void => {
this.doRefreshAllClick();
};
render = (): JSX.Element => {
if (this.state.auctions === undefined) {
if (DEBUG) console.debug("rendering loading page");
return <div>Loading...</div>;
} else if (this.state.page === "list") {
if (DEBUG) console.debug("rendering list page");
return <AuctionList auctions={this.state.auctions}
onNewClick={this.doNewClick}
onAuctionClick={this.doAuctionClick}
onRefreshClick={this.doRefreshAllClick}/>;
} else if (this.state.page === "new") {
if (DEBUG) console.debug("rendering add page");
return <NewAuction onStartClick={this.doStartClick}
onBackClick={this.doBackClick}/>;
} else { // details
const index = this.state.page.index;
const auction = this.state.auctions[index];
if (DEBUG) console.debug(`rendering details page for "${auction.name}"`);
return <AuctionDetails auction={auction}
onBidClick={(bidder, amt) => this.doBidClick(index, bidder, amt)}
onRefreshClick={() => this.doRefreshAuctionClick(index)}
onBackClick={this.doBackClick}/>;
}
};
doRefreshAllClick = (): void => {
fetch("/api/list").then(this.doListResp)
.catch(() => this.doListError("failed to connect to server"));
};
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});
};
doListError = (msg: string): void => {
console.error(`Error fetching /api/list: ${msg}`);
};
doNewClick = (): void => {
if (DEBUG) console.debug("set state to new");
this.setState({page: "new"});
};
doAuctionClick = (index: number): void => {
if (DEBUG) console.debug(`set state to details for auction ${index}`);
this.setState({page: {kind: "details", index}});
};
doBackClick = (): void => {
if (DEBUG) console.debug("set state to list");
this.setState({page: "list"});
};
doStartClick = (info: NewAuctionInfo): void => {
if (DEBUG) console.debug("add an auction and then show the full list");
fetch("/api/add", {
method: "POST", body: JSON.stringify(info),
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;
}
// Not sure where this goes, so let's fetch again.
const auction = parseAuction(data.auction);
if (auction !== undefined) {
this.setState({page: "list", auctions: undefined}); // show loading
fetch("/api/list").then(this.doListResp)
.catch(() => this.doListError("failed to connect to server"));
}
};
doAddError = (msg: string): void => {
console.error(`Error fetching /api/add: ${msg}`);
};
doBidClick = (index: number, bidder: string, amount: number): void => {
if (DEBUG) console.debug("add an auction and then show the full list");
const args = {index, bidder, amount};
fetch("/api/bid", {
method: "POST", body: JSON.stringify(args),
headers: {"Content-Type": "application/json"} })
.then((res) => this.doBidResp(index, res))
.catch(() => this.doBidError("failed to connect to server"));
};
doBidResp = (index: number, res: Response): void => {
if (res.status === 200) {
res.json().then((data) => this.doBidJson(index, data))
.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 = (index: number, data: unknown): void => {
if (this.state.auctions === undefined)
throw new Error("impossible");
if (!isRecord(data)) {
console.error("bad data from /api/bid: not a record", data);
return;
}
const auction = parseAuction(data.auction);
if (auction !== undefined) {
// Re-display the details page with the new auction.
const auctions = this.state.auctions.slice(0, index)
.concat([auction])
.concat(this.state.auctions.slice(index+1));
this.setState({auctions});
}
};
doBidError = (msg: string): void => {
console.error(`Error fetching /api/bid: ${msg}`);
};
doRefreshAuctionClick = (index: number): void => {
if (DEBUG) console.debug("add an auction and then show the full list");
const args = {index};
fetch("/api/get", {
method: "POST", body: JSON.stringify(args),
headers: {"Content-Type": "application/json"} })
.then((res) => this.doGetResp(index, res))
.catch(() => this.doGetError("failed to connect to server"));
};
doGetResp = (index: number, res: Response): void => {
if (res.status === 200) {
res.json().then((data) => this.doGetJson(index, data))
.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 = (index: number, data: unknown): void => {
if (this.state.auctions === undefined)
throw new Error("impossible");
if (!isRecord(data)) {
console.error("bad data from /api/refresh: not a record", data);
return;
}
const auction = parseAuction(data.auction);
if (auction !== undefined) {
// Re-display the details page with the new auction.
const auctions = this.state.auctions.slice(0, index)
.concat([auction])
.concat(this.state.auctions.slice(index+1));
this.setState({auctions});
}
};
doGetError = (msg: string): void => {
console.error(`Error fetching /api/refresh: ${msg}`);
};
}
AuctionList.tsx
import React, { Component, MouseEvent } from 'react';
import { Auction } from './auction';
type ListProps = {
auctions: ReadonlyArray<Auction>,
onNewClick: () => void,
onRefreshClick: () => void,
onAuctionClick: (index: number) => void
};
type ListState = {
now: number, // current time when rendering
};
// Shows the list of all the auctions.
export class AuctionList extends Component<ListProps, ListState> {
constructor(props: ListProps) {
super(props);
this.state = {now: Date.now()}
}
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>
<ul>{this.renderAuctions()}</ul>
<button type="button" onClick={this.doRefreshClick}>Refresh</button>
<button type="button" onClick={this.doNewClick}>New Auction</button>
</div>);
};
renderAuctions = (): JSX.Element[] => {
const auctions: JSX.Element[] = [];
// Inv: auctions = LI for each of auctions[0 .. i-1]
for (let i = 0; i < this.props.auctions.length; i++) {
const auction = this.props.auctions[i];
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, i)}>{auction.name}</a>
{desc}
</li>);
}
return auctions;
};
doRefreshClick = (_evt: MouseEvent<HTMLButtonElement>): void => {
this.props.onRefreshClick();
};
doNewClick = (_evt: MouseEvent<HTMLButtonElement>): void => {
this.props.onNewClick(); // tell the parent to show the new auction page
};
doAuctionClick = (evt: MouseEvent<HTMLAnchorElement>, index: number): void => {
evt.preventDefault();
this.props.onAuctionClick(index);
};
}
NewAuction.tsx
import React, { Component, ChangeEvent, MouseEvent } from 'react';
export type NewAuctionInfo = {
name: string,
description: string,
seller: string,
minutes: number,
minBid: number
};
type NewAuctionProps = {
onStartClick: (info: NewAuctionInfo) => void,
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).
this.props.onStartClick({
name: this.state.name,
description: this.state.description,
seller: this.state.seller,
minBid: minBid,
minutes: minutes });
};
doBackClick = (_: MouseEvent<HTMLButtonElement>): void => {
this.props.onBackClick(); // tell the parent this was clicked
};
}
AuctionDetails.tsx
import React, { Component, ChangeEvent, MouseEvent } from 'react';
import { Auction } from './auction';
type DetailsProps = {
auction: Auction,
onBidClick: (bidder: string, amount: number) => void,
onRefreshClick: () => void,
onBackClick: () => void,
};
type DetailsState = {
now: number,
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);
const amount = this.props.auction.maxBid + 1; // min bid is 1 more
this.state = {now: Date.now(), bidder: "", amount: '' + amount, error: ""};
}
componentDidUpdate = (prevProps: DetailsProps): void => {
if (prevProps !== this.props) {
// If the user's bid is too small, update it to be valid. In any case,
// we need to update the time.
const amount = parseFloat(this.state.amount);
if (!isNaN(amount) && amount < this.props.auction.maxBid + 1) {
this.setState({amount: '' + (this.props.auction.maxBid + 1),
now: Date.now(), error: ""});
} else {
this.setState({now: Date.now(), error: ""});
}
}
};
render = (): JSX.Element => {
const auction = this.props.auction;
if (auction.endTime <= this.state.now) {
return this.renderCompleted();
} else {
return this.renderOngoing();
}
};
renderCompleted = (): JSX.Element => {
const auction = this.props.auction;
return (
<div>
<h2>{auction.name}</h2>
<p>{auction.description}</p>
<p>Winning Bid: {auction.maxBid} (by {auction.maxBidder})</p>
</div>);
};
renderOngoing = (): JSX.Element => {
const auction = this.props.auction;
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>);
}
};
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 => {
// 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.props.auction.maxBid + 1) {
this.setState({error: "amount is not bigger than current bid"});
return;
}
this.props.onBidClick(this.state.bidder, amount);
};
doRefreshClick = (_evt: MouseEvent<HTMLButtonElement>): void => {
this.props.onRefreshClick(); // tell the parent to redraw this
};
doDoneClick = (_: MouseEvent<HTMLButtonElement>): void => {
this.props.onBackClick(); // tell the parent to show the full list again
};
}
Full Code