diff --git a/public/locales/en.json b/public/locales/en.json index 93dc79e..c4262e8 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -278,6 +278,10 @@ "siteTitle": "Settings", "title": "Settings", + "messageSuccess": "Setting changed successfully!", + + "settingIntegerSave": "Save", + "menuLanguage": "Language", "subMenuAutoRefresh": "Auto-refresh", @@ -293,6 +297,7 @@ "showRelativeDates": "Show relative dates instead of absolute ones if recent", "showRelativeDatesDescription": "Everywhere on the site, if a date is less than 7 days ago, it will show as a relative date instead.", "transactionDefaultRaw": "Default to the 'Raw' tab instead of 'CommonMeta' on the transaction page", + "defaultPageSize": "Default page size for table listings", "subMenuDebug": "Debug settings", "advancedWalletFormats": "Advanced wallet formats", diff --git a/src/krist/api/lookup.ts b/src/krist/api/lookup.ts index a1d24db..0fef0df 100644 --- a/src/krist/api/lookup.ts +++ b/src/krist/api/lookup.ts @@ -4,7 +4,7 @@ import { KristAddress, KristTransaction, KristName, KristBlock } from "./types"; import * as api from "."; -import { LookupFilterOptionsBase, LookupResultsBase, getFilterOptionsQuery } from "../../utils/table"; +import { LookupFilterOptionsBase, LookupResponseBase, getFilterOptionsQuery } from "../../utils/table"; // ============================================================================= // Addresses @@ -58,7 +58,7 @@ "time" | "difficulty"; export type LookupBlocksOptions = LookupFilterOptionsBase; -export interface LookupBlocksResponse extends LookupResultsBase { +export interface LookupBlocksResponse extends LookupResponseBase { blocks: KristBlock[]; } @@ -84,7 +84,7 @@ type?: LookupTransactionType; } -export interface LookupTransactionsResponse extends LookupResultsBase { +export interface LookupTransactionsResponse extends LookupResponseBase { transactions: KristTransaction[]; } @@ -120,7 +120,7 @@ | "registered" | "updated" | "a" | "unpaid"; export type LookupNamesOptions = LookupFilterOptionsBase; -export interface LookupNamesResponse extends LookupResultsBase { +export interface LookupNamesResponse extends LookupResponseBase { names: KristName[]; } diff --git a/src/layout/PageLayout.less b/src/layout/PageLayout.less index 11f111d..0231ce6 100644 --- a/src/layout/PageLayout.less +++ b/src/layout/PageLayout.less @@ -29,22 +29,4 @@ padding-top: 0; } } - - &.page-layout-negative-margin { - .page-layout-header.ant-page-header { - // Ensure it's still clickable - z-index: 10; - - // And to ensure the extra content is still clickable, make the header - // smaller. - // TODO: This is a pretty unreliable hack. Is there a way to just get the - // pagination in the header? - display: inline-block; - } - - .page-layout-contents { - // Used to pull table pagination up to the 'extra' area of the header - margin-top: -@kw-page-header-height + 4; - } - } } diff --git a/src/layout/PageLayout.tsx b/src/layout/PageLayout.tsx index 0606d37..3d26a31 100644 --- a/src/layout/PageLayout.tsx +++ b/src/layout/PageLayout.tsx @@ -23,7 +23,6 @@ className?: string; withoutTopPadding?: boolean; - negativeMargin?: boolean; } export const PageLayout: FC = ({ @@ -35,7 +34,6 @@ className, withoutTopPadding, - negativeMargin, children, ...rest }) => { @@ -48,8 +46,7 @@ }, [t, siteTitle, siteTitleKey]); const classes = classNames("page-layout", className, { - "page-layout-no-top-padding": withoutTopPadding, - "page-layout-negative-margin": negativeMargin + "page-layout-no-top-padding": withoutTopPadding }); return
diff --git a/src/pages/blocks/BlocksPage.tsx b/src/pages/blocks/BlocksPage.tsx index a9ffa40..506c585 100644 --- a/src/pages/blocks/BlocksPage.tsx +++ b/src/pages/blocks/BlocksPage.tsx @@ -11,6 +11,7 @@ import { BlocksTable } from "./BlocksTable"; import { useBooleanSetting } from "../../utils/settings"; +import { useLinkedPagination } from "../../utils/table"; interface Props { lowest?: boolean; @@ -19,6 +20,9 @@ export function BlocksPage({ lowest }: Props): JSX.Element { const [error, setError] = useState(); + // Linked pagination from the table + const [paginationComponent, setPagination] = useLinkedPagination(); + // Used to handle memoisation and auto-refreshing const lastBlockID = useSelector((s: RootState) => s.node.lastBlockID); const shouldAutoRefresh = useBooleanSetting("autoRefreshTables"); @@ -33,16 +37,17 @@ refreshingID={usedRefreshID} lowest={lowest} setError={setError} + setPagination={setPagination} /> - ), [usedRefreshID, lowest, setError]); + ), [usedRefreshID, lowest, setError, setPagination]); return {error ? diff --git a/src/pages/blocks/BlocksTable.tsx b/src/pages/blocks/BlocksTable.tsx index 836bcaf..40b6203 100644 --- a/src/pages/blocks/BlocksTable.tsx +++ b/src/pages/blocks/BlocksTable.tsx @@ -2,14 +2,15 @@ // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import React, { useState, useEffect, Dispatch, SetStateAction } from "react"; -import { Table } from "antd"; +import { Table, TablePaginationConfig } from "antd"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { KristBlock } from "../../krist/api/types"; import { lookupBlocks, LookupBlocksOptions, LookupBlocksResponse } from "../../krist/api/lookup"; -import { getTablePaginationSettings, handleLookupTableChange } from "../../utils/table"; +import { useMalleablePagination } from "../../utils/table"; +import { useIntegerSetting } from "../../utils/settings"; import { ContextualAddress } from "../../components/addresses/ContextualAddress"; import { BlockHash } from "./BlockHash"; @@ -23,21 +24,31 @@ // Number used to trigger a refresh of the blocks listing refreshingID?: number; lowest?: boolean; + setError?: Dispatch>; + setPagination?: Dispatch>; } -export function BlocksTable({ refreshingID, lowest, setError }: Props): JSX.Element { +export function BlocksTable({ refreshingID, lowest, setError, setPagination }: Props): JSX.Element { const { t } = useTranslation(); + const defaultPageSize = useIntegerSetting("defaultPageSize"); + const [loading, setLoading] = useState(true); const [res, setRes] = useState(); const [options, setOptions] = useState({ - limit: 20, + limit: defaultPageSize, offset: 0, orderBy: lowest ? "hash" : "height", order: lowest ? "ASC" : "DESC" }); + const { paginationTableProps } = useMalleablePagination( + res, res?.blocks, + "blocks.tableTotal", + options, setOptions, setPagination + ); + // Fetch the blocks from the API, mapping the table options useEffect(() => { debug("looking up blocks"); @@ -59,9 +70,7 @@ dataSource={res?.blocks || []} rowKey="height" - // Triggered whenever the filter, sorting, or pagination changes - onChange={handleLookupTableChange(setOptions)} - pagination={getTablePaginationSettings(t, res, "blocks.tableTotal")} + {...paginationTableProps} columns={[ // Height diff --git a/src/pages/names/NamesPage.tsx b/src/pages/names/NamesPage.tsx index e982e99..f5ba58f 100644 --- a/src/pages/names/NamesPage.tsx +++ b/src/pages/names/NamesPage.tsx @@ -15,6 +15,7 @@ import { useWallets } from "../../krist/wallets/Wallet"; import { useBooleanSetting } from "../../utils/settings"; +import { useLinkedPagination } from "../../utils/table"; import "./NamesPage.less"; @@ -63,6 +64,9 @@ // invalid address), the table will bubble it up to here const [error, setError] = useState(); + // Linked pagination from the table + const [paginationComponent, setPagination] = useLinkedPagination(); + // Used to handle memoisation and auto-refreshing const { joinedAddressList } = useWallets(); const lastNameTransactionID = useSelector((s: RootState) => s.node.lastNameTransactionID); @@ -89,8 +93,9 @@ sortNew={sortNew} addresses={usedAddresses?.split(",")} setError={setError} + setPagination={setPagination} /> - ), [usedAddresses, sortNew, usedRefreshID, setError]); + ), [usedAddresses, sortNew, usedRefreshID, setError, setPagination]); const siteTitle = getSiteTitle(t, listingType, address); const subTitle = listingType === ListingType.NETWORK_ADDRESS @@ -98,14 +103,14 @@ return {error ? ( diff --git a/src/pages/names/NamesTable.tsx b/src/pages/names/NamesTable.tsx index 3688fa8..d68cdfa 100644 --- a/src/pages/names/NamesTable.tsx +++ b/src/pages/names/NamesTable.tsx @@ -2,13 +2,14 @@ // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import React, { useState, useEffect, Dispatch, SetStateAction } from "react"; -import { Table, Tag } from "antd"; +import { Table, TablePaginationConfig, Tag } from "antd"; import { useTranslation } from "react-i18next"; import { KristName } from "../../krist/api/types"; import { lookupNames, LookupNamesOptions, LookupNamesResponse } from "../../krist/api/lookup"; -import { getTablePaginationSettings, handleLookupTableChange } from "../../utils/table"; +import { useMalleablePagination } from "../../utils/table"; +import { useIntegerSetting } from "../../utils/settings"; import { KristNameLink } from "../../components/names/KristNameLink"; import { ContextualAddress } from "../../components/addresses/ContextualAddress"; @@ -27,20 +28,29 @@ addresses?: string[]; setError?: Dispatch>; + setPagination?: Dispatch>; } -export function NamesTable({ refreshingID, sortNew, addresses, setError }: Props): JSX.Element { +export function NamesTable({ refreshingID, sortNew, addresses, setError, setPagination }: Props): JSX.Element { const { t } = useTranslation(); + const defaultPageSize = useIntegerSetting("defaultPageSize"); + const [loading, setLoading] = useState(true); const [res, setRes] = useState(); const [options, setOptions] = useState({ - limit: 20, + limit: defaultPageSize, offset: 0, orderBy: sortNew ? "registered" : "name", order: sortNew ? "DESC" : "ASC" }); + const { paginationTableProps } = useMalleablePagination( + res, res?.names, + "names.tableTotal", + options, setOptions, setPagination + ); + // Fetch the names from the API, mapping the table options useEffect(() => { debug("looking up names for %s", addresses ? addresses.join(",") : "network"); @@ -62,9 +72,7 @@ dataSource={res?.names || []} rowKey="name" - // Triggered whenever the filter, sorting, or pagination changes - onChange={handleLookupTableChange(setOptions)} - pagination={getTablePaginationSettings(t, res, "names.tableTotal")} + {...paginationTableProps} rowClassName={name => name.unpaid > 0 ? "name-row-unpaid" : ""} diff --git a/src/pages/settings/SettingBoolean.tsx b/src/pages/settings/SettingBoolean.tsx index 441de2c..1767deb 100644 --- a/src/pages/settings/SettingBoolean.tsx +++ b/src/pages/settings/SettingBoolean.tsx @@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; import { SettingName, setBooleanSetting, useBooleanSetting } from "../../utils/settings"; +import { SettingDescription } from "./SettingDescription"; interface Props { setting: SettingName; @@ -33,13 +34,16 @@ className="menu-item-setting menu-item-setting-switch" onClick={() => onChange(!settingValue)} > - + + {titleKey ? t(titleKey) : title} - {description || descriptionKey && ( -
- {descriptionKey ? t(descriptionKey) : description} -
- )} +
; } diff --git a/src/pages/settings/SettingDescription.tsx b/src/pages/settings/SettingDescription.tsx new file mode 100644 index 0000000..42cdcc3 --- /dev/null +++ b/src/pages/settings/SettingDescription.tsx @@ -0,0 +1,23 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import React from "react"; + +import { useTranslation } from "react-i18next"; + +interface Props { + description?: string; + descriptionKey?: string; +} + +export function SettingDescription({ description, descriptionKey }: Props): JSX.Element | null { + const { t } = useTranslation(); + + if (!description && !descriptionKey) return null; + + return ( +
+ {descriptionKey ? t(descriptionKey) : description} +
+ ); +} diff --git a/src/pages/settings/SettingInteger.tsx b/src/pages/settings/SettingInteger.tsx new file mode 100644 index 0000000..9f5920c --- /dev/null +++ b/src/pages/settings/SettingInteger.tsx @@ -0,0 +1,63 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import React, { useState } from "react"; +import { Input, InputNumber, Button } from "antd"; + +import { useTranslation } from "react-i18next"; + +import { SettingName, setIntegerSetting, useIntegerSetting, validateIntegerSetting } from "../../utils/settings"; +import { SettingDescription } from "./SettingDescription"; + +interface Props { + setting: SettingName; + title?: string; + titleKey?: string; + description?: string; + descriptionKey?: string; +} + +export function SettingInteger({ + setting, + title, titleKey, + description, descriptionKey +}: Props): JSX.Element { + const settingValue = useIntegerSetting(setting); + const [value, setValue] = useState(settingValue); + + const { t } = useTranslation(); + + const numVal = value ? Number(value) : undefined; + const isValid = numVal !== undefined + && !isNaN(numVal) + && validateIntegerSetting(setting, numVal); + + function onSave() { + if (!isValid) return; + setIntegerSetting(setting, numVal!); + } + + return
+ + {/* Number input */} + + + {/* Save button */} + + + + {titleKey ? t(titleKey) : title} + + +
; +} diff --git a/src/pages/settings/SettingsPage.less b/src/pages/settings/SettingsPage.less index 336dcac..606a5b7 100644 --- a/src/pages/settings/SettingsPage.less +++ b/src/pages/settings/SettingsPage.less @@ -29,4 +29,13 @@ margin-left: @padding-xs; } } + + .menu-item-setting-integer { + .ant-input-group.ant-input-group-compact { + display: inline-block; + width: auto; + margin-right: @margin-sm; + margin-bottom: @padding-xs; + } + } } diff --git a/src/pages/settings/SettingsPage.tsx b/src/pages/settings/SettingsPage.tsx index 6a3240b..877fc4a 100644 --- a/src/pages/settings/SettingsPage.tsx +++ b/src/pages/settings/SettingsPage.tsx @@ -10,6 +10,7 @@ import { PageLayout, PageLayoutProps } from "../../layout/PageLayout"; import { SettingBoolean } from "./SettingBoolean"; +import { SettingInteger } from "./SettingInteger"; import { getLanguageItems } from "./LanguageItem"; import "./SettingsPage.less"; @@ -91,6 +92,11 @@ + + {/* Default page size for table listings */} + + + {/* Debug settings */} diff --git a/src/pages/transactions/TransactionsPage.less b/src/pages/transactions/TransactionsPage.less new file mode 100644 index 0000000..e74407b --- /dev/null +++ b/src/pages/transactions/TransactionsPage.less @@ -0,0 +1,16 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.transactions-page { + .transactions-mined-switch { + display: flex; + align-items: center; + justify-content: flex-end; + + .ant-switch { + margin-right: @margin-sm; + } + } +} diff --git a/src/pages/transactions/TransactionsPage.tsx b/src/pages/transactions/TransactionsPage.tsx index d4720bb..0b27789 100644 --- a/src/pages/transactions/TransactionsPage.tsx +++ b/src/pages/transactions/TransactionsPage.tsx @@ -17,8 +17,11 @@ import { useWallets } from "../../krist/wallets/Wallet"; import { useBooleanSetting } from "../../utils/settings"; +import { useLinkedPagination } from "../../utils/table"; import { KristNameLink } from "../../components/names/KristNameLink"; +import "./TransactionsPage.less"; + /** The type of transaction listing to search by. */ export enum ListingType { /** Transactions involving the user's wallets */ @@ -98,6 +101,9 @@ // invalid address), the table will bubble it up to here const [error, setError] = useState(); + // Linked pagination from the table + const [paginationComponent, setPagination] = useLinkedPagination(); + // Used to handle memoisation and auto-refreshing const { joinedAddressList } = useWallets(); const nodeState = useSelector((s: RootState) => s.node, shallowEqual); @@ -123,9 +129,11 @@ name={name} includeMined={includeMined} + setError={setError} + setPagination={setPagination} /> - ), [listingType, usedAddresses, name, usedRefreshID, includeMined, setError]); + ), [listingType, usedAddresses, name, usedRefreshID, includeMined, setError, setPagination]); const siteTitle = getSiteTitle(t, listingType, address); const subTitle = name @@ -136,11 +144,6 @@ return - - {t("transactions.includeMined")} - } + extra={paginationComponent} > {error ? ( @@ -168,6 +164,17 @@ invalidParameterSubTitleKey="transactions.resultInvalid" /> ) - : memoTable} + : <> + {memoTable} + + {/* "Include mined transactions" switch in the bottom right */} + {!name &&
+ + {t("transactions.includeMined")} +
} + }
; } diff --git a/src/pages/transactions/TransactionsTable.tsx b/src/pages/transactions/TransactionsTable.tsx index 19098fe..3a05500 100644 --- a/src/pages/transactions/TransactionsTable.tsx +++ b/src/pages/transactions/TransactionsTable.tsx @@ -2,14 +2,15 @@ // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import React, { useState, useEffect, Dispatch, SetStateAction } from "react"; -import { Table } from "antd"; +import { Table, TablePaginationConfig } from "antd"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { KristTransaction } from "../../krist/api/types"; import { lookupTransactions, LookupTransactionsOptions, LookupTransactionsResponse, LookupTransactionType } from "../../krist/api/lookup"; -import { getTablePaginationSettings, handleLookupTableChange } from "../../utils/table"; +import { useMalleablePagination } from "../../utils/table"; +import { useIntegerSetting } from "../../utils/settings"; import { ListingType } from "./TransactionsPage"; @@ -43,21 +44,37 @@ name?: string; includeMined?: boolean; + setError?: Dispatch>; + setPagination?: Dispatch>; } -export function TransactionsTable({ listingType, refreshingID, addresses, name, includeMined, setError }: Props): JSX.Element { +export function TransactionsTable({ + listingType, + refreshingID, + addresses, name, + includeMined, + setError, setPagination +}: Props): JSX.Element { const { t } = useTranslation(); + const defaultPageSize = useIntegerSetting("defaultPageSize"); + const [loading, setLoading] = useState(true); const [res, setRes] = useState(); const [options, setOptions] = useState({ - limit: 20, + limit: defaultPageSize, offset: 0, orderBy: "time", // Equivalent to sorting by ID order: "DESC" }); + const { paginationTableProps } = useMalleablePagination( + res, res?.transactions, + "transactions.tableTotal", + options, setOptions, setPagination + ); + // Fetch the transactions from the API, mapping the table options useEffect(() => { debug("looking up transactions (type: %d mapped: %d) for %s", listingType, LISTING_TYPE_MAP[listingType], name || (addresses ? addresses.join(",") : "network")); @@ -83,9 +100,7 @@ dataSource={res?.transactions || []} rowKey="id" - // Triggered whenever the filter, sorting, or pagination changes - onChange={handleLookupTableChange(setOptions)} - pagination={getTablePaginationSettings(t, res, "transactions.tableTotal")} + {...paginationTableProps} columns={[ // ID diff --git a/src/store/actions/SettingsActions.ts b/src/store/actions/SettingsActions.ts index 5b09350..0aa64c4 100644 --- a/src/store/actions/SettingsActions.ts +++ b/src/store/actions/SettingsActions.ts @@ -8,6 +8,7 @@ import { State } from "../reducers/SettingsReducer"; +// Boolean settings export interface SetBooleanSettingPayload { settingName: keyof PickByValue; value: boolean; @@ -15,3 +16,12 @@ export const setBooleanSetting = createAction(constants.SET_BOOLEAN_SETTING, (settingName, value): SetBooleanSettingPayload => ({ settingName, value }))(); + +// Integer settings +export interface SetIntegerSettingPayload { + settingName: keyof PickByValue; + value: number; +} +export const setIntegerSetting = createAction(constants.SET_INTEGER_SETTING, + (settingName, value): SetIntegerSettingPayload => + ({ settingName, value }))(); diff --git a/src/store/constants.ts b/src/store/constants.ts index 7456fd9..cf5a11c 100644 --- a/src/store/constants.ts +++ b/src/store/constants.ts @@ -20,6 +20,7 @@ // Settings // --- export const SET_BOOLEAN_SETTING = "SET_BOOLEAN_SETTING"; +export const SET_INTEGER_SETTING = "SET_INTEGER_SETTING"; // Websockets // --- diff --git a/src/store/reducers/SettingsReducer.ts b/src/store/reducers/SettingsReducer.ts index 5e61b86..dd71006 100644 --- a/src/store/reducers/SettingsReducer.ts +++ b/src/store/reducers/SettingsReducer.ts @@ -1,9 +1,9 @@ // Copyright (c) 2020-2021 Drew Lemmy // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt -import { createReducer, ActionType } from "typesafe-actions"; +import { createReducer } from "typesafe-actions"; import { loadSettings, SettingsState } from "../../utils/settings"; -import { setBooleanSetting } from "../actions/SettingsActions"; +import { setBooleanSetting, setIntegerSetting } from "../actions/SettingsActions"; export type State = SettingsState; @@ -12,7 +12,11 @@ } export const SettingsReducer = createReducer({} as State) - .handleAction(setBooleanSetting, (state: State, action: ActionType) => ({ + .handleAction(setBooleanSetting, (state, action) => ({ + ...state, + [action.payload.settingName]: action.payload.value + })) + .handleAction(setIntegerSetting, (state, action) => ({ ...state, [action.payload.settingName]: action.payload.value })); diff --git a/src/utils/settings.ts b/src/utils/settings.ts index 95f6d24..1267799 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -9,14 +9,23 @@ import { useSelector } from "react-redux"; import { RootState } from "../store"; +import i18n from "./i18n"; +import { message } from "antd"; + import Debug from "debug"; const debug = Debug("kristweb:settings"); export interface SettingsState { + // =========================================================================== + // AUTO-REFRESH SETTINGS + // =========================================================================== /** Whether or not tables (e.g. transactions, names) should auto-refresh * when a change is detected on the network. */ readonly autoRefreshTables: boolean; + // =========================================================================== + // ADVANCED SETTINGS + // =========================================================================== /** Always include mined transactions by default in transaction listings. */ readonly alwaysIncludeMined: boolean; /** Whether or not to include the name suffix when copying a name. */ @@ -31,7 +40,12 @@ readonly showRelativeDates: boolean; /** Default to the 'Raw' tab instead of 'CommonMeta' on the transaction page. */ readonly transactionDefaultRaw: boolean; + /** Default page size for table listings. */ + readonly defaultPageSize: number; + // =========================================================================== + // DEBUG SETTINGS + // =========================================================================== /** Whether or not advanced wallet formats are enabled. */ readonly walletFormats: boolean; } @@ -46,6 +60,7 @@ blockHashCopyButtons: false, showRelativeDates: false, transactionDefaultRaw: false, + defaultPageSize: 15, walletFormats: false }; @@ -53,6 +68,15 @@ export type AnySettingName = keyof SettingsState; export type SettingName = keyof PickByValue; +export interface IntegerSettingConfig { + min?: number; + max?: number; +} + +export const SETTING_CONFIGS: Partial> = { + defaultPageSize: { min: 10, max: 200 } +}; + export const getSettingKey = (settingName: AnySettingName): string => "settings." + settingName; @@ -74,20 +98,43 @@ switch (typeof value) { case "boolean": - settings[settingName] = stored === "true"; + settings[settingName as SettingName] = stored === "true"; + break; + case "number": + settings[settingName as SettingName] = parseInt(stored); break; } - - // TODO: more setting types } return settings; } +export function notifySettingChange(): void { + message.success(i18n.t("settings.messageSuccess")); +} + export function setBooleanSetting(settingName: SettingName, value: boolean): void { - debug("changing setting %s value to %o", settingName, value); + debug("changing setting [boolean] %s value to %o", settingName, value); localStorage.setItem(getSettingKey(settingName), value ? "true" : "false"); store.dispatch(actions.setBooleanSetting(settingName, value)); + notifySettingChange(); +} + +export function setIntegerSetting(settingName: SettingName, value: number): void { + debug("changing setting [integer] %s value to %o", settingName, value); + localStorage.setItem(getSettingKey(settingName), Math.floor(value).toString()); + store.dispatch(actions.setIntegerSetting(settingName, value)); + notifySettingChange(); +} + +export function validateIntegerSetting(settingName: SettingName, value: number): boolean { + const config = SETTING_CONFIGS[settingName]; + if (!config) return true; + + if (config.min !== undefined && value < config.min) return false; + if (config.max !== undefined && value > config.max) return false; + + return true; } export function isValidSyncNode(syncNode?: string): boolean { @@ -104,3 +151,7 @@ /** React hook that gets the value of a boolean setting. */ export const useBooleanSetting = (setting: SettingName): boolean => useSelector((s: RootState) => s.settings[setting]); + +/** React hook that gets the value of an integer setting. */ +export const useIntegerSetting = (setting: SettingName): number => + useSelector((s: RootState) => s.settings[setting]); diff --git a/src/utils/table.ts b/src/utils/table.ts deleted file mode 100644 index 15e5329..0000000 --- a/src/utils/table.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) 2020-2021 Drew Lemmy -// This file is part of KristWeb 2 under GPL-3.0. -// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt -import { Dispatch, SetStateAction } from "react"; -import { TablePaginationConfig } from "antd"; -import { SorterResult } from "antd/lib/table/interface"; - -import { TFunction } from "react-i18next"; - -export interface LookupFilterOptionsBase { - limit?: number; - offset?: number; - orderBy?: FieldsT; - order?: "ASC" | "DESC"; -} - -export interface LookupResultsBase { - count: number; - total: number; -} - -export const handleLookupTableChange = (setOptions: Dispatch>>) => - (pagination: TablePaginationConfig, _: unknown, sorter: SorterResult | SorterResult[]): void => { - const pageSize = (pagination?.pageSize) || 20; - - // This will trigger a data re-fetch - setOptions({ - limit: pageSize, - offset: pageSize * ((pagination?.current || 1) - 1), - - orderBy: sorter instanceof Array ? undefined : sorter.field as FieldsT, - order: sorter instanceof Array ? undefined : convertSorterOrder(sorter.order), - }); - }; - -export const getTablePaginationSettings = (t: TFunction, res: ResultT | undefined, totalKey: string): TablePaginationConfig => ({ - size: "default", - position: ["topRight", "bottomRight"], - - showSizeChanger: true, - defaultPageSize: 20, - - total: res?.total || 0, - showTotal: total => t(totalKey, { count: total || 0 }) -}); - -export function convertSorterOrder(order: "descend" | "ascend" | null | undefined): "ASC" | "DESC" | undefined { - switch (order) { - case "ascend": - return "ASC"; - case "descend": - return "DESC"; - } -} - -export function getFilterOptionsQuery(opts: LookupFilterOptionsBase): URLSearchParams { - const qs = new URLSearchParams(); - if (opts.limit) qs.append("limit", opts.limit.toString()); - if (opts.offset) qs.append("offset", opts.offset.toString()); - if (opts.orderBy) qs.append("orderBy", opts.orderBy); - if (opts.order) qs.append("order", opts.order); - return qs; -} diff --git a/src/utils/table.tsx b/src/utils/table.tsx new file mode 100644 index 0000000..54027a6 --- /dev/null +++ b/src/utils/table.tsx @@ -0,0 +1,173 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import React, { useState, useEffect, useMemo, Dispatch, SetStateAction } from "react"; +import { TablePaginationConfig, TableProps, Pagination } from "antd"; +import { SorterResult } from "antd/lib/table/interface"; +import usePagination from "antd/lib/table/hooks/usePagination"; + +import { useTranslation, TFunction } from "react-i18next"; +import { useIntegerSetting } from "./settings"; + +import Debug from "debug"; +const debug = Debug("kristweb:table"); + +export interface LookupFilterOptionsBase { + limit?: number; + offset?: number; + orderBy?: FieldsT; + order?: "ASC" | "DESC"; +} + +export interface LookupResponseBase { + count: number; + total: number; +} + +export const handleLookupTableChange = ( + defaultPageSize: number, + setOptions: Dispatch>>, + setPaginationPos?: Dispatch> +) => + (pagination: TablePaginationConfig, _: unknown, sorter: SorterResult | SorterResult[]): void => { + if (!pagination?.pageSize) + debug("pagination doesn't have pageSize!", pagination?.pageSize, pagination); + + const pageSize = (pagination?.pageSize) || defaultPageSize; + + // Update any linked pagination elements + if (setPaginationPos && pagination) { + setPaginationPos({ + current: pagination.current, + pageSize: pagination.pageSize + }); + } + + // This will trigger a data re-fetch + setOptions({ + limit: pageSize, + offset: pageSize * ((pagination?.current || 1) - 1), + + orderBy: sorter instanceof Array ? undefined : sorter.field as FieldsT, + order: sorter instanceof Array ? undefined : convertSorterOrder(sorter.order), + }); + }; + +/** De-duplicates, sorts, and returns a list of page size options for table + * pagination, including the user's custom one (if set). */ +export function getPageSizes(defaultPageSize: number): string[] { + // De-duplicate the sizes if a default one is already in here + const sizes = [...new Set([10, 15, 20, 50, 100, defaultPageSize])]; + sizes.sort((a, b) => a - b); + return sizes.map(s => s.toString()); +} + +export const getTablePaginationConfig = ( + t: TFunction, + res: ResponseT | undefined, + totalKey: string, + defaultPageSize: number, + existingConfig?: TablePaginationConfig +): TablePaginationConfig => ({ + ...existingConfig, + + size: "default", + position: ["bottomRight"], + + showSizeChanger: true, + defaultPageSize, + pageSizeOptions: getPageSizes(defaultPageSize), + + total: res?.total || 0, + showTotal: total => t(totalKey, { count: total || 0 }), + }); + +export function useMalleablePagination< + ResultT, + ResponseT extends LookupResponseBase, + FieldsT extends string +>( + res: ResponseT | undefined, + results: ResultT[] | undefined, // Only really used for type inference + totalKey: string, + options: LookupFilterOptionsBase, + setOptions: Dispatch>>, + setPagination?: Dispatch> +): { + paginationTableProps: Pick, "onChange" | "pagination">; +} { + const { t } = useTranslation(); + + const defaultPageSize = useIntegerSetting("defaultPageSize"); + + // All this is done to allow putting the pagination in the page header + const [paginationPos, setPaginationPos] = useState({}); + const paginationConfig = getTablePaginationConfig(t, res, totalKey, defaultPageSize, paginationPos); + const [mergedPagination] = usePagination( + results?.length || 0, + paginationConfig, + (current, pageSize) => { + // Can't use onChange directly here unfortunately + debug("linked pagination called onChange with %d, %d", current, pageSize); + + setPaginationPos({ current, pageSize }); + setOptions({ + ...options, + limit: pageSize, + offset: pageSize * ((current || 1) - 1) + }); + } + ); + + // Update the pagination + useEffect(() => { + if (setPagination) { + const ret: TablePaginationConfig = { ...mergedPagination }; + if (paginationPos?.current) ret.current = paginationPos.current; + if (paginationPos?.pageSize) ret.pageSize = paginationPos.pageSize; + setPagination(ret); + } + }, [res, options, mergedPagination, paginationPos, setPagination]); + + return { + paginationTableProps: { + onChange: handleLookupTableChange(defaultPageSize, setOptions, setPaginationPos), + pagination: paginationConfig + } + }; +} + +export function useLinkedPagination(): [ + JSX.Element, + Dispatch> + ] { + // Used to display the pagination in the page header + const [pagination, setPagination] = useState({}); + + const paginationComponent = useMemo(() => ( + + ), [pagination]); + + return [paginationComponent, setPagination]; +} + +export function convertSorterOrder(order: "descend" | "ascend" | null | undefined): "ASC" | "DESC" | undefined { + switch (order) { + case "ascend": + return "ASC"; + case "descend": + return "DESC"; + } +} + +export function getFilterOptionsQuery(opts: LookupFilterOptionsBase): URLSearchParams { + const qs = new URLSearchParams(); + if (opts.limit) qs.append("limit", opts.limit.toString()); + if (opts.offset) qs.append("offset", opts.offset.toString()); + if (opts.orderBy) qs.append("orderBy", opts.orderBy); + if (opts.order) qs.append("order", opts.order); + return qs; +}