diff --git a/package-lock.json b/package-lock.json index ea10622..63951c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,8 +15,10 @@ "@mui/x-date-pickers": "^8.5.3", "formik": "^2.4.6", "luxon": "^3.6.1", + "mathjs": "^14.5.2", "react": "19.1.0", "react-dom": "19.1.0", + "react-i18next": "^15.5.3", "react-router-dom": "^7.6.2", "tslib": "^2.8.1", "zod": "^3.25.67" @@ -12118,6 +12120,18 @@ "dev": true, "license": "MIT" }, + "node_modules/complex.js": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.2.tgz", + "integrity": "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g==", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -12481,8 +12495,7 @@ "node_modules/decimal.js": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", - "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", - "dev": true + "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==" }, "node_modules/decompress-response": { "version": "6.0.0", @@ -13203,6 +13216,11 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "dev": true }, + "node_modules/escape-latex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", + "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==" + }, "node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -14543,6 +14561,18 @@ "node": ">= 0.6" } }, + "node_modules/fraction.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.2.2.tgz", + "integrity": "sha512-uXBDv5knpYmv/2gLzWQ5mBHGBRk9wcKTeWu6GLTUEQfjCxO09uM/mHDrojlL+Q1mVGIIFo149Gba7od1XPgSzQ==", + "engines": { + "node": ">= 12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -15103,6 +15133,14 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-assert": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", @@ -15270,6 +15308,37 @@ "node": ">=10.17.0" } }, + "node_modules/i18next": { + "version": "25.2.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.2.1.tgz", + "integrity": "sha512-+UoXK5wh+VlE1Zy5p6MjcvctHXAhRwQKCxiJD8noKZzIXmnAX8gdHX5fLPA3MEVxEN4vbZkQFy8N0LyD9tUqPw==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "peer": true, + "dependencies": { + "@babel/runtime": "^7.27.1" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -16105,6 +16174,11 @@ "node": "*" } }, + "node_modules/javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==" + }, "node_modules/jest": { "version": "30.0.2", "resolved": "https://registry.npmjs.org/jest/-/jest-30.0.2.tgz", @@ -19251,6 +19325,28 @@ "node": ">= 0.4" } }, + "node_modules/mathjs": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-14.5.2.tgz", + "integrity": "sha512-51U6hp7j4M4Rj+l+q2KbmXAV9EhQVQzUdw1wE67RnUkKKq5ibxdrl9Ky2YkSUEIc2+VU8/IsThZNu6QSHUoyTA==", + "dependencies": { + "@babel/runtime": "^7.26.10", + "complex.js": "^2.2.5", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^5.2.1", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.2.1" + }, + "bin": { + "mathjs": "bin/cli.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", @@ -20687,6 +20783,31 @@ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" }, + "node_modules/react-i18next": { + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.3.tgz", + "integrity": "sha512-ypYmOKOnjqPEJZO4m1BI0kS8kWqkBNsKYyhVUfij0gvjy9xJNoG/VcGkxq5dRlVwzmrmY1BQMAmpbbUBLwC4Kw==", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -21228,6 +21349,11 @@ "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==", "dev": true }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==" + }, "node_modules/seek-bzip": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-2.0.0.tgz", @@ -22277,6 +22403,11 @@ "dev": true, "license": "MIT" }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" + }, "node_modules/tiny-warning": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", @@ -22752,11 +22883,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-function": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.1.tgz", + "integrity": "sha512-EGjWssW7Tsk4DGfE+5yluuljS1OGYWiI1J6e8puZz9nTMM51Oug8CD5Zo4gWMsOhq5BI+1bF+rWTm4Vbj3ivRA==", + "engines": { + "node": ">= 18" + } + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -23294,6 +23433,14 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/vscode-uri": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", diff --git a/package.json b/package.json index b42053a..a322026 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,10 @@ "@mui/x-date-pickers": "^8.5.3", "formik": "^2.4.6", "luxon": "^3.6.1", + "mathjs": "^14.5.2", "react": "19.1.0", "react-dom": "19.1.0", + "react-i18next": "^15.5.3", "react-router-dom": "^7.6.2", "tslib": "^2.8.1", "zod": "^3.25.67" diff --git a/tsconfig.base.json b/tsconfig.base.json index 9a14fd0..03249f7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -10,7 +10,7 @@ "importHelpers": true, "target": "es2015", "module": "esnext", - "lib": ["es2020", "dom"], + "lib": ["es2021", "dom"], "skipLibCheck": true, "skipDefaultLibCheck": true, "baseUrl": ".", diff --git a/web/components/package.json b/web/components/package.json index 7ec3d38..a3e2741 100644 --- a/web/components/package.json +++ b/web/components/package.json @@ -12,7 +12,11 @@ }, "peerDependencies": { "react": "^19.1.0", - "@mui/material": "^7.1.2" + "@mui/material": "^7.1.2", + "react-i18next": "^15.5.3" + }, + "dependencies": { + "mathjs": "^14.5.2" }, "repository": { "type": "git", diff --git a/web/components/src/index.ts b/web/components/src/index.ts index eae73e9..da8451b 100644 --- a/web/components/src/index.ts +++ b/web/components/src/index.ts @@ -1,5 +1,4 @@ export * from "./lib/CashingTextField"; export * from "./lib/DataGridTitle"; -export * from "./lib/Loading"; -export * from "./lib/NumericInput"; +export * from "./lib/numeric-input"; export * from "./lib/Select"; diff --git a/web/components/src/lib/Loading.spec.tsx b/web/components/src/lib/Loading.spec.tsx deleted file mode 100644 index dbb59f0..0000000 --- a/web/components/src/lib/Loading.spec.tsx +++ /dev/null @@ -1,5 +0,0 @@ -describe("Dummy test suite", () => { - it("should work", () => { - expect(true).toBeTruthy(); - }); -}); diff --git a/web/components/src/lib/Loading.tsx b/web/components/src/lib/Loading.tsx deleted file mode 100644 index be9d410..0000000 --- a/web/components/src/lib/Loading.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import * as React from "react"; -import { CircularProgress, Grid } from "@mui/material"; - -export const Loading: React.FC = () => { - return ( - - - - ); -}; diff --git a/web/components/src/lib/NumericInput.tsx b/web/components/src/lib/NumericInput.tsx deleted file mode 100644 index d05ee69..0000000 --- a/web/components/src/lib/NumericInput.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import * as React from "react"; -import { TextField, TextFieldProps } from "@mui/material"; - -export type NumericInputProps = { - onChange: (value: number | null) => void; - value?: number | undefined | null; -} & Omit; - -export const NumericInput: React.FC = ({ value, onChange, ...props }) => { - const [internalValue, setInternalValue] = React.useState(""); - - React.useEffect(() => { - setInternalValue(String(value ?? "")); - }, [value, setInternalValue]); - - const onInternalChange: React.ChangeEventHandler = (event) => { - setInternalValue(event.target.value); - }; - - const propagateChange = () => { - if (internalValue === "") { - onChange(null); - return; - } - - const parsedValue = parseFloat(internalValue); - if (!isNaN(parsedValue)) { - setInternalValue(String(parsedValue)); - onChange(parsedValue); - } - }; - - const onInternalBlur = () => { - propagateChange(); - }; - - const onKeyUp = (event: React.KeyboardEvent) => { - if (event.key === "Enter") { - propagateChange(); - } - }; - - return ( - event.target.select()} - {...props} - /> - ); -}; diff --git a/web/components/src/lib/numeric-input/NumericInput.tsx b/web/components/src/lib/numeric-input/NumericInput.tsx new file mode 100644 index 0000000..ecf0463 --- /dev/null +++ b/web/components/src/lib/numeric-input/NumericInput.tsx @@ -0,0 +1,113 @@ +import * as React from "react"; +import { TextField, TextFieldProps } from "@mui/material"; +import { evaluateExpression } from "./mathExpression"; +import { useTranslation } from "react-i18next"; + +export type NumericInputProps = { + onChange: (value: number) => void; + value?: number | undefined; + isCurrency?: boolean | false; +} & Omit; + +const getDecimalSeparator = (locale: string) => { + const numberWithDecimalSeparator = 1.1; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const separator = Intl.NumberFormat(locale) + .formatToParts(numberWithDecimalSeparator) + .find((part) => part.type === "decimal")!.value; + + return [separator, separator === "." ? "," : "."]; +}; + +const adjustLocaleDecimalSeparators = (value: string, locale: string): string => { + const [decimalSeparator, thousandSeparator] = getDecimalSeparator(locale); + return value.replaceAll(thousandSeparator, "").replaceAll(decimalSeparator, "."); +}; + +const currencyFormatConfig = { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}; + +const numberFormatConfig = { + maximumFractionDigits: 12, +}; + +export const NumericInput: React.FC = ({ + value, + isCurrency, + onChange, + error, + helperText, + ...props +}) => { + const { i18n } = useTranslation(); + const inputRef = React.useRef(null); + + const [internalValue, setInternalValue] = React.useState(""); + const [internalError, setInternalError] = React.useState(false); + const [internalHelperText, setInternalHelperText] = React.useState(undefined); + + React.useEffect(() => { + if (value != null) { + setInternalValue( + Intl.NumberFormat(i18n.language, isCurrency ? currencyFormatConfig : numberFormatConfig).format(value) + ); + } + }, [value, setInternalValue, isCurrency, i18n.language]); + + const onInternalChange = (event: React.ChangeEvent) => { + setInternalValue(event.target.value); + }; + + const finalizeInput = () => { + const updateValue = (value: number) => { + setInternalValue(isCurrency ? value.toFixed(2) : String(value)); + onChange(value); + setInternalError(false); + setInternalHelperText(undefined); + }; + + // first, preprocess the input to have commas / dots adhere to the current locale + const cleanedValue = adjustLocaleDecimalSeparators(internalValue, i18n.language); + + // next, try to evaluate any math expression present in the input, plain numbers will remain unchanged + try { + const evaluated = evaluateExpression(cleanedValue); + updateValue(evaluated); + } catch (e) { + setInternalError(true); + setInternalHelperText(String(e)); + } + }; + + const onInternalBlur = () => { + finalizeInput(); + }; + + const onKeyUp = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + finalizeInput(); + inputRef.current?.select(); + } + }; + + const passedError = error || internalError; + const passedHelperText = internalHelperText ?? helperText; + + return ( + event.target.select()} + error={passedError} + helperText={passedHelperText} + inputRef={inputRef} + {...props} + /> + ); +}; diff --git a/web/components/src/lib/numeric-input/index.ts b/web/components/src/lib/numeric-input/index.ts new file mode 100644 index 0000000..0e3ba26 --- /dev/null +++ b/web/components/src/lib/numeric-input/index.ts @@ -0,0 +1 @@ +export * from "./NumericInput"; diff --git a/web/components/src/lib/numeric-input/mathExpression.spec.ts b/web/components/src/lib/numeric-input/mathExpression.spec.ts new file mode 100644 index 0000000..ec94563 --- /dev/null +++ b/web/components/src/lib/numeric-input/mathExpression.spec.ts @@ -0,0 +1,13 @@ +import { evaluateExpression } from "./mathExpression"; + +describe("mathExpressions", () => { + test("basic operations work", () => { + expect(evaluateExpression("(2 * 4 + 4 / 2) * 2")).toBe(20); + }); + test("test invalid expression throws", () => { + expect(() => evaluateExpression("(2 * 4 + 4 / 2 * 2")).toThrow(); + }); + test("advanced operations are not allowed", () => { + expect(() => evaluateExpression("sqrt(10)")).toThrow(); + }); +}); diff --git a/web/components/src/lib/numeric-input/mathExpression.ts b/web/components/src/lib/numeric-input/mathExpression.ts new file mode 100644 index 0000000..b44342e --- /dev/null +++ b/web/components/src/lib/numeric-input/mathExpression.ts @@ -0,0 +1,23 @@ +import { create, type ConfigOptions, evaluateDependencies, factory } from "mathjs"; + +const config: ConfigOptions = {}; + +const add = (a: number, b: number) => a + b; +const subtract = (a: number, b: number) => a - b; +const multiply = (a: number, b: number) => a * b; +const divide = (a: number, b: number) => a / b; + +const math = create( + { + evaluateDependencies, + createAdd: factory("add", [], () => add), + createSubtract: factory("subtract", [], () => subtract), + createMultiply: factory("multiply", [], () => multiply), + createDivide: factory("divide", [], () => divide), + }, + config +); + +export const evaluateExpression = (expr: string): number => { + return math.evaluate(expr); +};