diff --git a/public/locales/en.json b/public/locales/en.json index 15e4455..8941b10 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -733,7 +733,11 @@ "hintCurrentBalance": "Current balance: <1 />", + "errorRecipientRequired": "Recipient is required.", + "errorWalletRequired": "Wallet is required.", + "errorInvalidAddress": "Invalid address or name.", + "errorInvalidAddressOnly": "Invalid address.", "errorInvalidRecipient": "Invalid recipient. Must be an address or name.", "errorInvalidWalletsOnly": "Invalid wallet address.", @@ -742,5 +746,23 @@ "categoryAddressBook": "Address book", "categoryExactAddress": "Exact address", "categoryExactName": "Exact name" + }, + + "sendTransaction": { + "modalTitle": "Send transaction", + "modalSubmit": "Send", + + "buttonSubmit": "Send", + + "labelFrom": "From wallet", + "labelTo": "To address/name", + "labelValue": "Amount", + "labelMetadata": "Metadata", + "placeholderMetadata": "Optional metadata", + + "buttonMax": "Max" + + // TODO: modals/warnings for confirmation of sending over 50% of balance, + // whole balance, and form validation messages } } diff --git a/src/components/addresses/picker/AddressPicker.tsx b/src/components/addresses/picker/AddressPicker.tsx index 5cd7cd9..75c1a9e 100644 --- a/src/components/addresses/picker/AddressPicker.tsx +++ b/src/components/addresses/picker/AddressPicker.tsx @@ -3,7 +3,8 @@ // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import React, { useState, useMemo } from "react"; import classNames from "classnames"; -import { AutoComplete } from "antd"; +import { AutoComplete, Form } from "antd"; +import { Rule } from "antd/lib/form"; import { useTranslation } from "react-i18next"; @@ -11,7 +12,10 @@ import { RootState } from "@store"; import { useWallets } from "@wallets"; -import { isValidAddress, getNameParts } from "@utils/currency"; +import { + isValidAddress, getNameParts, + getNameRegex, getAddressRegexV2 +} from "@utils/currency"; import { getCategoryHeader } from "./Header"; import { getAddressItem } from "./Item"; @@ -20,11 +24,22 @@ import "./AddressPicker.less"; interface Props { + name: string; + label?: string; + walletsOnly?: boolean; + noNames?: boolean; className?: string; } -export function AddressPicker({ walletsOnly, className }: Props): JSX.Element { +export function AddressPicker({ + name, + label, + walletsOnly, + noNames, + className, + ...props +}: Props): JSX.Element { const { t } = useTranslation(); const [value, setValue] = useState(""); @@ -55,8 +70,12 @@ // Check if the input text is an exact name. It may begin with a metaname, but // must end with the name suffix. const nameSuffix = useSelector((s: RootState) => s.node.currency.name_suffix); - const nameParts = !walletsOnly ? getNameParts(nameSuffix, cleanValue) : undefined; - const hasExactName = cleanValue && !walletsOnly && !!nameParts?.name; + const nameParts = !walletsOnly && !noNames + ? getNameParts(nameSuffix, cleanValue) : undefined; + const hasExactName = cleanValue + && !walletsOnly + && !noNames + && !!nameParts?.name; const exactNameItem = hasExactName ? { ...getCategoryHeader(t("addressPicker.categoryExactName")), @@ -77,6 +96,7 @@ const classes = classNames("address-picker", className, { "address-picker-wallets-only": walletsOnly, + "address-picker-no-names": noNames, "address-picker-has-exact-address": hasExactAddress, "address-picker-has-exact-name": hasExactName, }); @@ -89,43 +109,81 @@ // - Name Purchase Page (owner) - Form // - Name Transfer Page (to) - Form // - Mining Page (to) - Possibly a form, can get away with making it one - return { - // Returning false if the option contains children will allow the select - // to run filterOption for each child of that option group. - if (option?.options || !inputValue) return false; - // TODO: Do we want to filter categories here too? + // Address/name regexp + { + pattern: walletsOnly || noNames + ? getAddressRegexV2(addressPrefix) + : getNameRegex(nameSuffix), - const inp = inputValue.toUpperCase(); + message: walletsOnly + ? t("addressPicker.errorInvalidWalletsOnly") + : (noNames + ? t("addressPicker.errorInvalidAddressOnly") + : t("addressPicker.errorInvalidRecipient")) + }, - const address = option!.value; - const walletLabel = option!["data-wallet-label"]; + // If this is walletsOnly, add an additional rule to enforce that the + // given address is a wallet we actually own + ...(walletsOnly ? [{ + type: "enum", + enum: addressList, + message: t("addressPicker.errorInvalidWalletsOnly") + } as Rule] : []) + ]} - const matchedAddress = address.toUpperCase().indexOf(inp) !== -1; - const matchedLabel = walletLabel && walletLabel.toUpperCase().indexOf(inp) !== -1; + {...props} + > + { + // Returning false if the option contains children will allow the select + // to run filterOption for each child of that option group. + if (option?.options || !inputValue) return false; + // TODO: Do we want to filter categories here too? - // TODO: remove this - style={{ minWidth: 300 }} - />; + const inp = inputValue.toUpperCase(); + + const address = option!.value; + const walletLabel = option!["data-wallet-label"]; + + const matchedAddress = address.toUpperCase().indexOf(inp) !== -1; + const matchedLabel = walletLabel + && walletLabel.toUpperCase().indexOf(inp) !== -1; + + return matchedAddress || matchedLabel; + }} + + options={fullOptions} + + onChange={setValue} + value={value} + + {...props} + /> + ; } diff --git a/src/pages/dev/DevPage.tsx b/src/pages/dev/DevPage.tsx index edb3385..8bccdf4 100644 --- a/src/pages/dev/DevPage.tsx +++ b/src/pages/dev/DevPage.tsx @@ -6,8 +6,8 @@ import { PageLayout } from "@layout/PageLayout"; import { ImportBackupModal } from "../backup/ImportBackupModal"; +import { SendTransactionModal } from "../transactions/send/SendTransactionModal"; import { AuthorisedAction } from "@comp/auth/AuthorisedAction"; -import { AddressPicker } from "@comp/addresses/picker/AddressPicker"; import { useWallets, deleteWallet } from "@wallets"; @@ -15,7 +15,8 @@ const debug = Debug("kristweb:dev-page"); export function DevPage(): JSX.Element { - const [modalVisible, setModalVisible] = useState(false); + const [importVisible, setImportVisible] = useState(false); + const [sendTXVisible, setSendTXVisible] = useState(false); const { wallets } = useWallets(); return setModalVisible(true)} + onAuthed={() => setImportVisible(true)} > - + + - - -

- - -

- + {/* Open send tx modal */} + setSendTXVisible(true)}> + + +

