Initial frontend commit

This commit is contained in:
Mikko Ahlroth 2019-05-30 15:29:56 +03:00
parent d51ca3ad7c
commit ef24b41019
18 changed files with 4390 additions and 0 deletions

2
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
dist/

17
frontend/index.html Normal file
View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Tiny Budget</title>
</head>
<body>
<main id="main-content">
<p>Loading TinyBudget… You need to have JavaScript enabled to see this page.</p>
</main>
<script src="dist/bundle.js"></script>
</body>
</html>

26
frontend/package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "weekbudget-frontend",
"version": "0.0.1",
"description": "Frontend PWA for the WeekBudget project.",
"main": "index.ts",
"repository": "https://gitlab.com/Nicd/weekbudget",
"author": "Mikko Ahlroth <mikko.ahlroth@gmail.com>",
"license": "MIT",
"private": true,
"devDependencies": {
"ts-loader": "^6.0.1",
"typescript": "^3.4.5",
"webpack": "^4.32.2",
"webpack-cli": "^3.3.2",
"webpack-dev-server": "^3.4.1"
},
"dependencies": {
"lit-element": "^2.1.0"
},
"scripts": {
"build": "webpack --mode development",
"serve": "webpack-dev-server",
"watch": "webpack --mode development -w",
"prod": "webpack --mode production"
}
}

View file

@ -0,0 +1,55 @@
import { LitElement, html } from 'lit-element'
const INPUT_ID = 'new-budget-uuid'
export class AddBudgetEvent extends CustomEvent<{ uuid: string }> { }
class BudgetAddComponent extends LitElement {
hasError: boolean
static get properties() {
return {
hasError: { type: String }
}
}
constructor() {
super()
this.hasError = false
}
onSubmit(e: Event) {
const root = <ShadowRoot>this.shadowRoot
const inputEl = <HTMLInputElement>root.getElementById(INPUT_ID)
const success = this.dispatchEvent(new AddBudgetEvent(
'addBudget',
{ detail: { uuid: inputEl.value } }
));
e.preventDefault()
inputEl.value = ''
}
render() {
return html`
<h2>Join a budget</h2>
<form @submit=${this.onSubmit}>
<p>Add below the budget secret that you can get from the person who created the budget.</p>
<input
id="${INPUT_ID}"
type="text"
placeholder="aaaaaaaa-bbbb-4ccc-9ddd-ffffffffffff"
maxlength="36"
pattern="[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}"
required
/>
<button type="submit">Add budget</button>
${this.hasError ? html`<p>Could not add given budget, please check it is correctly typed.</p>` : undefined}
</form>
`
}
}
customElements.define('budget-add', BudgetAddComponent)

View file

@ -0,0 +1,77 @@
import { LitElement, html } from 'lit-element'
import { Budget } from '../services/models/budget'
export class SpendMoneyEvent extends CustomEvent<{ budget: Budget, amount: number }> { }
export class ResetBudgetEvent extends CustomEvent<{ budget: Budget, amount: number }> { }
export class RemoveBudgetEvent extends CustomEvent<{ budget: Budget }> { }
class BudgetDisplayComponent extends LitElement {
budget: Budget
static get properties() {
return {
budget: { type: Budget }
}
}
constructor() {
super()
this.budget = null
}
onSpendMoney() {
const amount = parseFloat(prompt('Amount spent (use negative to receive money instead)'))
if (isNaN(amount)) {
return
}
this.dispatchEvent(new SpendMoneyEvent(
'spendMoney', { detail: { budget: this.budget, amount }, bubbles: true, composed: true }
))
}
onReset() {
const amount = parseFloat(prompt('Reset budget amount to (will delete all events)'))
if (isNaN(amount)) {
return
}
this.dispatchEvent(new ResetBudgetEvent(
'resetBudget', { detail: { budget: this.budget, amount }, bubbles: true, composed: true }
))
}
onRemove() {
if (!confirm('Do you really wish to remove the budget?')) {
return
}
this.dispatchEvent(new RemoveBudgetEvent(
'removeBudget', { detail: { budget: this.budget }, bubbles: true, composed: true }
))
}
render() {
if (!this.budget) {
return html``
}
return html`
<h3>${this.budget.uuid}</h3>
<p>${this.budget.amount.toFixed(2)} ${this.budget.currency} left of ${this.budget.init.toFixed(2)} ${this.budget.currency}</p>
<ul>
${this.budget.events.map(event => html`<li>${event.at.toISOString()}: ${event.amount.toFixed(2)}</li>`)}
</ul>
<button type="button" @click=${this.onSpendMoney}>Spend money</button>
<button type="button" @click=${this.onReset}>Reset</button>
<button type="button" @click=${this.onRemove}>Remove</button>
`
}
}
customElements.define('budget-display', BudgetDisplayComponent)

