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. */
- {text} )}
+ {text} )}
);
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 && <>
-
- Shop Wallet
- kreichdyes
-
- 12
- Shops
- 2020/09/11 08:08
-
-
-
- Main Wallet
- khugepoopy
-
- 3
-
- 2016/02/14 00:00
-
-
-
- Old Wallet
- kre3w0i79j
-
- 0
-
- 2015/02/14 00:00
-
- >}
+ {!loading && data && data.map((row, i) => )}
;
}
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 */}
-
+
;
}
}