From 6aafbcc9d6ac176702eafb0951abbc33c08669a1 Mon Sep 17 00:00:00 2001 From: "Marvin A. Ruder" Date: Sat, 18 May 2024 13:50:24 +0000 Subject: [PATCH] Improve form validation * Use `checkValidity()` * Show error messages in helper text with `onInvalid` callback * Use `number` input type and override default browser styling * Replace event target value modifiers with validation constraints * Remove `onMouseOver` callbacks Signed-off-by: Marvin A. Ruder --- .../autocomplete/CountryAutocomplete.tsx | 28 +++- .../autocomplete/CurrencyAutocomplete.tsx | 28 +++- .../dialogs/portfolio/AddPortfolio.tsx | 45 ++++-- .../dialogs/portfolio/AddStockToPortfolio.tsx | 43 ++++-- .../dialogs/portfolio/EditPortfolio.tsx | 42 ++++-- .../src/components/dialogs/stock/AddStock.tsx | 86 +++++++---- .../dialogs/stock/AddStockToCollection.tsx | 27 ++-- .../components/dialogs/stock/EditStock.tsx | 86 ++++++++--- .../dialogs/watchlist/AddWatchlist.tsx | 26 ++-- .../dialogs/watchlist/RenameWatchlist.tsx | 26 ++-- .../components/stock/layouts/StockDetails.tsx | 2 +- .../src/components/stock/layouts/StockRow.tsx | 46 +++--- .../stock/properties/AnalystRatingBar.tsx | 6 +- .../content/modules/Portfolio/Portfolio.tsx | 2 +- .../PortfolioBuilder/PortfolioBuilder.tsx | 139 +++++++++++------- packages/frontend/src/content/pages/Login.tsx | 35 +++-- .../Header/Userbox/ProfileSettings.tsx | 50 ++++--- packages/frontend/src/theme/ThemeProvider.tsx | 18 ++- packages/frontend/src/theme/scheme.ts | 26 ++++ .../src/utils/portfolioComputation.test.ts | 20 +++ .../src/utils/portfolioComputation.ts | 3 +- 21 files changed, 539 insertions(+), 245 deletions(-) diff --git a/packages/frontend/src/components/autocomplete/CountryAutocomplete.tsx b/packages/frontend/src/components/autocomplete/CountryAutocomplete.tsx index 996b90295..7eda7b27d 100644 --- a/packages/frontend/src/components/autocomplete/CountryAutocomplete.tsx +++ b/packages/frontend/src/components/autocomplete/CountryAutocomplete.tsx @@ -21,6 +21,7 @@ const CountryAutocomplete = (props: CountryAutocompleteProps): JSX.Element => { multiple={false} value={props.value} onChange={props.onChange} + onInvalid={props.onInvalid} filterOptions={(options) => { const currentInputValue = countryInputValue.trim().toUpperCase(); // Filter the country names by the input value. @@ -36,7 +37,16 @@ const CountryAutocomplete = (props: CountryAutocompleteProps): JSX.Element => { }} disableClearable selectOnFocus - renderInput={(params) => } + renderInput={(params) => ( + + )} /> ); }; @@ -50,10 +60,26 @@ interface CountryAutocompleteProps { * The change handler of the autocomplete. */ onChange: (event: React.SyntheticEvent, value: Country) => void; + /** + * The invalid handler of the Autocomplete component. + */ + onInvalid?: (event: React.SyntheticEvent) => void; /** * Whether the input value is invalid. */ error: boolean; + /** + * The helper text of the input element. + */ + helperText: string; + /** + * The ref of the input element. + */ + inputRef?: React.RefObject; + /** + * Whether the field is required. + */ + required?: boolean; } export default CountryAutocomplete; diff --git a/packages/frontend/src/components/autocomplete/CurrencyAutocomplete.tsx b/packages/frontend/src/components/autocomplete/CurrencyAutocomplete.tsx index 76092e4cf..02db77910 100644 --- a/packages/frontend/src/components/autocomplete/CurrencyAutocomplete.tsx +++ b/packages/frontend/src/components/autocomplete/CurrencyAutocomplete.tsx @@ -27,6 +27,7 @@ const CurrencyAutocomplete = (props: CurrencyAutocompleteProps): JSX.Element => multiple={false} value={props.value} onChange={props.onChange} + onInvalid={props.onInvalid} filterOptions={(options) => { const currentInputValue = currencyInputValue.trim().toUpperCase(); // Filter the currency names by the input value. @@ -42,7 +43,16 @@ const CurrencyAutocomplete = (props: CurrencyAutocompleteProps): JSX.Element => }} disableClearable selectOnFocus - renderInput={(params) => } + renderInput={(params) => ( + + )} /> ); }; @@ -56,10 +66,26 @@ interface CurrencyAutocompleteProps { * The change handler of the autocomplete. */ onChange: (event: React.SyntheticEvent, value: Currency) => void; + /** + * The invalid handler of the Autocomplete component. + */ + onInvalid?: (event: React.SyntheticEvent) => void; /** * Whether the input value is invalid. */ error: boolean; + /** + * The helper text of the input element. + */ + helperText: string; + /** + * The ref of the input element. + */ + inputRef?: React.RefObject; + /** + * Whether the field is required. + */ + required?: boolean; } export default CurrencyAutocomplete; diff --git a/packages/frontend/src/components/dialogs/portfolio/AddPortfolio.tsx b/packages/frontend/src/components/dialogs/portfolio/AddPortfolio.tsx index 5225b55bd..e425803ae 100644 --- a/packages/frontend/src/components/dialogs/portfolio/AddPortfolio.tsx +++ b/packages/frontend/src/components/dialogs/portfolio/AddPortfolio.tsx @@ -3,7 +3,7 @@ import LoadingButton from "@mui/lab/LoadingButton"; import { DialogTitle, Typography, DialogContent, Grid, TextField, DialogActions, Button } from "@mui/material"; import type { Currency } from "@rating-tracker/commons"; import { isCurrency, portfoliosAPIPath } from "@rating-tracker/commons"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useNotificationContextUpdater } from "../../../contexts/NotificationContext"; import api from "../../../utils/api"; @@ -18,19 +18,21 @@ export const AddPortfolio = (props: AddPortfolioProps): JSX.Element => { const [requestInProgress, setRequestInProgress] = useState(false); const [name, setName] = useState(""); const [currency, setCurrency] = useState(); - const [nameError, setNameError] = useState(false); // Error in the name text field. - const [currencyError, setCurrencyError] = useState(false); // Error in the currency input field. + const [nameError, setNameError] = useState(""); // Error message for the name text field. + const [currencyError, setCurrencyError] = useState(""); // Error message for the currency input field. const { setErrorNotificationOrClearSession } = useNotificationContextUpdater(); + const nameInputRef = useRef(null); + const currencyInputRef = useRef(null); + /** * Checks for errors in the input fields. * @returns Whether the input fields are valid. */ const validate = (): boolean => { - // The following fields are required. - setNameError(!name); - setCurrencyError(!currency); - return !!name && !!currency; + const isNameValid = nameInputRef.current?.checkValidity(); + const isCurrencyValid = currencyInputRef.current?.checkValidity(); + return isNameValid && isCurrencyValid; }; /** @@ -55,8 +57,16 @@ export const AddPortfolio = (props: AddPortfolioProps): JSX.Element => { (setName(event.target.value), setNameError(false))} - error={nameError} + onChange={(event) => { + setName(event.target.value); + // If in error state, check whether error is resolved. If so, clear the error. + if (nameError && event.target.checkValidity()) setNameError(""); + }} + onInvalid={(event) => setNameError((event.target as HTMLInputElement).validationMessage)} + error={!!nameError} + helperText={nameError} + inputRef={nameInputRef} + required label="Portfolio name" value={name} placeholder="e.g. Monthly Savings" @@ -66,8 +76,18 @@ export const AddPortfolio = (props: AddPortfolioProps): JSX.Element => { isCurrency(value) && (setCurrency(value), setCurrencyError(false))} - error={currencyError} + onChange={(_, value) => { + if (isCurrency(value)) { + setCurrency(value); + // If in error state, check whether error is resolved. If so, clear the error. + if (currencyError && currencyInputRef.current?.checkValidity()) setCurrencyError(""); + } + }} + onInvalid={(event) => setCurrencyError((event.target as HTMLInputElement).validationMessage)} + error={!!currencyError} + helperText={currencyError} + inputRef={currencyInputRef} + required /> @@ -80,8 +100,7 @@ export const AddPortfolio = (props: AddPortfolioProps): JSX.Element => { loading={requestInProgress} variant="contained" onClick={putPortfolio} - onMouseOver={validate} // Validate input fields on hover - disabled={nameError || currencyError} + disabled={!!nameError || !!currencyError} startIcon={} > Create Portfolio diff --git a/packages/frontend/src/components/dialogs/portfolio/AddStockToPortfolio.tsx b/packages/frontend/src/components/dialogs/portfolio/AddStockToPortfolio.tsx index ba2ca268e..5a0c16a36 100644 --- a/packages/frontend/src/components/dialogs/portfolio/AddStockToPortfolio.tsx +++ b/packages/frontend/src/components/dialogs/portfolio/AddStockToPortfolio.tsx @@ -19,7 +19,7 @@ import { } from "@mui/material"; import type { Stock, PortfolioSummary, Currency } from "@rating-tracker/commons"; import { stocksAPIPath, portfoliosAPIPath, currencyMinorUnits } from "@rating-tracker/commons"; -import { Fragment, useEffect, useState } from "react"; +import { Fragment, useEffect, useRef, useState } from "react"; import { useNotificationContextUpdater } from "../../../contexts/NotificationContext"; import api from "../../../utils/api"; @@ -36,23 +36,32 @@ export const AddStockToPortfolio = (props: AddStockToPortfolioProps): JSX.Elemen const [portfolioSummaries, setPortfolioSummaries] = useState([]); const [portfolioSummariesFinal, setPortfolioSummariesFinal] = useState(false); const [amountInput, setAmountInput] = useState(""); - const [amountError, setAmountError] = useState(false); + const [amountError, setAmountError] = useState(""); // Error message for the amount text field. const [addPortfolioOpen, setAddPortfolioOpen] = useState(false); const [hoverCurrency, setHoverCurrency] = useState("…"); const { setErrorNotificationOrClearSession } = useNotificationContextUpdater(); + const amountInputRef = useRef(null); + const theme = useTheme(); useEffect(() => getPortfolios(), []); /** * Checks for errors in the input fields. + * @param id The ID of the portfolio. * @returns Whether the input fields are valid. */ - const validate = (): boolean => { - // The following fields are required. - setAmountError(!amountInput || Number.isNaN(+amountInput) || +amountInput <= 0); - return !!amountInput && !Number.isNaN(+amountInput) && +amountInput > 0; + const validate = (id: number): boolean => { + // For currency minor unit validation, we need to set the minimum value of the input field after the touchend event + const currency = portfolioSummaries.find((portfolio) => portfolio.id === id)?.currency; + if (!currency) return false; + if (!amountInputRef?.current) return false; + amountInputRef.current.min = Math.pow(10, -1 * currencyMinorUnits[currency]).toString(); + amountInputRef.current.step = Math.pow(10, -1 * currencyMinorUnits[currency]).toString(); + + const isAmountValid = amountInputRef.current.checkValidity(); + return isAmountValid; }; /** @@ -78,7 +87,7 @@ export const AddStockToPortfolio = (props: AddStockToPortfolioProps): JSX.Elemen * @param id The ID of the portfolio. */ const addStockToPortfolio = (id: number) => { - if (!validate()) return; + if (!validate(id)) return; api .put(`${portfoliosAPIPath}/${id}${stocksAPIPath}/${props.stock.ticker}`, { params: { amount: +amountInput }, @@ -105,14 +114,21 @@ export const AddStockToPortfolio = (props: AddStockToPortfolioProps): JSX.Elemen }} inputProps={{ inputMode: "decimal", - pattern: "\\d+(\\.\\d+)?", - step: Math.pow(10, -1 * currencyMinorUnits[hoverCurrency]) || undefined, + type: "number", + // Amount must be divisible by the currency's minor unit + step: Math.pow(10, -1 * currencyMinorUnits[hoverCurrency]), + min: Math.pow(10, -1 * currencyMinorUnits[hoverCurrency]), // Amount must be positive }} onChange={(event) => { - setAmountInput(event.target.value.replaceAll(/[^0-9.]/g, "")); - setAmountError(false); + setAmountInput(event.target.value); + // If in error state, check whether error is resolved. If so, clear the error. + if (amountError && event.target.checkValidity()) setAmountError(""); }} - error={amountError} + onInvalid={(event) => setAmountError((event.target as HTMLInputElement).validationMessage)} + error={!!amountError} + helperText={amountError} + inputRef={amountInputRef} + required label="Amount" value={amountInput} autoFocus @@ -136,8 +152,7 @@ export const AddStockToPortfolio = (props: AddStockToPortfolioProps): JSX.Elemen > addStockToPortfolio(portfolioSummary.id)} - onMouseOver={validate} - disabled={portfoliosAlreadyContainingStock.includes(portfolioSummary.id) || amountError} + disabled={portfoliosAlreadyContainingStock.includes(portfolioSummary.id) || !!amountError} > { const [requestInProgress, setRequestInProgress] = useState(false); const [name, setName] = useState(props.portfolio?.name); const [currency, setCurrency] = useState(props.portfolio?.currency); - const [nameError, setNameError] = useState(false); // Error in the name text field. - const [currencyError, setCurrencyError] = useState(false); // Error in the currency input field. + const [nameError, setNameError] = useState(""); // Error message for the name text field. + const [currencyError, setCurrencyError] = useState(""); // Error message for the currency input field. const { setErrorNotificationOrClearSession } = useNotificationContextUpdater(); + const nameInputRef = useRef(null); + const currencyInputRef = useRef(null); + /** * Checks for errors in the input fields. * @returns Whether the input fields are valid. */ const validate = (): boolean => { - // The following fields are required. - setNameError(!name); - setCurrencyError(!currency); - return !!name && !!currency; + const isNameValid = nameInputRef.current?.checkValidity(); + const isCurrencyValid = currencyInputRef.current?.checkValidity(); + return isNameValid && isCurrencyValid; }; /** @@ -63,9 +65,14 @@ export const EditPortfolio = (props: EditPortfolioProps): JSX.Element => { { setName(event.target.value); - setNameError(false); + // If in error state, check whether error is resolved. If so, clear the error. + if (nameError && event.target.checkValidity()) setNameError(""); }} - error={nameError} + onInvalid={(event) => setNameError((event.target as HTMLInputElement).validationMessage)} + error={!!nameError} + helperText={nameError} + inputRef={nameInputRef} + required label="Portfolio name" value={name} placeholder="e.g. Monthly Savings" @@ -75,8 +82,18 @@ export const EditPortfolio = (props: EditPortfolioProps): JSX.Element => { isCurrency(value) && (setCurrency(value), setCurrencyError(false))} - error={currencyError} + onChange={(_, value) => { + if (isCurrency(value)) { + setCurrency(value); + // If in error state, check whether error is resolved. If so, clear the error. + if (currencyError && currencyInputRef.current?.checkValidity()) setCurrencyError(""); + } + }} + onInvalid={(event) => setCurrencyError((event.target as HTMLInputElement).validationMessage)} + error={!!currencyError} + helperText={currencyError} + inputRef={currencyInputRef} + required /> @@ -89,8 +106,7 @@ export const EditPortfolio = (props: EditPortfolioProps): JSX.Element => { loading={requestInProgress} variant="contained" onClick={updatePortfolio} - onMouseOver={validate} // Validate input fields on hover - disabled={nameError || currencyError} + disabled={!!nameError || !!currencyError} startIcon={} > Update Portfolio diff --git a/packages/frontend/src/components/dialogs/stock/AddStock.tsx b/packages/frontend/src/components/dialogs/stock/AddStock.tsx index 43b6ccb27..c6c00bdb9 100644 --- a/packages/frontend/src/components/dialogs/stock/AddStock.tsx +++ b/packages/frontend/src/components/dialogs/stock/AddStock.tsx @@ -32,7 +32,7 @@ import { stocksAPIPath, fetchAPIPath, } from "@rating-tracker/commons"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useNotificationContextUpdater } from "../../../contexts/NotificationContext"; import api from "../../../utils/api"; @@ -56,10 +56,10 @@ export const AddStock = (props: AddStockProps): JSX.Element => { }); const [finalStock, setFinalStock] = useState(); const [requestInProgress, setRequestInProgress] = useState(false); - const [tickerError, setTickerError] = useState(false); // Error in the ticker text field. - const [nameError, setNameError] = useState(false); // Error in the name text field. - const [isinError, setIsinError] = useState(false); // Error in the ISIN text field. - const [countryError, setCountryError] = useState(false); // Error in the country input field. + const [nameError, setNameError] = useState(""); // Error message for the name text field. + const [tickerError, setTickerError] = useState(""); // Error message for the ticker text field. + const [isinError, setISINError] = useState(""); // Error message for the ISIN text field. + const [countryError, setCountryError] = useState(""); // Error message for the country input field. const [morningstarIDRequestInProgress, setMorningstarIDRequestInProgress] = useState(false); // Whether the Morningstar ID has been transmitted to the server. const [morningstarIDSet, setMorningstarIDSet] = useState(false); @@ -78,17 +78,21 @@ export const AddStock = (props: AddStockProps): JSX.Element => { const { setNotification, setErrorNotificationOrClearSession } = useNotificationContextUpdater(); + const nameInputRef = useRef(null); + const tickerInputRef = useRef(null); + const isinInputRef = useRef(null); + const countryInputRef = useRef(null); + /** * Checks for errors in the input fields. * @returns Whether the input fields are valid. */ const validate = (): boolean => { - // The following fields are required. - setTickerError(!stock.ticker); - setNameError(!stock.name); - setIsinError(!stock.isin); - setCountryError(!stock.country); - return !!stock.ticker && !!stock.name && !!stock.isin && !!stock.country; + const isNameValid = nameInputRef.current?.checkValidity(); + const isTickerValid = tickerInputRef.current?.checkValidity(); + const isISINValid = isinInputRef.current?.checkValidity(); + const isCountryValid = countryInputRef.current?.checkValidity(); + return isNameValid && isTickerValid && isISINValid && isCountryValid; }; /** @@ -327,7 +331,7 @@ export const AddStock = (props: AddStockProps): JSX.Element => { Let’s start by adding some basic information: - + { @@ -345,10 +349,10 @@ export const AddStock = (props: AddStockProps): JSX.Element => { isin: isin, country: country, })); - setTickerError(false); - setNameError(false); - setIsinError(false); - setCountryError(false); + setTickerError(""); + setNameError(""); + setISINError(""); + setCountryError(""); } }} /> @@ -362,9 +366,14 @@ export const AddStock = (props: AddStockProps): JSX.Element => { { setStock((prevStock) => ({ ...prevStock, name: event.target.value })); - setNameError(false); + // If in error state, check whether error is resolved. If so, clear the error. + if (nameError && event.target.checkValidity()) setNameError(""); }} - error={nameError} + onInvalid={(event) => setNameError((event.target as HTMLInputElement).validationMessage)} + error={!!nameError} + helperText={nameError} + inputRef={nameInputRef} + required label="Stock name" value={stock.name} placeholder="e.g. Apple Inc." @@ -375,9 +384,14 @@ export const AddStock = (props: AddStockProps): JSX.Element => { { setStock((prevStock) => ({ ...prevStock, ticker: event.target.value })); - setTickerError(false); + // If in error state, check whether error is resolved. If so, clear the error. + if (tickerError && event.target.checkValidity()) setTickerError(""); }} - error={tickerError} + onInvalid={(event) => setTickerError((event.target as HTMLInputElement).validationMessage)} + error={!!tickerError} + helperText={tickerError} + inputRef={tickerInputRef} + required label="Ticker symbol" value={stock.ticker} placeholder="e.g. AAPL" @@ -386,6 +400,7 @@ export const AddStock = (props: AddStockProps): JSX.Element => { { setStock((prevStock) => ({ ...prevStock, isin: event.target.value })); if (!stock.country && event.target.value.length >= 2) { @@ -394,12 +409,17 @@ export const AddStock = (props: AddStockProps): JSX.Element => { if (isCountry(possibleCountry)) { // If the extracted country is valid, we set it as the stock’s country. setStock((prevStock) => ({ ...prevStock, country: possibleCountry })); - setCountryError(false); + setCountryError(""); } } - setIsinError(false); + // If in error state, check whether error is resolved. If so, clear the error. + if (isinError && event.target.checkValidity()) setISINError(""); }} - error={isinError} + onInvalid={(event) => setISINError((event.target as HTMLInputElement).validationMessage)} + error={!!isinError} + helperText={isinError} + inputRef={isinInputRef} + required label="ISIN" value={stock.isin} placeholder="e.g. US0378331005" @@ -409,11 +429,18 @@ export const AddStock = (props: AddStockProps): JSX.Element => { - isCountry(value) && - (setStock((prevStock) => ({ ...prevStock, country: value })), setCountryError(false)) - } - error={countryError} + onChange={(_, value) => { + if (isCountry(value)) { + setStock((prevStock) => ({ ...prevStock, country: value })); + // If in error state, check whether error is resolved. If so, clear the error. + if (countryError && countryInputRef.current?.checkValidity()) setCountryError(""); + } + }} + onInvalid={(event) => setCountryError((event.target as HTMLInputElement).validationMessage)} + error={!!countryError} + helperText={countryError} + inputRef={countryInputRef} + required /> @@ -424,8 +451,7 @@ export const AddStock = (props: AddStockProps): JSX.Element => { loading={requestInProgress} variant="contained" onClick={putStock} - onMouseOver={validate} // Validate input fields on hover - disabled={tickerError || nameError || countryError} + disabled={!!nameError || !!tickerError || !!isinError || !!countryError} startIcon={} > Create Stock diff --git a/packages/frontend/src/components/dialogs/stock/AddStockToCollection.tsx b/packages/frontend/src/components/dialogs/stock/AddStockToCollection.tsx index 0727b16ca..778eb6303 100644 --- a/packages/frontend/src/components/dialogs/stock/AddStockToCollection.tsx +++ b/packages/frontend/src/components/dialogs/stock/AddStockToCollection.tsx @@ -9,7 +9,7 @@ import { currencyMinorUnits, FAVORITES_NAME, } from "@rating-tracker/commons"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useFavoritesContextUpdater } from "../../../contexts/FavoritesContext"; import { useNotificationContextUpdater } from "../../../contexts/NotificationContext"; @@ -24,10 +24,12 @@ import SelectStock from "./SelectStock"; */ const AddStockToCollection = (props: AddStockToCollectionProps): JSX.Element => { const [amountInput, setAmountInput] = useState(""); - const [amountError, setAmountError] = useState(false); + const [amountError, setAmountError] = useState(""); // Error message for the amount text field. const { setErrorNotificationOrClearSession } = useNotificationContextUpdater(); const { refetchFavorites } = useFavoritesContextUpdater(); + const amountInputRef = useRef(null); + const isPortfolio = "currency" in props.collection; const isWatchlist = "subscribed" in props.collection; const collectionLabel = isPortfolio ? "Portfolio" : "Watchlist"; @@ -39,10 +41,8 @@ const AddStockToCollection = (props: AddStockToCollectionProps): JSX.Element => */ const validate = (): boolean => { if (isPortfolio) { - // The following fields are required. - console.log(amountInput); - setAmountError(!amountInput || Number.isNaN(+amountInput) || +amountInput <= 0); - return !!amountInput && !Number.isNaN(+amountInput) && +amountInput > 0; + const isAmountValid = amountInputRef.current?.checkValidity(); + return isAmountValid; } return true; }; @@ -86,14 +86,21 @@ const AddStockToCollection = (props: AddStockToCollectionProps): JSX.Element => }} inputProps={{ inputMode: "decimal", - pattern: "\\d+(\\.\\d+)?", + type: "number", + // Amount must be divisible by the currency's minor unit step: Math.pow(10, -1 * currencyMinorUnits[props.collection.currency]), + min: Math.pow(10, -1 * currencyMinorUnits[props.collection.currency]), // Amount must be positive }} onChange={(event) => { - setAmountInput(event.target.value.replaceAll(/[^0-9.]/g, "")); - setAmountError(false); + setAmountInput(event.target.value); + // If in error state, check whether error is resolved. If so, clear the error. + if (amountError && event.target.checkValidity()) setAmountError(""); }} - error={amountError} + onInvalid={(event) => setAmountError((event.target as HTMLInputElement).validationMessage)} + error={!!amountError} + helperText={amountError} + inputRef={amountInputRef} + required label="Amount" value={amountInput} autoFocus diff --git a/packages/frontend/src/components/dialogs/stock/EditStock.tsx b/packages/frontend/src/components/dialogs/stock/EditStock.tsx index f69f0e67a..f4e6d33d7 100644 --- a/packages/frontend/src/components/dialogs/stock/EditStock.tsx +++ b/packages/frontend/src/components/dialogs/stock/EditStock.tsx @@ -26,7 +26,7 @@ import { SP_PREMIUM_STOCK_ERROR_MESSAGE, fetchAPIPath, } from "@rating-tracker/commons"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useNotificationContextUpdater } from "../../../contexts/NotificationContext"; import api from "../../../utils/api"; @@ -40,14 +40,14 @@ import CountryAutocomplete from "../../autocomplete/CountryAutocomplete"; export const EditStock = (props: EditStockProps): JSX.Element => { const [requestInProgress, setRequestInProgress] = useState(false); const [unsafeRequestSent, setUnsafeRequestSent] = useState(false); // Whether an unsafe request was sent. - const [ticker, setTicker] = useState(props.stock.ticker); - const [tickerError, setTickerError] = useState(false); // Error in the ticker text field. const [name, setName] = useState(props.stock.name); - const [nameError, setNameError] = useState(false); // Error in the name text field. - const [isin, setIsin] = useState(props.stock.isin); - const [isinError, setIsinError] = useState(false); // Error in the ISIN text field. + const [nameError, setNameError] = useState(""); // Error message for the name text field. + const [ticker, setTicker] = useState(props.stock.ticker); + const [tickerError, setTickerError] = useState(""); // Error message for the ticker text field. + const [isin, setISIN] = useState(props.stock.isin); + const [isinError, setISINError] = useState(""); // Error message for the ISIN text field. const [country, setCountry] = useState(props.stock.country); - const [countryError, setCountryError] = useState(false); // Error in the country input field. + const [countryError, setCountryError] = useState(""); // Error message for the country input field. // Whether to clear information related to the data provider before fetching const [clear, setClear] = useState(false); const [morningstarID, setMorningstarID] = useState(props.stock.morningstarID ?? ""); @@ -64,17 +64,21 @@ export const EditStock = (props: EditStockProps): JSX.Element => { const [sustainalyticsIDRequestInProgress, setSustainalyticsIDRequestInProgress] = useState(false); const { setNotification, setErrorNotificationOrClearSession } = useNotificationContextUpdater(); + const nameInputRef = useRef(null); + const tickerInputRef = useRef(null); + const isinInputRef = useRef(null); + const countryInputRef = useRef(null); + /** * Checks for errors in the input fields. * @returns Whether the input fields are valid. */ const validate = (): boolean => { - // The following fields are required. - setTickerError(!ticker); - setNameError(!name); - setIsinError(!isin); - setCountryError(!country); - return !!name && !!isin && !!country; + const isNameValid = nameInputRef.current?.checkValidity(); + const isTickerValid = tickerInputRef.current?.checkValidity(); + const isISINValid = isinInputRef.current?.checkValidity(); + const isCountryValid = countryInputRef.current?.checkValidity(); + return isNameValid && isTickerValid && isISINValid && isCountryValid; }; /** @@ -296,11 +300,19 @@ export const EditStock = (props: EditStockProps): JSX.Element => { Edit Stock “{props.stock.name}” - + (setName(event.target.value), setNameError(false))} - error={nameError} + onChange={(event) => { + setName(event.target.value); + // If in error state, check whether error is resolved. If so, clear the error. + if (nameError && event.target.checkValidity()) setNameError(""); + }} + onInvalid={(event) => setNameError((event.target as HTMLInputElement).validationMessage)} + error={!!nameError} + helperText={nameError} + inputRef={nameInputRef} + required label="Stock name" value={name} placeholder="e.g. Apple Inc." @@ -309,8 +321,16 @@ export const EditStock = (props: EditStockProps): JSX.Element => { (setTicker(event.target.value), setTickerError(false))} - error={tickerError} + onChange={(event) => { + setTicker(event.target.value); + // If in error state, check whether error is resolved. If so, clear the error. + if (tickerError && event.target.checkValidity()) setTickerError(""); + }} + onInvalid={(event) => setTickerError((event.target as HTMLInputElement).validationMessage)} + error={!!tickerError} + helperText={tickerError} + inputRef={tickerInputRef} + required label="Ticker" value={ticker} placeholder="e.g. AAPL" @@ -319,8 +339,17 @@ export const EditStock = (props: EditStockProps): JSX.Element => { (setIsin(event.target.value), setIsinError(false))} - error={isinError} + inputProps={{ pattern: "[A-Z]{2}[A-Z0-9]{10}" }} + onChange={(event) => { + setISIN(event.target.value); + // If in error state, check whether error is resolved. If so, clear the error. + if (isinError && event.target.checkValidity()) setISINError(""); + }} + onInvalid={(event) => setISINError((event.target as HTMLInputElement).validationMessage)} + error={!!isinError} + helperText={isinError} + inputRef={isinInputRef} + required label="ISIN" value={isin} placeholder="e.g. US0378331005" @@ -330,8 +359,18 @@ export const EditStock = (props: EditStockProps): JSX.Element => { isCountry(value) && (setCountry(value), setCountryError(false))} - error={countryError} + onChange={(_, value) => { + if (isCountry(value)) { + setCountry(value); + // If in error state, check whether error is resolved. If so, clear the error. + if (countryError && countryInputRef.current?.checkValidity()) setCountryError(""); + } + }} + onInvalid={(event) => setCountryError((event.target as HTMLInputElement).validationMessage)} + error={!!countryError} + helperText={countryError} + inputRef={countryInputRef} + required /> @@ -514,8 +553,7 @@ export const EditStock = (props: EditStockProps): JSX.Element => { loading={requestInProgress} variant="contained" onClick={updateStock} - onMouseOver={validate} // Validate input fields on hover - disabled={nameError || isinError || countryError} + disabled={!!nameError || !!tickerError || !!isinError || !!countryError} startIcon={} > Update Stock diff --git a/packages/frontend/src/components/dialogs/watchlist/AddWatchlist.tsx b/packages/frontend/src/components/dialogs/watchlist/AddWatchlist.tsx index b5fb6e9bc..e96c762fc 100644 --- a/packages/frontend/src/components/dialogs/watchlist/AddWatchlist.tsx +++ b/packages/frontend/src/components/dialogs/watchlist/AddWatchlist.tsx @@ -2,7 +2,7 @@ import AddBoxIcon from "@mui/icons-material/AddBox"; import LoadingButton from "@mui/lab/LoadingButton"; import { DialogTitle, Typography, DialogContent, Grid, TextField, DialogActions, Button } from "@mui/material"; import { watchlistsAPIPath } from "@rating-tracker/commons"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useNotificationContextUpdater } from "../../../contexts/NotificationContext"; import api from "../../../utils/api"; @@ -15,17 +15,18 @@ import api from "../../../utils/api"; export const AddWatchlist = (props: AddWatchlistProps): JSX.Element => { const [requestInProgress, setRequestInProgress] = useState(false); const [name, setName] = useState(""); - const [nameError, setNameError] = useState(false); // Error in the name text field. + const [nameError, setNameError] = useState(""); // Error message for the name text field. const { setErrorNotificationOrClearSession } = useNotificationContextUpdater(); + const nameInputRef = useRef(null); + /** * Checks for errors in the input fields. * @returns Whether the input fields are valid. */ const validate = (): boolean => { - // The following fields are required. - setNameError(!name); - return !!name; + const isNameValid = nameInputRef.current?.checkValidity(); + return isNameValid; }; /** @@ -50,8 +51,16 @@ export const AddWatchlist = (props: AddWatchlistProps): JSX.Element => { (setName(event.target.value), setNameError(false))} - error={nameError} + onChange={(event) => { + setName(event.target.value); + // If in error state, check whether error is resolved. If so, clear the error. + if (nameError && event.target.checkValidity()) setNameError(""); + }} + onInvalid={(event) => setNameError((event.target as HTMLInputElement).validationMessage)} + error={!!nameError} + helperText={nameError} + inputRef={nameInputRef} + required label="Watchlist name" value={name} placeholder="e.g. Noteworthy Stocks" @@ -68,8 +77,7 @@ export const AddWatchlist = (props: AddWatchlistProps): JSX.Element => { loading={requestInProgress} variant="contained" onClick={putWatchlist} - onMouseOver={validate} // Validate input fields on hover - disabled={nameError} + disabled={!!nameError} startIcon={} > Create Watchlist diff --git a/packages/frontend/src/components/dialogs/watchlist/RenameWatchlist.tsx b/packages/frontend/src/components/dialogs/watchlist/RenameWatchlist.tsx index 69f758680..eed15e436 100644 --- a/packages/frontend/src/components/dialogs/watchlist/RenameWatchlist.tsx +++ b/packages/frontend/src/components/dialogs/watchlist/RenameWatchlist.tsx @@ -3,7 +3,7 @@ import LoadingButton from "@mui/lab/LoadingButton"; import { DialogTitle, Typography, DialogContent, Grid, TextField, DialogActions, Button } from "@mui/material"; import type { WatchlistSummary } from "@rating-tracker/commons"; import { watchlistsAPIPath } from "@rating-tracker/commons"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useNotificationContextUpdater } from "../../../contexts/NotificationContext"; import api from "../../../utils/api"; @@ -16,17 +16,18 @@ import api from "../../../utils/api"; export const RenameWatchlist = (props: RenameWatchlistProps): JSX.Element => { const [requestInProgress, setRequestInProgress] = useState(false); const [name, setName] = useState(props.watchlist?.name); - const [nameError, setNameError] = useState(false); // Error in the name text field. + const [nameError, setNameError] = useState(""); // Error message for the name text field. const { setErrorNotificationOrClearSession } = useNotificationContextUpdater(); + const nameInputRef = useRef(null); + /** * Checks for errors in the input fields. * @returns Whether the input fields are valid. */ const validate = (): boolean => { - // The following fields are required. - setNameError(!name); - return !!name; + const isNameValid = nameInputRef.current?.checkValidity(); + return isNameValid; }; /** @@ -54,8 +55,16 @@ export const RenameWatchlist = (props: RenameWatchlistProps): JSX.Element => { (setName(event.target.value), setNameError(false))} - error={nameError} + onChange={(event) => { + setName(event.target.value); + // If in error state, check whether error is resolved. If so, clear the error. + if (nameError && event.target.checkValidity()) setNameError(""); + }} + onInvalid={(event) => setNameError((event.target as HTMLInputElement).validationMessage)} + error={!!nameError} + helperText={nameError} + inputRef={nameInputRef} + required label="Watchlist name" value={name} placeholder="e.g. Noteworthy Stocks" @@ -72,8 +81,7 @@ export const RenameWatchlist = (props: RenameWatchlistProps): JSX.Element => { loading={requestInProgress} variant="contained" onClick={updateWatchlist} - onMouseOver={validate} // Validate input fields on hover - disabled={nameError} + disabled={!!nameError} startIcon={} > Update Watchlist diff --git a/packages/frontend/src/components/stock/layouts/StockDetails.tsx b/packages/frontend/src/components/stock/layouts/StockDetails.tsx index 325e9a03b..1672ff5da 100644 --- a/packages/frontend/src/components/stock/layouts/StockDetails.tsx +++ b/packages/frontend/src/components/stock/layouts/StockDetails.tsx @@ -467,7 +467,7 @@ export const StockDetails = (props: StockDetailsProps): JSX.Element => { <> {props.stock?.analystConsensus !== null && props.stock?.analystRatings !== null && ( - + )} diff --git a/packages/frontend/src/components/stock/layouts/StockRow.tsx b/packages/frontend/src/components/stock/layouts/StockRow.tsx index 55a8931a9..4741676f6 100644 --- a/packages/frontend/src/components/stock/layouts/StockRow.tsx +++ b/packages/frontend/src/components/stock/layouts/StockRow.tsx @@ -124,6 +124,8 @@ export const StockRow = (props: StockRowProps): JSX.Element => { const theme = useTheme(); const { setErrorNotificationOrClearSession } = useNotificationContextUpdater(); + const amountInputRef = useRef(null); + const [optionsMenuOpen, setOptionsMenuOpen] = useState(false); const [optionsMenuPositionEvent, setOptionsMenuPositionEvent] = useState>(); const [detailsDialogOpen, setDetailsDialogOpen] = useState(false); @@ -140,16 +142,17 @@ export const StockRow = (props: StockRowProps): JSX.Element => { "amount" in props.stock && props.stock.amount.toFixed(currencyMinorUnits[props.portfolio.currency]), ); - const [amountError, setAmountError] = useState(false); + const [amountError, setAmountError] = useState(""); // Error message for the amount text field. /** * Checks for errors in the input fields. * @returns Whether the input fields are valid. */ const validate = (): boolean => { - // The following fields are required. - setAmountError(!amountInput || Number.isNaN(+amountInput) || +amountInput <= 0); - return !!amountInput && !Number.isNaN(+amountInput) && +amountInput > 0; + const isAmountValid = amountInputRef.current.checkValidity(); + // Focus the text field again so that the error can be cleared when the user leaves the field thereafter + if (!isAmountValid) amountInputRef.current.focus(); + return isAmountValid; }; /** @@ -388,7 +391,8 @@ export const StockRow = (props: StockRowProps): JSX.Element => { arrow > { ref={updateAmountButtonRef} size="small" onClick={updateStockInPortfolio} - onMouseOver={validate} - disabled={amountError || props.stock.amount === +amountInput} + disabled={!!amountError || props.stock.amount === +amountInput} > @@ -414,20 +417,29 @@ export const StockRow = (props: StockRowProps): JSX.Element => { }} inputProps={{ inputMode: "decimal", - pattern: "\\d+(\\.\\d+)?", + type: "number", + // Amount must be divisible by the currency's minor unit step: Math.pow(10, -1 * currencyMinorUnits[props.portfolio.currency]), + min: Math.pow(10, -1 * currencyMinorUnits[props.portfolio.currency]), // Amount must be positive sx: { textAlign: "right" }, }} onChange={(event) => { - setAmountInput(event.target.value.replaceAll(/[^0-9.]/g, "")); - setAmountError(false); + setAmountInput(event.target.value); + // If in error state, check whether error is resolved. If so, clear the error. + if (amountError && event.target.checkValidity()) setAmountError(""); + }} + onInvalid={(event) => setAmountError((event.target as HTMLInputElement).validationMessage)} + error={!!amountError} + helperText={amountError} + onBlur={(event) => { + if ("amount" in props.stock && event.relatedTarget !== updateAmountButtonRef.current) { + setAmountInput(props.stock.amount.toFixed(currencyMinorUnits[props.portfolio.currency])); + // Clear the error message if the input is reset to the original value + setAmountError(""); + } }} - onBlur={(event) => - "amount" in props.stock && - event.relatedTarget !== updateAmountButtonRef.current && - setAmountInput(props.stock.amount.toFixed(currencyMinorUnits[props.portfolio.currency])) - } - error={amountError} + inputRef={amountInputRef} + required label="Amount" value={amountInput} /> @@ -621,7 +633,7 @@ export const StockRow = (props: StockRowProps): JSX.Element => { {props.stock.analystConsensus !== null && props.stock.analystRatings && ( - + )} diff --git a/packages/frontend/src/components/stock/properties/AnalystRatingBar.tsx b/packages/frontend/src/components/stock/properties/AnalystRatingBar.tsx index 118267c62..dbed93cce 100644 --- a/packages/frontend/src/components/stock/properties/AnalystRatingBar.tsx +++ b/packages/frontend/src/components/stock/properties/AnalystRatingBar.tsx @@ -46,7 +46,7 @@ export const AnalystRatingBar = ({ stock, ...props }: AnalystRatingBarProps): JS }} enterDelay={0} placement="bottom" - open + open={props.open} arrow > @@ -63,4 +63,8 @@ interface AnalystRatingBarProps { * The width of the slider. */ width?: number; + /** + * Whether the tooltip is open. + */ + open: boolean; } diff --git a/packages/frontend/src/content/modules/Portfolio/Portfolio.tsx b/packages/frontend/src/content/modules/Portfolio/Portfolio.tsx index 6ac7983e2..901a76123 100644 --- a/packages/frontend/src/content/modules/Portfolio/Portfolio.tsx +++ b/packages/frontend/src/content/modules/Portfolio/Portfolio.tsx @@ -422,7 +422,7 @@ const PortfolioModule = (): JSX.Element => { {analystConsensus !== null && analystRatings !== null && ( - + )} diff --git a/packages/frontend/src/content/modules/PortfolioBuilder/PortfolioBuilder.tsx b/packages/frontend/src/content/modules/PortfolioBuilder/PortfolioBuilder.tsx index 02aa81c85..92e4c36a5 100644 --- a/packages/frontend/src/content/modules/PortfolioBuilder/PortfolioBuilder.tsx +++ b/packages/frontend/src/content/modules/PortfolioBuilder/PortfolioBuilder.tsx @@ -76,7 +76,7 @@ import { styleArray, watchlistsAPIPath, } from "@rating-tracker/commons"; -import { Fragment, useEffect, useState } from "react"; +import { Fragment, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router"; import CurrencyAutocomplete from "../../../components/autocomplete/CurrencyAutocomplete"; @@ -147,16 +147,22 @@ const PortfolioBuilderModule = (): JSX.Element => { ) as Record, ); const [currency, setCurrency] = useState(); - const [currencyError, setCurrencyError] = useState(false); // Error in the currency input field. + const [currencyError, setCurrencyError] = useState(""); // Error message for the currency input field. const [totalAmountInput, setTotalAmountInput] = useState(""); - const [totalAmountError, setTotalAmountError] = useState(false); + // Error message for the total amount input field. + const [totalAmountError, setTotalAmountError] = useState(""); const [minAmountInput, setMinAmountInput] = useState("1.00"); - const [minAmountError, setMinAmountError] = useState(false); + const [minAmountError, setMinAmountError] = useState(""); // Error message for the minimal amount input field. const [tickInput, setTickInput] = useState("1.00"); - const [tickError, setTickError] = useState(false); + const [tickError, setTickError] = useState(""); // Error message for the tick input field. const [proportionalRepresentationAlgorithm, setProportionalRepresentationAlgorithm] = useState("sainteLague"); + const currencyInputRef = useRef(null); + const totalAmountInputRef = useRef(null); + const minAmountInputRef = useRef(null); + const tickInputRef = useRef(null); + const [weightedStocks, setWeightedStocks] = useState([]); const [rse, setRSE] = useState(0); const [scatterData, setScatterData] = useState([]); @@ -309,41 +315,11 @@ const PortfolioBuilderModule = (): JSX.Element => { * @returns Whether the input fields are valid. */ const validatePortfolioParameters = (): boolean => { - // The following fields are required. - // Currency must be set - setCurrencyError(!currency); - // Total amount must be a positive number - setTotalAmountError(!totalAmountInput || Number.isNaN(+totalAmountInput) || +totalAmountInput <= 0); - // Smallest amount must be a non-negative number and must fit into the total amount as often as there are stocks - setMinAmountError( - !minAmountInput || - Number.isNaN(+minAmountInput) || - +minAmountInput < 0 || - +minAmountInput * stocks.length > +totalAmountInput, - ); - // Tick must be a positive number and divide the total amount evenly - setTickError( - !tickInput || - Number.isNaN(+tickInput) || - +tickInput <= 0 || - +totalAmountInput / +tickInput !== Math.trunc(+totalAmountInput / +tickInput), - ); - return ( - !!currency && - !(!totalAmountInput || Number.isNaN(+totalAmountInput) || +totalAmountInput <= 0) && - !( - !minAmountInput || - Number.isNaN(+minAmountInput) || - +minAmountInput < 0 || - +minAmountInput * stocks.length > +totalAmountInput - ) && - !( - !tickInput || - Number.isNaN(+tickInput) || - +tickInput <= 0 || - +totalAmountInput / +tickInput !== Math.trunc(+totalAmountInput / +tickInput) - ) - ); + const isCurrencyValid = currencyInputRef.current?.checkValidity(); + const isTotalAmountValid = totalAmountInputRef.current?.checkValidity(); + const isMinAmountValid = minAmountInputRef.current?.checkValidity(); + const isTickValid = tickInputRef.current?.checkValidity(); + return isCurrencyValid && isTotalAmountValid && isMinAmountValid && isTickValid; }; /** @@ -638,8 +614,18 @@ const PortfolioBuilderModule = (): JSX.Element => { isCurrency(value) && (setCurrency(value), setCurrencyError(false))} - error={currencyError} + onChange={(_, value) => { + if (isCurrency(value)) { + setCurrency(value); + // If in error state, check whether error is resolved. If so, clear the error. + if (currencyError && currencyInputRef.current?.checkValidity()) setCurrencyError(""); + } + }} + onInvalid={(event) => setCurrencyError((event.target as HTMLInputElement).validationMessage)} + error={!!currencyError} + helperText={currencyError} + inputRef={currencyInputRef} + required // Currency must be set /> @@ -653,14 +639,43 @@ const PortfolioBuilderModule = (): JSX.Element => { }} inputProps={{ inputMode: "decimal", - pattern: "\\d+(\\.\\d+)?", - step: Math.pow(10, -1 * currencyMinorUnits[currency || "…"]) || undefined, + type: "number", + // Smallest amount per stock must fit into the total amount at least as often as there are stocks, + // and must be divisible by the tick + min: + +( + // 6. Multiply with the tick again + ( + +tickInput * + // 5. Ceil the result to get a number that is divisibly by the tick + Math.ceil( + // 1. Take the smallest amount per stock, + ((+minAmountInput || Math.pow(10, -1 * currencyMinorUnits[currency || "…"])) * + // 2. multiply it by the number of stocks, + stocks.length * + // 3. Scale it down a tiny bit so that precision errors do not mess with ceiling + (1 - Number.EPSILON)) / + // 4. Divide by the tick + +tickInput, + ) + ) + // 7. If multiplying with the tick again results in a number with precision errors, we round + // the result to the nearest value allowed by the currency. Damn you, IEEE 754! + .toFixed(currencyMinorUnits[currency || "…"]) + ) || undefined, + // Tick must divide the total amount evenly + step: +tickInput || Math.pow(10, -1 * currencyMinorUnits[currency || "…"]) || undefined, }} onChange={(event) => { - setTotalAmountInput(event.target.value.replaceAll(/[^0-9.]/g, "")); - setTotalAmountError(false); + setTotalAmountInput(event.target.value); + // If in error state, check whether error is resolved. If so, clear the error. + if (totalAmountError && event.target.checkValidity()) setTotalAmountError(""); }} - error={totalAmountError} + onInvalid={(event) => setTotalAmountError((event.target as HTMLInputElement).validationMessage)} + error={!!totalAmountError} + helperText={totalAmountError} + inputRef={totalAmountInputRef} + required label="Total Amount" value={totalAmountInput} fullWidth @@ -677,14 +692,20 @@ const PortfolioBuilderModule = (): JSX.Element => { }} inputProps={{ inputMode: "decimal", - pattern: "\\d+(\\.\\d+)?", + type: "number", + min: 0, // Smallest amount per stock must be non-negative step: Math.pow(10, -1 * currencyMinorUnits[currency || "…"]) || undefined, }} onChange={(event) => { - setMinAmountInput(event.target.value.replaceAll(/[^0-9.]/g, "")); - setMinAmountError(false); + setMinAmountInput(event.target.value); + // If in error state, check whether error is resolved. If so, clear the error. + if (minAmountError && event.target.checkValidity()) setMinAmountError(""); }} - error={minAmountError} + onInvalid={(event) => setMinAmountError((event.target as HTMLInputElement).validationMessage)} + error={!!minAmountError} + helperText={minAmountError} + inputRef={minAmountInputRef} + required label="Smallest amount per stock" value={minAmountInput} fullWidth @@ -701,14 +722,20 @@ const PortfolioBuilderModule = (): JSX.Element => { }} inputProps={{ inputMode: "decimal", - pattern: "\\d+(\\.\\d+)?", + type: "number", step: Math.pow(10, -1 * currencyMinorUnits[currency || "…"]) || undefined, + min: Math.pow(10, -1 * currencyMinorUnits[currency || "…"]) || undefined, // Tick must be positive }} onChange={(event) => { - setTickInput(event.target.value.replaceAll(/[^0-9.]/g, "")); - setTickError(false); + setTickInput(event.target.value); + // If in error state, check whether error is resolved. If so, clear the error. + if (tickError && event.target.checkValidity()) setTickError(""); }} - error={tickError} + onInvalid={(event) => setTickError((event.target as HTMLInputElement).validationMessage)} + error={!!tickError} + helperText={tickError} + inputRef={tickInputRef} + required label="Round amounts to multiples of" value={tickInput} fullWidth @@ -1149,8 +1176,6 @@ const PortfolioBuilderModule = (): JSX.Element => { } setActiveStep((prevActiveStep) => prevActiveStep + 1); }} - // Validate input fields on hover - onMouseOver={() => activeStep === 1 && validatePortfolioParameters()} disabled={activeStep === maxStep} > Next diff --git a/packages/frontend/src/content/pages/Login.tsx b/packages/frontend/src/content/pages/Login.tsx index 7c0ea5afd..94df1f422 100644 --- a/packages/frontend/src/content/pages/Login.tsx +++ b/packages/frontend/src/content/pages/Login.tsx @@ -20,8 +20,8 @@ export const LoginPage = (): JSX.Element => { const [email, setEmail] = useState(""); const [name, setName] = useState(""); const [requestInProgress, setRequestInProgress] = useState(false); - const [emailError, setEmailError] = useState(false); - const [nameError, setNameError] = useState(false); + const [emailError, setEmailError] = useState(""); // Error message for the email text field. + const [nameError, setNameError] = useState(""); // Error message for the name text field. const { setNotification, setErrorNotificationOrClearSession } = useNotificationContextUpdater(); const { refetchUser } = useUserContextUpdater(); @@ -36,9 +36,9 @@ export const LoginPage = (): JSX.Element => { */ const validate = (): boolean => { if (action === "register") { - setEmailError(!inputEmail.current.reportValidity()); - setNameError(!inputName.current.reportValidity()); - return inputEmail.current.reportValidity() && inputName.current.reportValidity(); + const isEmailValid = inputEmail.current?.checkValidity(); + const isNameValid = inputName.current?.checkValidity(); + return isEmailValid && isNameValid; } return true; }; @@ -48,11 +48,11 @@ export const LoginPage = (): JSX.Element => { */ const onButtonClick = () => { void (async (): Promise => { + // Validate input fields + if (!validate()) return; setRequestInProgress(true); switch (action) { case "register": - // Validate input fields - if (!validate()) return; try { // Request registration challenge const res = await api.get(authAPIPath + registerEndpointSuffix, { @@ -153,11 +153,14 @@ export const LoginPage = (): JSX.Element => { type="email" label="Email Address" value={email} - error={emailError} - onChange={(event: React.ChangeEvent) => { + onChange={(event) => { setEmail(event.target.value); - setEmailError(false); + // If in error state, check whether error is resolved. If so, clear the error. + if (emailError && event.target.checkValidity()) setEmailError(""); }} + onInvalid={(event) => setEmailError((event.target as HTMLInputElement).validationMessage)} + error={!!emailError} + helperText={emailError} required /> @@ -179,11 +182,14 @@ export const LoginPage = (): JSX.Element => { label="Name" autoComplete="name" value={name} - error={nameError} - onChange={(event: React.ChangeEvent) => { + onChange={(event) => { setName(event.target.value); - setNameError(false); + // If in error state, check whether error is resolved. If so, clear the error. + if (nameError && event.target.checkValidity()) setNameError(""); }} + onInvalid={(event) => setNameError((event.target as HTMLInputElement).validationMessage)} + error={!!nameError} + helperText={nameError} required /> @@ -192,9 +198,8 @@ export const LoginPage = (): JSX.Element => { loading={requestInProgress} startIcon={} variant="contained" - disabled={action === "register" && (emailError || nameError)} + disabled={action === "register" && (!!emailError || !!nameError)} fullWidth - onMouseOver={validate} // Validate input fields on hover onClick={onButtonClick} > {action === "signIn" ? "Sign in" : "Register"} diff --git a/packages/frontend/src/layouts/SidebarLayout/Header/Userbox/ProfileSettings.tsx b/packages/frontend/src/layouts/SidebarLayout/Header/Userbox/ProfileSettings.tsx index fd96f02b9..f5d4000da 100644 --- a/packages/frontend/src/layouts/SidebarLayout/Header/Userbox/ProfileSettings.tsx +++ b/packages/frontend/src/layouts/SidebarLayout/Header/Userbox/ProfileSettings.tsx @@ -47,11 +47,11 @@ export const ProfileSettings = (props: ProfileSettingsProps): JSX.Element => { const [requestInProgress, setRequestInProgress] = useState(false); const [email, setEmail] = useState(user.email); - const [emailError, setEmailError] = useState(false); // Error in the email text field. + const [emailError, setEmailError] = useState(""); // Error message for the email text field. const [name, setName] = useState(user.name); - const [nameError, setNameError] = useState(false); // Error in the name text field. + const [nameError, setNameError] = useState(""); // Error message for the name text field. const [phone, setPhone] = useState(user.phone); - const [phoneError, setPhoneError] = useState(false); // Error in the phone text field. + const [phoneError, setPhoneError] = useState(""); // Error message for the phone text field. const [processingAvatar, setProcessingAvatar] = useState(true); const [subscriptions, setSubscriptions] = useState(user.subscriptions); @@ -84,13 +84,10 @@ export const ProfileSettings = (props: ProfileSettingsProps): JSX.Element => { * @returns Whether the input fields are valid. */ const validate = (): boolean => { - // The following fields are required. - setEmailError(!inputEmail.current.reportValidity()); - setNameError(!inputName.current.reportValidity()); - setPhoneError(!inputPhone.current.reportValidity()); - return ( - inputEmail.current.reportValidity() && inputName.current.reportValidity() && inputPhone.current.reportValidity() - ); + const isEmailValid = inputEmail.current?.checkValidity(); + const isNameValid = inputName.current?.checkValidity(); + const isPhoneValid = inputPhone.current?.checkValidity(); + return isEmailValid && isNameValid && isPhoneValid; }; /** @@ -230,9 +227,12 @@ export const ProfileSettings = (props: ProfileSettingsProps): JSX.Element => { type="email" onChange={(event) => { setEmail(event.target.value); - setEmailError(false); + // If in error state, check whether error is resolved. If so, clear the error. + if (emailError && event.target.checkValidity()) setEmailError(""); }} - error={emailError} + onInvalid={(event) => setEmailError((event.target as HTMLInputElement).validationMessage)} + error={!!emailError} + helperText={emailError} label="Email address" value={email} placeholder="jane.doe@example.com" @@ -245,9 +245,12 @@ export const ProfileSettings = (props: ProfileSettingsProps): JSX.Element => { inputRef={inputName} onChange={(event) => { setName(event.target.value); - setNameError(false); + // If in error state, check whether error is resolved. If so, clear the error. + if (nameError && event.target.checkValidity()) setNameError(""); }} - error={nameError} + onInvalid={(event) => setNameError((event.target as HTMLInputElement).validationMessage)} + error={!!nameError} + helperText={nameError} label="Name" autoComplete="name" value={name} @@ -259,13 +262,15 @@ export const ProfileSettings = (props: ProfileSettingsProps): JSX.Element => { { - setPhone(event.target.value.replaceAll(/[^0-9+]+/g, "").substring(0, 16)); - setPhoneError(false); + setPhone(event.target.value); + // If in error state, check whether error is resolved. If so, clear the error. + if (phoneError && event.target.checkValidity()) setPhoneError(""); }} - error={phoneError} + onInvalid={(event) => setPhoneError((event.target as HTMLInputElement).validationMessage)} + error={!!phoneError} + helperText={phoneError} label="Phone number" value={phone} placeholder="+12125550123" @@ -318,12 +323,11 @@ export const ProfileSettings = (props: ProfileSettingsProps): JSX.Element => { loading={requestInProgress} variant="contained" onClick={updateProfile} - onMouseOver={validate} // Validate input fields on hover disabled={ // We cannot save if there are errors or if nothing has changed. - emailError || - nameError || - phoneError || + !!emailError || + !!nameError || + !!phoneError || (email === user.email && name === user.name && phone === user.phone && subscriptions === user.subscriptions) } startIcon={} diff --git a/packages/frontend/src/theme/ThemeProvider.tsx b/packages/frontend/src/theme/ThemeProvider.tsx index c945359a5..be80ba7df 100644 --- a/packages/frontend/src/theme/ThemeProvider.tsx +++ b/packages/frontend/src/theme/ThemeProvider.tsx @@ -400,16 +400,24 @@ const ThemeProviderWrapper: FC = (props: React.PropsWit useEffect(() => { // Add listener to update styles - window - .matchMedia("(prefers-color-scheme: dark)") - .addEventListener("change", (e) => setThemeName(e.matches ? "dark" : "light")); + const themeListener = (e) => setThemeName(e.matches ? "dark" : "light"); + window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", themeListener); + + // Add listener to prevent changing number inputs with the mouse wheel + const numberListener = () => { + if ((document.activeElement as HTMLInputElement)?.type === "number") { + (document.activeElement as HTMLInputElement)?.blur(); + } + }; + document.addEventListener("wheel", numberListener, { passive: true }); // Setup dark/light mode for the first time setThemeName(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"); - // Remove listener + // Remove listeners return () => { - window.matchMedia("(prefers-color-scheme: dark)").removeEventListener("change", () => {}); + window.matchMedia("(prefers-color-scheme: dark)").removeEventListener("change", themeListener); + document.removeEventListener("wheel", numberListener); }; }, []); diff --git a/packages/frontend/src/theme/scheme.ts b/packages/frontend/src/theme/scheme.ts index ab9157ddd..917385eb7 100644 --- a/packages/frontend/src/theme/scheme.ts +++ b/packages/frontend/src/theme/scheme.ts @@ -506,6 +506,19 @@ const generateScheme = (light: boolean, themeColors, colors) => ({ }, }, }, + MuiInputBase: { + styleOverrides: { + input: { + "&[type=number]::-webkit-inner-spin-button, &[type=number]::-webkit-outer-spin-button": { + WebkitAppearance: "none", + margin: 0, + }, + "&[type=number]": { + MozAppearance: "textfield", + }, + }, + }, + }, MuiOutlinedInput: { styleOverrides: { root: { @@ -887,6 +900,16 @@ const generateScheme = (light: boolean, themeColors, colors) => ({ }, }, }, + MuiChartsTooltip: { + styleOverrides: { + container: { + boxShadow: "none", + borderWidth: 1, + borderStyle: "solid", + borderColor: colors.alpha.black[10], + }, + }, + }, MuiLink: { defaultProps: { underline: "hover", @@ -1060,6 +1083,9 @@ const generateScheme = (light: boolean, themeColors, colors) => ({ ".MuiAutocomplete-inputRoot.MuiOutlinedInput-root .MuiAutocomplete-endAdornment": { right: 14, }, + ".MuiAutocomplete-inputRoot .MuiAutocomplete-input": { + minWidth: 45, + }, }, clearIndicator: { background: light ? colors.error.lighter : alpha(colors.error.lighter, 0.2), diff --git a/packages/frontend/src/utils/portfolioComputation.test.ts b/packages/frontend/src/utils/portfolioComputation.test.ts index 9b1962ed3..15feef077 100644 --- a/packages/frontend/src/utils/portfolioComputation.test.ts +++ b/packages/frontend/src/utils/portfolioComputation.test.ts @@ -124,4 +124,24 @@ describe.concurrent("Portfolio Computation", () => { validateResults(result, options); }); + + it("handles floating point precision issues properly", () => { + expect(() => + computePortfolio(stocks.slice(1, 4) as Stock[], constraints, { + minAmount: 0.1, + totalAmount: 0.3, + tick: 0.1, + proportionalRepresentationAlgorithm: "sainteLague", + }), + ).not.toThrow(); + + expect(() => + computePortfolio(stocks.slice(1, 4) as Stock[], constraints, { + minAmount: 0.1, + totalAmount: 0.3, + tick: 0.1, + proportionalRepresentationAlgorithm: "hareNiemeyer", + }), + ).not.toThrow(); + }); }); diff --git a/packages/frontend/src/utils/portfolioComputation.ts b/packages/frontend/src/utils/portfolioComputation.ts index e2063253c..d161a0f19 100644 --- a/packages/frontend/src/utils/portfolioComputation.ts +++ b/packages/frontend/src/utils/portfolioComputation.ts @@ -133,7 +133,8 @@ export const computePortfolio = ( Object.fromEntries( stocks.map((stock, index) => [stock.ticker, Math.round((solution.K.get(index, 0) + minWeight) / EPSILON)]), ), - options.totalAmount / options.tick, + // Fix floating point precision errors. We validated before that the tick divides the total amount evenly. + Math.round(options.totalAmount / options.tick), { draw: true }, );