View file

@ -0,0 +1,27 @@
import './budget-display'
import { LitElement, html } from 'lit-element'
import { Budget } from '../services/models/budget'
class BudgetListComponent extends LitElement {
budgets: Budget[]
static get properties() {
return {
budgets: { type: Array }
}
}
constructor() {
super()
this.budgets = []
}
render() {
return html`
${this.budgets.map(b => html`<budget-display .budget=${b}></budget-display>`)}
`
}
}
customElements.define('budget-list', BudgetListComponent)

View file

@ -0,0 +1,112 @@
import { LitElement, html } from 'lit-element'
import { Budget } from '../services/models/budget'
import { AddBudgetEvent } from './budget-add'
import './budget-add'
import './budget-list'
import { readBudget, readEvent } from '../services/readers'
import { getBudget, GetError, addEvent, resetBudget } from '../services/api'
import { getFromLocal, storeToLocal } from '../services/storage'
import { SpendMoneyEvent, ResetBudgetEvent, RemoveBudgetEvent } from './budget-display'
import { ETIME } from 'constants';
class WeekBudgetComponent extends LitElement {
budgets: Budget[] = []
hasAddError: boolean = false
static get properties() {
return {
budgets: { type: Array },
hasAddError: { type: Boolean }
}
}
async firstUpdated() {
const uuids = getFromLocal()
for (const uuid of uuids) {
try {
const budget = await this._getBudget(uuid)
this.budgets = [...this.budgets, budget]
}
catch (err) {
console.error(err)
}
}
}
async onAddBudget(e: AddBudgetEvent) {
this.hasAddError = false
try {
const budget = await this._getBudget(e.detail.uuid)
this.budgets = [...this.budgets, budget]
storeToLocal(this.budgets)
}
catch (err) {
if (err instanceof GetError) {
this.hasAddError = true
}
}
}
async onSpendMoney(e: SpendMoneyEvent) {
try {
const event = await addEvent(e.detail.budget.uuid, e.detail.amount)
const eventObj = readEvent(event)
const budgetIdx = this.budgets.findIndex(b => b.uuid === e.detail.budget.uuid)
const budget = this.budgets[budgetIdx].copy()
budget.amount -= e.detail.amount
budget.events.unshift(eventObj)
budget.makeImmutable()
const budgetsCopy = [...this.budgets]
budgetsCopy.splice(budgetIdx, 1, budget)
this.budgets = budgetsCopy
}
catch (err) {
console.error(err)
}
}
async onResetBudget(e: ResetBudgetEvent) {
try {
const budget = await resetBudget(e.detail.budget.uuid, e.detail.amount)
const budgetObj = readBudget(budget)
const budgetIdx = this.budgets.findIndex(b => b.uuid === e.detail.budget.uuid)
const budgetsCopy = [...this.budgets]
budgetsCopy.splice(budgetIdx, 1, budgetObj)
this.budgets = budgetsCopy
}
catch (err) {
console.error(err)
}
}
async onRemoveBudget(e: RemoveBudgetEvent) {
const budgetIdx = this.budgets.findIndex(b => b.uuid === e.detail.budget.uuid)
const budgetsCopy = [...this.budgets]
budgetsCopy.splice(budgetIdx, 1)
this.budgets = budgetsCopy
storeToLocal(this.budgets)
}
render() {
return html`
<h1>TinyBudget</h1>
<budget-list
@spendMoney=${this.onSpendMoney}
@resetBudget=${this.onResetBudget}
@removeBudget=${this.onRemoveBudget}
.budgets=${this.budgets}
></budget-list>
<budget-add @addBudget=${this.onAddBudget} .hasError=${this.hasAddError}></budget-add>
`
}
async _getBudget(uuid: string): Promise<Budget> {
return readBudget(await getBudget(uuid)).makeImmutable()
}
}
customElements.define('week-budget', WeekBudgetComponent)

6
frontend/src/index.ts Normal file
View file

@ -0,0 +1,6 @@
import './components/week-budget'
window.onload = () => {
const mainEl = document.getElementById('main-content')
mainEl.innerHTML = '<week-budget></week-budget>'
}

View file

