From f2b782e081808e0ecd75803f27e8530b77924d00 Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Thu, 2 Sep 2021 01:28:16 +1000 Subject: [PATCH 01/27] Serve index document for all paths not found. --- server/src/server.ts | 28 +++++++++++++++++++++++++++- web/build.mjs | 1 + 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/server/src/server.ts b/server/src/server.ts index aded9818..ac269450 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1,9 +1,11 @@ import { ArgumentParser } from 'argparse'; -import express from 'express'; +import express, { RequestHandler } from 'express'; +import { readFile } from 'fs/promises'; import { createConnection } from 'mongoose'; import { resolve } from 'path'; import { env } from 'process'; import { ApiRouter } from './api/api-router'; +import { HttpStatusError } from './api/HttpStatusError'; interface IArgs { dist: string; @@ -12,6 +14,29 @@ interface IArgs { secret: string; } +type ServeIndexRequestHandlerAsync = + (distPath: string, ...args: Parameters) => Promise; + +const serveIndexAsync: ServeIndexRequestHandlerAsync = async (distPath, _req, res) => { + const index = resolve(distPath, 'index.html'); + let file: Buffer; + try { + file = await readFile(index); + } catch { + throw new HttpStatusError(404); + } + res.status(200).contentType('.html').send(file); +}; + +function serveIndex(distPath: string): RequestHandler { + /* eslint-disable-next-line @typescript-eslint/no-shadow */ + const serveIndex: RequestHandler = (...args) => { + serveIndexAsync(distPath, ...args).catch(args[2]); + }; + + return serveIndex; +} + async function main() { const parser = new ArgumentParser(); parser.add_argument('-d', '--dist'); @@ -36,6 +61,7 @@ async function main() { app.use('/api', apiRouter.router); app.use(dist); + app.get('*', serveIndex(distPath)); console.log(`Listening on port ${port}...`); app.listen(port); diff --git a/web/build.mjs b/web/build.mjs index 95ab9b6b..d503a595 100755 --- a/web/build.mjs +++ b/web/build.mjs @@ -49,6 +49,7 @@ webpack({ }, plugins: [ new HtmlWebpackPlugin({ + publicPath: '/', title: args.title, template: src('index.ejs') }) From f4dbbb7d1ee66b7878ac28cdaf71042ebce988d8 Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Thu, 2 Sep 2021 01:42:58 +1000 Subject: [PATCH 02/27] Use MemoryRouter. --- web/src/App.tsx | 19 +++++++++---------- web/src/__tests__/App.tsx | 5 +---- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index ded523f8..e3e40d6a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,9 +1,8 @@ import { mergeStyleSets } from '@fluentui/react'; -import { History } from 'history'; import PropTypes from 'prop-types'; import React, { createContext, useContext } from 'react'; import { - BrowserRouter, Route, Router, RouterProps, Switch, + BrowserRouter, MemoryRouter, Route, Switch, } from 'react-router-dom'; import { Header } from './components/Header'; import { ICard } from './controllers/Card'; @@ -49,17 +48,17 @@ const getClassNames = () => mergeStyleSets({ }); export interface IAppProps { - history?: RouterProps['history']; + useMemoryRouter?: boolean; } -export const App: React.VoidFunctionComponent = ({ history }) => { +export const App: React.VoidFunctionComponent = ({ useMemoryRouter }) => { const context: IAppContext = { searchQuery: '', user: null, update() { }, showCardDetail() { }, - login() {}, - logout() {}, + login() { }, + logout() { }, }; const { contentRoot } = getClassNames(); @@ -81,8 +80,8 @@ export const App: React.VoidFunctionComponent = ({ history }) => {
- {history != null - ? {content} + {useMemoryRouter + ? {content} : {content}}
@@ -90,9 +89,9 @@ export const App: React.VoidFunctionComponent = ({ history }) => { }; App.propTypes = { - history: PropTypes.object as React.Validator | undefined, + useMemoryRouter: PropTypes.bool, }; App.defaultProps = { - history: undefined, + useMemoryRouter: false, }; diff --git a/web/src/__tests__/App.tsx b/web/src/__tests__/App.tsx index 4d5944c4..10751935 100644 --- a/web/src/__tests__/App.tsx +++ b/web/src/__tests__/App.tsx @@ -1,4 +1,3 @@ -import { createMemoryHistory } from 'history'; import React from 'react'; import { create } from 'react-test-renderer'; import { App } from '../App'; @@ -6,9 +5,7 @@ import './disable-icon-warnings.helpers'; describe('App tests', () => { test('Simple render', () => { - const history = createMemoryHistory(); - - const tree = create().toJSON(); + const tree = create().toJSON(); expect(tree).toMatchInlineSnapshot(`
Date: Thu, 2 Sep 2021 04:28:25 +1000 Subject: [PATCH 03/27] Implement login and logout controller methods. --- package-lock.json | 34 +++++++++++++++++ web/package.json | 1 + web/src/App.tsx | 94 +++++++++++++++++++++++++++++++---------------- 3 files changed, 98 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index 509a2e1f..6b205c00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9627,6 +9627,7 @@ "@types/react-router-dom": "^5.1.8", "@types/react-test-renderer": "^17.0.1", "argparse": "^2.0.1", + "buffer": "^6.0.3", "history": "^5.0.1", "html-webpack-plugin": "^5.3.2", "prop-types": "^15.7.2", @@ -9659,6 +9660,29 @@ "resolved": "web/shared", "link": true }, + "web/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "web/shared": {} }, "dependencies": { @@ -11069,6 +11093,7 @@ "@typescript-eslint/eslint-plugin": "^4.29.1", "@typescript-eslint/parser": "^4.29.1", "argparse": "^2.0.1", + "buffer": "^6.0.3", "eslint": "^7.32.0", "eslint-config-airbnb-typescript": "^12.3.1", "eslint-plugin-import": "^2.24.0", @@ -11091,6 +11116,15 @@ "dependencies": { "@porkbellypro/crm-shared": { "version": "file:web/shared" + }, + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } } } }, diff --git a/web/package.json b/web/package.json index b11de142..93188f63 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "@types/react-router-dom": "^5.1.8", "@types/react-test-renderer": "^17.0.1", "argparse": "^2.0.1", + "buffer": "^6.0.3", "history": "^5.0.1", "html-webpack-plugin": "^5.3.2", "prop-types": "^15.7.2", diff --git a/web/src/App.tsx b/web/src/App.tsx index ee3fbf4d..4a691d12 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,8 +1,9 @@ import { mergeStyleSets } from '@fluentui/react'; +import { Buffer } from 'buffer'; import PropTypes from 'prop-types'; -import React, { createContext, useContext } from 'react'; +import React, { createContext, useContext, useState } from 'react'; import { - BrowserRouter, MemoryRouter, Route, Switch, + BrowserRouter, MemoryRouter, Route, Switch, useHistory, } from 'react-router-dom'; import { Header } from './components/Header'; import { ICard } from './controllers/Card'; @@ -52,55 +53,86 @@ export interface IAppProps { useMemoryRouter?: boolean; } -export const App: React.VoidFunctionComponent = ({ useMemoryRouter }) => { +const AppComponent: React.VoidFunctionComponent = () => { + const [user, setUser] = useState(null); + const history = useHistory(); + const context: IAppContext = { searchQuery: '', - user: null, + user, update() { }, showCardDetail() { }, - login() { - return Promise.resolve(new ResponseStatus({ - ok: true, - status: 200, - statusText: 'OK', - })); + login(username, password, register) { + return (async function loginAsync() { + const body = { + username, + password: Buffer.from( + await crypto.subtle.digest( + 'SHA-256', + Buffer.from(password), + ), + ).toString('hex'), + }; + const endpoint = register ? '/api/register' : '/api/login'; + + const res = await fetch(endpoint, { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + }); + if (res.ok) { + if (register) history.push('/login'); + else setUser({} as unknown as IUser); + } + + return new ResponseStatus(res); + }()); }, logout() { - return Promise.resolve(new ResponseStatus({ - ok: true, - status: 200, - statusText: 'OK', - })); + return (async function logoutAsync() { + const res = await fetch('/api/logout', { method: 'POST' }); + if (res.ok) setUser(null); + + return new ResponseStatus(res); + }()); }, }; const { contentRoot } = getClassNames(); - const content = ( - - - - - - - - - - - - ); return (
- {useMemoryRouter - ? {content} - : {content}} + + + + + + + + + + +
); }; +export const App: React.VoidFunctionComponent = ({ useMemoryRouter }) => { + type RouterType = typeof MemoryRouter & typeof BrowserRouter; + const Router: RouterType = useMemoryRouter ? MemoryRouter : BrowserRouter; + + return ( + + + + ); +}; + App.propTypes = { useMemoryRouter: PropTypes.bool, }; From 79869a44bbc6445587b7f2d4ad275e5f83e5ed25 Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Thu, 2 Sep 2021 18:29:53 +1000 Subject: [PATCH 04/27] Allocate space for top-level components. --- web/src/App.tsx | 32 ++++++++++++++++++++++++-------- web/src/__tests__/App.tsx | 11 ++++++++--- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 4a691d12..85a2501f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -43,11 +43,25 @@ export function useApp(): IAppContext { return context; } -const getClassNames = () => mergeStyleSets({ - contentRoot: { - height: '100%', - }, -}); +const getClassNames = () => { + const headerHeight = '60px'; + + return mergeStyleSets({ + header: { + height: headerHeight, + left: '0', + position: 'fixed', + right: '0', + }, + body: { + bottom: '0', + position: 'fixed', + left: '0', + right: '0', + top: headerHeight, + }, + }); +}; export interface IAppProps { useMemoryRouter?: boolean; @@ -100,12 +114,14 @@ const AppComponent: React.VoidFunctionComponent = () => { }, }; - const { contentRoot } = getClassNames(); + const { header, body } = getClassNames(); return ( -
-
+
+
+
+
diff --git a/web/src/__tests__/App.tsx b/web/src/__tests__/App.tsx index 10751935..d2d91ba9 100644 --- a/web/src/__tests__/App.tsx +++ b/web/src/__tests__/App.tsx @@ -7,9 +7,14 @@ describe('App tests', () => { test('Simple render', () => { const tree = create().toJSON(); expect(tree).toMatchInlineSnapshot(` -
+Array [ +
, +
, +] `); }); }); From 3de0fdb90c4800a8e8cce8f3c07b0727d2c7a803 Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Thu, 2 Sep 2021 18:34:26 +1000 Subject: [PATCH 05/27] Remove App unit tests. --- web/src/__tests__/App.tsx | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 web/src/__tests__/App.tsx diff --git a/web/src/__tests__/App.tsx b/web/src/__tests__/App.tsx deleted file mode 100644 index d2d91ba9..00000000 --- a/web/src/__tests__/App.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import { create } from 'react-test-renderer'; -import { App } from '../App'; -import './disable-icon-warnings.helpers'; - -describe('App tests', () => { - test('Simple render', () => { - const tree = create().toJSON(); - expect(tree).toMatchInlineSnapshot(` -Array [ -
, -
, -] -`); - }); -}); From 6aff16a9b2a6fe7ffee044cab0d14168a313e5c5 Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Thu, 2 Sep 2021 18:42:26 +1000 Subject: [PATCH 06/27] Add App redirects. --- web/src/App.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 85a2501f..abe1873c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,7 +3,7 @@ import { Buffer } from 'buffer'; import PropTypes from 'prop-types'; import React, { createContext, useContext, useState } from 'react'; import { - BrowserRouter, MemoryRouter, Route, Switch, useHistory, + BrowserRouter, MemoryRouter, Redirect, Route, Switch, useHistory, } from 'react-router-dom'; import { Header } from './components/Header'; import { ICard } from './controllers/Card'; @@ -116,6 +116,8 @@ const AppComponent: React.VoidFunctionComponent = () => { const { header, body } = getClassNames(); + const loggedIn = user != null; + return (
@@ -124,14 +126,20 @@ const AppComponent: React.VoidFunctionComponent = () => {
+ {!loggedIn && } + {loggedIn && } + {loggedIn && } + + +
From 03dc2ae5a305c0d994a2f98ef6c00eef34f82a10 Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Thu, 2 Sep 2021 20:15:15 +1000 Subject: [PATCH 07/27] Add type checking helpers. --- shared/src/ensure.ts | 33 +++++++++++++++++++++++++++++++++ shared/src/index.ts | 1 + 2 files changed, 34 insertions(+) create mode 100644 shared/src/ensure.ts diff --git a/shared/src/ensure.ts b/shared/src/ensure.ts new file mode 100644 index 00000000..0fcea5d5 --- /dev/null +++ b/shared/src/ensure.ts @@ -0,0 +1,33 @@ +import type { TypeOfTag } from 'typescript'; + +export type AnyUnknown = { [k: string]: unknown }; + +export function ensureType(value: unknown, type: 'undefined'): undefined; +export function ensureType(value: unknown, type: 'number'): number; +export function ensureType(value: unknown, type: 'bigint'): bigint; +export function ensureType(value: unknown, type: 'boolean'): boolean; +export function ensureType(value: unknown, type: 'string'): string; +export function ensureType(value: unknown, type: 'symbol'): symbol; +export function ensureType(value: unknown, type: 'object'): AnyUnknown | null; +export function ensureType(value: unknown, type: 'function'): CallableFunction; + +export function ensureType(value: unknown, type: TypeOfTag): unknown { + if (typeof value !== type) throw new Error(`typeof value is not ${type}`); + + return value; +} + +export function ensureObject(value: unknown): AnyUnknown { + if (value == null) throw new Error('value is nullish'); + + const ensured = ensureType(value, 'object'); + if (ensured == null) throw new Error('ensured is null'); + + return ensured; +} + +export function ensureArray(value: unknown): unknown[] { + if (!(value instanceof Array)) throw new Error(`value is not instanceof ${Array.name}`); + + return value; +} diff --git a/shared/src/index.ts b/shared/src/index.ts index 5a4793a9..2e987267 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1 +1,2 @@ export * from './api-types'; +export * from './ensure'; From a41ce3754c2a981f612f115d32fe0846decbd3d7 Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Thu, 2 Sep 2021 21:15:23 +1000 Subject: [PATCH 08/27] Decode /api/me response. --- web/src/App.tsx | 76 ++++++++++++++++++++++++++++++-- web/src/controllers/Card.ts | 71 ++++++++++++++++++++++++++++- web/src/controllers/CardField.ts | 26 +++++++++++ 3 files changed, 167 insertions(+), 6 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index abe1873c..9cfc0d4c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,5 @@ import { mergeStyleSets } from '@fluentui/react'; +import { ensureArray, ensureObject, ensureType } from '@porkbellypro/crm-shared'; import { Buffer } from 'buffer'; import PropTypes from 'prop-types'; import React, { createContext, useContext, useState } from 'react'; @@ -6,7 +7,7 @@ import { BrowserRouter, MemoryRouter, Redirect, Route, Switch, useHistory, } from 'react-router-dom'; import { Header } from './components/Header'; -import { ICard } from './controllers/Card'; +import { impl as Card, ICard } from './controllers/Card'; import { ResponseStatus } from './ResponseStatus'; import { Home } from './views/Home'; import { Login } from './views/Login'; @@ -67,10 +68,75 @@ export interface IAppProps { useMemoryRouter?: boolean; } +function notAcceptable(): ResponseStatus { + return new ResponseStatus({ + ok: false, + status: 406, + statusText: 'Not Acceptable', + }); +} + +type GetMeResult = { + status: ResponseStatus; + user?: IUser; +}; + +async function getMe(): Promise { + const res = await fetch('/api/me', { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + if (!res.ok) return { status: new ResponseStatus(res) }; + + try { + if (res.headers.get('Content-Type')?.startsWith('application/json;') !== true) { + throw new Error(); + } + + const body = ensureObject(await res.json()); + const username = ensureType(body.username, 'string'); + const settings = ensureObject(body.settings); + const cards = ensureArray(body.cards).map(Card.fromRaw); + + return { + status: new ResponseStatus(res), + user: { + username, + settings, + cards, + }, + }; + } catch { + return { status: notAcceptable() }; + } +} + const AppComponent: React.VoidFunctionComponent = () => { - const [user, setUser] = useState(null); + const [userState, setUser] = useState(); const history = useHistory(); + const updateMe: () => Promise = async () => { + const { status, user } = await getMe(); + const { ok } = status; + + if (ok) { + if (user == null) throw new Error('Expected result.user to be non-null'); + + setUser(user); + } + + return status; + }; + + if (userState === undefined) { + updateMe(); + } + + const user = userState === undefined ? null : userState; + const context: IAppContext = { searchQuery: '', user, @@ -98,7 +164,9 @@ const AppComponent: React.VoidFunctionComponent = () => { }); if (res.ok) { if (register) history.push('/login'); - else setUser({} as unknown as IUser); + else { + return updateMe(); + } } return new ResponseStatus(res); @@ -116,7 +184,7 @@ const AppComponent: React.VoidFunctionComponent = () => { const { header, body } = getClassNames(); - const loggedIn = user != null; + const loggedIn = userState != null; return ( diff --git a/web/src/controllers/Card.ts b/web/src/controllers/Card.ts index e4252fe4..8a2578a8 100644 --- a/web/src/controllers/Card.ts +++ b/web/src/controllers/Card.ts @@ -1,6 +1,8 @@ -import { ObjectId } from '@porkbellypro/crm-shared'; +import { + ObjectId, ensureArray, ensureObject, ensureType, +} from '@porkbellypro/crm-shared'; import { ResponseStatus } from '../ResponseStatus'; -import { ICardField } from './CardField'; +import { impl as CardField, ICardField } from './CardField'; interface ICardPropertiesCommon { favorite: boolean; @@ -27,3 +29,68 @@ export interface ICard extends Readonly { export function newCard(): ICard { throw new Error('Not implemented'); } + +class RawCard implements ICard { + readonly favorite: boolean; + + readonly name: string; + + readonly phone: string; + + readonly email: string; + + readonly jobTitle: string; + + readonly company: string; + + readonly fields: readonly ICardField[]; + + readonly id: ObjectId; + + readonly image?: string; + + constructor(raw: unknown) { + const { + id, + favorite, + name, + phone, + email, + jobTitle, + company, + hasImage, + fields: fieldsRaw, + } = ensureObject(raw); + + this.id = ensureType(id, 'string'); + this.favorite = ensureType(favorite, 'boolean'); + this.name = ensureType(name, 'string'); + this.phone = ensureType(phone, 'string'); + this.email = ensureType(email, 'string'); + this.jobTitle = ensureType(jobTitle, 'string'); + this.company = ensureType(company, 'string'); + if (ensureType(hasImage, 'boolean')) { + this.image = `/image/${id}`; + } + + const fields = ensureArray(fieldsRaw); + this.fields = fields.map(CardField.fromRaw); + } + + /* eslint-disable-next-line class-methods-use-this */ + update() { } + + /* eslint-disable-next-line class-methods-use-this */ + commit() { return Promise.reject(); } + + /* eslint-disable-next-line class-methods-use-this */ + delete() { return Promise.reject(); } +} + +function fromRaw(raw: unknown): ICard { + return new RawCard(raw); +} + +export const impl = { + fromRaw, +}; diff --git a/web/src/controllers/CardField.ts b/web/src/controllers/CardField.ts index f63e4e2b..26faacad 100644 --- a/web/src/controllers/CardField.ts +++ b/web/src/controllers/CardField.ts @@ -1,3 +1,5 @@ +import { ensureObject, ensureType } from '@porkbellypro/crm-shared'; + export interface ICardFieldProperties { key: string; value: string; @@ -6,3 +8,27 @@ export interface ICardFieldProperties { export interface ICardField extends Readonly { update(props: Partial): void; } + +class RawCardField implements ICardField { + readonly key: string; + + readonly value: string; + + constructor(raw: unknown) { + const { key, value } = ensureObject(raw); + + this.key = ensureType(key, 'string'); + this.value = ensureType(value, 'string'); + } + + /* eslint-disable-next-line class-methods-use-this */ + update() { } +} + +function fromRaw(raw: unknown): ICardField { + return new RawCardField(raw); +} + +export const impl = { + fromRaw, +}; From 06377569bb7a511d6b252a5b6f567afaccfb4a2b Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Thu, 2 Sep 2021 22:10:01 +1000 Subject: [PATCH 09/27] Add type checking helper tests. --- shared/src/__tests__/ensure.ts | 85 ++++++++++++++++++++++++++++++++++ shared/src/__tests__/index.ts | 1 - 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 shared/src/__tests__/ensure.ts delete mode 100644 shared/src/__tests__/index.ts diff --git a/shared/src/__tests__/ensure.ts b/shared/src/__tests__/ensure.ts new file mode 100644 index 00000000..fbf80e86 --- /dev/null +++ b/shared/src/__tests__/ensure.ts @@ -0,0 +1,85 @@ +import { ensureArray, ensureObject, ensureType } from '../ensure'; + +const map = { + undefined, + number: Number(), + bigint: BigInt(Number()), + boolean: Boolean(), + string: String(), + symbol: Symbol('symbol'), + object: Object(), + function() { }, +}; + +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +function asTypeTag(tag: string): any { + return tag; +} + +describe('ensure tests', () => { + describe('ensureType tests', () => { + describe('Success tests', () => { + Object + .entries(map) + .forEach(([k, v]) => test(k, () => { + expect(() => ensureType(v, asTypeTag(k))).not.toThrow(); + })); + + test('null', () => { + expect(() => ensureType(null, 'object')).not.toThrow(); + }); + }); + + describe('Fail tests', () => { + Object + .entries(map) + .forEach(([k0, v]) => describe(k0, () => { + Object + .keys(map) + .filter((k1) => k0 !== k1) + .map((k1) => test(k1, () => { + expect(() => ensureType(v, asTypeTag(k1))).toThrow(); + })); + })); + + describe('null', () => { + Object + .keys(map) + .filter((k) => k !== 'object') + .map((k) => test(k, () => { + expect(() => ensureType(null, asTypeTag(k))).toThrow(); + })); + }); + }); + }); + + describe('ensureObject tests', () => { + test('Success test', () => { + expect(() => ensureObject(Object())).not.toThrow(); + }); + + describe('Fail tests', () => { + Object + .entries(map) + .filter(([k]) => k !== 'object') + .map(([k, v]) => test(k, () => { + expect(() => ensureObject(v)).toThrow(); + })); + }); + }); + + describe('ensureArray tests', () => { + test('Success test', () => { + expect(() => ensureArray([])).not.toThrow(); + }); + + describe('Fail tests', () => { + Object + .entries(map) + .concat([['null', null]]) + .map(([k, v]) => test(k, () => { + expect(() => ensureArray(v)).toThrow(); + })); + }); + }); +}); diff --git a/shared/src/__tests__/index.ts b/shared/src/__tests__/index.ts deleted file mode 100644 index 0bb5e88a..00000000 --- a/shared/src/__tests__/index.ts +++ /dev/null @@ -1 +0,0 @@ -test('Dummy test', () => {}); From 38b682d227bcda21d9fa70cf3aa7940e09bba3ca Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Thu, 2 Sep 2021 22:11:09 +1000 Subject: [PATCH 10/27] Add web dummy test. --- web/src/__tests__/dummy.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 web/src/__tests__/dummy.ts diff --git a/web/src/__tests__/dummy.ts b/web/src/__tests__/dummy.ts new file mode 100644 index 00000000..61cbac42 --- /dev/null +++ b/web/src/__tests__/dummy.ts @@ -0,0 +1 @@ +test('Dummy test', () => { }); From 019bc9b691a240658a14abcc5bb20a32266b1953 Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Thu, 2 Sep 2021 22:33:33 +1000 Subject: [PATCH 11/27] Add CardField tests. --- web/src/__tests__/controllers/CardField.ts | 37 ++++++++++++++++++++++ web/src/__tests__/dummy.ts | 1 - 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 web/src/__tests__/controllers/CardField.ts delete mode 100644 web/src/__tests__/dummy.ts diff --git a/web/src/__tests__/controllers/CardField.ts b/web/src/__tests__/controllers/CardField.ts new file mode 100644 index 00000000..2d95d20d --- /dev/null +++ b/web/src/__tests__/controllers/CardField.ts @@ -0,0 +1,37 @@ +import { impl } from '../../controllers/CardField'; + +describe('CardField tests', () => { + describe('fromRaw tests', () => { + test('Success test', () => { + const json = JSON.parse('{"key":"k","value":"v"}'); + const obj = impl.fromRaw(json); + expect(obj.key).toBe('k'); + expect(obj.value).toBe('v'); + }); + + test('Fail test: unexpected key type', () => { + const json = JSON.parse('{"key":0,"value":"v"}'); + expect(() => impl.fromRaw(json)).toThrow(); + }); + + test('Fail test: missing key', () => { + const json = JSON.parse('{"value":"v"}'); + expect(() => impl.fromRaw(json)).toThrow(); + }); + + test('Fail test: unexpected value type', () => { + const json = JSON.parse('{"key":"k","value":0}'); + expect(() => impl.fromRaw(json)).toThrow(); + }); + + test('Fail test: missing value', () => { + const json = JSON.parse('{"key":"k"}'); + expect(() => impl.fromRaw(json)).toThrow(); + }); + + test('Fail test: unexpected raw value', () => { + const json = JSON.parse('[]'); + expect(() => impl.fromRaw(json)).toThrow(); + }); + }); +}); diff --git a/web/src/__tests__/dummy.ts b/web/src/__tests__/dummy.ts deleted file mode 100644 index 61cbac42..00000000 --- a/web/src/__tests__/dummy.ts +++ /dev/null @@ -1 +0,0 @@ -test('Dummy test', () => { }); From 988556264dc7bba6f04d17a65801c65e8b40da62 Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Thu, 2 Sep 2021 22:56:03 +1000 Subject: [PATCH 12/27] Add Card tests. --- web/src/__tests__/controllers/Card.ts | 71 +++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 web/src/__tests__/controllers/Card.ts diff --git a/web/src/__tests__/controllers/Card.ts b/web/src/__tests__/controllers/Card.ts new file mode 100644 index 00000000..09147654 --- /dev/null +++ b/web/src/__tests__/controllers/Card.ts @@ -0,0 +1,71 @@ +import { Card } from '@porkbellypro/crm-shared'; +import { impl } from '../../controllers/Card'; + +type DeepReadonly = T extends { [k in keyof T]: T[k] } + ? { readonly [k in keyof T]: DeepReadonly; } + : Readonly; + +describe('Card tests', () => { + const template: DeepReadonly = { + id: '000000000000000000000000', + favorite: false, + name: 'name', + phone: 'phone', + email: 'email', + jobTitle: 'jobTitle', + company: 'company', + hasImage: false, + fields: [ + { + key: 'Key 1', + value: 'Value 1', + }, + { + key: 'Key 2', + value: 'Value 2', + }, + ], + tags: [], + }; + + describe('fromRaw tests', () => { + test('Success test', () => { + const obj = impl.fromRaw(template); + expect(obj).toMatchInlineSnapshot(` +RawCard { + "company": "company", + "email": "email", + "favorite": false, + "fields": Array [ + RawCardField { + "key": "Key 1", + "value": "Value 1", + }, + RawCardField { + "key": "Key 2", + "value": "Value 2", + }, + ], + "id": "000000000000000000000000", + "jobTitle": "jobTitle", + "name": "name", + "phone": "phone", +} +`); + }); + + describe('Fail tests', () => { + Object + .keys(template) + // TODO: tags not implemented yet + .filter((k) => k !== 'tags') + .map((k0) => test(k0, () => { + const fn = () => impl.fromRaw(Object.fromEntries(Object + .entries(template) + .filter(([k1]) => k1 !== k0))); + + expect(fn).toThrow(); + })); + }); + }); +}); From 7bf9539c9ba6d3daf02466984ac7e905f79cdc57 Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Fri, 3 Sep 2021 16:28:40 +1000 Subject: [PATCH 13/27] Lift controller classes. --- web/src/App.tsx | 4 +- web/src/__tests__/controllers/Card.ts | 6 +- web/src/__tests__/controllers/CardField.ts | 14 ++--- web/src/controllers/Card.ts | 70 ++-------------------- web/src/controllers/CardField.ts | 24 +------- web/src/controllers/RawCard.ts | 63 +++++++++++++++++++ web/src/controllers/RawCardField.ts | 18 ++++++ 7 files changed, 99 insertions(+), 100 deletions(-) create mode 100644 web/src/controllers/RawCard.ts create mode 100644 web/src/controllers/RawCardField.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 9cfc0d4c..53e5f025 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,7 +7,7 @@ import { BrowserRouter, MemoryRouter, Redirect, Route, Switch, useHistory, } from 'react-router-dom'; import { Header } from './components/Header'; -import { impl as Card, ICard } from './controllers/Card'; +import { ICard, fromRaw } from './controllers/Card'; import { ResponseStatus } from './ResponseStatus'; import { Home } from './views/Home'; import { Login } from './views/Login'; @@ -99,7 +99,7 @@ async function getMe(): Promise { const body = ensureObject(await res.json()); const username = ensureType(body.username, 'string'); const settings = ensureObject(body.settings); - const cards = ensureArray(body.cards).map(Card.fromRaw); + const cards = ensureArray(body.cards).map(fromRaw); return { status: new ResponseStatus(res), diff --git a/web/src/__tests__/controllers/Card.ts b/web/src/__tests__/controllers/Card.ts index 09147654..94f13ab4 100644 --- a/web/src/__tests__/controllers/Card.ts +++ b/web/src/__tests__/controllers/Card.ts @@ -1,5 +1,5 @@ import { Card } from '@porkbellypro/crm-shared'; -import { impl } from '../../controllers/Card'; +import { fromRaw } from '../../controllers/Card'; type DeepReadonly = T extends { [k in keyof T]: T[k] } ? { readonly [k in keyof T]: DeepReadonly; } @@ -30,7 +30,7 @@ describe('Card tests', () => { describe('fromRaw tests', () => { test('Success test', () => { - const obj = impl.fromRaw(template); + const obj = fromRaw(template); expect(obj).toMatchInlineSnapshot(` RawCard { "company": "company", @@ -60,7 +60,7 @@ RawCard { // TODO: tags not implemented yet .filter((k) => k !== 'tags') .map((k0) => test(k0, () => { - const fn = () => impl.fromRaw(Object.fromEntries(Object + const fn = () => fromRaw(Object.fromEntries(Object .entries(template) .filter(([k1]) => k1 !== k0))); diff --git a/web/src/__tests__/controllers/CardField.ts b/web/src/__tests__/controllers/CardField.ts index 2d95d20d..b1edb8fd 100644 --- a/web/src/__tests__/controllers/CardField.ts +++ b/web/src/__tests__/controllers/CardField.ts @@ -1,37 +1,37 @@ -import { impl } from '../../controllers/CardField'; +import { fromRaw } from '../../controllers/CardField'; describe('CardField tests', () => { describe('fromRaw tests', () => { test('Success test', () => { const json = JSON.parse('{"key":"k","value":"v"}'); - const obj = impl.fromRaw(json); + const obj = fromRaw(json); expect(obj.key).toBe('k'); expect(obj.value).toBe('v'); }); test('Fail test: unexpected key type', () => { const json = JSON.parse('{"key":0,"value":"v"}'); - expect(() => impl.fromRaw(json)).toThrow(); + expect(() => fromRaw(json)).toThrow(); }); test('Fail test: missing key', () => { const json = JSON.parse('{"value":"v"}'); - expect(() => impl.fromRaw(json)).toThrow(); + expect(() => fromRaw(json)).toThrow(); }); test('Fail test: unexpected value type', () => { const json = JSON.parse('{"key":"k","value":0}'); - expect(() => impl.fromRaw(json)).toThrow(); + expect(() => fromRaw(json)).toThrow(); }); test('Fail test: missing value', () => { const json = JSON.parse('{"key":"k"}'); - expect(() => impl.fromRaw(json)).toThrow(); + expect(() => fromRaw(json)).toThrow(); }); test('Fail test: unexpected raw value', () => { const json = JSON.parse('[]'); - expect(() => impl.fromRaw(json)).toThrow(); + expect(() => fromRaw(json)).toThrow(); }); }); }); diff --git a/web/src/controllers/Card.ts b/web/src/controllers/Card.ts index 8a2578a8..ab82d9d3 100644 --- a/web/src/controllers/Card.ts +++ b/web/src/controllers/Card.ts @@ -1,8 +1,7 @@ -import { - ObjectId, ensureArray, ensureObject, ensureType, -} from '@porkbellypro/crm-shared'; +import { ObjectId } from '@porkbellypro/crm-shared'; import { ResponseStatus } from '../ResponseStatus'; -import { impl as CardField, ICardField } from './CardField'; +import { ICardField } from './CardField'; +import { RawCard } from './RawCard'; interface ICardPropertiesCommon { favorite: boolean; @@ -30,67 +29,6 @@ export function newCard(): ICard { throw new Error('Not implemented'); } -class RawCard implements ICard { - readonly favorite: boolean; - - readonly name: string; - - readonly phone: string; - - readonly email: string; - - readonly jobTitle: string; - - readonly company: string; - - readonly fields: readonly ICardField[]; - - readonly id: ObjectId; - - readonly image?: string; - - constructor(raw: unknown) { - const { - id, - favorite, - name, - phone, - email, - jobTitle, - company, - hasImage, - fields: fieldsRaw, - } = ensureObject(raw); - - this.id = ensureType(id, 'string'); - this.favorite = ensureType(favorite, 'boolean'); - this.name = ensureType(name, 'string'); - this.phone = ensureType(phone, 'string'); - this.email = ensureType(email, 'string'); - this.jobTitle = ensureType(jobTitle, 'string'); - this.company = ensureType(company, 'string'); - if (ensureType(hasImage, 'boolean')) { - this.image = `/image/${id}`; - } - - const fields = ensureArray(fieldsRaw); - this.fields = fields.map(CardField.fromRaw); - } - - /* eslint-disable-next-line class-methods-use-this */ - update() { } - - /* eslint-disable-next-line class-methods-use-this */ - commit() { return Promise.reject(); } - - /* eslint-disable-next-line class-methods-use-this */ - delete() { return Promise.reject(); } -} - -function fromRaw(raw: unknown): ICard { +export function fromRaw(raw: unknown): ICard { return new RawCard(raw); } - -export const impl = { - fromRaw, -}; diff --git a/web/src/controllers/CardField.ts b/web/src/controllers/CardField.ts index 26faacad..d9fd83bd 100644 --- a/web/src/controllers/CardField.ts +++ b/web/src/controllers/CardField.ts @@ -1,4 +1,4 @@ -import { ensureObject, ensureType } from '@porkbellypro/crm-shared'; +import { RawCardField } from './RawCardField'; export interface ICardFieldProperties { key: string; @@ -9,26 +9,6 @@ export interface ICardField extends Readonly { update(props: Partial): void; } -class RawCardField implements ICardField { - readonly key: string; - - readonly value: string; - - constructor(raw: unknown) { - const { key, value } = ensureObject(raw); - - this.key = ensureType(key, 'string'); - this.value = ensureType(value, 'string'); - } - - /* eslint-disable-next-line class-methods-use-this */ - update() { } -} - -function fromRaw(raw: unknown): ICardField { +export function fromRaw(raw: unknown): ICardField { return new RawCardField(raw); } - -export const impl = { - fromRaw, -}; diff --git a/web/src/controllers/RawCard.ts b/web/src/controllers/RawCard.ts new file mode 100644 index 00000000..c9fe61bc --- /dev/null +++ b/web/src/controllers/RawCard.ts @@ -0,0 +1,63 @@ +import { + ObjectId, ensureArray, ensureObject, ensureType, +} from '@porkbellypro/crm-shared'; +import { ResponseStatus } from '../ResponseStatus'; +import { ICardField, fromRaw } from './CardField'; +import type { ICard } from './Card'; + +export class RawCard implements ICard { + readonly favorite: boolean; + + readonly name: string; + + readonly phone: string; + + readonly email: string; + + readonly jobTitle: string; + + readonly company: string; + + readonly fields: readonly ICardField[]; + + readonly id: ObjectId; + + readonly image?: string; + + constructor(raw: unknown) { + const { + id, + favorite, + name, + phone, + email, + jobTitle, + company, + hasImage, + fields: fieldsRaw, + } = ensureObject(raw); + + this.id = ensureType(id, 'string'); + this.favorite = ensureType(favorite, 'boolean'); + this.name = ensureType(name, 'string'); + this.phone = ensureType(phone, 'string'); + this.email = ensureType(email, 'string'); + this.jobTitle = ensureType(jobTitle, 'string'); + this.company = ensureType(company, 'string'); + if (ensureType(hasImage, 'boolean')) { + this.image = `/image/${id}`; + } + + const fields = ensureArray(fieldsRaw); + this.fields = fields.map(fromRaw); + } + + /* eslint-disable-next-line class-methods-use-this */ + update(): void { } + + /* eslint-disable-next-line class-methods-use-this */ + commit(): Promise { return Promise.reject(); } + + /* eslint-disable-next-line class-methods-use-this */ + delete(): Promise { return Promise.reject(); } +} diff --git a/web/src/controllers/RawCardField.ts b/web/src/controllers/RawCardField.ts new file mode 100644 index 00000000..66c69013 --- /dev/null +++ b/web/src/controllers/RawCardField.ts @@ -0,0 +1,18 @@ +import { ensureObject, ensureType } from '@porkbellypro/crm-shared'; +import type { ICardField } from './CardField'; + +export class RawCardField implements ICardField { + readonly key: string; + + readonly value: string; + + constructor(raw: unknown) { + const { key, value } = ensureObject(raw); + + this.key = ensureType(key, 'string'); + this.value = ensureType(value, 'string'); + } + + /* eslint-disable-next-line class-methods-use-this */ + update(): void { } +} From 07f663e1f9c92d98723eb525b2a5f06d05a9e665 Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Fri, 3 Sep 2021 20:27:22 +1000 Subject: [PATCH 14/27] Remove RawCard and RawCardField classes. --- web/src/App.tsx | 32 ++++++++++++-- web/src/__tests__/controllers/Card.ts | 37 ++++++++++++++-- web/src/controllers/Card.ts | 64 ++++++++++++++++++++++++--- web/src/controllers/CardField.ts | 25 +++++++++-- web/src/controllers/RawCard.ts | 63 -------------------------- web/src/controllers/RawCardField.ts | 18 -------- 6 files changed, 142 insertions(+), 97 deletions(-) delete mode 100644 web/src/controllers/RawCard.ts delete mode 100644 web/src/controllers/RawCardField.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 53e5f025..62168b13 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,7 +7,10 @@ import { BrowserRouter, MemoryRouter, Redirect, Route, Switch, useHistory, } from 'react-router-dom'; import { Header } from './components/Header'; -import { ICard, fromRaw } from './controllers/Card'; +import { + CardMethods, ICard, ICardData, fromRaw, implement, +} from './controllers/Card'; +import { CardFieldMethods } from './controllers/CardField'; import { ResponseStatus } from './ResponseStatus'; import { Home } from './views/Home'; import { Login } from './views/Login'; @@ -76,9 +79,15 @@ function notAcceptable(): ResponseStatus { }); } +interface IUserStatic { + readonly username: string; + readonly settings: ISettings; + readonly cards: readonly ICardData[]; +} + type GetMeResult = { status: ResponseStatus; - user?: IUser; + user?: IUserStatic; }; async function getMe(): Promise { @@ -115,7 +124,7 @@ async function getMe(): Promise { } const AppComponent: React.VoidFunctionComponent = () => { - const [userState, setUser] = useState(); + const [userState, setUser] = useState(); const history = useHistory(); const updateMe: () => Promise = async () => { @@ -135,7 +144,22 @@ const AppComponent: React.VoidFunctionComponent = () => { updateMe(); } - const user = userState === undefined ? null : userState; + const user = userState == null + ? null + : { + ...userState, + cards: userState?.cards.map((card) => { + const cardMethods: CardMethods = { + update() { }, + commit() { return Promise.reject(); }, + delete() { return Promise.reject(); }, + }; + const fieldMethods: CardFieldMethods = { + update() { }, + }; + return implement(card, cardMethods, fieldMethods); + }), + }; const context: IAppContext = { searchQuery: '', diff --git a/web/src/__tests__/controllers/Card.ts b/web/src/__tests__/controllers/Card.ts index 94f13ab4..b15eeed6 100644 --- a/web/src/__tests__/controllers/Card.ts +++ b/web/src/__tests__/controllers/Card.ts @@ -29,19 +29,19 @@ describe('Card tests', () => { }; describe('fromRaw tests', () => { - test('Success test', () => { + test('Success test: without image', () => { const obj = fromRaw(template); expect(obj).toMatchInlineSnapshot(` -RawCard { +Object { "company": "company", "email": "email", "favorite": false, "fields": Array [ - RawCardField { + Object { "key": "Key 1", "value": "Value 1", }, - RawCardField { + Object { "key": "Key 2", "value": "Value 2", }, @@ -54,6 +54,35 @@ RawCard { `); }); + test('Success test: with image', () => { + const obj = fromRaw({ + ...template, + hasImage: true, + }); + expect(obj).toMatchInlineSnapshot(` +Object { + "company": "company", + "email": "email", + "favorite": false, + "fields": Array [ + Object { + "key": "Key 1", + "value": "Value 1", + }, + Object { + "key": "Key 2", + "value": "Value 2", + }, + ], + "id": "000000000000000000000000", + "image": "/image/000000000000000000000000", + "jobTitle": "jobTitle", + "name": "name", + "phone": "phone", +} +`); + }); + describe('Fail tests', () => { Object .keys(template) diff --git a/web/src/controllers/Card.ts b/web/src/controllers/Card.ts index c4757488..5498d2e0 100644 --- a/web/src/controllers/Card.ts +++ b/web/src/controllers/Card.ts @@ -1,7 +1,15 @@ -import { ObjectId } from '@porkbellypro/crm-shared'; +import { + ObjectId, ensureArray, ensureObject, ensureType, +} from '@porkbellypro/crm-shared'; import { ResponseStatus } from '../ResponseStatus'; -import { ICardField, ICardFieldProperties } from './CardField'; -import { RawCard } from './RawCard'; +import { + CardFieldMethods, + ICardField, + ICardFieldData, + ICardFieldProperties, + fromRaw as fieldFromRaw, + implement as implementField, +} from './CardField'; interface ICardPropertiesCommon { favorite: boolean; @@ -30,6 +38,52 @@ export function newCard(): ICard { throw new Error('Not implemented'); } -export function fromRaw(raw: unknown): ICard { - return new RawCard(raw); +export interface ICardData extends Readonly { + readonly id?: ObjectId; + readonly image?: string; + readonly fields: readonly ICardFieldData[]; +} + +export function fromRaw(raw: unknown): ICardData { + const { + id, + favorite, + name, + phone, + email, + jobTitle, + company, + hasImage, + fields: fieldsRaw, + } = ensureObject(raw); + + const result = { + id: ensureType(id, 'string'), + favorite: ensureType(favorite, 'boolean'), + name: ensureType(name, 'string'), + phone: ensureType(phone, 'string'), + email: ensureType(email, 'string'), + jobTitle: ensureType(jobTitle, 'string'), + company: ensureType(company, 'string'), + image: ensureType(hasImage, 'boolean') ? `/image/${id}` : undefined, + fields: ensureArray(fieldsRaw).map(fieldFromRaw), + }; + + if (result.image === undefined) delete result.image; + + return result; +} + +export type CardMethods = Pick; + +export function implement( + data: ICardData, + methods: CardMethods, + fieldMethods: CardFieldMethods, +): ICard { + return { + ...data, + fields: data.fields.map((field) => implementField(field, fieldMethods)), + ...methods, + }; } diff --git a/web/src/controllers/CardField.ts b/web/src/controllers/CardField.ts index d9fd83bd..7c3182ab 100644 --- a/web/src/controllers/CardField.ts +++ b/web/src/controllers/CardField.ts @@ -1,4 +1,4 @@ -import { RawCardField } from './RawCardField'; +import { ensureObject, ensureType } from '@porkbellypro/crm-shared'; export interface ICardFieldProperties { key: string; @@ -9,6 +9,25 @@ export interface ICardField extends Readonly { update(props: Partial): void; } -export function fromRaw(raw: unknown): ICardField { - return new RawCardField(raw); +export type ICardFieldData = Readonly; + +export function fromRaw(raw: unknown): ICardFieldData { + const { key, value } = ensureObject(raw); + + return { + key: ensureType(key, 'string'), + value: ensureType(value, 'string'), + }; +} + +export type CardFieldMethods = Pick; + +export function implement( + data: ICardFieldData, + methods: CardFieldMethods, +): ICardField { + return { + ...data, + ...methods, + }; } diff --git a/web/src/controllers/RawCard.ts b/web/src/controllers/RawCard.ts deleted file mode 100644 index c9fe61bc..00000000 --- a/web/src/controllers/RawCard.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - ObjectId, ensureArray, ensureObject, ensureType, -} from '@porkbellypro/crm-shared'; -import { ResponseStatus } from '../ResponseStatus'; -import { ICardField, fromRaw } from './CardField'; -import type { ICard } from './Card'; - -export class RawCard implements ICard { - readonly favorite: boolean; - - readonly name: string; - - readonly phone: string; - - readonly email: string; - - readonly jobTitle: string; - - readonly company: string; - - readonly fields: readonly ICardField[]; - - readonly id: ObjectId; - - readonly image?: string; - - constructor(raw: unknown) { - const { - id, - favorite, - name, - phone, - email, - jobTitle, - company, - hasImage, - fields: fieldsRaw, - } = ensureObject(raw); - - this.id = ensureType(id, 'string'); - this.favorite = ensureType(favorite, 'boolean'); - this.name = ensureType(name, 'string'); - this.phone = ensureType(phone, 'string'); - this.email = ensureType(email, 'string'); - this.jobTitle = ensureType(jobTitle, 'string'); - this.company = ensureType(company, 'string'); - if (ensureType(hasImage, 'boolean')) { - this.image = `/image/${id}`; - } - - const fields = ensureArray(fieldsRaw); - this.fields = fields.map(fromRaw); - } - - /* eslint-disable-next-line class-methods-use-this */ - update(): void { } - - /* eslint-disable-next-line class-methods-use-this */ - commit(): Promise { return Promise.reject(); } - - /* eslint-disable-next-line class-methods-use-this */ - delete(): Promise { return Promise.reject(); } -} diff --git a/web/src/controllers/RawCardField.ts b/web/src/controllers/RawCardField.ts deleted file mode 100644 index 66c69013..00000000 --- a/web/src/controllers/RawCardField.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ensureObject, ensureType } from '@porkbellypro/crm-shared'; -import type { ICardField } from './CardField'; - -export class RawCardField implements ICardField { - readonly key: string; - - readonly value: string; - - constructor(raw: unknown) { - const { key, value } = ensureObject(raw); - - this.key = ensureType(key, 'string'); - this.value = ensureType(value, 'string'); - } - - /* eslint-disable-next-line class-methods-use-this */ - update(): void { } -} From ff886484bf395981acca02960f2fa514ea4d18a6 Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Sat, 4 Sep 2021 00:16:42 +1000 Subject: [PATCH 15/27] Lift App context. --- web/src/App.tsx | 37 ++++--------------------------------- web/src/AppContext.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 33 deletions(-) create mode 100644 web/src/AppContext.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 62168b13..30fc816d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,10 +2,13 @@ import { mergeStyleSets } from '@fluentui/react'; import { ensureArray, ensureObject, ensureType } from '@porkbellypro/crm-shared'; import { Buffer } from 'buffer'; import PropTypes from 'prop-types'; -import React, { createContext, useContext, useState } from 'react'; +import React, { useState } from 'react'; import { BrowserRouter, MemoryRouter, Redirect, Route, Switch, useHistory, } from 'react-router-dom'; +import { + AppProvider, IAppContext, ISettings, IUser, +} from './AppContext'; import { Header } from './components/Header'; import { CardMethods, ICard, ICardData, fromRaw, implement, @@ -15,38 +18,6 @@ import { ResponseStatus } from './ResponseStatus'; import { Home } from './views/Home'; import { Login } from './views/Login'; -/* eslint-disable-next-line @typescript-eslint/no-empty-interface */ -export interface ISettings { } - -export interface IUser { - readonly username: string; - readonly settings: ISettings; - readonly cards: readonly ICard[]; -} - -export interface IAppContextProperties { - searchQuery: string; -} - -export interface IAppContext extends Readonly { - readonly user: IUser | null; - update(props: Partial): void; - showCardDetail(card: ICard | null): void; - login(username: string, password: string, register?: boolean): Promise; - logout(): Promise; -} - -const appContext = createContext(undefined); - -export const AppProvider = appContext.Provider; - -export function useApp(): IAppContext { - const context = useContext(appContext); - if (context == null) throw new Error('context is null'); - - return context; -} - const getClassNames = () => { const headerHeight = '60px'; diff --git a/web/src/AppContext.ts b/web/src/AppContext.ts new file mode 100644 index 00000000..0034b469 --- /dev/null +++ b/web/src/AppContext.ts @@ -0,0 +1,35 @@ +import { createContext, useContext } from 'react'; +import { ICard } from './controllers/Card'; +import { ResponseStatus } from './ResponseStatus'; + +/* eslint-disable-next-line @typescript-eslint/no-empty-interface */ +export interface ISettings { } + +export interface IUser { + readonly username: string; + readonly settings: ISettings; + readonly cards: readonly ICard[]; +} + +export interface IAppContextProperties { + searchQuery: string; +} + +export interface IAppContext extends Readonly { + readonly user: IUser | null; + update(props: Partial): void; + showCardDetail(card: ICard | null): void; + login(username: string, password: string, register?: boolean): Promise; + logout(): Promise; +} + +const appContext = createContext(undefined); + +export const AppProvider = appContext.Provider; + +export function useApp(): IAppContext { + const context = useContext(appContext); + if (context == null) throw new Error('context is null'); + + return context; +} From b3c3d7d7780df69bc544ef181194175aae09b5cd Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Sat, 4 Sep 2021 00:19:07 +1000 Subject: [PATCH 16/27] Implement card update. --- web/src/App.tsx | 62 +++++++++++++++++++++++++++++--- web/src/controllers/Card.ts | 10 +++--- web/src/controllers/CardField.ts | 4 +-- 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 30fc816d..7a5c5590 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,7 +11,7 @@ import { } from './AppContext'; import { Header } from './components/Header'; import { - CardMethods, ICard, ICardData, fromRaw, implement, + CardMethods, ICardData, ICardProperties, fromRaw, implement, } from './controllers/Card'; import { CardFieldMethods } from './controllers/CardField'; import { ResponseStatus } from './ResponseStatus'; @@ -50,10 +50,16 @@ function notAcceptable(): ResponseStatus { }); } +interface ICardOverrideData { + readonly base?: ICardData; + readonly overrides: Partial; +} + interface IUserStatic { readonly username: string; readonly settings: ISettings; readonly cards: readonly ICardData[]; + readonly overrides: readonly ICardOverrideData[]; } type GetMeResult = { @@ -87,6 +93,7 @@ async function getMe(): Promise { username, settings, cards, + overrides: [], }, }; } catch { @@ -115,20 +122,65 @@ const AppComponent: React.VoidFunctionComponent = () => { updateMe(); } - const user = userState == null + const user: IUser | null = userState == null ? null : { - ...userState, + username: userState.username, + settings: userState.settings, cards: userState?.cards.map((card) => { + const override = userState.overrides.find(({ base }) => base === card); const cardMethods: CardMethods = { - update() { }, + update(updates) { + if (override == null) { + setUser({ + ...userState, + overrides: [...userState.overrides, + { + base: card, + overrides: updates, + }], + }); + } else { + const { overrides: { image } } = override; + if (image != null && updates.image !== undefined) { + URL.revokeObjectURL(image[1]); + } + const overrides: Partial = { + ...override.overrides, + ...updates, + }; + setUser({ + ...userState, + overrides: userState.overrides.map((elem) => { + if (elem.base === card) { + return { + base: card, + overrides, + }; + } + return elem; + }), + }); + } + }, commit() { return Promise.reject(); }, delete() { return Promise.reject(); }, }; const fieldMethods: CardFieldMethods = { update() { }, }; - return implement(card, cardMethods, fieldMethods); + let data = card; + if (override != null) { + const { overrides: { image } } = override; + let imageStr: string | undefined = card.image; + if (image !== undefined) imageStr = image == null ? undefined : image[1]; + data = { + ...card, + ...override.overrides, + image: imageStr, + }; + } + return implement(data, cardMethods, fieldMethods); }), }; diff --git a/web/src/controllers/Card.ts b/web/src/controllers/Card.ts index 5498d2e0..c54792ee 100644 --- a/web/src/controllers/Card.ts +++ b/web/src/controllers/Card.ts @@ -38,10 +38,10 @@ export function newCard(): ICard { throw new Error('Not implemented'); } -export interface ICardData extends Readonly { - readonly id?: ObjectId; - readonly image?: string; - readonly fields: readonly ICardFieldData[]; +export interface ICardData extends ICardPropertiesCommon { + id?: ObjectId; + image?: string; + fields: readonly Readonly[]; } export function fromRaw(raw: unknown): ICardData { @@ -77,7 +77,7 @@ export function fromRaw(raw: unknown): ICardData { export type CardMethods = Pick; export function implement( - data: ICardData, + data: Readonly, methods: CardMethods, fieldMethods: CardFieldMethods, ): ICard { diff --git a/web/src/controllers/CardField.ts b/web/src/controllers/CardField.ts index 7c3182ab..b3b95e90 100644 --- a/web/src/controllers/CardField.ts +++ b/web/src/controllers/CardField.ts @@ -9,7 +9,7 @@ export interface ICardField extends Readonly { update(props: Partial): void; } -export type ICardFieldData = Readonly; +export type ICardFieldData = ICardFieldProperties; export function fromRaw(raw: unknown): ICardFieldData { const { key, value } = ensureObject(raw); @@ -23,7 +23,7 @@ export function fromRaw(raw: unknown): ICardFieldData { export type CardFieldMethods = Pick; export function implement( - data: ICardFieldData, + data: Readonly, methods: CardFieldMethods, ): ICardField { return { From c1cebcd2748dd7e17c6dfed704b3feb12c97ee29 Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Sat, 4 Sep 2021 03:38:56 +1000 Subject: [PATCH 17/27] Add ICardField.remove(). --- web/src/controllers/CardField.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/controllers/CardField.ts b/web/src/controllers/CardField.ts index b3b95e90..6e8029b7 100644 --- a/web/src/controllers/CardField.ts +++ b/web/src/controllers/CardField.ts @@ -7,6 +7,7 @@ export interface ICardFieldProperties { export interface ICardField extends Readonly { update(props: Partial): void; + remove(): void; } export type ICardFieldData = ICardFieldProperties; From 8ea7bd3e7e7e3a76d875922b4d34979fccc48d26 Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Sat, 4 Sep 2021 03:42:01 +1000 Subject: [PATCH 18/27] Implement card field update. --- web/src/App.tsx | 26 ++++++++++++++++++++------ web/src/controllers/Card.ts | 10 +++++++--- web/src/controllers/CardField.ts | 2 +- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 7a5c5590..7be37d4d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,9 +11,8 @@ import { } from './AppContext'; import { Header } from './components/Header'; import { - CardMethods, ICardData, ICardProperties, fromRaw, implement, + CardFieldMethodsFactory, CardMethods, ICardData, ICardProperties, fromRaw, implement, } from './controllers/Card'; -import { CardFieldMethods } from './controllers/CardField'; import { ResponseStatus } from './ResponseStatus'; import { Home } from './views/Home'; import { Login } from './views/Login'; @@ -166,9 +165,24 @@ const AppComponent: React.VoidFunctionComponent = () => { commit() { return Promise.reject(); }, delete() { return Promise.reject(); }, }; - const fieldMethods: CardFieldMethods = { - update() { }, - }; + const fieldMethodsFactory: CardFieldMethodsFactory = (field) => ({ + update({ key, value }) { + const base = override?.overrides.fields ?? card.fields; + cardMethods.update({ + fields: base.map( + (existing) => (field === existing + ? { key: key ?? existing.key, value: value ?? existing.value } + : existing), + ), + }); + }, + remove() { + const base = override?.overrides.fields ?? card.fields; + cardMethods.update({ + fields: base.filter((existing) => field !== existing), + }); + }, + }); let data = card; if (override != null) { const { overrides: { image } } = override; @@ -180,7 +194,7 @@ const AppComponent: React.VoidFunctionComponent = () => { image: imageStr, }; } - return implement(data, cardMethods, fieldMethods); + return implement(data, cardMethods, fieldMethodsFactory); }), }; diff --git a/web/src/controllers/Card.ts b/web/src/controllers/Card.ts index c54792ee..071665ed 100644 --- a/web/src/controllers/Card.ts +++ b/web/src/controllers/Card.ts @@ -74,16 +74,20 @@ export function fromRaw(raw: unknown): ICardData { return result; } -export type CardMethods = Pick; +export type CardMethods = Omit; + +export type CardFieldMethodsFactory = ( + field: Readonly, +) => CardFieldMethods; export function implement( data: Readonly, methods: CardMethods, - fieldMethods: CardFieldMethods, + fieldMethodsFactory: CardFieldMethodsFactory, ): ICard { return { ...data, - fields: data.fields.map((field) => implementField(field, fieldMethods)), + fields: data.fields.map((field) => implementField(field, fieldMethodsFactory(field))), ...methods, }; } diff --git a/web/src/controllers/CardField.ts b/web/src/controllers/CardField.ts index 6e8029b7..3545e07b 100644 --- a/web/src/controllers/CardField.ts +++ b/web/src/controllers/CardField.ts @@ -21,7 +21,7 @@ export function fromRaw(raw: unknown): ICardFieldData { }; } -export type CardFieldMethods = Pick; +export type CardFieldMethods = Omit; export function implement( data: Readonly, From c0d54ac3f59aca1a815eea115283961c1a7fb95a Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Mon, 6 Sep 2021 03:39:12 +1000 Subject: [PATCH 19/27] Implement card delete. --- web/src/App.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 7be37d4d..c45d51cf 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -163,7 +163,24 @@ const AppComponent: React.VoidFunctionComponent = () => { } }, commit() { return Promise.reject(); }, - delete() { return Promise.reject(); }, + async delete() { + const res = await fetch('/api/card', { + method: 'DELETE', + body: JSON.stringify({ + id: card.id, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + if (res.ok) { + setUser({ + ...userState, + cards: userState.cards.filter((that) => that !== card), + }); + } + return new ResponseStatus(res); + }, }; const fieldMethodsFactory: CardFieldMethodsFactory = (field) => ({ update({ key, value }) { From 0f2b56da1d9ccdc59fbc87c5303a2fe0c0e2457c Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Mon, 6 Sep 2021 04:45:10 +1000 Subject: [PATCH 20/27] Implement card commit. Note that the method does not actually send PATCH request if update has never been called on that card. --- web/src/App.tsx | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index c45d51cf..1db5439c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -162,7 +162,53 @@ const AppComponent: React.VoidFunctionComponent = () => { }); } }, - commit() { return Promise.reject(); }, + async commit() { + if (card.id == null) throw new Error('card.id is nullish'); + + if (override == null) { + return new ResponseStatus({ + ok: true, + status: 200, + statusText: 'OK', + }); + } + + const { overrides } = override; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const bodyObj: any = Object.fromEntries(Object + .entries(overrides) + .filter(([k, v]) => k !== 'image' && v !== undefined) + .concat([['id', card.id]])); + const { image } = overrides; + if (image !== undefined) { + if (image === null) { + bodyObj.image = null; + } else { + const [blob] = image; + bodyObj.image = Buffer.from(await blob.arrayBuffer()).toString('base64'); + } + } + const body = JSON.stringify(bodyObj); + const res = await fetch('/api/card', { + method: 'PATCH', + body, + headers: { + 'Content-Type': 'application/json', + }, + }); + if (res.ok) { + const data = await res.json(); + const updated = fromRaw(data); + setUser({ + ...userState, + cards: userState.cards.map((that) => { + if (that === card) return updated; + return that; + }), + }); + } + return new ResponseStatus(res); + }, async delete() { const res = await fetch('/api/card', { method: 'DELETE', From 8dd46c9611b4596f33e52d0c55c3e39f21a4386a Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Mon, 6 Sep 2021 04:54:11 +1000 Subject: [PATCH 21/27] Router switch robustness fix. --- web/src/App.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 1db5439c..52d847bf 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -318,16 +318,13 @@ const AppComponent: React.VoidFunctionComponent = () => {
- {!loggedIn && } - + {loggedIn ? : } - {loggedIn && } - + {loggedIn ? : } - {loggedIn && } - + {loggedIn ? : } From a53be993e211b4b11493fda1944eae77abcea057 Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Mon, 6 Sep 2021 04:55:54 +1000 Subject: [PATCH 22/27] Remove unnecessary optional chaining. --- web/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 52d847bf..c87f8296 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -126,7 +126,7 @@ const AppComponent: React.VoidFunctionComponent = () => { : { username: userState.username, settings: userState.settings, - cards: userState?.cards.map((card) => { + cards: userState.cards.map((card) => { const override = userState.overrides.find(({ base }) => base === card); const cardMethods: CardMethods = { update(updates) { From 70e00d952d16e919865fdadab038ac5c8fea31d2 Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Mon, 6 Sep 2021 05:04:56 +1000 Subject: [PATCH 23/27] Refactor AppComponent. --- web/src/App.tsx | 276 +++++++++++++++++++++++++----------------------- 1 file changed, 141 insertions(+), 135 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index c87f8296..3c691f74 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,7 +2,7 @@ import { mergeStyleSets } from '@fluentui/react'; import { ensureArray, ensureObject, ensureType } from '@porkbellypro/crm-shared'; import { Buffer } from 'buffer'; import PropTypes from 'prop-types'; -import React, { useState } from 'react'; +import React, { Dispatch, SetStateAction, useState } from 'react'; import { BrowserRouter, MemoryRouter, Redirect, Route, Switch, useHistory, } from 'react-router-dom'; @@ -11,7 +11,7 @@ import { } from './AppContext'; import { Header } from './components/Header'; import { - CardFieldMethodsFactory, CardMethods, ICardData, ICardProperties, fromRaw, implement, + CardFieldMethodsFactory, CardMethods, ICard, ICardData, ICardProperties, fromRaw, implement, } from './controllers/Card'; import { ResponseStatus } from './ResponseStatus'; import { Home } from './views/Home'; @@ -100,6 +100,144 @@ async function getMe(): Promise { } } +function inflate( + card: ICardData, + userState: IUserStatic, + setUser: Dispatch>, +): ICard { + const override = userState.overrides.find(({ base }) => base === card); + const cardMethods: CardMethods = { + update(updates) { + if (override == null) { + setUser({ + ...userState, + overrides: [...userState.overrides, + { + base: card, + overrides: updates, + }], + }); + } else { + const { overrides: { image } } = override; + if (image != null && updates.image !== undefined) { + URL.revokeObjectURL(image[1]); + } + const overrides: Partial = { + ...override.overrides, + ...updates, + }; + setUser({ + ...userState, + overrides: userState.overrides.map((elem) => { + if (elem.base === card) { + return { + base: card, + overrides, + }; + } + return elem; + }), + }); + } + }, + async commit() { + if (card.id == null) throw new Error('card.id is nullish'); + + if (override == null) { + return new ResponseStatus({ + ok: true, + status: 200, + statusText: 'OK', + }); + } + + const { overrides } = override; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const bodyObj: any = Object.fromEntries(Object + .entries(overrides) + .filter(([k, v]) => k !== 'image' && v !== undefined) + .concat([['id', card.id]])); + const { image } = overrides; + if (image !== undefined) { + if (image === null) { + bodyObj.image = null; + } else { + const [blob] = image; + bodyObj.image = Buffer.from(await blob.arrayBuffer()).toString('base64'); + } + } + const body = JSON.stringify(bodyObj); + const res = await fetch('/api/card', { + method: 'PATCH', + body, + headers: { + 'Content-Type': 'application/json', + }, + }); + if (res.ok) { + const data = await res.json(); + const updated = fromRaw(data); + setUser({ + ...userState, + cards: userState.cards.map((that) => { + if (that === card) return updated; + return that; + }), + }); + } + return new ResponseStatus(res); + }, + async delete() { + const res = await fetch('/api/card', { + method: 'DELETE', + body: JSON.stringify({ + id: card.id, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + if (res.ok) { + setUser({ + ...userState, + cards: userState.cards.filter((that) => that !== card), + }); + } + return new ResponseStatus(res); + }, + }; + const fieldMethodsFactory: CardFieldMethodsFactory = (field) => ({ + update({ key, value }) { + const base = override?.overrides.fields ?? card.fields; + cardMethods.update({ + fields: base.map( + (existing) => (field === existing + ? { key: key ?? existing.key, value: value ?? existing.value } + : existing), + ), + }); + }, + remove() { + const base = override?.overrides.fields ?? card.fields; + cardMethods.update({ + fields: base.filter((existing) => field !== existing), + }); + }, + }); + let data = card; + if (override != null) { + const { overrides: { image } } = override; + let imageStr: string | undefined = card.image; + if (image !== undefined) imageStr = image == null ? undefined : image[1]; + data = { + ...card, + ...override.overrides, + image: imageStr, + }; + } + return implement(data, cardMethods, fieldMethodsFactory); +} + const AppComponent: React.VoidFunctionComponent = () => { const [userState, setUser] = useState(); const history = useHistory(); @@ -126,139 +264,7 @@ const AppComponent: React.VoidFunctionComponent = () => { : { username: userState.username, settings: userState.settings, - cards: userState.cards.map((card) => { - const override = userState.overrides.find(({ base }) => base === card); - const cardMethods: CardMethods = { - update(updates) { - if (override == null) { - setUser({ - ...userState, - overrides: [...userState.overrides, - { - base: card, - overrides: updates, - }], - }); - } else { - const { overrides: { image } } = override; - if (image != null && updates.image !== undefined) { - URL.revokeObjectURL(image[1]); - } - const overrides: Partial = { - ...override.overrides, - ...updates, - }; - setUser({ - ...userState, - overrides: userState.overrides.map((elem) => { - if (elem.base === card) { - return { - base: card, - overrides, - }; - } - return elem; - }), - }); - } - }, - async commit() { - if (card.id == null) throw new Error('card.id is nullish'); - - if (override == null) { - return new ResponseStatus({ - ok: true, - status: 200, - statusText: 'OK', - }); - } - - const { overrides } = override; - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - const bodyObj: any = Object.fromEntries(Object - .entries(overrides) - .filter(([k, v]) => k !== 'image' && v !== undefined) - .concat([['id', card.id]])); - const { image } = overrides; - if (image !== undefined) { - if (image === null) { - bodyObj.image = null; - } else { - const [blob] = image; - bodyObj.image = Buffer.from(await blob.arrayBuffer()).toString('base64'); - } - } - const body = JSON.stringify(bodyObj); - const res = await fetch('/api/card', { - method: 'PATCH', - body, - headers: { - 'Content-Type': 'application/json', - }, - }); - if (res.ok) { - const data = await res.json(); - const updated = fromRaw(data); - setUser({ - ...userState, - cards: userState.cards.map((that) => { - if (that === card) return updated; - return that; - }), - }); - } - return new ResponseStatus(res); - }, - async delete() { - const res = await fetch('/api/card', { - method: 'DELETE', - body: JSON.stringify({ - id: card.id, - }), - headers: { - 'Content-Type': 'application/json', - }, - }); - if (res.ok) { - setUser({ - ...userState, - cards: userState.cards.filter((that) => that !== card), - }); - } - return new ResponseStatus(res); - }, - }; - const fieldMethodsFactory: CardFieldMethodsFactory = (field) => ({ - update({ key, value }) { - const base = override?.overrides.fields ?? card.fields; - cardMethods.update({ - fields: base.map( - (existing) => (field === existing - ? { key: key ?? existing.key, value: value ?? existing.value } - : existing), - ), - }); - }, - remove() { - const base = override?.overrides.fields ?? card.fields; - cardMethods.update({ - fields: base.filter((existing) => field !== existing), - }); - }, - }); - let data = card; - if (override != null) { - const { overrides: { image } } = override; - let imageStr: string | undefined = card.image; - if (image !== undefined) imageStr = image == null ? undefined : image[1]; - data = { - ...card, - ...override.overrides, - image: imageStr, - }; - } - return implement(data, cardMethods, fieldMethodsFactory); - }), + cards: userState.cards.map((card) => inflate(card, userState, setUser)), }; const context: IAppContext = { From 7fc266227cd219ffb22b1885b3c781683189da67 Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Mon, 6 Sep 2021 07:09:06 +1000 Subject: [PATCH 24/27] Implement new card commit. --- web/src/App.tsx | 270 ++++++++++++++++++++++++------------ web/src/AppContext.ts | 1 + web/src/controllers/Card.ts | 14 +- 3 files changed, 189 insertions(+), 96 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 3c691f74..7d741237 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,7 +11,14 @@ import { } from './AppContext'; import { Header } from './components/Header'; import { - CardFieldMethodsFactory, CardMethods, ICard, ICardData, ICardProperties, fromRaw, implement, + CardFieldMethodsFactory, + CardMethods, + ICard, + ICardData, + ICardProperties, + cardDataDefaults, + fromRaw, + implement, } from './controllers/Card'; import { ResponseStatus } from './ResponseStatus'; import { Home } from './views/Home'; @@ -58,7 +65,6 @@ interface IUserStatic { readonly username: string; readonly settings: ISettings; readonly cards: readonly ICardData[]; - readonly overrides: readonly ICardOverrideData[]; } type GetMeResult = { @@ -92,7 +98,6 @@ async function getMe(): Promise { username, settings, cards, - overrides: [], }, }; } catch { @@ -100,50 +105,87 @@ async function getMe(): Promise { } } -function inflate( +function notImplemented() { + return new Error('Not implemented'); +} + +function implementDelete( card: ICardData, userState: IUserStatic, setUser: Dispatch>, +): ICard['delete'] { + return (async () => { + if (card.id == null) throw new Error('card.id is nullish'); + + const res = await fetch('/api/card', { + method: 'DELETE', + body: JSON.stringify({ + id: card.id, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + if (res.ok) { + setUser({ + ...userState, + cards: userState.cards.filter((that) => that !== card), + }); + } + return new ResponseStatus(res); + }); +} + +function implementCard( + card: ICardData, + userState: IUserStatic, + setUser: Dispatch>, +): ICard { + const cardMethods: CardMethods = { + update() { throw notImplemented(); }, + commit() { return Promise.reject(notImplemented()); }, + delete: implementDelete(card, userState, setUser), + }; + const fieldMethodsFactory: CardFieldMethodsFactory = () => ({ + update() { throw notImplemented(); }, + remove() { throw notImplemented(); }, + }); + return implement(card, cardMethods, fieldMethodsFactory); +} + +function implementCardOverride( + data: ICardOverrideData, + setDetail: Dispatch>, + userState: IUserStatic, + setUser: Dispatch>, ): ICard { - const override = userState.overrides.find(({ base }) => base === card); + const { base, overrides: { image, ...overrides } } = data; + const cardData: ICardData = { + ...cardDataDefaults, + ...base, + ...overrides, + }; + if (image !== undefined) { + cardData.image = image === null ? undefined : image[1]; + } const cardMethods: CardMethods = { update(updates) { - if (override == null) { - setUser({ - ...userState, - overrides: [...userState.overrides, - { - base: card, - overrides: updates, - }], - }); - } else { - const { overrides: { image } } = override; - if (image != null && updates.image !== undefined) { - URL.revokeObjectURL(image[1]); - } - const overrides: Partial = { - ...override.overrides, - ...updates, - }; - setUser({ - ...userState, - overrides: userState.overrides.map((elem) => { - if (elem.base === card) { - return { - base: card, - overrides, - }; - } - return elem; - }), - }); + if (image != null && updates.image !== undefined) { + URL.revokeObjectURL(image[1]); } + const newOverrides: Partial = { + ...data.overrides, + ...updates, + }; + setDetail({ + ...data, + overrides: newOverrides, + }); }, async commit() { - if (card.id == null) throw new Error('card.id is nullish'); + const put = base?.id == null; - if (override == null) { + if (!put && !Object.entries(data.overrides).some(([, v]) => v !== undefined)) { return new ResponseStatus({ ok: true, status: 200, @@ -151,13 +193,19 @@ function inflate( }); } - const { overrides } = override; - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - const bodyObj: any = Object.fromEntries(Object - .entries(overrides) - .filter(([k, v]) => k !== 'image' && v !== undefined) - .concat([['id', card.id]])); - const { image } = overrides; + let bodyObj; + if (put) { + bodyObj = Object.fromEntries( + Object + .entries(cardDataDefaults) + .concat(Object.entries(overrides), [['tags', []]]), + ); + } else { + if (base?.id == null) throw new Error('Unreachable'); + bodyObj = Object.fromEntries(Object + .entries(overrides) + .concat([['id', base.id]])); + } if (image !== undefined) { if (image === null) { bodyObj.image = null; @@ -168,49 +216,40 @@ function inflate( } const body = JSON.stringify(bodyObj); const res = await fetch('/api/card', { - method: 'PATCH', + method: put ? 'PUT' : 'PATCH', body, headers: { 'Content-Type': 'application/json', }, }); if (res.ok) { - const data = await res.json(); - const updated = fromRaw(data); - setUser({ - ...userState, - cards: userState.cards.map((that) => { - if (that === card) return updated; - return that; - }), - }); - } - return new ResponseStatus(res); - }, - async delete() { - const res = await fetch('/api/card', { - method: 'DELETE', - body: JSON.stringify({ - id: card.id, - }), - headers: { - 'Content-Type': 'application/json', - }, - }); - if (res.ok) { - setUser({ - ...userState, - cards: userState.cards.filter((that) => that !== card), - }); + const raw = await res.json(); + const updated = fromRaw(raw); + if (put) { + setUser({ + ...userState, + cards: userState.cards.concat(updated), + }); + } else { + setUser({ + ...userState, + cards: userState.cards.map((that) => { + if (that === base) return updated; + return that; + }), + }); + } } return new ResponseStatus(res); }, + delete: base == null + ? () => Promise.reject(notImplemented()) + : implementDelete(base, userState, setUser), }; const fieldMethodsFactory: CardFieldMethodsFactory = (field) => ({ update({ key, value }) { - const base = override?.overrides.fields ?? card.fields; cardMethods.update({ - fields: base.map( + fields: cardData.fields.map( (existing) => (field === existing ? { key: key ?? existing.key, value: value ?? existing.value } : existing), @@ -218,30 +257,47 @@ function inflate( }); }, remove() { - const base = override?.overrides.fields ?? card.fields; cardMethods.update({ - fields: base.filter((existing) => field !== existing), + fields: cardData.fields.filter((existing) => field !== existing), }); }, }); - let data = card; - if (override != null) { - const { overrides: { image } } = override; - let imageStr: string | undefined = card.image; - if (image !== undefined) imageStr = image == null ? undefined : image[1]; - data = { - ...card, - ...override.overrides, - image: imageStr, - }; - } - return implement(data, cardMethods, fieldMethodsFactory); + return implement(cardData, cardMethods, fieldMethodsFactory); } const AppComponent: React.VoidFunctionComponent = () => { - const [userState, setUser] = useState(); + const [userState, setUserState] = useState(); + const [detail, setDetail] = useState(null); const history = useHistory(); + function freeDetail() { + if (detail == null) return; + + const { overrides: { image } } = detail; + if (image != null) { + const [, url] = image; + URL.revokeObjectURL(url); + } + } + + const setUser: Dispatch> = (value) => { + let newState: IUserStatic | null | undefined; + if (typeof value === 'function') newState = value(userState); + else newState = value; + setUserState(newState); + let newBase: ICardData | undefined; + if (detail?.base?.id != null && newState != null) { + newBase = newState.cards.find((card) => card.id === detail?.base?.id); + } + freeDetail(); + setDetail(newBase == null + ? null + : { + base: newBase, + overrides: {}, + }); + }; + const updateMe: () => Promise = async () => { const { status, user } = await getMe(); const { ok } = status; @@ -264,14 +320,32 @@ const AppComponent: React.VoidFunctionComponent = () => { : { username: userState.username, settings: userState.settings, - cards: userState.cards.map((card) => inflate(card, userState, setUser)), + cards: userState.cards.map((card) => implementCard(card, userState, setUser)), }; const context: IAppContext = { searchQuery: '', user, update() { }, - showCardDetail() { }, + showCardDetail(card) { + if (userState == null) throw new Error('userState is nullish'); + freeDetail(); + if (card == null) { + setDetail(null); + } else { + const base = userState.cards.find((existing) => existing.id === card.id); + if (base == null) throw new Error('Not found'); + setDetail({ + base, + overrides: {}, + }); + } + }, + newCard() { + setDetail({ + overrides: {}, + }); + }, login(username, password, register) { return (async function loginAsync() { const body = { @@ -312,6 +386,18 @@ const AppComponent: React.VoidFunctionComponent = () => { }, }; + let override: ICard | undefined; + if (userState != null) { + override = detail != null + ? implementCardOverride( + detail, + setDetail, + userState, + setUser, + ) + : undefined; + } + const { header, body } = getClassNames(); const loggedIn = userState != null; @@ -324,7 +410,7 @@ const AppComponent: React.VoidFunctionComponent = () => {
- {loggedIn ? : } + {loggedIn ? : } {loggedIn ? : } diff --git a/web/src/AppContext.ts b/web/src/AppContext.ts index 0034b469..27e3af9d 100644 --- a/web/src/AppContext.ts +++ b/web/src/AppContext.ts @@ -19,6 +19,7 @@ export interface IAppContext extends Readonly { readonly user: IUser | null; update(props: Partial): void; showCardDetail(card: ICard | null): void; + newCard(): void; login(username: string, password: string, register?: boolean): Promise; logout(): Promise; } diff --git a/web/src/controllers/Card.ts b/web/src/controllers/Card.ts index 071665ed..bc595b5d 100644 --- a/web/src/controllers/Card.ts +++ b/web/src/controllers/Card.ts @@ -34,16 +34,22 @@ export interface ICard extends Readonly { delete(): Promise; } -export function newCard(): ICard { - throw new Error('Not implemented'); -} - export interface ICardData extends ICardPropertiesCommon { id?: ObjectId; image?: string; fields: readonly Readonly[]; } +export const cardDataDefaults: ICardData = Object.freeze({ + favorite: false, + name: '', + phone: '', + email: '', + jobTitle: '', + company: '', + fields: [], +}); + export function fromRaw(raw: unknown): ICardData { const { id, From c730263737bb55485d9b67f40a7af8adef5c4384 Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Mon, 6 Sep 2021 07:19:57 +1000 Subject: [PATCH 25/27] Implement app update. --- web/src/App.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 7d741237..6d874669 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -268,6 +268,7 @@ function implementCardOverride( const AppComponent: React.VoidFunctionComponent = () => { const [userState, setUserState] = useState(); const [detail, setDetail] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); const history = useHistory(); function freeDetail() { @@ -324,9 +325,11 @@ const AppComponent: React.VoidFunctionComponent = () => { }; const context: IAppContext = { - searchQuery: '', + searchQuery, user, - update() { }, + update({ searchQuery: query }) { + if (query != null) setSearchQuery(query); + }, showCardDetail(card) { if (userState == null) throw new Error('userState is nullish'); freeDetail(); From 0818694a094b278ab52707fddc46af8e27a8af4e Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Mon, 6 Sep 2021 15:10:07 +1000 Subject: [PATCH 26/27] Catch all server errors. --- server/src/server.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/server/src/server.ts b/server/src/server.ts index ac269450..6803027f 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1,5 +1,5 @@ import { ArgumentParser } from 'argparse'; -import express, { RequestHandler } from 'express'; +import express, { ErrorRequestHandler, RequestHandler } from 'express'; import { readFile } from 'fs/promises'; import { createConnection } from 'mongoose'; import { resolve } from 'path'; @@ -37,6 +37,18 @@ function serveIndex(distPath: string): RequestHandler { return serveIndex; } +/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ +const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => { + if (err) { + if (err instanceof HttpStatusError) { + res.status(err.code).send(err.message); + return; + } + } + + res.sendStatus(500); +}; + async function main() { const parser = new ArgumentParser(); parser.add_argument('-d', '--dist'); @@ -62,6 +74,7 @@ async function main() { app.use('/api', apiRouter.router); app.use(dist); app.get('*', serveIndex(distPath)); + app.use(errorHandler); console.log(`Listening on port ${port}...`); app.listen(port); From 0ecdea04bd16d80b855acca76c25afb09879d154 Mon Sep 17 00:00:00 2001 From: Shang Zhe Lee Date: Mon, 6 Sep 2021 17:10:35 +1000 Subject: [PATCH 27/27] Use base64 strings instead of blobs and URLs for image sources. --- web/src/App.tsx | 24 ++---------------------- web/src/controllers/Card.ts | 2 +- 2 files changed, 3 insertions(+), 23 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 6d874669..bd94c009 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -166,13 +166,10 @@ function implementCardOverride( ...overrides, }; if (image !== undefined) { - cardData.image = image === null ? undefined : image[1]; + cardData.image = image === null ? undefined : image; } const cardMethods: CardMethods = { update(updates) { - if (image != null && updates.image !== undefined) { - URL.revokeObjectURL(image[1]); - } const newOverrides: Partial = { ...data.overrides, ...updates, @@ -207,12 +204,7 @@ function implementCardOverride( .concat([['id', base.id]])); } if (image !== undefined) { - if (image === null) { - bodyObj.image = null; - } else { - const [blob] = image; - bodyObj.image = Buffer.from(await blob.arrayBuffer()).toString('base64'); - } + bodyObj.image = image; } const body = JSON.stringify(bodyObj); const res = await fetch('/api/card', { @@ -271,16 +263,6 @@ const AppComponent: React.VoidFunctionComponent = () => { const [searchQuery, setSearchQuery] = useState(''); const history = useHistory(); - function freeDetail() { - if (detail == null) return; - - const { overrides: { image } } = detail; - if (image != null) { - const [, url] = image; - URL.revokeObjectURL(url); - } - } - const setUser: Dispatch> = (value) => { let newState: IUserStatic | null | undefined; if (typeof value === 'function') newState = value(userState); @@ -290,7 +272,6 @@ const AppComponent: React.VoidFunctionComponent = () => { if (detail?.base?.id != null && newState != null) { newBase = newState.cards.find((card) => card.id === detail?.base?.id); } - freeDetail(); setDetail(newBase == null ? null : { @@ -332,7 +313,6 @@ const AppComponent: React.VoidFunctionComponent = () => { }, showCardDetail(card) { if (userState == null) throw new Error('userState is nullish'); - freeDetail(); if (card == null) { setDetail(null); } else { diff --git a/web/src/controllers/Card.ts b/web/src/controllers/Card.ts index bc595b5d..fe708696 100644 --- a/web/src/controllers/Card.ts +++ b/web/src/controllers/Card.ts @@ -21,7 +21,7 @@ interface ICardPropertiesCommon { } export interface ICardProperties extends ICardPropertiesCommon { - image: [Blob, string] | null; + image: string | null; fields: readonly ICardFieldProperties[]; }