From 3a9babb06a8c4c04686494a1dcd4271b970615c2 Mon Sep 17 00:00:00 2001 From: Paulius Date: Thu, 16 May 2024 11:24:01 +0100 Subject: [PATCH] Render connections view (first page) (#24) * VR-63: version bump. * VR-71: missing asset. * VR-63: removing the example. * VR-63: switching branches. * VR-63: and a table component - final * VR-63: xss-scan. * VR-63: snapshot update. * VR-63: added filter icon for table column names.. * VR-63: xss-scan * VR-63: copy update. * Update src/views/connection.tsx Co-authored-by: David Blane <32327139+dblane-digicatapult@users.noreply.github.com> * VR-63: no need to escape html (connection view).g --------- Co-authored-by: Paulius Michelevicius Co-authored-by: David Blane <32327139+dblane-digicatapult@users.noreply.github.com> --- package-lock.json | 4 +- package.json | 2 +- public/images/button-plus.svg | 6 + public/images/sort.svg | 5 + public/styles/main.css | 158 ++++++++++++++++---- public/styles/reset.css | 55 ++++++- src/controllers/__tests__/example.test.ts | 63 -------- src/controllers/__tests__/helpers.ts | 13 -- src/controllers/example.ts | 67 --------- src/models/__tests__/counter.test.ts | 33 ---- src/models/counter.ts | 17 --- src/views/__tests__/connection.test.ts.snap | 8 +- src/views/__tests__/example.test.ts | 58 ------- src/views/__tests__/example.test.ts.snap | 15 -- src/views/common.tsx | 14 ++ src/views/connection.tsx | 70 ++++++++- src/views/example.tsx | 42 ------ 17 files changed, 270 insertions(+), 360 deletions(-) create mode 100644 public/images/button-plus.svg create mode 100644 public/images/sort.svg delete mode 100644 src/controllers/__tests__/example.test.ts delete mode 100644 src/controllers/example.ts delete mode 100644 src/models/__tests__/counter.test.ts delete mode 100644 src/models/counter.ts delete mode 100644 src/views/__tests__/example.test.ts delete mode 100644 src/views/__tests__/example.test.ts.snap delete mode 100644 src/views/example.tsx diff --git a/package-lock.json b/package-lock.json index 0024294f..6f79525b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "veritable-ui", - "version": "0.2.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "veritable-ui", - "version": "0.2.1", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { "@digicatapult/tsoa-oauth-express": "^0.1.0", diff --git a/package.json b/package.json index c45530ad..c744ad33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "veritable-ui", - "version": "0.2.1", + "version": "0.3.0", "description": "UI for Veritable", "main": "src/index.ts", "type": "commonjs", diff --git a/public/images/button-plus.svg b/public/images/button-plus.svg new file mode 100644 index 00000000..0c0ca78e --- /dev/null +++ b/public/images/button-plus.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/sort.svg b/public/images/sort.svg new file mode 100644 index 00000000..2f42749f --- /dev/null +++ b/public/images/sort.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/styles/main.css b/public/styles/main.css index b2242ede..f2eea5d4 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -7,14 +7,18 @@ } :root { - --mobile-header-bg: transparent; - --side-bar-width: 5vw; - --desktop-content-width: calc(100vw - 5vw); - --accent-color: #5670f1; - --secondary-color: #FFCC91; - --sub-text-color: #c9cace; + /* color pallete */ --text-color: #45494c; + --text-color-secondary: #6E7079; + --text-color-sub: #c9cace; --bg-color: #f4f5fb; + --secondary-color: #FFCC91; + --accent-color: #5670f1; + + /* layout and other vars */ + --mobile-header-bg: transparent; + --side-bar-width: 5vw; + --desktop-content-width: calc(100vw - var(--side-bar-width)); } body { @@ -39,14 +43,14 @@ body.flex-page { flex-direction: column; justify-content: flex-start; align-items: center; - padding: 10px; + padding: 1rem; background-color: #fff; } .side-bar.logo-container { height: 61px; width: 61px; - margin-bottom: 1.5rem; + margin-bottom: 4rem; } a.side-bar, i.side-bar { @@ -62,23 +66,24 @@ a.side-bar, i.side-bar { } a.side-bar.icon { - border: 1px solid var(--sub-text-color); + border: 1px solid var(--text-color-sub); border-radius: 10px; background-repeat: no-repeat; background-position: center; } -.flex-page.content { - display: flex; - flex-direction: column; +a.connections-table.icon { + padding: 1rem; + text-align: center; + margin-bottom: 0.5rem; + background-image: url("/public/images/filter.svg"); + background-repeat: no-repeat; + background-position: center; } -.content.header { +.flex-page.content { display: flex; flex-direction: column; - align-items: flex-start; - width: var(--desktop-content-width); /* due to column flex-grow: grows height so to void double wrap of els e.g. col/row */ - background-color: #fff; } .header.heading { @@ -96,7 +101,7 @@ a.side-bar.icon { padding: 0.25rem; font-size: 0.75rem; line-height: 1.25rem; - color: var(--sub-text-color); + color: var(--text-color-sub); } .nav.icon { @@ -107,10 +112,102 @@ a.side-bar.icon { background-image: url("/public/images/home.svg"); } +.content.header { + display: flex; + flex-direction: column; + align-items: flex-start; + width: var(--desktop-content-width); /* due to column flex-grow: grows height so to void double wrap of els e.g. col/row */ + background-color: #fff; +} + .content.main { - padding: 10px; - margin: 3rem; - background-color: white; + padding: 1rem; +} + +/* Connections listPage */ +/* TODO: include in the media @fn below (mobile) */ +.button { + display: flex; + align-items: center; + background-color: var(--accent-color); + color: inherit; + font-size: 0.75rem; + padding: 5px 10px; + border-radius: 12px; + transition: all 0.3s; + + &:hover { + opacity: 0.7; + } +} + +.button.icon { + width: 14px; + height: 14px; + background-repeat: no-repeat; + background-position: center; + background-color: transparent; + background-image: url("/public/images/plus.svg"); +} + +.button.text { + background-color: transparent; + vertical-align: middle; + color: #fff; +} + +.main.connections { + display: flex; + flex-direction: column; +} + +.connections.list.nav, +.connections.header { + display: flex; + flex-direction: row; + justify-content: space-between; + color: var(--text-color); + font-size: 1rem; +} + +.connections.header { + background-color: var(--bg-color); + padding-top: 0.5rem; + padding-bottom: 1rem; +} + +.connections.list table { + width: 100%; + border-radius: 0px; + background-color: #fff; + border-collapse:collapse; + color: var(--text-color-secondary); + border-bottom: 1px solid var(--text-color-sub); +} + +.connections.list th { + text-align: left; + color: var(--text-color); + padding: 1rem 0; + font-size: 0.75rem; + font-weight: bold; + border-top: 1px solid var(--text-color-sub); + border-bottom: 1px solid var(--text-color-sub); +} + +.connections.list td { + text-align: left; + height: 3rem; + color: var(--text-color-secondary); + font-size: 0.7rem; +} + +.connections.list { + background-color: #fff; + overflow-x: auto; + + border-radius: 12px; + padding: 1rem; } /* For mobile view: TODO if more needed - new file */ @@ -118,27 +215,28 @@ a.side-bar.icon { body.flex-page { flex-direction: column; } + .header.heading { + display: none; + } .content.header { width: 100%; background-color: var(--mobile-header-bg); } + .content.main { + width: 100%; + } .flex-page.side-bar { flex-direction: row; - align-items: center; + align-items: space-around; width: 100%; - padding-top: 1rem; justify-content: space-between; - min-height: 60px; + min-height: auto; } -} -#counter { - position: relative; -} - -#counter.htmx-request > span { - opacity: 0; + .side-bar.logo-container { + margin: 0; + } } .spinner { diff --git a/public/styles/reset.css b/public/styles/reset.css index 092e937c..137248dd 100644 --- a/public/styles/reset.css +++ b/public/styles/reset.css @@ -10,21 +10,60 @@ filter: brightness(115%); } } - + .active { background-color: #5670f1 !important; } + + .warning, + .error, + .success, + .disconnected { + width: max-content; + border-radius: 8px; + font-size: 0.75rem; + line-height: 1rem; + padding: 5px; + } + + * > .warning { + background-color: rgba(255, 168, 0, 0.20); + } + + * > .error { + background-color: rgba(255, 0, 0, 0.20); + } + + * > .success { + background-color: rgba(50, 147, 111, 0.20); + } + + * > .disconnected { + background-color: rgba(54, 54, 54, 0.20); + } + + * > .outline { + border: solid 1px var(--accent-color) !important; + background-color: transparent !important; + color: var(--accent-color) !important; + } + + * > .button.text.accent { + color: var(--accent-color); + } - .content.content.header > .disabled, - .content.content.content.main > .disabled, - .side-bar > .disabled, + * > .disabled, .disabled { - filter: grayscale(100%) blur(3px) !important; - cursor: not-allowed; + width: max-content; + cursor: not-allowed !important; + color: var(--accent-color) !important; + transition: opacity filter 0.3s !important; + filter: grayscale(100%) blur(1px) !important; + opacity: 0.3; &:hover{ - background-color: transparent; - filter: blur(1px) !important; + filter: blur(3px) !important; + opacity: 1; } } diff --git a/src/controllers/__tests__/example.test.ts b/src/controllers/__tests__/example.test.ts deleted file mode 100644 index 9304d9d0..00000000 --- a/src/controllers/__tests__/example.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, it } from 'mocha' -import sinon from 'sinon' - -import { counterMock, mockLogger, templateMock, toHTMLString } from './helpers' - -import { RootController } from '../example' - -describe('ExampleController', () => { - let expect: Chai.ExpectStatic - before(async () => { - expect = (await import('chai')).expect - }) - - afterEach(() => { - sinon.restore() - }) - - describe('get', () => { - it('should return rendered root template', async () => { - const controller = new RootController(counterMock, templateMock, mockLogger) - const result = await controller.get().then(toHTMLString) - expect(result).to.equal('root_TSOA HTMX demo_root') - }) - }) - - describe('counter', () => { - it('should return rendered counter template', async () => { - const controller = new RootController(counterMock, templateMock, mockLogger) - const triggerEventSpy = sinon.spy(controller, 'triggerEvent') - const result = await controller.getCounter().then(toHTMLString) - expect(result).to.equal('counter') - expect(triggerEventSpy.callCount).to.equal(1) - expect(triggerEventSpy.calledWith('counter-loaded')).to.equal(true) - }) - }) - - describe('buttonClick', () => { - it('should return rendered button template', async () => { - const controller = new RootController(counterMock, templateMock, mockLogger) - const incrementSpy = sinon.spy(counterMock, 'increment') - const triggerEventSpy = sinon.spy(controller, 'triggerEvent') - const result = await controller.buttonClick().then(toHTMLString) - - expect(result).to.equal('button_true_button') - expect(incrementSpy.callCount).to.equal(1) - expect(triggerEventSpy.callCount).to.equal(1) - expect(triggerEventSpy.calledWith('button-click')).to.equal(true) - }) - }) - - describe('button', () => { - it('should return rendered button template', async () => { - const controller = new RootController(counterMock, templateMock, mockLogger) - const incrementSpy = sinon.spy(counterMock, 'increment') - const triggerEventSpy = sinon.spy(controller, 'triggerEvent') - const result = await controller.button().then(toHTMLString) - - expect(result).to.equal('button_false_button') - expect(incrementSpy.called).to.equal(false) - expect(triggerEventSpy.called).to.equal(false) - }) - }) -}) diff --git a/src/controllers/__tests__/helpers.ts b/src/controllers/__tests__/helpers.ts index 07e245f4..66e8dcb9 100644 --- a/src/controllers/__tests__/helpers.ts +++ b/src/controllers/__tests__/helpers.ts @@ -3,14 +3,6 @@ import { Readable } from 'node:stream' import pino from 'pino' import { Env } from '../../env.js' -import Counter from '../../models/counter.js' -import RootTemplates from '../../views/example.js' - -export const templateMock = { - Root: (s: string) => `root_${s}_root`, - Counter: () => `counter`, - Button: ({ disabled }) => `button_${disabled}_button`, -} as RootTemplates export const mockLogger = pino({ level: 'silent' }) @@ -22,11 +14,6 @@ export const mockEnv = { }, } as Env -export const counterMock = { - get: () => 42, - increment: () => 43, -} as Counter - export const toHTMLString = async (stream: Readable) => { const chunks: Uint8Array[] = [] for await (const chunk of stream) { diff --git a/src/controllers/example.ts b/src/controllers/example.ts deleted file mode 100644 index ecbbbef1..00000000 --- a/src/controllers/example.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Get, Post, Produces, Route, Security, SuccessResponse } from 'tsoa' -import { inject, injectable, singleton } from 'tsyringe' - -import { Logger, type ILogger } from '../logger.js' -import Counter from '../models/counter.js' -import ExampleTemplates from '../views/example.js' -import { HTML, HTMLController } from './HTMLController.js' - -@singleton() -@injectable() -@Route('/example') -@Produces('text/html') -@Security('oauth2') -export class RootController extends HTMLController { - constructor( - private counter: Counter, - private templates: ExampleTemplates, - @inject(Logger) private logger: ILogger - ) { - super() - this.logger = logger.child({ controller: '/' }) - } - - /** - * Retrieves the root page for the site - */ - @SuccessResponse(200) - @Get('/') - public async get(): Promise { - this.logger.debug('root page requested') - return this.html(this.templates.Root('TSOA HTMX demo', this.counter.get())) - } - - /** - * Returns a HTML fragment of the root page counter - */ - @SuccessResponse(200) - @Get('/counter') - public async getCounter(): Promise { - this.logger.debug('counter received') - await new Promise((resolve) => setTimeout(resolve, 1000)) - this.triggerEvent('counter-loaded') - return this.html(this.templates.Counter({ count: this.counter.get() })) - } - - /** - * Increments counter and returns a disabled button - */ - @SuccessResponse(200) - @Post('/button') - public async buttonClick(): Promise { - this.logger.debug('click received') - this.counter.increment() - this.triggerEvent('button-click') - return this.html(this.templates.Button({ disabled: true })) - } - - /** - * Returns a HTML fragment of the root page button - */ - @SuccessResponse(200) - @Get('/button') - public async button(): Promise { - this.logger.debug('button received') - return this.html(this.templates.Button({ disabled: false })) - } -} diff --git a/src/models/__tests__/counter.test.ts b/src/models/__tests__/counter.test.ts deleted file mode 100644 index dc6de884..00000000 --- a/src/models/__tests__/counter.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it } from 'mocha' - -import Counter from '../counter' - -describe('Counter', () => { - let expect: Chai.ExpectStatic - before(async () => { - expect = (await import('chai')).expect - }) - - describe('get', () => { - it('should return counter state 0 initially', async () => { - const counter = new Counter() - const result = counter.get() - expect(result).to.equal(0) - }) - - it('should return counter state 1 after incrementing', async () => { - const counter = new Counter() - counter.increment() - const result = counter.get() - expect(result).to.equal(1) - }) - - it('should return counter state 2 after incrementing twice', async () => { - const counter = new Counter() - counter.increment() - counter.increment() - const result = counter.get() - expect(result).to.equal(2) - }) - }) -}) diff --git a/src/models/counter.ts b/src/models/counter.ts deleted file mode 100644 index fff70151..00000000 --- a/src/models/counter.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { singleton } from 'tsyringe' - -@singleton() -export default class Counter { - private counter: number = 0 - - constructor() {} - - get(): number { - return this.counter - } - - increment(): number { - this.counter = this.counter + 1 - return this.counter - } -} diff --git a/src/views/__tests__/connection.test.ts.snap b/src/views/__tests__/connection.test.ts.snap index 2f8763d4..f6363864 100644 --- a/src/views/__tests__/connection.test.ts.snap +++ b/src/views/__tests__/connection.test.ts.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ConnectionTemplates listPage should escape html in name 1`] = `"Veritable - Connections

Connections

<div>I own you</div>
verified_both
"`; +exports[`ConnectionTemplates listPage should escape html in name 1`] = `"Veritable - Connections

Connections

Connections Summary
Add a New Connection
Connections
Company NameVerification StatusActions
<div>I own you</div>
Verified - Established Connection
some action
"`; -exports[`ConnectionTemplates listPage should render multiple with each status 1`] = `"Veritable - Connections

Connections

Company A
disconnected
Company B
pending
Company C
unverified
Company D
verified_both
Company E
verified_them
Company F
verified_us
"`; +exports[`ConnectionTemplates listPage should render multiple with each status 1`] = `"Veritable - Connections

Connections

Connections Summary
Add a New Connection
Connections
Company NameVerification StatusActions
Company A
Disconnected
some action
Company B
'Pending Your Verification'
some action
Company C
Unverified
some action
Company D
Verified - Established Connection
some action
Company E
Pending Your Verification
some action
Company F
Pending Their Verification
some action
"`; -exports[`ConnectionTemplates listPage should render with no connections 1`] = `"Veritable - Connections

Connections

"`; +exports[`ConnectionTemplates listPage should render with no connections 1`] = `"Veritable - Connections

Connections

Connections Summary
Add a New Connection
Connections
Company NameVerification StatusActions
"`; -exports[`ConnectionTemplates listPage should render with single connection 1`] = `"Veritable - Connections

Connections

Company A
disconnected
"`; +exports[`ConnectionTemplates listPage should render with single connection 1`] = `"Veritable - Connections

Connections

Connections Summary
Add a New Connection
Connections
Company NameVerification StatusActions
Company A
Disconnected
some action
"`; diff --git a/src/views/__tests__/example.test.ts b/src/views/__tests__/example.test.ts deleted file mode 100644 index 0044707a..00000000 --- a/src/views/__tests__/example.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, it } from 'mocha' - -import ExampleTemplates from '../example' - -describe('RootTemplates', async () => { - let expect: Chai.ExpectStatic - before(async () => { - expect = (await import('chai')).expect - }) - - describe('Root', () => { - it('should render root page', async () => { - const templates = new ExampleTemplates() - const rendered = await templates.Root('title', 0) - expect(rendered).to.matchSnapshot() - }) - - it('should escape html in title', async () => { - const templates = new ExampleTemplates() - const rendered = await templates.Root('
Malicious Content
', 0) - expect(rendered).to.matchSnapshot() - }) - - it('should render counter 1 on second load', async () => { - const templates = new ExampleTemplates() - const rendered = await templates.Root('title', 1) - expect(rendered).to.matchSnapshot() - }) - }) - - describe('Counter', () => { - it('should render the counter with value 0', async () => { - const templates = new ExampleTemplates() - const rendered = await templates.Counter({ count: 0 }) - expect(rendered).to.matchSnapshot() - }) - - it('should render the counter on second call with value 1', async () => { - const templates = new ExampleTemplates() - const rendered = await templates.Counter({ count: 1 }) - expect(rendered).to.matchSnapshot() - }) - }) - - describe('Button', () => { - it('should render the enabled button', async () => { - const templates = new ExampleTemplates() - const rendered = await templates.Button({ disabled: false }) - expect(rendered).to.matchSnapshot() - }) - - it('should render the disabled button', async () => { - const templates = new ExampleTemplates() - const rendered = await templates.Button({ disabled: true }) - expect(rendered).to.matchSnapshot() - }) - }) -}) diff --git a/src/views/__tests__/example.test.ts.snap b/src/views/__tests__/example.test.ts.snap deleted file mode 100644 index ad6b7d39..00000000 --- a/src/views/__tests__/example.test.ts.snap +++ /dev/null @@ -1,15 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`RootTemplates Button should render the disabled button 1`] = `"
"`; - -exports[`RootTemplates Button should render the enabled button 1`] = `"
"`; - -exports[`RootTemplates Counter should render the counter on second call with value 1 1`] = `"
1
"`; - -exports[`RootTemplates Counter should render the counter with value 0 1`] = `"
0
"`; - -exports[`RootTemplates Root should escape html in title 1`] = `"<div>Malicious Content</div>

example

0
"`; - -exports[`RootTemplates Root should render counter 1 on second load 1`] = `"title

example

1
"`; - -exports[`RootTemplates Root should render root page 1`] = `"title

example

0
"`; diff --git a/src/views/common.tsx b/src/views/common.tsx index b12b511f..7c99ba84 100644 --- a/src/views/common.tsx +++ b/src/views/common.tsx @@ -6,6 +6,20 @@ type PageProps = { url: string } +type ButtonProps = { + name: string + icon?: string + disabled?: boolean + outline?: boolean +} + +export const ButtonIcon = (props: ButtonProps): JSX.Element => ( +
+
+ {props.name || 'unknown'} +
+) + /** * Main menu/Nav * @returns JSX - Sidarbar diff --git a/src/views/connection.tsx b/src/views/connection.tsx index 9ec9c290..b935f3a4 100644 --- a/src/views/connection.tsx +++ b/src/views/connection.tsx @@ -1,25 +1,81 @@ import Html from '@kitajs/html' import { singleton } from 'tsyringe' -import { Page } from './common' +import { ButtonIcon, Page } from './common' + +type ConnectionStatus = 'pending' | 'unverified' | 'verified_them' | 'verified_us' | 'verified_both' | 'disconnected' interface connection { company_name: string - status: 'pending' | 'unverified' | 'verified_them' | 'verified_us' | 'verified_both' | 'disconnected' + status: ConnectionStatus } @singleton() export default class ConnectionTemplates { constructor() {} + private statusToClass = (status: string | ConnectionStatus): JSX.Element => { + switch (status) { + case 'pending': + return
'Pending Your Verification'
+ case 'verified_them': + case 'verified_us': + return ( +
+ {status == 'verified_them' + ? 'Pending Your Verification' + : status == 'verified_us' + ? 'Pending Their Verification' + : 'unknown'} +
+ ) + case 'disconnected': + case 'unverified': + return
{status == 'disconnected' ? 'Disconnected' : 'Unverified'}
+ case 'verified_both': + return
Verified - Established Connection
+ default: + return
unknown
+ } + } + public listPage = (connections: connection[]) => { return ( - {connections.map((connection) => ( -
-
{Html.escapeHtml(connection.company_name)}
-
{connection.status}
+
+
+ Connections Summary + +
+
+ + + + {['Company Name', 'Verification Status', 'Actions'].map((name: string) => ( + + ))} + + {connections.map((connection) => ( + + + + + + ))} +
+ {name || 'unknown'} + +
{Html.escapeHtml(connection.company_name)}{this.statusToClass(connection.status)} + +
- ))} +
) } diff --git a/src/views/example.tsx b/src/views/example.tsx deleted file mode 100644 index 90417373..00000000 --- a/src/views/example.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/// - -import { singleton } from 'tsyringe' -import { Page } from './common' - -@singleton() -export default class ExampleTemplates { - constructor() {} - - public Root = (title: string, count: number) => ( - - - - - ) - - public Counter = ({ count }: { count: number }) => ( -
- {count} - -
- ) - - public Button = ({ disabled }: { disabled: boolean }) => { - const attributes: Htmx.Attributes = disabled - ? { - 'hx-target': 'closest .button-group', - 'hx-trigger': 'counter-loaded from:body', - 'hx-get': '/example/button', - 'hx-swap': 'outerHTML', - } - : { 'hx-target': 'closest .button-group', 'hx-post': '/example/button', 'hx-swap': 'outerHTML' } - - return ( -
- -
- ) - } -}