@ -0,0 +1,72 @@
import { APIBudget, APIEvent } from './typings'
class APIError extends Error { }
export class GetError extends APIError { }
export class CreationError extends APIError { }
export class ResetError extends APIError { }
export class EventCreationError extends APIError { }
const BASE_HEADERS = {
'accept': 'application/json',
'content-type': 'application/json'
}
export async function getBudget(uuid: string): Promise<APIBudget> {
const resp = await fetch(_uuid2Path(uuid), {
method: 'GET',
headers: BASE_HEADERS
})
if (resp.status !== 200) {
throw new GetError('Unable to read budget.')
}
return resp.json()
}
export async function createBudget(amount: number, currency: string): Promise<APIBudget> {
const resp = await fetch('/budgets', {
method: 'POST',
headers: BASE_HEADERS,
body: JSON.stringify({ amount, currency })
})
if (resp.status !== 201) {
throw new CreationError('Unable to create budget.')
}
return resp.json()
}
export async function resetBudget(uuid: string, amount: number): Promise<APIBudget> {
const resp = await fetch(_uuid2Path(uuid), {
method: 'PATCH',
headers: BASE_HEADERS,
body: JSON.stringify({ amount })
})
if (resp.status !== 200) {
throw new ResetError('Unable to reset budget.')
}
return resp.json()
}
export async function addEvent(uuid: string, amount: number): Promise<APIEvent> {
const resp = await fetch(`${_uuid2Path(uuid)}/events`, {
method: 'POST',
headers: BASE_HEADERS,
body: JSON.stringify({ amount })
})
if (resp.status !== 201) {
throw new EventCreationError('Unable to create event.')
}
return resp.json()
}
function _uuid2Path(uuid: string) {
return `/budgets/${uuid}`
}

View file

@ -0,0 +1,13 @@
import { ImmutableModel } from './immutable-model'
export class BudgetEvent extends ImmutableModel {
at: Date
amount: number
constructor(at: Date, amount: number) {
super()
this.at = at
this.amount = amount
}
}

View file

@ -0,0 +1,30 @@
import { ImmutableModel } from './immutable-model'
import { BudgetEvent } from './budget-event';
export class Budget extends ImmutableModel {
uuid: string
currency: string
init: number
amount: number
events: BudgetEvent[]
constructor(uuid: string, currency: string, init: number, amount: number, events: Array<BudgetEvent>) {
super()
this.uuid = uuid
this.currency = currency
this.init = init
this.amount = amount
this.events = events
}
copy(): Budget {
return new Budget(
this.uuid,
this.currency,
this.init,
this.amount,
this.events
)
}
}

View file

@ -0,0 +1,12 @@
class ImmutableModelCopyError extends Error { }
export class ImmutableModel {
makeImmutable(): this {
Object.freeze(this)
return this
}
copy(): ImmutableModel {
throw new ImmutableModelCopyError(`Copy operation not implemented for ${this.constructor.name}.`)
}
}

View file

@ -0,0 +1,24 @@
import { Budget } from './models/budget'
import { BudgetEvent } from './models/budget-event'
import { APIEvent, APIBudget } from './typings'
export function readEvent(data: APIEvent): BudgetEvent {
const at = new Date(data.at)
return new BudgetEvent(at, data.amount)
}
export function readBudget(data: APIBudget): Budget {
const events: BudgetEvent[] = []
for (const apiEvent of data.events) {
events.push(readEvent(apiEvent).makeImmutable())
}
return new Budget(
data.uuid,
data.currency,
data.init,
data.amount,
events
)
}

View file

@ -0,0 +1,13 @@
import { Budget } from './models/budget'
const LS_KEY = 'tinybudget-budgets'
export function storeToLocal(budgets: Budget[]) {
const uuids = budgets.reduce((acc: string[], val) => [...acc, val.uuid], [])
window.localStorage.setItem(LS_KEY, JSON.stringify(uuids))
}
export function getFromLocal(): string[] {
const uuids = window.localStorage.getItem(LS_KEY) || '[]'
return JSON.parse(uuids)
}

View file

@ -0,0 +1,12 @@
export type APIEvent = {
amount: number,
at: string
}
export type APIBudget = {
uuid: string,
init: number,
amount: number,
events: Array<APIEvent>,
currency: string
}

12
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"outDir": "./dist/",
"noImplicitAny": true,
"module": "esnext",
"target": "esnext",
"jsx": "react",
"allowJs": true,
"sourceMap": true,
"moduleResolution": "node"
}
}

View file

@ -0,0 +1,32 @@
const path = require('path');
module.exports = {
entry: './src/index.ts',
devtool: 'inline-source-map',
devServer: {
contentBase: __dirname,
compress: true,
port: 8000,
host: '0.0.0.0',
proxy: {
'/budgets': 'http://127.0.0.1:2019'
},
watchContentBase: true
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/
}
]
},
resolve: {
extensions: ['.tsx', '.ts', '.js']
},
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
}
};

3848
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load diff