diff --git a/src/pages/transactions/send/SendTransactionForm.tsx b/src/pages/transactions/send/SendTransactionForm.tsx new file mode 100644 index 0000000..0c8224b --- /dev/null +++ b/src/pages/transactions/send/SendTransactionForm.tsx @@ -0,0 +1,103 @@ +// 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 { Form, FormInstance } from "antd"; + +import { useTranslation } from "react-i18next"; + +import { useWallets } from "@wallets"; + +import { AddressPicker } from "@comp/addresses/picker/AddressPicker"; + +interface FormValues { + from: string; + to: string; + value: number; + metadata?: string; +} + +interface Props { + form: FormInstance; + triggerSubmit: () => Promise; +} + +function SendTransactionForm({ + form, + triggerSubmit +}: Props): JSX.Element { + const { t } = useTranslation(); + + // Used to get the initial wallet to show for the 'from' field + // TODO: Remember this value? + const { addressList } = useWallets(); + const initialFrom = addressList[0] || ""; + // TODO: initialFrom here should never be an empty string, so need to add a + // modal that says "You currently don't have any saved wallets" etc, + // and prevents opening the sendTX modal/rendering the page + + return
+ {/* From */} + + + {/* To */} + + ; +} + +interface TransactionFormHookResponse { + form: FormInstance; + triggerSubmit: () => Promise; + isSubmitting: boolean; + txForm: JSX.Element; +} + +export function useTransactionForm(): TransactionFormHookResponse { + const [form] = Form.useForm(); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function onSubmit() { + setIsSubmitting(true); + + setTimeout(() => setIsSubmitting(false), 1000); + } + + // Create the transaction form instance here to be rendered by the caller + const txForm = ; + + return { + form, + triggerSubmit: onSubmit, + isSubmitting, + txForm + }; +} diff --git a/src/pages/transactions/send/SendTransactionModal.tsx b/src/pages/transactions/send/SendTransactionModal.tsx new file mode 100644 index 0000000..cd53d1a --- /dev/null +++ b/src/pages/transactions/send/SendTransactionModal.tsx @@ -0,0 +1,42 @@ +// 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, { Dispatch, SetStateAction } from "react"; +import { Modal } from "antd"; + +import { useTranslation } from "react-i18next"; + +import { useTransactionForm } from "./SendTransactionForm"; + +interface Props { + visible: boolean; + setVisible: Dispatch>; +} + +export function SendTransactionModal({ + visible, setVisible +}: Props): JSX.Element { + const { t } = useTranslation(); + const { form, isSubmitting, triggerSubmit, txForm } = useTransactionForm(); + + function closeModal() { + form.resetFields(); + setVisible(false); + } + + return + {txForm} + ; +} diff --git a/src/style/theme.less b/src/style/theme.less index ec9368f..e3fa9a8 100644 --- a/src/style/theme.less +++ b/src/style/theme.less @@ -108,6 +108,9 @@ @card-background: mix(@kw-dark, @kw-slighter, 75%); @kw-big-card-border-radius: 4px; +// Form required mark +@label-required-color: @kw-red; + // general theme // --- @kw-sidebar-bg: @kw-darker;