diff --git a/.env.example b/.env.example index 6a1ea0373..5b5274df8 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,6 @@ VITE_DATA_URL=https://data.xrpl.org/v1/network # Whether to use ws instead of wss (boolean) # Only used locally (the deployed Explorer requires wss) VITE_INSECURE_WS=0 + +# LOS Endpoint URL +VITE_LOS_URL=https://los.dev.ripplex.io diff --git a/package-lock.json b/package-lock.json index 6906feba4..64f2b04fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,8 @@ "version": "1.5.0", "dependencies": { "@rollup/plugin-inject": "^5.0.5", - "@testing-library/jest-dom": "^5.17.0", - "@testing-library/react": "^12.1.5", "@vitejs/plugin-react": "^4.2.1", "@xrplf/isomorphic": "^1.0.0-beta.1", - "@xrplf/prettier-config": "^1.9.1", "assert": "^2.1.0", "autoprefixer": "^10.4.20", "axios": "^1.6.5", @@ -63,6 +60,9 @@ "@babel/eslint-parser": "^7.22.6", "@babel/preset-env": "^7.25.7", "@babel/preset-react": "^7.27.1", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^12.1.5", + "@testing-library/react-hooks": "^8.0.1", "@types/create-hash": "^1.2.6", "@types/enzyme": "^3.10.12", "@types/jest": "^26.0.24", @@ -74,6 +74,7 @@ "@types/testing-library__jest-dom": "^5.14.9", "@typescript-eslint/eslint-plugin": "^8.32.1", "@typescript-eslint/parser": "^8.32.1", + "@xrplf/prettier-config": "^1.9.1", "babel-jest": "^29.7.0", "cheerio": "1.0.0-rc.3", "enzyme": "3.11.0", @@ -119,7 +120,8 @@ "node_modules/@adobe/css-tools": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.2.tgz", - "integrity": "sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==" + "integrity": "sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==", + "dev": true }, "node_modules/@ampproject/remapping": { "version": "2.3.0", @@ -4345,6 +4347,7 @@ "version": "8.20.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", + "dev": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4363,6 +4366,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "engines": { "node": ">=10" }, @@ -4374,6 +4378,7 @@ "version": "5.1.3", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, "dependencies": { "deep-equal": "^2.0.5" } @@ -4382,6 +4387,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -4395,6 +4401,7 @@ "version": "5.17.0", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz", "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==", + "dev": true, "dependencies": { "@adobe/css-tools": "^4.0.1", "@babel/runtime": "^7.9.2", @@ -4416,6 +4423,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -4428,6 +4436,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, "engines": { "node": ">=8" } @@ -4436,6 +4445,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" @@ -4448,6 +4458,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, "dependencies": { "min-indent": "^1.0.0" }, @@ -4459,6 +4470,7 @@ "version": "12.1.5", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==", + "dev": true, "dependencies": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^8.0.0", @@ -4472,6 +4484,54 @@ "react-dom": "<18.0.0" } }, + "node_modules/@testing-library/react-hooks": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz", + "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "react-error-boundary": "^3.1.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "@types/react": "^16.9.0 || ^17.0.0", + "react": "^16.9.0 || ^17.0.0", + "react-dom": "^16.9.0 || ^17.0.0", + "react-test-renderer": "^16.9.0 || ^17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-test-renderer": { + "optional": true + } + } + }, + "node_modules/@testing-library/react-hooks/node_modules/react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -4507,7 +4567,8 @@ "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==" + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4684,6 +4745,7 @@ "version": "26.0.24", "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.24.tgz", "integrity": "sha512-E/X5Vib8BWqZNRlDxj9vYXhsDwPYbPINqKF9BsnSoon4RQ0D9moEuLD8txgyypFLH7J4+Lho9Nr/c8H0Fi+17w==", + "dev": true, "dependencies": { "jest-diff": "^26.0.0", "pretty-format": "^26.0.0" @@ -4768,12 +4830,14 @@ "node_modules/@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", - "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + "dev": true }, "node_modules/@types/react": { "version": "17.0.85", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.85.tgz", "integrity": "sha512-5oBDUsRDsrYq4DdyHaL99gE1AJCfuDhyxqF6/55fvvOIRkp1PpKuwJ+aMiGJR+GJt7YqMNclPROTHF20vY2cXA==", + "dev": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "^0.16", @@ -4784,6 +4848,7 @@ "version": "17.0.26", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.26.tgz", "integrity": "sha512-Z+2VcYXJwOqQ79HreLU/1fyQ88eXSSFh6I3JdrEHQIfYSI0kCQpTGvOrbE6jFGGYXKsHuwY9tBa/w5Uo6KzrEg==", + "dev": true, "peerDependencies": { "@types/react": "^17.0.0" } @@ -4812,7 +4877,8 @@ "node_modules/@types/scheduler": { "version": "0.16.8", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", - "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", + "dev": true }, "node_modules/@types/semver": { "version": "7.7.0", @@ -4829,6 +4895,7 @@ "version": "5.14.9", "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz", "integrity": "sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw==", + "dev": true, "dependencies": { "@types/jest": "*" } @@ -5240,7 +5307,8 @@ "node_modules/@xrplf/prettier-config": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@xrplf/prettier-config/-/prettier-config-1.9.1.tgz", - "integrity": "sha512-1k4WCTstvGiTJQYUNTZhz4BM2DI/kVBRXh1Dw5fVaVDiGVb4YOuzxFNgipOWX/EllrjRTN5NNGLJEDk0nsMFEg==" + "integrity": "sha512-1k4WCTstvGiTJQYUNTZhz4BM2DI/kVBRXh1Dw5fVaVDiGVb4YOuzxFNgipOWX/EllrjRTN5NNGLJEDk0nsMFEg==", + "dev": true }, "node_modules/@xrplf/secret-numbers": { "version": "2.0.0", @@ -5397,6 +5465,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "devOptional": true, "engines": { "node": ">=8" } @@ -5481,6 +5550,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, "engines": { "node": ">= 0.4" } @@ -5516,6 +5586,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" @@ -7284,7 +7355,8 @@ "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==" + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true }, "node_modules/cssesc": { "version": "3.0.0", @@ -7942,6 +8014,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, "dependencies": { "array-buffer-byte-length": "^1.0.0", "call-bind": "^1.0.5", @@ -8128,6 +8201,7 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", "integrity": "sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==", + "dev": true, "engines": { "node": ">= 10.14.2" } @@ -8165,7 +8239,8 @@ "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -8677,6 +8752,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", @@ -10831,6 +10907,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -11259,6 +11336,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -11786,6 +11864,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", @@ -11850,6 +11929,7 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -11890,6 +11970,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, "dependencies": { "has-bigints": "^1.0.2" }, @@ -11916,6 +11997,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" @@ -12004,6 +12086,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" @@ -12144,6 +12227,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -12178,6 +12262,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" @@ -12242,6 +12327,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -12253,6 +12339,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, "dependencies": { "call-bound": "^1.0.3" }, @@ -12280,6 +12367,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" @@ -12301,6 +12389,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", @@ -12336,6 +12425,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -12362,6 +12452,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" @@ -12397,7 +12488,8 @@ "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true }, "node_modules/isexe": { "version": "2.0.0", @@ -12829,6 +12921,7 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz", "integrity": "sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA==", + "dev": true, "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^26.6.2", @@ -13907,6 +14000,7 @@ "version": "26.3.0", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==", + "dev": true, "engines": { "node": ">= 10.14.2" } @@ -15479,6 +15573,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, "bin": { "lz-string": "bin/bin.js" } @@ -15947,6 +16042,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, "engines": { "node": ">=4" } @@ -17538,6 +17634,7 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", + "dev": true, "dependencies": { "@jest/types": "^26.6.2", "ansi-regex": "^5.0.0", @@ -17552,6 +17649,7 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, "dependencies": { "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", @@ -17567,6 +17665,7 @@ "version": "15.0.19", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.19.tgz", "integrity": "sha512-2XUaGVmyQjgyAZldf0D0c14vvo/yv0MhQBSTJcejMMaitsn3nxCB6TmH4G0ZQf+uxROOa9mpanoSm8h6SG/1ZA==", + "dev": true, "dependencies": { "@types/yargs-parser": "*" } @@ -17874,7 +17973,8 @@ "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true }, "node_modules/react-query": { "version": "3.39.3", @@ -18211,6 +18311,7 @@ "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", @@ -19264,6 +19365,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -20017,6 +20119,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" @@ -22141,6 +22244,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", @@ -22186,6 +22290,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", @@ -22544,7 +22649,8 @@ "@adobe/css-tools": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.2.tgz", - "integrity": "sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==" + "integrity": "sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==", + "dev": true }, "@ampproject/remapping": { "version": "2.3.0", @@ -25112,6 +25218,7 @@ "version": "8.20.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", + "dev": true, "requires": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -25126,12 +25233,14 @@ "ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true }, "aria-query": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, "requires": { "deep-equal": "^2.0.5" } @@ -25140,6 +25249,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, "requires": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -25152,6 +25262,7 @@ "version": "5.17.0", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz", "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==", + "dev": true, "requires": { "@adobe/css-tools": "^4.0.1", "@babel/runtime": "^7.9.2", @@ -25168,6 +25279,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -25176,12 +25288,14 @@ "indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==" + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true }, "redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, "requires": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" @@ -25191,6 +25305,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, "requires": { "min-indent": "^1.0.0" } @@ -25201,12 +25316,34 @@ "version": "12.1.5", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==", + "dev": true, "requires": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^8.0.0", "@types/react-dom": "<18.0.0" } }, + "@testing-library/react-hooks": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz", + "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "react-error-boundary": "^3.1.0" + }, + "dependencies": { + "react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5" + } + } + } + }, "@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -25239,7 +25376,8 @@ "@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==" + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true }, "@types/babel__core": { "version": "7.20.5", @@ -25418,6 +25556,7 @@ "version": "26.0.24", "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.24.tgz", "integrity": "sha512-E/X5Vib8BWqZNRlDxj9vYXhsDwPYbPINqKF9BsnSoon4RQ0D9moEuLD8txgyypFLH7J4+Lho9Nr/c8H0Fi+17w==", + "dev": true, "requires": { "jest-diff": "^26.0.0", "pretty-format": "^26.0.0" @@ -25495,12 +25634,14 @@ "@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", - "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + "dev": true }, "@types/react": { "version": "17.0.85", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.85.tgz", "integrity": "sha512-5oBDUsRDsrYq4DdyHaL99gE1AJCfuDhyxqF6/55fvvOIRkp1PpKuwJ+aMiGJR+GJt7YqMNclPROTHF20vY2cXA==", + "dev": true, "requires": { "@types/prop-types": "*", "@types/scheduler": "^0.16", @@ -25511,6 +25652,7 @@ "version": "17.0.26", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.26.tgz", "integrity": "sha512-Z+2VcYXJwOqQ79HreLU/1fyQ88eXSSFh6I3JdrEHQIfYSI0kCQpTGvOrbE6jFGGYXKsHuwY9tBa/w5Uo6KzrEg==", + "dev": true, "requires": {} }, "@types/react-router": { @@ -25537,7 +25679,8 @@ "@types/scheduler": { "version": "0.16.8", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", - "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", + "dev": true }, "@types/semver": { "version": "7.7.0", @@ -25554,6 +25697,7 @@ "version": "5.14.9", "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz", "integrity": "sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw==", + "dev": true, "requires": { "@types/jest": "*" } @@ -25807,7 +25951,8 @@ "@xrplf/prettier-config": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@xrplf/prettier-config/-/prettier-config-1.9.1.tgz", - "integrity": "sha512-1k4WCTstvGiTJQYUNTZhz4BM2DI/kVBRXh1Dw5fVaVDiGVb4YOuzxFNgipOWX/EllrjRTN5NNGLJEDk0nsMFEg==" + "integrity": "sha512-1k4WCTstvGiTJQYUNTZhz4BM2DI/kVBRXh1Dw5fVaVDiGVb4YOuzxFNgipOWX/EllrjRTN5NNGLJEDk0nsMFEg==", + "dev": true }, "@xrplf/secret-numbers": { "version": "2.0.0", @@ -25924,7 +26069,8 @@ "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "devOptional": true }, "ansi-styles": { "version": "4.3.0", @@ -25988,7 +26134,8 @@ "aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==" + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true }, "arr-diff": { "version": "4.0.0", @@ -26012,6 +26159,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, "requires": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" @@ -27326,7 +27474,8 @@ "css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==" + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true }, "cssesc": { "version": "3.0.0", @@ -27810,6 +27959,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, "requires": { "array-buffer-byte-length": "^1.0.0", "call-bind": "^1.0.5", @@ -27953,7 +28103,8 @@ "diff-sequences": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", - "integrity": "sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==" + "integrity": "sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==", + "dev": true }, "dir-glob": { "version": "3.0.1", @@ -27982,7 +28133,8 @@ "dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true }, "dom-helpers": { "version": "5.2.1", @@ -28405,6 +28557,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, "requires": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", @@ -29946,7 +30099,8 @@ "functions-have-names": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==" + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true }, "gauge": { "version": "3.0.2", @@ -30257,7 +30411,8 @@ "has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==" + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true }, "has-flag": { "version": "4.0.0", @@ -30645,6 +30800,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, "requires": { "es-errors": "^1.3.0", "hasown": "^2.0.2", @@ -30691,6 +30847,7 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, "requires": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -30719,6 +30876,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, "requires": { "has-bigints": "^1.0.2" } @@ -30736,6 +30894,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, "requires": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" @@ -30794,6 +30953,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, "requires": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" @@ -30884,7 +31044,8 @@ "is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==" + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true }, "is-nan": { "version": "1.3.2", @@ -30904,6 +31065,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, "requires": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" @@ -30946,12 +31108,14 @@ "is-set": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==" + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true }, "is-shared-array-buffer": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, "requires": { "call-bound": "^1.0.3" } @@ -30967,6 +31131,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, "requires": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" @@ -30982,6 +31147,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, "requires": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", @@ -31004,7 +31170,8 @@ "is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==" + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true }, "is-weakref": { "version": "1.1.1", @@ -31019,6 +31186,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, "requires": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" @@ -31042,7 +31210,8 @@ "isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true }, "isexe": { "version": "2.0.0", @@ -31361,6 +31530,7 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz", "integrity": "sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA==", + "dev": true, "requires": { "chalk": "^4.0.0", "diff-sequences": "^26.6.2", @@ -32210,7 +32380,8 @@ "jest-get-type": { "version": "26.3.0", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", - "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==" + "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==", + "dev": true }, "jest-haste-map": { "version": "29.7.0", @@ -33435,7 +33606,8 @@ "lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==" + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true }, "magic-string": { "version": "0.30.17", @@ -33757,7 +33929,8 @@ "min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==" + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true }, "minimatch": { "version": "9.0.5", @@ -34917,6 +35090,7 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", + "dev": true, "requires": { "@jest/types": "^26.6.2", "ansi-regex": "^5.0.0", @@ -34928,6 +35102,7 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, "requires": { "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", @@ -34940,6 +35115,7 @@ "version": "15.0.19", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.19.tgz", "integrity": "sha512-2XUaGVmyQjgyAZldf0D0c14vvo/yv0MhQBSTJcejMMaitsn3nxCB6TmH4G0ZQf+uxROOa9mpanoSm8h6SG/1ZA==", + "dev": true, "requires": { "@types/yargs-parser": "*" } @@ -35163,7 +35339,8 @@ "react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true }, "react-query": { "version": "3.39.3", @@ -35411,6 +35588,7 @@ "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, "requires": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", @@ -36193,6 +36371,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, "requires": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -36777,6 +36956,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, "requires": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" @@ -38282,6 +38462,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, "requires": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", @@ -38315,6 +38496,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, "requires": { "is-map": "^2.0.3", "is-set": "^2.0.3", diff --git a/package.json b/package.json index 893c5e064..c2a6c81bc 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,8 @@ "version": "1.5.0", "dependencies": { "@rollup/plugin-inject": "^5.0.5", - "@testing-library/jest-dom": "^5.17.0", - "@testing-library/react": "^12.1.5", "@vitejs/plugin-react": "^4.2.1", "@xrplf/isomorphic": "^1.0.0-beta.1", - "@xrplf/prettier-config": "^1.9.1", "assert": "^2.1.0", "autoprefixer": "^10.4.20", "axios": "^1.6.5", @@ -58,6 +55,9 @@ "@babel/eslint-parser": "^7.22.6", "@babel/preset-env": "^7.25.7", "@babel/preset-react": "^7.27.1", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^12.1.5", + "@testing-library/react-hooks": "^8.0.1", "@types/create-hash": "^1.2.6", "@types/enzyme": "^3.10.12", "@types/jest": "^26.0.24", @@ -69,6 +69,7 @@ "@types/testing-library__jest-dom": "^5.14.9", "@typescript-eslint/eslint-plugin": "^8.32.1", "@typescript-eslint/parser": "^8.32.1", + "@xrplf/prettier-config": "^1.9.1", "babel-jest": "^29.7.0", "cheerio": "1.0.0-rc.3", "enzyme": "3.11.0", diff --git a/public/locales/ca-CA/translations.json b/public/locales/ca-CA/translations.json index ecde7c3e8..0436db1a1 100644 --- a/public/locales/ca-CA/translations.json +++ b/public/locales/ca-CA/translations.json @@ -1,9 +1,6 @@ { "action": "acció", "assets": "Actius", - "assets.nft_tab_title": "NFTs", - "assets.issued_tab_title": "Tokens encunyats", - "assets.no_issued_message": "No s'han trobat Tokens", "assets.no_nfts_message": "No s'han trobat NFTs", "network": "Xarxa", "amendments": "Amendments", @@ -540,7 +537,6 @@ "holders": null, "trustlines": null, "website": null, - "assets.mpt_tab_title": null, "assets.no_mpts_message": null, "transaction_type_name_MPTokenIssuanceCreate": null, "transaction_type_name_MPTokenIssuanceDestroy": null, @@ -610,5 +606,90 @@ "vault_create_table_detail": null, "withdraws": null, "deletes": null, - "vault_delete_table_detail": null + "vault_delete_table_detail": null, + "account_flag_title_lsf_global_freeze": null, + "account_flag_title_lsf_disable_master": null, + "account_flag_title_lsf_default_ripple": null, + "account_flag_title_lsf_allow_trustline_clawback": null, + "account_flag_title_lsf_require_destination_tag": null, + "account_flag_title_lsf_no_freeze": null, + "account_flag_title_lsf_require_auth": null, + "account_flag_title_lsf_disallow_xrp": null, + "account_flag_title_lsf_disallow_incoming_trustline": null, + "account_flag_title_lsf_disallow_incoming_pay_chan": null, + "account_flag_title_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_title_asf_authorized_nft_token_minter": null, + "account_flag_title_lsf_disallow_incoming_check": null, + "account_flag_title_lsf_deposit_auth": null, + "account_flag_title_asf_account_txn_id": null, + "account_flag_description_lsf_global_freeze": null, + "account_flag_description_lsf_disable_master": null, + "account_flag_description_lsf_default_ripple": null, + "account_flag_description_lsf_allow_trustline_clawback": null, + "account_flag_description_lsf_require_destination_tag": null, + "account_flag_description_lsf_no_freeze": null, + "account_flag_description_lsf_require_auth": null, + "account_flag_description_lsf_disallow_xrp": null, + "account_flag_description_lsf_disallow_incoming_trustline": null, + "account_flag_description_lsf_disallow_incoming_pay_chan": null, + "account_flag_description_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_description_asf_authorized_nft_token_minter": null, + "account_flag_description_lsf_disallow_incoming_check": null, + "account_flag_description_lsf_deposit_auth": null, + "account_flag_description_asf_account_txn_id": null, + "account_page_address": null, + "account_page_address_tag": null, + "account_page_classic_address": null, + "account_page_deleted_account_label": null, + "account_page_deleted_account_warning": null, + "account_page_extended_address": null, + "account_page_domain": null, + "account_page_reserve_balance": null, + "account_page_xrp_balance": null, + "account_page_xrp_balance_in_usd": null, + "account_page_account_properties": null, + "account_page_flags": null, + "account_page_flag_status_enabled": null, + "account_page_flag_status_disabled": null, + "account_page_signers": null, + "account_page_signer_weight": null, + "account_page_details": null, + "account_page_current_sequence": null, + "account_page_ticket_count": null, + "account_page_email_hash": null, + "account_page_payment_channels": null, + "account_page_payment_channels_text": null, + "account_page_nft_minter": null, + "account_page_asset_held_title": null, + "account_page_asset_issued_title": null, + "account_page_asset_tab_iou": null, + "account_page_asset_tab_lptoken": null, + "account_page_asset_tab_mpt": null, + "account_page_asset_tab_nft": null, + "account_page_asset_table_column_amm_instance": null, + "account_page_asset_table_column_amm_pair": null, + "account_page_asset_table_column_asset_class": null, + "account_page_asset_table_column_balance": null, + "account_page_asset_table_column_balance_usd": null, + "account_page_asset_table_column_currency_code": null, + "account_page_asset_table_column_frozen": null, + "account_page_asset_table_column_highest_bid": null, + "account_page_asset_table_column_holders": null, + "account_page_asset_table_column_issuer": null, + "account_page_asset_table_column_locked": null, + "account_page_asset_table_column_lowest_ask": null, + "account_page_asset_table_column_price_usd": null, + "account_page_asset_table_column_share": null, + "account_page_asset_table_column_supply": null, + "account_page_asset_table_column_ticker": null, + "account_page_asset_table_column_token_id": null, + "account_page_asset_table_column_transfer_fee": null, + "account_page_asset_table_column_trustlines": null, + "account_page_asset_table_column_url": null, + "account_page_asset_table_mpt_locked_global": null, + "account_page_asset_table_mpt_locked_individual": null, + "account_page_asset_table_no_iou": null, + "account_page_asset_table_no_lptoken": null, + "account_page_asset_table_no_mpt": null, + "account_page_asset_table_no_nft": null } diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index 770a7e5b7..de71d9737 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -1,10 +1,6 @@ { "action": "action", "assets": "Assets", - "assets.nft_tab_title": "Non-Fungible Tokens", - "assets.mpt_tab_title": "Multi-Purpose Tokens", - "assets.issued_tab_title": "Issued Tokens", - "assets.no_issued_message": "No tokens found.", "assets.no_nfts_message": "No NFTs found.", "assets.no_mpts_message": "No MPTs found.", "network": "Network", @@ -617,5 +613,90 @@ "vault_create_table_detail": "vault for ", "withdraws": "withdraws", "deletes": "deletes", - "vault_delete_table_detail": "vault with id" + "vault_delete_table_detail": "vault with id", + "account_flag_title_lsf_global_freeze": "Global Freeze", + "account_flag_title_lsf_disable_master": "Master Key Disabled", + "account_flag_title_lsf_default_ripple": "Rippling", + "account_flag_title_lsf_allow_trustline_clawback": "Clawback", + "account_flag_title_lsf_require_destination_tag": "Require Destination Tag", + "account_flag_title_lsf_no_freeze": "No Freeze", + "account_flag_title_lsf_require_auth": "Require Authorization", + "account_flag_title_lsf_disallow_xrp": "No XRP Allowed", + "account_flag_title_lsf_disallow_incoming_trustline": "Block Trustlines", + "account_flag_title_lsf_disallow_incoming_pay_chan": "Block Payment Channels", + "account_flag_title_lsf_disallow_incoming_nft_token_offer": "Block NFT Offers", + "account_flag_title_asf_authorized_nft_token_minter": "NFT Minter", + "account_flag_title_lsf_disallow_incoming_check": "Block Checks", + "account_flag_title_lsf_deposit_auth": "Deposit Authorization", + "account_flag_title_asf_account_txn_id": "Track Account Latest Transaction", + "account_flag_description_lsf_global_freeze": "Freeze all assets issued by this account.", + "account_flag_description_lsf_disable_master": "Disable the account's master key pair.", + "account_flag_description_lsf_default_ripple": "Enable rippling on this account's trust lines by default.", + "account_flag_description_lsf_allow_trustline_clawback": "Allow account to claw back tokens it has issued.", + "account_flag_description_lsf_require_destination_tag": "Require a destination tag to send transactions to this account.", + "account_flag_description_lsf_no_freeze": "Permanently give up the ability to freeze individual trust lines or disable Global Freeze. This flag can never be disabled after being enabled.", + "account_flag_description_lsf_require_auth": "Require authorization for users to hold balances issued by this address.", + "account_flag_description_lsf_disallow_xrp": "XRP should not be sent to this account.", + "account_flag_description_lsf_disallow_incoming_trustline": "Block incoming trust lines.", + "account_flag_description_lsf_disallow_incoming_pay_chan": "Block incoming Payment Channels.", + "account_flag_description_lsf_disallow_incoming_nft_token_offer": "Block incoming NFTokenOffers.", + "account_flag_description_asf_authorized_nft_token_minter": "Enable to allow another account to mint non-fungible tokens (NFTokens) on this account's behalf.", + "account_flag_description_lsf_disallow_incoming_check": "Block incoming Checks.", + "account_flag_description_lsf_deposit_auth": "Enable Deposit Authorization on this account.", + "account_flag_description_asf_account_txn_id": "Track the ID of this account's most recent transaction.", + "account_page_address": "Address", + "account_page_address_tag": "Tag", + "account_page_classic_address": "Classic Address", + "account_page_deleted_account_label": "Account Deleted", + "account_page_deleted_account_warning": "This account has been deleted from the XRP Ledger. Historical data is shown for reference only.", + "account_page_extended_address": "Extended Address (X-Address)", + "account_page_domain": "Domain", + "account_page_reserve_balance": "Reserve Balance", + "account_page_xrp_balance": "XRP Balance", + "account_page_xrp_balance_in_usd": "XRP Balance (USD)", + "account_page_account_properties": "Account Properties", + "account_page_flags": "Flags", + "account_page_flag_status_enabled": "Enabled", + "account_page_flag_status_disabled": "Disabled", + "account_page_signers": "Signers", + "account_page_signer_weight": "Weight", + "account_page_details": "Details", + "account_page_current_sequence": "Current Sequence", + "account_page_ticket_count": "Ticket Count", + "account_page_email_hash": "Email Hash", + "account_page_payment_channels": "Payment Channels", + "account_page_payment_channels_text": "{{currency}} available in {{number}} channel(s)", + "account_page_nft_minter": "NFT Minter", + "account_page_asset_held_title": "Assets Held", + "account_page_asset_issued_title": "Assets Issued", + "account_page_asset_tab_iou": "IOUs ({{count}})", + "account_page_asset_tab_lptoken": "LP Tokens ({{count}})", + "account_page_asset_tab_mpt": "MPTs ({{count}})", + "account_page_asset_tab_nft": "NFTs ({{count}})", + "account_page_asset_table_column_amm_instance": "AMM Instance", + "account_page_asset_table_column_amm_pair": "AMM Pair", + "account_page_asset_table_column_asset_class": "Asset Class", + "account_page_asset_table_column_balance": "Balance", + "account_page_asset_table_column_balance_usd": "Balance (USD)", + "account_page_asset_table_column_currency_code": "Currency Code", + "account_page_asset_table_column_frozen": "Frozen", + "account_page_asset_table_column_highest_bid": "Highest Bid", + "account_page_asset_table_column_holders": "Holders", + "account_page_asset_table_column_issuer": "Issuer", + "account_page_asset_table_column_locked": "Locked", + "account_page_asset_table_column_lowest_ask": "Lowest Ask", + "account_page_asset_table_column_price_usd": "Price (USD)", + "account_page_asset_table_column_share": "Share (%)", + "account_page_asset_table_column_supply": "Supply", + "account_page_asset_table_column_ticker": "Ticker", + "account_page_asset_table_column_token_id": "Token ID", + "account_page_asset_table_column_transfer_fee": "Transfer Fee", + "account_page_asset_table_column_trustlines": "Trustlines", + "account_page_asset_table_column_url": "URL", + "account_page_asset_table_mpt_locked_global": "Global", + "account_page_asset_table_mpt_locked_individual": "Individual", + "account_page_asset_table_no_iou": "No IOUs found", + "account_page_asset_table_no_lptoken": "No LP Tokens found", + "account_page_asset_table_no_mpt": "No MPTs found", + "account_page_asset_table_no_nft": "No NFTs found" } diff --git a/public/locales/es-ES/translations.json b/public/locales/es-ES/translations.json index 91fa11979..4dbcdd926 100644 --- a/public/locales/es-ES/translations.json +++ b/public/locales/es-ES/translations.json @@ -1,9 +1,6 @@ { "action": "acción", "assets": "Activos", - "assets.nft_tab_title": "Tokens No-Fungibles", - "assets.issued_tab_title": "Tokens Emitidos", - "assets.no_issued_message": "No se han encontrado tokens.", "assets.no_nfts_message": "NFTs no encontrados.", "network": "Red", "amendments": null, @@ -551,7 +548,6 @@ "can_transfer": "Puede Transferir", "can_clawback": "Puede Recuperar", "search_results_banner": "¡La búsqueda de tokens por nombre y cuenta ya está disponible! Intenta buscar USD", - "assets.mpt_tab_title": null, "assets.no_mpts_message": null, "transaction_type_name_MPTokenIssuanceCreate": null, "transaction_type_name_MPTokenIssuanceDestroy": null, @@ -611,5 +607,90 @@ "vault_create_table_detail": null, "withdraws": null, "deletes": null, - "vault_delete_table_detail": null + "vault_delete_table_detail": null, + "account_flag_title_lsf_global_freeze": null, + "account_flag_title_lsf_disable_master": null, + "account_flag_title_lsf_default_ripple": null, + "account_flag_title_lsf_allow_trustline_clawback": null, + "account_flag_title_lsf_require_destination_tag": null, + "account_flag_title_lsf_no_freeze": null, + "account_flag_title_lsf_require_auth": null, + "account_flag_title_lsf_disallow_xrp": null, + "account_flag_title_lsf_disallow_incoming_trustline": null, + "account_flag_title_lsf_disallow_incoming_pay_chan": null, + "account_flag_title_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_title_asf_authorized_nft_token_minter": null, + "account_flag_title_lsf_disallow_incoming_check": null, + "account_flag_title_lsf_deposit_auth": null, + "account_flag_title_asf_account_txn_id": null, + "account_flag_description_lsf_global_freeze": null, + "account_flag_description_lsf_disable_master": null, + "account_flag_description_lsf_default_ripple": null, + "account_flag_description_lsf_allow_trustline_clawback": null, + "account_flag_description_lsf_require_destination_tag": null, + "account_flag_description_lsf_no_freeze": null, + "account_flag_description_lsf_require_auth": null, + "account_flag_description_lsf_disallow_xrp": null, + "account_flag_description_lsf_disallow_incoming_trustline": null, + "account_flag_description_lsf_disallow_incoming_pay_chan": null, + "account_flag_description_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_description_asf_authorized_nft_token_minter": null, + "account_flag_description_lsf_disallow_incoming_check": null, + "account_flag_description_lsf_deposit_auth": null, + "account_flag_description_asf_account_txn_id": null, + "account_page_address": null, + "account_page_address_tag": null, + "account_page_classic_address": null, + "account_page_deleted_account_label": null, + "account_page_deleted_account_warning": null, + "account_page_extended_address": null, + "account_page_domain": null, + "account_page_reserve_balance": null, + "account_page_xrp_balance": null, + "account_page_xrp_balance_in_usd": null, + "account_page_account_properties": null, + "account_page_flags": null, + "account_page_flag_status_enabled": null, + "account_page_flag_status_disabled": null, + "account_page_signers": null, + "account_page_signer_weight": null, + "account_page_details": null, + "account_page_current_sequence": null, + "account_page_ticket_count": null, + "account_page_email_hash": null, + "account_page_payment_channels": null, + "account_page_payment_channels_text": null, + "account_page_nft_minter": null, + "account_page_asset_held_title": null, + "account_page_asset_issued_title": null, + "account_page_asset_tab_iou": null, + "account_page_asset_tab_lptoken": null, + "account_page_asset_tab_mpt": null, + "account_page_asset_tab_nft": null, + "account_page_asset_table_column_amm_instance": null, + "account_page_asset_table_column_amm_pair": null, + "account_page_asset_table_column_asset_class": null, + "account_page_asset_table_column_balance": null, + "account_page_asset_table_column_balance_usd": null, + "account_page_asset_table_column_currency_code": null, + "account_page_asset_table_column_frozen": null, + "account_page_asset_table_column_highest_bid": null, + "account_page_asset_table_column_holders": null, + "account_page_asset_table_column_issuer": null, + "account_page_asset_table_column_locked": null, + "account_page_asset_table_column_lowest_ask": null, + "account_page_asset_table_column_price_usd": null, + "account_page_asset_table_column_share": null, + "account_page_asset_table_column_supply": null, + "account_page_asset_table_column_ticker": null, + "account_page_asset_table_column_token_id": null, + "account_page_asset_table_column_transfer_fee": null, + "account_page_asset_table_column_trustlines": null, + "account_page_asset_table_column_url": null, + "account_page_asset_table_mpt_locked_global": null, + "account_page_asset_table_mpt_locked_individual": null, + "account_page_asset_table_no_iou": null, + "account_page_asset_table_no_lptoken": null, + "account_page_asset_table_no_mpt": null, + "account_page_asset_table_no_nft": null } diff --git a/public/locales/fr-FR/translations.json b/public/locales/fr-FR/translations.json index 20c8f9de4..b7f236b75 100644 --- a/public/locales/fr-FR/translations.json +++ b/public/locales/fr-FR/translations.json @@ -1,9 +1,6 @@ { "action": "action", "assets": "Actifs", - "assets.nft_tab_title": "Jetons non fongibles", - "assets.issued_tab_title": "Tokens émis", - "assets.no_issued_message": "Aucun token trouvé", "assets.no_nfts_message": "Aucun NFT trouvé.", "network": "Réseau", "amendments": null, @@ -537,7 +534,6 @@ "holders": null, "trustlines": null, "website": null, - "assets.mpt_tab_title": null, "assets.no_mpts_message": null, "transaction_type_name_MPTokenIssuanceCreate": null, "transaction_type_name_MPTokenIssuanceDestroy": null, @@ -614,5 +610,90 @@ "vault_create_table_detail": null, "withdraws": null, "deletes": null, - "vault_delete_table_detail": null + "vault_delete_table_detail": null, + "account_flag_title_lsf_global_freeze": null, + "account_flag_title_lsf_disable_master": null, + "account_flag_title_lsf_default_ripple": null, + "account_flag_title_lsf_allow_trustline_clawback": null, + "account_flag_title_lsf_require_destination_tag": null, + "account_flag_title_lsf_no_freeze": null, + "account_flag_title_lsf_require_auth": null, + "account_flag_title_lsf_disallow_xrp": null, + "account_flag_title_lsf_disallow_incoming_trustline": null, + "account_flag_title_lsf_disallow_incoming_pay_chan": null, + "account_flag_title_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_title_asf_authorized_nft_token_minter": null, + "account_flag_title_lsf_disallow_incoming_check": null, + "account_flag_title_lsf_deposit_auth": null, + "account_flag_title_asf_account_txn_id": null, + "account_flag_description_lsf_global_freeze": null, + "account_flag_description_lsf_disable_master": null, + "account_flag_description_lsf_default_ripple": null, + "account_flag_description_lsf_allow_trustline_clawback": null, + "account_flag_description_lsf_require_destination_tag": null, + "account_flag_description_lsf_no_freeze": null, + "account_flag_description_lsf_require_auth": null, + "account_flag_description_lsf_disallow_xrp": null, + "account_flag_description_lsf_disallow_incoming_trustline": null, + "account_flag_description_lsf_disallow_incoming_pay_chan": null, + "account_flag_description_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_description_asf_authorized_nft_token_minter": null, + "account_flag_description_lsf_disallow_incoming_check": null, + "account_flag_description_lsf_deposit_auth": null, + "account_flag_description_asf_account_txn_id": null, + "account_page_address": null, + "account_page_address_tag": null, + "account_page_classic_address": null, + "account_page_deleted_account_label": null, + "account_page_deleted_account_warning": null, + "account_page_extended_address": null, + "account_page_domain": null, + "account_page_reserve_balance": null, + "account_page_xrp_balance": null, + "account_page_xrp_balance_in_usd": null, + "account_page_account_properties": null, + "account_page_flags": null, + "account_page_flag_status_enabled": null, + "account_page_flag_status_disabled": null, + "account_page_signers": null, + "account_page_signer_weight": null, + "account_page_details": null, + "account_page_current_sequence": null, + "account_page_ticket_count": null, + "account_page_email_hash": null, + "account_page_payment_channels": null, + "account_page_payment_channels_text": null, + "account_page_nft_minter": null, + "account_page_asset_held_title": null, + "account_page_asset_issued_title": null, + "account_page_asset_tab_iou": null, + "account_page_asset_tab_lptoken": null, + "account_page_asset_tab_mpt": null, + "account_page_asset_tab_nft": null, + "account_page_asset_table_column_amm_instance": null, + "account_page_asset_table_column_amm_pair": null, + "account_page_asset_table_column_asset_class": null, + "account_page_asset_table_column_balance": null, + "account_page_asset_table_column_balance_usd": null, + "account_page_asset_table_column_currency_code": null, + "account_page_asset_table_column_frozen": null, + "account_page_asset_table_column_highest_bid": null, + "account_page_asset_table_column_holders": null, + "account_page_asset_table_column_issuer": null, + "account_page_asset_table_column_locked": null, + "account_page_asset_table_column_lowest_ask": null, + "account_page_asset_table_column_price_usd": null, + "account_page_asset_table_column_share": null, + "account_page_asset_table_column_supply": null, + "account_page_asset_table_column_ticker": null, + "account_page_asset_table_column_token_id": null, + "account_page_asset_table_column_transfer_fee": null, + "account_page_asset_table_column_trustlines": null, + "account_page_asset_table_column_url": null, + "account_page_asset_table_mpt_locked_global": null, + "account_page_asset_table_mpt_locked_individual": null, + "account_page_asset_table_no_iou": null, + "account_page_asset_table_no_lptoken": null, + "account_page_asset_table_no_mpt": null, + "account_page_asset_table_no_nft": null } diff --git a/public/locales/ja-JP/translations.json b/public/locales/ja-JP/translations.json index 2dbd8dbf9..a69ef3856 100644 --- a/public/locales/ja-JP/translations.json +++ b/public/locales/ja-JP/translations.json @@ -1,9 +1,6 @@ { "action": "アクション", "assets": "資産", - "assets.nft_tab_title": "非代替性トークン", - "assets.issued_tab_title": "発行済み通貨", - "assets.no_issued_message": "トークンが見つかりません。", "assets.no_nfts_message": "NFTが見つかりません。", "network": "ネットワーク", "amendments": "Amendment", @@ -536,7 +533,6 @@ "holders": "保有者: {{holders}}", "trustlines": "トラストライン: {{trustlines}}", "website": "Webサイト", - "assets.mpt_tab_title": null, "assets.no_mpts_message": null, "transaction_type_name_MPTokenIssuanceCreate": null, "transaction_type_name_MPTokenIssuanceDestroy": null, @@ -613,5 +609,90 @@ "vault_create_table_detail": null, "withdraws": null, "deletes": null, - "vault_delete_table_detail": null + "vault_delete_table_detail": null, + "account_flag_title_lsf_global_freeze": null, + "account_flag_title_lsf_disable_master": null, + "account_flag_title_lsf_default_ripple": null, + "account_flag_title_lsf_allow_trustline_clawback": null, + "account_flag_title_lsf_require_destination_tag": null, + "account_flag_title_lsf_no_freeze": null, + "account_flag_title_lsf_require_auth": null, + "account_flag_title_lsf_disallow_xrp": null, + "account_flag_title_lsf_disallow_incoming_trustline": null, + "account_flag_title_lsf_disallow_incoming_pay_chan": null, + "account_flag_title_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_title_asf_authorized_nft_token_minter": null, + "account_flag_title_lsf_disallow_incoming_check": null, + "account_flag_title_lsf_deposit_auth": null, + "account_flag_title_asf_account_txn_id": null, + "account_flag_description_lsf_global_freeze": null, + "account_flag_description_lsf_disable_master": null, + "account_flag_description_lsf_default_ripple": null, + "account_flag_description_lsf_allow_trustline_clawback": null, + "account_flag_description_lsf_require_destination_tag": null, + "account_flag_description_lsf_no_freeze": null, + "account_flag_description_lsf_require_auth": null, + "account_flag_description_lsf_disallow_xrp": null, + "account_flag_description_lsf_disallow_incoming_trustline": null, + "account_flag_description_lsf_disallow_incoming_pay_chan": null, + "account_flag_description_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_description_asf_authorized_nft_token_minter": null, + "account_flag_description_lsf_disallow_incoming_check": null, + "account_flag_description_lsf_deposit_auth": null, + "account_flag_description_asf_account_txn_id": null, + "account_page_address": null, + "account_page_address_tag": null, + "account_page_classic_address": null, + "account_page_deleted_account_label": null, + "account_page_deleted_account_warning": null, + "account_page_extended_address": null, + "account_page_domain": null, + "account_page_reserve_balance": null, + "account_page_xrp_balance": null, + "account_page_xrp_balance_in_usd": null, + "account_page_account_properties": null, + "account_page_flags": null, + "account_page_flag_status_enabled": null, + "account_page_flag_status_disabled": null, + "account_page_signers": null, + "account_page_signer_weight": null, + "account_page_details": null, + "account_page_current_sequence": null, + "account_page_ticket_count": null, + "account_page_email_hash": null, + "account_page_payment_channels": null, + "account_page_payment_channels_text": null, + "account_page_nft_minter": null, + "account_page_asset_held_title": null, + "account_page_asset_issued_title": null, + "account_page_asset_tab_iou": null, + "account_page_asset_tab_lptoken": null, + "account_page_asset_tab_mpt": null, + "account_page_asset_tab_nft": null, + "account_page_asset_table_column_amm_instance": null, + "account_page_asset_table_column_amm_pair": null, + "account_page_asset_table_column_asset_class": null, + "account_page_asset_table_column_balance": null, + "account_page_asset_table_column_balance_usd": null, + "account_page_asset_table_column_currency_code": null, + "account_page_asset_table_column_frozen": null, + "account_page_asset_table_column_highest_bid": null, + "account_page_asset_table_column_holders": null, + "account_page_asset_table_column_issuer": null, + "account_page_asset_table_column_locked": null, + "account_page_asset_table_column_lowest_ask": null, + "account_page_asset_table_column_price_usd": null, + "account_page_asset_table_column_share": null, + "account_page_asset_table_column_supply": null, + "account_page_asset_table_column_ticker": null, + "account_page_asset_table_column_token_id": null, + "account_page_asset_table_column_transfer_fee": null, + "account_page_asset_table_column_trustlines": null, + "account_page_asset_table_column_url": null, + "account_page_asset_table_mpt_locked_global": null, + "account_page_asset_table_mpt_locked_individual": null, + "account_page_asset_table_no_iou": null, + "account_page_asset_table_no_lptoken": null, + "account_page_asset_table_no_mpt": null, + "account_page_asset_table_no_nft": null } diff --git a/public/locales/ko-KR/translations.json b/public/locales/ko-KR/translations.json index 172a91f55..09e410363 100644 --- a/public/locales/ko-KR/translations.json +++ b/public/locales/ko-KR/translations.json @@ -1,9 +1,6 @@ { "action": "action", "assets": "자산", - "assets.nft_tab_title": "NFT", - "assets.issued_tab_title": "발행된 토큰", - "assets.no_issued_message": "토큰을 찾을 수 없습니다", "no_tokens_message": "토큰을 찾을 수 없습니다", "assets.no_nfts_message": "NFT를 찾을 수 없습니다.", "network": "네트워크", @@ -534,7 +531,6 @@ "holders": null, "trustlines": null, "website": null, - "assets.mpt_tab_title": null, "assets.no_mpts_message": null, "transaction_type_name_MPTokenIssuanceCreate": null, "transaction_type_name_MPTokenIssuanceDestroy": null, @@ -611,5 +607,90 @@ "vault_create_table_detail": null, "withdraws": null, "deletes": null, - "vault_delete_table_detail": null + "vault_delete_table_detail": null, + "account_flag_title_lsf_global_freeze": null, + "account_flag_title_lsf_disable_master": null, + "account_flag_title_lsf_default_ripple": null, + "account_flag_title_lsf_allow_trustline_clawback": null, + "account_flag_title_lsf_require_destination_tag": null, + "account_flag_title_lsf_no_freeze": null, + "account_flag_title_lsf_require_auth": null, + "account_flag_title_lsf_disallow_xrp": null, + "account_flag_title_lsf_disallow_incoming_trustline": null, + "account_flag_title_lsf_disallow_incoming_pay_chan": null, + "account_flag_title_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_title_asf_authorized_nft_token_minter": null, + "account_flag_title_lsf_disallow_incoming_check": null, + "account_flag_title_lsf_deposit_auth": null, + "account_flag_title_asf_account_txn_id": null, + "account_flag_description_lsf_global_freeze": null, + "account_flag_description_lsf_disable_master": null, + "account_flag_description_lsf_default_ripple": null, + "account_flag_description_lsf_allow_trustline_clawback": null, + "account_flag_description_lsf_require_destination_tag": null, + "account_flag_description_lsf_no_freeze": null, + "account_flag_description_lsf_require_auth": null, + "account_flag_description_lsf_disallow_xrp": null, + "account_flag_description_lsf_disallow_incoming_trustline": null, + "account_flag_description_lsf_disallow_incoming_pay_chan": null, + "account_flag_description_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_description_asf_authorized_nft_token_minter": null, + "account_flag_description_lsf_disallow_incoming_check": null, + "account_flag_description_lsf_deposit_auth": null, + "account_flag_description_asf_account_txn_id": null, + "account_page_address": null, + "account_page_address_tag": null, + "account_page_classic_address": null, + "account_page_deleted_account_label": null, + "account_page_deleted_account_warning": null, + "account_page_extended_address": null, + "account_page_domain": null, + "account_page_reserve_balance": null, + "account_page_xrp_balance": null, + "account_page_xrp_balance_in_usd": null, + "account_page_account_properties": null, + "account_page_flags": null, + "account_page_flag_status_enabled": null, + "account_page_flag_status_disabled": null, + "account_page_signers": null, + "account_page_signer_weight": null, + "account_page_details": null, + "account_page_current_sequence": null, + "account_page_ticket_count": null, + "account_page_email_hash": null, + "account_page_payment_channels": null, + "account_page_payment_channels_text": null, + "account_page_nft_minter": null, + "account_page_asset_held_title": null, + "account_page_asset_issued_title": null, + "account_page_asset_tab_iou": null, + "account_page_asset_tab_lptoken": null, + "account_page_asset_tab_mpt": null, + "account_page_asset_tab_nft": null, + "account_page_asset_table_column_amm_instance": null, + "account_page_asset_table_column_amm_pair": null, + "account_page_asset_table_column_asset_class": null, + "account_page_asset_table_column_balance": null, + "account_page_asset_table_column_balance_usd": null, + "account_page_asset_table_column_currency_code": null, + "account_page_asset_table_column_frozen": null, + "account_page_asset_table_column_highest_bid": null, + "account_page_asset_table_column_holders": null, + "account_page_asset_table_column_issuer": null, + "account_page_asset_table_column_locked": null, + "account_page_asset_table_column_lowest_ask": null, + "account_page_asset_table_column_price_usd": null, + "account_page_asset_table_column_share": null, + "account_page_asset_table_column_supply": null, + "account_page_asset_table_column_ticker": null, + "account_page_asset_table_column_token_id": null, + "account_page_asset_table_column_transfer_fee": null, + "account_page_asset_table_column_trustlines": null, + "account_page_asset_table_column_url": null, + "account_page_asset_table_mpt_locked_global": null, + "account_page_asset_table_mpt_locked_individual": null, + "account_page_asset_table_no_iou": null, + "account_page_asset_table_no_lptoken": null, + "account_page_asset_table_no_mpt": null, + "account_page_asset_table_no_nft": null } diff --git a/public/locales/my-MM/translations.json b/public/locales/my-MM/translations.json index ab6ae68bf..49b3af57f 100644 --- a/public/locales/my-MM/translations.json +++ b/public/locales/my-MM/translations.json @@ -1,10 +1,6 @@ { "action": "လုပ်ဆောင်ချက်", "assets": "ပိုင်ဆိုင်မှုများ", - "assets.nft_tab_title": "မတူညီသော Tokens များ", - "assets.mpt_tab_title": "ရည်ရွယ်ချက်များစွာသုံး Tokens များ", - "assets.issued_tab_title": "ထုတ်ပြန်ထားသော Tokens များ", - "assets.no_issued_message": "Token များ မတွေ့ရှိပါ။", "assets.no_nfts_message": "NFT များ မတွေ့ရှိပါ။", "assets.no_mpts_message": "MPT များ မတွေ့ရှိပါ။", "network": "ကွန်ယက်", @@ -611,5 +607,90 @@ "vault_create_table_detail": null, "withdraws": null, "deletes": null, - "vault_delete_table_detail": null + "vault_delete_table_detail": null, + "account_flag_title_lsf_global_freeze": null, + "account_flag_title_lsf_disable_master": null, + "account_flag_title_lsf_default_ripple": null, + "account_flag_title_lsf_allow_trustline_clawback": null, + "account_flag_title_lsf_require_destination_tag": null, + "account_flag_title_lsf_no_freeze": null, + "account_flag_title_lsf_require_auth": null, + "account_flag_title_lsf_disallow_xrp": null, + "account_flag_title_lsf_disallow_incoming_trustline": null, + "account_flag_title_lsf_disallow_incoming_pay_chan": null, + "account_flag_title_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_title_asf_authorized_nft_token_minter": null, + "account_flag_title_lsf_disallow_incoming_check": null, + "account_flag_title_lsf_deposit_auth": null, + "account_flag_title_asf_account_txn_id": null, + "account_flag_description_lsf_global_freeze": null, + "account_flag_description_lsf_disable_master": null, + "account_flag_description_lsf_default_ripple": null, + "account_flag_description_lsf_allow_trustline_clawback": null, + "account_flag_description_lsf_require_destination_tag": null, + "account_flag_description_lsf_no_freeze": null, + "account_flag_description_lsf_require_auth": null, + "account_flag_description_lsf_disallow_xrp": null, + "account_flag_description_lsf_disallow_incoming_trustline": null, + "account_flag_description_lsf_disallow_incoming_pay_chan": null, + "account_flag_description_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_description_asf_authorized_nft_token_minter": null, + "account_flag_description_lsf_disallow_incoming_check": null, + "account_flag_description_lsf_deposit_auth": null, + "account_flag_description_asf_account_txn_id": null, + "account_page_address": null, + "account_page_address_tag": null, + "account_page_classic_address": null, + "account_page_deleted_account_label": null, + "account_page_deleted_account_warning": null, + "account_page_extended_address": null, + "account_page_domain": null, + "account_page_reserve_balance": null, + "account_page_xrp_balance": null, + "account_page_xrp_balance_in_usd": null, + "account_page_account_properties": null, + "account_page_flags": null, + "account_page_flag_status_enabled": null, + "account_page_flag_status_disabled": null, + "account_page_signers": null, + "account_page_signer_weight": null, + "account_page_details": null, + "account_page_current_sequence": null, + "account_page_ticket_count": null, + "account_page_email_hash": null, + "account_page_payment_channels": null, + "account_page_payment_channels_text": null, + "account_page_nft_minter": null, + "account_page_asset_held_title": null, + "account_page_asset_issued_title": null, + "account_page_asset_tab_iou": null, + "account_page_asset_tab_lptoken": null, + "account_page_asset_tab_mpt": null, + "account_page_asset_tab_nft": null, + "account_page_asset_table_column_amm_instance": null, + "account_page_asset_table_column_amm_pair": null, + "account_page_asset_table_column_asset_class": null, + "account_page_asset_table_column_balance": null, + "account_page_asset_table_column_balance_usd": null, + "account_page_asset_table_column_currency_code": null, + "account_page_asset_table_column_frozen": null, + "account_page_asset_table_column_highest_bid": null, + "account_page_asset_table_column_holders": null, + "account_page_asset_table_column_issuer": null, + "account_page_asset_table_column_locked": null, + "account_page_asset_table_column_lowest_ask": null, + "account_page_asset_table_column_price_usd": null, + "account_page_asset_table_column_share": null, + "account_page_asset_table_column_supply": null, + "account_page_asset_table_column_ticker": null, + "account_page_asset_table_column_token_id": null, + "account_page_asset_table_column_transfer_fee": null, + "account_page_asset_table_column_trustlines": null, + "account_page_asset_table_column_url": null, + "account_page_asset_table_mpt_locked_global": null, + "account_page_asset_table_mpt_locked_individual": null, + "account_page_asset_table_no_iou": null, + "account_page_asset_table_no_lptoken": null, + "account_page_asset_table_no_mpt": null, + "account_page_asset_table_no_nft": null } diff --git a/src/containers/Accounts/AMM/AMMAccounts/index.tsx b/src/containers/Accounts/AMM/AMMAccounts/index.tsx index b3361d61a..5ba391f00 100644 --- a/src/containers/Accounts/AMM/AMMAccounts/index.tsx +++ b/src/containers/Accounts/AMM/AMMAccounts/index.tsx @@ -6,7 +6,7 @@ import '../../styles.scss' import { formatAmount } from '../../../../rippled/lib/txSummary/formatAmount' import { getAccountInfo, - getAMMInfo, + getAMMInfoByAssets, getLedgerEntry, } from '../../../../rippled/lib/rippled' import { Tabs } from '../../../shared/components/Tabs' @@ -76,7 +76,7 @@ export const AMMAccounts = () => { asset1 = temp } - return getAMMInfo(rippledSocket, asset1, asset2) + return getAMMInfoByAssets(rippledSocket, asset1, asset2) }) /* Use the assets to get the AMM Info. diff --git a/src/containers/Accounts/AMM/AMMAccounts/test/AMMAccounts.test.tsx b/src/containers/Accounts/AMM/AMMAccounts/test/AMMAccounts.test.tsx index 8f3a4f260..ac0ca7746 100644 --- a/src/containers/Accounts/AMM/AMMAccounts/test/AMMAccounts.test.tsx +++ b/src/containers/Accounts/AMM/AMMAccounts/test/AMMAccounts.test.tsx @@ -12,7 +12,7 @@ import { ACCOUNT_ROUTE } from '../../../../App/routes' function setSpy(accountInfo: any, getLedgerEntry: any, ammInfo: any) { const spyAccountInfo = jest.spyOn(rippled, 'getAccountInfo') const spyLedgerEntry = jest.spyOn(rippled, 'getLedgerEntry') - const spyInfo = jest.spyOn(rippled, 'getAMMInfo') + const spyInfo = jest.spyOn(rippled, 'getAMMInfoByAssets') spyAccountInfo.mockReturnValue( new Promise((resolve) => { resolve(accountInfo) diff --git a/src/containers/Accounts/AccountAsset/FutureDataIcon.scss b/src/containers/Accounts/AccountAsset/FutureDataIcon.scss new file mode 100644 index 000000000..a3495a0ff --- /dev/null +++ b/src/containers/Accounts/AccountAsset/FutureDataIcon.scss @@ -0,0 +1,14 @@ +@use '../../shared/css/variables' as *; + +.future-data { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: default; + + .clock-icon { + width: 16px; + height: 16px; + color: $black-50; + } +} diff --git a/src/containers/Accounts/AccountAsset/FutureDataIcon.tsx b/src/containers/Accounts/AccountAsset/FutureDataIcon.tsx new file mode 100644 index 000000000..6bdb1199c --- /dev/null +++ b/src/containers/Accounts/AccountAsset/FutureDataIcon.tsx @@ -0,0 +1,45 @@ +import ClockIcon from '../../shared/images/clock-icon.svg' +import { useTooltip } from '../../shared/components/Tooltip' +import './FutureDataIcon.scss' + +const TOOLTIP_X_OFFSET = 10 +const TOOLTIP_Y_OFFSET = -120 + +interface FutureDataIconProps { + message?: string +} +/** + * Displays an icon with a tooltip indicating that the associated data will be available in a future release. + * + * Note: For the tooltip functionality to work, ensure that: + * 1. The parent component is wrapped with ``. + * 2. A `` component is rendered within the component tree. + */ +export const FutureDataIcon = ({ + message = 'This data will be provided in a future release.', +}: FutureDataIconProps) => { + const { showTooltip, hideTooltip } = useTooltip() + + const handleShow = (e: React.MouseEvent | React.FocusEvent) => { + const rect = e.currentTarget.getBoundingClientRect() + showTooltip('text', e as any, message, { + // The tooltip position is adjusted to appear below and slightly to the right of the icon + x: rect.left + window.scrollX + TOOLTIP_X_OFFSET, + y: rect.top + window.scrollY + TOOLTIP_Y_OFFSET, + }) + } + + return ( + hideTooltip()} + onFocus={handleShow} + onBlur={() => hideTooltip()} + tabIndex={0} + role="button" + > + + + ) +} diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx new file mode 100644 index 000000000..80810a977 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx @@ -0,0 +1,420 @@ +import { useTranslation } from 'react-i18next' +import { useQuery } from 'react-query' +import { useEffect, useContext, useState, useCallback, useMemo } from 'react' +import Currency, { + LP_TOKEN_IDENTIFIER, +} from '../../../shared/components/Currency' +import { Loader } from '../../../shared/components/Loader' +import { Account } from '../../../shared/components/Account' +import { + ACCOUNT_FLAGS, + buildFlags, + formatTransferFee, +} from '../../../../rippled/lib/utils' +import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' +import { RouteLink } from '../../../shared/routing' +import { TOKEN_ROUTE } from '../../../App/routes' +import SocketContext from '../../../shared/SocketContext' +import { shortenAccount } from '../../../shared/utils' +import { + getBalances, + getAccountLines, + getAccountInfo, +} from '../../../../rippled/lib/rippled' +import logger from '../../../../rippled/lib/logger' +import DefaultTokenIcon from '../../../shared/images/default_token_icon.svg' + +import { useLanguage } from '../../../shared/hooks' +import { calculateFormattedUsdBalance } from '../../../shared/NumberFormattingUtils' + +const log = logger({ name: 'HeldIOUs' }) + +// The LOS Token API has a batch size limit of 100; larger requests will be rejected. +const LOS_TOKEN_API_BATCH_SIZE = 100 + +interface HeldIOUsProps { + accountId: string + onChange?: (data: { count: number; isLoading: boolean }) => void +} + +interface IOU { + tokenCode: string + tokenIcon?: string + issuer: string + issuerName: string + balance: number + priceInUSD: number + assetClass: string + transferFee?: string + frozen?: string +} + +const fetchAccountHeldIOUs = async ( + rippledSocket: any, + accountId: string, +): Promise => { + let balancesResponse + try { + balancesResponse = await getBalances(rippledSocket, accountId) + } catch (error) { + log.error( + `Error calling 'gatewayBalances' for account ${accountId}: ${JSON.stringify(error)}`, + ) + return [] + } + + // Collect all tokens, whether they are LP Tokens or IOUs + const assetTokens: any[] = [] + for (const assets of Object.values(balancesResponse?.assets ?? {})) { + for (const asset of assets as any[]) { + if (asset.currency) { + assetTokens.push(asset.currency) + } + } + } + if (assetTokens.length === 0) { + // No tokens held, return empty array + return [] + } + + // Get all trust lines using account_lines with pagination + const allTrustLines: any[] = [] + let marker = '' + do { + try { + // eslint-disable-next-line no-await-in-loop + const accountLinesResponse = await getAccountLines( + rippledSocket, + accountId, + 400, + marker, + ) + + if (accountLinesResponse?.lines) { + allTrustLines.push(...accountLinesResponse.lines) + } + + marker = accountLinesResponse?.marker || '' + } catch (error) { + log.error( + `Error fetching account lines for account ${accountId}: ${JSON.stringify(error)}`, + ) + // Break the loop on error to avoid infinite retry + break + } + } while (marker) + + // Keep positive balances only (no LP token filtering at this stage) + const positiveBalanceLines = allTrustLines.filter( + (line: any) => parseFloat(line.balance) > 0, + ) + + if (positiveBalanceLines.length === 0) { + return [] + } + + // Batch get token data from LOS Token API + const allTokenIds = positiveBalanceLines.map( + (line: any) => `${line.currency}.${line.account}`, + ) + let allTokensFromLOS: Record = {} + for (let i = 0; i < allTokenIds.length; i += LOS_TOKEN_API_BATCH_SIZE) { + const tokenIds = allTokenIds.slice(i, i + LOS_TOKEN_API_BATCH_SIZE) + try { + // eslint-disable-next-line no-await-in-loop + const apiResponse = await fetch( + `${process.env.VITE_LOS_URL}/tokens/batch-get`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tokenIds }), + }, + ) + + if (apiResponse.ok) { + // eslint-disable-next-line no-await-in-loop + const responseBody = await apiResponse.json() + const tokensFromLOS = + responseBody.tokens?.reduce((acc: any, token: any) => { + acc[`${token.currency}.${token.issuer_account}`] = { + ...token, + } + return acc + }, {}) || {} + allTokensFromLOS = { ...allTokensFromLOS, ...tokensFromLOS } + } + } catch (error) { + log.error( + `Error batch-get tokens[${tokenIds.join(', ')}]. Error: ${JSON.stringify(error)}`, + ) + } + } + + // Combine all data (without transfer fees and Global freeze status for now) + const tokens: IOU[] = positiveBalanceLines.map((line: any) => { + const tokenId = `${line.currency}.${line.account}` + const token = allTokensFromLOS[tokenId] + + return { + tokenCode: line.currency, + tokenIcon: token?.icon, + issuer: line.account, + issuerName: token?.issuer_name, + balance: parseFloat(line.balance), + priceInUSD: token?.price_usd ? parseFloat(token.price_usd) : 0, + assetClass: token?.asset_class || '--', + transferFee: '--', + frozen: line.freeze || line.freeze_peer ? 'Trustline' : '--', + } + }) + + return tokens +} + +/** + * Held IOUs rendering flow: + * 1. Initially display all tokens whose currency codes don't start with `03` (since `03` may indicate LP tokens). + * 2. After confirming a `03` token is not an LP token, add it to the table. + * 3. Progressively enrich each token row with transfer fee and account-level freeze status as that data loads. + */ +export const HeldIOUs = ({ accountId, onChange }: HeldIOUsProps) => { + const lang = useLanguage() + const { t } = useTranslation() + const rippledSocket = useContext(SocketContext) + + const [progressiveUpdates, setProgressiveUpdates] = useState< + Record + >({}) + const [confirmedNonLPTokens, setConfirmedNonLPTokens] = useState>( + new Set(), + ) + const [lpTokenCheckComplete, setLpTokenCheckComplete] = useState(false) + + const heldIOUsQuery = useQuery(['heldIOUs', accountId], () => + fetchAccountHeldIOUs(rippledSocket, accountId), + ) + + // Filter out ALL '03' tokens initially, then add back confirmed non-LP tokens + const sortedIOUs = useMemo(() => { + const data = heldIOUsQuery.data || [] + const filteredData = data.filter((token) => { + // If it starts with '03', only include if confirmed as non-LP + if (token.tokenCode.startsWith(LP_TOKEN_IDENTIFIER)) { + return confirmedNonLPTokens.has(token.tokenCode) + } + // Include all non-'03' tokens + return true + }) + + return [...filteredData].sort( + (a, b) => b.priceInUSD * b.balance - a.priceInUSD * a.balance, + ) + }, [heldIOUsQuery.data, confirmedNonLPTokens]) + + // Apply progressive updates to the base IOU token data + const iouTokens = sortedIOUs.map((token) => { + const progressiveUpdate = progressiveUpdates[token.issuer] + return { + ...token, + transferFee: progressiveUpdate?.transferFee || token.transferFee, + frozen: progressiveUpdate?.accountGlobalFrozen ? 'Global' : token.frozen, + } + }) + + // Progressive fetching of account info with transfer fee and Global freeze status + const fetchAccountInfoProgressively = useCallback(async () => { + if (sortedIOUs.length === 0) { + return + } + + const uniqueIssuers = [...new Set(sortedIOUs.map((line) => line.issuer))] + for (const issuer of uniqueIssuers) { + try { + // eslint-disable-next-line no-await-in-loop + const accountInfo = await getAccountInfo(rippledSocket, issuer, false) + + const transferFee = formatTransferFee(accountInfo?.TransferRate, 'IOU') + + const accountGlobalFrozen = buildFlags( + accountInfo?.Flags, + ACCOUNT_FLAGS, + ).includes('lsfGlobalFreeze') + + setProgressiveUpdates((prev) => ({ + ...prev, + [issuer]: { + transferFee: `${transferFee}%`, + accountGlobalFrozen, + }, + })) + } catch (error) { + log.error( + `Error fetching account information: ${JSON.stringify(error)}`, + ) + } + } + }, [sortedIOUs, rippledSocket]) + + // Identify non-LP tokens - check which '03' tokens are regular IOUs (not LP tokens) + const identifyNonLPTokensProgressively = useCallback(async () => { + if (!heldIOUsQuery.data || heldIOUsQuery.data.length === 0) { + setLpTokenCheckComplete(true) + return + } + + // Group tokens by issuer to minimize getAccountInfo calls + const tokensByIssuer = new Map() + for (const token of heldIOUsQuery.data) { + if (token.tokenCode.startsWith(LP_TOKEN_IDENTIFIER)) { + const tokens = tokensByIssuer.get(token.issuer) || [] + tokens.push(token.tokenCode) + tokensByIssuer.set(token.issuer, tokens) + } + } + + for (const [issuer, tokenCodes] of tokensByIssuer.entries()) { + try { + // eslint-disable-next-line no-await-in-loop + const accountInfo = await getAccountInfo(rippledSocket, issuer, false) + + // If the issuer does NOT have an AMMID, these are regular IOUs (non-LP tokens) + if (!accountInfo.AMMID) { + setConfirmedNonLPTokens((prev) => { + const newSet = new Set(prev) + for (const tokenCode of tokenCodes) { + newSet.add(tokenCode) + } + return newSet + }) + } + } catch (error) { + log.warn( + `Error checking if issuer ${issuer} is AMM account: ${JSON.stringify(error)}`, + ) + // If we can't fetch account info, assume it's not an AMM to be safe + // Add these tokens to the confirmed non-LP list + setConfirmedNonLPTokens((prev) => { + const newSet = new Set(prev) + for (const tokenCode of tokenCodes) { + newSet.add(tokenCode) + } + return newSet + }) + } + } + + setLpTokenCheckComplete(true) + }, [heldIOUsQuery.data, rippledSocket]) + + // Start identifying non-LP tokens immediately after initial data loads + useEffect(() => { + if (heldIOUsQuery.data && heldIOUsQuery.data.length > 0) { + identifyNonLPTokensProgressively() + } else if (heldIOUsQuery.data && heldIOUsQuery.data.length === 0) { + setLpTokenCheckComplete(true) + } + }, [heldIOUsQuery.data, identifyNonLPTokensProgressively]) + + // Begin progressive updates once the LP token check is complete (we now have the final IOU list) + useEffect(() => { + if (lpTokenCheckComplete && sortedIOUs.length > 0) { + fetchAccountInfoProgressively() + } + }, [lpTokenCheckComplete, sortedIOUs.length, fetchAccountInfoProgressively]) + + // Communicate count and loading state back to parent + // Keep loading state true until LP token checks are complete + useEffect(() => { + if (onChange) { + onChange({ + count: iouTokens.length, + isLoading: heldIOUsQuery.isLoading || !lpTokenCheckComplete, + }) + } + }, [ + iouTokens.length, + heldIOUsQuery.isLoading, + lpTokenCheckComplete, + onChange, + ]) + + if (heldIOUsQuery.isLoading) { + return + } + + return ( +
+ + + + + + + + + + + + + + + {iouTokens.length === 0 ? ( + + {t('account_page_asset_table_no_iou')} + + ) : ( + iouTokens.map((token) => { + const { + formattedUsdPrice, + formattedBalance, + formattedBalanceUsd, + } = calculateFormattedUsdBalance( + token.balance, + token.priceInUSD, + lang, + ) + + return ( + + + + + + + + + + + ) + }) + )} + +
{t('account_page_asset_table_column_currency_code')}{t('account_page_asset_table_column_issuer')}{t('account_page_asset_table_column_price_usd')}{t('account_page_asset_table_column_balance')}{t('account_page_asset_table_column_balance_usd')}{t('account_page_asset_table_column_asset_class')}{t('account_page_asset_table_column_transfer_fee')}{t('account_page_asset_table_column_frozen')}
+ +
+ {token.tokenIcon ? ( + {token.tokenCode} + ) : ( + + )} + +
+
+
+ + {formattedUsdPrice}{formattedBalance}{formattedBalanceUsd}{token.assetClass}{token.transferFee || '--'}{token.frozen}
+
+ ) +} diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx new file mode 100644 index 000000000..509b603f4 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx @@ -0,0 +1,260 @@ +import { useTranslation } from 'react-i18next' +import { useQuery } from 'react-query' +import { useEffect, useContext, useMemo } from 'react' +import { Loader } from '../../../shared/components/Loader' +import SocketContext from '../../../shared/SocketContext' +import Currency, { + LP_TOKEN_IDENTIFIER, +} from '../../../shared/components/Currency' +import { Account } from '../../../shared/components/Account' +import { + formatUsdValue, + formatTokenBalance, +} from '../../../shared/NumberFormattingUtils' +import { useLanguage } from '../../../shared/hooks' +import { shortenAccount } from '../../../shared/utils' + +import { + getBalances, + getAMMInfoByAMMAccount, +} from '../../../../rippled/lib/rippled' +import { XRP_BASE } from '../../../shared/transactionUtils' +import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' +import logger from '../../../../rippled/lib/logger' + +const log = logger({ name: 'HeldLPTokens' }) + +const fetchAccountHeldLPTokens = async (rippledSocket, accountId) => { + let balancesResponse + try { + balancesResponse = await getBalances(rippledSocket, accountId) + } catch (error) { + log.error( + `Error calling gatewayBalances for account ${accountId}: ${JSON.stringify(error)}`, + ) + return [] + } + const lpTokens: any[] = [] + for (const [issuerAccount, assets] of Object.entries( + balancesResponse?.assets ?? {}, + )) { + for (const asset of assets as any[]) { + if (asset.currency && asset.currency.startsWith(LP_TOKEN_IDENTIFIER)) { + // eslint-disable-next-line no-await-in-loop + const result = await processLPTokenAsset( + rippledSocket, + issuerAccount, + asset, + ) + + // The currency code of an IOU could start with `LP_TOKEN_IDENTIFIER` as well, + // but `result` will be null since getAMMInfoByAMMAccount will throw an exception + if (result) { + lpTokens.push(result) + } + } + } + } + + return lpTokens +} + +const processLPTokenAsset = async (rippledSocket, issuerAccount, asset) => { + let ammInfoResponse + try { + ammInfoResponse = await getAMMInfoByAMMAccount(rippledSocket, issuerAccount) + } catch (error) { + log.error(`Error fetching AMM pool: ${JSON.stringify(error)}`) + return null + } + + if (!ammInfoResponse.amm) { + return null + } + + const ammData = ammInfoResponse.amm + + // Calculate share percentage + const accountLPTokenBalance = parseFloat(asset.value) + const ammLPTokenBalance = parseFloat(ammData.lp_token?.value || 0) + const sharePercentage = + ammLPTokenBalance > 0 + ? (100 * accountLPTokenBalance) / ammLPTokenBalance + : 0 + + const isAmount1XRP = typeof ammData.amount === 'string' + const isAmount2XRP = typeof ammData.amount2 === 'string' + + // Calculate LP Token price in XRP only when one of the assets is XRP + let lpTokenPriceInXRP = 0 + if (isAmount1XRP || isAmount2XRP) { + const xrpAmount = isAmount1XRP + ? parseFloat(ammData.amount) / XRP_BASE + : parseFloat(ammData.amount2) / XRP_BASE + + if (ammLPTokenBalance > 0 && xrpAmount > 0) { + // Price per LP token = (XRP in pool * 2) / total LP tokens + // We multiply by 2 because LP token represents share of both assets + lpTokenPriceInXRP = (xrpAmount * 2) / ammLPTokenBalance + } + } + + let currency1 + let currency2 + if (isAmount1XRP && !isAmount2XRP) { + currency1 = 'XRP' + currency2 = ammData.amount2.currency + } else if (!isAmount1XRP && isAmount2XRP) { + currency1 = 'XRP' + currency2 = ammData.amount.currency + } else { + // Both are tokens (no XRP) + currency1 = ammData.amount.currency + currency2 = ammData.amount2.currency + } + + return { + ammInstance: issuerAccount, + currency1, + currency2, + lpTokenBalance: accountLPTokenBalance, + lpTokenPriceInXRP, + share: sharePercentage, + } +} + +interface HeldLPTokensProps { + accountId: string + xrpToUSDRate: number + onChange?: (data: { count: number; isLoading: boolean }) => void +} + +export const HeldLPTokens = ({ + accountId, + xrpToUSDRate, + onChange, +}: HeldLPTokensProps) => { + const lang = useLanguage() + const { t } = useTranslation() + const rippledSocket = useContext(SocketContext) + + const heldLPTokensQuery = useQuery(['heldLPTokens', accountId], () => + fetchAccountHeldLPTokens(rippledSocket, accountId), + ) + + const lpTokenData = useMemo( + () => heldLPTokensQuery.data ?? [], + [heldLPTokensQuery.data], + ) + + // Two-tier sort: 1) XRP pairs first, 2) within XRP pairs, sort by USD value descending + const rows = lpTokenData.sort((a, b) => { + const aHasXRP = a.currency1 === 'XRP' || a.currency2 === 'XRP' + const bHasXRP = b.currency1 === 'XRP' || b.currency2 === 'XRP' + + // First, prioritize XRP pairs + if (aHasXRP && !bHasXRP) { + return -1 + } + if (!aHasXRP && bHasXRP) { + return 1 + } + + // Then sort by Balance USD descending (only for XRP pairs) + if (aHasXRP && bHasXRP) { + const aBalanceUSD = a.lpTokenBalance * a.lpTokenPriceInXRP * xrpToUSDRate + const bBalanceUSD = b.lpTokenBalance * b.lpTokenPriceInXRP * xrpToUSDRate + return bBalanceUSD - aBalanceUSD + } + + // For non-XRP pairs, maintain original order + return 0 + }) + + // Communicate count and loading state back to parent + useEffect(() => { + if (onChange) { + onChange({ count: rows.length, isLoading: heldLPTokensQuery.isLoading }) + } + }, [rows.length, heldLPTokensQuery.isLoading, onChange]) + + if (heldLPTokensQuery.isLoading) { + return + } + + const cols = [ + t('account_page_asset_table_column_amm_instance'), + t('account_page_asset_table_column_amm_pair'), + t('account_page_asset_table_column_balance'), + t('account_page_asset_table_column_balance_usd'), + t('account_page_asset_table_column_share'), + ] + return ( +
+ + + + {cols.map((c) => ( + + ))} + + + + {rows.length === 0 ? ( + + {t('account_page_asset_table_no_lptoken')} + + ) : ( + rows.map((row) => { + // Calculate display values using utility functions + const formattedBalance = formatTokenBalance( + row.lpTokenBalance, + lang, + ) + + // Format USD Balance (only for XRP pairs) + let formattedBalanceUsd = '--' + if (row.currency1 === 'XRP' || row.currency2 === 'XRP') { + const balanceUSD = + row.lpTokenBalance * row.lpTokenPriceInXRP * xrpToUSDRate + formattedBalanceUsd = formatUsdValue(balanceUSD, lang) + } + + return ( + + + + + + + + ) + }) + )} + +
{c}
+ + + + / + + {formattedBalance}{formattedBalanceUsd} + {row.share < 1 + ? row.share.toFixed(4) + : row.share.toFixed(2)} + % +
+
+ ) +} diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx new file mode 100644 index 000000000..ddcf90a39 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx @@ -0,0 +1,219 @@ +import { useQuery } from 'react-query' +import { useEffect, useContext } from 'react' +import { useTranslation } from 'react-i18next' +import { RouteLink } from '../../../shared/routing' +import { MPT_ROUTE } from '../../../App/routes' +import { Loader } from '../../../shared/components/Loader' +import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' +import { Account } from '../../../shared/components/Account' +import { + Tooltip, + useTooltip, + TooltipProvider, +} from '../../../shared/components/Tooltip' +import { + formatMPTIssuance, + formatMPToken, + formatTransferFee, +} from '../../../../rippled/lib/utils' +import { getAccountMPTs, getMPTIssuance } from '../../../../rippled/lib/rippled' +import SocketContext from '../../../shared/SocketContext' +import { + localizeNumber, + shortenAccount, + shortenMPTID, +} from '../../../shared/utils' +import { useLanguage } from '../../../shared/hooks' +import logger from '../../../../rippled/lib/logger' +import { FutureDataIcon } from '../FutureDataIcon' + +const log = logger({ name: 'HeldMPTs' }) + +interface HeldMPTsProps { + accountId: string + onChange?: (data: { count: number; isLoading: boolean }) => void +} + +const fetchAccountHeldMPTs = async (accountId: string, rippledSocket: any) => { + const mpts: any[] = [] + let marker = '' + do { + try { + // eslint-disable-next-line no-await-in-loop + const response = await getAccountMPTs(rippledSocket, accountId, marker) + if (!response?.account_objects) { + break + } + + mpts.push(...response.account_objects) + marker = response.marker || '' + } catch (error) { + log.error(`Error fetching MPTs: ${JSON.stringify(error)}`) + // Break the loop on error to avoid infinite retry + break + } + } while (marker) + + // Format and filter MPTs + const positiveBalanceMPTs = mpts + .map((mpToken: any) => formatMPToken(mpToken)) + .filter((mpToken: any) => parseInt(mpToken.mptAmount || '0', 10) > 0) + + // For each MPTokenIssuanceID, call getMPTIssuance and format the response + const mptIssuancePromises = positiveBalanceMPTs.map(async (mpToken: any) => { + try { + const mptIssuanceResponse = await getMPTIssuance( + rippledSocket, + mpToken.mptIssuanceID, + ) + + const formattedMPTIssuance = formatMPTIssuance(mptIssuanceResponse.node) + return { + mptIssuanceId: mpToken.mptIssuanceID, + mptIssuance: formattedMPTIssuance, + } + } catch (error) { + log.error( + `Error fetching MPT issuance for token ${mpToken.mptIssuanceID}: ${JSON.stringify(error)}`, + ) + return { mptIssuanceId: mpToken.mptIssuanceID, mptIssuance: null } + } + }) + + const mptIssuanceResults = await Promise.all(mptIssuancePromises) + const mptIssuanceIdToIssuance = new Map() + mptIssuanceResults.forEach((result) => { + if (result && result.mptIssuance) { + mptIssuanceIdToIssuance.set(result.mptIssuanceId, result.mptIssuance) + } + }) + + // Combine MPToken and MPTIssuance data + const combinedMPTs = positiveBalanceMPTs.map((mpToken: any) => { + const mptIssuance = mptIssuanceIdToIssuance.get(mpToken.mptIssuanceID) + + return { + tokenId: mpToken.mptIssuanceID, + balance: mpToken.mptAmount, + ticker: mptIssuance?.metadata?.Ticker || null, + issuer: mptIssuance?.issuer || '', + issuerName: mptIssuance?.metadata?.IssuerName || null, + assetClass: mptIssuance?.metadata?.AssetClass || null, + transferFee: formatTransferFee(mptIssuance?.transferFee, 'MPT'), + locked: (() => { + if (mptIssuance?.flags?.includes('lsfMPTLocked')) { + return 'Global' + } + if (mpToken.flags?.includes('lsfMPTLocked')) { + return 'Individual' + } + + return '' + })(), + } + }) + + return combinedMPTs +} + +const HeldMPTsContent = ({ accountId, onChange }: HeldMPTsProps) => { + const lang = useLanguage() + const { t } = useTranslation() + const rippledSocket = useContext(SocketContext) + const { tooltip } = useTooltip() + + const heldMPTsQuery = useQuery(['heldMPTs', accountId], () => + fetchAccountHeldMPTs(accountId, rippledSocket), + ) + // TODO: When MPT Dex is live, sort MPTs based on USD balances + const rows = heldMPTsQuery.data ?? [] + + // Communicate count and loading state back to parent + useEffect(() => { + if (onChange) { + onChange({ count: rows.length, isLoading: heldMPTsQuery.isLoading }) + } + }, [rows.length, heldMPTsQuery.isLoading, onChange]) + + if (heldMPTsQuery.isLoading) { + return + } + + return ( +
+ + + + + + + + + + + + + + + + + {rows.length === 0 ? ( + + {t('account_page_asset_table_no_mpt')} + + ) : ( + rows.map((token) => ( + + + + + + + + + + + + )) + )} + +
{t('account_page_asset_table_column_token_id')}{t('account_page_asset_table_column_ticker')}{t('account_page_asset_table_column_issuer')}{t('account_page_asset_table_column_price_usd')}{t('account_page_asset_table_column_balance')}{t('account_page_asset_table_column_balance_usd')}{t('account_page_asset_table_column_asset_class')}{t('account_page_asset_table_column_transfer_fee')}{t('account_page_asset_table_column_locked')}
+ + {shortenMPTID(token.tokenId)} + + {token.ticker ? token.ticker : } + + + + {localizeNumber(token.balance, lang)} + + + {token.assetClass ? token.assetClass : } + {token.transferFee}% + {(() => { + if (token.locked === '') { + return '--' + } + if (token.locked === 'Global') { + return t('account_page_asset_table_mpt_locked_global') + } + if (token.locked === 'Individual') { + return t('account_page_asset_table_mpt_locked_individual') + } + return token.locked + })()} +
+
+ ) +} + +export const HeldMPTs = ({ accountId, onChange }: HeldMPTsProps) => ( + + + +) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx new file mode 100644 index 000000000..107d813c3 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx @@ -0,0 +1,55 @@ +import { getAccountNFTs } from '../../../../rippled/lib/rippled' +import { formatTransferFee } from '../../../../rippled/lib/utils' +import { NFTTable, NFT } from './NFTTable' +import logger from '../../../../rippled/lib/logger' + +const log = logger({ name: 'HeldNFTs' }) + +interface HeldNFTsProps { + accountId: string + onChange?: (data: { count: number; isLoading: boolean }) => void +} + +const fetchAccountHeldNFTs = async ( + accountId: string, + rippledSocket: any, +): Promise => { + try { + const allNFTs: any[] = [] + let marker = '' + do { + // eslint-disable-next-line no-await-in-loop + const response = await getAccountNFTs( + rippledSocket, + accountId, + marker, + 10, // Not 10 NFTs, but 10 pages of NFTs + ) + if (response.account_nfts) { + allNFTs.push(...response.account_nfts) + } + + marker = response.marker || '' + } while (marker) + + return allNFTs.map((nft) => ({ + nftId: nft.NFTokenID, + issuer: nft.Issuer, + url: nft.URI ? Buffer.from(nft.URI, 'hex').toString('utf8') : '', + transferFee: formatTransferFee(nft.TransferFee, 'NFT'), + })) + } catch (error) { + log.error(`Error fetching held NFTs: ${JSON.stringify(error)}`) + return [] + } +} + +export const HeldNFTs = ({ accountId, onChange }: HeldNFTsProps) => ( + +) diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx new file mode 100644 index 000000000..5bef7089f --- /dev/null +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx @@ -0,0 +1,226 @@ +import { useTranslation } from 'react-i18next' +import { useQuery } from 'react-query' +import { useEffect, useContext, useMemo } from 'react' +import Currency from '../../../shared/components/Currency' +import { Loader } from '../../../shared/components/Loader' +import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' +import { RouteLink } from '../../../shared/routing' +import { TOKEN_ROUTE } from '../../../App/routes' +import SocketContext from '../../../shared/SocketContext' +import { getBalances } from '../../../../rippled/lib/rippled' +import logger from '../../../../rippled/lib/logger' +import DefaultTokenIcon from '../../../shared/images/default_token_icon.svg' +import { localizeNumber } from '../../../shared/utils' +import { useLanguage } from '../../../shared/hooks' +import { + formatUsdValue, + formatTokenBalance, +} from '../../../shared/NumberFormattingUtils' + +const log = logger({ name: 'IssuedIOUs' }) + +const LOS_TOKEN_API_BATCH_SIZE = 100 + +interface IssuedIOUsProps { + accountId: string + account: any + onChange?: (data: { count: number; isLoading: boolean }) => void +} + +interface IOU { + tokenCode: string + tokenIcon?: string + priceInUSD: number + trustlines: number + holders: number + supply: number + assetClass: string + transferFee: string + frozen: string +} + +const fetchAccountIssuedIOUs = async ( + rippledSocket: any, + accountId: string, + account: any, +): Promise => { + let balancesResponse + try { + balancesResponse = await getBalances(rippledSocket, accountId) + } catch (error) { + log.error( + `Error calling gatewayBalances for account ${accountId}: ${JSON.stringify(error)}`, + ) + return [] + } + + // We don't need to filter out LP tokens because if an account issued an LP token, + // it would be an AMM account and would be displayed on the AMM account page + // instead of a regular account page + const iouTokens: string[] = Object.keys(balancesResponse?.obligations ?? {}) + if (iouTokens.length === 0) { + // No IOUs issued by this account, return empty array + return [] + } + + // Batch get token data from LOS Token API + const allTokenIds = iouTokens.map((currency) => `${currency}.${accountId}`) + let allTokens: Record = {} + for (let i = 0; i < allTokenIds.length; i += LOS_TOKEN_API_BATCH_SIZE) { + const tokenIds = allTokenIds.slice(i, i + LOS_TOKEN_API_BATCH_SIZE) + try { + // eslint-disable-next-line no-await-in-loop + const apiResponse = await fetch( + `${process.env.VITE_LOS_URL}/tokens/batch-get`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tokenIds }), + }, + ) + + if (apiResponse.ok) { + // eslint-disable-next-line no-await-in-loop + const responseBody = await apiResponse.json() + const tokens = + responseBody.tokens?.reduce((acc: any, token: any) => { + acc[`${token.currency}.${token.issuer_account}`] = { + ...token, + } + return acc + }, {}) || {} + allTokens = { ...allTokens, ...tokens } + } + } catch (error) { + log.error( + `Error batch-get tokens[${tokenIds.join(', ')}]. Error: ${JSON.stringify(error)}`, + ) + } + } + + const transferFee = account?.info?.rate + const accountGlobalFreeze = account?.info?.flags?.includes('lsfGlobalFreeze') + + const iouData: IOU[] = iouTokens.map((currency: string) => { + const tokenId = `${currency}.${accountId}` + const token = allTokens[tokenId] + + // Use obligation value from gateway balance as supply since it excludes frozen balances + const obligationSupply = balancesResponse?.obligations?.[currency] + ? parseFloat(balancesResponse.obligations[currency]) + : 0 + + const apiSupply = token?.supply ? parseFloat(token.supply) : 0 + if (apiSupply > obligationSupply) { + const diff = apiSupply - obligationSupply + log.warn( + `Supply for ${currency}: GatewayBalance=${obligationSupply}, API=${apiSupply}, Likely Frozen=${diff}`, + ) + } + + return { + tokenCode: currency, + tokenIcon: token?.icon, + priceInUSD: token?.price_usd ? parseFloat(token.price_usd) : 0, + trustlines: token?.number_of_trustlines || 0, + holders: token?.number_of_holders || 0, + supply: obligationSupply, + assetClass: token?.asset_class || '--', + transferFee: `${transferFee}%`, + frozen: accountGlobalFreeze ? 'Global' : '--', + } + }) + + return iouData +} + +export const IssuedIOUs = ({ + accountId, + account, + onChange, +}: IssuedIOUsProps) => { + const lang = useLanguage() + const { t } = useTranslation() + const rippledSocket = useContext(SocketContext) + + const issuedIOUsQuery = useQuery(['issuedIOUs', accountId], () => + fetchAccountIssuedIOUs(rippledSocket, accountId, account), + ) + + // Sort by USD price + const sortedIOUs = useMemo(() => { + const data = issuedIOUsQuery.data || [] + return [...data].sort((a, b) => b.priceInUSD - a.priceInUSD) + }, [issuedIOUsQuery.data]) + + // Communicate count and loading state back to parent + useEffect(() => { + if (onChange) { + onChange({ + count: sortedIOUs.length, + isLoading: issuedIOUsQuery.isLoading, + }) + } + }, [sortedIOUs.length, issuedIOUsQuery.isLoading, onChange]) + + if (issuedIOUsQuery.isLoading) { + return + } + + return ( +
+ + + + + + + + + + + + + + + {sortedIOUs.length === 0 ? ( + + {t('account_page_asset_table_no_iou')} + + ) : ( + sortedIOUs.map((token) => ( + + + + + + + + + + + )) + )} + +
{t('account_page_asset_table_column_currency_code')}{t('account_page_asset_table_column_price_usd')}{t('account_page_asset_table_column_trustlines')}{t('account_page_asset_table_column_holders')}{t('account_page_asset_table_column_supply')}{t('account_page_asset_table_column_asset_class')}{t('account_page_asset_table_column_transfer_fee')}{t('account_page_asset_table_column_frozen')}
+ +
+ {token.tokenIcon ? ( + {token.tokenCode} + ) : ( + + )} + +
+
+
{formatUsdValue(token.priceInUSD, lang)}{localizeNumber(token.trustlines, lang)}{localizeNumber(token.holders, lang)}{formatTokenBalance(token.supply, lang)}{token.assetClass}{token.transferFee}{token.frozen}
+
+ ) +} diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx new file mode 100644 index 000000000..018e44701 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx @@ -0,0 +1,158 @@ +import { useTranslation } from 'react-i18next' +import { useQuery } from 'react-query' +import { useEffect, useContext } from 'react' +import { RouteLink } from '../../../shared/routing' +import { MPT_ROUTE } from '../../../App/routes' +import { Loader } from '../../../shared/components/Loader' +import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' +import { FutureDataIcon } from '../FutureDataIcon' +import { + Tooltip, + useTooltip, + TooltipProvider, +} from '../../../shared/components/Tooltip' +import { getAccountObjects } from '../../../../rippled/lib/rippled' +import SocketContext from '../../../shared/SocketContext' +import { + formatMPTIssuance, + formatTransferFee, +} from '../../../../rippled/lib/utils' +import { localizeNumber, shortenMPTID } from '../../../shared/utils' +import { useLanguage } from '../../../shared/hooks' +import logger from '../../../../rippled/lib/logger' + +const log = logger({ name: 'IssuedMPTs' }) + +interface IssuedMPTsProps { + accountId: string + onChange?: (data: { count: number; isLoading: boolean }) => void +} + +const fetchAccountIssuedMPTs = async ( + accountId: string, + rippledSocket: any, +) => { + const mptIssuances: any[] = [] + let marker = '' + do { + try { + // eslint-disable-next-line no-await-in-loop + const response = await getAccountObjects( + rippledSocket, + accountId, + 'mpt_issuance', + marker, + ) + if (!response?.account_objects) { + break + } + + mptIssuances.push(...response.account_objects) + marker = response.marker || '' + } catch (error) { + log.error(`Error fetching MPT issuances: ${JSON.stringify(error)}`) + // Break the loop on error to avoid infinite retry + break + } + } while (marker) + + // Format the MPT issuances + const issuedMPTs = mptIssuances.map((mptIssuance: any) => { + const formattedIssuance = formatMPTIssuance(mptIssuance) + + return { + tokenId: mptIssuance.mpt_issuance_id, + ticker: formattedIssuance?.metadata?.Ticker || null, + supply: formattedIssuance?.outstandingAmt || '0', + assetClass: formattedIssuance?.metadata?.AssetClass || null, + transferFee: formatTransferFee(formattedIssuance?.transferFee, 'MPT'), + locked: formattedIssuance?.flags?.includes('lsfMPTLocked') + ? 'Global' + : '', + } + }) + + return issuedMPTs +} + +const IssuedMPTsContent = ({ accountId, onChange }: IssuedMPTsProps) => { + const lang = useLanguage() + const { t } = useTranslation() + const rippledSocket = useContext(SocketContext) + const { tooltip } = useTooltip() + + const issuedMPTsQuery = useQuery(['issuedMPTs', accountId], () => + fetchAccountIssuedMPTs(accountId, rippledSocket), + ) + // TODO: When MPT Dex is live, sort MPTs based on USD price + const rows = issuedMPTsQuery.data ?? [] + + // Communicate count and loading state back to parent + useEffect(() => { + if (onChange) { + onChange({ count: rows.length, isLoading: issuedMPTsQuery.isLoading }) + } + }, [rows.length, issuedMPTsQuery.isLoading, onChange]) + + if (issuedMPTsQuery.isLoading) { + return + } + + return ( +
+ + + + + + + + + + + + + + + {rows.length === 0 ? ( + + {t('account_page_asset_table_no_mpt')} + + ) : ( + rows.map((token) => ( + + + + + + + + + + )) + )} + +
{t('account_page_asset_table_column_token_id')}{t('account_page_asset_table_column_ticker')}{t('account_page_asset_table_column_price_usd')}{t('account_page_asset_table_column_supply')}{t('account_page_asset_table_column_asset_class')}{t('account_page_asset_table_column_transfer_fee')}{t('account_page_asset_table_column_locked')}
+ + {shortenMPTID(token.tokenId)} + + + + + + {localizeNumber(token.supply, lang)} + + {token.transferFee}% + {token.locked === '' + ? '--' + : t('account_page_asset_table_mpt_locked_global')} +
+
+ ) +} + +export const IssuedMPTs = ({ accountId, onChange }: IssuedMPTsProps) => ( + + + +) diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx new file mode 100644 index 000000000..aab202db3 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx @@ -0,0 +1,54 @@ +import { getNFTsIssuedByAccount } from '../../../../rippled/lib/rippled' +import { formatTransferFee } from '../../../../rippled/lib/utils' +import { NFTTable, NFT } from './NFTTable' +import logger from '../../../../rippled/lib/logger' + +const log = logger({ name: 'IssuedNFTs' }) + +interface IssuedNFTsProps { + accountId: string + onChange?: (data: { count: number; isLoading: boolean }) => void +} + +const fetchAccountIssuedNFTs = async ( + accountId: string, + rippledSocket: any, +): Promise => { + try { + const allNFTs: any[] = [] + let marker = '' + do { + // eslint-disable-next-line no-await-in-loop + const response = await getNFTsIssuedByAccount( + rippledSocket, + accountId, + marker, + 50, + ) + if (response.nfts) { + allNFTs.push(...response.nfts) + } + + marker = response.marker || '' + } while (marker) + + return allNFTs.map((nft) => ({ + nftId: nft.nft_id, + url: nft.uri ? Buffer.from(nft.uri, 'hex').toString('utf8') : '', + transferFee: formatTransferFee(nft.transfer_fee, 'NFT'), + })) + } catch (error) { + log.error(`Error fetching issued NFTs: ${JSON.stringify(error)}`) + return [] + } +} + +export const IssuedNFTs = ({ accountId, onChange }: IssuedNFTsProps) => ( + +) diff --git a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx new file mode 100644 index 000000000..f3b3b37b7 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx @@ -0,0 +1,294 @@ +import { useTranslation } from 'react-i18next' +import { useQuery } from 'react-query' +import { useEffect, useMemo, useContext, useState, useCallback } from 'react' +import { Account } from '../../../shared/components/Account' +import { PaginatedTable } from '../../../shared/components/PaginatedTable' +import { Loader } from '../../../shared/components/Loader' +import SocketContext from '../../../shared/SocketContext' +import { + getBuyNFToffers, + getSellNFToffers, +} from '../../../../rippled/lib/rippled' +import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' +import { NFTokenLink } from '../../../shared/components/NFTokenLink' +import DomainLink from '../../../shared/components/DomainLink' +import { XRP_BASE } from '../../../shared/transactionUtils' +import { + localizeNumber, + shortenAccount, + shortenDomain, + shortenNFTTokenID, +} from '../../../shared/utils' +import { XRP_SMALL_BALANCE_CURRENCY_OPTIONS } from '../../../shared/NumberFormattingUtils' +import { useLanguage } from '../../../shared/hooks' +import logger from '../../../../rippled/lib/logger' + +const log = logger({ name: 'NFTTable' }) + +const PAGE_SIZE = 10 + +interface NFTTableProps { + accountId: string + onChange?: (data: { count: number; isLoading: boolean }) => void + fetchNFTs: (accountId: string, rippledSocket: any) => Promise + queryKey: string + showIssuer?: boolean +} + +export interface NFT { + nftId: string + issuer?: string + url: string + transferFee: string + lowestAsk?: number + highestBid?: number +} + +export const NFTTable = ({ + accountId, + onChange, + fetchNFTs, + queryKey, + showIssuer = false, +}: NFTTableProps) => { + const { t } = useTranslation() + const lang = useLanguage() + const rippledSocket = useContext(SocketContext) + const [nfts, setNFTs] = useState([]) + const [fetchedNFTs, setFetchedNFTs] = useState>(new Set()) + const [isLoadingOffers, setIsLoadingOffers] = useState(false) + + // Fetch and show basic NFT data first + const nftsQuery = useQuery([queryKey, accountId], () => + fetchNFTs(accountId, rippledSocket), + ) + + const basicNFTs = useMemo(() => nftsQuery.data ?? [], [nftsQuery.data]) + + const processXRPOffers = useCallback( + (offers: any[], sortAscending: boolean) => { + const xrpOffers = offers.filter( + (offer: any) => typeof offer.amount === 'string', + ) + + if (xrpOffers.length === 0) { + return undefined + } + + const sortedOffers = xrpOffers.sort((a: any, b: any) => { + const amountA = parseInt(a.amount, 10) + const amountB = parseInt(b.amount, 10) + return sortAscending ? amountA - amountB : amountB - amountA + }) + + const bestOffer = sortedOffers[0] + return parseInt(bestOffer.amount, 10) / XRP_BASE + }, + [], + ) + + const fetchOffers = useCallback( + async (fetchFn: Function, nftId: string) => { + try { + const response = await fetchFn(rippledSocket, nftId, 50) + return response.offers && response.offers.length > 0 + ? response.offers + : [] + } catch (error: any) { + if (error.code === 404 && error.message === 'notFound') { + // This means there is no offer for the given NFT + } else { + log.error(error) + } + return [] + } + }, + [rippledSocket], + ) + + const fetchOffersForNFT = useCallback( + async (nft: NFT): Promise => { + // Get sell offers to calculate lowest ask (XRP only) + const sellOffers = await fetchOffers(getSellNFToffers, nft.nftId) + const lowestAsk = processXRPOffers(sellOffers, true) // ascending for lowest + + // Get buy offers to calculate highest bid (XRP only) + const buyOffers = await fetchOffers(getBuyNFToffers, nft.nftId) + const highestBid = processXRPOffers(buyOffers, false) // descending for highest + + return { ...nft, lowestAsk, highestBid } + }, + [processXRPOffers, fetchOffers], + ) + + const batchProcessNFTOffers = useCallback( + async (nftsToFetch: NFT[]) => { + if (nftsToFetch.length === 0) { + return + } + + setIsLoadingOffers(true) + + // Fetch NFT offers sequentially to avoid being throttled + for (const nft of nftsToFetch) { + try { + // eslint-disable-next-line no-await-in-loop + const nftWithOffers = await fetchOffersForNFT(nft) + + // Update NFT immediately as offers are fetched + setNFTs((prev) => + prev.map((prevNft) => + prevNft.nftId === nftWithOffers.nftId ? nftWithOffers : prevNft, + ), + ) + + // Mark as fetched + setFetchedNFTs((prev) => new Set([...prev, nft.nftId])) + } catch (error) { + // Handle individual NFT errors, continue with others + log.error( + `Error fetching offers for NFT ${nft.nftId}: ${JSON.stringify(error)}`, + ) + } + } + + setIsLoadingOffers(false) + }, + [fetchOffersForNFT], + ) + + // Initialize NFTs when basic data loads + useEffect(() => { + if (basicNFTs.length > 0) { + // Initialize all NFTs with undefined lowest ask and highest bid + const initialNFTs = basicNFTs.map((nft) => ({ + ...nft, + lowestAsk: undefined, + highestBid: undefined, + })) + setNFTs(initialNFTs) + + // Reset for new account + setFetchedNFTs(new Set()) + setIsLoadingOffers(false) + + // Fetch offers for first page NFTs immediately + const firstPageNFTs = basicNFTs.slice(0, PAGE_SIZE) + + // Fetch offers for first page NFTs immediately + batchProcessNFTOffers(firstPageNFTs) + } + }, [basicNFTs, batchProcessNFTOffers]) + + // Communicate count and loading state back to parent + useEffect(() => { + if (onChange) { + onChange({ count: nfts.length, isLoading: nftsQuery.isLoading }) + } + }, [nfts.length, nftsQuery.isLoading, onChange]) + + if (nftsQuery.isLoading) { + return + } + + const tableStructure = (paginatedRows: any[]) => { + // Fetch offers for visible NFTs when page changes + // Only fetch if there are NFTs, for which we haven't fetched offers yet + if (paginatedRows.length > 0) { + const unfetchedNFTs = paginatedRows.filter( + (nft) => !fetchedNFTs.has(nft.nftId), + ) + + if (unfetchedNFTs.length > 0 && !isLoadingOffers) { + // Use setTimeout to avoid calling setState during render + setTimeout(() => { + batchProcessNFTOffers(unfetchedNFTs) + }, 0) + } + } + + const colSpan = showIssuer ? 6 : 5 + + return ( +
+ + + + + {showIssuer && ( + + )} + + + + + + + + {paginatedRows.length === 0 ? ( + + {t('account_page_asset_table_no_nft')} + + ) : ( + paginatedRows.map((nft, index) => ( + + + {showIssuer && nft.issuer && ( + + )} + + + + + + )) + )} + +
{t('account_page_asset_table_column_token_id')}{t('account_page_asset_table_column_issuer')}{t('account_page_asset_table_column_url')}{t('account_page_asset_table_column_transfer_fee')}{t('account_page_asset_table_column_lowest_ask')}{t('account_page_asset_table_column_highest_bid')}
+ + + + + {nft.url ? ( + + ) : ( + '--' + )} + {nft.transferFee}% + {nft.lowestAsk + ? localizeNumber( + nft.lowestAsk, + lang, + XRP_SMALL_BALANCE_CURRENCY_OPTIONS, + ) + : '--'} + + {nft.highestBid + ? localizeNumber( + nft.highestBid, + lang, + XRP_SMALL_BALANCE_CURRENCY_OPTIONS, + ) + : '--'} +
+
+ ) + } + + return ( + + ) +} diff --git a/src/containers/Accounts/AccountAsset/index.tsx b/src/containers/Accounts/AccountAsset/index.tsx new file mode 100644 index 000000000..e883a1487 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/index.tsx @@ -0,0 +1,342 @@ +import { useState, useCallback } from 'react' +import './styles.scss' +import { useTranslation } from 'react-i18next' +import { localizeNumber } from '../../shared/utils' +import { useLanguage } from '../../shared/hooks' +import ArrowIcon from '../../shared/images/down_arrow.svg' +import { HeldIOUs } from './assetTables/HeldIOUs' +import { HeldMPTs } from './assetTables/HeldMPTs' +import { HeldLPTokens } from './assetTables/HeldLPTokens' +import { HeldNFTs } from './assetTables/HeldNFTs' +import { IssuedIOUs } from './assetTables/IssuedIOUs' +import { IssuedMPTs } from './assetTables/IssuedMPTs' +import { IssuedNFTs } from './assetTables/IssuedNFTs' + +type HeldAssetTabKey = 'iou' | 'mpt' | 'lptoken' | 'nft' +type IssuedAssetTabKey = 'iou' | 'mpt' | 'nft' + +function TabButton({ + label, + active, + onClick, + loading = false, +}: { + label: string + active?: boolean + onClick?: () => void + loading?: boolean +}) { + return ( + + ) +} + +interface AccountAssetProps { + accountId: string + account?: any + xrpToUSDRate: number +} + +export default function AccountAsset({ + accountId, + account, + xrpToUSDRate, +}: AccountAssetProps) { + const lang = useLanguage() + const { t } = useTranslation() + + // Counts managed by individual table components + const [counts, setCounts] = useState({ + heldIou: 0, + heldMpt: 0, + heldLptoken: 0, + heldNft: 0, + issuedIou: 0, + issuedMpt: 0, + issuedNft: 0, + }) + + // Loading states - start as true, set to false when we get data + const [loading, setLoading] = useState({ + heldIou: true, + heldMpt: true, + heldLptoken: true, + heldNft: true, + issuedIou: true, + issuedMpt: true, + issuedNft: true, + }) + + // Stable update functions for each asset type + const updateHeldIOUs = useCallback( + ({ count, isLoading }: { count: number; isLoading: boolean }) => { + setCounts((prev) => ({ ...prev, heldIou: count })) + setLoading((prev) => ({ ...prev, heldIou: isLoading })) + }, + [], + ) + + const updateHeldMPTs = useCallback( + ({ count, isLoading }: { count: number; isLoading: boolean }) => { + setCounts((prev) => ({ ...prev, heldMpt: count })) + setLoading((prev) => ({ ...prev, heldMpt: isLoading })) + }, + [], + ) + + const updateHeldLPTokens = useCallback( + ({ count, isLoading }: { count: number; isLoading: boolean }) => { + setCounts((prev) => ({ ...prev, heldLptoken: count })) + setLoading((prev) => ({ ...prev, heldLptoken: isLoading })) + }, + [], + ) + + const updateHeldNFTs = useCallback( + ({ count, isLoading }: { count: number; isLoading: boolean }) => { + setCounts((prev) => ({ ...prev, heldNft: count })) + setLoading((prev) => ({ ...prev, heldNft: isLoading })) + }, + [], + ) + + const updateIssuedIOUs = useCallback( + ({ count, isLoading }: { count: number; isLoading: boolean }) => { + setCounts((prev) => ({ ...prev, issuedIou: count })) + setLoading((prev) => ({ ...prev, issuedIou: isLoading })) + }, + [], + ) + + const updateIssuedMPTs = useCallback( + ({ count, isLoading }: { count: number; isLoading: boolean }) => { + setCounts((prev) => ({ ...prev, issuedMpt: count })) + setLoading((prev) => ({ ...prev, issuedMpt: isLoading })) + }, + [], + ) + + const updateIssuedNFTs = useCallback( + ({ count, isLoading }: { count: number; isLoading: boolean }) => { + setCounts((prev) => ({ ...prev, issuedNft: count })) + setLoading((prev) => ({ ...prev, issuedNft: isLoading })) + }, + [], + ) + + // Tabs state + const [heldTab, setHeldTab] = useState('iou') + const [issuedTab, setIssuedTab] = useState('iou') + + // Collapse state - default to expanded (true means open) + const [heldSectionOpen, setHeldSectionOpen] = useState(true) + const [issuedSectionOpen, setIssuedSectionOpen] = useState(true) + + return ( +
+ {/* Assets Held */} +
+

+ {t('account_page_asset_held_title')} +

+ +
+
+
+ setHeldTab('iou')} + loading={loading.heldIou} + /> + setHeldTab('mpt')} + loading={loading.heldMpt} + /> + setHeldTab('lptoken')} + loading={loading.heldLptoken} + /> + setHeldTab('nft')} + loading={loading.heldNft} + /> +
+ + {/* Render all components to fetch data, but only show active tab */} +
+ +
+
+ +
+
+ +
+
+ +
+
+ + {/* Assets Issued */} +
+

+ {t('account_page_asset_issued_title')} +

+ +
+
+
+ setIssuedTab('iou')} + loading={loading.issuedIou} + /> + setIssuedTab('mpt')} + loading={loading.issuedMpt} + /> + setIssuedTab('nft')} + loading={loading.issuedNft} + /> +
+ + {/* Render all components to fetch data, but only show active tab */} +
+ +
+
+ +
+
+ +
+
+
+ ) +} diff --git a/src/containers/Accounts/AccountAsset/styles.scss b/src/containers/Accounts/AccountAsset/styles.scss new file mode 100644 index 000000000..ffe56eb01 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/styles.scss @@ -0,0 +1,259 @@ +@use '../../shared/css/variables' as *; + +.account-asset { + padding: 24px 0px; +} + +.account-asset-title { + @include bold; + + font-size: 20px; +} + +.asset-section-header { + display: flex; + align-items: center; + justify-content: flex-start; + margin: 24px 0 20px; + gap: 2px; + + &:first-child { + margin-top: 0; + } + + .account-asset-title { + margin: 0; + } + + .asset-section-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + } + + .asset-section-arrow { + display: inline-block; + width: 18px; + height: 18px; + transform-origin: center; + transition: transform 180ms ease; + } + + .asset-section-arrow.open { + transform: rotate(180deg); + } +} + +.account-asset-tabs { + display: flex; + height: 30px; + flex-wrap: wrap; + align-items: flex-start; + border-radius: 100px; + margin-bottom: 15px; + background: $black-80; + gap: 10px; +} + +.account-asset-tab { + padding: 4px 10px; + border: 1px solid transparent; + border-radius: 100px; + background: transparent; + color: var(--text); + cursor: pointer; + font-size: 14px; + white-space: nowrap; +} + +.account-asset-tab:hover { + border-color: rgb(255 255 255 / 3%); + background: rgb(255 255 255 / 2%); +} + +.account-asset-tab.is-active { + @include bold; + + padding: 4px 14px; + border: 1px solid $green-50; + background: $black-90; + color: $black-0; +} + +// Loading spinner for tab buttons +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +.loading-spinner { + display: inline-block; + width: 10px; + height: 10px; + border: 1px solid rgb(255 255 255 / 30%); + border-radius: 50%; + border-top-color: #fff; + animation: spin 1s ease-in-out infinite; +} + +.account-asset-table { + background: transparent; + + /* Allow horizontal scrolling if the table is wider than its parent */ + -webkit-overflow-scrolling: touch; + overflow-x: auto; +} + +.account-asset-table-wrapper-fixed .account-asset-table { + overflow: auto; /* Vertical scroll when overflowing, keep horizontal if needed */ + max-height: 255px; /* Fixed viewport height for the table area */ + background: transparent; + -webkit-overflow-scrolling: touch; + + /* Custom scroll bars */ + scrollbar-color: rgb(255 255 255 / 20%) transparent; + scrollbar-width: thin; /* Firefox */ + + /* WebKit */ + &::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + border: 2px solid transparent; /* create padding effect */ + border-radius: 6px; + background: rgb(255 255 255 / 20%); + background-clip: padding-box; + } + + &::-webkit-scrollbar-thumb:hover { + background: rgb(255 255 255 / 12%); + } +} + +.account-asset-table table { + width: 100%; + min-width: 980px; + border-collapse: separate; + color: var(--text); +} + +.account-asset-table thead th { + @include semibold; + + /* Make header sticky for scrollable tables */ + position: sticky; + z-index: 2; + top: 0; + padding: 10px 12px; + backdrop-filter: blur(100px); /* As table rows scroll under the header, they appear blurred behind it */ + color: $black-50; + font-size: 12px; + text-align: left; + text-transform: uppercase; +} + +.account-asset-table tbody td { + padding: 10px 12px; + border-bottom: 1px solid $black-80; /* Table row separator */ + color: $black-0; + font-size: 14px; + text-align: left; + + &.empty-message { + padding: 16px; + color: $black-40; + font-size: 16px; + text-align: center; + + @media (max-width: $tablet-portrait-upper-boundary) { + padding: 12px 14px; + font-size: 14px; + } + } +} + +/* Add border-top to the first row to separate header from body */ +.account-asset-table tbody tr:first-child td { + border-top: 1px solid $black-80; +} + +/* Fee column in orange */ +.account-asset-table tbody td.transfer-fee, +.account-asset-table tbody td.transfer-fee * { + color: $orange-50; +} + +.account-asset-table tbody td.asset-class { + text-transform: uppercase; +} + +/* Token icon styling */ +.token { + display: flex; + align-items: center; + gap: 8px; + + .token-icon { + width: 20px; + height: 20px; + } +} + +/* Token link styling */ +.account-asset-table tbody td a { + text-decoration: none; + + &:hover { + text-decoration: none; + } +} + +@media (max-width: $phone-upper-boundary) { + .account-asset-tabs { + display: grid; + height: auto; + align-items: stretch; + padding: 4px; + border-radius: 12px; + gap: 4px; + grid-template-columns: 1fr 1fr; /* Two columns */ + } + + .account-asset-tab { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 10px; + border-radius: 100px; + background: transparent; + font-size: 13px; + white-space: nowrap; + } + + /* Allow more height for scrollable tables on small screen */ + .account-asset-table-wrapper-fixed .account-asset-table { + max-height: 400px; + } +} + +@media (max-width: $tablet-portrait-upper-boundary) { + .account-asset-title { + font-size: 18px; + } +} diff --git a/src/containers/Accounts/AccountAsset/test/AccountAsset.test.tsx b/src/containers/Accounts/AccountAsset/test/AccountAsset.test.tsx new file mode 100644 index 000000000..a385e9341 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/test/AccountAsset.test.tsx @@ -0,0 +1,596 @@ +import { + render, + screen, + waitFor, + cleanup, + fireEvent, +} from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import { QueryClientProvider } from 'react-query' +import i18n from '../../../../i18n/testConfigEnglish' +import SocketContext from '../../../shared/SocketContext' +import AccountAsset from '../index' +import { queryClient } from '../../../shared/QueryClient' +import { + getBalances, + getAccountLines, + getAccountInfo, + getAccountNFTs, + getAccountMPTs, + getAccountObjects, + getNFTsIssuedByAccount, +} from '../../../../rippled/lib/rippled' +import Mock = jest.Mock + +jest.mock('../../../../rippled/lib/rippled') +jest.mock('../../../../rippled/NFTTransactions') +jest.mock('../../../../rippled/lib/utils', () => ({ + formatTransferFee: jest.fn().mockReturnValue('1.00%'), +})) +jest.mock('../../../../rippled/lib/logger', () => ({ + __esModule: true, + default: () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }), +})) + +global.fetch = jest.fn() as jest.Mock + +const mockedGetBalances = getBalances as Mock +const mockedGetAccountLines = getAccountLines as Mock +const mockedGetAccountInfo = getAccountInfo as Mock +const mockedGetAccountNFTs = getAccountNFTs as Mock +const mockedGetAccountMPTs = getAccountMPTs as Mock +const mockedGetAccountObjects = getAccountObjects as Mock +const mockedGetNFTsIssuedByAccount = getNFTsIssuedByAccount as Mock +const mockedFetch = fetch as Mock + +// Mock socket client +const mockSocket = {} as any + +// Test wrapper component +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + + + + +) + +describe('AccountAsset Component', () => { + beforeEach(() => { + jest.clearAllMocks() + queryClient.clear() + cleanup() + + // Default mock implementations - empty data + mockedGetBalances.mockResolvedValue([]) + mockedGetAccountLines.mockResolvedValue({ lines: [] }) + mockedGetAccountInfo.mockResolvedValue({ + account_data: { Account: 'rTest123', Flags: 0 }, + }) + mockedGetAccountNFTs.mockResolvedValue({ account_nfts: [] }) + mockedGetAccountMPTs.mockResolvedValue({ account_objects: [] }) + mockedGetAccountObjects.mockResolvedValue({ account_objects: [] }) + mockedGetNFTsIssuedByAccount.mockResolvedValue({ nfts: [] }) + mockedFetch.mockResolvedValue({ + ok: true, + json: async () => ({ holders: [] }), + } as Response) + }) + + describe('Rendering and Layout', () => { + it('renders both Assets Held and Assets Issued sections', async () => { + render( + + + , + ) + + expect(screen.getByText('Assets Held')).toBeInTheDocument() + expect(screen.getByText('Assets Issued')).toBeInTheDocument() + }) + + it('renders all held asset tabs', async () => { + render( + + + , + ) + + // Wait for tabs to render - use getAllByRole since there are multiple tabs with IOU/MPT/NFT + await waitFor(() => { + const allTabs = screen.getAllByRole('tab') + expect(allTabs.length).toBeGreaterThan(0) + }) + + // Check that the held tabs list contains expected tabs + const heldTabList = screen.getAllByRole('tablist')[0] + expect(heldTabList).toHaveTextContent('IOUs') + expect(heldTabList).toHaveTextContent('MPTs') + expect(heldTabList).toHaveTextContent('LP Tokens') + expect(heldTabList).toHaveTextContent('NFTs') + }) + + it('renders all issued asset tabs', async () => { + render( + + + , + ) + + // Wait for tabs to render + await waitFor(() => { + const allTabs = screen.getAllByRole('tab') + expect(allTabs.length).toBeGreaterThan(0) + }) + + // Check that the issued tabs list contains expected tabs + const issuedTabList = screen.getAllByRole('tablist')[1] + expect(issuedTabList).toHaveTextContent('IOUs') + expect(issuedTabList).toHaveTextContent('MPTs') + expect(issuedTabList).toHaveTextContent('NFTs') + }) + }) + + describe('Tab Navigation', () => { + it('switches between held asset tabs', async () => { + render( + + + , + ) + + await waitFor(() => { + expect(screen.getAllByRole('tablist').length).toBe(2) + }) + + // Find tabs using more specific queries + const heldTabList = screen.getAllByRole('tablist')[0] + const iouTab = heldTabList.querySelector('[aria-selected="true"]') + expect(iouTab).toHaveTextContent('IOUs') + + // Click MPT tab + const mptTab = heldTabList.querySelector('[title*="MPTs"]') as HTMLElement + fireEvent.click(mptTab) + + await waitFor(() => { + expect(mptTab).toHaveAttribute('aria-selected', 'true') + }) + }) + + it('switches between issued asset tabs', async () => { + render( + + + , + ) + + await waitFor(() => { + expect(screen.getAllByRole('tablist').length).toBe(2) + }) + + const issuedTabList = screen.getAllByRole('tablist')[1] + const iouTab = issuedTabList.querySelector('[aria-selected="true"]') + expect(iouTab).toHaveTextContent('IOUs') + + // Click NFT tab + const nftTab = issuedTabList.querySelector( + '[title*="NFTs"]', + ) as HTMLElement + fireEvent.click(nftTab) + + await waitFor(() => { + expect(nftTab).toHaveAttribute('aria-selected', 'true') + }) + }) + }) + + describe('Section Collapse/Expand', () => { + it('collapses and expands held section', async () => { + render( + + + , + ) + + // Find the collapse button for held section + const toggleButtons = screen.getAllByLabelText(/Toggle assets/i) + const heldToggle = toggleButtons[0] + + expect(heldToggle).toHaveAttribute('aria-expanded', 'true') + + // Collapse + fireEvent.click(heldToggle) + + await waitFor(() => { + expect(heldToggle).toHaveAttribute('aria-expanded', 'false') + }) + + // Expand + fireEvent.click(heldToggle) + + await waitFor(() => { + expect(heldToggle).toHaveAttribute('aria-expanded', 'true') + }) + }) + + it('collapses and expands issued section', async () => { + render( + + + , + ) + + const toggleButtons = screen.getAllByLabelText(/Toggle assets/i) + const issuedToggle = toggleButtons[1] + + expect(issuedToggle).toHaveAttribute('aria-expanded', 'true') + + // Collapse + fireEvent.click(issuedToggle) + + await waitFor(() => { + expect(issuedToggle).toHaveAttribute('aria-expanded', 'false') + }) + + // Expand + fireEvent.click(issuedToggle) + + await waitFor(() => { + expect(issuedToggle).toHaveAttribute('aria-expanded', 'true') + }) + }) + }) + + describe('Loading States', () => { + it('shows loading spinner in tabs while data is being fetched', async () => { + // Mock delayed responses + mockedGetBalances.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve([]), 100)), + ) + + render( + + + , + ) + + // Check for loading spinner + await waitFor(() => { + const loadingSpinners = screen.getAllByRole('tab') + const hasSpinner = loadingSpinners.some((tab) => + tab.querySelector('.loading-spinner'), + ) + expect(hasSpinner).toBe(true) + }) + + // Wait for loading to complete + await waitFor( + () => { + const tabs = screen.getAllByRole('tab') + const hasSpinner = tabs.some((tab) => + tab.querySelector('.loading-spinner'), + ) + expect(hasSpinner).toBe(false) + }, + { timeout: 3000 }, + ) + }) + }) + + describe('Asset Counts', () => { + // Note: Asset count tests are covered in individual component tests + // Here we just verify that counts are displayed in tabs + it('displays asset counts in tab labels', async () => { + render( + + + , + ) + + await waitFor(() => { + const allTabs = screen.getAllByRole('tab') + // Each tab should have a count in parentheses + allTabs.forEach((tab) => { + expect(tab.textContent).toMatch(/\(\d+\)/) + }) + }) + }) + }) + + describe('Component Integration', () => { + it('renders all held asset components simultaneously', async () => { + const { container } = render( + + + , + ) + + // All components should call their APIs even if not visible + await waitFor(() => { + expect(mockedGetBalances).toHaveBeenCalled() + expect(mockedGetAccountMPTs).toHaveBeenCalled() + expect(mockedGetAccountNFTs).toHaveBeenCalled() + }) + + // Verify all 4 held asset table wrappers and asset tables are rendered + const allSections = container.querySelectorAll('.account-asset-content') + const heldSection = allSections[0] // First section (Held) + const heldWrappers = heldSection.querySelectorAll( + '.account-asset-table-wrapper', + ) + expect(heldWrappers.length).toBe(4) + + const heldTables = heldSection.querySelectorAll('.account-asset-table') + expect(heldTables.length).toBe(4) + }) + + it('renders all issued asset components simultaneously', async () => { + const { container } = render( + + + , + ) + + // All issued components should call their APIs even if not visible + await waitFor(() => { + expect(mockedGetAccountObjects).toHaveBeenCalled() + expect(mockedGetNFTsIssuedByAccount).toHaveBeenCalled() + }) + + // Verify all 3 issued asset table wrappers and asset tables are rendered + const allSections = container.querySelectorAll('.account-asset-content') + const issuedSection = allSections[1] // Second section (Issued) + const issuedWrappers = issuedSection.querySelectorAll( + '.account-asset-table-wrapper', + ) + expect(issuedWrappers.length).toBe(3) + + const issuedTables = issuedSection.querySelectorAll( + '.account-asset-table', + ) + expect(issuedTables.length).toBe(3) + }) + + it('passes accountId to all child components', async () => { + const accountId = 'rTestAccount123' + + render( + + + , + ) + + await waitFor(() => { + // getBalances is called by child components with socket as first param + expect(mockedGetBalances).toHaveBeenCalled() + expect(mockedGetAccountMPTs).toHaveBeenCalled() + expect(mockedGetAccountNFTs).toHaveBeenCalled() + }) + }) + + it('passes xrpToUSDRate to HeldLPTokens component', async () => { + const xrpToUSDRate = 1.25 + + render( + + + , + ) + + // The component should render with the rate + await waitFor(() => { + expect(mockedGetBalances).toHaveBeenCalled() + }) + }) + + it('passes account data to IssuedIOUs component', async () => { + const mockAccount = { + Account: 'rTest123', + Balance: '1000000', + Flags: 0, + } + + render( + + + , + ) + + await waitFor(() => { + expect(mockedGetBalances).toHaveBeenCalled() + }) + }) + }) + + describe('Tab Active States', () => { + it('has IOU tab active by default in held section', async () => { + render( + + + , + ) + + await waitFor(() => { + const heldTabList = screen.getAllByRole('tablist')[0] + const activeTab = heldTabList.querySelector('[aria-selected="true"]') + expect(activeTab).toHaveTextContent('IOUs') + }) + }) + + it('has IOU tab active by default in issued section', async () => { + render( + + + , + ) + + await waitFor(() => { + expect(screen.getAllByRole('tablist').length).toBe(2) + }) + + const issuedTabList = screen.getAllByRole('tablist')[1] + const activeTab = issuedTabList.querySelector('[aria-selected="true"]') + expect(activeTab).toHaveTextContent('IOUs') + }) + }) + + describe('Accessibility', () => { + it('has proper ARIA labels for tab lists', async () => { + render( + + + , + ) + + await waitFor(() => { + expect(screen.getAllByRole('tablist').length).toBe(2) + }) + + const tabLists = screen.getAllByRole('tablist') + expect(tabLists[0]).toHaveAttribute('aria-label', 'Assets Held Tabs') + expect(tabLists[1]).toHaveAttribute('aria-label', 'Assets Issued Tabs') + }) + + it('has proper ARIA attributes on tabs', async () => { + render( + + + , + ) + + await waitFor(() => { + const tabs = screen.getAllByRole('tab') + expect(tabs.length).toBeGreaterThan(0) + }) + + const tabs = screen.getAllByRole('tab') + tabs.forEach((tab) => { + expect(tab).toHaveAttribute('aria-selected') + expect(tab).toHaveAttribute('role', 'tab') + }) + }) + + it('has proper aria-expanded on toggle buttons', async () => { + render( + + + , + ) + + const toggleButtons = screen.getAllByLabelText(/Toggle assets/i) + expect(toggleButtons).toHaveLength(2) + + toggleButtons.forEach((button) => { + expect(button).toHaveAttribute('aria-expanded') + }) + }) + }) + + describe('Number Localization', () => { + it('displays counts with proper formatting', async () => { + render( + + + , + ) + + // Check that counts appear in a consistent format + await waitFor(() => { + const allTabs = screen.getAllByRole('tab') + // All tabs should show count in format: "Label (N)" + allTabs.forEach((tab) => { + expect(tab.textContent).toMatch(/^.+\s+\(\d+\)$/) + }) + }) + }) + }) +}) diff --git a/src/containers/Accounts/AccountAsset/test/FutureDataIcon.test.tsx b/src/containers/Accounts/AccountAsset/test/FutureDataIcon.test.tsx new file mode 100644 index 000000000..f573e732f --- /dev/null +++ b/src/containers/Accounts/AccountAsset/test/FutureDataIcon.test.tsx @@ -0,0 +1,207 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { FutureDataIcon } from '../FutureDataIcon' +import { TooltipProvider } from '../../../shared/components/Tooltip' + +// Mock the SVG import +jest.mock('../../../shared/images/clock-icon.svg', () => ({ + __esModule: true, + default: ({ className }: { className?: string }) => ( + + ), +})) + +// Mock the useTooltip hook +const mockShowTooltip = jest.fn() +const mockHideTooltip = jest.fn() + +jest.mock('../../../shared/components/Tooltip', () => ({ + ...jest.requireActual('../../../shared/components/Tooltip'), + useTooltip: () => ({ + showTooltip: mockShowTooltip, + hideTooltip: mockHideTooltip, + }), +})) + +// Helper to render component with TooltipProvider +const renderWithTooltipProvider = (component: React.ReactElement) => + render({component}) + +describe('FutureDataIcon', () => { + beforeEach(() => { + jest.clearAllMocks() + // Mock getBoundingClientRect + Element.prototype.getBoundingClientRect = jest.fn(() => ({ + left: 100, + top: 200, + right: 120, + bottom: 220, + width: 20, + height: 20, + x: 100, + y: 200, + toJSON: () => {}, + })) + + // Mock window.scrollX and window.scrollY + Object.defineProperty(window, 'scrollX', { value: 0, writable: true }) + Object.defineProperty(window, 'scrollY', { value: 0, writable: true }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('Rendering', () => { + it('renders the clock icon', () => { + renderWithTooltipProvider() + + expect(screen.getByTestId('clock-icon')).toBeInTheDocument() + expect(screen.getByTestId('clock-icon')).toHaveClass('clock-icon') + }) + + it('renders with default message when no message prop provided', () => { + renderWithTooltipProvider() + + const futureDataSpan = screen.getByRole('button') + fireEvent.mouseOver(futureDataSpan) + + expect(mockShowTooltip).toHaveBeenCalledWith( + 'text', + expect.any(Object), + 'This data will be provided in a future release.', + expect.objectContaining({ + x: 110, // 100 + 10 (TOOLTIP_X_OFFSET) + y: 80, // 200 + 0 - 120 (TOOLTIP_Y_OFFSET) + }), + ) + }) + + it('renders with custom message when message prop provided', () => { + const customMessage = 'Custom future data message' + renderWithTooltipProvider() + + const futureDataSpan = screen.getByRole('button') + fireEvent.mouseOver(futureDataSpan) + + expect(mockShowTooltip).toHaveBeenCalledWith( + 'text', + expect.any(Object), + customMessage, + expect.any(Object), + ) + }) + }) + + describe('Mouse Interactions', () => { + it('shows tooltip on mouse over', () => { + renderWithTooltipProvider() + + const futureDataSpan = screen.getByRole('button') + fireEvent.mouseOver(futureDataSpan) + + expect(mockShowTooltip).toHaveBeenCalledTimes(1) + expect(mockShowTooltip).toHaveBeenCalledWith( + 'text', + expect.any(Object), + 'This data will be provided in a future release.', + expect.objectContaining({ + x: expect.any(Number), + y: expect.any(Number), + }), + ) + }) + + it('hides tooltip on mouse leave', () => { + renderWithTooltipProvider() + + const futureDataSpan = screen.getByRole('button') + fireEvent.mouseLeave(futureDataSpan) + + expect(mockHideTooltip).toHaveBeenCalledTimes(1) + }) + + it('shows and hides tooltip on mouse over and leave sequence', () => { + renderWithTooltipProvider() + + const futureDataSpan = screen.getByRole('button') + + fireEvent.mouseOver(futureDataSpan) + expect(mockShowTooltip).toHaveBeenCalledTimes(1) + + fireEvent.mouseLeave(futureDataSpan) + expect(mockHideTooltip).toHaveBeenCalledTimes(1) + }) + }) + + describe('Tooltip Positioning', () => { + it('calculates correct tooltip position with scroll offset', () => { + // Set scroll values + Object.defineProperty(window, 'scrollX', { value: 50, writable: true }) + Object.defineProperty(window, 'scrollY', { value: 100, writable: true }) + + renderWithTooltipProvider() + + const futureDataSpan = screen.getByRole('button') + fireEvent.mouseOver(futureDataSpan) + + expect(mockShowTooltip).toHaveBeenCalledWith( + 'text', + expect.any(Object), + 'This data will be provided in a future release.', + { + x: 160, // 100 (left) + 50 (scrollX) + 10 (TOOLTIP_X_OFFSET) + y: 180, // 200 (top) + 100 (scrollY) - 120 (TOOLTIP_Y_OFFSET) + }, + ) + }) + + it('uses correct offset constants', () => { + renderWithTooltipProvider() + + const futureDataSpan = screen.getByRole('button') + fireEvent.mouseOver(futureDataSpan) + + expect(mockShowTooltip).toHaveBeenCalledWith( + 'text', + expect.any(Object), + expect.any(String), + { + x: 110, // Confirms TOOLTIP_X_OFFSET = 10 + y: 80, // Confirms TOOLTIP_Y_OFFSET = -120 + }, + ) + }) + }) + + describe('Edge Cases', () => { + it('handles multiple rapid mouse events', () => { + renderWithTooltipProvider() + + const futureDataSpan = screen.getByRole('button') + + // Rapid mouse events + fireEvent.mouseOver(futureDataSpan) + fireEvent.mouseOver(futureDataSpan) + fireEvent.mouseLeave(futureDataSpan) + fireEvent.mouseOver(futureDataSpan) + fireEvent.mouseLeave(futureDataSpan) + + expect(mockShowTooltip).toHaveBeenCalledTimes(3) + expect(mockHideTooltip).toHaveBeenCalledTimes(2) + }) + + it('handles empty message prop', () => { + renderWithTooltipProvider() + + const futureDataSpan = screen.getByRole('button') + fireEvent.mouseOver(futureDataSpan) + + expect(mockShowTooltip).toHaveBeenCalledWith( + 'text', + expect.any(Object), + '', + expect.any(Object), + ) + }) + }) +}) diff --git a/src/containers/Accounts/AccountAsset/test/HeldIOUs.test.tsx b/src/containers/Accounts/AccountAsset/test/HeldIOUs.test.tsx new file mode 100644 index 000000000..537b9316f --- /dev/null +++ b/src/containers/Accounts/AccountAsset/test/HeldIOUs.test.tsx @@ -0,0 +1,405 @@ +import { render, screen, cleanup, waitFor } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import { QueryClientProvider } from 'react-query' +import i18n from '../../../../i18n/testConfigEnglish' +import SocketContext from '../../../shared/SocketContext' +import { HeldIOUs } from '../assetTables/HeldIOUs' +import { + getBalances, + getAccountLines, + getAccountInfo, +} from '../../../../rippled/lib/rippled' +import { formatTransferFee } from '../../../../rippled/lib/utils' +import { queryClient } from '../../../shared/QueryClient' +import Mock = jest.Mock + +jest.mock('../../../../rippled/lib/rippled') +jest.mock('../../../../rippled/lib/utils') +jest.mock('../../../../rippled/lib/logger', () => ({ + __esModule: true, + default: () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }), +})) + +global.fetch = jest.fn() as jest.Mock + +const mockedGetBalances = getBalances as Mock +const mockedGetAccountLines = getAccountLines as Mock +const mockedGetAccountInfo = getAccountInfo as Mock +const mockedFormatTransferFee = formatTransferFee as Mock +const mockedFetch = fetch as Mock + +// Mock socket client +const mockSocket = {} as any + +// Test wrapper component +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + + + + +) + +const mockBalancesResponse = { + assets: { + rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH: [ + { currency: 'USD', value: '100' }, + { currency: 'EUR', value: '50' }, + ], + rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B: [{ currency: 'BTC', value: '0.001' }], + rLNaPoKeeBjZe2qs6x52yVPZpZ8td4dc6w: [ + { + currency: + '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', + value: '1000', + }, + ], + }, +} + +const mockAccountLinesResponse = { + lines: [ + { + currency: 'USD', + account: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH', + balance: '100.50', + freeze: false, + freeze_peer: false, + }, + { + currency: 'EUR', + account: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH', + balance: '50.25', + freeze: false, + freeze_peer: false, + }, + { + currency: 'BTC', + account: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', + balance: '0.001', + freeze: true, + freeze_peer: false, + }, + { + currency: + '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', + account: 'rLNaPoKeeBjZe2qs6x52yVPZpZ8td4dc6w', + balance: '1000', + freeze: false, + freeze_peer: false, + }, + ], + marker: '', +} + +const mockLOSTokenResponse = { + tokens: [ + { + currency: 'USD', + issuer_account: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH', + icon: 'https://example.com/usd-icon.png', + issuer_name: 'Gatehub', + price_usd: '1.00', + asset_class: 'Currency', + }, + { + currency: 'EUR', + issuer_account: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH', + icon: 'https://example.com/eur-icon.png', + issuer_name: 'Gatehub', + price_usd: '1.10', + asset_class: 'Currency', + }, + { + currency: 'BTC', + issuer_account: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', + issuer_name: 'Bitstamp', + price_usd: '45000.00', + asset_class: 'Cryptocurrency', + }, + { + currency: + '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', + issuer_account: 'rLNaPoKeeBjZe2qs6x52yVPZpZ8td4dc6w', + price_usd: '1.00', + asset_class: 'RWA', + }, + ], +} + +const mockAccountInfo = { + TransferRate: 1010000000, // 1% transfer fee + Flags: 0, + AMMID: undefined, +} + +describe('HeldIOUs', () => { + afterEach(() => { + cleanup() + queryClient.clear() // Clear React Query cache between tests + }) + + // Helper function to verify column headers + const verifyColumnHeaders = async () => { + await waitFor(() => { + expect(screen.getByText('Currency Code')).toBeInTheDocument() + }) + expect(screen.getByText('Issuer')).toBeInTheDocument() + expect(screen.getByText('Price (USD)')).toBeInTheDocument() + expect(screen.getByText('Balance')).toBeInTheDocument() + expect(screen.getByText('Balance (USD)')).toBeInTheDocument() + expect(screen.getByText('Asset Class')).toBeInTheDocument() + expect(screen.getByText('Transfer Fee')).toBeInTheDocument() + expect(screen.getByText('Frozen')).toBeInTheDocument() + } + + it('renders empty state when no IOUs are held', async () => { + // Override the default mocks to return truly empty data + mockedGetBalances.mockResolvedValueOnce({ assets: {} }) + mockedGetAccountLines.mockResolvedValueOnce({ lines: [], marker: '' }) + + // Also mock the LOS token fetch to return empty tokens + mockedFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ tokens: [] }), + } as Response) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Check that the empty message is displayed (this is the translated text) + await waitFor(() => { + expect(screen.getByText('No IOUs found')).toBeInTheDocument() + }) + }) + + beforeEach(() => { + jest.clearAllMocks() + process.env.VITE_LOS_URL = 'https://api.los.example.com' + + mockedFormatTransferFee.mockReturnValue('1.0%') + mockedGetBalances.mockResolvedValue(mockBalancesResponse) + mockedGetAccountLines.mockResolvedValue(mockAccountLinesResponse) + + // Set up getAccountInfo mock to handle different account types + mockedGetAccountInfo.mockImplementation((_, accountId) => { + if (accountId === 'rLNaPoKeeBjZe2qs6x52yVPZpZ8td4dc6w') { + // LP token issuer - make it an AMM account by default make tokens starting with `03` LP tokens + return Promise.resolve({ + TransferRate: undefined, + Flags: 0, + AMMID: 'AMMID123456', + }) + } + // Regular accounts + return Promise.resolve(mockAccountInfo) + }) + + mockedFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockLOSTokenResponse), + } as Response) + }) + + it('shows all tokens including token starting with `03` whose issuer is not an AMM account', async () => { + // Override the mock to make `03` token issuer NOT an AMM account + // Add a delay to simulate async checking and test progressive reveal + mockedGetAccountInfo.mockImplementation((_, accountId): Promise => { + if (accountId === 'rLNaPoKeeBjZe2qs6x52yVPZpZ8td4dc6w') { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + TransferRate: 1020000000, // 2% transfer fee + Flags: 0x00400000, // lsfGlobalFreeze + AMMID: undefined, // This makes it NOT an AMM account + }) + }, 100) // 100ms delay to simulate network request + }) + } + + // Regular accounts - immediate response + return Promise.resolve(mockAccountInfo) + }) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Wait for initial tokens to load (non-03 tokens) + await waitFor(() => { + expect(screen.getByText('USD')).toBeInTheDocument() + }) + + // Get initial table rows (excluding header) + let rows = screen.getAllByRole('row') + let dataRows = rows.slice(1) + + // Initially, we should only have 3 tokens (USD, EUR, BTC) + // The `03` token should be hidden until confirmed as non-LP + expect(dataRows).toHaveLength(3) + expect( + screen.queryByText( + '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', + ), + ).not.toBeInTheDocument() + + // Now wait for the `03` token to appear after confirmation + await waitFor( + () => { + expect( + screen.getByText( + '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', + ), + ).toBeInTheDocument() + }, + { timeout: 5000 }, + ) + + // Get all table rows again (should now include the `03` token) + rows = screen.getAllByRole('row') + dataRows = rows.slice(1) // Skip header row + + // Verify we have 4 token rows (sorted by Balance USD descending) + expect(dataRows).toHaveLength(4) + + // / Verify `03` non-lp token data in first row (highest balance USD: $1,000.00) + const nonLPTokenRow = dataRows[0] + expect(nonLPTokenRow).toHaveTextContent( + '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', + ) + expect(nonLPTokenRow).toHaveTextContent('rLNaPoK...4dc6w') + expect(nonLPTokenRow).toHaveTextContent('$1.00') + expect(nonLPTokenRow).toHaveTextContent('1,000') + expect(nonLPTokenRow).toHaveTextContent('$1,000.00') + expect(nonLPTokenRow).toHaveTextContent('RWA') + // Initial state shows -- for both transfer fee and frozen (no trustline freeze) + const nonLPTokenPlaceholders = nonLPTokenRow.textContent?.match(/--/g) || [] + expect(nonLPTokenPlaceholders.length).toBe(2) + + // Verify USD token data in second row (highest balance USD: $100.50) + const usdRow = dataRows[1] + expect(usdRow).toHaveTextContent('USD') + expect(usdRow).toHaveTextContent('Gatehub') + expect(usdRow).toHaveTextContent('$1.00') + expect(usdRow).toHaveTextContent('100.5') + expect(usdRow).toHaveTextContent('$100.50') + + // Initial state shows -- for both transfer fee and frozen (no trustline freeze) + const usdPlaceholders = usdRow.textContent?.match(/--/g) || [] + expect(usdPlaceholders.length).toBe(2) + + // Verify EUR token data in third row (second highest: $55.28) + const eurRow = dataRows[2] + expect(eurRow).toHaveTextContent('EUR') + expect(eurRow).toHaveTextContent('Gatehub') + expect(eurRow).toHaveTextContent('$1.10') + expect(eurRow).toHaveTextContent('50.25') + expect(eurRow).toHaveTextContent('$55.28') + expect(eurRow).toHaveTextContent('Currency') + // Initial state shows -- for both transfer fee and frozen (no trustline freeze) + const eurPlaceholders = eurRow.textContent?.match(/--/g) || [] + expect(eurPlaceholders.length).toBe(2) + + // Verify BTC token data in fourth row (third highest: $45.00) + const btcRow = dataRows[3] + expect(btcRow).toHaveTextContent('BTC') + expect(btcRow).toHaveTextContent('Bitstamp') + expect(btcRow).toHaveTextContent('$45,000.00') + expect(btcRow).toHaveTextContent('0.001') + expect(btcRow).toHaveTextContent('$45.00') + expect(btcRow).toHaveTextContent('Cryptocurrency') + // BTC has trustline freeze in the account lines + expect(btcRow).toHaveTextContent('Trustline') + // Only 1 placeholder for transfer fee (frozen shows Trustline, not --) + const btcPlaceholders = btcRow.textContent?.match(/--/g) || [] + expect(btcPlaceholders.length).toBe(1) + }) + + it('excludes LP tokens whose issuer is an AMM account', async () => { + // Use the default mock setup where LP token issuer IS an AMM account (from beforeEach) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Wait for tokens to load + await waitFor(() => { + expect(screen.getByText('USD')).toBeInTheDocument() + }) + + // Get all table rows (excluding header) + const rows = screen.getAllByRole('row') + const dataRows = rows.slice(1) // Skip header row + + // Verify we have 3 token rows (LP token excluded) + expect(dataRows).toHaveLength(3) + + // Verify USD token data in first row (highest balance USD: $100.50) + const usdRow = dataRows[0] + expect(usdRow).toHaveTextContent('USD') + expect(usdRow).toHaveTextContent('Gatehub') + expect(usdRow).toHaveTextContent('$1.00') + expect(usdRow).toHaveTextContent('100.5') + expect(usdRow).toHaveTextContent('$100.50') + expect(usdRow).toHaveTextContent('Currency') + const usdPlaceholders = usdRow.textContent?.match(/--/g) || [] + expect(usdPlaceholders.length).toBe(2) + + // Verify EUR token data in second row (second highest: $55.28) + const eurRow = dataRows[1] + expect(eurRow).toHaveTextContent('EUR') + expect(eurRow).toHaveTextContent('Gatehub') + expect(eurRow).toHaveTextContent('$1.10') + expect(eurRow).toHaveTextContent('50.25') + expect(eurRow).toHaveTextContent('$55.28') + expect(eurRow).toHaveTextContent('Currency') + const eurPlaceholders = eurRow.textContent?.match(/--/g) || [] + expect(eurPlaceholders.length).toBe(2) + + // Verify BTC token data in third row (third highest: $45.00) + const btcRow = dataRows[2] + expect(btcRow).toHaveTextContent('BTC') + expect(btcRow).toHaveTextContent('Bitstamp') + expect(btcRow).toHaveTextContent('$45,000.00') + expect(btcRow).toHaveTextContent('0.001') + expect(btcRow).toHaveTextContent('$45.00') + expect(btcRow).toHaveTextContent('Cryptocurrency') + expect(btcRow).toHaveTextContent('Trustline') + const btcPlaceholders = btcRow.textContent?.match(/--/g) || [] + expect(btcPlaceholders.length).toBe(1) + + // Verify LP token is NOT displayed + expect( + screen.queryByText( + '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', + ), + ).not.toBeInTheDocument() + expect(screen.queryByText('rLNaPoK...4dc6w')).not.toBeInTheDocument() + expect(screen.queryByText('1,000')).not.toBeInTheDocument() + }) +}) diff --git a/src/containers/Accounts/AccountAsset/test/HeldLPTokens.test.tsx b/src/containers/Accounts/AccountAsset/test/HeldLPTokens.test.tsx new file mode 100644 index 000000000..8459e209b --- /dev/null +++ b/src/containers/Accounts/AccountAsset/test/HeldLPTokens.test.tsx @@ -0,0 +1,220 @@ +import { render, screen, cleanup, waitFor } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import { QueryClientProvider } from 'react-query' +import i18n from '../../../../i18n/testConfigEnglish' +import SocketContext from '../../../shared/SocketContext' +import { HeldLPTokens } from '../assetTables/HeldLPTokens' +import { + getBalances, + getAMMInfoByAMMAccount, +} from '../../../../rippled/lib/rippled' +import { queryClient } from '../../../shared/QueryClient' +import Mock = jest.Mock + +jest.mock('../../../../rippled/lib/rippled') +jest.mock('../../../../rippled/lib/logger', () => ({ + __esModule: true, + default: () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }), +})) + +const mockedGetBalances = getBalances as Mock +const mockedGetAMMInfoByAMMAccount = getAMMInfoByAMMAccount as Mock + +// Mock socket client +const mockSocket = {} as any + +// Test wrapper component +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + + + + +) + +// Mock data for LP token balances (with LP token identifier prefix '03') +const mockBalancesResponseWithLPTokens = { + assets: { + rp9E3FN9YAJjc7xrr8eTTYVpBheop35uoxu8vM: [ + { + currency: + '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', + value: '1000.5', + }, + ], + rDMNE7xaqxZ6YqPMJJV6sJ7Bw5UjT3nT6vd4XW: [ + { + currency: + '03BD9C0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C888888', + value: '500.25', + }, + ], + }, +} + +// Mock empty balances response +const mockEmptyBalancesResponse = { + assets: {}, +} + +// Mock AMM info responses +const mockAMMInfoXRP = { + amm: { + amount: '50000000000', // 50,000 XRP (in drops) + amount2: { + currency: 'USD', + issuer: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH', + value: '50000', + }, + lp_token: { + currency: + '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', + issuer: 'rp9E3FN9YAJjc7xrr8eTTYVpBheop35uoxu8vM', + value: '10000', + }, + }, +} + +const mockAMMInfoTokens = { + amm: { + amount: { + currency: 'USD', + issuer: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH', + value: '25000', + }, + amount2: { + currency: 'EUR', + issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', + value: '22727.27', + }, + lp_token: { + currency: + '03BD9C0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C888888', + issuer: 'rDMNE7xaqxZ6YqPMJJV6sJ7Bw5UjT3nT6vd4XW', + value: '5000', + }, + }, +} + +describe('HeldLPTokens', () => { + const mockXRPToUSDRate = 0.5 // 1 XRP = $0.50 + + // Helper function to verify column headers + const verifyColumnHeaders = async () => { + await waitFor(() => { + expect(screen.getByText('AMM Instance')).toBeInTheDocument() + }) + expect(screen.getByText('AMM Pair')).toBeInTheDocument() + expect(screen.getByText('Balance')).toBeInTheDocument() + expect(screen.getByText('Balance (USD)')).toBeInTheDocument() + expect(screen.getByText('Share (%)')).toBeInTheDocument() + } + + afterEach(() => { + cleanup() + queryClient.clear() // Clear React Query cache between tests + }) + + beforeEach(() => { + jest.clearAllMocks() + + mockedGetBalances.mockResolvedValue(mockBalancesResponseWithLPTokens) + + // Set up getAMMInfoByAMMAccount mock to handle different AMM accounts + mockedGetAMMInfoByAMMAccount.mockImplementation((_, ammAccount) => { + if (ammAccount === 'rp9E3FN9YAJjc7xrr8eTTYVpBheop35uoxu8vM') { + return Promise.resolve(mockAMMInfoXRP) + } + if (ammAccount === 'rDMNE7xaqxZ6YqPMJJV6sJ7Bw5UjT3nT6vd4XW') { + return Promise.resolve(mockAMMInfoTokens) + } + return Promise.reject(new Error('AMM not found')) + }) + }) + + it('renders empty state when no LP tokens are held', async () => { + // Override the default mock to return empty assets + mockedGetBalances.mockResolvedValueOnce(mockEmptyBalancesResponse) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Check that the empty message is displayed (this is the translated text) + await waitFor(() => { + expect(screen.getByText('No LP Tokens found')).toBeInTheDocument() + }) + }) + + it('handles error when getBalances fails', async () => { + // Mock getBalances to throw an error + mockedGetBalances.mockRejectedValueOnce(new Error('Network error')) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Should show empty state when error occurs + await waitFor(() => { + expect(screen.getByText('No LP Tokens found')).toBeInTheDocument() + }) + }) + + it('shows two LP tokens with XRP pair and token pair', async () => { + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Wait for LP tokens to load + await waitFor(() => { + expect(screen.getByText('rp9E3FN...xu8vM')).toBeInTheDocument() + }) + + // Get all table rows (excluding header) + const rows = screen.getAllByRole('row') + const dataRows = rows.slice(1) // Skip header row + + // Verify we have 2 LP token rows (XRP pair first, then token pair) + expect(dataRows).toHaveLength(2) + + // Verify first LP token (XRP/USD pair) + const xrpUsdRow = dataRows[0] + expect(xrpUsdRow).toHaveTextContent('rp9E3FN...xu8vM') // AMM instance + expect(xrpUsdRow.textContent).toMatch(/XRP\/USD/) // AMM pair + expect(xrpUsdRow).toHaveTextContent('1,000.5') // Balance + expect(xrpUsdRow).toHaveTextContent('$5,002.50') // Balance USD + expect(xrpUsdRow.textContent).toMatch(/10\.01/) // Share % + + // Verify second LP token (USD/EUR pair) + const usdEurRow = dataRows[1] + expect(usdEurRow).toHaveTextContent('rDMNE7x...vd4XW') // AMM instance + expect(usdEurRow.textContent).toMatch(/USD\/EUR/) // AMM pair + expect(usdEurRow).toHaveTextContent('500.25') // Balance + expect(usdEurRow).toHaveTextContent('--') // Balance USD (no XRP in pair) + expect(usdEurRow.textContent).toMatch(/10\.01/) // Share % + }) +}) diff --git a/src/containers/Accounts/AccountAsset/test/HeldMPTs.test.tsx b/src/containers/Accounts/AccountAsset/test/HeldMPTs.test.tsx new file mode 100644 index 000000000..139e96877 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/test/HeldMPTs.test.tsx @@ -0,0 +1,222 @@ +import { render, screen, cleanup, waitFor } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import { QueryClientProvider } from 'react-query' +import i18n from '../../../../i18n/testConfigEnglish' +import SocketContext from '../../../shared/SocketContext' +import { HeldMPTs } from '../assetTables/HeldMPTs' +import { getAccountMPTs, getMPTIssuance } from '../../../../rippled/lib/rippled' +import { queryClient } from '../../../shared/QueryClient' +import Mock = jest.Mock + +jest.mock('../../../../rippled/lib/rippled') +jest.mock('../../../../rippled/lib/logger', () => ({ + __esModule: true, + default: () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }), +})) + +const mockedGetAccountMPTs = getAccountMPTs as Mock +const mockedGetMPTIssuance = getMPTIssuance as Mock + +// Mock socket client +const mockSocket = {} as any + +// Test wrapper component +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + + + + +) + +const mockMPTsResponse = { + account_objects: [ + { + MPTokenIssuanceID: '000004C463C52827307480341125DA65C267105D00000001', + MPTAmount: '1000000', + Flags: 1, // lsfMPTLocked + }, + { + MPTokenIssuanceID: '000004C463C52827307480341125DA65C267105D00000002', + MPTAmount: '500000', + Flags: 0, + }, + { + MPTokenIssuanceID: '000004C463C52827307480341125DA65C267105D00000003', + MPTAmount: '250000', + Flags: 0, + }, + ], + marker: '', +} + +const mockMPTIssuanceResponses = { + '000004C463C52827307480341125DA65C267105D00000001': { + node: { + Issuer: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH', + TransferFee: 5000, + Flags: 0, + MPTokenMetadata: { + Ticker: 'USD', + IssuerName: 'Gatehub', + AssetClass: 'Stablecoin', + }, + }, + }, + '000004C463C52827307480341125DA65C267105D00000002': { + node: { + Issuer: 'rLNaPoKeeBjZe2qs6x52yVPZpZ8td4dc6w', + TransferFee: 10000, + Flags: 1, // lsfMPTLocked (Global) + MPTokenMetadata: { + Ticker: 'EUR', + IssuerName: 'Bitstamp', + AssetClass: 'Currency', + }, + }, + }, + '000004C463C52827307480341125DA65C267105D00000003': { + node: { + Issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', + TransferFee: 15000, + Flags: 0, + MPTokenMetadata: { + Ticker: 'BTC', + IssuerName: 'Kraken', + AssetClass: 'Cryptocurrency', + }, + }, + }, +} + +describe('HeldMPTs', () => { + // Helper function to verify all column headers are displayed + const verifyColumnHeaders = async () => { + await waitFor(() => { + expect(screen.getByText('Token ID')).toBeInTheDocument() + }) + expect(screen.getByText('Ticker')).toBeInTheDocument() + expect(screen.getByText('Issuer')).toBeInTheDocument() + expect(screen.getByText('Price (USD)')).toBeInTheDocument() + expect(screen.getByText('Balance')).toBeInTheDocument() + expect(screen.getByText('Balance (USD)')).toBeInTheDocument() + expect(screen.getByText('Asset Class')).toBeInTheDocument() + expect(screen.getByText('Transfer Fee')).toBeInTheDocument() + expect(screen.getByText('Locked')).toBeInTheDocument() + } + + afterEach(() => { + cleanup() + queryClient.clear() + }) + + beforeEach(() => { + jest.clearAllMocks() + + mockedGetAccountMPTs.mockResolvedValue(mockMPTsResponse) + mockedGetMPTIssuance.mockImplementation((_, mptIssuanceId) => { + const response = + mockMPTIssuanceResponses[ + mptIssuanceId as keyof typeof mockMPTIssuanceResponses + ] + return Promise.resolve(response) + }) + }) + + it('renders empty state when no MPTs are held', async () => { + mockedGetAccountMPTs.mockResolvedValueOnce({ account_objects: [] }) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Check that the empty message is displayed + await waitFor(() => { + expect(screen.getByText('No MPTs found')).toBeInTheDocument() + }) + }) + + it('handles error when getAccountMPTs fails', async () => { + mockedGetAccountMPTs.mockRejectedValueOnce(new Error('Network error')) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Should show empty state when error occurs + await waitFor(() => { + expect(screen.getByText('No MPTs found')).toBeInTheDocument() + }) + }) + + it('shows 3 held MPTs with all column values', async () => { + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Wait for MPTs to load + await waitFor(() => { + expect(screen.getByText('000004C463...5D00000001')).toBeInTheDocument() + }) + + // Get all table rows (excluding header) + const rows = screen.getAllByRole('row') + const dataRows = rows.slice(1) // Skip header row + + // Verify we have 3 MPT rows + expect(dataRows).toHaveLength(3) + + // Verify USD MPT data in first row + const usdRow = dataRows[0] + expect(usdRow).toHaveTextContent('000004C463...5D00000001') + expect(usdRow).toHaveTextContent('rN7n7ot...6fzRH') // issuer + expect(usdRow).toHaveTextContent('1,000,000') // balance + expect(usdRow).toHaveTextContent('5%') // transfer fee + expect(usdRow).toHaveTextContent('Individual') // locked status + + // Verify EUR MPT data in second row + const eurRow = dataRows[1] + expect(eurRow).toHaveTextContent('000004C463...5D00000002') + expect(eurRow).toHaveTextContent('rLNaPoK...4dc6w') // issuer + expect(eurRow).toHaveTextContent('500,000') // balance + expect(eurRow).toHaveTextContent('10%') // transfer fee + expect(eurRow).toHaveTextContent('Global') // locked status + + // Verify BTC MPT data in third row + const btcRow = dataRows[2] + expect(btcRow).toHaveTextContent('000004C463...5D00000003') + expect(btcRow).toHaveTextContent('rvYAfWj...bs59B') // issuer + expect(btcRow).toHaveTextContent('250,000') // balance + expect(btcRow).toHaveTextContent('15%') // transfer fee + expect(btcRow).toHaveTextContent('--') // not locked + + // Verify FutureDataIcon appears for Ticker, Price (USD), Balance (USD), and Asset Class columns + // Each MPT has 4 FutureDataIcons (ticker, price USD, balance USD, asset class) = 3 MPTs * 4 icons = 12 total + const futureDataIcons = document.querySelectorAll('.future-data') + expect(futureDataIcons).toHaveLength(12) + }) +}) diff --git a/src/containers/Accounts/AccountAsset/test/HeldNFTs.test.tsx b/src/containers/Accounts/AccountAsset/test/HeldNFTs.test.tsx new file mode 100644 index 000000000..e61e5f2aa --- /dev/null +++ b/src/containers/Accounts/AccountAsset/test/HeldNFTs.test.tsx @@ -0,0 +1,244 @@ +import { render, screen, cleanup, waitFor } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import { QueryClientProvider } from 'react-query' +import i18n from '../../../../i18n/testConfigEnglish' +import SocketContext from '../../../shared/SocketContext' +import { HeldNFTs } from '../assetTables/HeldNFTs' +import { + getAccountNFTs, + getBuyNFToffers, + getSellNFToffers, +} from '../../../../rippled/lib/rippled' +import { queryClient } from '../../../shared/QueryClient' +import Mock = jest.Mock + +jest.mock('../../../../rippled/lib/rippled') +jest.mock('../../../../rippled/lib/logger', () => ({ + __esModule: true, + default: () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }), +})) + +const mockedGetAccountNFTs = getAccountNFTs as Mock +const mockedGetBuyNFToffers = getBuyNFToffers as Mock +const mockedGetSellNFToffers = getSellNFToffers as Mock + +// Mock socket client +const mockSocket = {} as any + +// Test wrapper component +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + + + + +) + +// Mock NFT data +const mockNFTsResponse = { + account_nfts: [ + { + NFTokenID: + '00081388F2C3F5B05AD6C8C0F5C0F5C0F5C0F5C0F5C0F5C0F5C0F5C000000001', + Issuer: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH', + URI: Buffer.from('https://example.com/nft1').toString('hex'), + TransferFee: 5000, // 5% + }, + { + NFTokenID: + '00081388F2C3F5B05AD6C8C0F5C0F5C0F5C0F5C0F5C0F5C0F5C0F5C000000002', + Issuer: 'rLNaPoKeeBjZe2qs6x52yVPZpZ8td4dc6w', + URI: Buffer.from('https://example.com/nft2').toString('hex'), + TransferFee: 10000, // 10% + }, + { + NFTokenID: + '00081388F2C3F5B05AD6C8C0F5C0F5C0F5C0F5C0F5C0F5C0F5C0F5C000000003', + Issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', + URI: '', // No URI + TransferFee: 15000, // 15% + }, + ], + marker: '', +} + +// Mock offer data (XRP only - in drops, 1 XRP = 1,000,000 drops) +const mockSellOffersNFT1 = { + offers: [ + { amount: '5000000' }, // 5 XRP + { amount: '6000000' }, // 6 XRP + ], +} + +const mockBuyOffersNFT1 = { + offers: [ + { amount: '4500000' }, // 4.5 XRP + { amount: '4000000' }, // 4 XRP + ], +} + +const mockSellOffersNFT2 = { + offers: [ + { amount: '10000000' }, // 10 XRP + ], +} + +const mockBuyOffersNFT2 = { + offers: [ + { amount: '9500000' }, // 9.5 XRP + ], +} + +// NFT3 has no offers +const mockNoOffers = { + offers: [], +} + +describe('HeldNFTs', () => { + // Helper function to verify all column headers are displayed + const verifyColumnHeaders = async () => { + await waitFor(() => { + expect(screen.getByText('Token ID')).toBeInTheDocument() + }) + expect(screen.getByText('Issuer')).toBeInTheDocument() + expect(screen.getByText('URL')).toBeInTheDocument() + expect(screen.getByText('Transfer Fee')).toBeInTheDocument() + expect(screen.getByText('Lowest Ask')).toBeInTheDocument() + expect(screen.getByText('Highest Bid')).toBeInTheDocument() + } + + afterEach(() => { + cleanup() + queryClient.clear() + }) + + beforeEach(() => { + jest.clearAllMocks() + mockedGetAccountNFTs.mockResolvedValue(mockNFTsResponse) + mockedGetBuyNFToffers.mockImplementation( + (_rippledSocket: any, nftId: string) => { + if (nftId === mockNFTsResponse.account_nfts[0].NFTokenID) { + return Promise.resolve(mockBuyOffersNFT1) + } + if (nftId === mockNFTsResponse.account_nfts[1].NFTokenID) { + return Promise.resolve(mockBuyOffersNFT2) + } + return Promise.resolve(mockNoOffers) + }, + ) + mockedGetSellNFToffers.mockImplementation( + (_rippledSocket: any, nftId: string) => { + if (nftId === mockNFTsResponse.account_nfts[0].NFTokenID) { + return Promise.resolve(mockSellOffersNFT1) + } + if (nftId === mockNFTsResponse.account_nfts[1].NFTokenID) { + return Promise.resolve(mockSellOffersNFT2) + } + return Promise.resolve(mockNoOffers) + }, + ) + }) + + it('renders empty state when no NFTs are held', async () => { + mockedGetAccountNFTs.mockResolvedValueOnce({ account_nfts: [] }) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Check that the empty message is displayed + await waitFor(() => { + expect(screen.getByText('No NFTs found')).toBeInTheDocument() + }) + }) + + it('handles error when getAccountNFTs fails', async () => { + mockedGetAccountNFTs.mockRejectedValueOnce(new Error('Network error')) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Should show empty state when error occurs + await waitFor(() => { + expect(screen.getByText('No NFTs found')).toBeInTheDocument() + }) + }) + + it('shows 3 held NFTs with all column values', async () => { + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Wait for NFTs to load + await waitFor(() => { + expect(screen.getByText('00081388F2...C000000001')).toBeInTheDocument() + }) + + // Get all table rows (excluding header) + const rows = screen.getAllByRole('row') + const dataRows = rows.slice(1) // Skip header row + + // Verify we have 3 NFT rows + expect(dataRows).toHaveLength(3) + + // Verify NFT1 data in first row + const nft1Row = dataRows[0] + expect(nft1Row).toHaveTextContent('00081388F2...C000000001') + expect(nft1Row).toHaveTextContent('rN7n7ot...6fzRH') + expect(nft1Row).toHaveTextContent('https://example.com/nft1') + expect(nft1Row).toHaveTextContent('5%') + + // Verify NFT2 data in second row + const nft2Row = dataRows[1] + expect(nft2Row).toHaveTextContent('00081388F2...C000000002') + expect(nft2Row).toHaveTextContent('rLNaPoK...4dc6w') + expect(nft2Row).toHaveTextContent('https://example.com/nft2') + expect(nft2Row).toHaveTextContent('10%') + + // Verify NFT3 data in third row + const nft3Row = dataRows[2] + expect(nft3Row).toHaveTextContent('00081388F2...C000000003') + expect(nft3Row).toHaveTextContent('rvYAfWj...bs59B') + expect(nft3Row).toHaveTextContent('15%') + + // Verify offer data (async loaded) + // XRP symbol is \uE900, offers are formatted with localizeNumber with currency: 'XRP' + await waitFor(() => { + expect(nft1Row.textContent).toContain('\uE9005.00') // Lowest Ask for NFT1 + }) + expect(nft1Row.textContent).toContain('\uE9004.50') // Highest Bid for NFT1 + + // NFT2 offers + expect(nft2Row.textContent).toContain('\uE90010.00') // Lowest Ask for NFT2 + expect(nft2Row.textContent).toContain('\uE9009.50') // Highest Bid for NFT2 + + // NFT3: No offers, should show -- + const nft3Placeholders = nft3Row.textContent?.match(/--/g) || [] + expect(nft3Placeholders.length).toBeGreaterThanOrEqual(2) // At least 2: Lowest Ask and Highest Bid (no URL is also --) + }) +}) diff --git a/src/containers/Accounts/AccountAsset/test/IssuedIOUs.test.tsx b/src/containers/Accounts/AccountAsset/test/IssuedIOUs.test.tsx new file mode 100644 index 000000000..74233817d --- /dev/null +++ b/src/containers/Accounts/AccountAsset/test/IssuedIOUs.test.tsx @@ -0,0 +1,219 @@ +import { render, screen, cleanup, waitFor } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import { QueryClientProvider } from 'react-query' +import i18n from '../../../../i18n/testConfigEnglish' +import SocketContext from '../../../shared/SocketContext' +import { IssuedIOUs } from '../assetTables/IssuedIOUs' +import { getBalances } from '../../../../rippled/lib/rippled' +import { queryClient } from '../../../shared/QueryClient' +import Mock = jest.Mock + +jest.mock('../../../../rippled/lib/rippled') +jest.mock('../../../../rippled/lib/logger', () => ({ + __esModule: true, + default: () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }), +})) + +global.fetch = jest.fn() as jest.Mock + +const mockedGetBalances = getBalances as Mock +const mockedFetch = fetch as Mock + +// Mock socket client +const mockSocket = {} as any + +// Test wrapper component +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + + + + +) + +// Mock data for issued tokens (obligations) +const mockBalancesResponse = { + obligations: { + USD: '1000000.50', + EUR: '500000.25', + BTC: '50.001', + }, +} + +// Mock LOS Token API response for issued tokens +const mockLOSTokenResponse = { + tokens: [ + { + currency: 'USD', + issuer_account: 'rTest123', + icon: 'https://example.com/usd-icon.png', + price_usd: '1.00', + number_of_trustlines: 15000, + number_of_holders: 12500, + supply: '1000000.50', + // Missing asset_class to test placeholder + }, + { + currency: 'EUR', + issuer_account: 'rTest123', + icon: 'https://example.com/eur-icon.png', + price_usd: '1.10', + number_of_trustlines: 8500, + number_of_holders: 7200, + supply: '500000.25', + asset_class: 'Currency', + }, + { + currency: 'BTC', + issuer_account: 'rTest123', + price_usd: '45000.00', + number_of_trustlines: 2500, + number_of_holders: 2100, + supply: '50.001', + asset_class: 'Cryptocurrency', + }, + ], +} + +// Mock account with transfer fee and global freeze +const mockAccount = { + info: { + rate: 1.5, // 1.5% transfer fee + flags: ['lsfGlobalFreeze'], + }, +} + +describe('IssuedIOUs', () => { + // Helper function to verify all column headers are displayed + const verifyColumnHeaders = async () => { + await waitFor(() => { + expect(screen.getByText('Currency Code')).toBeInTheDocument() + }) + expect(screen.getByText('Price (USD)')).toBeInTheDocument() + expect(screen.getByText('Trustlines')).toBeInTheDocument() + expect(screen.getByText('Holders')).toBeInTheDocument() + expect(screen.getByText('Supply')).toBeInTheDocument() + expect(screen.getByText('Asset Class')).toBeInTheDocument() + expect(screen.getByText('Transfer Fee')).toBeInTheDocument() + expect(screen.getByText('Frozen')).toBeInTheDocument() + } + + afterEach(() => { + cleanup() + queryClient.clear() // Clear React Query cache between tests + }) + + beforeEach(() => { + jest.clearAllMocks() + process.env.VITE_LOS_URL = 'https://api.los.example.com' + + mockedGetBalances.mockResolvedValue(mockBalancesResponse) + mockedFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockLOSTokenResponse), + } as Response) + }) + + it('handles error when getBalances fails', async () => { + // Mock getBalances to throw an error + mockedGetBalances.mockRejectedValueOnce(new Error('Network error')) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Should show empty state when error occurs + await waitFor(() => { + expect(screen.getByText('No IOUs found')).toBeInTheDocument() + }) + }) + + it('renders empty state when no IOUs are issued', async () => { + // Override the default mocks to return empty obligations + mockedGetBalances.mockResolvedValueOnce({ obligations: {} }) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Check that the empty message is displayed (this is the translated text) + await waitFor(() => { + expect(screen.getByText('No IOUs found')).toBeInTheDocument() + }) + }) + + it('shows 3 issued tokens with all column values', async () => { + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Wait for tokens to load + await waitFor(() => { + expect(screen.getByText('BTC')).toBeInTheDocument() + }) + + // Get all table rows (excluding header) + const rows = screen.getAllByRole('row') + const dataRows = rows.slice(1) // Skip header row + + // Verify we have 3 token rows (sorted by Price USD descending) + expect(dataRows).toHaveLength(3) + + // Verify BTC token data in first row (highest price: $45,000.00) + const btcRow = dataRows[0] + expect(btcRow).toHaveTextContent('BTC') + expect(btcRow).toHaveTextContent('$45,000.00') // price + expect(btcRow).toHaveTextContent('2,500') // trustlines + expect(btcRow).toHaveTextContent('2,100') // holders + expect(btcRow).toHaveTextContent('50.001') // supply + expect(btcRow).toHaveTextContent('Cryptocurrency') // asset class + expect(btcRow).toHaveTextContent('1.5%') // transfer fee + expect(btcRow).toHaveTextContent('Global') // frozen + + // Verify EUR token data in second row (second highest: $1.10) + const eurRow = dataRows[1] + expect(eurRow).toHaveTextContent('EUR') + expect(eurRow).toHaveTextContent('$1.10') // price + expect(eurRow).toHaveTextContent('8,500') // trustlines + expect(eurRow).toHaveTextContent('7,200') // holders + expect(eurRow).toHaveTextContent('500,000.25') // supply + expect(eurRow).toHaveTextContent('Currency') // asset class + expect(eurRow).toHaveTextContent('1.5%') // transfer fee + expect(eurRow).toHaveTextContent('Global') // frozen + + // Verify USD token data in third row (lowest price: $1.00) + const usdRow = dataRows[2] + expect(usdRow).toHaveTextContent('USD') + expect(usdRow).toHaveTextContent('$1.00') // price + expect(usdRow).toHaveTextContent('15,000') // trustlines + expect(usdRow).toHaveTextContent('12,500') // holders + expect(usdRow).toHaveTextContent('1,000,000.5') // supply + expect(usdRow).toHaveTextContent('--') // asset class + expect(usdRow).toHaveTextContent('1.5%') // transfer fee + expect(usdRow).toHaveTextContent('Global') // frozen + }) +}) diff --git a/src/containers/Accounts/AccountAsset/test/IssuedMPTs.test.tsx b/src/containers/Accounts/AccountAsset/test/IssuedMPTs.test.tsx new file mode 100644 index 000000000..2b2c2ca95 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/test/IssuedMPTs.test.tsx @@ -0,0 +1,184 @@ +import { render, screen, cleanup, waitFor } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import { QueryClientProvider } from 'react-query' +import i18n from '../../../../i18n/testConfigEnglish' +import SocketContext from '../../../shared/SocketContext' +import { IssuedMPTs } from '../assetTables/IssuedMPTs' +import { getAccountObjects } from '../../../../rippled/lib/rippled' +import { queryClient } from '../../../shared/QueryClient' +import Mock = jest.Mock + +jest.mock('../../../../rippled/lib/rippled') +jest.mock('../../../../rippled/lib/logger', () => ({ + __esModule: true, + default: () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }), +})) + +const mockedGetAccountObjects = getAccountObjects as Mock + +// Mock socket client +const mockSocket = {} as any + +// Test wrapper component +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + + + + +) + +const mockMPTIssuancesResponse = { + account_objects: [ + { + mpt_issuance_id: '000004C463C52827307480341125DA65C267105D00000001', + OutstandingAmount: '1000000', + TransferFee: 5000, + Flags: 0, + MPTokenMetadata: { + Ticker: 'USD', + AssetClass: 'Stablecoin', + }, + }, + { + mpt_issuance_id: '000004C463C52827307480341125DA65C267105D00000002', + OutstandingAmount: '500000', + TransferFee: 10000, + Flags: 1, // lsfMPTLocked + MPTokenMetadata: { + Ticker: 'EUR', + AssetClass: 'Currency', + }, + }, + { + mpt_issuance_id: '000004C463C52827307480341125DA65C267105D00000003', + OutstandingAmount: '250000', + TransferFee: 15000, + Flags: 0, + MPTokenMetadata: { + Ticker: 'BTC', + AssetClass: 'Cryptocurrency', + }, + }, + ], + marker: '', +} + +describe('IssuedMPTs', () => { + // Helper function to verify all column headers are displayed + const verifyColumnHeaders = async () => { + await waitFor(() => { + expect(screen.getByText('Token ID')).toBeInTheDocument() + }) + expect(screen.getByText('Ticker')).toBeInTheDocument() + expect(screen.getByText('Price (USD)')).toBeInTheDocument() + expect(screen.getByText('Supply')).toBeInTheDocument() + expect(screen.getByText('Asset Class')).toBeInTheDocument() + expect(screen.getByText('Transfer Fee')).toBeInTheDocument() + expect(screen.getByText('Locked')).toBeInTheDocument() + } + + afterEach(() => { + cleanup() + queryClient.clear() + }) + + beforeEach(() => { + jest.clearAllMocks() + mockedGetAccountObjects.mockResolvedValue(mockMPTIssuancesResponse) + }) + + it('renders empty state when no MPTs are issued', async () => { + mockedGetAccountObjects.mockResolvedValueOnce({ account_objects: [] }) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Check that the empty message is displayed + await waitFor(() => { + expect(screen.getByText('No MPTs found')).toBeInTheDocument() + }) + }) + + it('handles error when getAccountObjects fails', async () => { + mockedGetAccountObjects.mockRejectedValueOnce(new Error('Network error')) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Should show empty state when error occurs + await waitFor(() => { + expect(screen.getByText('No MPTs found')).toBeInTheDocument() + }) + }) + + it('shows 3 issued MPTs with all column values', async () => { + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Wait for MPTs to load + await waitFor(() => { + expect(screen.getByText('000004C463...5D00000001')).toBeInTheDocument() + }) + + // Get all table rows (excluding header) + const rows = screen.getAllByRole('row') + const dataRows = rows.slice(1) // Skip header row + + // Verify we have 3 MPT rows + expect(dataRows).toHaveLength(3) + + // Verify USD MPT data in first row + const usdRow = dataRows[0] + expect(usdRow).toHaveTextContent('000004C463...5D00000001') + expect(usdRow).toHaveTextContent('1,000,000') // supply + expect(usdRow).toHaveTextContent('5%') // transfer fee + expect(usdRow).toHaveTextContent('--') // not locked + + // Verify EUR MPT data in second row + const eurRow = dataRows[1] + expect(eurRow).toHaveTextContent('000004C463...5D00000002') + expect(eurRow).toHaveTextContent('500,000') // supply + expect(eurRow).toHaveTextContent('10%') // transfer fee + expect(eurRow).toHaveTextContent('Global') // locked + + // Verify BTC MPT data in third row + const btcRow = dataRows[2] + expect(btcRow).toHaveTextContent('000004C463...5D00000003') + expect(btcRow).toHaveTextContent('250,000') // supply + expect(btcRow).toHaveTextContent('15%') // transfer fee + expect(btcRow).toHaveTextContent('--') // not locked + + // Verify FutureDataIcon appears for Ticker, Price (USD), and Asset Class columns + // Each MPT has 3 FutureDataIcons (ticker, price, asset class) = 3 MPTs * 3 icons = 9 total + const futureDataIcons = document.querySelectorAll('.future-data') + expect(futureDataIcons).toHaveLength(9) + }) +}) diff --git a/src/containers/Accounts/AccountAsset/test/IssuedNFTs.test.tsx b/src/containers/Accounts/AccountAsset/test/IssuedNFTs.test.tsx new file mode 100644 index 000000000..6b3ae29a5 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/test/IssuedNFTs.test.tsx @@ -0,0 +1,242 @@ +import { render, screen, cleanup, waitFor } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import { QueryClientProvider } from 'react-query' +import i18n from '../../../../i18n/testConfigEnglish' +import SocketContext from '../../../shared/SocketContext' +import { IssuedNFTs } from '../assetTables/IssuedNFTs' +import { + getNFTsIssuedByAccount, + getBuyNFToffers, + getSellNFToffers, +} from '../../../../rippled/lib/rippled' +import { queryClient } from '../../../shared/QueryClient' +import Mock = jest.Mock + +jest.mock('../../../../rippled/lib/rippled') +jest.mock('../../../../rippled/lib/logger', () => ({ + __esModule: true, + default: () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }), +})) + +const mockedGetNFTsIssuedByAccount = getNFTsIssuedByAccount as Mock +const mockedGetBuyNFToffers = getBuyNFToffers as Mock +const mockedGetSellNFToffers = getSellNFToffers as Mock + +// Mock socket client +const mockSocket = {} as any + +// Test wrapper component +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + + + + +) + +// Mock NFT data +const mockIssuedNFTsResponse = { + nfts: [ + { + nft_id: + '00081388F2C3F5B05AD6C8C0F5C0F5C0F5C0F5C0F5C0F5C0F5C0F5C000000001', + uri: Buffer.from('https://example.com/issued-nft1').toString('hex'), + transfer_fee: 2500, // 2.5% + }, + { + nft_id: + '00081388F2C3F5B05AD6C8C0F5C0F5C0F5C0F5C0F5C0F5C0F5C0F5C000000002', + uri: Buffer.from('https://example.com/issued-nft2').toString('hex'), + transfer_fee: 7500, // 7.5% + }, + { + nft_id: + '00081388F2C3F5B05AD6C8C0F5C0F5C0F5C0F5C0F5C0F5C0F5C0F5C000000003', + uri: '', // No URI + transfer_fee: 12500, // 12.5% + }, + ], + marker: '', +} + +// Mock offer data (XRP only - in drops, 1 XRP = 1,000,000 drops) +const mockSellOffersNFT1 = { + offers: [ + { amount: '3000000' }, // 3 XRP + { amount: '3500000' }, // 3.5 XRP + ], +} + +const mockBuyOffersNFT1 = { + offers: [ + { amount: '2800000' }, // 2.8 XRP + { amount: '2500000' }, // 2.5 XRP + ], +} + +const mockSellOffersNFT2 = { + offers: [ + { amount: '8000000' }, // 8 XRP + ], +} + +const mockBuyOffersNFT2 = { + offers: [ + { amount: '7500000' }, // 7.5 XRP + ], +} + +// NFT3 has no offers +const mockNoOffers = { + offers: [], +} + +describe('IssuedNFTs', () => { + // Helper function to verify all column headers are displayed + const verifyColumnHeaders = async () => { + await waitFor(() => { + expect(screen.getByText('Token ID')).toBeInTheDocument() + }) + expect(screen.getByText('URL')).toBeInTheDocument() + expect(screen.getByText('Transfer Fee')).toBeInTheDocument() + expect(screen.getByText('Lowest Ask')).toBeInTheDocument() + expect(screen.getByText('Highest Bid')).toBeInTheDocument() + } + + afterEach(() => { + cleanup() + queryClient.clear() + }) + + beforeEach(() => { + jest.clearAllMocks() + mockedGetNFTsIssuedByAccount.mockResolvedValue(mockIssuedNFTsResponse) + mockedGetBuyNFToffers.mockImplementation( + (_rippledSocket: any, nftId: string) => { + if (nftId === mockIssuedNFTsResponse.nfts[0].nft_id) { + return Promise.resolve(mockBuyOffersNFT1) + } + if (nftId === mockIssuedNFTsResponse.nfts[1].nft_id) { + return Promise.resolve(mockBuyOffersNFT2) + } + return Promise.resolve(mockNoOffers) + }, + ) + mockedGetSellNFToffers.mockImplementation( + (_rippledSocket: any, nftId: string) => { + if (nftId === mockIssuedNFTsResponse.nfts[0].nft_id) { + return Promise.resolve(mockSellOffersNFT1) + } + if (nftId === mockIssuedNFTsResponse.nfts[1].nft_id) { + return Promise.resolve(mockSellOffersNFT2) + } + return Promise.resolve(mockNoOffers) + }, + ) + }) + + it('renders empty state when no NFTs are issued', async () => { + mockedGetNFTsIssuedByAccount.mockResolvedValueOnce({ nfts: [] }) + + render( + + + , + ) + + // Verify all column headers are displayed (no Issuer column for issued NFTs) + await verifyColumnHeaders() + + // Check that the empty message is displayed + await waitFor(() => { + expect(screen.getByText('No NFTs found')).toBeInTheDocument() + }) + }) + + it('handles error when getNFTsIssuedByAccount fails', async () => { + mockedGetNFTsIssuedByAccount.mockRejectedValueOnce( + new Error('Network error'), + ) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Should show empty state when error occurs + await waitFor(() => { + expect(screen.getByText('No NFTs found')).toBeInTheDocument() + }) + }) + + it('shows 3 issued NFTs with all column values', async () => { + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Verify "Issuer" column is NOT displayed for issued NFTs (showIssuer=false) + expect(screen.queryByText('Issuer')).not.toBeInTheDocument() + + // Wait for NFTs to load + await waitFor(() => { + expect(screen.getByText('00081388F2...C000000001')).toBeInTheDocument() + }) + + // Get all table rows (excluding header) + const rows = screen.getAllByRole('row') + const dataRows = rows.slice(1) // Skip header row + + // Verify we have 3 NFT rows + expect(dataRows).toHaveLength(3) + + // Verify NFT1 data in first row + const nft1Row = dataRows[0] + expect(nft1Row).toHaveTextContent('00081388F2...C000000001') + expect(nft1Row).toHaveTextContent('https://example...issued-nft1') + expect(nft1Row).toHaveTextContent('2.5%') + + // Verify NFT2 data in second row + const nft2Row = dataRows[1] + expect(nft2Row).toHaveTextContent('00081388F2...C000000002') + expect(nft2Row).toHaveTextContent('https://example...issued-nft2') + expect(nft2Row).toHaveTextContent('7.5%') + + // Verify NFT3 data in third row + const nft3Row = dataRows[2] + expect(nft3Row).toHaveTextContent('00081388F2...C000000003') + expect(nft3Row).toHaveTextContent('12.5%') + + // Verify offer data (async loaded) + // XRP symbol is \uE900, offers are formatted with localizeNumber with currency: 'XRP' + await waitFor(() => { + expect(nft1Row.textContent).toContain('\uE9003.00') // Lowest Ask for NFT1 + }) + expect(nft1Row.textContent).toContain('\uE9002.80') // Highest Bid for NFT1 + + // NFT2 offers + expect(nft2Row.textContent).toContain('\uE9008.00') // Lowest Ask for NFT2 + expect(nft2Row.textContent).toContain('\uE9007.50') // Highest Bid for NFT2 + + // NFT3: No offers, should show -- + const nft3Placeholders = nft3Row.textContent?.match(/--/g) || [] + expect(nft3Placeholders.length).toBeGreaterThanOrEqual(2) // At least 2: Lowest Ask and Highest Bid (no URL is also --) + }) +}) diff --git a/src/containers/Accounts/AccountAssetTab/AccountAssetTab.tsx b/src/containers/Accounts/AccountAssetTab/AccountAssetTab.tsx deleted file mode 100644 index 6445b33ba..000000000 --- a/src/containers/Accounts/AccountAssetTab/AccountAssetTab.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { ChangeEvent } from 'react' -import { useTranslation } from 'react-i18next' -import { useNavigate } from 'react-router' -import { useRouteParams } from '../../shared/routing' -import { AccountIssuedTokenTable } from '../AccountIssuedTokenTable' -import { AccountNFTTable } from '../AccountNFTTable/AccountNFTTable' -import { AccountMPTTable } from '../AccountMPTTable/AccountMPTTable' -import { ACCOUNT_ROUTE } from '../../App/routes' - -// TODO: Add state types or convert to react query -interface Props { - account: any -} - -const assetTypes = ['issued', 'nft', 'mpt'] - -export const AccountAssetTab = ({ account }: Props) => { - const { id: accountId = '', assetType = assetTypes[0] } = - useRouteParams(ACCOUNT_ROUTE) - - const navigate = useNavigate() - const { t } = useTranslation() - function switchAsset(event: ChangeEvent) { - return navigate(`/accounts/${accountId}/assets/${event.target.value}`) - } - // eslint-disable-next-line react/jsx-no-useless-fragment - if (account.deleted) return <> - return ( - <> -
- {assetTypes.map((type) => { - const fieldId = `tokens-${type}` - return ( - - ) - })} -
- -
- {assetType === 'issued' && ( - - )} - {assetType === 'nft' && } - {assetType === 'mpt' && } -
- - ) -} diff --git a/src/containers/Accounts/AccountAssetTab/test/AccountAssetTab.test.tsx b/src/containers/Accounts/AccountAssetTab/test/AccountAssetTab.test.tsx deleted file mode 100644 index 0183e7d6b..000000000 --- a/src/containers/Accounts/AccountAssetTab/test/AccountAssetTab.test.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { mount } from 'enzyme' -import { QueryClientProvider } from 'react-query' -import { Route } from 'react-router-dom' -import i18n from '../../../../i18n/testConfig' -import { testQueryClient } from '../../../test/QueryClient' -import { flushPromises, QuickHarness } from '../../../test/utils' -import { AccountAssetTab } from '../AccountAssetTab' -import { ACCOUNT_ROUTE } from '../../../App/routes' - -const mockAccountData = { - account: 'rH3UU2acTY5fz4rYwMLMniivDqcPLjg6mk', - info: { - sequence: 6467288, - ownerCount: 1, - reserve: 1.2, - flags: [], - balance: '99999999', - previousTxn: - '3915E32DD06F670644EDF10EB1193C7B001209113B92A580D2C09B0FDE36E3BC', - previousLedger: 6467291, - }, - balances: { - XRP: 99.999999, - }, - tokens: [], - deleted: false, - hasBridge: false, -} - -// Child tables mocked to isolate AccountAssetTab -jest.mock('../../AccountIssuedTokenTable', () => ({ - AccountIssuedTokenTable: ({ account }: any) => ( -
{account.account}-issued
- ), -})) -jest.mock('../../AccountNFTTable/AccountNFTTable', () => ({ - AccountNFTTable: ({ accountId }: any) => ( -
{accountId}-nft
- ), -})) -jest.mock('../../AccountMPTTable/AccountMPTTable', () => ({ - AccountMPTTable: ({ accountId }: any) => ( -
{accountId}-mpt
- ), -})) - -describe('AccountAssetTab routing', () => { - const createWrapper = (initialEntry: string, account) => - mount( - - - } - /> - - , - ) - - it('renders issued tab by default', async () => { - const wrapper = createWrapper( - `/accounts/${mockAccountData.account}/assets/issued`, - mockAccountData, - ) - await flushPromises() - wrapper.update() - - // Always show all 3 tabs - expect(wrapper.find('.radio-group').children().length).toBe(3) - - expect(wrapper.find('.issued-table')).toExist() - expect(wrapper.find('input[value="issued"]').prop('checked')).toBe(true) - - wrapper.unmount() - }) - - it('renders empty fragment when account is deleted', async () => { - const wrapper = createWrapper( - `/accounts/${mockAccountData.account}/assets/issued`, - { - deleted: true, - }, - ) - await flushPromises() - wrapper.update() - - expect(wrapper.html()).toBe('') - wrapper.unmount() - }) - - it('renders nft tab when route param is nft', async () => { - const wrapper = createWrapper( - `/accounts/${mockAccountData.account}/assets/nft`, - mockAccountData, - ) - await flushPromises() - wrapper.update() - - expect(wrapper.find('.nft-table')).toExist() - expect(wrapper.find('input[value="nft"]').prop('checked')).toBe(true) - - wrapper.unmount() - }) - - it('renders mpt tab when route param is mpt', async () => { - const wrapper = createWrapper( - `/accounts/${mockAccountData.account}/assets/mpt`, - mockAccountData, - ) - await flushPromises() - wrapper.update() - - expect(wrapper.find('.mpt-table')).toExist() - expect(wrapper.find('input[value="mpt"]').prop('checked')).toBe(true) - - wrapper.unmount() - }) - - it('navigates when radio button is changed', async () => { - const wrapper = createWrapper( - `/accounts/${mockAccountData.account}/assets/mpt`, - mockAccountData, - ) - await flushPromises() - wrapper.update() - - wrapper.find('input[value="mpt"]').simulate('change', { - target: { value: 'mpt' }, - }) - - await flushPromises() - wrapper.update() - expect(wrapper.find('.mpt-table')).toExist() - - wrapper.unmount() - }) -}) diff --git a/src/containers/Accounts/AccountHeader/BalanceSelector/BalanceSelector.tsx b/src/containers/Accounts/AccountHeader/BalanceSelector/BalanceSelector.tsx deleted file mode 100644 index 3ca3a7ea9..000000000 --- a/src/containers/Accounts/AccountHeader/BalanceSelector/BalanceSelector.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { Dropdown } from '../../../shared/components/Dropdown' -import { BalanceSelectorItem } from './BalanceSelectorItem' -import './balance-selector.scss' - -export interface BalanceSelectorProps { - balances: { [currency: string]: string } - onSetCurrencySelected: (currency: string) => void - currencySelected: string -} - -export const BalanceSelector = ({ - balances, - onSetCurrencySelected, - currencySelected, -}: BalanceSelectorProps) => { - const { t } = useTranslation() - const balanceTuples = Object.entries(balances) - const title = `${balanceTuples.length - 1} ${t('accounts.other_balances')}` - - return ( - - {balanceTuples.map(([currency, value]) => { - if (currency === currencySelected) { - return undefined - } - - return ( - { - onSetCurrencySelected(currency) - }} - value={value} - /> - ) - })} - - ) -} diff --git a/src/containers/Accounts/AccountHeader/BalanceSelector/BalanceSelectorItem.tsx b/src/containers/Accounts/AccountHeader/BalanceSelector/BalanceSelectorItem.tsx deleted file mode 100644 index 27a7da75c..000000000 --- a/src/containers/Accounts/AccountHeader/BalanceSelector/BalanceSelectorItem.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react' -import { useLanguage } from '../../../shared/hooks' -import { CURRENCY_OPTIONS } from '../../../shared/transactionUtils' -import { localizeNumber } from '../../../shared/utils' -import { DropdownItem } from '../../../shared/components/Dropdown' -import Currency from '../../../shared/components/Currency' - -export interface BalanceSelectorItemProps { - currency: string - handler: (currency) => void - value: string -} - -export const BalanceSelectorItem = React.memo( - ({ currency, value, handler }: BalanceSelectorItemProps) => { - const language = useLanguage() - const options = { - ...CURRENCY_OPTIONS, - currency, - minimumFractionDigits: 0, - maximumFractionDigits: 2, - } - const formattedValue = localizeNumber(value, language, options) || '0.00' - - return ( - - - {formattedValue} - - ) - }, -) diff --git a/src/containers/Accounts/AccountHeader/BalanceSelector/balance-selector.scss b/src/containers/Accounts/AccountHeader/BalanceSelector/balance-selector.scss deleted file mode 100644 index 31c4742a4..000000000 --- a/src/containers/Accounts/AccountHeader/BalanceSelector/balance-selector.scss +++ /dev/null @@ -1,32 +0,0 @@ -@use '../../../shared/css/variables.scss' as *; - -.balance-selector { - position: relative; - display: block; - width: 100%; - - @include for-size(tablet-landscape-up) { - width: 280px; - } - - .dropdown-toggle { - padding: 12px 16px; - } - - .dropdown-menu { - width: 100%; - } - - .dropdown-item { - display: flex; - } - - .currency { - font-weight: bold; - } - - .total-balance { - margin-left: auto; - color: $black-40; - } -} diff --git a/src/containers/Accounts/AccountHeader/SubheaderLine.tsx b/src/containers/Accounts/AccountHeader/SubheaderLine.tsx deleted file mode 100644 index dd2f3ad17..000000000 --- a/src/containers/Accounts/AccountHeader/SubheaderLine.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { PropsWithChildren } from 'react' -import classnames from 'classnames' - -export type SubheaderLineProps = PropsWithChildren<{ - className?: string - label: string -}> - -export const SubheaderLine = (props: SubheaderLineProps) => { - const { label, children, className } = props - return ( -
  • - {label} -
    {children}
    -
  • - ) -} diff --git a/src/containers/Accounts/AccountHeader/index.tsx b/src/containers/Accounts/AccountHeader/index.tsx index 112475809..b62b7390f 100644 --- a/src/containers/Accounts/AccountHeader/index.tsx +++ b/src/containers/Accounts/AccountHeader/index.tsx @@ -1,313 +1,71 @@ -import { Trans, useTranslation } from 'react-i18next' +import { useTranslation } from 'react-i18next' +import InfoIcon from '../../shared/images/info-duotone.svg' +import DomainLink from '../../shared/components/DomainLink' import './styles.scss' -import { BalanceSelector } from './BalanceSelector/BalanceSelector' import { Account } from '../../shared/components/Account' -import { localizeNumber } from '../../shared/utils' -import InfoIcon from '../../shared/images/info.svg' -import { useLanguage } from '../../shared/hooks' -import Currency from '../../shared/components/Currency' -import DomainLink from '../../shared/components/DomainLink' -import { AccountState } from '../../../rippled/accountState' - -const CURRENCY_OPTIONS = { - style: 'currency', - currency: 'XRP', - minimumFractionDigits: 2, - maximumFractionDigits: 6, -} interface AccountHeaderProps { - onSetCurrencySelected: (currency: string) => void - currencySelected: string - account: any + isAccountDeleted: boolean accountId: string + account?: any } -export const AccountHeader = ({ - account, +const AccountHeader = ({ + isAccountDeleted, accountId, - onSetCurrencySelected, - currencySelected, + account, }: AccountHeaderProps) => { const { t } = useTranslation() - const language = useLanguage() - - function renderBalancesSelector(data: AccountState) { - const { balances = {} } = data - return ( - Object.keys(balances).length > 1 && ( -
    - -
    - ) - ) - } + const xAddress = account?.xAddress - function renderPaymentChannels(data: AccountState) { - const { paychannels } = data - return ( - paychannels && ( -
    -
    {t('payment_channels')}
    -
      -
    • - - {localizeNumber( - paychannels.total_available, - language, - CURRENCY_OPTIONS, - )} - - {t('available_in')} - {localizeNumber(paychannels.channels.length, language)} - {t('channels')} -
    • -
    -
    - ) - ) - } - - function renderEscrows(data: AccountState) { - const { escrows } = data - return ( - escrows && ( -
    -
    {t('escrows')}
    -
      -
    • - {t('inbound_total')}: - - {localizeNumber(escrows.totalIn, language, CURRENCY_OPTIONS)} - -
    • -
    • - {t('outbound_total')}: - - {localizeNumber(escrows.totalOut, language, CURRENCY_OPTIONS)} - -
    • -
    -
    - ) - ) - } - - function renderSignerList(data: AccountState) { - const { signerList } = data - return ( - signerList && ( -
    -

    {t('signers')}

    -
      - {signerList.signers.map((d) => ( -
    • - Signer - -
      - {` ${t('weight')}:`} - {d.weight} -
      -
    • - ))} - -
    • - -
    • -
    + return ( +
    + {isAccountDeleted && ( +
    +
    +
    +
    + {t('account_page_deleted_account_warning')} +
    - ) - ) - } - // TODO: show X-address on 'classic' account pages + )} - function renderExtendedAddress(data: AccountState) { - const { xAddress } = data // undefined when page has not yet finished loading - - let messageAboutTag: JSX.Element | string = '' - if (xAddress && xAddress.tag !== false) { - messageAboutTag = ( -
  • - - - ⚠️ - {' '} - This address contains a tag, indicating it may refer to a hosted - wallet. The balance and transactions below apply to the entire - account. They are not filtered to the specified destination tag. - -
  • - ) - } - - // TODO: Translate everything e.g. {t('signers')} - return ( - xAddress && ( -
    -
    Extended Address (X-address) Details
    -

    - {xAddress.classicAddress} - {xAddress.tag ? `:${xAddress.tag}` : ''} -

    -
      -
    • - - Classic Address: - {' '} - {/* TODO: Add [copy] button */} - -
    • -
    • - Tag: - - {xAddress.tag === false ? 'false' : xAddress.tag} - -
    • -
    • - Network: - - {xAddress.test ? 'Testnet' : 'Mainnet'} - -
    • - {messageAboutTag} -
    +
    +
    + {xAddress + ? t('account_page_extended_address') + : t('account_page_address')}
    - ) - ) - } - - function renderInfo(data: AccountState) { - const { info } = data - return ( - info && ( -
    -
    {t('account_info')}
    -
      -
    • - {t('reserve')}: - - {localizeNumber( - info.reserve || 0.0, - language, - CURRENCY_OPTIONS, - )} - -
    • -
    • - {t('current_sequence')}: - {localizeNumber(info.sequence, language)} -
    • - {info.ticketCount && ( -
    • - {t('ticket_count')}: - {localizeNumber(info.ticketCount, language)} -
    • - )} - {info.domain && ( -
    • - {t('domain')}: - -
    • - )} - {info.emailHash && ( -
    • - {t('email_hash')}: - {info.emailHash} -
    • - )} - {info.nftMinter && ( -
    • - {t('nftoken_minter')}: - -
    • - )} - {info.flags.length ? ( -
    • -
        - {info.flags.map((flag) => ( -
      • {flag}
      • - ))} -
      -
    • - ) : null} -
    +
    + {accountId}
    - ) - ) - } - - function renderHeaderContent(data: AccountState) { - const { balances = {}, deleted } = data - const balance = localizeNumber( - balances[currencySelected] || 0.0, - language, - { - style: 'currency', - currency: currencySelected, - minimumFractionDigits: 0, - maximumFractionDigits: 2, - }, - ) - return ( -
    -
    - {renderExtendedAddress(data)} -
    - {deleted ? ( -
    - {' '} - Account Deleted -
    - ) : ( - <> -
    - - - -
    -
    {balance}
    - - )} - {renderBalancesSelector(data)} + {xAddress?.classicAddress && ( +
    +
    + {t('account_page_classic_address')}: +
    +
    -
    -
    - {renderInfo(data)} - {renderEscrows(data)} - {renderPaymentChannels(data)} -
    -
    -
    {renderSignerList(data)}
    -
    -
    - ) - } - - const xAddress = account?.xAddress ?? false - const hasBridge = account?.hasBridge ?? false - - return ( -
    -
    -
    - Account ID - {hasBridge &&
    Door Account
    } -
    -

    {accountId}

    -
    -
    - {account != null && renderHeaderContent(account)} + )} + {xAddress?.tag && ( +
    +
    + {t('account_page_address_tag')}: +
    +
    {xAddress.tag}
    +
    + )} + {account?.info?.domain && ( +
    +
    {t('account_page_domain')}:
    + +
    + )}
    ) } + +export default AccountHeader diff --git a/src/containers/Accounts/AccountHeader/styles.scss b/src/containers/Accounts/AccountHeader/styles.scss index d76a9033d..013810487 100644 --- a/src/containers/Accounts/AccountHeader/styles.scss +++ b/src/containers/Accounts/AccountHeader/styles.scss @@ -1,308 +1,133 @@ @use '../../shared/css/variables' as *; .account-header { - margin-bottom: 16px; + padding: 50px 0 30px; - .title { - padding-bottom: 4px; - color: $black-40; - font-size: 14px; - text-transform: uppercase; - @include semibold; - } - - .box-header { - margin-top: 106px; - margin-bottom: 80px; - - .title { + .deleted-banner { + display: flex; + flex-direction: column; + padding: 25px; + border: 1px solid $orange-50; + border-radius: 8px; + margin-bottom: 30px; + background: $black-90; + gap: 12px; + + .deleted-label { display: flex; - flex-direction: row; + max-width: 180px; align-items: center; - } - - h1 { - overflow: hidden; - text-overflow: ellipsis; - } - - .badge { - border: 1px solid $yellow-50; - background: $yellow-50; - box-shadow: 0 1px 1px $yellow-80; - color: $black; - } - - .classic { - margin: 0; - color: $white; - font-size: 20px; - @include bold; - - @include for-size(tablet-landscape-up) { - font-size: 24px; - } - - @include for-size(desktop-up) { - font-size: 36px; - } - } - - .currency-pair { - display: inline-block; - padding: 5px; - border-width: thin; - border-style: solid; - border-color: $black-70; - margin: 0; - margin-left: 12px; - color: $white; - font-size: 16px; - text-transform: none; + padding: 3px 12px; + border: 1px solid $orange-50; + border-radius: 100px; + margin-bottom: 8px; + background: $orange-80; + color: $black-0; + font-size: 12px; @include semibold; - @include for-size(tablet-landscape-up) { - font-size: 10px; - } + gap: 10px; + text-transform: uppercase; - @include for-size(desktop-up) { - font-size: 20px; + .deleted-info-icon { + width: 16px; + height: 16px; + margin-right: 3px; } } - .amm { - display: inline-block; - padding-right: 10px; - border-bottom: none; + .deleted-message { margin: 0; - color: $white; - font-size: 18px; - @include bold; - - @include for-size(tablet-landscape-up) { - font-size: 10px; - } - - @include for-size(desktop-up) { - font-size: 23px; - } - } - } - - .x-address { - margin: 0; - color: $white; - font-size: 14px; - @include bold; - - @include for-size(tablet-landscape-up) { - font-size: 18px; - } - - @include for-size(desktop-up) { - font-size: 36px; + color: $black-0; + font-size: 16px; } } - .box-content { - min-height: 100px; - padding-bottom: 20px; - } - - .info-container { + .address-panel { display: flex; + height: auto; + box-sizing: border-box; + flex: none; flex-direction: column; - justify-content: space-between; - margin-top: 68px; - - @include for-size(desktop-up) { - flex-direction: row; - margin-top: 80px; - } - - .values { - display: flex; - flex-direction: row; - justify-content: space-between; - margin-bottom: 18px; - color: $white; - - @include for-size(desktop-up) { - flex-direction: column; - margin-bottom: 0; - } + flex-grow: 0; + align-items: flex-start; + align-self: stretch; + order: 0; + padding: 25px; + border: 1px solid $black-70; + border-radius: 8px; + background: $black-90; + gap: 10px; + + .address-label { + color: $black-40; + font-size: 14px; + @include semibold; - .value { - color: $white; - font-size: 18px; - line-height: 22.5px; - text-decoration: none; - @include bold; - } + line-height: 1; + text-transform: uppercase; } - } - .lower-header { - display: flex; - flex-direction: column; - gap: 16px; + .address-value { + display: inline-flex; + align-items: center; + color: $black-0; + font-size: 24px; + @include bold; - @include for-size(desktop-up) { - flex-direction: row; + line-height: 1; + word-break: break-all; } - } - - .column { - display: inline-block; - width: 100%; - vertical-align: top; - @include for-size(desktop-up) { - width: 50%; + .domain { + display: flex; - &.first { - padding-right: calc(50% - clamp(47.5%, 500px, 50%)); + .domain-label { + margin-right: 8px; + color: $black-40; + font-size: 14px; + line-height: 1; } - &.second { - padding-left: calc(50% - clamp(47.5%, 500px, 50%)); - text-align: right; + a { + font-size: 14px; + line-height: 1; + word-break: break-all; } } - .secondary { - padding: 0; - margin-bottom: 20px; - font-size: 12px; - @include bold; - - @include for-size(desktop-up) { - border: none; - } + .classic-address { + display: flex; - .label { + .classic-address-label { + margin-right: 8px; color: $black-40; - text-align: left; - text-transform: uppercase; - @include medium; + font-size: 14px; + line-height: 1; } - ul { - width: 100%; - padding: 0; - margin: 0; - list-style: none; - - li { - padding: 1px; - vertical-align: top; - - a { - display: inline-block; - text-transform: none; - vertical-align: middle; - @include medium; - @extend %truncate; - } - - b { - color: $white; - word-break: break-all; - @include bold; - } - - &.flags { - color: $blue-purple-30; - font-style: italic; - } - } - } - - &.x-address ul { - width: 100%; - - li { - display: flex; - justify-content: space-between; - - @media (width >= 450px) { - display: block; - } - - .tag { - font-weight: 500; - } - - .network { - font-weight: 500; - } - - &.tag-info { - padding-top: 14px; - font-size: 14px; - } - } + a { + font-size: 14px; + line-height: 1; + word-break: break-all; } + } - &.balance { - .value { - margin: -4px 0 5px -0.1em; - color: $white; - font-size: 26px; - line-height: 30px; - @include regular; - @include for-size(desktop-up) { - font-size: 30px; - line-height: 34px; - } - @extend %truncate; - } + .address-tag { + display: flex; - .balance-selector-container { - position: relative; - margin-top: 10px; - } + .address-tag-label { + margin-right: 8px; + color: $black-40; + font-size: 14px; + line-height: 1; } - &.signer-list { - display: block; - font-weight: 500; - - h2 { - text-align: left; - } - - ul { - li { - display: flex; - - .label { - padding-right: 15px; - text-align: left; - white-space: nowrap; - } - - .value { - overflow: hidden; - margin-left: auto; - font-weight: inherit; - text-overflow: ellipsis; - } - - .account { - display: block; - font-weight: inherit; - @extend %truncate; - } - - .weight { - overflow: inherit; - text-align: right; - text-overflow: inherit; - white-space: nowrap; - } - } - } + .address-tag-value { + color: $black-0; + font-size: 14px; + line-height: 1; + word-break: break-all; } } } diff --git a/src/containers/Accounts/AccountHeader/test/AccountHeader.test.tsx b/src/containers/Accounts/AccountHeader/test/AccountHeader.test.tsx index 2d730716e..d696e38aa 100644 --- a/src/containers/Accounts/AccountHeader/test/AccountHeader.test.tsx +++ b/src/containers/Accounts/AccountHeader/test/AccountHeader.test.tsx @@ -1,242 +1,239 @@ -import { mount } from 'enzyme' -import { QueryClientProvider } from 'react-query' +import { render, screen } from '@testing-library/react' import { I18nextProvider } from 'react-i18next' -import { BrowserRouter } from 'react-router-dom' -import { testQueryClient } from '../../../test/QueryClient' -import i18n from '../../../../i18n/testConfig' -import { AccountHeader } from '..' -import { flushPromises, V7_FUTURE_ROUTER_FLAGS } from '../../../test/utils' - -const TEST_ADDRESS = 'rDsbeomae4FXwgQTJp9Rs64Qg9vDiTCdBv' -const TEST_X_ADDRESS = 'XV3oNHx95sqdCkTDCBCVsVeuBmvh2dz5fTZvfw8UCcMVsfe' - -describe('AccountHeader Actions', () => { - const createWrapper = (account, accountId) => - mount( - - - - - - - , - ) - - beforeEach(() => { - jest.resetModules() +import { BrowserRouter as Router } from 'react-router-dom' +import i18n from '../../../../i18n/testConfigEnglish' +import AccountHeader from '../index' + +// Mock the DomainLink component +jest.mock('../../../shared/components/DomainLink', () => ({ + __esModule: true, + default: ({ domain }: { domain: string }) => ( + + {domain} + + ), +})) + +// Mock the Account component +jest.mock('../../../shared/components/Account', () => ({ + Account: ({ account }: { account: string }) => ( + {account} + ), +})) + +// Mock the SVG import +jest.mock('../../../shared/images/info-duotone.svg', () => ({ + __esModule: true, + default: ({ className }: { className?: string }) => ( + + ), +})) + +// Test wrapper component +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +) + +describe('AccountHeader', () => { + const mockAccountId = 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH' + + describe('Basic rendering', () => { + it('renders regular account address', () => { + render( + + + , + ) + + expect(screen.getByText('Address')).toBeInTheDocument() + expect(screen.getByText(mockAccountId)).toBeInTheDocument() + // No deleted banner + expect(screen.queryByTestId('info-icon')).not.toBeInTheDocument() + expect(screen.queryByText('Account Deleted')).not.toBeInTheDocument() + }) }) - it('successful account header', async () => { - const mockedGetAccountState = { - account: TEST_ADDRESS, - ledger_index: 68990183, - info: { - sequence: 2148991, - ticketCount: undefined, - ownerCount: 0, - reserve: 10, - tick: undefined, - rate: undefined, - domain: undefined, - emailHash: undefined, - flags: [], - balance: '123456000', - nftMinter: undefined, - previousTxn: - '6B6F2CA1633A22247058E988372BA9EFFFC5BF10212230B67341CA32DC9D4A82', - previousLedger: 68990183, - }, - deleted: false, - balances: { XRP: 123.456 }, - signerList: undefined, - tokens: [], - escrows: undefined, - paychannels: null, - xAddress: undefined, - hasBridge: false, - } - - const wrapper = createWrapper(mockedGetAccountState, TEST_ADDRESS) - await flushPromises() - wrapper.update() - - expect(wrapper.find('h1').text()).toBe(TEST_ADDRESS) - - wrapper.unmount() + describe('Deleted account banner', () => { + it('shows deleted banner when account is deleted', () => { + render( + + + , + ) + + expect(screen.getByTestId('info-icon')).toBeInTheDocument() + expect(screen.getByText('Account Deleted')).toBeInTheDocument() + expect( + screen.getByText( + 'This account has been deleted from the XRP Ledger. Historical data is shown for reference only.', + ), + ).toBeInTheDocument() + }) }) - it('account with tokens', async () => { - const mockedGetAccountState = { - account: TEST_ADDRESS, - ledger_index: 72338736, - info: { - sequence: 1227, - ticketCount: undefined, - ownerCount: 28, - reserve: 66, - tick: undefined, - rate: undefined, - domain: undefined, - emailHash: undefined, - flags: [], - balance: '1172875760329', - previousTxn: - '259A84CE4B3B09D5FBCAA133F62FC767CA2B57B3C64CF065F7546AA63D55E070', - previousLedger: 67657581, - }, - balances: { - '0158415500000000C1F76FF6ECB0BAC600000000': 3.692385398244198, - BTC: 1.075524263886059, - CHF: 0.7519685210971255, - CNY: 12.328638002, - DYM: 95.13258522535791, - EUR: 53.426387263174405, - GBP: 79.51188949705619, - JPY: 4986.30908732758, - USD: 936.8290046958887, - XAU: 3.419442510305086, - XRP: 1172875.760329, - }, - signerList: undefined, - tokens: [ - { - amount: 95.13258522535791, - currency: 'DYM', - issuer: 'rGwUWgN5BEg3QGNY3RX2HfYowjUTZdid3E', + describe('X-Address support', () => { + it('renders extended address label when xAddress is provided', () => { + const mockAccount = { + xAddress: { + classicAddress: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH', + tag: 12345, }, - { - amount: 20, - currency: 'USD', - issuer: 'rME7HanzUymzFvETpoLgAy5rvxGcKiLrYL', + } + + render( + + + , + ) + + expect( + screen.getByText('Extended Address (X-Address)'), + ).toBeInTheDocument() + expect( + screen.getByText('X7AcgcsBL6XDcUb289X4mJ8djcdyKaB5hJDWMArnXr61cqZ'), + ).toBeInTheDocument() + + // Classic address and tag should also be shown + expect(screen.getByText('Classic Address:')).toBeInTheDocument() + expect(screen.getByTestId('classic-address')).toBeInTheDocument() + expect( + screen.getByText('rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH'), + ).toBeInTheDocument() + + expect(screen.getByText('Tag:')).toBeInTheDocument() + expect(screen.getByText('12345')).toBeInTheDocument() + }) + + it('does not render classic address when xAddress classicAddress is missing', () => { + const mockAccount = { + xAddress: { + tag: 12345, }, - { - amount: 255.7836054268899, - currency: 'USD', - issuer: 'rMwjYedjc7qqtKYVLiAccJSmCwih4LnE2q', + } + + render( + + + , + ) + + expect(screen.queryByText('Classic Address:')).not.toBeInTheDocument() + }) + + it('does not render address tag when xAddress tag is missing', () => { + const mockAccount = { + xAddress: { + classicAddress: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH', }, - { - amount: 1.075524263886059, - currency: 'BTC', - issuer: 'rMwjYedjc7qqtKYVLiAccJSmCwih4LnE2q', - }, - { - amount: 12.328638002, - currency: 'CNY', - issuer: 'rnuF96W4SZoCJmbHYBFoJZpR8eCaxNvekK', - }, - { - amount: 3.419442510305086, - currency: 'XAU', - issuer: 'rrh7rf1gV2pXAoqA8oYbpHd8TKv5ZQeo67', - }, - { - amount: 3.692385398244198, - currency: '0158415500000000C1F76FF6ECB0BAC600000000', - issuer: 'rrh7rf1gV2pXAoqA8oYbpHd8TKv5ZQeo67', - }, - { - amount: 0.7519685210971255, - currency: 'CHF', - issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', - }, - { - amount: 78.5098894970562, - currency: 'GBP', - issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', - }, - { - amount: 4986.30908732758, - currency: 'JPY', - issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', - }, - { - amount: 28.42638726317441, - currency: 'EUR', - issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', - }, - ], - escrows: undefined, - paychannels: null, - xAddress: undefined, - hasBridge: false, - deleted: false, - } - - const wrapper = createWrapper(mockedGetAccountState, TEST_ADDRESS) - await flushPromises() - wrapper.update() - - expect(wrapper.find('h1').text()).toBe(TEST_ADDRESS) - expect(wrapper.find('.balance-selector-container')).toExist() - expect(wrapper.find('.balance .value').text()).toBe('\uE9001,172,875.76') - - wrapper.unmount() + } + + render( + + + , + ) + + expect(screen.queryByText('Tag:')).not.toBeInTheDocument() + }) }) - it('X-Address', async () => { - const mockedGetAccountState = { - account: TEST_ADDRESS, - ledger_index: 68990183, - info: { - sequence: 2148991, - ticketCount: undefined, - ownerCount: 0, - reserve: 10, - tick: undefined, - rate: undefined, - domain: undefined, - emailHash: undefined, - flags: [], - balance: '123456000', - nftMinter: undefined, - previousTxn: - '6B6F2CA1633A22247058E988372BA9EFFFC5BF10212230B67341CA32DC9D4A82', - previousLedger: 68990183, - }, - deleted: false, - balances: { XRP: 123.456 }, - signerList: undefined, - tokens: [], - escrows: undefined, - hasBridge: false, - paychannels: null, - xAddress: { - classicAddress: TEST_ADDRESS, - tag: 0, - test: false, - }, - } - - const wrapper = createWrapper(mockedGetAccountState, TEST_X_ADDRESS) - await flushPromises() - wrapper.update() - - expect(wrapper.find('h1').text()).toBe(TEST_X_ADDRESS) - - wrapper.unmount() + describe('Domain display', () => { + it('renders domain when account info has domain', () => { + const mockAccount = { + info: { + domain: 'ripple.com', + }, + } + + render( + + + , + ) + + expect(screen.getByText('Domain:')).toBeInTheDocument() + expect(screen.getByTestId('domain-link')).toBeInTheDocument() + expect(screen.getByText('ripple.com')).toBeInTheDocument() + }) + + it('does not render domain when account info is missing', () => { + render( + + + , + ) + + expect(screen.queryByText('Domain:')).not.toBeInTheDocument() + expect(screen.queryByTestId('domain-link')).not.toBeInTheDocument() + }) + + it('does not render domain when domain field is missing', () => { + const mockAccount = { + info: {}, + } + + render( + + + , + ) + + expect(screen.queryByText('Domain:')).not.toBeInTheDocument() + expect(screen.queryByTestId('domain-link')).not.toBeInTheDocument() + }) + + it('renders with all optional fields missing', () => { + render( + + + , + ) + + // Only address should be shown + expect(screen.getByText('Address')).toBeInTheDocument() + expect(screen.getByText(mockAccountId)).toBeInTheDocument() + + // No optional fields + expect(screen.queryByText(/Classic Address/)).not.toBeInTheDocument() + expect(screen.queryByText(/Tag/)).not.toBeInTheDocument() + expect(screen.queryByText(/Domain/)).not.toBeInTheDocument() + expect(screen.queryByText('Account Deleted')).not.toBeInTheDocument() + }) }) - it('deleted account', async () => { - const mockedGetAccountState = { - account: TEST_ADDRESS, - deleted: true, - xAddress: undefined, - } - - const wrapper = createWrapper(mockedGetAccountState, TEST_ADDRESS) - await flushPromises() - wrapper.update() - - expect(wrapper.find('h1').text()).toBe(TEST_ADDRESS) - expect(wrapper.find('.warning').find('.account-deleted-text').text()).toBe( - 'Account Deleted', - ) - - wrapper.unmount() + describe('Address title attribute', () => { + it('sets title attribute on address value for tooltip', () => { + render( + + + , + ) + + const addressValue = screen.getByTitle(mockAccountId) + expect(addressValue).toBeInTheDocument() + expect(addressValue).toHaveTextContent(mockAccountId) + }) }) }) diff --git a/src/containers/Accounts/AccountHeader/test/accountDeletedTransactions.json b/src/containers/Accounts/AccountHeader/test/accountDeletedTransactions.json deleted file mode 100644 index 58d5c9a1a..000000000 --- a/src/containers/Accounts/AccountHeader/test/accountDeletedTransactions.json +++ /dev/null @@ -1,156 +0,0 @@ -{ - "account_info": { - "id": 2, - "status": "error", - "type": "response", - "account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6", - "error": "actNotFound", - "error_code": 19, - "error_message": "Account not found.", - "ledger_current_index": 80602456, - "request": { - "account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6", - "command": "account_info", - "id": 2, - "ledger_index": "current", - "queue": true, - "strict": true - }, - "validated": false, - "forwarded": true, - "warnings": [ - { - "id": 2001, - "message": "This is a clio server. clio only serves validated data. If you want to talk to rippled, include 'ledger_index':'current' in your request" - } - ] - }, - "account_tx": { - "id": 2, - "status": "success", - "type": "response", - "result": { - "account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6", - "ledger_index_min": 32570, - "ledger_index_max": 80602468, - "transactions": [ - { - "meta": { - "AffectedNodes": [ - { - "ModifiedNode": { - "FinalFields": { - "Account": "rEoRCpve6aPs6gg2u5aaeCsBmX9ZB7TzhR", - "Balance": "1553170065225", - "Flags": 0, - "OwnerCount": 0, - "Sequence": 80463368 - }, - "LedgerEntryType": "AccountRoot", - "LedgerIndex": "856F60BEC3D95BC1035C2D9691CA4CA2FD455FA0D62783F504277374A6966589", - "PreviousFields": { - "Balance": "1553152515225" - }, - "PreviousTxnID": "005613ADA8AA0618C62B4EC53C181ED8831E694F24F1B77706DE98218522AADF", - "PreviousTxnLgrSeq": 80537220 - } - }, - { - "DeletedNode": { - "FinalFields": { - "Account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6", - "Balance": "0", - "Flags": 0, - "OwnerCount": 0, - "PreviousTxnID": "B5180561D25F1D17672F2FCED48E06F3B72D43E33202660BF57647D1914D940A", - "PreviousTxnLgrSeq": 29256436, - "Sequence": 8 - }, - "LedgerEntryType": "AccountRoot", - "LedgerIndex": "EAE9EC4DC190D2C0A33E8963D86B48D3A18394B04E2C891176E5A78E1EFD7023", - "PreviousFields": { - "Balance": "19550000", - "Sequence": 7 - } - } - } - ], - "DeliveredAmount": "17550000", - "TransactionIndex": 25, - "TransactionResult": "tesSUCCESS", - "delivered_amount": "17550000" - }, - "tx": { - "Account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6", - "Destination": "rEoRCpve6aPs6gg2u5aaeCsBmX9ZB7TzhR", - "DestinationTag": 2123456789, - "Fee": "2000000", - "Flags": 2147483648, - "Sequence": 7, - "SigningPubKey": "029D82602618266B98506884CE4C61CECF5504CBCEDA41CED583FE4C6972455C9E", - "TransactionType": "AccountDelete", - "TxnSignature": "3045022100A95A9508F96B5D979BD2AFE1F667733DDFC06A5BF79C5C085439CB39C4B14B04022074D84F5AB63390565A4D12D313A8D4666370A99F05B0B887505A80F431E18682", - "hash": "BB955E1E2DDC4529A79F6DCB9741013B6D218FFBE441871FC466895DD4300F37", - "ledger_index": 80537221, - "date": 740350911 - }, - "validated": true - }, - { - "meta": { - "AffectedNodes": [ - { - "ModifiedNode": { - "FinalFields": { - "Account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6", - "Balance": "19550000", - "Flags": 0, - "OwnerCount": 0, - "Sequence": 7 - }, - "LedgerEntryType": "AccountRoot", - "LedgerIndex": "EAE9EC4DC190D2C0A33E8963D86B48D3A18394B04E2C891176E5A78E1EFD7023", - "PreviousFields": { - "Balance": "19700000", - "Sequence": 6 - }, - "PreviousTxnID": "56675612ACAF05BAA0499A0CD2D81E0C2B66C2133A2AC328CE311B0BD8C83401", - "PreviousTxnLgrSeq": 29256433 - } - } - ], - "TransactionIndex": 22, - "TransactionResult": "tecUNFUNDED_PAYMENT" - }, - "tx": { - "Account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6", - "Amount": "4856600000", - "Destination": "rDCgaaSBAWYfsxUYhCk1n26Na7x8PQGmkq", - "Fee": "150000", - "Flags": 2147483648, - "Sequence": 6, - "SigningPubKey": "029D82602618266B98506884CE4C61CECF5504CBCEDA41CED583FE4C6972455C9E", - "TransactionType": "Payment", - "TxnSignature": "3045022100D84347CBC15152E08D83AD7F50B29E07548DB142E69D23D7B983AFD71E5AE67F02205EA4717A39120489DA26088BAD9F741D9F2B87A78C863CBED47FE518675B8215", - "hash": "B5180561D25F1D17672F2FCED48E06F3B72D43E33202660BF57647D1914D940A", - "ledger_index": 29256436, - "date": 546295030 - }, - "validated": true - } - ], - "validated": true, - "marker": { - "ledger": 29256436, - "seq": 22 - }, - "limit": 2 - }, - "warnings": [ - { - "id": 2001, - "message": "This is a clio server. clio only serves validated data. If you want to talk to rippled, include 'ledger_index':'current' in your request" - } - ] - } -} diff --git a/src/containers/Accounts/AccountHeader/test/accountWithTokens.json b/src/containers/Accounts/AccountHeader/test/accountWithTokens.json deleted file mode 100644 index 8d7feab70..000000000 --- a/src/containers/Accounts/AccountHeader/test/accountWithTokens.json +++ /dev/null @@ -1,219 +0,0 @@ -{ - "account_info": { - "result": { - "account_data": { - "Account": "rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY", - "Balance": "1172875760329", - "Flags": 0, - "LedgerEntryType": "AccountRoot", - "OwnerCount": 28, - "PreviousTxnID": "259A84CE4B3B09D5FBCAA133F62FC767CA2B57B3C64CF065F7546AA63D55E070", - "PreviousTxnLgrSeq": 67657581, - "Sequence": 1227, - "index": "9B242A0D59328CE964FFFBFF7D3BBF8B024F9CB1A212923727B42F24ADC93930", - "signer_lists": [] - }, - "ledger_hash": "5B60A4BE898B7ED490F5126D1CB5FF5DCA64B7974B6DB7DBA7D7F0EC0F8C6D75", - "ledger_index": 72338736, - "validated": true, - "warnings": [ - { - "id": 1004, - "message": "This is a reporting server. The default behavior of a reporting server is to only return validated data. If you are looking for not yet validated data, include \"ledger_index : current\" in your request, which will cause this server to forward the request to a p2p node. If the forward is successful the response will include \"forwarded\" : \"true\"" - } - ] - }, - "status": "success", - "type": "response" - }, - "account_objects": { - "result": { - "account": "rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY", - "account_objects": [], - "ledger_hash": "1BAA8681189844A207D3C6AAF6F1375663F434B2534A87E8D1821E5E8F670D36", - "ledger_index": 72338713, - "validated": true, - "warnings": [ - { - "id": 1004, - "message": "This is a reporting server. The default behavior of a reporting server is to only return validated data. If you are looking for not yet validated data, include \"ledger_index : current\" in your request, which will cause this server to forward the request to a p2p node. If the forward is successful the response will include \"forwarded\" : \"true\"" - } - ] - }, - "status": "success", - "type": "response" - }, - "gateway_balances": { - "result": { - "account": "rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY", - "assets": { - "rGwUWgN5BEg3QGNY3RX2HfYowjUTZdid3E": [ - { - "currency": "DYM", - "value": "95.13258522535792" - } - ], - "rME7HanzUymzFvETpoLgAy5rvxGcKiLrYL": [ - { - "currency": "USD", - "value": "20" - } - ], - "rMwjYedjc7qqtKYVLiAccJSmCwih4LnE2q": [ - { - "currency": "USD", - "value": "255.7836054268899" - }, - { - "currency": "BTC", - "value": "1.075524263886059" - } - ], - "rnuF96W4SZoCJmbHYBFoJZpR8eCaxNvekK": [ - { - "currency": "CNY", - "value": "12.328638002" - } - ], - "rrh7rf1gV2pXAoqA8oYbpHd8TKv5ZQeo67": [ - { - "currency": "XAU", - "value": "3.419442510305086" - }, - { - "currency": "0158415500000000C1F76FF6ECB0BAC600000000", - "value": "3.692385398244198" - } - ], - "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B": [ - { - "currency": "CHF", - "value": "0.7519685210971255" - }, - { - "currency": "GBP", - "value": "78.5098894970562" - }, - { - "currency": "JPY", - "value": "4986.30908732758" - }, - { - "currency": "EUR", - "value": "28.42638726317441" - } - ] - }, - "ledger_hash": "A03F61C5E0A3E880BECFE05D2435CF370A0F962FEF574C09B024C3C9BD69C1A4", - "ledger_index": 72338588, - "obligations": { - "EUR": "25", - "GBP": "1.002", - "USD": "661.0453992689988" - }, - "validated": true - }, - "status": "success", - "type": "response" - }, - "server_info": { - "result": { - - "info": { - "build_version": "1.9.1", - "complete_ledgers": "32570-72338589", - "hostid": "HONE", - "initial_sync_duration_us": "15604", - "io_latency_ms": 1, - "last_close": { - "converge_time_s": 0, - "proposers": 0 - }, - "load_factor": 1, - "pubkey_node": "n9Ln87hHwcszpaGqr3L3zTD1psJrMgd6MaFQasXYsNBUhq9De6N8", - "published_ledger": "none", - "reporting": { - "etl_sources": [ - { - "connected": true, - "grpc_port": "50051", - "ip": "172.16.3.21", - "last_message_arrival_time": "2022-Jun-14 17:28:07.976885 UTC", - "validated_ledgers_range": "70162870-72338589", - "websocket_port": "51233" - }, - { - "connected": true, - "grpc_port": "50051", - "ip": "172.16.3.81", - "last_message_arrival_time": "2022-Jun-14 17:28:07.968027 UTC", - "validated_ledgers_range": "70151390-72338589", - "websocket_port": "51233" - }, - { - "connected": true, - "grpc_port": "50051", - "ip": "172.16.3.89", - "last_message_arrival_time": "2022-Jun-14 17:28:07.969184 UTC", - "validated_ledgers_range": "71108293-72338589", - "websocket_port": "51233" - }, - { - "connected": true, - "grpc_port": "50051", - "ip": "172.16.3.106", - "last_message_arrival_time": "2022-Jun-14 17:28:07.992218 UTC", - "validated_ledgers_range": "70161864-72338589", - "websocket_port": "51233" - } - ], - "is_writer": false, - "last_publish_time": "2022-Jun-14 17:28:04.981829 UTC" - }, - "server_state": "full", - "server_state_duration_us": "1817931687725", - "state_accounting": { - "connected": { - "duration_us": "0", - "transitions": "0" - }, - "disconnected": { - "duration_us": "15604", - "transitions": "1" - }, - "full": { - "duration_us": "1817931687725", - "transitions": "1" - }, - "syncing": { - "duration_us": "0", - "transitions": "0" - }, - "tracking": { - "duration_us": "0", - "transitions": "0" - } - }, - "time": "2022-Jun-14 17:28:07.993308 UTC", - "uptime": 1817935, - "validated_ledger": { - "age": 6, - "base_fee_xrp": 0.00001, - "hash": "35464A53AEDF0ADBBFD44D49EE58EF0B82D626B122F6338A36C99D395B594D4D", - "reserve_base_xrp": 10, - "reserve_inc_xrp": 2, - "seq": 72338589 - }, - "validation_quorum": 28 - }, - "warnings": [ - { - "id": 1004, - "message": "This is a reporting server. The default behavior of a reporting server is to only return validated data. If you are looking for not yet validated data, include \"ledger_index : current\" in your request, which will cause this server to forward the request to a p2p node. If the forward is successful the response will include \"forwarded\" : \"true\"" - } - ] - }, - "status": "success", - "type": "response" - } -} diff --git a/src/containers/Accounts/AccountHeader/test/rippledResponses.json b/src/containers/Accounts/AccountHeader/test/rippledResponses.json deleted file mode 100644 index 8b10781dd..000000000 --- a/src/containers/Accounts/AccountHeader/test/rippledResponses.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "account_info": { - "result": { - "account_data": { - "Account": "rDsbeomae4FXwgQTJp9Rs64Qg9vDiTCdBv", - "Balance": "123456000", - "Flags": 0, - "LedgerEntryType": "AccountRoot", - "OwnerCount": 0, - "PreviousTxnID": "6B6F2CA1633A22247058E988372BA9EFFFC5BF10212230B67341CA32DC9D4A82", - "PreviousTxnLgrSeq": 68990183, - "Sequence": 2148991, - "index": "C3B625B296E95A21D7BBBB7E3D343AF423B463B87B5D56EE7F79C8E16A47A6F5", - "signer_lists": [] - }, - "ledger_hash": "43C4195B2C90423771E6C5DF4AED11BF3D77FFD1E8A153A489E5B00C96318FCA", - "ledger_index": 68990183, - "validated": true - }, - "status": "success", - "type": "response" - }, - "account_objects": { - "result": { - "account": "rDsbeomae4FXwgQTJp9Rs64Qg9vDiTCdBv", - "account_objects": [], - "ledger_current_index": 24402380, - "validated": false - }, - "status": "success", - "type": "response" - }, - "gateway_balances": { - "result": { - "account": "rDsbeomae4FXwgQTJp9Rs64Qg9vDiTCdBv", - "ledger_hash": "F4023C801B8B4D05F16EFE5D8C4C3C14D02354AABBB94151F581A6BF0E04C20B", - "ledger_index": 24402706, - "validated": true - }, - "status": "success", - "type": "response" - }, - "server_info": { - "result": { - "info": { - "validated_ledger": { - "age": 1, - "base_fee_xrp": 0.00001, - "hash": "EA01E248FCA5CFD33A3393DA5EBCCD9219BA8DB6AF6DC28A3B0A968604F46A76", - "reserve_base_xrp": 10, - "reserve_inc_xrp": 2, - "seq": 24402729 - }, - "validation_quorum": 5 - } - }, - "status": "success", - "type": "response" - } -} diff --git a/src/containers/Accounts/AccountIssuedTokenTable/AccountIssuedTokenTable.tsx b/src/containers/Accounts/AccountIssuedTokenTable/AccountIssuedTokenTable.tsx deleted file mode 100644 index f7c39d2b8..000000000 --- a/src/containers/Accounts/AccountIssuedTokenTable/AccountIssuedTokenTable.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { useTranslation } from 'react-i18next' -import Currency from '../../shared/components/Currency' -import { Amount } from '../../shared/components/Amount' -import { Loader } from '../../shared/components/Loader' -import { EmptyMessageTableRow } from '../../shared/EmptyMessageTableRow' -import { RouteLink } from '../../shared/routing' -import { TOKEN_ROUTE } from '../../App/routes' - -interface Props { - account: any -} - -export const AccountIssuedTokenTable = (props: Props) => { - const { account } = props - const { t } = useTranslation() - - function renderNoResults() { - return ( - - {t('assets.no_issued_message')} - - ) - } - - function renderRow(token: any) { - const tokenName = `${token.currency}.${token.issuer}` - - return ( - - - - - - - {token.issuer} - - - - - - - ) - } - - return ( -
    - - - - - - - - - - {account.tokens && - (account?.tokens?.length - ? account?.tokens.map(renderRow) - : renderNoResults())} - -
    {t('currency_code')}{t('issuer')}{t('amount')}
    - {!account.tokens && } -
    - ) -} diff --git a/src/containers/Accounts/AccountIssuedTokenTable/index.ts b/src/containers/Accounts/AccountIssuedTokenTable/index.ts deleted file mode 100644 index 2e1697f56..000000000 --- a/src/containers/Accounts/AccountIssuedTokenTable/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AccountIssuedTokenTable } from './AccountIssuedTokenTable' diff --git a/src/containers/Accounts/AccountMPTTable/AccountMPTTable.tsx b/src/containers/Accounts/AccountMPTTable/AccountMPTTable.tsx deleted file mode 100644 index d1ab69f6c..000000000 --- a/src/containers/Accounts/AccountMPTTable/AccountMPTTable.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { useContext } from 'react' -import { useTranslation } from 'react-i18next' -import { useInfiniteQuery, useQuery } from 'react-query' -import { Loader } from '../../shared/components/Loader' -import SocketContext from '../../shared/SocketContext' -import { useAnalytics } from '../../shared/analytics' -import { EmptyMessageTableRow } from '../../shared/EmptyMessageTableRow' -import { getAccountMPTs, getMPTIssuance } from '../../../rippled/lib/rippled' -import { Account } from '../../shared/components/Account' -import { LoadMoreButton } from '../../shared/LoadMoreButton' -import { MPTokenLink } from '../../shared/components/MPTokenLink' -import { - formatMPTokenInfo, - formatMPTIssuanceInfo, -} from '../../../rippled/lib/utils' -import { MPTIssuanceFormattedInfo } from '../../shared/Interfaces' -import { convertScaledPrice } from '../../shared/utils' - -export interface AccountMPTTableProps { - accountId: string -} - -export const AccountMPTRow = ({ mpt }: any) => { - const rippledSocket = useContext(SocketContext) - const { trackException } = useAnalytics() - const { data: mptIssuanceData } = useQuery( - ['getMPTIssuanceScale', mpt.mptIssuanceID], - async () => { - const info = await getMPTIssuance(rippledSocket, mpt.mptIssuanceID) - return formatMPTIssuanceInfo(info) - }, - { - onError: (e: any) => { - trackException( - `mptIssuance ${mpt.mptIssuanceID} --- ${JSON.stringify(e)}`, - ) - }, - }, - ) - - if (!mptIssuanceData) return null - - const scale = mptIssuanceData?.assetScale ?? 0 - - return ( - - - - - - - - - {convertScaledPrice( - parseInt(mpt.mptAmount as string, 10).toString(16), - scale, - )} - - - ) -} - -export const AccountMPTTable = ({ accountId }: AccountMPTTableProps) => { - const rippledSocket = useContext(SocketContext) - const { trackException } = useAnalytics() - const { - data: pages, - isFetching: loading, - hasNextPage, - fetchNextPage, - } = useInfiniteQuery( - ['account_objects', accountId], - ({ pageParam = '' }) => - getAccountMPTs(rippledSocket, accountId, pageParam).catch( - (errorResponse) => { - const errorLocation = `account MPTs ${accountId} at ${pageParam}` - trackException( - `${errorLocation} --- ${JSON.stringify(errorResponse)}`, - ) - }, - ), - { - getNextPageParam: (data) => data.marker, - }, - ) - const { t } = useTranslation() - - function renderNoResults() { - return ( - - {t('assets.no_mpts_message')} - - ) - } - - const renderLoadMoreButton = () => - hasNextPage && fetchNextPage()} /> - - const mpts = pages?.pages - .flatMap((page: any) => page.account_objects) - .map((mpt) => formatMPTokenInfo(mpt)) - - return ( -
    - - - - - - - - - - {!loading && - (mpts?.length - ? mpts.map((mpt) => ( - - )) - : renderNoResults())} - -
    {t('mpt_issuance_id')} {t('issuer')}{t('amount')}
    - {loading ? : renderLoadMoreButton()} -
    - ) -} diff --git a/src/containers/Accounts/AccountMPTTable/test/AccountMPTRow.test.tsx b/src/containers/Accounts/AccountMPTTable/test/AccountMPTRow.test.tsx deleted file mode 100644 index 1dd71cbfd..000000000 --- a/src/containers/Accounts/AccountMPTTable/test/AccountMPTRow.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { I18nextProvider } from 'react-i18next' -import { BrowserRouter } from 'react-router-dom' -import { mount } from 'enzyme' -import { QueryClientProvider } from 'react-query' -import { AccountMPTRow } from '../AccountMPTTable' -import i18n from '../../../../i18n/testConfig' -import { testQueryClient } from '../../../test/QueryClient' -import { getMPTIssuance } from '../../../../rippled/lib/rippled' -import { flushPromises, V7_FUTURE_ROUTER_FLAGS } from '../../../test/utils' - -import Mock = jest.Mock - -jest.mock('../../../../rippled/lib/rippled', () => ({ - __esModule: true, - getMPTIssuance: jest.fn(), -})) - -const mockedFetMPTIssuance = getMPTIssuance as Mock - -const mptData = { - account: 'rw6UtpfBFaGht6SiC1HpDPNw6Yt25pKvnu', - flags: [], - mptIssuanceID: '000017C2CE76E3E3328AE9E0D80CDD68BA72CC8D8D053DB6', - mptIssuer: 'rKFgd9FNzwu7a7iVYa2Me4dmBC3zzUepSC', - mptAmount: '100', -} - -describe('AccountMPTRow', () => { - const createWrapper = (component: JSX.Element) => - mount( - - - - {component} - - - , - ) - - it('handles Account MPT row', async () => { - const issuanceData = { - node: { - AssetScale: 3, - }, - } - - mockedFetMPTIssuance.mockReset() - - mockedFetMPTIssuance.mockImplementation(() => - Promise.resolve({ ...issuanceData }), - ) - - const wrapper = createWrapper() - await flushPromises() - wrapper.update() - expect(wrapper.find('td').at(0).html()).toBe( - '000017C2CE76E3E3328AE9E0D80CDD68BA72CC8D8D053DB6', - ) - expect(wrapper.find('td').at(1).html()).toBe( - '', - ) - expect(wrapper.find('td').at(2).html()).toBe('0.100') - wrapper.unmount() - }) -}) diff --git a/src/containers/Accounts/AccountMPTTable/test/AccountMPTTable.test.tsx b/src/containers/Accounts/AccountMPTTable/test/AccountMPTTable.test.tsx deleted file mode 100644 index 4c2a1a378..000000000 --- a/src/containers/Accounts/AccountMPTTable/test/AccountMPTTable.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { mount } from 'enzyme' -import { QueryClientProvider } from 'react-query' -import { I18nextProvider } from 'react-i18next' -import { BrowserRouter } from 'react-router-dom' -import { getAccountMPTs } from '../../../../rippled/lib/rippled' -import { AccountMPTTable } from '../AccountMPTTable' -import i18n from '../../../../i18n/testConfig' -import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' -import { testQueryClient } from '../../../test/QueryClient' -import { flushPromises, V7_FUTURE_ROUTER_FLAGS } from '../../../test/utils' - -import Mock = jest.Mock - -jest.mock('../../../../rippled/lib/rippled', () => ({ - __esModule: true, - getAccountMPTs: jest.fn(), -})) - -const mockedGetAccountMPTs = getAccountMPTs as Mock - -const data = { - account: 'rw6UtpfBFaGht6SiC1HpDPNw6Yt25pKvnu', - account_objects: [ - { - Account: 'rw6UtpfBFaGht6SiC1HpDPNw6Yt25pKvnu', - Flags: 0, - LedgerEntryType: 'MPToken', - MPTAmount: '100', - MPTokenIssuanceID: '000017C2CE76E3E3328AE9E0D80CDD68BA72CC8D8D053DB6', - OwnerNode: '0', - PreviousTxnID: - '98646C9E7C6D7B8461DC92712B83EFF6CDA4203CE3FF3FF7E0B86FD57907949F', - PreviousTxnLgrSeq: 969, - index: '3BAA73912496683A414494218D3CCA33D02F80D588F80C1257C691448E00E486', - }, - ], - ledger_current_index: 970, - status: 'success', - validated: false, -} - -describe('AccountMPTTable component', () => { - const TEST_ACCOUNT_ID = 'rTEST_ACCOUNT' - - const createWrapper = () => - mount( - - - - - - - , - ) - - afterEach(() => { - mockedGetAccountMPTs.mockReset() - }) - - it('should render a table of mpts', async () => { - mockedGetAccountMPTs.mockReset() - - mockedGetAccountMPTs.mockImplementation(() => Promise.resolve(data)) - - const wrapper = createWrapper() - await flushPromises() - wrapper.update() - expect(wrapper.find('.load-more-btn')).not.toExist() - wrapper.unmount() - }) - - it('should handle load more', async () => { - mockedGetAccountMPTs.mockReset() - - mockedGetAccountMPTs.mockImplementation(() => - Promise.resolve({ - ...data, - marker: 'hello', - }), - ) - - const wrapper = createWrapper() - await flushPromises() - wrapper.update() - - expect(wrapper.find('.load-more-btn')).toExist() - wrapper.find('.load-more-btn').simulate('click') - expect(mockedGetAccountMPTs.mock.calls[1][2]).toEqual('hello') - wrapper.unmount() - }) - - it(`should handle no results`, async () => { - mockedGetAccountMPTs.mockReset() - - mockedGetAccountMPTs.mockImplementation(() => - Promise.resolve({ - account: 'rLEr9C6kwybj1BFE1iYbhJXNFVz8rnLu8C', - account_objects: [], - ledger_hash: - '7CC58622B7071E675AF63F39AAF81BDB4E87FDFEBB0BA8CE7D753F3C702D2886', - ledger_index: 6086, - validated: true, - marker: 'hello', - }), - ) - - const wrapper = createWrapper() - await flushPromises() - wrapper.update() - - expect(wrapper.find(EmptyMessageTableRow)).toExist() - wrapper.unmount() - }) -}) diff --git a/src/containers/Accounts/AccountNFTTable/AccountNFTTable.tsx b/src/containers/Accounts/AccountNFTTable/AccountNFTTable.tsx deleted file mode 100644 index bfcbacffe..000000000 --- a/src/containers/Accounts/AccountNFTTable/AccountNFTTable.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useContext } from 'react' -import { useTranslation } from 'react-i18next' -import { useInfiniteQuery } from 'react-query' - -import { Loader } from '../../shared/components/Loader' -import SocketContext from '../../shared/SocketContext' -import { useAnalytics } from '../../shared/analytics' -import { EmptyMessageTableRow } from '../../shared/EmptyMessageTableRow' -import { getAccountNFTs } from '../../../rippled/lib/rippled' -import { Account } from '../../shared/components/Account' -import { LoadMoreButton } from '../../shared/LoadMoreButton' -import { NFTokenLink } from '../../shared/components/NFTokenLink' - -export interface AccountNFTTableProps { - accountId: string -} - -export const AccountNFTTable = ({ accountId }: AccountNFTTableProps) => { - const rippledSocket = useContext(SocketContext) - const { trackException } = useAnalytics() - const { - data: pages, - isFetching: loading, - hasNextPage, - fetchNextPage, - } = useInfiniteQuery( - ['account_nfts', accountId], - ({ pageParam = '' }) => - getAccountNFTs(rippledSocket, accountId, pageParam).catch( - (errorResponse) => { - const errorLocation = `account NFTs ${accountId} at ${pageParam}` - trackException( - `${errorLocation} --- ${JSON.stringify(errorResponse)}`, - ) - }, - ), - { - getNextPageParam: (data) => data.marker, - }, - ) - const { t } = useTranslation() - - function renderNoResults() { - return ( - - {t('assets.no_nfts_message')} - - ) - } - - const renderRow = (nft: any) => ( - - - - - - - - {nft.NFTokenTaxon} - - ) - - const renderLoadMoreButton = () => - hasNextPage && fetchNextPage()} /> - - const nfts = pages?.pages.flatMap((page: any) => page.account_nfts) - return ( -
    - - - - - - - - - - {!loading && (nfts?.length ? nfts.map(renderRow) : renderNoResults())} - -
    {t('token_id')}{t('issuer')}{t('taxon')}
    - {loading ? : renderLoadMoreButton()} -
    - ) -} diff --git a/src/containers/Accounts/AccountNFTTable/test/AccountNFTtable.test.tsx b/src/containers/Accounts/AccountNFTTable/test/AccountNFTtable.test.tsx deleted file mode 100644 index c64a8f417..000000000 --- a/src/containers/Accounts/AccountNFTTable/test/AccountNFTtable.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { mount } from 'enzyme' -import { QueryClientProvider } from 'react-query' -import { I18nextProvider } from 'react-i18next' -import { BrowserRouter } from 'react-router-dom' -import { getAccountNFTs } from '../../../../rippled/lib/rippled' -import { AccountNFTTable } from '../AccountNFTTable' -import i18n from '../../../../i18n/testConfig' -import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' -import { testQueryClient } from '../../../test/QueryClient' -import { flushPromises, V7_FUTURE_ROUTER_FLAGS } from '../../../test/utils' -import Mock = jest.Mock - -jest.mock('../../../../rippled/lib/rippled', () => ({ - __esModule: true, - getAccountNFTs: jest.fn(), -})) - -const mockedGetAccountNFTs = getAccountNFTs as Mock - -const data = { - account: 'rnuweigWrt8Jp1gBmKJT6VLxrVMSJSuu6G', - account_nfts: [ - { - Flags: 0, - Issuer: 'rGymBL8Huct6euA8jtEcLagYXpRgQKh6EC', - NFTokenID: - '00000000AF460F0D7BC0F05B626E19498DF690E00C97080B0000099B00000000', - NFTokenTaxon: 0, - nft_serial: 0, - }, - ], - ledger_current_index: 21174400, - validated: false, -} - -describe('AccountNFTTable component', () => { - const TEST_ACCOUNT_ID = 'rTEST_ACCOUNT' - - const createWrapper = () => - mount( - - - - - - - , - ) - - afterEach(() => { - mockedGetAccountNFTs.mockReset() - }) - - it('should render a table of nfts', async () => { - mockedGetAccountNFTs.mockImplementation(() => Promise.resolve(data)) - - const wrapper = createWrapper() - await flushPromises() - wrapper.update() - - expect(wrapper.find('tbody tr td').length).toEqual(3) - expect(wrapper.find('.load-more-btn')).not.toExist() - wrapper.unmount() - }) - - it('should handle load more', async () => { - mockedGetAccountNFTs.mockImplementation(() => - Promise.resolve({ - ...data, - marker: 'hello', - }), - ) - - const wrapper = createWrapper() - await flushPromises() - wrapper.update() - - const columns = wrapper.find('tbody tr td') - expect(columns.at(0).text()).toEqual( - `00000000AF460F0D7BC0F05B626E19498DF690E00C97080B0000099B00000000`, - ) - expect(columns.at(1).text()).toEqual(`rGymBL8Huct6euA8jtEcLagYXpRgQKh6EC`) - expect(columns.at(2).text()).toEqual(`0`) - expect(wrapper.find('.load-more-btn')).toExist() - wrapper.find('.load-more-btn').simulate('click') - expect(mockedGetAccountNFTs.mock.calls[1][2]).toEqual('hello') - wrapper.unmount() - }) - - it(`should handle no results`, async () => { - mockedGetAccountNFTs.mockImplementation(() => - Promise.resolve({ - account: 'rnuweigWrt8Jp1gBmKJT6VLxrVMSJSuu6G', - account_nfts: [], - marker: 'hello', - ledger_current_index: 21174400, - validated: false, - }), - ) - - const wrapper = createWrapper() - await flushPromises() - wrapper.update() - - expect(wrapper.find(EmptyMessageTableRow)).toExist() - wrapper.unmount() - }) -}) diff --git a/src/containers/Accounts/AccountSummary/Balances.tsx b/src/containers/Accounts/AccountSummary/Balances.tsx new file mode 100644 index 000000000..dbbbed167 --- /dev/null +++ b/src/containers/Accounts/AccountSummary/Balances.tsx @@ -0,0 +1,75 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useLanguage } from '../../shared/hooks' +import { localizeNumber } from '../../shared/utils' +import { XRP_BASE } from '../../shared/transactionUtils' +import UsdIcon from '../../shared/images/usd_icon.svg' +import XrpIcon from '../../shared/images/xrp_balance_icon.svg' +import ReserveIcon from '../../shared/images/xrp_reserve_balance_icon.svg' +import { + XRP_CURRENCY_OPTIONS, + USD_CURRENCY_OPTIONS, +} from '../../shared/NumberFormattingUtils' + +interface BalancesProps { + account: any + xrpToUSDRate: number +} + +const Balances = ({ account, xrpToUSDRate }: BalancesProps) => { + const { t } = useTranslation() + const lang = useLanguage() + const { xrpBalance, xrpBalanceInUSD } = useMemo(() => { + const balance = (account.info?.balance ?? 0) / XRP_BASE + return { + xrpBalance: balance, + xrpBalanceInUSD: balance * xrpToUSDRate, + } + }, [account.info?.balance, xrpToUSDRate]) + + return ( +
    +
    +
    + {typeof XrpIcon === 'string' ? ( + XRP + ) : ( + + )} + {t('account_page_xrp_balance')} +
    +
    + {localizeNumber(xrpBalance, lang, XRP_CURRENCY_OPTIONS)} +
    +
    +
    +
    + + {t('account_page_xrp_balance_in_usd')} +
    +
    + {localizeNumber(xrpBalanceInUSD, lang, USD_CURRENCY_OPTIONS)} +
    +
    +
    +
    + {typeof ReserveIcon === 'string' ? ( + Reserve + ) : ( + + )} + {t('account_page_reserve_balance')} +
    +
    + {localizeNumber( + account.info?.reserve ?? 0, + lang, + XRP_CURRENCY_OPTIONS, + )} +
    +
    +
    + ) +} + +export default Balances diff --git a/src/containers/Accounts/AccountSummary/DetailsCard.tsx b/src/containers/Accounts/AccountSummary/DetailsCard.tsx new file mode 100644 index 000000000..a1d7a5752 --- /dev/null +++ b/src/containers/Accounts/AccountSummary/DetailsCard.tsx @@ -0,0 +1,70 @@ +import { useTranslation } from 'react-i18next' +import { localizeNumber } from '../../shared/utils' +import { XRP_SMALL_BALANCE_CURRENCY_OPTIONS } from '../../shared/NumberFormattingUtils' +import { Account } from '../../shared/components/Account' + +interface DetailsCardProps { + account: any + lang: string +} + +const DetailsCard = ({ account, lang }: DetailsCardProps) => { + const { t } = useTranslation() + + return ( +
    +
    +
    {t('account_page_details')}
    +
    +
    +
    +
    + {t('account_page_current_sequence')} +
    +
    + {localizeNumber(account.info?.sequence, lang)} +
    +
    +
    +
    {t('account_page_ticket_count')}
    +
    + {localizeNumber(account.info?.ticketCount, lang)} +
    +
    + {account.info?.emailHash && ( +
    +
    {t('account_page_email_hash')}
    +
    {account.info.emailHash}
    +
    + )} + {account.paychannels?.total_available && ( +
    +
    + {t('account_page_payment_channels')} +
    +
    + {t('account_page_payment_channels_text', { + currency: localizeNumber( + account.paychannels.total_available, + lang, + XRP_SMALL_BALANCE_CURRENCY_OPTIONS, + ), + number: account.paychannels.channels.length, + })} +
    +
    + )} + {account.info?.nftMinter && ( +
    +
    {t('account_page_nft_minter')}
    +
    + +
    +
    + )} +
    +
    + ) +} + +export default DetailsCard diff --git a/src/containers/Accounts/AccountSummary/FlagsCard.tsx b/src/containers/Accounts/AccountSummary/FlagsCard.tsx new file mode 100644 index 000000000..b98515fa4 --- /dev/null +++ b/src/containers/Accounts/AccountSummary/FlagsCard.tsx @@ -0,0 +1,158 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +interface FlagsCardProps { + account: any +} + +const FlagsCard = ({ account }: FlagsCardProps) => { + const { t } = useTranslation() + + const flags = useMemo< + Array<{ + key: string + title: string + description: string + enabled: boolean + }> + >(() => { + const accountFlags: string[] = account.info?.flags ?? [] + + return [ + { + key: 'lsfGlobalFreeze', + title: t('account_flag_title_lsf_global_freeze'), + description: t('account_flag_description_lsf_global_freeze'), + enabled: accountFlags.includes('lsfGlobalFreeze'), + }, + { + key: 'lsfDisableMaster', + title: t('account_flag_title_lsf_disable_master'), + description: t('account_flag_description_lsf_disable_master'), + enabled: accountFlags.includes('lsfDisableMaster'), + }, + { + key: 'lsfDefaultRipple', + title: t('account_flag_title_lsf_default_ripple'), + description: t('account_flag_description_lsf_default_ripple'), + enabled: accountFlags.includes('lsfDefaultRipple'), + }, + { + key: 'lsfAllowTrustLineClawback', + title: t('account_flag_title_lsf_allow_trustline_clawback'), + description: t('account_flag_description_lsf_allow_trustline_clawback'), + enabled: accountFlags.includes('lsfAllowTrustLineClawback'), + }, + { + key: 'lsfRequireDestTag', + title: t('account_flag_title_lsf_require_destination_tag'), + description: t('account_flag_description_lsf_require_destination_tag'), + enabled: accountFlags.includes('lsfRequireDestTag'), + }, + { + key: 'lsfNoFreeze', + title: t('account_flag_title_lsf_no_freeze'), + description: t('account_flag_description_lsf_no_freeze'), + enabled: accountFlags.includes('lsfNoFreeze'), + }, + { + key: 'lsfRequireAuth', + title: t('account_flag_title_lsf_require_auth'), + description: t('account_flag_description_lsf_require_auth'), + enabled: accountFlags.includes('lsfRequireAuth'), + }, + { + key: 'lsfDisallowXRP', + title: t('account_flag_title_lsf_disallow_xrp'), + description: t('account_flag_description_lsf_disallow_xrp'), + enabled: accountFlags.includes('lsfDisallowXRP'), + }, + { + key: 'lsfDisallowIncomingTrustline', + title: t('account_flag_title_lsf_disallow_incoming_trustline'), + description: t( + 'account_flag_description_lsf_disallow_incoming_trustline', + ), + enabled: accountFlags.includes('lsfDisallowIncomingTrustline'), + }, + { + key: 'lsfDisallowIncomingPayChannel', + title: t('account_flag_title_lsf_disallow_incoming_pay_chan'), + description: t( + 'account_flag_description_lsf_disallow_incoming_pay_chan', + ), + enabled: accountFlags.includes('lsfDisallowIncomingPayChannel'), + }, + { + key: 'lsfDisallowIncomingNFTokenOffer', + title: t('account_flag_title_lsf_disallow_incoming_nft_token_offer'), + description: t( + 'account_flag_description_lsf_disallow_incoming_nft_token_offer', + ), + enabled: accountFlags.includes('lsfDisallowIncomingNFTokenOffer'), + }, + { + key: 'asfAuthorizedNFTokenMinter', + title: t('account_flag_title_asf_authorized_nft_token_minter'), + description: t( + 'account_flag_description_asf_authorized_nft_token_minter', + ), + // No ledger flag exists for the AccountSet flag `asfAuthorizedNFTokenMinter`. + // The NFTokenMinter field's presence or absence is sufficient to determine the flag's status. + enabled: !!account.info?.nftMinter, + }, + { + key: 'lsfDisallowIncomingCheck', + title: t('account_flag_title_lsf_disallow_incoming_check'), + description: t('account_flag_description_lsf_disallow_incoming_check'), + enabled: accountFlags.includes('lsfDisallowIncomingCheck'), + }, + { + key: 'lsfDepositAuth', + title: t('account_flag_title_lsf_deposit_auth'), + description: t('account_flag_description_lsf_deposit_auth'), + enabled: accountFlags.includes('lsfDepositAuth'), + }, + { + key: 'asfAccountTxnID', + title: t('account_flag_title_asf_account_txn_id'), + description: t('account_flag_description_asf_account_txn_id'), + // No ledger flag exists for the AccountSet flag `asfAccountTxnID`. + // The AccountTxnID field's presence or absence is sufficient to determine the flag's status. + enabled: !!account.info?.accountTransactionID, + }, + ] + }, [account.info, t]) + + return ( +
    +
    +
    {t('account_page_flags')}
    +
    +
    + {flags.map((flag, i) => ( +
    +
    +
    {flag.title}
    +
    {flag.description}
    +
    +
    + {(() => { + if (flag.key === 'lsfDisableMaster') { + return flag.enabled ? t('yes') : t('no') + } + return flag.enabled + ? t('account_page_flag_status_enabled') + : t('account_page_flag_status_disabled') + })()} +
    +
    + ))} +
    +
    + ) +} + +export default FlagsCard diff --git a/src/containers/Accounts/AccountSummary/SignersCard.tsx b/src/containers/Accounts/AccountSummary/SignersCard.tsx new file mode 100644 index 000000000..76e232a13 --- /dev/null +++ b/src/containers/Accounts/AccountSummary/SignersCard.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from 'react-i18next' +import { Account } from '../../shared/components/Account' +import { localizeNumber, shortenAccount } from '../../shared/utils' + +interface Signer { + account: string + weight?: number // Individual weight values cannot exceed 2^16-1. +} + +interface SignersCardProps { + signers: Signer[] +} +const SignersCard = ({ signers }: SignersCardProps) => { + const { t } = useTranslation() + return ( +
    +
    +
    {t('account_page_signers')}
    +
    +
    + {signers.map((signer, i) => ( +
    +
    + +
    + {signer.weight !== undefined && ( +
    + {t('account_page_signer_weight')}{' '} + {localizeNumber(signer.weight)} +
    + )} +
    + ))} +
    +
    + ) +} + +export default SignersCard diff --git a/src/containers/Accounts/AccountSummary/index.tsx b/src/containers/Accounts/AccountSummary/index.tsx new file mode 100644 index 000000000..b045242a3 --- /dev/null +++ b/src/containers/Accounts/AccountSummary/index.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { useLanguage } from '../../shared/hooks' +import ArrowIcon from '../../shared/images/down_arrow.svg' +import Balances from './Balances' +import DetailsCard from './DetailsCard' +import FlagsCard from './FlagsCard' +import SignersCard from './SignersCard' + +import './styles.scss' + +interface AccountSummaryProps { + account: any + xrpToUSDRate: number +} + +export const AccountSummary = ({ + account = {}, + xrpToUSDRate, +}: AccountSummaryProps) => { + const { t } = useTranslation() + const lang = useLanguage() + const [propertiesOpen, setPropertiesOpen] = useState(false) + + return ( +
    + + +
    +
    +

    {t('account_page_account_properties')}

    + +
    + {propertiesOpen && ( +
    + + {account.signerList?.signers && ( + + )} + +
    + )} +
    +
    + ) +} + +export default AccountSummary diff --git a/src/containers/Accounts/AccountSummary/styles.scss b/src/containers/Accounts/AccountSummary/styles.scss new file mode 100644 index 000000000..14b4d044d --- /dev/null +++ b/src/containers/Accounts/AccountSummary/styles.scss @@ -0,0 +1,505 @@ +@use '../../shared/css/variables' as *; + +.account-summary { + .card { + padding: 18px; + border: 1px solid $black-70; + border-radius: 8px; + background: $black-90; + } + + .balances { + /* Frame that encompasses the 3 balance boxes */ + display: flex; + + /* Inside auto layout */ + flex: none; + flex-direction: row; + flex-grow: 0; + align-items: center; + align-self: stretch; + order: 1; + padding: 0; + gap: 28px; + + /* common base for all balance cards (markup uses .balance-card and modifier classes) */ + .balance-card { + display: flex; + min-width: 0; /* allow shrinking for long content */ + flex: 1 1 0; + flex-direction: column; + justify-content: center; + gap: 12px; + } + + .balance-title { + display: flex; + align-items: center; + color: $black-40; + font-size: 14px; + gap: 6px; + text-transform: uppercase; + @include semibold; + } + + .balance-icon { + display: inline-block; + width: 24px; + height: 24px; + flex: 0 0 auto; + } + + .balance-value { + color: $black-0; + font-size: 24px; + @include bold; + } + + /* shared visual box properties for the three balance tiles */ + .xrp, + .usd, + .reserve { + display: flex; + min-width: 0; + height: 130px; + box-sizing: border-box; + flex: 1 1 0; + flex-direction: column; + align-items: flex-start; + padding: 38px; + border-radius: 8px; + gap: 12px; + } + + /* individual visuals only set border/background/order */ + .xrp { + order: 0; + border: 1px solid #145c35; + background: rgb(20 92 53 / 40%); + } + + .usd { + order: 1; + border: 1px solid #6b0080; + background: rgb(64 0 76 / 50%); + } + + .reserve { + order: 2; + border: 1px solid #004d80; + background: rgb(0 77 128 / 40%); + } + } + + .properties { + /* layout for header and the chevron toggle */ + .properties-header { + display: flex; + align-items: center; + justify-content: flex-start; + margin: 24px 0; + gap: 2px; + + h3 { + margin: 0; + color: $black-0; + font-size: 20px; + } + + .properties-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + } + + .properties-arrow { + display: inline-block; + width: 18px; + height: 18px; + transform-origin: center; + transition: transform 180ms ease; + } + + .properties-arrow.open { + transform: rotate(180deg); + } + } + + .properties-grid { + display: grid; + align-items: start; + gap: 28px; + grid-template-columns: repeat(3, 1fr); + + /* Custom scrollbars for Flags and Signers lists */ + .flags-list, + .signers-list { + scrollbar-color: rgb(255 255 255 / 20%) transparent; + + /* Firefox */ + scrollbar-width: thin; + + /* WebKit */ + &::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + border: 2px solid transparent; /* create padding effect */ + border-radius: 6px; + background: rgb(255 255 255 / 20%); + background-clip: padding-box; + } + + &::-webkit-scrollbar-thumb:hover { + background: rgb(255 255 255 / 12%); + } + } + + /* Shared visuals for both cards */ + .flags-card, + .signers-card { + display: flex; + min-width: 0; + min-height: 0; + box-sizing: border-box; + flex: none; + flex-direction: column; + flex-grow: 0; + align-items: stretch; + padding: 20px; + gap: 15px; + isolation: isolate; + } + + /* shared header styles to avoid duplicates */ + .card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + + .header-title { + color: $black-0; + font-size: 20px; + @include bold; + } + } + + .flags-card { + grid-column: 1 / span 2; + + .flags-list { + display: flex; + max-height: calc(5 * 70px); + flex-direction: column; + padding-right: 6px; + gap: 8px; + overflow-y: auto; + } + + .flag-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + border-radius: 8px; + background: $black-80; + + .flag-meta { + display: flex; + flex-direction: column; + + .flag-title { + margin-bottom: 6px; + color: $black-0; + font-size: 14px; + @include semibold; + + text-transform: uppercase; + } + + .flag-desc { + color: $black-0; + font-size: 14px; + } + } + + .flag-status { + min-width: 85px; // Ensures YES/NO and ENABLED/DISABLED have the same width + padding: 6px 10px; + border-radius: 999px; + font-size: 12px; + letter-spacing: 0.06em; + text-align: center; + text-transform: uppercase; + white-space: nowrap; + @include semibold; + } + + .flag-status.enabled { + background: $green-50; + color: $black-100; + } + + .flag-status.disabled { + background: $black-50; + color: $black-0; + } + } + } + + .signers-card { + grid-column: 3 / 4; + + .signers-list { + display: flex; + width: 100%; + max-height: calc(5 * 44px); + flex-direction: column; + align-self: stretch; + padding-right: 6px; /* match flags-list spacing to keep scrollbar offset consistent */ + gap: 8px; + overflow-y: auto; + } + + .signer-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px; + border-radius: 8px; + background: $black-80; + + .signer-address { + font-size: 14px; + } + + .signer-weight { + color: $blue-50; + font-size: 14px; + } + } + } + } + + /* Details card styles */ + @media (min-width: $tablet-landscape-upper-boundary) { + .details-card { + /* Ensure Details card matches Flags card width in properties grid (desktop only) */ + grid-column: 1 / span 2; + } + } + + .details-card { + display: flex; + flex-direction: column; + padding: 20px; + background: $black-90; + gap: 16px; + + /* Only first two details side by side, rest full width */ + .details-box { + display: flex; + min-width: 0; + flex: 1 1 180px; + flex-direction: row; + align-items: center; + padding: 10px; + border: 1px solid $black-70; + border-radius: 8px; + background: $black-80; + gap: 12px; + } + + .details-list { + display: grid; + gap: 8px; + grid-template-columns: repeat(2, 1fr); + + /* Only the first two direct .details-box children are side by side on desktop */ + > .details-box:nth-child(1) { + grid-column: 1; + grid-row: 1; + } + + > .details-box:nth-child(2) { + grid-column: 2; + grid-row: 1; + } + + > .details-box:nth-child(n+3) { + grid-column: 1 / span 2; + } + } + + .details-label { + color: $black-0; + font-size: 14px; + @include medium; + + text-transform: none; + } + + .details-value { + margin-left: auto; + color: $black-0; + font-size: 14px; + text-align: right; + } + } + + /* Force vertical stacking for tablet portrait and smaller */ + @media (max-width: $tablet-portrait-upper-boundary) { + .details-card .details-list { + grid-template-columns: 1fr; /* Keep grid but force 1 column */ + + > .details-box:nth-child(1), + > .details-box:nth-child(2) { + grid-column: 1; + grid-row: auto; + } + + /* Ensure all boxes span full width in grid */ + > .details-box:nth-child(n+3) { + grid-column: 1; + } + } + } + + @media (max-width: $tablet-landscape-upper-boundary) { + .details-card { + padding: 16px; + } + + .details-list { + /* Keep as grid instead of flex to maintain alignment */ + display: grid; + gap: 10px; + grid-template-columns: 1fr; + } + + .details-box { + width: 100%; + flex: none; + flex-direction: column; + align-items: flex-start; + padding: 12px; + gap: 8px; + } + .details-label { font-size: 12px; } + .details-value { font-size: 14px; } + } + + /* Extra narrow screens - force better text wrapping */ + @media (max-width: $tablet-portrait-upper-boundary) { + .details-card { + padding: 12px; + + .details-list { + gap: 8px; + } + + .details-box { + overflow: hidden; /* Prevent box expansion */ + min-height: auto; + padding: 10px; + } + + .details-label { + line-height: 1.2; + } + + .details-value { + max-width: 100%; + hyphens: auto; /* Allow hyphenation if supported */ + line-height: 1.3; + overflow-wrap: anywhere; /* More aggressive wrapping */ + word-break: break-all; + } + } + } + } + + @media (max-width: $tablet-landscape-upper-boundary) { + .balances { + flex-direction: column; + margin-bottom: 24px; /* preserve spacing on mobile */ + gap: 12px; + + .balance-card { + width: 100%; + flex: none; + } + + .xrp, .usd, .reserve { + height: auto; + padding: 20px; + } + + .balance-value { font-size: 18px; } + + .balance-icon { + width: 20px; + height: 20px; + } + } + + .properties { + .properties-header { + gap: 8px; + .properties-toggle { padding: 4px; } + + .properties-arrow { + width: 16px; + height: 16px; + } + } + + .properties-grid { + margin-top: 12px; + gap: 12px; + grid-template-columns: 1fr; + + .flags-card, + .signers-card { + padding: 16px; + } + + .flags-card { grid-column: auto; } + .signers-card { grid-column: auto; } + + .flags-list, .signers-list { + max-height: calc(5 * 64px); + padding-inline: 4px; + } + + .flag-item, .signer-item { + padding: 10px; + } + } + } + } + + @media (max-width: $tablet-portrait-upper-boundary) { + .properties { + .properties-header { + h3 { font-size: 18px; } + } + + .properties-grid .card-header .header-title { + font-size: 18px; + } + } + } +} diff --git a/src/containers/Accounts/AccountSummary/test/AccountSummary.test.tsx b/src/containers/Accounts/AccountSummary/test/AccountSummary.test.tsx new file mode 100644 index 000000000..24ac88370 --- /dev/null +++ b/src/containers/Accounts/AccountSummary/test/AccountSummary.test.tsx @@ -0,0 +1,175 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import i18n from '../../../../i18n/testConfigEnglish' +import { AccountSummary } from '../index' + +// Mock the child components and SVG imports +jest.mock('../Balances', () => ({ + __esModule: true, + default: ({ account, xrpToUSDRate }: any) => ( +
    + Balance: {account.info?.balance} Rate: {xrpToUSDRate} +
    + ), +})) + +jest.mock('../DetailsCard', () => ({ + __esModule: true, + default: ({ account, lang }: any) => ( +
    + Sequence: {account.info?.sequence} Lang: {lang} +
    + ), +})) + +jest.mock('../FlagsCard', () => ({ + __esModule: true, + default: ({ account }: any) => ( +
    + Flags: {account.info?.flags?.length || 0} +
    + ), +})) + +jest.mock('../SignersCard', () => ({ + __esModule: true, + default: ({ signers }: any) => ( +
    Signers: {signers.length}
    + ), +})) + +jest.mock('../../../shared/images/down_arrow.svg', () => ({ + __esModule: true, + default: () => , +})) + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +) + +describe('AccountSummary Component', () => { + const mockAccount = { + info: { + balance: 1000000000, + sequence: 123, + flags: ['lsfGlobalFreeze'], + }, + } + + describe('Rendering and Toggle Behavior', () => { + it('renders balances and collapsed properties by default, then expands/collapses on toggle', () => { + render( + + + , + ) + + // Renders balances and properties header + expect(screen.getByTestId('balances-mock')).toBeInTheDocument() + expect(screen.getByText('Account Properties')).toBeInTheDocument() + + // Properties are collapsed by default + const toggleButton = screen.getByLabelText('Toggle account properties') + expect(toggleButton).toBeInTheDocument() + expect(toggleButton).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByTestId('flags-card-mock')).not.toBeInTheDocument() + expect(screen.queryByTestId('details-card-mock')).not.toBeInTheDocument() + + // Expand properties + fireEvent.click(toggleButton) + expect(toggleButton).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByTestId('flags-card-mock')).toBeInTheDocument() + expect(screen.getByTestId('details-card-mock')).toBeInTheDocument() + + // Collapse again + fireEvent.click(toggleButton) + expect(toggleButton).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByTestId('flags-card-mock')).not.toBeInTheDocument() + }) + }) + + describe('Props and Content', () => { + it('passes correct props to child components', () => { + render( + + + , + ) + + // Balances receives account and rate + expect(screen.getByText(/Balance: 1000000000/)).toBeInTheDocument() + expect(screen.getByText(/Rate: 0.5/)).toBeInTheDocument() + + // FlagsCard receives account + const toggleButton = screen.getByLabelText('Toggle account properties') + fireEvent.click(toggleButton) + expect(screen.getByText('Flags: 1')).toBeInTheDocument() + }) + + it('renders SignersCard when signers exist, hides when none', () => { + // Without signers + render( + + + , + ) + + const toggleButton = screen.getByLabelText('Toggle account properties') + fireEvent.click(toggleButton) + expect(screen.queryByTestId('signers-card-mock')).not.toBeInTheDocument() + }) + + it('renders SignersCard when account has signers', () => { + const accountWithSigners = { + ...mockAccount, + signerList: { + signers: [ + { account: 'rSigner1', weight: 1 }, + { account: 'rSigner2', weight: 2 }, + ], + }, + } + + render( + + + , + ) + + const toggleButton = screen.getByLabelText('Toggle account properties') + fireEvent.click(toggleButton) + expect(screen.getByTestId('signers-card-mock')).toBeInTheDocument() + expect(screen.getByText('Signers: 2')).toBeInTheDocument() + }) + + it('handles empty account gracefully', () => { + render( + + + , + ) + + expect(screen.getByTestId('balances-mock')).toBeInTheDocument() + + const toggleButton = screen.getByLabelText('Toggle account properties') + fireEvent.click(toggleButton) + expect(screen.getByTestId('flags-card-mock')).toBeInTheDocument() + expect(screen.getByTestId('details-card-mock')).toBeInTheDocument() + }) + + it('handles undefined account with default value', () => { + render( + + + , + ) + + expect(screen.getByTestId('balances-mock')).toBeInTheDocument() + + const toggleButton = screen.getByLabelText('Toggle account properties') + fireEvent.click(toggleButton) + expect(screen.getByTestId('flags-card-mock')).toBeInTheDocument() + expect(screen.getByTestId('details-card-mock')).toBeInTheDocument() + }) + }) +}) diff --git a/src/containers/Accounts/AccountSummary/test/Balances.test.tsx b/src/containers/Accounts/AccountSummary/test/Balances.test.tsx new file mode 100644 index 000000000..045063900 --- /dev/null +++ b/src/containers/Accounts/AccountSummary/test/Balances.test.tsx @@ -0,0 +1,243 @@ +import { render, screen } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import i18n from '../../../../i18n/testConfigEnglish' +import Balances from '../Balances' + +// Mock SVG imports +jest.mock('../../../shared/images/usd_icon.svg', () => ({ + __esModule: true, + default: () => , +})) + +jest.mock('../../../shared/images/xrp_balance_icon.svg', () => ({ + __esModule: true, + default: () => , +})) + +jest.mock('../../../shared/images/xrp_reserve_balance_icon.svg', () => ({ + __esModule: true, + default: () => , +})) + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +) + +describe('Balances Component', () => { + describe('Rendering', () => { + it('renders all three balance cards with icons', () => { + const account = { + info: { + balance: 1000000000, // 1000 XRP (in drops) + reserve: 10000000, // 10 XRP + }, + } + + const { container } = render( + + + , + ) + + // Verify three balance cards exist + const balanceCards = container.querySelectorAll('.balance-card') + expect(balanceCards.length).toBe(3) + + // Verify XRP balance card and icon + expect(screen.getByText('XRP Balance')).toBeInTheDocument() + expect(screen.getByTestId('xrp-icon')).toBeInTheDocument() + + // Verify USD balance card and icon + expect(screen.getByText('XRP Balance (USD)')).toBeInTheDocument() + expect(screen.getByTestId('usd-icon')).toBeInTheDocument() + + // Verify Reserve balance card and icon + expect(screen.getByText('Reserve Balance')).toBeInTheDocument() + expect(screen.getByTestId('reserve-icon')).toBeInTheDocument() + }) + }) + + describe('Balance Calculations', () => { + it('converts drops to XRP correctly (1000 XRP)', () => { + const account = { + info: { + balance: 1000000000, // 1000 XRP in drops + reserve: 0, + }, + } + + const { container } = render( + + + , + ) + + // XRP balance should show 1000 + const xrpCard = container.querySelector('.balance-card.xrp') + expect(xrpCard?.textContent).toContain('1,000') + }) + + it('calculates USD value correctly', () => { + const account = { + info: { + balance: 2000000000, // 2000 XRP + reserve: 0, + }, + } + + const { container } = render( + + + , + ) + + // USD should be 2000 * 0.5 = 1000 + const usdCard = container.querySelector('.balance-card.usd') + expect(usdCard?.textContent).toContain('$1,000') + }) + + it('handles zero balance', () => { + const account = { + info: { + balance: 0, + reserve: 0, + }, + } + + const { container } = render( + + + , + ) + + const xrpCard = container.querySelector('.balance-card.xrp') + expect(xrpCard?.textContent).toContain('0') + }) + + it('handles missing balance (defaults to 0)', () => { + const account = { + info: {}, + } + + const { container } = render( + + + , + ) + + const xrpCard = container.querySelector('.balance-card.xrp') + expect(xrpCard?.textContent).toContain('0') + }) + + it('displays reserve balance', () => { + const account = { + info: { + balance: 1000000000, + reserve: 20, // 20 XRP (already in XRP, not drops) + }, + } + + const { container } = render( + + + , + ) + + const reserveCard = container.querySelector('.balance-card.reserve') + expect(reserveCard?.textContent).toContain('20') + }) + }) + + describe('XRP to USD Rate', () => { + it('updates USD value when rate changes', () => { + const account = { + info: { + balance: 1000000000, // 1000 XRP + reserve: 0, + }, + } + + const { container, rerender } = render( + + + , + ) + + // Initial: 1000 XRP * $0.5 = $500 + let usdCard = container.querySelector('.balance-card.usd') + expect(usdCard?.textContent).toContain('$500') + + // Update rate: 1000 XRP * $2 = $2000 + rerender( + + + , + ) + + usdCard = container.querySelector('.balance-card.usd') + expect(usdCard?.textContent).toContain('2,000') + }) + + it('handles zero USD rate', () => { + const account = { + info: { + balance: 1000000000, + reserve: 0, + }, + } + + const { container } = render( + + + , + ) + + const usdCard = container.querySelector('.balance-card.usd') + expect(usdCard?.textContent).toContain('$0') + }) + }) + + describe('Number Formatting', () => { + it('formats all three balances with proper formatting', () => { + const account = { + info: { + balance: 5545000, // 5.545 XRP + reserve: 20.538, // 20.54 XRP + }, + } + + const { container } = render( + + + , + ) + + // XRP balance with decimal + const xrpCard = container.querySelector('.balance-card.xrp') + expect(xrpCard?.textContent).toContain('5.55') + + // USD balance calculated and formatted + const usdCard = container.querySelector('.balance-card.usd') + expect(usdCard?.textContent).toContain('$2.77') // 5.545 * 0.5 = 2.7725 + + // Reserve balance with decimal + const reserveCard = container.querySelector('.balance-card.reserve') + expect(reserveCard?.textContent).toContain('20.54') + }) + }) + + describe('Edge Cases', () => { + it('handles missing info property', () => { + const account = {} + + const { container } = render( + + + , + ) + + const xrpCard = container.querySelector('.balance-card.xrp') + expect(xrpCard).toBeInTheDocument() + }) + }) +}) diff --git a/src/containers/Accounts/AccountSummary/test/DetailsCard.test.tsx b/src/containers/Accounts/AccountSummary/test/DetailsCard.test.tsx new file mode 100644 index 000000000..693eb4e9f --- /dev/null +++ b/src/containers/Accounts/AccountSummary/test/DetailsCard.test.tsx @@ -0,0 +1,304 @@ +import { render, screen } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import i18n from '../../../../i18n/testConfigEnglish' +import DetailsCard from '../DetailsCard' + +// Mock the Account component +jest.mock('../../../shared/components/Account', () => ({ + Account: ({ account }: { account: string }) => ( + {account} + ), +})) + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +) + +describe('DetailsCard Component', () => { + describe('Rendering', () => { + it('renders card header and required fields', () => { + const account = { + info: { + sequence: 123, + ticketCount: 5, + }, + } + + render( + + + , + ) + + // Verify card header + expect(screen.getByText('Details')).toBeInTheDocument() + + // Verify current sequence field + expect(screen.getByText('Current Sequence')).toBeInTheDocument() + expect(screen.getByText('123')).toBeInTheDocument() + + // Verify ticket count field + expect(screen.getByText('Ticket Count')).toBeInTheDocument() + expect(screen.getByText('5')).toBeInTheDocument() + }) + }) + + describe('Optional Fields', () => { + it('renders email hash when present', () => { + const account = { + info: { + sequence: 123, + ticketCount: 0, + emailHash: 'ABC123DEF456', + }, + } + + render( + + + , + ) + + expect(screen.getByText('Email Hash')).toBeInTheDocument() + expect(screen.getByText('ABC123DEF456')).toBeInTheDocument() + }) + + it('does not render email hash when not present', () => { + const account = { + info: { + sequence: 123, + ticketCount: 0, + }, + } + + render( + + + , + ) + + expect(screen.queryByText('Email Hash')).not.toBeInTheDocument() + }) + + it('renders payment channels when present', () => { + const account = { + info: { + sequence: 123, + ticketCount: 0, + }, + paychannels: { + total_available: 100, + channels: [{ id: '1' }, { id: '2' }, { id: '3' }], + }, + } + + render( + + + , + ) + + expect(screen.getByText('Payment Channels')).toBeInTheDocument() + // Should show formatted text with both amount and number of channels + expect(screen.getByText(/100\.00.*3 channel\(s\)/i)).toBeInTheDocument() + }) + + it('does not render payment channels when not present', () => { + const account = { + info: { + sequence: 123, + ticketCount: 0, + }, + } + + render( + + + , + ) + + expect(screen.queryByText('Payment Channels')).not.toBeInTheDocument() + }) + + it('renders NFT minter when present', () => { + const account = { + info: { + sequence: 123, + ticketCount: 0, + nftMinter: 'rNFTMinterAccount123', + }, + } + + render( + + + , + ) + + expect(screen.getByText('NFT Minter')).toBeInTheDocument() + expect(screen.getByTestId('account-component')).toHaveTextContent( + 'rNFTMinterAccount123', + ) + }) + + it('does not render NFT minter when not present', () => { + const account = { + info: { + sequence: 123, + ticketCount: 0, + }, + } + + render( + + + , + ) + + expect(screen.queryByText('NFT Minter')).not.toBeInTheDocument() + }) + }) + + describe('Number Formatting', () => { + it('formats sequence and ticket count with commas for large numbers', () => { + const account = { + info: { + sequence: 1234567, + ticketCount: 1000, + }, + } + + render( + + + , + ) + + expect(screen.getByText('1,234,567')).toBeInTheDocument() + expect(screen.getByText('1,000')).toBeInTheDocument() + }) + + it('handles zero values', () => { + const account = { + info: { + sequence: 0, + ticketCount: 0, + }, + } + + render( + + + , + ) + + const zeroElements = screen.getAllByText('0') + expect(zeroElements.length).toBe(2) + }) + }) + + describe('Combined Optional Fields', () => { + it('renders all optional fields when present', () => { + const account = { + info: { + sequence: 123, + ticketCount: 5, + emailHash: 'ABC123', + nftMinter: 'rMinter123', + }, + paychannels: { + total_available: 100, + channels: [{ id: '1' }, { id: '2' }], + }, + } + + render( + + + , + ) + + expect(screen.getByText('Email Hash')).toBeInTheDocument() + expect(screen.getByText('Payment Channels')).toBeInTheDocument() + expect(screen.getByText('NFT Minter')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('handles undefined sequence', () => { + const account = { + info: { + ticketCount: 0, + }, + } + + render( + + + , + ) + + expect(screen.getByText('Current Sequence')).toBeInTheDocument() + }) + + it('handles undefined ticket count', () => { + const account = { + info: { + sequence: 123, + }, + } + + render( + + + , + ) + + expect(screen.getByText('Ticket Count')).toBeInTheDocument() + }) + + it('handles empty paychannels array', () => { + const account = { + info: { + sequence: 123, + ticketCount: 0, + }, + paychannels: { + total_available: 0, + channels: [], + }, + } + + render( + + + , + ) + + // Should not render payment channels if total_available is 0 (falsy) + expect(screen.queryByText('Payment Channels')).not.toBeInTheDocument() + }) + }) + + describe('Language Support', () => { + it('accepts different language codes', () => { + const account = { + info: { + sequence: 1234567, + ticketCount: 0, + }, + } + + render( + + + , + ) + + expect(screen.getByText('Details')).toBeInTheDocument() + }) + }) +}) diff --git a/src/containers/Accounts/AccountSummary/test/FlagsCard.test.tsx b/src/containers/Accounts/AccountSummary/test/FlagsCard.test.tsx new file mode 100644 index 000000000..eaf7993d2 --- /dev/null +++ b/src/containers/Accounts/AccountSummary/test/FlagsCard.test.tsx @@ -0,0 +1,768 @@ +import { render, screen } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import i18n from '../../../../i18n/testConfigEnglish' +import FlagsCard from '../FlagsCard' + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +) + +describe('FlagsCard Component', () => { + describe('Rendering', () => { + it('renders the card with header', () => { + const account = { + info: { + flags: [], + }, + } + + render( + + + , + ) + + expect(screen.getByText('Flags')).toBeInTheDocument() + }) + + it('renders all 15 flag items', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flagItems = container.querySelectorAll('.flag-item') + expect(flagItems.length).toBe(15) + }) + }) + + describe('Flag Status - Enabled(Yes)/Disabled(No)', () => { + it('shows lsfGlobalFreeze as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfGlobalFreeze'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Global Freeze'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') + }) + + it('shows lsfGlobalFreeze as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Global Freeze'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') + }) + + it('shows lsfDisableMaster as enabled with "Yes" when flag is present', () => { + const account = { + info: { + flags: ['lsfDisableMaster'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Master Key'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Yes') + }) + + it('shows lsfDisableMaster as disabled with "No" when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Master Key'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('No') + }) + + it('shows lsfDefaultRipple as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfDefaultRipple'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Rippling'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') + }) + + it('shows lsfDefaultRipple as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Rippling'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') + }) + + it('shows lsfAllowTrustLineClawback as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfAllowTrustLineClawback'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Clawback'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') + }) + + it('shows lsfAllowTrustLineClawback as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Clawback'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') + }) + + it('shows lsfRequireDestTag as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfRequireDestTag'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Require Destination Tag'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') + }) + + it('shows lsfRequireDestTag as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Require Destination Tag'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') + }) + + it('shows lsfNoFreeze as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfNoFreeze'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('No Freeze'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') + }) + + it('shows lsfNoFreeze as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('No Freeze'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') + }) + + it('shows lsfRequireAuth as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfRequireAuth'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => + item.querySelector('.flag-title')?.textContent === + 'Require Authorization', + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') + }) + + it('shows lsfRequireAuth as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => + item.querySelector('.flag-title')?.textContent === + 'Require Authorization', + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') + }) + + it('shows lsfDisallowXRP as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfDisallowXRP'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('No XRP Allowed'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') + }) + + it('shows lsfDisallowXRP as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('No XRP Allowed'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') + }) + + it('shows lsfDisallowIncomingTrustline as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfDisallowIncomingTrustline'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Block Trustlines'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') + }) + + it('shows lsfDisallowIncomingTrustline as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Block Trustlines'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') + }) + + it('shows lsfDisallowIncomingPayChannel as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfDisallowIncomingPayChannel'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Block Payment Channels'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') + }) + + it('shows lsfDisallowIncomingPayChannel as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Block Payment Channels'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') + }) + + it('shows lsfDisallowIncomingNFTokenOffer as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfDisallowIncomingNFTokenOffer'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Block NFT Offers'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') + }) + + it('shows lsfDisallowIncomingNFTokenOffer as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Block NFT Offers'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') + }) + + it('shows asfAuthorizedNFTokenMinter as enabled when nftMinter field is present', () => { + const account = { + info: { + flags: [], + nftMinter: 'rMinterAccount123', + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('NFT Minter'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') + }) + + it('shows asfAuthorizedNFTokenMinter as disabled when nftMinter field is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('NFT Minter'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') + }) + + it('shows lsfDisallowIncomingCheck as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfDisallowIncomingCheck'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Block Checks'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') + }) + + it('shows lsfDisallowIncomingCheck as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Block Checks'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') + }) + + it('shows lsfDepositAuth as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfDepositAuth'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => + item.querySelector('.flag-title')?.textContent === + 'Deposit Authorization', + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') + }) + + it('shows lsfDepositAuth as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => + item.querySelector('.flag-title')?.textContent === + 'Deposit Authorization', + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') + }) + + it('shows asfAccountTxnID as enabled when accountTransactionID field is present', () => { + const account = { + info: { + flags: [], + accountTransactionID: 'ABCD1234', + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => + item.textContent?.includes('Track Account Latest Transaction'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') + }) + + it('shows asfAccountTxnID as disabled when accountTransactionID field is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => + item.textContent?.includes('Track Account Latest Transaction'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') + }) + }) + + describe('Multiple Flags', () => { + it('handles multiple flags enabled simultaneously', () => { + const account = { + info: { + flags: [ + 'lsfGlobalFreeze', + 'lsfDefaultRipple', + 'lsfRequireDestTag', + 'lsfNoFreeze', + ], + }, + } + + const { container } = render( + + + , + ) + + const enabledFlags = container.querySelectorAll('.flag-status.enabled') + // At least 4 should be enabled (the ones we set) + // Note: Some inverted flags might also be enabled by default + expect(enabledFlags.length).toBeGreaterThanOrEqual(4) + }) + }) + + describe('Flag Structure', () => { + it('each flag has non-empty title, description, and status', () => { + const account = { + info: { + flags: ['lsfGlobalFreeze'], + }, + } + + const { container } = render( + + + , + ) + + const flagItems = container.querySelectorAll('.flag-item') + flagItems.forEach((item) => { + // Each flag must have all three elements + const title = item.querySelector('.flag-title') + const desc = item.querySelector('.flag-desc') + const status = item.querySelector('.flag-status') + + expect(title).toBeInTheDocument() + expect(desc).toBeInTheDocument() + expect(status).toBeInTheDocument() + + // And none should be empty + expect(title?.textContent).not.toBe('') + expect(desc?.textContent).not.toBe('') + expect(status?.textContent).not.toBe('') + }) + }) + }) + + describe('Edge Cases', () => { + it('handles undefined flags array', () => { + const account = { + info: {}, + } + + const { container } = render( + + + , + ) + + expect(screen.getByText('Flags')).toBeInTheDocument() + const flagItems = container.querySelectorAll('.flag-item') + expect(flagItems.length).toBe(15) + }) + + it('handles missing info object', () => { + const account = {} + + const { container } = render( + + + , + ) + + expect(screen.getByText('Flags')).toBeInTheDocument() + const flagItems = container.querySelectorAll('.flag-item') + expect(flagItems.length).toBe(15) + }) + + it('handles empty account object', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + expect(screen.getByText('Flags')).toBeInTheDocument() + const flagItems = container.querySelectorAll('.flag-item') + expect(flagItems.length).toBe(15) + }) + }) +}) diff --git a/src/containers/Accounts/AccountSummary/test/SignersCard.test.tsx b/src/containers/Accounts/AccountSummary/test/SignersCard.test.tsx new file mode 100644 index 000000000..668d8ed75 --- /dev/null +++ b/src/containers/Accounts/AccountSummary/test/SignersCard.test.tsx @@ -0,0 +1,230 @@ +import { render, screen } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import i18n from '../../../../i18n/testConfigEnglish' +import SignersCard from '../SignersCard' + +// Mock the Account component +jest.mock('../../../shared/components/Account', () => ({ + Account: ({ account, displayText }: any) => ( + {displayText || account} + ), +})) + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +) + +describe('SignersCard Component', () => { + describe('Rendering', () => { + it('renders the card with header', () => { + const signers = [{ account: 'rSigner1', weight: 1 }] + + const { container } = render( + + + , + ) + + expect(screen.getByText('Signers')).toBeInTheDocument() + expect(container.querySelector('.card-header')).toBeInTheDocument() + expect(container.querySelector('.header-title')).toBeInTheDocument() + }) + + it('renders a single signer', () => { + const signers = [{ account: 'rSigner', weight: 1 }] + + const { container } = render( + + + , + ) + + expect(screen.getByTestId('account-rSigner')).toBeInTheDocument() + const signerItems = container.querySelectorAll('.signer-item') + expect(signerItems.length).toBe(1) + }) + + it('renders multiple signers', () => { + const signers = [ + { account: 'rSigner1', weight: 1 }, + { account: 'rSigner2', weight: 2 }, + { account: 'rSigner3', weight: 3 }, + ] + + const { container } = render( + + + , + ) + + expect(screen.getByTestId('account-rSigner1')).toBeInTheDocument() + expect(screen.getByTestId('account-rSigner2')).toBeInTheDocument() + expect(screen.getByTestId('account-rSigner3')).toBeInTheDocument() + const signerItems = container.querySelectorAll('.signer-item') + expect(signerItems.length).toBe(3) + }) + }) + + describe('Signer Weight', () => { + it('displays signer weight when present', () => { + const signers = [{ account: 'rSigner1', weight: 5 }] + + render( + + + , + ) + + expect(screen.getByText(/Weight/i)).toBeInTheDocument() + expect(screen.getByText(/5/)).toBeInTheDocument() + }) + + it('displays different weights for different signers', () => { + const signers = [ + { account: 'rSigner1', weight: 1 }, + { account: 'rSigner2', weight: 3 }, + { account: 'rSigner3', weight: 5 }, + ] + + render( + + + , + ) + + expect(screen.getByText(/Weight.*1/)).toBeInTheDocument() + expect(screen.getByText(/Weight.*3/)).toBeInTheDocument() + expect(screen.getByText(/Weight.*5/)).toBeInTheDocument() + }) + + it('does not display weight when undefined', () => { + const signers = [{ account: 'rSigner1' }] + + const { container } = render( + + + , + ) + + expect(container.querySelector('.signer-weight')).not.toBeInTheDocument() + }) + + it('displays weight of zero', () => { + const signers = [{ account: 'rSigner1', weight: 0 }] + + render( + + + , + ) + + expect(screen.getByText(/Weight.*0/)).toBeInTheDocument() + }) + }) + + describe('Account Display', () => { + it('uses shortened account address for display', () => { + const longAccount = 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH' + const signers = [{ account: longAccount, weight: 1 }] + + render( + + + , + ) + + // Should show shortened version: 7 chars...5 chars + expect(screen.getByText(/rN7n7ot.*6fzRH/)).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('handles empty signers array', () => { + const signers: any[] = [] + + const { container } = render( + + + , + ) + + const signerItems = container.querySelectorAll('.signer-item') + expect(signerItems.length).toBe(0) + }) + + it('uses index as key when account is missing', () => { + const signers = [{ weight: 1 } as any, { weight: 2 } as any] + + const { container } = render( + + + , + ) + + const signerItems = container.querySelectorAll('.signer-item') + expect(signerItems.length).toBe(2) + }) + + it('handles very large weights', () => { + const signers = [{ account: 'rSigner1', weight: 999999 }] + + render( + + + , + ) + + expect(screen.getByText(/999,999/)).toBeInTheDocument() + }) + + it('handles mix of signers with and without weights', () => { + const signers = [ + { account: 'rSigner1', weight: 1 }, + { account: 'rSigner2' }, + { account: 'rSigner3', weight: 3 }, + ] + + const { container } = render( + + + , + ) + + const weights = container.querySelectorAll('.signer-weight') + expect(weights.length).toBe(2) // Only two signers have weights + }) + }) + + describe('List Structure', () => { + it('contains signers-list container', () => { + const signers = [{ account: 'rSigner1', weight: 1 }] + + const { container } = render( + + + , + ) + + expect(container.querySelector('.signers-list')).toBeInTheDocument() + }) + + it('each signer has signer-address element', () => { + const signers = [ + { account: 'rSigner1', weight: 1 }, + { account: 'rSigner2', weight: 2 }, + ] + + const { container } = render( + + + , + ) + + const addresses = container.querySelectorAll('.signer-address') + expect(addresses.length).toBe(2) + }) + }) +}) diff --git a/src/containers/Accounts/AccountTransactionTable/AccountTransactionTable.tsx b/src/containers/Accounts/AccountTransactionTable/AccountTransactionTable.tsx index dadfad7da..e8c220a3d 100644 --- a/src/containers/Accounts/AccountTransactionTable/AccountTransactionTable.tsx +++ b/src/containers/Accounts/AccountTransactionTable/AccountTransactionTable.tsx @@ -7,17 +7,16 @@ import { useAnalytics } from '../../shared/analytics' import SocketContext from '../../shared/SocketContext' import { getAccountTransactions } from '../../../rippled' +import './styles.scss' export interface AccountTransactionsTableProps { accountId: string - currencySelected?: string hasTokensColumn: boolean } export const AccountTransactionTable = ({ accountId, hasTokensColumn, - currencySelected = 'XRP', }: AccountTransactionsTableProps) => { const { t } = useTranslation() const { trackException } = useAnalytics() @@ -49,40 +48,32 @@ export const AccountTransactionTable = ({ }, ) - const filterTransactions = () => { - let processedTransactions = data?.pages?.reduce((txArray, page: any) => { - if (page.transactions) { - return txArray.concat(page.transactions) - } - return txArray - }, []) + const transactions = + data?.pages?.reduce( + (allTransactions: any[], page: any) => + page.transactions + ? allTransactions.concat(page.transactions) + : allTransactions, + [], + ) || [] - if (currencySelected !== 'XRP') { - processedTransactions = processedTransactions?.filter( - (tx) => - !currencySelected || - (currencySelected && - JSON.stringify(tx).includes( - `"currency":"${currencySelected.toUpperCase()}"`, - )), - ) - } - return processedTransactions - } - - const transactions = filterTransactions() - const tryLoading = transactions?.length === 0 && data?.pages[0]?.transactions + const tryLoading = transactions.length === 0 && data?.pages[0]?.transactions const emptyMessage = tryLoading ? 'get_account_transactions_try' : error?.message return ( - fetchNextPage()} - hasAdditionalResults={hasNextPage} - /> +
    +
    +

    {t('transactions')}

    +
    + fetchNextPage()} + hasAdditionalResults={hasNextPage} + /> +
    ) } diff --git a/src/containers/Accounts/AccountTransactionTable/styles.scss b/src/containers/Accounts/AccountTransactionTable/styles.scss new file mode 100644 index 000000000..12c2cda10 --- /dev/null +++ b/src/containers/Accounts/AccountTransactionTable/styles.scss @@ -0,0 +1,13 @@ +@use '../../shared/css/variables' as *; + +.transactions-header { + @include bold; + + font-size: 20px; +} + +@media (max-width: $tablet-portrait-upper-boundary) { + .transactions-header { + font-size: 18px; + } +} diff --git a/src/containers/Accounts/AccountTransactionTable/test/AccountTransactionTable.test.tsx b/src/containers/Accounts/AccountTransactionTable/test/AccountTransactionTable.test.tsx index 71c57c766..9c8c5aa3a 100644 --- a/src/containers/Accounts/AccountTransactionTable/test/AccountTransactionTable.test.tsx +++ b/src/containers/Accounts/AccountTransactionTable/test/AccountTransactionTable.test.tsx @@ -22,7 +22,6 @@ describe('AccountTransactionsTable container', () => { const createWrapper = ( getAccountTransactionsImpl: () => Promise = () => new Promise(() => {}), - currencySelected: string = '', state = { hasToken: false }, ) => { ;(getAccountTransactions as Mock).mockImplementation( @@ -33,7 +32,6 @@ describe('AccountTransactionsTable container', () => { , ) @@ -85,7 +83,6 @@ describe('AccountTransactionsTable container', () => { await flushPromises() component.update() - expect(component.find('.load-more-btn')).not.toExist() expect(component.find('.transaction-table')).toExist() expect(component.find('.empty-transactions-message')).toHaveText( 'get_account_transactions_failed', @@ -94,28 +91,9 @@ describe('AccountTransactionsTable container', () => { component.unmount() }) - it('renders try loading more message when no filtered results show but there is a marker', async () => { - const component = createWrapper( - () => Promise.resolve(TEST_TRANSACTIONS_DATA), - 'EUR', - ) - - await flushPromises() - component.update() - - expect(component.find('.load-more-btn')).toExist() - expect(component.find('.transaction-table')).toExist() - expect(component.find('.empty-transactions-message')).toHaveText( - 'get_account_transactions_try', - ) - expect(component.find(Link).length).toBe(0) - component.unmount() - }) - it('renders dynamic content with transaction data and token column', async () => { const component = createWrapper( () => Promise.resolve(TEST_TRANSACTIONS_DATA), - undefined, { hasToken: true }, ) @@ -148,7 +126,6 @@ describe('AccountTransactionsTable container', () => { await flushPromises() component.update() - expect(component.find('.load-more-btn')).not.toExist() expect(component.find('.transaction-table')).toExist() expect(component.find('.empty-transactions-message')).toHaveText( 'get_account_transactions_failed', @@ -156,22 +133,4 @@ describe('AccountTransactionsTable container', () => { expect(component.find(Link).length).toBe(0) component.unmount() }) - - it('renders try loading more message when no filtered results show but there is a marker', async () => { - const component = createWrapper( - () => Promise.resolve(TEST_TRANSACTIONS_DATA), - 'EUR', - ) - - await flushPromises() - component.update() - - expect(component.find('.load-more-btn')).toExist() - expect(component.find('.transaction-table')).toExist() - expect(component.find('.empty-transactions-message')).toHaveText( - 'get_account_transactions_try', - ) - expect(component.find(Link).length).toBe(0) - component.unmount() - }) }) diff --git a/src/containers/Accounts/index.tsx b/src/containers/Accounts/index.tsx index 543598d64..b2c65fe78 100644 --- a/src/containers/Accounts/index.tsx +++ b/src/containers/Accounts/index.tsx @@ -1,26 +1,24 @@ -import { useContext, useEffect, useState } from 'react' +import { useContext, useEffect } from 'react' import { Helmet } from 'react-helmet-async' import { useQuery } from 'react-query' import { isValidClassicAddress, isValidXAddress } from 'ripple-address-codec' -import { AccountHeader } from './AccountHeader' import { AccountTransactionTable } from './AccountTransactionTable' import './styles.scss' import { useAnalytics } from '../shared/analytics' -import { Tabs } from '../shared/components/Tabs' -import { AccountAssetTab } from './AccountAssetTab/AccountAssetTab' -import { buildPath, useRouteParams } from '../shared/routing' +import { useRouteParams } from '../shared/routing' import { ACCOUNT_ROUTE } from '../App/routes' import { BAD_REQUEST } from '../shared/utils' import { getAccountState } from '../../rippled' import SocketContext from '../shared/SocketContext' import { Loader } from '../shared/components/Loader' +import { AccountSummary } from './AccountSummary' +import { useXRPToUSDRate } from '../shared/hooks/useXRPToUSDRate' +import AccountAsset from './AccountAsset' +import AccountHeader from './AccountHeader' export const Accounts = () => { const { trackScreenLoaded, trackException } = useAnalytics() - const { id: accountId = '', tab = 'transactions' } = - useRouteParams(ACCOUNT_ROUTE) - const [currencySelected, setCurrencySelected] = useState('XRP') - const mainPath = buildPath(ACCOUNT_ROUTE, { id: accountId }) + const { id: accountId = '' } = useRouteParams(ACCOUNT_ROUTE) const rippledSocket = useContext(SocketContext) const { data: account, isLoading } = useQuery( @@ -30,17 +28,13 @@ export const Accounts = () => { return Promise.reject(BAD_REQUEST) } - return getAccountState(accountId, rippledSocket).catch( - (transactionRequestError) => { - const status = transactionRequestError.code - trackException( - `ledger ${accountId} --- ${JSON.stringify( - transactionRequestError, - )}`, - ) - return Promise.reject(status) - }, - ) + return getAccountState(accountId, rippledSocket).catch((requestError) => { + const status = requestError.code + trackException( + `ledger ${accountId} --- ${JSON.stringify(requestError)}`, + ) + return Promise.reject(status) + }) }, ) @@ -50,9 +44,13 @@ export const Accounts = () => { return () => { window.scrollTo(0, 0) } - }, [tab, trackScreenLoaded]) + }, [trackScreenLoaded]) - const tabs = ['transactions', 'assets'] + const xrpToUSDRate = useXRPToUSDRate() + + const isAccountDeleted = account?.deleted === true + // Show account data only after account.info is loaded and account isn't deleted + const showAccount = !!account?.info && !isAccountDeleted return (
    @@ -60,20 +58,27 @@ export const Accounts = () => { {accountId && ( <> setCurrencySelected(currency)} - currencySelected={currencySelected} + account={account} /> - - {tab === 'transactions' && ( - + {showAccount && ( + <> + + + )} - {tab === 'assets' && account && } + + {/* Show account transactions regardless of account delete status */} + )} {isLoading && } diff --git a/src/containers/Accounts/test/index.test.tsx b/src/containers/Accounts/test/index.test.tsx index 555cff680..734981887 100644 --- a/src/containers/Accounts/test/index.test.tsx +++ b/src/containers/Accounts/test/index.test.tsx @@ -1,11 +1,9 @@ -import { mount } from 'enzyme' +import { render, screen, waitFor } from '@testing-library/react' import { Route } from 'react-router' import i18n from '../../../i18n/testConfig' import { Accounts } from '../index' -import { AccountHeader } from '../AccountHeader' -import { AccountTransactionTable } from '../AccountTransactionTable' import mockAccountState from './mockAccountState.json' -import { QuickHarness, flushPromises } from '../../test/utils' +import { QuickHarness } from '../../test/utils' import { ACCOUNT_ROUTE } from '../../App/routes' import { getAccountState } from '../../../rippled' import Mock = jest.Mock @@ -16,13 +14,37 @@ jest.mock('../../../rippled', () => ({ getAccountTransactions: jest.fn(() => []), })) +jest.mock('../AccountHeader', () => ({ + __esModule: true, + default: () =>
    Account Header
    , +})) + +jest.mock('../AccountSummary', () => ({ + __esModule: true, + AccountSummary: () => ( +
    Account Summary
    + ), +})) + +jest.mock('../AccountAsset', () => ({ + __esModule: true, + default: () =>
    Account Asset
    , +})) + +jest.mock('../AccountTransactionTable', () => ({ + __esModule: true, + AccountTransactionTable: () => ( +
    Account Transaction Table
    + ), +})) + const mockedGetAccountState = getAccountState as Mock describe('Account container', () => { const TEST_ACCOUNT_ID = 'rncKvRcdDq9hVJpdLdTcKoxsS3NSkXsvfM' - const createWrapper = () => - mount( + const renderComponent = () => + render( { ) beforeEach(() => { - jest.resetModules() + jest.clearAllMocks() }) it('renders without crashing', () => { - const wrapper = createWrapper() - wrapper.unmount() + const { unmount } = renderComponent() + unmount() }) it('renders static parts', async () => { @@ -45,13 +67,15 @@ describe('Account container', () => { Promise.resolve(mockAccountState), ) - const wrapper = createWrapper() - await flushPromises() - wrapper.update() + renderComponent() - expect(wrapper.find(AccountHeader).length).toBe(1) - expect(wrapper.find(AccountTransactionTable).length).toBe(1) - wrapper.find('.balance-selector button').simulate('click') - wrapper.unmount() + await waitFor(() => { + expect(screen.getByTestId('account-header')).toBeInTheDocument() + expect(screen.getByTestId('account-summary')).toBeInTheDocument() + expect(screen.getByTestId('account-asset')).toBeInTheDocument() + expect( + screen.getByTestId('account-transaction-table'), + ).toBeInTheDocument() + }) }) }) diff --git a/src/containers/App/test/App.test.jsx b/src/containers/App/test/App.test.jsx index 1525bc321..dc3888625 100644 --- a/src/containers/App/test/App.test.jsx +++ b/src/containers/App/test/App.test.jsx @@ -333,7 +333,7 @@ describe('App container', () => { network: 'mainnet', }, ]) - expect(mockGetAccountInfo).toBeCalledWith( + expect(mockGetAccountInfo).toHaveBeenCalledWith( expect.anything(), 'r35jYntLwkrbc3edisgavDbEdNRSKgcQE6', ) @@ -354,7 +354,7 @@ describe('App container', () => { network: 'mainnet', }, ]) - expect(mockGetAccountInfo).toBeCalledWith( + expect(mockGetAccountInfo).toHaveBeenCalledWith( expect.anything(), 'rKV8HEL3vLc6q9waTiJcewdRdSFyx67QFb', ) diff --git a/src/containers/MPT/MPTHeader/MPTHeader.tsx b/src/containers/MPT/MPTHeader/MPTHeader.tsx index 354268991..972f50674 100644 --- a/src/containers/MPT/MPTHeader/MPTHeader.tsx +++ b/src/containers/MPT/MPTHeader/MPTHeader.tsx @@ -9,7 +9,7 @@ import { BAD_REQUEST, HASH192_REGEX } from '../../shared/utils' import { Account } from '../../shared/components/Account' import { useAnalytics } from '../../shared/analytics' import { getMPTIssuance } from '../../../rippled/lib/rippled' -import { formatMPTIssuanceInfo } from '../../../rippled/lib/utils' +import { formatMPTIssuance } from '../../../rippled/lib/utils' import { MPTIssuanceFormattedInfo } from '../../shared/Interfaces' import { Details } from './Details' import { Settings } from './Settings' @@ -30,7 +30,7 @@ export const MPTHeader = (props: Props) => { ['getMPTIssuance', tokenId], async () => { const info = await getMPTIssuance(rippledSocket, tokenId) - return formatMPTIssuanceInfo(info) + return formatMPTIssuance(info.node) }, { onError: (e: any) => { diff --git a/src/containers/Token/TokenHeader/index.tsx b/src/containers/Token/TokenHeader/index.tsx index c22ba9e7c..725be2729 100644 --- a/src/containers/Token/TokenHeader/index.tsx +++ b/src/containers/Token/TokenHeader/index.tsx @@ -45,9 +45,7 @@ export const TokenHeader = ({ value={} /> )} - {rate && ( - - )} + {previousLedger && ( { + if (value === 0) { + return '--' + } + + let options + if (value >= USD_REGULAR_BALANCE_LOWER_BOUND) { + options = USD_CURRENCY_OPTIONS + } else if (value >= USD_SMALL_BALANCE_LOWER_BOUND) { + options = USD_SMALL_BALANCE_CURRENCY_OPTIONS + } else { + options = USD_EXTRA_SMALL_BALANCE_CURRENCY_OPTIONS + } + + return localizeNumber(value, lang, options) || '--' +} + +/** + * Formats token balances with conditional precision based on value + * @param balance - The token balance to format + * @param lang - Language for localization + * @returns Formatted balance string + */ +export const formatTokenBalance = (balance: number, lang: string): string => { + const options = + balance > TOKEN_BALANCE_LARGE_LOWER_BOUND + ? NUMBER_DEFAULT_OPTIONS + : NUMBER_SMALL_OPTIONS + return localizeNumber(balance, lang, options) || '0' +} + +/** + * Three-step calculation for USD balance to ensure mathematical consistency + * Formats USD price, formats token balance, then calculates USD balance using displayed values + * @param tokenBalance - The token balance + * @param priceInUSD - Price in USD + * @param lang - Language for localization + * @returns Object with formatted USD price, token balance, and calculated USD balance + */ +export const calculateFormattedUsdBalance = ( + tokenBalance: number, + priceInUSD: number, + lang: string, +): { + formattedUsdPrice: string + formattedBalance: string + formattedBalanceUsd: string +} => { + let formattedUsdPrice = '--' + let formattedBalance = '--' + let formattedBalanceUsd = '--' + + if (priceInUSD !== 0) { + // Step 1: Format USD Price + formattedUsdPrice = formatUsdValue(priceInUSD, lang) + const displayedUsdPrice = parseFloat( + (formattedUsdPrice || '0').replace(/[$,]/g, ''), // Removes dollar signs and commas from USD prices like "$4,321.30" → "4321.30" + ) + + // Step 2: Format Balance + formattedBalance = formatTokenBalance(tokenBalance, lang) + const displayedBalance = parseFloat( + (formattedBalance || '0').replace(/[,]/g, ''), // Removes commas from token balances like "1,234.5678" → "1234.5678" + ) + + // Step 3: Calculate USD Balance using displayed values + const calculatedBalanceUSD = displayedUsdPrice * displayedBalance + formattedBalanceUsd = formatUsdValue(calculatedBalanceUSD, lang) + } else { + // If no price, still format the balance + formattedBalance = formatTokenBalance(tokenBalance, lang) + } + + return { + formattedUsdPrice, + formattedBalance, + formattedBalanceUsd, + } +} diff --git a/src/containers/shared/components/Account.tsx b/src/containers/shared/components/Account.tsx index c27f17ec3..cafaa5925 100644 --- a/src/containers/shared/components/Account.tsx +++ b/src/containers/shared/components/Account.tsx @@ -3,12 +3,13 @@ import { ACCOUNT_ROUTE } from '../../App/routes' export interface AccountProps { account: string + displayText?: string link?: boolean tag?: number } export const Account = (props: AccountProps) => { - const { account, link = true, tag } = props + const { account, displayText, link = true, tag } = props const parts = account.split(':') const computedTag = tag || parts[1] @@ -22,11 +23,11 @@ export const Account = (props: AccountProps) => { to={ACCOUNT_ROUTE} params={{ id: parts[0] }} > - {parts[0]} + {displayText || parts[0]} ) : ( - {parts[0]} + {displayText || parts[0]} )} {computedTag && ( diff --git a/src/containers/shared/components/Amount.tsx b/src/containers/shared/components/Amount.tsx index 8c7607ca0..aaec18ce1 100644 --- a/src/containers/shared/components/Amount.tsx +++ b/src/containers/shared/components/Amount.tsx @@ -7,7 +7,7 @@ import Currency from './Currency' import { ExplorerAmount } from '../types' import { MPTIssuanceFormattedInfo } from '../Interfaces' import { getMPTIssuance } from '../../../rippled/lib/rippled' -import { formatMPTIssuanceInfo } from '../../../rippled/lib/utils' +import { formatMPTIssuance } from '../../../rippled/lib/utils' import SocketContext from '../SocketContext' import { useAnalytics } from '../analytics' @@ -57,7 +57,7 @@ export const Amount = ({ ['getMPTIssuanceScale', mptID], async () => { const info = await getMPTIssuance(rippledSocket, mptID) - return formatMPTIssuanceInfo(info) + return formatMPTIssuance(info.node) }, { onError: (e: any) => { diff --git a/src/containers/shared/components/Currency.tsx b/src/containers/shared/components/Currency.tsx index cd959538e..3c5ccbdea 100644 --- a/src/containers/shared/components/Currency.tsx +++ b/src/containers/shared/components/Currency.tsx @@ -4,7 +4,8 @@ import { TOKEN_ROUTE, MPT_ROUTE } from '../../App/routes' // https://xrpl.org/currency-formats.html#nonstandard-currency-codes const NON_STANDARD_CODE_LENGTH = 40 const XRP = 'XRP' -const LP_TOKEN_IDENTIFIER = '03' +// https://xrpl.org/docs/concepts/tokens/decentralized-exchange/automated-market-makers#lp-token-currency-codes +export const LP_TOKEN_IDENTIFIER = '03' export interface Props { issuer?: string diff --git a/src/containers/shared/components/DomainLink.tsx b/src/containers/shared/components/DomainLink.tsx index 4afe8e831..a79d25d30 100644 --- a/src/containers/shared/components/DomainLink.tsx +++ b/src/containers/shared/components/DomainLink.tsx @@ -5,6 +5,7 @@ export interface Props { className?: string decode?: boolean domain: string + displayDomain?: string keepProtocol?: boolean } @@ -13,7 +14,13 @@ const PROTOCOL_REGEX = /^([a-z][a-z0-9+\-.]*):\/\// const PROTOCOL_REMOVAL_REGEX = /^(https?:\/\/)?(.*?)(\/)?$/ const DomainLink = (props: Props) => { - const { className, decode = false, domain, keepProtocol = true } = props + const { + className, + decode = false, + domain, + displayDomain = '', + keepProtocol = true, + } = props // If decode is true, decode the domain const decodedDomain = decode ? decodeHex(domain) : domain @@ -40,7 +47,7 @@ const DomainLink = (props: Props) => { href={href} onClick={(event) => event.stopPropagation()} > - {domainText} + {displayDomain || domainText} ) } diff --git a/src/containers/shared/components/NFTokenLink.tsx b/src/containers/shared/components/NFTokenLink.tsx index e22bdcdbc..336be0601 100644 --- a/src/containers/shared/components/NFTokenLink.tsx +++ b/src/containers/shared/components/NFTokenLink.tsx @@ -3,10 +3,11 @@ import { NFT_ROUTE } from '../../App/routes' export interface NFTokenLinkProps { tokenID: string + shortTokenID?: string } -export const NFTokenLink = ({ tokenID }: NFTokenLinkProps) => ( +export const NFTokenLink = ({ tokenID, shortTokenID }: NFTokenLinkProps) => ( - {tokenID} + {shortTokenID || tokenID} ) diff --git a/src/containers/shared/components/PaginatedTable/index.tsx b/src/containers/shared/components/PaginatedTable/index.tsx new file mode 100644 index 000000000..99311af03 --- /dev/null +++ b/src/containers/shared/components/PaginatedTable/index.tsx @@ -0,0 +1,140 @@ +import './styles.scss' +import { useState, useMemo, ReactNode } from 'react' + +function getPageItems(current: number, total: number) { + const pages: (number | 'dots')[] = [] + const push = (v: number | 'dots') => pages.push(v) + const addRange = (s: number, e: number) => { + for (let i = s; i <= e; i += 1) { + push(i) + } + } + + if (total <= 7) { + addRange(1, total) + return pages + } + + if (current <= 4) { + addRange(1, 5) + push('dots') + push(total) + return pages + } + if (current >= total - 3) { + push(1) + push('dots') + addRange(total - 4, total) + return pages + } + + push(1) + push('dots') + addRange(current - 2, current + 2) + push('dots') + push(total) + return pages +} + +export interface PaginatedTableProps { + data: any[] + pageSize?: number + tableStructure: (paginatedData: any[]) => ReactNode +} + +export function PaginatedTable({ + data, + pageSize = 10, + tableStructure, +}: PaginatedTableProps) { + const [currentPage, setCurrentPage] = useState(1) + + const totalPages = Math.max(1, Math.ceil(data.length / pageSize)) + + // Calculate paginated data + const paginatedData = useMemo(() => { + const start = (currentPage - 1) * pageSize + return data.slice(start, start + pageSize) + }, [currentPage, data, pageSize]) + + const items = getPageItems(currentPage, totalPages) + const prevDisabled = currentPage === 1 + const nextDisabled = currentPage === totalPages + + const pagination = + totalPages > 1 ? ( +
    + + + + + {items.map((it, index) => + it === 'dots' ? ( + + … + + ) : ( + + ), + )} + + + + +
    + ) : null + + return ( +
    + {tableStructure(paginatedData)} + {pagination} +
    + ) +} diff --git a/src/containers/shared/components/PaginatedTable/styles.scss b/src/containers/shared/components/PaginatedTable/styles.scss new file mode 100644 index 000000000..f7f39c048 --- /dev/null +++ b/src/containers/shared/components/PaginatedTable/styles.scss @@ -0,0 +1,58 @@ +@use '../../css/variables' as *; + +.table-pagination { + position: relative; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + margin: 12px 0 0; + gap: 3px; +} + +/* Page numbers + chevrons */ +.table-pg-btn { + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: $black-50; + cursor: pointer; + font-size: 14px; +} + +.table-pg-btn:hover { + background: rgb(255 255 255 / 3%); + color: $black-0; +} + +.table-pg-btn.is-active { + background: rgb(22 163 74 / 6%); + color: $green-30; +} + +/* Larger green chevrons for first/prev/next/last */ +.table-pg-btn[aria-label="First page"], +.table-pg-btn[aria-label="Previous page"], +.table-pg-btn[aria-label="Next page"], +.table-pg-btn[aria-label="Last page"] { + padding: 0 5px; + color: $green-30; + font-size: 25px; +} + +/* Disabled state */ +.table-pg-btn:disabled { + background: transparent; + color: $black-50; + cursor: not-allowed; + opacity: 0.35; +} + +/* Ellipsis */ +.table-pg-ellipsis { + padding: 0 6px; + color: $black-50; + font-size: 15px; +} diff --git a/src/containers/shared/components/PaginatedTable/test/PaginatedTable.test.tsx b/src/containers/shared/components/PaginatedTable/test/PaginatedTable.test.tsx new file mode 100644 index 000000000..b75a102be --- /dev/null +++ b/src/containers/shared/components/PaginatedTable/test/PaginatedTable.test.tsx @@ -0,0 +1,325 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { PaginatedTable } from '../index' + +describe('PaginatedTable', () => { + const mockTableStructure = (data: any[]) => ( + + + {data.map((item) => ( + + + + ))} + +
    {item.name}
    + ) + + const generateMockData = (count: number) => + Array.from({ length: count }, (_, i) => ({ name: `Item ${i + 1}` })) + + describe('Rendering', () => { + it('renders without crashing with empty data', () => { + render() + expect(screen.queryByRole('navigation')).not.toBeInTheDocument() + }) + + it('renders table with data', () => { + const data = generateMockData(5) + render() + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.getByText('Item 5')).toBeInTheDocument() + }) + + it('does not render pagination when data fits in one page', () => { + const data = generateMockData(5) + render( + , + ) + expect(screen.queryByRole('navigation')).not.toBeInTheDocument() + }) + + it('renders pagination when data exceeds page size', () => { + const data = generateMockData(15) + render( + , + ) + expect( + screen.getByRole('navigation', { name: 'Table pagination' }), + ).toBeInTheDocument() + }) + }) + + describe('Pagination controls', () => { + it('renders all pagination buttons', () => { + const data = generateMockData(15) + render( + , + ) + expect(screen.getByLabelText('First page')).toBeInTheDocument() + expect(screen.getByLabelText('Previous page')).toBeInTheDocument() + expect(screen.getByLabelText('Next page')).toBeInTheDocument() + expect(screen.getByLabelText('Last page')).toBeInTheDocument() + }) + + it('disables prev/first buttons on first page', () => { + const data = generateMockData(15) + render( + , + ) + expect(screen.getByLabelText('First page')).toBeDisabled() + expect(screen.getByLabelText('Previous page')).toBeDisabled() + expect(screen.getByLabelText('Next page')).not.toBeDisabled() + expect(screen.getByLabelText('Last page')).not.toBeDisabled() + }) + + it('disables next/last buttons on last page', () => { + const data = generateMockData(15) + render( + , + ) + const lastPageButton = screen.getByLabelText('Last page') + fireEvent.click(lastPageButton) + + expect(screen.getByLabelText('First page')).not.toBeDisabled() + expect(screen.getByLabelText('Previous page')).not.toBeDisabled() + expect(screen.getByLabelText('Next page')).toBeDisabled() + expect(screen.getByLabelText('Last page')).toBeDisabled() + }) + + it('marks current page as active', () => { + const data = generateMockData(15) + render( + , + ) + const page1Button = screen.getByRole('button', { + name: '1', + current: 'page', + }) + expect(page1Button).toHaveClass('is-active') + }) + }) + + describe('Navigation', () => { + it('navigates to next page', () => { + const data = generateMockData(25) + render( + , + ) + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.queryByText('Item 11')).not.toBeInTheDocument() + + fireEvent.click(screen.getByLabelText('Next page')) + + expect(screen.queryByText('Item 1')).not.toBeInTheDocument() + expect(screen.getByText('Item 11')).toBeInTheDocument() + }) + + it('navigates to previous page', () => { + const data = generateMockData(25) + render( + , + ) + fireEvent.click(screen.getByLabelText('Next page')) + fireEvent.click(screen.getByLabelText('Previous page')) + + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.queryByText('Item 11')).not.toBeInTheDocument() + }) + + it('navigates to first page', () => { + const data = generateMockData(25) + render( + , + ) + fireEvent.click(screen.getByLabelText('Last page')) + fireEvent.click(screen.getByLabelText('First page')) + + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.queryByText('Item 21')).not.toBeInTheDocument() + }) + + it('navigates to last page', () => { + const data = generateMockData(25) + render( + , + ) + fireEvent.click(screen.getByLabelText('Last page')) + + expect(screen.queryByText('Item 1')).not.toBeInTheDocument() + expect(screen.getByText('Item 21')).toBeInTheDocument() + expect(screen.getByText('Item 25')).toBeInTheDocument() + }) + + it('navigates to specific page by clicking page number', () => { + const data = generateMockData(30) + render( + , + ) + const page2Button = screen.getByRole('button', { name: '2' }) + fireEvent.click(page2Button) + + expect(screen.queryByText('Item 1')).not.toBeInTheDocument() + expect(screen.getByText('Item 11')).toBeInTheDocument() + expect(screen.getByText('Item 20')).toBeInTheDocument() + }) + }) + + describe('Page items display', () => { + it('shows all pages when total pages <= 7', () => { + const data = generateMockData(70) + render( + , + ) + expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '7' })).toBeInTheDocument() + expect(screen.queryByText('…')).not.toBeInTheDocument() + }) + + it('shows ellipsis at end when current page <= 4', () => { + const data = generateMockData(100) + render( + , + ) + expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '5' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '10' })).toBeInTheDocument() + expect(screen.getAllByText('…')).toHaveLength(1) + }) + + it('shows ellipsis at start when current page >= total - 3', () => { + const data = generateMockData(100) + render( + , + ) + fireEvent.click(screen.getByLabelText('Last page')) + + expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '6' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '10' })).toBeInTheDocument() + expect(screen.getAllByText('…')).toHaveLength(1) + }) + + it('shows ellipsis at both ends when in middle pages', () => { + const data = generateMockData(100) + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: '5' })) + + expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '3' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '5' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '7' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '10' })).toBeInTheDocument() + expect(screen.getAllByText('…')).toHaveLength(2) + }) + }) + + describe('Data pagination', () => { + it('displays correct data for each page', () => { + const data = generateMockData(25) + render( + , + ) + + // Page 1: Items 1-10 + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.getByText('Item 10')).toBeInTheDocument() + expect(screen.queryByText('Item 11')).not.toBeInTheDocument() + + fireEvent.click(screen.getByLabelText('Next page')) + + // Page 2: Items 11-20 + expect(screen.queryByText('Item 10')).not.toBeInTheDocument() + expect(screen.getByText('Item 11')).toBeInTheDocument() + expect(screen.getByText('Item 20')).toBeInTheDocument() + expect(screen.queryByText('Item 21')).not.toBeInTheDocument() + + fireEvent.click(screen.getByLabelText('Next page')) + + // Page 3: Items 21-25 + expect(screen.queryByText('Item 20')).not.toBeInTheDocument() + expect(screen.getByText('Item 21')).toBeInTheDocument() + expect(screen.getByText('Item 25')).toBeInTheDocument() + }) + + it('uses custom page size', () => { + const data = generateMockData(15) + render( + , + ) + + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.getByText('Item 5')).toBeInTheDocument() + expect(screen.queryByText('Item 6')).not.toBeInTheDocument() + }) + }) +}) diff --git a/src/containers/shared/components/test/Account.test.tsx b/src/containers/shared/components/test/Account.test.tsx index 958463089..2b7dc1d35 100644 --- a/src/containers/shared/components/test/Account.test.tsx +++ b/src/containers/shared/components/test/Account.test.tsx @@ -72,4 +72,29 @@ describe('Account', () => { expect(element).toHaveAttribute('title', ACCOUNT) expect(screen.queryByTestId('dt')).toHaveTextContent(':123') }) + + it('should render with displayText', () => { + renderComponent( + , + ) + const element = screen.getByTestId('account') + expect(element).toHaveClass('account') + expect(element).toHaveTextContent('Custom Display Name') + expect(element).toHaveAttribute('href', `/accounts/${ACCOUNT}`) + expect(element).toHaveAttribute('title', ACCOUNT) + }) + + it('should render with displayText and no link', () => { + renderComponent( + , + ) + const element = screen.getByTestId('account') + expect(element).toHaveTextContent('Custom Display Name') + expect(element).not.toHaveAttribute('href') + expect(element).toHaveAttribute('title', ACCOUNT) + }) }) diff --git a/src/containers/shared/components/test/DomainLink.test.tsx b/src/containers/shared/components/test/DomainLink.test.tsx index 57e83ad77..2f2993fb1 100644 --- a/src/containers/shared/components/test/DomainLink.test.tsx +++ b/src/containers/shared/components/test/DomainLink.test.tsx @@ -1,72 +1,82 @@ -import { mount } from 'enzyme' +import { render, screen } from '@testing-library/react' import DomainLink from '../DomainLink' describe('DomainLink', () => { it('handles domain link with only domain parameter', () => { - const wrapper = mount() - expect(wrapper.find('a').props().className).toEqual('domain') - expect(wrapper.find('a').text()).toEqual('bithomp.com') - expect(wrapper.find('a').props().href).toEqual('https://bithomp.com') - wrapper.unmount() + render() + const link = screen.getByRole('link') + expect(link).toHaveClass('domain') + expect(link).toHaveTextContent('bithomp.com') + expect(link).toHaveAttribute('href', 'https://bithomp.com') }) it('handles domain link with decoded domain parameter', () => { - const wrapper = mount( - , - ) - expect(wrapper.find('a').props().className).toEqual('domain') - expect(wrapper.find('a').text()).toEqual('sologenic.com') - expect(wrapper.find('a').props().href).toEqual('https://sologenic.com') - wrapper.unmount() + render() + const link = screen.getByRole('link') + expect(link).toHaveClass('domain') + expect(link).toHaveTextContent('sologenic.com') + expect(link).toHaveAttribute('href', 'https://sologenic.com') }) it('handles domain link with domain parameter and classname', () => { - const wrapper = mount() - expect(wrapper.find('a').props().className).toEqual('domain test') - expect(wrapper.find('a').text()).toEqual('bithomp.com') - expect(wrapper.find('a').props().href).toEqual('https://bithomp.com') - wrapper.unmount() + render() + const link = screen.getByRole('link') + expect(link).toHaveClass('domain test') + expect(link).toHaveTextContent('bithomp.com') + expect(link).toHaveAttribute('href', 'https://bithomp.com') }) it('handles domain link with decoded domain parameter and classname', () => { - const wrapper = mount( + render( , ) - expect(wrapper.find('a').props().className).toEqual('domain test') - expect(wrapper.find('a').text()).toEqual('sologenic.com') - expect(wrapper.find('a').props().href).toEqual('https://sologenic.com') - wrapper.unmount() + const link = screen.getByRole('link') + expect(link).toHaveClass('domain test') + expect(link).toHaveTextContent('sologenic.com') + expect(link).toHaveAttribute('href', 'https://sologenic.com') }) it('handles domain link with domain provided in HEX-encoded format', () => { const url = 'https://example.com' const urlInHex = '68747470733A2F2F6578616D706C652E636F6D' - const wrapper = mount() - expect(wrapper.find('a').props().className).toEqual('domain') - expect(wrapper.find('a').text()).toEqual(url) - expect(wrapper.find('a').props().href).toEqual(url) - wrapper.unmount() + render() + const link = screen.getByRole('link') + expect(link).toHaveClass('domain') + expect(link).toHaveTextContent(url) + expect(link).toHaveAttribute('href', url) }) it('handles IPFS domain link', () => { - const wrapper = mount() - expect(wrapper.find('a').props().className).toEqual('domain') - expect(wrapper.find('a').text()).toEqual('ipfs://random/metadata.json') - expect(wrapper.find('a').props().href).toEqual( + render() + const link = screen.getByRole('link') + expect(link).toHaveClass('domain') + expect(link).toHaveTextContent('ipfs://random/metadata.json') + expect(link).toHaveAttribute( + 'href', 'https://ipfs.io/ipfs/random/metadata.json', ) - wrapper.unmount() }) it('handles domain link with protocol removal', () => { const url = 'https://example.com/' - const wrapper = mount() - expect(wrapper.find('a').props().className).toEqual('domain') - expect(wrapper.find('a').text()).toEqual('example.com') - expect(wrapper.find('a').props().href).toEqual('https://example.com/') + render() + const link = screen.getByRole('link') + expect(link).toHaveClass('domain') + expect(link).toHaveTextContent('example.com') + expect(link).toHaveAttribute('href', 'https://example.com/') + }) + + it('handles domain link with displayDomain', () => { + render( + , + ) + const link = screen.getByRole('link') + expect(link).toHaveClass('domain') + expect(link).toHaveTextContent('Custom Display Text') + expect(link).toHaveAttribute('href', 'https://bithomp.com') }) }) diff --git a/src/containers/shared/components/test/NFTokenLink.test.tsx b/src/containers/shared/components/test/NFTokenLink.test.tsx new file mode 100644 index 000000000..aa9985318 --- /dev/null +++ b/src/containers/shared/components/test/NFTokenLink.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react' +import { BrowserRouter } from 'react-router-dom' +import { NFTokenLink } from '../NFTokenLink' + +const renderComponent = (component: JSX.Element) => + render({component}) + +const TOKEN_ID = + '000827103B94ECBB7BF0A0A6ED62B3607801A27B65F4B11F5E1D5E8A3F3D8E9A' + +describe('NFTokenLink', () => { + it('should render with full tokenID', () => { + renderComponent() + const link = screen.getByRole('link') + expect(link).toHaveTextContent(TOKEN_ID) + expect(link).toHaveAttribute('href', `/nft/${TOKEN_ID}`) + expect(link).toHaveAttribute('title', TOKEN_ID) + }) + + it('should render with shortTokenID when provided', () => { + const shortID = '000827...3D8E9A' + renderComponent() + const link = screen.getByRole('link') + expect(link).toHaveTextContent(shortID) + expect(link).toHaveAttribute('href', `/nft/${TOKEN_ID}`) + expect(link).toHaveAttribute('title', TOKEN_ID) + }) + + it('should use full tokenID when shortTokenID is empty string', () => { + renderComponent() + const link = screen.getByRole('link') + expect(link).toHaveTextContent(TOKEN_ID) + expect(link).toHaveAttribute('href', `/nft/${TOKEN_ID}`) + expect(link).toHaveAttribute('title', TOKEN_ID) + }) +}) diff --git a/src/containers/shared/hooks/test/useXRPToUSDRate.test.tsx b/src/containers/shared/hooks/test/useXRPToUSDRate.test.tsx new file mode 100644 index 000000000..dd7f4719d --- /dev/null +++ b/src/containers/shared/hooks/test/useXRPToUSDRate.test.tsx @@ -0,0 +1,73 @@ +import { renderHook } from '@testing-library/react-hooks' +import { waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from 'react-query' +import { useXRPToUSDRate } from '../useXRPToUSDRate' +import SocketContext from '../../SocketContext' +import * as rippled from '../../../../rippled/lib/rippled' +import Log from '../../log' + +jest.mock('../../../../rippled/lib/rippled') +jest.mock('../../log') + +const mockedGetAccountLines = rippled.getAccountLines as jest.Mock + +const createWrapper = + (socket: any = {}, queryClient = new QueryClient()) => + ({ children }: { children: React.ReactNode }) => ( + + {children} + + ) + +describe('useXRPToUSDRate', () => { + beforeEach(() => { + jest.clearAllMocks() + process.env.VITE_ENVIRONMENT = 'mainnet' + }) + + it('returns 0.0 if no lines available', async () => { + mockedGetAccountLines.mockResolvedValue({ lines: [] }) + const { result } = renderHook(() => useXRPToUSDRate(), { + wrapper: createWrapper(), + }) + await waitFor(() => expect(result.current).toBe(0.0)) + }) + + it('returns 1.5 if not mainnet', () => { + process.env.VITE_ENVIRONMENT = 'dev' + const { result } = renderHook(() => useXRPToUSDRate(), { + wrapper: createWrapper(), + }) + expect(result.current).toBe(1.5) + }) + + it('fetches XRP/USD rate if mainnet', async () => { + mockedGetAccountLines.mockResolvedValue({ lines: [{ limit: 2.3 }] }) + const { result } = renderHook(() => useXRPToUSDRate(), { + wrapper: createWrapper({}), + }) + await waitFor(() => expect(result.current).toBe(2.3)) + }) + + it('falls back to last successful rate on error', async () => { + mockedGetAccountLines + .mockResolvedValueOnce({ lines: [{ limit: 2.1 }] }) + .mockRejectedValueOnce(new Error('network error')) + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: 0 } }, + }) + const { result } = renderHook(() => useXRPToUSDRate(), { + wrapper: createWrapper({}, queryClient), + }) + + // First fetch succeeds + await waitFor(() => expect(result.current).toBe(2.1)) + + // Force useQuery to refetch + queryClient.invalidateQueries('XRPToUSDRate') + + await waitFor(() => expect(result.current).toBe(2.1)) + expect(Log.error).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/containers/shared/hooks/useXRPToUSDRate.ts b/src/containers/shared/hooks/useXRPToUSDRate.ts new file mode 100644 index 000000000..0edfe3117 --- /dev/null +++ b/src/containers/shared/hooks/useXRPToUSDRate.ts @@ -0,0 +1,46 @@ +import { useContext, useState } from 'react' +import { useQuery } from 'react-query' +import SocketContext from '../SocketContext' +import { getAccountLines } from '../../../rippled/lib/rippled' +import Log from '../log' + +const FETCH_INTERVAL_MILLIS = 5 * 1000 // 1 minute +const XRP_USD_ORACLE_ACCOUNT = 'rXUMMaPpZqPutoRszR29jtC8amWq3APkx' + +const fetchXRPToUSDRate = async (rippledSocket: any) => { + const accountLines = await getAccountLines( + rippledSocket, + XRP_USD_ORACLE_ACCOUNT, + 1, + ) + + return accountLines.lines[0]?.limit ?? 0.0 +} + +/** + * Returns the current exchange rate for XRP to USD. + * Retries {@link FETCH_RETRY_COUNT} times on failure and falls back to the last successful value if fetching fails. + */ +export function useXRPToUSDRate(): number { + const isMainnet = process.env.VITE_ENVIRONMENT === 'mainnet' + + const rippledSocket = useContext(SocketContext) + const [lastRate, setLastRate] = useState(0.0) + + const { data } = useQuery( + ['XRPToUSDRate'], + () => fetchXRPToUSDRate(rippledSocket), + { + enabled: isMainnet, + refetchInterval: FETCH_INTERVAL_MILLIS, + onSuccess: (rate: number) => setLastRate(rate), // store the last successfully fetched rate + onError: (error) => Log.error(error), // do nothing, last rate stays + }, + ) + + if (!isMainnet) { + return 1.5 // This is chosen randomly for non-mainnet environments + } + + return data ?? lastRate +} diff --git a/src/containers/shared/images/clock-icon.svg b/src/containers/shared/images/clock-icon.svg new file mode 100644 index 000000000..2ca85f9a0 --- /dev/null +++ b/src/containers/shared/images/clock-icon.svg @@ -0,0 +1,39 @@ + + + + + + + + + diff --git a/src/containers/shared/images/default_token_icon.svg b/src/containers/shared/images/default_token_icon.svg new file mode 100644 index 000000000..2008239c3 --- /dev/null +++ b/src/containers/shared/images/default_token_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/containers/shared/images/info-duotone.svg b/src/containers/shared/images/info-duotone.svg new file mode 100644 index 000000000..bee2e99b7 --- /dev/null +++ b/src/containers/shared/images/info-duotone.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/containers/shared/images/usd_icon.svg b/src/containers/shared/images/usd_icon.svg new file mode 100644 index 000000000..45aa5e424 --- /dev/null +++ b/src/containers/shared/images/usd_icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/containers/shared/images/xrp_balance_icon.svg b/src/containers/shared/images/xrp_balance_icon.svg new file mode 100644 index 000000000..8743414d5 --- /dev/null +++ b/src/containers/shared/images/xrp_balance_icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/containers/shared/images/xrp_reserve_balance_icon.svg b/src/containers/shared/images/xrp_reserve_balance_icon.svg new file mode 100644 index 000000000..435082dba --- /dev/null +++ b/src/containers/shared/images/xrp_reserve_balance_icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/containers/shared/test/NumberFormattingUtils.test.ts b/src/containers/shared/test/NumberFormattingUtils.test.ts new file mode 100644 index 000000000..d282e8532 --- /dev/null +++ b/src/containers/shared/test/NumberFormattingUtils.test.ts @@ -0,0 +1,178 @@ +import { + formatUsdValue, + formatTokenBalance, + calculateFormattedUsdBalance, +} from '../NumberFormattingUtils' + +describe('NumberFormattingUtils', () => { + const lang = 'en-US' + + describe('formatUsdValue', () => { + it('returns "--" for zero value', () => { + expect(formatUsdValue(0, lang)).toBe('--') + }) + + it('formats regular USD value (>= $1) with 2 decimals', () => { + expect(formatUsdValue(1, lang)).toBe('$1.00') + expect(formatUsdValue(100, lang)).toBe('$100.00') + expect(formatUsdValue(23.565, lang)).toBe('$23.57') + expect(formatUsdValue(5678.9, lang)).toBe('$5,678.90') + }) + + it('formats small USD value (>= $0.0001, < $1) with 4 decimals', () => { + expect(formatUsdValue(0.0001, lang)).toBe('$0.0001') + expect(formatUsdValue(0.00014, lang)).toBe('$0.0001') + expect(formatUsdValue(0.00015, lang)).toBe('$0.0002') + expect(formatUsdValue(0.25, lang)).toBe('$0.25') + expect(formatUsdValue(0.5, lang)).toBe('$0.50') + expect(formatUsdValue(0.9999, lang)).toBe('$0.9999') + expect(formatUsdValue(0.99995, lang)).toBe('$1.00') + }) + + it('formats extra small USD value (< $0.0001) with up to 10 decimals', () => { + expect(formatUsdValue(0.00000000051, lang)).toBe('$0.0000000005') + expect(formatUsdValue(0.000001, lang)).toBe('$0.000001') + expect(formatUsdValue(0.0000051, lang)).toBe('$0.0000051') + expect(formatUsdValue(0.000014, lang)).toBe('$0.000014') + expect(formatUsdValue(0.00005, lang)).toBe('$0.00005') + }) + + it('handles negative values', () => { + expect(formatUsdValue(-100, lang)).toBe('-$100.00') + expect(formatUsdValue(-0.0001, lang)).toBe('-$0.0001') + expect(formatUsdValue(-200, lang)).toBe('-$200.00') + expect(formatUsdValue(-0.0005, lang)).toBe('-$0.0005') + }) + }) + + describe('formatTokenBalance', () => { + it('formats large token balance (> 999) with 2 decimals', () => { + expect(formatTokenBalance(1000, lang)).toBe('1,000') + expect(formatTokenBalance(5000, lang)).toBe('5,000') + expect(formatTokenBalance(1234567.891, lang)).toBe('1,234,567.89') + }) + + it('formats small token balance (<= 999) with 4 decimals', () => { + expect(formatTokenBalance(0, lang)).toBe('0') + expect(formatTokenBalance(1, lang)).toBe('1') + expect(formatTokenBalance(100, lang)).toBe('100') + expect(formatTokenBalance(999, lang)).toBe('999') + expect(formatTokenBalance(0.1234, lang)).toBe('0.1234') + expect(formatTokenBalance(500.56784, lang)).toBe('500.5678') + expect(formatTokenBalance(500.56785, lang)).toBe('500.5679') + }) + + it('handles negative token balances', () => { + expect(formatTokenBalance(-1000, lang)).toBe('-1,000') + expect(formatTokenBalance(-100, lang)).toBe('-100') + }) + }) + + describe('calculateFormattedUsdBalance', () => { + it('returns all "--" when price is zero', () => { + const result = calculateFormattedUsdBalance(1000, 0, lang) + expect(result).toEqual({ + formattedUsdPrice: '--', + formattedBalance: '1,000', // Balance is formatted even when price is zero + formattedBalanceUsd: '--', + }) + }) + + it('calculates formatted USD balance correctly for regular values', () => { + const result = calculateFormattedUsdBalance(100, 50, lang) + expect(result).toEqual({ + formattedUsdPrice: '$50.00', + formattedBalance: '100', + formattedBalanceUsd: '$5,000.00', + }) + }) + + it('calculates formatted USD balance correctly for small token balance', () => { + const result = calculateFormattedUsdBalance(0.5, 100, lang) + expect(result).toEqual({ + formattedUsdPrice: '$100.00', + formattedBalance: '0.5', + formattedBalanceUsd: '$50.00', + }) + }) + + it('calculates formatted USD balance correctly for small price', () => { + const result = calculateFormattedUsdBalance(1000, 0.0005, lang) + expect(result).toEqual({ + formattedUsdPrice: '$0.0005', + formattedBalance: '1,000', + formattedBalanceUsd: '$0.50', + }) + }) + + it('calculates formatted USD balance correctly for extra small price', () => { + const result = calculateFormattedUsdBalance(10000, 0.00001, lang) + expect(result).toEqual({ + formattedUsdPrice: '$0.00001', + formattedBalance: '10,000', + formattedBalanceUsd: '$0.10', + }) + }) + + it('handles calculations resulting in small USD balances', () => { + const result = calculateFormattedUsdBalance(10, 0.05, lang) + expect(result).toEqual({ + formattedUsdPrice: '$0.05', + formattedBalance: '10', + formattedBalanceUsd: '$0.50', + }) + }) + + it('handles calculations with decimal token balances', () => { + const result = calculateFormattedUsdBalance(123.4561, 2.5, lang) + expect(result).toEqual({ + formattedUsdPrice: '$2.50', + formattedBalance: '123.4561', + formattedBalanceUsd: '$308.64', + }) + }) + + it('handles large token balances with regular prices', () => { + const result = calculateFormattedUsdBalance(5000, 1.25, lang) + expect(result).toEqual({ + formattedUsdPrice: '$1.25', + formattedBalance: '5,000', + formattedBalanceUsd: '$6,250.00', + }) + }) + + it('handles calculations that result in very small USD balances', () => { + const result = calculateFormattedUsdBalance(0.1, 0.0001, lang) + expect(result).toEqual({ + formattedUsdPrice: '$0.0001', + formattedBalance: '0.1', + formattedBalanceUsd: '$0.00001', + }) + }) + + it('handles zero token balance', () => { + const result = calculateFormattedUsdBalance(0, 100, lang) + expect(result).toEqual({ + formattedUsdPrice: '$100.00', + formattedBalance: '0', + formattedBalanceUsd: '--', + }) + }) + + it('correctly removes commas and dollar signs in calculation', () => { + // Large values that will have commas + const result = calculateFormattedUsdBalance(1234, 5.671, lang) + expect(result.formattedUsdPrice).toBe('$5.67') + expect(result.formattedBalance).toBe('1,234') + // 1234 * 5.67 = 6,996.78 + expect(result.formattedBalanceUsd).toBe('$6,996.78') + }) + }) + + describe('Edge Cases', () => { + it('handles very large numbers', () => { + expect(formatUsdValue(1000000, lang)).toBe('$1,000,000.00') + expect(formatTokenBalance(999999999, lang)).toBe('999,999,999') + }) + }) +}) diff --git a/src/containers/shared/test/utils.test.ts b/src/containers/shared/test/utils.test.ts index bc4994268..234cdfd50 100644 --- a/src/containers/shared/test/utils.test.ts +++ b/src/containers/shared/test/utils.test.ts @@ -7,6 +7,10 @@ import { localizeDate, durationToHuman, formatAsset, + shortenAccount, + shortenDomain, + shortenNFTTokenID, + shortenMPTID, } from '../utils' describe('utils', () => { @@ -174,3 +178,55 @@ describe('AMM utils format asset', () => { expect(formatted).toEqual({ currency: 'USD', issuer: 'your mom' }) }) }) + +describe('Shorten utils', () => { + describe('shortenAccount', () => { + it('shortens long account addresses', () => { + const longAccount = 'rN7n7otQDd6FczFgLdlqtyMVrn5f4W01dn' + expect(shortenAccount(longAccount)).toBe('rN7n7ot...W01dn') + }) + + it('returns short account addresses unchanged', () => { + const shortAccount = 'rShortAddr' + expect(shortenAccount(shortAccount)).toBe(shortAccount) + }) + }) + + describe('shortenDomain', () => { + it('shortens long domain names', () => { + const longDomain = 'verylongdomainnamethatexceedslimit.com' + expect(shortenDomain(longDomain)).toBe('verylongdomainn...dslimit.com') + }) + + it('returns short domain names unchanged', () => { + const shortDomain = 'example.com' + expect(shortenDomain(shortDomain)).toBe(shortDomain) + }) + }) + + describe('shortenNFTTokenID', () => { + it('shortens long NFT token IDs', () => { + const longTokenID = + '000827103B94ECBB7BF0A0A6ED62B3607801A27B65F4B11F5E1D5E8A3F3D8E9A' + expect(shortenNFTTokenID(longTokenID)).toBe('000827103B...8A3F3D8E9A') + }) + + it('returns short NFT token IDs unchanged', () => { + const shortTokenID = '000827103B94ECBB7BF0' + expect(shortenNFTTokenID(shortTokenID)).toBe(shortTokenID) + }) + }) + + describe('shortenMPTID', () => { + it('shortens long MPT token IDs', () => { + const longMPTID = + '00000000A8B71A79C3CE4E8A3F3D8E9A5BEB9D7C6F4B11F5E1D5E8A3F3D8E9A' + expect(shortenMPTID(longMPTID)).toBe('00000000A8...8A3F3D8E9A') + }) + + it('returns short MPT token IDs unchanged', () => { + const shortMPTID = '00000000A8B71A79C3CE' + expect(shortenMPTID(shortMPTID)).toBe(shortMPTID) + }) + }) +}) diff --git a/src/containers/shared/utils.js b/src/containers/shared/utils.js index 3d8365cbc..770cc6c82 100644 --- a/src/containers/shared/utils.js +++ b/src/containers/shared/utils.js @@ -401,3 +401,19 @@ export function convertScaledPrice(assetPrice, scale) { ? `${integerPart}.${fractionalPart.toString().padStart(scale, '0')}` : `${integerPart}` } + +export const shortenAccount = (addr = '') => + addr.length > 12 ? `${addr.slice(0, 7)}...${addr.slice(-5)}` : addr + +export const shortenDomain = (domain = '') => + domain.length > 26 ? `${domain.slice(0, 15)}...${domain.slice(-11)}` : domain + +export const shortenNFTTokenID = (nftTokenID = '') => + nftTokenID.length > 20 + ? `${nftTokenID.slice(0, 10)}...${nftTokenID.slice(-10)}` + : nftTokenID + +export const shortenMPTID = (mptTokenID = '') => + mptTokenID.length > 20 + ? `${mptTokenID.slice(0, 10)}...${mptTokenID.slice(-10)}` + : mptTokenID diff --git a/src/rippled/accountState.ts b/src/rippled/accountState.ts index 122cb8fdc..3e0a6658a 100644 --- a/src/rippled/accountState.ts +++ b/src/rippled/accountState.ts @@ -1,9 +1,3 @@ -/** - * page path: /accounts/:id? - * - * part 1 of 2 - */ - import { isValidClassicAddress, isValidXAddress, @@ -11,16 +5,12 @@ import { } from 'ripple-address-codec' import { getAccountInfo, - getAccountEscrows, getAccountPaychannels, - getBalances, getServerInfo, getAccountTransactions, - getAccountBridges, } from './lib/rippled' import logger from './lib/logger' import { formatAccountInfo, formatSignerList } from './lib/utils' -import type { ExplorerAmount } from '../containers/shared/types' import type { ExplorerXrplClient } from '../containers/shared/SocketContext' interface XAddress { @@ -31,18 +21,11 @@ interface XAddress { export interface AccountState { account: string - balances: { - XRP: number - } paychannels?: { // eslint-disable-next-line camelcase total_available: number channels: any[] } - escrows?: { - totalIn: number - totalOut: number - } signerList?: { signers: { account: string @@ -52,6 +35,7 @@ export interface AccountState { maxSigners: number } info: { + accountTransactionID?: string reserve: number sequence?: number ticketCount: number @@ -66,53 +50,16 @@ export interface AccountState { test: boolean } deleted: boolean - hasBridge: boolean } -const log = logger({ name: 'account balances' }) - -const formatBalances = (info, data) => { - const balances = { XRP: Number(info.Balance) / 1000000 } - const { assets = {}, obligations = {} } = data - const tokens: ExplorerAmount[] = [] - - Object.keys(obligations).forEach((currency) => { - if (!balances[currency]) { - balances[currency] = 0 - } - - balances[currency] += Number(obligations[currency]) - }) - - Object.keys(assets).forEach((issuer) => { - assets[issuer].forEach((d) => { - if (!balances[d.currency]) { - balances[d.currency] = 0 - } +const log = logger({ name: 'GetAccountState' }) - balances[d.currency] += Number(d.value) - tokens.push({ - amount: Number(d.value), - currency: d.currency, - issuer, - }) - }) - }) - - return { - balances, - tokens, - } -} async function getAccountState( account: string, rippledSocket: ExplorerXrplClient, ): Promise { - // TODO: Retrieve balances for untagged X-address only? or display notice/warning - let classicAddress: string let decomposedAddress: XAddress | null = null - try { if (!isValidClassicAddress(account) && !isValidXAddress(account)) { throw new Error('Malformed address') @@ -121,10 +68,7 @@ async function getAccountState( if (isValidXAddress(account)) { decomposedAddress = xAddressToClassicAddress(account) ;({ classicAddress } = decomposedAddress) - // TODO: Display tag, if present const isTestnet = decomposedAddress.test - - // TODO: Display tag, if present if ( (isTestnet && process.env.VITE_ENVIRONMENT === 'mainnet') || (!isTestnet && @@ -140,36 +84,25 @@ async function getAccountState( log.error(error.toString()) throw error } - log.info(`get balances: ${account} -> ${classicAddress}`) + return getAccountInfo(rippledSocket, classicAddress) .then((info) => Promise.all([ - getBalances(rippledSocket, classicAddress).then((data) => - formatBalances(info, data), - ), - getAccountEscrows(rippledSocket, classicAddress, info.ledger_index), getAccountPaychannels(rippledSocket, classicAddress, info.ledger_index), getServerInfo(rippledSocket), - getAccountBridges(rippledSocket, classicAddress), ]).then((data) => ({ account: info.Account as string, - info: formatAccountInfo(info, data[3].info.validated_ledger), - balances: data[0].balances, - tokens: data[0].tokens, + info: formatAccountInfo(info, data[1].info.validated_ledger), signerList: info.signer_lists?.[0] ? formatSignerList(info.signer_lists[0]) : undefined, - escrows: data[1], - paychannels: data[2], + paychannels: data[1], xAddress: decomposedAddress || undefined, deleted: false, - hasBridge: data[4]?.length > 0, })), ) .catch((error) => { - // X-address: - // error.toString(): CustomError: account not found - // error.code: 404 + // Check if it's a deleted account if (error.code === 404) { return getAccountTransactions(rippledSocket, classicAddress, 1).then( (data) => { @@ -178,8 +111,6 @@ async function getAccountState( account: classicAddress, deleted: true, xAddress: decomposedAddress || undefined, - balances: { XRP: 0 }, - hasBridge: false, info: { reserve: 0, ticketCount: 0, flags: [] }, } } diff --git a/src/rippled/index.js b/src/rippled/index.js index 7928a12d7..874b45934 100644 --- a/src/rippled/index.js +++ b/src/rippled/index.js @@ -7,7 +7,7 @@ import getNegativeUNL from './nUNL' import getToken from './token' import getOffers from './offers' -import { getAccountInfo, getAMMInfo } from './lib/rippled' +import { getAccountInfo, getAMMInfoByAssets } from './lib/rippled' export { getAccountInfo, @@ -19,5 +19,5 @@ export { getNegativeUNL, getToken, getOffers, - getAMMInfo, + getAMMInfoByAssets, } diff --git a/src/rippled/lib/rippled.js b/src/rippled/lib/rippled.js index 6720ee845..fca22a980 100644 --- a/src/rippled/lib/rippled.js +++ b/src/rippled/lib/rippled.js @@ -178,14 +178,13 @@ const getTransaction = (rippledSocket, txId) => { }) } -// get account info -const getAccountInfo = (rippledSocket, account) => +const getAccountInfo = (rippledSocket, account, includeSignerLists = true) => query(rippledSocket, { command: 'account_info', api_version: 1, account, ledger_index: 'validated', - signer_lists: true, + signer_lists: includeSignerLists, }).then((resp) => { if (resp.error === 'actNotFound') { throw new Error('account not found', 404) @@ -265,6 +264,7 @@ const getAccountPaychannels = async ( throw new Error(resp.error_message, 500) } + // This isn't working, resp.marker isn't empty, but we don't enter this if block if (!resp.account_objects.length) { return undefined } @@ -278,10 +278,12 @@ const getAccountPaychannels = async ( }) await getChannels() + const channels = list.map((c) => { remaining += c.Amount - c.Balance return formatPaychannel(c) }) + return channels.length ? { channels, @@ -347,11 +349,9 @@ const getBalances = (rippledSocket, account, ledgerIndex = 'validated') => if (resp.error_message) { throw new Error(resp.error_message, 500) } - return resp }) -// get account transactions const getAccountTransactions = ( rippledSocket, account, @@ -395,7 +395,40 @@ const getAccountNFTs = (rippledSocket, account, marker = '', limit = 20) => command: 'account_nfts', account, marker: marker || undefined, + limit, // Not `limit` of NFTs, but `limit` pages of NFTs + }).then((resp) => { + if (resp.error === 'actNotFound') { + throw new Error('account not found', 404) + } + + if (resp.error_message) { + throw new Error(resp.error_message, 500) + } + + return resp + }) + +const getNFTsIssuedByAccount = ( + rippledSocket, + issuer, + marker = '', + limit = 20, +) => + query(rippledSocket, { + command: 'nfts_by_issuer', + issuer, + marker: marker || undefined, limit, + }).then((resp) => { + if (resp.error === 'actNotFound') { + throw new Error('account not found', 404) + } + + if (resp.error_message) { + throw new Error(resp.error_message, 500) + } + + return resp }) const getNFTInfo = (rippledSocket, tokenId) => @@ -413,27 +446,40 @@ const getNFTInfo = (rippledSocket, tokenId) => return resp }) -const getNFToffers = ( +const getNFToffers = async ( offerCmd, rippledSocket, tokenId, limit = 50, marker = '', -) => - query(rippledSocket, { - command: offerCmd, - nft_id: tokenId, - limit, - marker: marker !== '' ? marker : undefined, - }).then((resp) => { +) => { + const allOffers = [] + let currentMarker = marker + + do { + // eslint-disable-next-line no-await-in-loop + const resp = await query(rippledSocket, { + command: offerCmd, + nft_id: tokenId, + limit, + marker: currentMarker !== '' ? currentMarker : undefined, + }) + + // The NFT does not have any offers (note that object refers to the offer rather than the NFT itself). if (resp.error === 'objectNotFound') { - throw new Error('NFT not found', 404) + throw new Error(resp.error_message, 404) } + if (resp.error_message) { throw new Error(resp.error_message, 500) } - return resp - }) + + allOffers.push(...(resp.offers || [])) + currentMarker = resp.marker + } while (currentMarker) + + return { offers: allOffers } +} const getBuyNFToffers = (rippledSocket, tokenId, limit = 50, marker = '') => getNFToffers('nft_buy_offers', rippledSocket, tokenId, limit, marker) @@ -551,25 +597,34 @@ const getOffers = ( return resp }) -// AMM Additions - -const getAMMInfo = (rippledSocket, asset, asset2) => { +const getAMMInfo = (rippledSocket, params) => { const request = { command: 'amm_info', - asset, - asset2, ledger_index: 'validated', + ...params, } return query(rippledSocket, request).then((resp) => { - if (resp.error_message || !resp.validated) { - throw new Error('Failed to get amm info', 404) + if (resp.error_message) { + throw new Error(resp.error_message) + } + + if (!resp.validated) { + throw new Error( + 'Ledger is not validated. The response data is pending and might change', + ) } return resp }) } +const getAMMInfoByAssets = (rippledSocket, asset, asset2) => + getAMMInfo(rippledSocket, { asset, asset2 }) + +const getAMMInfoByAMMAccount = (rippledSocket, ammAccount) => + getAMMInfo(rippledSocket, { amm_account: ammAccount }) + // get feature const getFeature = (rippledSocket, amendmentId) => { const request = { @@ -597,12 +652,13 @@ const getMPTIssuance = (rippledSocket, tokenId) => resp.error === 'lgrNotFound' || resp.error === 'objectNotFound' ) { - throw new Error('MPT not found', 404) + throw new Error('MPT Issuance not found', 404) } if (resp.error_message) { throw new Error(resp.error_message, 500) } + return resp }) @@ -611,6 +667,7 @@ const getAccountMPTs = ( account, marker = '', ledgerIndex = 'validated', + limit = 400, ) => query(rippledSocket, { command: 'account_objects', @@ -618,13 +675,47 @@ const getAccountMPTs = ( ledger_index: ledgerIndex, type: 'mptoken', marker: marker || undefined, - limit: 400, + limit, }).then((resp) => { if (resp.error === 'actNotFound') { throw new Error('account not found', 404) } + if (resp.error === 'invalidParams') { - return undefined + // For example, "error_message": "Required field 'account' missing" + throw new Error(resp.error_message, 400) + } + + if (resp.error_message) { + throw new Error(resp.error_message, 500) + } + + return resp + }) + +const getAccountObjects = ( + rippledSocket, + account, + objectType, + marker = '', + ledgerIndex = 'validated', + limit = 400, +) => + query(rippledSocket, { + command: 'account_objects', + account, + ledger_index: ledgerIndex, + type: objectType, + marker: marker || undefined, + limit, + }).then((resp) => { + if (resp.error === 'actNotFound') { + throw new Error('account not found', 404) + } + + if (resp.error === 'invalidParams') { + // For example, "error_message": "Required field 'account' missing" + throw new Error(resp.error_message, 400) } if (resp.error_message) { @@ -634,17 +725,19 @@ const getAccountMPTs = ( return resp }) -const getAccountLines = (rippledSocket, account, limit) => +const getAccountLines = (rippledSocket, account, limit, marker = '') => query(rippledSocket, { command: 'account_lines', account, limit, + marker: marker || undefined, }).then((resp) => { if (resp.error === 'actNotFound') { throw new Error('account not found', 404) } if (resp.error === 'invalidParams') { - return undefined + // For example, "error_message": "Required field 'account' missing" + throw new Error(resp.error_message, 400) } if (resp.error_message) { @@ -663,6 +756,8 @@ export { getAccountPaychannels, getAccountBridges, getAccountNFTs, + getAccountObjects, + getNFTsIssuedByAccount, getBalances, getAccountTransactions, getNegativeUNL, @@ -673,7 +768,8 @@ export { getBuyNFToffers, getSellNFToffers, getNFTTransactions, - getAMMInfo, + getAMMInfoByAssets, + getAMMInfoByAMMAccount, getFeature, getMPTIssuance, getAccountMPTs, diff --git a/src/rippled/lib/test/utils.test.ts b/src/rippled/lib/test/utils.test.ts index 1c7f9d0dc..134348d93 100644 --- a/src/rippled/lib/test/utils.test.ts +++ b/src/rippled/lib/test/utils.test.ts @@ -1,4 +1,4 @@ -import { formatAccountInfo, formatNFTInfo } from '../utils' +import { formatAccountInfo, formatNFTInfo, formatTransferFee } from '../utils' describe('rippled utils:', () => { describe('formatNFTInfo', () => { @@ -89,4 +89,85 @@ describe('rippled utils:', () => { ) }) }) + + describe('formatTransferFee', () => { + describe('when no transfer fee is provided', () => { + it('should return "0" for null', () => { + expect(formatTransferFee(null)).toBe('0') + }) + + it('should return "0" for undefined', () => { + expect(formatTransferFee(undefined)).toBe('0') + }) + + it('should return "0" for 0', () => { + expect(formatTransferFee(0)).toBe('0') + }) + }) + + describe('when tokenType is "IOU"', () => { + it('should calculate percentage for IOU tokens using billion-based formula', () => { + // 0.5% transfer fee: 1000000000 + (1000000000 * 0.005) = 1005000000 + expect(formatTransferFee(1005000000, 'IOU')).toBe('0.5') + + // 10% transfer fee: 1000000000 + (1000000000 * 0.1) = 1100000000 + expect(formatTransferFee(1100000000, 'IOU')).toBe('10') + }) + + it('should handle high precision values for IOU tokens', () => { + // Test with a value that needs rounding to 7 decimal places + const transferFee = 1000000000 + 12345678 // should result in 1.2345678% + expect(formatTransferFee(transferFee, 'IOU')).toBe('1.2345678') + }) + + it('should handle very small transfer fees for IOU tokens', () => { + const billion = 1000000000 + const transferFee = billion + 1 // 0.0000001% + expect(formatTransferFee(transferFee, 'IOU')).toBe('1e-7') // JavaScript converts very small numbers to scientific notation + }) + }) + + describe('when tokenType is "MPT" or "NFT"', () => { + it('should calculate percentage for MPT tokens using thousand-based formula', () => { + // 0.5% transfer fee + expect(formatTransferFee(5, 'MPT')).toBe('0.005') + + // 10% transfer fee + expect(formatTransferFee(100, 'MPT')).toBe('0.1') + }) + + it('should calculate percentage for NFT tokens using thousand-based formula', () => { + expect(formatTransferFee(5, 'NFT')).toBe('0.005') + expect(formatTransferFee(100, 'NFT')).toBe('0.1') + }) + + it('should handle high precision values for MPT/NFT tokens', () => { + // Test with a value that needs rounding to 3 decimal places + expect(formatTransferFee(1234, 'MPT')).toBe('1.234') + expect(formatTransferFee(1235, 'NFT')).toBe('1.235') // Test rounding + }) + + it('should handle very small transfer fees for MPT/NFT tokens', () => { + expect(formatTransferFee(1, 'MPT')).toBe('0.001') + expect(formatTransferFee(0.5, 'NFT')).toBe('0.001') // Should round to 3 decimal places + }) + }) + + describe('edge cases', () => { + it('should throw error for unsupported token types', () => { + expect(() => formatTransferFee(100, 'XRP')).toThrow( + 'Unsupported Token type: XRP', + ) + expect(() => formatTransferFee(100, 'INVALID')).toThrow( + 'Unsupported Token type: INVALID', + ) + expect(() => formatTransferFee(100, '')).toThrow( + 'Unsupported Token type: ', + ) + expect(() => formatTransferFee(100, null)).toThrow( + 'Unsupported Token type: null', + ) + }) + }) + }) }) diff --git a/src/rippled/lib/utils.js b/src/rippled/lib/utils.js index e08f4862f..407f39c7b 100644 --- a/src/rippled/lib/utils.js +++ b/src/rippled/lib/utils.js @@ -5,6 +5,7 @@ import { formatSignerList } from './formatSignerList' import { decodeHex } from '../../containers/shared/transactionUtils' const XRP_BASE = 1000000 +const THOUSAND = 1000 const BILLION = 1000000000 export const ACCOUNT_FLAGS = { @@ -57,7 +58,7 @@ const zeroPad = (num, size, back = false) => { return s } -const buildFlags = (flags, flagMap) => { +export const buildFlags = (flags, flagMap) => { const bits = zeroPad((flags || 0).toString(2), 32).split('') return bits @@ -69,16 +70,38 @@ const buildFlags = (flags, flagMap) => { .filter((d) => Boolean(d)) } +const formatTransferFee = (transferFee, tokenType) => { + if (!transferFee) { + return '0' + } + + // https://xrpl.org/docs/concepts/tokens/fungible-tokens/transfer-fees#technical-details + if (tokenType === 'IOU') { + const transferFeePercentage = (100 * (transferFee - BILLION)) / BILLION + return parseFloat(transferFeePercentage.toFixed(7)).toString() + } + + // MTP: https://xrpl.org/docs/references/protocol/data-types/nftoken#transferfee + // NFT: https://xrpl.org/docs/concepts/tokens/fungible-tokens/multi-purpose-tokens#transfer-fees + if (tokenType === 'NFT' || tokenType === 'MPT') { + const transferFeePercentage = transferFee / THOUSAND + return parseFloat(transferFeePercentage.toFixed(3)).toString() + } + + throw new Error(`Unsupported Token type: ${tokenType}`) +} + const formatAccountInfo = (info, serverInfoValidated) => ({ + accountTransactionID: info.AccountTxnID, sequence: info.Sequence, - ticketCount: info.TicketCount, + ticketCount: info.TicketCount ?? 0, ownerCount: info.OwnerCount, reserve: serverInfoValidated.reserve_base_xrp ? serverInfoValidated.reserve_base_xrp + info.OwnerCount * serverInfoValidated.reserve_inc_xrp : undefined, tick: info.TickSize, - rate: info.TransferRate ? (info.TransferRate - BILLION) / BILLION : undefined, + rate: formatTransferFee(info.TransferRate, 'IOU'), domain: info.Domain ? hexToString(info.Domain) : undefined, emailHash: info.EmailHash, flags: buildFlags(info.Flags, ACCOUNT_FLAGS), @@ -135,24 +158,24 @@ const formatNFTInfo = (info) => ({ warnings: info.warnings, }) -const formatMPTIssuanceInfo = (info) => ({ - issuer: info.node.Issuer, - assetScale: info.node.AssetScale, - maxAmt: info.node.MaximumAmount - ? BigInt(info.node.MaximumAmount).toString(10) +const formatMPTIssuance = (info) => ({ + issuer: info.Issuer, + assetScale: info.AssetScale, + maxAmt: info.MaximumAmount + ? BigInt(info.MaximumAmount).toString(10) : undefined, // default is undefined because the default maxAmt is the largest 63-bit int - outstandingAmt: info.node.OutstandingAmount - ? BigInt(info.node.OutstandingAmount).toString(10) + outstandingAmt: info.OutstandingAmount + ? BigInt(info.OutstandingAmount).toString(10) : '0', - transferFee: info.node.TransferFee, - sequence: info.node.Sequence, - metadata: info.node.MPTokenMetadata - ? decodeHex(info.node.MPTokenMetadata) - : info.node.MPTokenMetadata, - flags: buildFlags(info.node.Flags, MPT_ISSUANCE_FLAGS), + transferFee: info.TransferFee, + sequence: info.Sequence, + metadata: info.MPTokenMetadata + ? decodeHex(info.MPTokenMetadata) + : info.MPTokenMetadata, + flags: buildFlags(info.Flags, MPT_ISSUANCE_FLAGS), }) -const formatMPTokenInfo = (info) => ({ +const formatMPToken = (info) => ({ account: info.Account, flags: buildFlags(info.Flags, MPTOKEN_FLAGS), mptIssuanceID: info.MPTokenIssuanceID, @@ -171,6 +194,7 @@ export { formatAccountInfo, convertHexToString, formatNFTInfo, - formatMPTIssuanceInfo, - formatMPTokenInfo, + formatMPTIssuance, + formatMPToken, + formatTransferFee, } diff --git a/src/rippled/token.ts b/src/rippled/token.ts index ab1adebd9..d0c04671d 100644 --- a/src/rippled/token.ts +++ b/src/rippled/token.ts @@ -9,7 +9,7 @@ export interface TokenData { balance: string reserve: number sequence: number - rate?: number + rate?: string obligations?: string domain?: string emailHash?: string