diff --git a/.eslintrc.json b/.eslintrc.json index fca6087..c435bea 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -26,6 +26,7 @@ "FunctionDeclaration": { "parameters": "first" } }], "eol-last": ["error", "always"], + "object-shorthand": ["error", "always"], "tsdoc/syntax": "warn", "react/function-component-definition": ["warn", { "namedComponents": "arrow-function", diff --git a/src/layouts/dialogs/HelpWalletStorageDialog.tsx b/src/layouts/dialogs/HelpWalletStorageDialog.tsx index 7e73edc..4d3420d 100644 --- a/src/layouts/dialogs/HelpWalletStorageDialog.tsx +++ b/src/layouts/dialogs/HelpWalletStorageDialog.tsx @@ -48,7 +48,7 @@ title={t("masterPassword.helpWalletStorageTitle")} > {paragraphs.map((text, i) => -

+

{text}

)} diff --git a/src/layouts/my-wallets/MyWalletsPage.tsx b/src/layouts/my-wallets/MyWalletsPage.tsx index b5b042f..07a9bca 100644 --- a/src/layouts/my-wallets/MyWalletsPage.tsx +++ b/src/layouts/my-wallets/MyWalletsPage.tsx @@ -2,7 +2,11 @@ import { withTranslation, WithTranslation } from "react-i18next"; -import { ListView, HeaderSpec } from "@components/list-view/ListView"; +import { ColumnKey, ColumnSpec, QueryStateBase } from "@components/list-view/DataProvider"; +import { formatKristValue, formatDateTime, formatNumber } from "@components/list-view/Formatters"; +import { ListView } from "@components/list-view/ListView"; + +import { KristValue } from "@components/krist-value/KristValue"; import { IconButton } from "@components/icon-button/IconButton"; import Button from "react-bootstrap/Button"; @@ -11,6 +15,8 @@ import { FilterSelect } from "@components/list-view/FilterSelect"; import { DateString } from "@krist/types/KristTypes"; +import { sleep } from "@utils"; + // TODO: Temporary interface Wallet { label?: string; @@ -21,13 +27,22 @@ firstSeen?: DateString; } -const WALLET_HEADERS = new Map, HeaderSpec>() +const WALLET_COLUMNS = new Map, ColumnSpec>() .set("label", { nameKey: "myWallets.columnLabel" }) .set("address", { nameKey: "myWallets.columnAddress" }) - .set("balance", { nameKey: "myWallets.columnBalance" }) - .set("names", { nameKey: "myWallets.columnNames" }) + .set("balance", { + nameKey: "myWallets.columnBalance", + formatValue: formatKristValue("balance") + }) + .set("names", { + nameKey: "myWallets.columnNames", + formatValue: formatNumber("names") + }) .set("category", { nameKey: "myWallets.columnCategory" }) - .set("firstSeen", { nameKey: "myWallets.columnFirstSeen" }); + .set("firstSeen", { + nameKey: "myWallets.columnFirstSeen", + formatValue: formatDateTime("firstSeen") + }); class MyWalletsPageComponent extends Component { render(): ReactNode { @@ -61,7 +76,39 @@ } /> } - headers={WALLET_HEADERS} + columns={WALLET_COLUMNS} + dataProvider={async (query: QueryStateBase) => { + // Provide the data to the list view + // TODO: temporary + await sleep((Math.random() * 500) + 250); + return { + total: 30, + data: [ + { + label: "Shop Wallet", + address: "kreichdyes", + balance: 15364, + names: 12, + category: "Shops", + firstSeen: new Date().toISOString() + }, + { + label: "Main Wallet", + address: "khugepoopy", + balance: 1024, + names: 3, + firstSeen: new Date().toISOString() + }, + { + label: "Old Wallet", + address: "kre3w0i79j", + balance: 0, + names: 0, + firstSeen: new Date().toISOString() + } + ] + }; + }} />; } } diff --git a/src/shared-components/list-view/ColumnHeader.tsx b/src/shared-components/list-view/ColumnHeader.tsx index e612864..4dcd87b 100644 --- a/src/shared-components/list-view/ColumnHeader.tsx +++ b/src/shared-components/list-view/ColumnHeader.tsx @@ -2,39 +2,36 @@ import { Translation } from "react-i18next"; -import { HeaderSpec, SortDirection } from "./ListView"; +import { ColumnKey, ColumnSpec, SortDirection } from "./DataProvider"; import "./ColumnHeader.scss"; interface Props { - headerKey: Extract; - headerSpec: HeaderSpec; + columnKey: ColumnKey; + columnSpec: ColumnSpec; sortDirection?: SortDirection; - onSort: (headerKey?: Extract, direction?: SortDirection) => void; + onSort: (columnKey?: ColumnKey, direction?: SortDirection) => void; } export class ColumnHeader extends Component> { onClickSort(): void { - const { headerKey, sortDirection: oldDirection, onSort } = this.props; + const { columnKey, sortDirection: oldDirection, onSort } = this.props; if (oldDirection === undefined) // NONE -> ASC - onSort(headerKey, SortDirection.ASC); + onSort(columnKey, SortDirection.ASC); else if (oldDirection === SortDirection.ASC) // ASC -> DESC - onSort(headerKey, SortDirection.DESC); + onSort(columnKey, SortDirection.DESC); else if (oldDirection === SortDirection.DESC) // DESC -> NONE onSort(undefined, undefined); } render(): ReactNode { - const { headerSpec, sortDirection } = this.props; - - // If 'nowrap' is undefined, then assume it is nowrap - const nowrap = headerSpec.nowrap === undefined ? true : headerSpec.nowrap; + const { columnSpec, sortDirection } = this.props; // If 'sortable' is undefined, then assume it is sortable - const sortable = headerSpec.sortable === undefined ? true : headerSpec.sortable; + const sortable = columnSpec.sortable === undefined || columnSpec.sortable; // Decide which sort button to render const sortButton = () => { @@ -51,9 +48,9 @@ }; return {t => - {/* in case the name got truncated */} + {/* in case the name got truncated */}
- {t(headerSpec.nameKey)} + {t(columnSpec.nameKey)} {sortable && sortButton()}
diff --git a/src/shared-components/list-view/DataProvider.tsx b/src/shared-components/list-view/DataProvider.tsx new file mode 100644 index 0000000..02da26c --- /dev/null +++ b/src/shared-components/list-view/DataProvider.tsx @@ -0,0 +1,43 @@ +import { ReactNode } from "react"; + +export enum SortDirection { + ASC, DESC +} + +export interface ColumnSpec { + /** i18n key for column/header name */ + nameKey: string; + sortable?: boolean; + + /** Whether or not a cell's contents should have text wrapping disabled + (default: true) */ + nowrap?: boolean; + skeletonEmWidth?: number; + + /** Optional function to format the value as a ReactNode */ + formatValue?: (key: ColumnKey, value: DataT) => ReactNode; +} + +export type ColumnKey = Extract; +export type Columns = Map, ColumnSpec>; + +// Basic properties defining the search query +export interface QueryStateBase { + orderBy?: ColumnKey; + order?: SortDirection; + + page?: number; +} + +// Basic properties defining the results and the total count available +export interface DataResultBase { + total?: number; + data?: T[]; +} + +// Basic properties defining the state of the results +export interface DataStateBase extends DataResultBase { + loading: boolean; +} + +export type DataProvider = (query: QueryStateBase) => Promise>; diff --git a/src/shared-components/list-view/FilterSelect.tsx b/src/shared-components/list-view/FilterSelect.tsx index cda33dc..47631e6 100644 --- a/src/shared-components/list-view/FilterSelect.tsx +++ b/src/shared-components/list-view/FilterSelect.tsx @@ -16,6 +16,6 @@ {Array.from(props.options, ([formValue, text]) => /* formValue is the key of the select provided to the form, text is the human-readable name for it. */ - )} + )} ); diff --git a/src/shared-components/list-view/Formatters.tsx b/src/shared-components/list-view/Formatters.tsx new file mode 100644 index 0000000..6a59ca2 --- /dev/null +++ b/src/shared-components/list-view/Formatters.tsx @@ -0,0 +1,34 @@ +/* eslint-disable react/display-name */ +import React, { ReactNode } from "react"; + +import { ColumnKey } from "./DataProvider"; +import { KristValue } from "@components/krist-value/KristValue"; + +function verify(key: ColumnKey, formatKey: ColumnKey, value: DataT): T | undefined { + const val = value[formatKey]; + if (key !== formatKey) return; + return val as unknown as T; // I'm sorry +} + +export function formatKristValue(key: ColumnKey): ((formatKey: ColumnKey, value: T) => ReactNode) { + return (formatKey, value) => { + const val = verify(key, formatKey, value); + return val && ; + }; +} + +export function formatNumber(key: ColumnKey): ((formatKey: ColumnKey, value: T) => ReactNode) { + return (formatKey, value) => { + const val = verify(key, formatKey, value); + return val && {val.toLocaleString()}; + }; +} + +export function formatDateTime(key: ColumnKey): ((formatKey: ColumnKey, value: T) => ReactNode) { + return (formatKey, value) => { + const val = verify(key, formatKey, value); + if (!val) return; + const date = new Date(val); + return {date.toLocaleString()}; + }; +} diff --git a/src/shared-components/list-view/ListTable.tsx b/src/shared-components/list-view/ListTable.tsx index 3f9699b..8b21349 100644 --- a/src/shared-components/list-view/ListTable.tsx +++ b/src/shared-components/list-view/ListTable.tsx @@ -1,143 +1,51 @@ import React, { Component, ReactNode } from "react"; -import { HeaderSpec, SortDirection } from "./ListView"; import Table from "react-bootstrap/Table"; +import { Columns, ColumnKey, SortDirection, QueryStateBase, DataStateBase } from "./DataProvider"; import { ColumnHeader } from "./ColumnHeader"; -import { KristValue } from "@components/krist-value/KristValue"; +import { ListTableRow } from "./ListTableRow"; +import { ListTableSkeletonRows } from "./ListTableSkeletonRows"; import "./ListTable.scss"; -import { SkeletonText } from "@components/skeleton/SkeletonText"; -// Default widths for the skeleton elements that look good enough -const SKELETON_EM_WIDTHS = [10, 7, 5, 4, 8, 10]; -const SKELETON_ROWS = 5; +interface Props extends QueryStateBase, DataStateBase { + columns: Columns; -interface State { - orderBy?: Extract; - order?: SortDirection; - - loading: boolean; + setSort: (orderBy?: ColumnKey, order?: SortDirection) => void; } -interface Props { - headers?: Map, HeaderSpec>; -} - -export class ListTable extends Component, State> { - constructor(props: Props) { - super(props); - - this.state = { - orderBy: undefined, - order: undefined, - - loading: true - }; - } - - componentDidMount(): void { - // TODO: temporary - setTimeout(() => { - this.setState({ loading: false }); - }, 6000); - } - - setSort(orderBy?: Extract, order?: SortDirection): void { - this.setState({ - orderBy, order - }); - } - - generateSkeletonSizes(): Array | undefined { - const { headers } = this.props; - const { loading } = this.state; - - // Generate the sizes of skeleton text - const skeletonSizes = headers && loading - ? Array.from(headers, ([, headerSpec]) => - headerSpec.skeletonEmWidth || undefined) // If no width was defined, use undefined - : undefined; - - // Fill any missing sizes with defaults - if (skeletonSizes !== undefined) { - for (let i = 0; i < Math.min(skeletonSizes.length, SKELETON_EM_WIDTHS.length); i++) { - if (skeletonSizes[i] !== undefined) continue; - skeletonSizes[i] = SKELETON_EM_WIDTHS[i]; - } - } - - return skeletonSizes; - } - - generateSkeletonRow(skeletonSizes: Array, rowID: number): Array { - return skeletonSizes.map((width, j) => - ); - } - - generateSkeletonRows(skeletonSizes: Array): Array { - return new Array(SKELETON_ROWS).fill(null).map((_, i) => - {this.generateSkeletonRow(skeletonSizes, i)}); - } - +export class ListTable extends Component> { render(): ReactNode { - const { headers } = this.props; - const { orderBy, order, loading } = this.state; - - // Generate the skeleton rows - const skeletonSizes = this.generateSkeletonSizes(); - const skeletonRows = skeletonSizes ? this.generateSkeletonRows(skeletonSizes) : null; + const { columns, orderBy, order, loading, data, setSort } = this.props; // Render the table return - {/* Table headers, defined by the header map in the props */} - {headers && + {/* Table headers, defined by the column map in the props */} + - {Array.from(headers, ([headerKey, headerSpec]) => + {Array.from(columns, ([columnKey, columnSpec]) => ())} - } + {/* Table rows */} - {/* Render skeleton rows if the table is loading loading */} - {skeletonRows} + {/* Render skeleton rows if the table is loading */} + {loading && } {/* Otherwise, render the data */} - {!loading && <> - - - - - - - - - - - - - - - - - - - - - - - - - - - } + {!loading && data && data.map((row, i) => )}
Shop Walletkreichdyes12Shops2020/09/11 08:08
Main Walletkhugepoopy32016/02/14 00:00
Old Walletkre3w0i79j02015/02/14 00:00
; } diff --git a/src/shared-components/list-view/ListTableRow.tsx b/src/shared-components/list-view/ListTableRow.tsx new file mode 100644 index 0000000..11a6497 --- /dev/null +++ b/src/shared-components/list-view/ListTableRow.tsx @@ -0,0 +1,32 @@ +import React, { Component, ReactNode } from "react"; + +import { Columns } from "./DataProvider"; + +interface Props { + columns: Columns; + data: T; +} + +export class ListTableRow extends Component> { + render(): ReactNode { + const { columns, data } = this.props; + + // Create the row + return + {/* Render each cell */} + {Array.from(columns, ([columnKey, columnSpec]) => { + // If 'nowrap' is undefined, then assume it is nowrap + const nowrap = columnSpec.nowrap === undefined || columnSpec.nowrap; + + // Format the value if the columnSpec has a formatter + const value = columnSpec.formatValue + ? columnSpec.formatValue(columnKey, data) + : data[columnKey]; + + return + {value} + ; + })} + ; + } +} diff --git a/src/shared-components/list-view/ListTableSkeletonRows.tsx b/src/shared-components/list-view/ListTableSkeletonRows.tsx new file mode 100644 index 0000000..273eeb6 --- /dev/null +++ b/src/shared-components/list-view/ListTableSkeletonRows.tsx @@ -0,0 +1,49 @@ +import React, { Component, ReactNode } from "react"; + +import { Columns } from "./DataProvider"; +import { SkeletonText } from "@components/skeleton/SkeletonText"; + +// Default widths for the skeleton elements that look good enough +const SKELETON_EM_WIDTHS = [10, 7, 5, 4, 8, 10]; +const SKELETON_ROWS = 5; + +interface Props { + columns?: Columns; +} + +export class ListTableSkeletonRows extends Component> { + generateSkeletonSizes(): Array | undefined { + const { columns } = this.props; + + // Generate the sizes of skeleton text + const skeletonSizes = columns + ? Array.from(columns, ([, columnSpec]) => + columnSpec.skeletonEmWidth || undefined) // If no width was defined, use undefined + : undefined; + + // Fill any missing sizes with defaults + if (skeletonSizes !== undefined) { + for (let i = 0; i < Math.min(skeletonSizes.length, SKELETON_EM_WIDTHS.length); i++) { + if (skeletonSizes[i] !== undefined) continue; + skeletonSizes[i] = SKELETON_EM_WIDTHS[i]; + } + } + + return skeletonSizes; + } + + generateSkeletonRow(skeletonSizes: Array): Array { + return skeletonSizes.map((width, i) => + ); + } + + generateSkeletonRows(skeletonSizes: Array): Array { + return new Array(SKELETON_ROWS).fill(null).map((_, i) => + {this.generateSkeletonRow(skeletonSizes)}); + } + + render(): ReactNode { + const skeletonSizes = this.generateSkeletonSizes(); + return skeletonSizes ? this.generateSkeletonRows(skeletonSizes) : null; + } +} diff --git a/src/shared-components/list-view/ListView.tsx b/src/shared-components/list-view/ListView.tsx index 37093cf..afbcea3 100644 --- a/src/shared-components/list-view/ListView.tsx +++ b/src/shared-components/list-view/ListView.tsx @@ -4,26 +4,12 @@ import Row from "react-bootstrap/Row"; import Col from "react-bootstrap/Col"; +import { Columns, ColumnKey, SortDirection, DataProvider, QueryStateBase, DataStateBase } from "./DataProvider"; import { ListPagination } from "./ListPagination"; import { ListTable } from "./ListTable"; import "./ListView.scss"; -export enum SortDirection { - ASC, DESC -} - -export interface HeaderSpec { - /** i18n key for column/header name */ - nameKey: string; - sortable?: boolean; - - /* Whether or not a cell's contents should have text wrapping disabled - (default: true) */ - nowrap?: boolean; - skeletonEmWidth?: number; -} - interface Props { title?: string; actions?: ReactNode; @@ -32,15 +18,51 @@ page?: number; pages?: number; - headers?: Map, HeaderSpec>; + columns: Columns; + + dataProvider: DataProvider; } -export class ListView extends Component> { +interface State extends QueryStateBase, DataStateBase {} + +export class ListView extends Component, State> { + constructor(props: Props) { + super(props); + + this.state = { + loading: true + }; + } + + componentDidMount(): void { + this.loadData(); + } + + // Assign the new sort orderBy key and direction to the state and refresh the + // data immediately. + setSort(orderBy?: ColumnKey, order?: SortDirection): void { + this.setState({ + orderBy, order, + loading: true // Reload the data + }, () => this.loadData()); + } + + /** Refresh the data with the latest query parameters */ + async loadData(): Promise { + const data = await this.props.dataProvider(this.state); + + this.setState({ + loading: false, + total: data.total, + data: data.data + }); + } + render(): ReactNode { const { title, actions, filters, page, pages, - headers + columns } = this.props; return @@ -71,7 +93,17 @@ {/* Main table */} - + ; } }