Initial frontend commit
This commit is contained in:
parent
d51ca3ad7c
commit
ef24b41019
18 changed files with 4390 additions and 0 deletions
2
frontend/.gitignore
vendored
Normal file
2
frontend/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
dist/
|
17
frontend/index.html
Normal file
17
frontend/index.html
Normal 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
26
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
55
frontend/src/components/budget-add.ts
Normal file
55
frontend/src/components/budget-add.ts
Normal 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)
|
77
frontend/src/components/budget-display.ts
Normal file
77
frontend/src/components/budget-display.ts
Normal 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)
|
27
frontend/src/components/budget-list.ts
Normal file
27
frontend/src/components/budget-list.ts
Normal 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)
|
112
frontend/src/components/week-budget.ts
Normal file
112
frontend/src/components/week-budget.ts
Normal 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
6
frontend/src/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import './components/week-budget'
|
||||
|
||||
window.onload = () => {
|
||||
const mainEl = document.getElementById('main-content')
|
||||
mainEl.innerHTML = '<week-budget></week-budget>'
|
||||
}
|
72
frontend/src/services/api.ts
Normal file
72
frontend/src/services/api.ts
Normal 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}`
|
||||
}
|
13
frontend/src/services/models/budget-event.ts
Normal file
13
frontend/src/services/models/budget-event.ts
Normal 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
|
||||
}
|
||||
}
|
30
frontend/src/services/models/budget.ts
Normal file
30
frontend/src/services/models/budget.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
12
frontend/src/services/models/immutable-model.ts
Normal file
12
frontend/src/services/models/immutable-model.ts
Normal 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}.`)
|
||||
}
|
||||
}
|
24
frontend/src/services/readers.ts
Normal file
24
frontend/src/services/readers.ts
Normal 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
|
||||
)
|
||||
}
|
13
frontend/src/services/storage.ts
Normal file
13
frontend/src/services/storage.ts
Normal 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)
|
||||
}
|
12
frontend/src/services/typings.ts
Normal file
12
frontend/src/services/typings.ts
Normal 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
12
frontend/tsconfig.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/",
|
||||
"noImplicitAny": true,
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"jsx": "react",
|
||||
"allowJs": true,
|
||||
"sourceMap": true,
|
||||
"moduleResolution": "node"
|
||||
}
|
||||
}
|
32
frontend/webpack.config.js
Normal file
32
frontend/webpack.config.js
Normal 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
3848
frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
Reference in a new issue