diff options
Diffstat (limited to 'packages/web-util')
95 files changed, 10409 insertions, 236 deletions
diff --git a/packages/web-util/README b/packages/web-util/README deleted file mode 100644 index e69de29bb..000000000 --- a/packages/web-util/README +++ /dev/null diff --git a/packages/web-util/README.md b/packages/web-util/README.md new file mode 100644 index 000000000..d1465aa71 --- /dev/null +++ b/packages/web-util/README.md @@ -0,0 +1,3 @@ +# web-util + +Common utilities for other web applications in this repository. diff --git a/packages/web-util/build.mjs b/packages/web-util/build.mjs index ba277b666..02d077571 100755 --- a/packages/web-util/build.mjs +++ b/packages/web-util/build.mjs @@ -15,89 +15,195 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import esbuild from 'esbuild' -import path from "path" -import fs from "fs" +import esbuild from "esbuild"; +import path from "path"; +import fs from "fs"; // eslint-disable-next-line no-undef -const BASE = process.cwd() +const BASE = process.cwd(); -let GIT_ROOT = BASE -while (!fs.existsSync(path.join(GIT_ROOT, '.git')) && GIT_ROOT !== '/') { - GIT_ROOT = path.join(GIT_ROOT, '../') +let GIT_ROOT = BASE; +while (!fs.existsSync(path.join(GIT_ROOT, ".git")) && GIT_ROOT !== "/") { + GIT_ROOT = path.join(GIT_ROOT, "../"); } -if (GIT_ROOT === '/') { +if (GIT_ROOT === "/") { // eslint-disable-next-line no-undef - console.log("not found") + console.log("not found"); // eslint-disable-next-line no-undef process.exit(1); } -const GIT_HASH = GIT_ROOT === '/' ? undefined : git_hash() +const GIT_HASH = GIT_ROOT === "/" ? undefined : git_hash(); - -let _package = JSON.parse(fs.readFileSync(path.join(BASE, 'package.json'))); +let _package = JSON.parse(fs.readFileSync(path.join(BASE, "package.json"))); function git_hash() { - const rev = fs.readFileSync(path.join(GIT_ROOT, '.git', 'HEAD')).toString().trim().split(/.*[: ]/).slice(-1)[0]; - if (rev.indexOf('/') === -1) { + const rev = fs + .readFileSync(path.join(GIT_ROOT, ".git", "HEAD")) + .toString() + .trim() + .split(/.*[: ]/) + .slice(-1)[0]; + if (rev.indexOf("/") === -1) { return rev; } else { - return fs.readFileSync(path.join(GIT_ROOT, '.git', rev)).toString().trim(); + return fs.readFileSync(path.join(GIT_ROOT, ".git", rev)).toString().trim(); } } +/** + * Problem: + * No loader is configured for ".node" files: ../../node_modules/.pnpm/fsevents@2.3.3/node_modules/fsevents/fsevents.node + * + * Reference: + * https://github.com/evanw/esbuild/issues/1051#issuecomment-806325487 + * + * kept as reference, need to be tested on MacOs + */ +const nativeNodeModulesPlugin = { + name: 'native-node-modules', + setup(build) { + // If a ".node" file is imported within a module in the "file" namespace, resolve + // it to an absolute path and put it into the "node-file" virtual namespace. + build.onResolve({ filter: /\.node$/, namespace: 'file' }, args => ({ + path: require.resolve(args.path, { paths: [args.resolveDir] }), + namespace: 'node-file', + })) + + // Files in the "node-file" virtual namespace call "require()" on the + // path from esbuild of the ".node" file in the output directory. + build.onLoad({ filter: /.*/, namespace: 'node-file' }, args => ({ + contents: ` + import path from ${JSON.stringify(args.path)} + try { module.exports = require(path) } + catch {} + `, + })) + + // If a ".node" file is imported within a module in the "node-file" namespace, put + // it in the "file" namespace where esbuild's default loading behavior will handle + // it. It is already an absolute path since we resolved it to one above. + build.onResolve({ filter: /\.node$/, namespace: 'node-file' }, args => ({ + path: args.path, + namespace: 'file', + })) + + // Tell esbuild's default loading behavior to use the "file" loader for + // these ".node" files. + let opts = build.initialOptions + opts.loader = opts.loader || {} + opts.loader['.node'] = 'file' + }, +} + const buildConfigBase = { outdir: "lib", bundle: true, minify: false, - target: [ - 'es6' - ], + target: ["es2020"], loader: { - '.key': 'text', - '.crt': 'text', - '.html': 'text', + ".key": "text", + ".crt": "text", + ".node": "file", + ".html": "text", + ".svg": "dataurl", }, sourcemap: true, define: { - '__VERSION__': `"${_package.version}"`, - '__GIT_HASH__': `"${GIT_HASH}"`, + __VERSION__: `"${_package.version}"`, + __GIT_HASH__: `"${GIT_HASH}"`, }, -} + //plugins: [nativeNodeModulesPlugin], +}; + +/** + * Build time libraries, under node runtime + */ +const buildConfigBuild = { + ...buildConfigBase, + entryPoints: ["src/index.build.ts"], + outExtension: { + ".js": ".mjs", + }, + format: "esm", + platform: "node", + external: ["esbuild"], + // https://github.com/evanw/esbuild/issues/1921 + // How to fix "Dynamic require of "os" is not supported" + // esbuild cannot convert external "static" commonjs require statements to static esm imports + banner: { + js: ` + import { fileURLToPath } from 'url'; + import { createRequire as topLevelCreateRequire } from 'module'; + const require = topLevelCreateRequire(import.meta.url); + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); +`, + }, +}; +/** + * Development libraries, under node runtime + */ +const buildConfigTesting = { + ...buildConfigBase, + entryPoints: ["src/index.testing.ts"], + outExtension: { + ".js": ".mjs", + }, + format: "esm", + platform: "browser", + external: ["preact", "@gnu-taler/taler-util", "jed", "swr", "axios"], + jsxFactory: "h", + jsxFragment: "Fragment", +}; + +/** + * Testing libraries, under node runtime + */ const buildConfigNode = { ...buildConfigBase, entryPoints: ["src/index.node.ts", "src/cli.ts"], outExtension: { - '.js': '.cjs' + ".js": ".cjs", }, - format: 'cjs', - platform: 'node', + format: "cjs", + platform: "node", external: ["preact"], + }; +/** + * Support libraries, under browser runtime + */ const buildConfigBrowser = { ...buildConfigBase, - entryPoints: ["src/index.browser.ts", "src/live-reload.ts", 'src/stories.tsx'], + entryPoints: [ + "src/tests/mock.ts", + "src/tests/swr.ts", + "src/index.browser.ts", + "src/live-reload.ts", + "src/stories.tsx", + ], outExtension: { - '.js': '.mjs' + ".js": ".mjs", }, - format: 'esm', - platform: 'browser', - external: ["preact", "@gnu-taler/taler-util", "jed"], - jsxFactory: 'h', - jsxFragment: 'Fragment', + format: "esm", + platform: "browser", + external: ["preact", "@gnu-taler/taler-util", "jed", "swr", "axios"], + jsxFactory: "h", + jsxFragment: "Fragment", }; -[buildConfigNode, buildConfigBrowser].forEach((config) => { - esbuild - .build(config) - .catch((e) => { - // eslint-disable-next-line no-undef - console.log(e) - // eslint-disable-next-line no-undef - process.exit(1) - }); - -}) - +[ + buildConfigNode, + buildConfigBrowser, + buildConfigBuild, + buildConfigTesting, +].forEach((config) => { + esbuild.build(config).catch((e) => { + // eslint-disable-next-line no-undef + console.log(e); + // eslint-disable-next-line no-undef + process.exit(1); + }); +}); diff --git a/packages/web-util/package.json b/packages/web-util/package.json index a4d1c116b..369b872b6 100644 --- a/packages/web-util/package.json +++ b/packages/web-util/package.json @@ -1,40 +1,70 @@ { "name": "@gnu-taler/web-util", - "version": "0.9.0", + "version": "0.10.7", "description": "Generic helper functionality for GNU Taler Web Apps", "type": "module", "types": "./lib/index.node.d.ts", "main": "./dist/taler-web-cli.cjs", - "bin": { - "taler-wallet-cli": "./bin/taler-web-cli.cjs" - }, "author": "Sebastian Marchano", "license": "AGPL-3.0-or-later", "private": false, "exports": { - "./lib/index.browser": "./lib/index.browser.mjs", - "./lib/index.node": "./lib/index.node.cjs" + "./browser": { + "types": "./lib/index.browser.js", + "default": "./lib/index.browser.mjs" + }, + "./build": { + "types": "./lib/index.build.js", + "default": "./lib/index.build.mjs" + }, + "./node": { + "types": "./lib/index.node.js", + "default": "./lib/index.node.cjs" + }, + "./testing": { + "types": "./lib/index.testing.js", + "default": "./lib/index.testing.mjs" + } }, "scripts": { - "prepare": "tsc && ./build.mjs", "compile": "tsc && ./build.mjs", - "clean": "rimraf dist lib tsconfig.tsbuildinfo", + "build": "tsc && ./build.mjs", + "clean": "rm -rf dist lib tsconfig.tsbuildinfo", "pretty": "prettier --write src" }, "devDependencies": { + "@babel/preset-react": "^7.22.3", + "@babel/preset-typescript": "^7.21.5", "@gnu-taler/taler-util": "workspace:*", + "@heroicons/react": "^2.0.17", + "@linaria/babel-preset": "5.0.4", + "@linaria/core": "5.0.2", + "@linaria/esbuild": "5.0.4", + "@linaria/react": "5.0.3", "@types/express": "^4.17.14", - "@types/node": "^18.11.9", + "@types/node": "^18.11.17", "@types/web": "^0.0.82", "@types/ws": "^8.5.3", + "autoprefixer": "^10.4.14", "chokidar": "^3.5.3", - "esbuild": "^0.14.21", + "date-fns": "2.29.3", + "esbuild": "^0.19.9", "express": "^4.18.2", + "postcss": "^8.4.23", + "postcss-load-config": "^4.0.1", "preact": "10.11.3", - "prettier": "^2.5.1", - "rimraf": "^3.0.2", - "tslib": "^2.4.0", - "typescript": "^4.8.4", + "preact-render-to-string": "^5.2.6", + "prettier": "^3.1.1", + "sass": "1.56.1", + "swr": "2.0.3", + "tslib": "^2.6.2", + "typescript": "^5.3.3", "ws": "7.4.5" + }, + "dependencies": { + "@babel/core": "7.18.9", + "@babel/helper-compilation-targets": "7.18.9", + "@types/chrome": "0.0.197", + "tailwindcss": "^3.3.2" } } diff --git a/packages/web-util/src/assets/lang.svg b/packages/web-util/src/assets/lang.svg new file mode 100644 index 000000000..dd72ce65e --- /dev/null +++ b/packages/web-util/src/assets/lang.svg @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 2411.2 2794" style="enable-background:new 0 0 2411.2 2794;" xml:space="preserve"> +<style type="text/css"> + .st0{fill:#FFFFFF;} + .st1{fill-rule:evenodd;clip-rule:evenodd;} + .st2{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;} +</style> +<g id="Layer_2"> +</g> +<g id="Layer_x5F_1_x5F_1"> + <g> + <polygon points="1204.6,359.2 271.8,30 271.8,2060.1 1204.6,1758.3 "/> + <polygon class="st0" points="1182.2,358.1 2150.6,29 2150.6,2059 1182.2,1757.3 "/> + <polygon class="st0" points="30,2415.4 1182.2,2031.4 1182.2,357.9 30,742 "/> + <polygon points="1707.2,2440.7 1870.5,2709.4 1956.6,2459.8 "/> + <g> + <path d="M421.7,934.8c-6.1-6,8,49.1,27.6,68.9c34.8,35.1,61.9,39.6,76.4,40.2c32,1.3,71.5-8,94.9-17.8 + c22.7-9.7,62.4-30,77.5-59.6c3.2-6.3,11.9-17,6.4-43.2c-4.2-20.2-17-27.3-32.7-26.2c-15.7,1.1-63.2,13.7-86.1,20.8 + c-23,7-70.3,21.4-90.9,25.8C474.3,948.2,429,941.7,421.7,934.8z"/> + <path d="M1003.1,1593.7c-9.1-3.3-196.9-81.1-223.6-93.9c-21.8-10.5-75.2-33.1-100.4-43.3c70.8-109.2,115.5-191.6,121.5-204.1 + c11-23,86-169.6,87.7-178.7c1.7-9.1,3.8-42.9,2.2-51c-1.7-8.2-29.1,7.6-66.4,20.2c-37.4,12.6-108.4,58.8-135.8,64.6 + c-27.5,5.7-115.5,39.1-160.5,54c-45,14.9-130.2,40.9-165.2,50.4c-35.1,9.5-65.7,10.2-85.3,16.2c0,0,2.6,27.5,7.8,35.7 + c5.2,8.2,23.7,28.4,45.3,34.1c21.6,5.7,57.3,3.4,73.6-0.3c16.3-3.8,44.4-17.5,48.2-23.6c3.8-6.1-2-24.9,4.5-30.6 + c6.5-5.6,92.2-25.7,124.6-35.4c32.4-10,156.3-52.6,173.1-50.5c-5.3,17.7-105,215.1-137.1,274c-32.1,58.9-218.6,318-258.3,363.6 + c-30.1,34.7-103.2,123.5-128.5,143.6c6.4,1.8,51.6-2.1,59.9-7.2c51.3-31.6,136.9-138.1,164.4-170.5 + c81.9-96,153.8-196.8,210.8-283.4h0.1c11.1,4.6,100.9,77.8,124.4,94c23.4,16.2,115.9,67.8,136,76.4c20,8.7,97.1,44.2,100.3,32.2 + C1029.4,1668,1012.2,1597.1,1003.1,1593.7z"/> + </g> + <path class="st1" d="M569,2572c18,11,35,20,54,29c38,19,81,39,122,54c56,21,112,38,168,51c31,7,65,13,98,18c3,0,92,11,110,11h90 + c35-3,68-5,103-10c28-4,59-9,89-16c22-5,45-10,67-17c21-6,45-14,68-22c15-5,31-12,47-18c13-6,29-13,44-19c18-8,39-19,59-29 + c16-8,34-18,51-28c13-7,43-30,59-30c18,0,30,16,30,30c0,29-39,38-57,51c-19,13-42,23-62,34c-40,21-81,39-120,54 + c-51,19-107,37-157,49c-19,4-38,9-57,12c-10,2-114,18-143,18h-132c-35-3-72-7-107-12c-31-5-64-11-95-18c-24-5-50-12-73-19 + c-40-11-79-25-117-40c-69-26-141-60-209-105c-12-8-13-16-13-25c0-15,11-29,29-29C531,2546,563,2569,569,2572z"/> + <path class="st1" d="M1151,2009L61,2372V764l1090-363V2009z M1212,354v1680c-1,5-3,10-7,15c-2,3-6,7-9,8c-25,10-1151,388-1166,388 + c-12,0-23-8-29-21c0-1-1-2-1-4V739c2-5,3-12,7-16c8-11,22-13,31-16c17-6,1126-378,1142-378C1190,329,1212,336,1212,354z"/> + <path class="st1" d="M2120,2017l-907-282V380l907-308V2017z M2181,32v2023c-1,23-17,33-32,33c-13,0-107-32-123-37 + c-126-39-253-78-378-117c-28-9-57-18-84-27c-24-7-50-15-74-23c-107-33-216-66-323-102c-4-1-14-15-14-18V351c2-5,4-11,9-15 + c8-9,351-123,486-168c36-13,487-168,501-168C2167,0,2181,13,2181,32z"/> + <polygon points="2411.2,2440.7 1199.5,2054.5 1204.6,373.2 2411.2,757.2 "/> + <g> + <path class="st2" d="M1800.3,1124.6L1681.4,1412l218.6,66.3L1800.3,1124.6z M1729,853.2l156.1,47.3l284.4,1025l-160.3-48.7 + l-57.6-210.4L1620.2,1566l-71.3,171.4l-160.4-48.7L1729,853.2z"/> + </g> + </g> +</g> +</svg> diff --git a/packages/web-util/src/assets/logo-2021.svg b/packages/web-util/src/assets/logo-2021.svg new file mode 100644 index 000000000..8c5ff3e5b --- /dev/null +++ b/packages/web-util/src/assets/logo-2021.svg @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 201 90"> + <g fill="#0042b3" fill-rule="evenodd" stroke-width=".3"> + <path d="M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z" /> + <path d="M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z" /> + <path d="M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z" /> + </g> + <path d="M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z" /> +</svg>
\ No newline at end of file diff --git a/packages/web-util/src/assets/logo-white.svg b/packages/web-util/src/assets/logo-white.svg new file mode 100644 index 000000000..cb1f023c5 --- /dev/null +++ b/packages/web-util/src/assets/logo-white.svg @@ -0,0 +1,45 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + width="670" + height="300" + viewBox="0 0 201 90" + version="1.1" + id="svg8"> + <g + id="logo"> + <g + id="circles" + style="fill:#FFF;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.327943"> + <path + d="m 86.662153,1.1211936 c 15.589697,0 29.129227,9.4011664 35.961027,23.2018054 h -5.81736 C 110.4866,13.623304 99.349002,6.5180852 86.662153,6.5180852 c -19.690571,0 -35.652876,17.1120008 -35.652876,38.2205688 0,10.331797 3.825597,19.704678 10.03957,26.582945 -1.342357,1.120912 -2.771532,2.127905 -4.275488,3.006754 C 50.071485,66.553412 45.974857,56.15992 45.974857,44.738654 c 0,-24.089211 18.216325,-43.6174604 40.687296,-43.6174604 z M 122.51416,65.375898 c -6.86645,13.680134 -20.34561,22.980218 -35.852007,22.980218 -1.052702,0 -2.096093,-0.04291 -3.128683,-0.127026 3.052192,-1.561167 5.913582,-3.480387 8.538307,-5.707305 10.320963,-1.684389 19.185983,-8.113638 24.601813,-17.145887 z" + id="path2350" /> + <path + d="m 64.212372,1.1211936 c 1.052607,0 2.095998,0.042919 3.128684,0.1270583 C 64.288864,2.8094199 61.427378,4.728606 58.802653,6.9555572 41.679542,9.7498571 28.559494,25.601563 28.559494,44.738654 c 0,14.264563 7.29059,26.702023 18.093843,33.268925 -1.593656,0.26719 -3.226966,0.406948 -4.890748,0.406948 -1.239545,0 -2.46151,-0.07952 -3.663522,-0.229364 C 29.191129,70.184015 23.525076,58.171633 23.525076,44.738654 23.525076,20.649443 41.7414,1.1211936 64.212372,1.1211936 Z M 69.62209,82.521785 C 79.943207,80.837396 88.808164,74.407841 94.224059,65.375422 h 5.840511 c -6.866354,13.680305 -20.345548,22.980694 -35.852198,22.980694 -1.052703,0 -2.095999,-0.04291 -3.128684,-0.127026 3.052002,-1.561371 5.913836,-3.480218 8.538402,-5.707305 z M 94.355885,24.322999 c -3.13939,-5.314721 -7.467551,-9.74275 -12.584511,-12.853269 1.593656,-0.26719 3.226904,-0.406948 4.890779,-0.406948 1.239451,0 2.461512,0.07952 3.663524,0.229364 4.016018,3.607242 7.373195,8.030111 9.849053,13.030853 z" + id="path2352" /> + <path + d="m 41.762589,1.1211936 c 1.064296,0 2.118804,0.044379 3.162607,0.1302161 -3.046523,1.558961 -5.903162,3.4745139 -8.52358,5.6968133 C 19.254624,9.7205882 6.1097128,25.583465 6.1097128,44.738654 c 0,21.108568 15.9624012,38.22057 35.6528762,38.22057 12.599746,0 23.672446,-7.007056 30.013748,-17.583802 h 5.838515 C 70.748498,79.055727 57.26924,88.356116 41.762589,88.356116 c -22.470907,0 -40.6871998,-19.52825 -40.6871998,-43.617462 0,-24.089211 18.2162928,-43.6174604 40.6871998,-43.6174604 z M 71.905375,24.322999 c -1.31192,-2.220567 -2.830984,-4.287049 -4.528877,-6.166508 1.342452,-1.120945 2.771374,-2.128381 4.275139,-3.00723 2.372984,2.753011 4.418875,5.834636 6.072489,9.173738 z" + id="path2354" /> + </g> + <g + id="letters" + style="fill:#FFF"> + <path + d="m 76.135411,34.409066 h 9.161042 V 29.36588 H 61.857537 v 5.043186 h 9.161137 v 25.92317 h 5.116737 z" + id="path2346" /> + <path + d="m 92.647571,52.856334 h 13.659009 l 2.93009,7.476072 h 5.36461 L 101.89122,29.144903 H 97.187186 L 84.477089,60.332406 h 5.199533 z m 11.802109,-4.822276 h -9.944771 l 4.951718,-12.386462 z" + id="path2362" /> + <path + d="m 123.80641,29.366084 h -4.58038 v 30.966322 h 20.54728 v -4.910253 c -5.32227,0 -10.64463,0 -15.9669,0 z" + id="path2356" /> + <path + d="m 166.4722,29.366084 h -21.37564 v 30.966322 h 21.58203 v -4.910253 h -16.54771 v -8.27275 h 14.48439 V 42.23925 h -14.48439 v -7.962811 h 16.34132 z" + id="path2360" /> + <path + d="m 191.19035,39.474593 c 0,1.59947 -0.53646,2.87535 -1.61628,3.818883 -1.07281,0.95124 -2.52409,1.422837 -4.34678,1.422837 h -7.44851 V 34.276439 h 7.4073 c 1.9051,0 3.38376,0.435027 4.42939,1.312178 1.05226,0.870258 1.57488,2.167734 1.57488,3.885976 z m 6.06602,20.857813 -7.79911,-11.723191 c 1.01771,-0.294794 1.94631,-0.714813 2.78553,-1.260566 0.83885,-0.545619 1.56122,-1.209263 2.16629,-1.990627 0.60541,-0.781738 1.07981,-1.681096 1.42369,-2.698345 0.34378,-1.017553 0.51561,-2.175238 0.51561,-3.472883 0,-1.50409 -0.24743,-2.867948 -0.74267,-4.092048 -0.49515,-1.223794 -1.20344,-2.256186 -2.12499,-3.096734 -0.92173,-0.840446 -2.04957,-1.489252 -3.38375,-1.946452 -1.33447,-0.457267 -2.82692,-0.685476 -4.4774,-0.685476 h -12.87512 v 30.966322 h 5.03433 V 49.538522 h 6.37569 l 7.11829,10.793884 z" + id="path2358" /> + </g> + </g> +</svg> diff --git a/packages/web-util/src/cli.ts b/packages/web-util/src/cli.ts index dca4fc664..05a22bc8a 100644 --- a/packages/web-util/src/cli.ts +++ b/packages/web-util/src/cli.ts @@ -1,4 +1,5 @@ -import { clk, setGlobalLogLevelFromString } from "@gnu-taler/taler-util"; +import { setGlobalLogLevelFromString } from "@gnu-taler/taler-util"; +import { clk } from "@gnu-taler/taler-util/clk"; import { serve } from "./serve.js"; export const walletCli = clk @@ -35,7 +36,6 @@ walletCli return serve({ folder: args.serve.folder || "./dist", port: args.serve.port || 8000, - development: args.serve.development, }); }); diff --git a/packages/web-util/src/components/Attention.tsx b/packages/web-util/src/components/Attention.tsx new file mode 100644 index 000000000..4172c0c9b --- /dev/null +++ b/packages/web-util/src/components/Attention.tsx @@ -0,0 +1,80 @@ +import { Duration, TranslatedString, assertUnreachable } from "@gnu-taler/taler-util"; +import { ComponentChildren, Fragment, VNode, h } from "preact"; + +interface Props { + type?: "info" | "success" | "warning" | "danger" | "low", + onClose?: () => void, + title: TranslatedString, + children?: ComponentChildren, + timeout?: Duration, +} +export function Attention({ type = "info", title, children, onClose, timeout = Duration.getForever() }: Props): VNode { + + return <div class={`group attention-${type} mt-2 shadow-lg`}> + {timeout.d_ms === "forever" ? undefined : <style>{` + .progress { + animation: notificationTimeoutBar ${Math.round(timeout.d_ms / 1000)}s ease-in-out; + animation-fill-mode:both; + } + + @keyframes notificationTimeoutBar { + 0% { width: 0; } + 100% { width: 100%; } + } + `}</style> + } + + <div data-timed={timeout.d_ms !== "forever"} class="rounded-md data-[timed=true]:rounded-b-none group-[.attention-info]:bg-blue-50 group-[.attention-low]:bg-gray-100 group-[.attention-warning]:bg-yellow-50 group-[.attention-danger]:bg-red-50 group-[.attention-success]:bg-green-50 p-4 shadow"> + <div class="flex"> + <div > + {type === "low" ? undefined : + <svg xmlns="http://www.w3.org/2000/svg" stroke="none" viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8 group-[.attention-info]:text-blue-400 group-[.attention-warning]:text-yellow-400 group-[.attention-danger]:text-red-400 group-[.attention-success]:text-green-400"> + {(() => { + switch (type) { + case "info": + return <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" /> + case "warning": + return <path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" /> + case "danger": + return <path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" /> + case "success": + return <path fill-rule="evenodd" d="M7.493 18.75c-.425 0-.82-.236-.975-.632A7.48 7.48 0 016 15.375c0-1.75.599-3.358 1.602-4.634.151-.192.373-.309.6-.397.473-.183.89-.514 1.212-.924a9.042 9.042 0 012.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 00.322-1.672V3a.75.75 0 01.75-.75 2.25 2.25 0 012.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 01-2.649 7.521c-.388.482-.987.729-1.605.729H14.23c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 00-1.423-.23h-.777zM2.331 10.977a11.969 11.969 0 00-.831 4.398 12 12 0 00.52 3.507c.26.85 1.084 1.368 1.973 1.368H4.9c.445 0 .72-.498.523-.898a8.963 8.963 0 01-.924-3.977c0-1.708.476-3.305 1.302-4.666.245-.403-.028-.959-.5-.959H4.25c-.832 0-1.612.453-1.918 1.227z" /> + default: + assertUnreachable(type) + } + })()} + </svg> + } + </div> + <div class="ml-3 w-full"> + <h3 class="text-sm font-bold group-[.attention-info]:text-blue-800 group-[.attention-success]:text-green-800 group-[.attention-warning]:text-yellow-800 group-[.attention-danger]:text-red-800"> + {title} + </h3> + <div class="mt-2 text-sm group-[.attention-info]:text-blue-700 group-[.attention-warning]:text-yellow-700 group-[.attention-danger]:text-red-700 group-[.attention-success]:text-green-700"> + {children} + </div> + </div> + {onClose && + <div> + <button type="button" class="font-semibold items-center rounded bg-transparent px-2 py-1 text-xs text-gray-900 hover:bg-gray-50" + onClick={(e) => { + e.preventDefault(); + onClose(); + }} + > + <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" /> + </svg> + </button> + </div> + } + </div> + </div> + {timeout.d_ms === "forever" ? undefined : + <div class="meter group-[.attention-info]:bg-blue-50 group-[.attention-low]:bg-gray-100 group-[.attention-warning]:bg-yellow-50 group-[.attention-danger]:bg-red-50 group-[.attention-success]:bg-green-50 h-1 relative overflow-hidden -mt-1"> + <span class="w-full h-full block"><span class="h-full block progress group-[.attention-info]:bg-blue-600 group-[.attention-low]:bg-gray-600 group-[.attention-warning]:bg-yellow-600 group-[.attention-danger]:bg-red-600 group-[.attention-success]:bg-green-600"></span></span> + </div> + } + + </div> +} diff --git a/packages/web-util/src/components/Button.tsx b/packages/web-util/src/components/Button.tsx new file mode 100644 index 000000000..b142114e7 --- /dev/null +++ b/packages/web-util/src/components/Button.tsx @@ -0,0 +1,167 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AbsoluteTime, + OperationAlternative, + OperationFail, + OperationOk, + OperationResult, + TalerError, + TranslatedString, +} from "@gnu-taler/taler-util"; +// import { NotificationMessage, notifyInfo } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { HTMLAttributes, useState } from "preact/compat"; +import { + NotificationMessage, + buildUnifiedRequestErrorMessage, + notifyInfo, + useTranslationContext, +} from "../index.browser.js"; +// import { useBankCoreApiContext } from "../context/config.js"; + +// function errorMap<T extends OperationFail<unknown>>(resp: T, map: (d: T["case"]) => TranslatedString): void { + +export type OnOperationSuccesReturnType<T> = ( + result: T extends OperationOk<any> ? T : never, +) => TranslatedString | void; +export type OnOperationFailReturnType<T> = ( + (d: (T extends OperationFail<any> ? T : never) | (T extends OperationAlternative<any,any> ? T : never)) => TranslatedString) + +export interface ButtonHandler<T extends OperationResult<A, B>, A, B> { + onClick: () => Promise<T | undefined>; + onNotification: (n: NotificationMessage) => void; + onOperationSuccess: OnOperationSuccesReturnType<T>; + onOperationFail?: OnOperationFailReturnType<T>; + onOperationComplete?: () => void; +} + +interface Props<T extends OperationResult<A, B>, A, B> + extends HTMLAttributes<HTMLButtonElement> { + handler: ButtonHandler<T, A, B> | undefined; +} + +/** + * This button accept an async function and report a notification + * on error or success. + * + * When the async function is running the inner text will change into + * a "loading" animation. + * + * @param param0 + * @returns + */ +export function Button<T extends OperationResult<A, B>, A, B>({ + handler, + children, + disabled, + onClick: clickEvent, + ...rest +}: Props<T, A, B>): VNode { + const { i18n } = useTranslationContext(); + const [running, setRunning] = useState(false); + return ( + <button + {...rest} + disabled={disabled || running} + onClick={(e) => { + e.preventDefault(); + if (!handler) { + return; + } + setRunning(true); + handler + .onClick() + .then((resp) => { + if (resp) { + if (resp.type === "ok") { + const result: OperationOk<any> = resp; + // @ts-expect-error this is an operationOk + const msg = handler.onOperationSuccess(result); + if (msg) { + notifyInfo(msg); + } + } + if (resp.type === "fail") { + const d = 'detail' in resp ? resp.detail : undefined + + const title = !handler.onOperationFail ? "Unexpected error." as TranslatedString : handler.onOperationFail(resp as any); + handler.onNotification({ + title, + type: "error", + description: d && d.hint ? d.hint as TranslatedString : undefined, + debug: d, + when: AbsoluteTime.now(), + }); + } + } + if (handler.onOperationComplete) { + handler.onOperationComplete(); + } + setRunning(false); + }) + .catch((error) => { + console.error(error); + + if (error instanceof TalerError) { + handler.onNotification( + buildUnifiedRequestErrorMessage(i18n, error), + ); + } else { + const description = ( + error instanceof Error ? error.message : String(error) + ) as TranslatedString; + + handler.onNotification({ + title: i18n.str`Operation failed`, + type: "error", + description, + when: AbsoluteTime.now(), + }); + } + + if (handler.onOperationComplete) { + handler.onOperationComplete(); + } + setRunning(false); + }); + }} + > + {running ? <Wait /> : children} + </button> + ); +} + +function Wait(): VNode { + return ( + <Fragment> + <style> + {` + #l1 { width: 120px; + height: 20px; + -webkit-mask: radial-gradient(circle closest-side, currentColor 90%, #0000) left/20% 100%; + background: linear-gradient(currentColor 0 0) left/0% 100% no-repeat #ddd; + animation: l17 2s infinite steps(6); + } + @keyframes l17 { + 100% {background-size:120% 100%} +`} + </style> + <div id="l1" /> + </Fragment> + ); +} diff --git a/packages/web-util/src/components/CopyButton.tsx b/packages/web-util/src/components/CopyButton.tsx new file mode 100644 index 000000000..dbb38b474 --- /dev/null +++ b/packages/web-util/src/components/CopyButton.tsx @@ -0,0 +1,56 @@ +import { ComponentChildren, h, VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; + +export function CopyIcon(): VNode { + return ( + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> + <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" /> + </svg> + ) +}; + +export function CopiedIcon(): VNode { + return ( + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> + <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /> + </svg> + ) +}; + +export function CopyButton({ class: clazz, children, getContent }: { children?: ComponentChildren, class: string, getContent: () => string }): VNode { + const [copied, setCopied] = useState(false); + function copyText(): void { + if (!navigator.clipboard && !window.isSecureContext) { + alert('clipboard is not available on insecure context (http)') + } + if (navigator.clipboard) { + navigator.clipboard.writeText(getContent() || ""); + setCopied(true); + } + } + useEffect(() => { + if (copied) { + setTimeout(() => { + setCopied(false); + }, 1000); + } + }, [copied]); + + if (!copied) { + return ( + <button class={clazz} onClick={e => { + e.preventDefault() + copyText() + }} > + <CopyIcon /> + {children} + </button> + ); + } + return ( + <button class={clazz} disabled> + <CopiedIcon /> + {children} + </button> + ); +} diff --git a/packages/web-util/src/components/ErrorLoading.tsx b/packages/web-util/src/components/ErrorLoading.tsx new file mode 100644 index 000000000..7089266b9 --- /dev/null +++ b/packages/web-util/src/components/ErrorLoading.tsx @@ -0,0 +1,147 @@ +/* +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { TalerError, TalerErrorCode, assertUnreachable } from "@gnu-taler/taler-util"; +import { Fragment, VNode, h } from "preact"; +import { Attention } from "./Attention.js"; +import { useTranslationContext } from "../index.browser.js"; + +export function ErrorLoading({ error, showDetail }: { error: TalerError, showDetail?: boolean }): VNode { + const { i18n } = useTranslationContext() + switch (error.errorDetail.code) { + ////////////////// + // Every error that can be produce in a Http Request + ////////////////// + case TalerErrorCode.GENERIC_TIMEOUT: { + if (error.hasErrorCode(TalerErrorCode.GENERIC_TIMEOUT)) { + const { requestMethod, requestUrl, timeoutMs } = error.errorDetail + return <Attention type="danger" title={i18n.str`The request reached a timeout, check your connection.`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, timeoutMs }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + case TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR: { + if (error.hasErrorCode(TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR)) { + const { requestMethod, requestUrl, timeoutMs } = error.errorDetail + return <Attention type="danger" title={i18n.str`The request was cancelled.`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, timeoutMs }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + case TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT: { + if (error.hasErrorCode(TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT)) { + const { requestMethod, requestUrl, timeoutMs } = error.errorDetail + return <Attention type="danger" title={i18n.str`The request reached a timeout, check your connection.`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, timeoutMs }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + case TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED: { + if (error.hasErrorCode(TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED)) { + const { requestMethod, requestUrl, throttleStats } = error.errorDetail + return <Attention type="danger" title={i18n.str`A lot of request were made to the same server and this action was throttled`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, throttleStats }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + case TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE: { + if (error.hasErrorCode(TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE)) { + const { requestMethod, requestUrl, httpStatusCode, validationError } = error.errorDetail + return <Attention type="danger" title={i18n.str`The response of the request is malformed.`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, httpStatusCode, validationError }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + case TalerErrorCode.WALLET_NETWORK_ERROR: { + if (error.hasErrorCode(TalerErrorCode.WALLET_NETWORK_ERROR)) { + const { requestMethod, requestUrl } = error.errorDetail + return <Attention type="danger" title={i18n.str`Could not complete the request due to a network problem.`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + case TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR: { + if (error.hasErrorCode(TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR)) { + const { requestMethod, requestUrl, httpStatusCode, errorResponse } = error.errorDetail + return <Attention type="danger" title={i18n.str`Unexpected request error`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, httpStatusCode, errorResponse }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + ////////////////// + // Every other error + ////////////////// + // case TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR: { + // return <Attention type="danger" title={i18n.str``}> + // </Attention> + // } + ////////////////// + // Default message for unhandled case + ////////////////// + default: return <Attention type="danger" title={i18n.str`Unexpected error`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify(error.errorDetail, undefined, 2)} + </pre> + } + </Attention> + } +} + diff --git a/packages/web-util/src/components/ErrorLoadingMerchant.tsx b/packages/web-util/src/components/ErrorLoadingMerchant.tsx new file mode 100644 index 000000000..7089266b9 --- /dev/null +++ b/packages/web-util/src/components/ErrorLoadingMerchant.tsx @@ -0,0 +1,147 @@ +/* +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { TalerError, TalerErrorCode, assertUnreachable } from "@gnu-taler/taler-util"; +import { Fragment, VNode, h } from "preact"; +import { Attention } from "./Attention.js"; +import { useTranslationContext } from "../index.browser.js"; + +export function ErrorLoading({ error, showDetail }: { error: TalerError, showDetail?: boolean }): VNode { + const { i18n } = useTranslationContext() + switch (error.errorDetail.code) { + ////////////////// + // Every error that can be produce in a Http Request + ////////////////// + case TalerErrorCode.GENERIC_TIMEOUT: { + if (error.hasErrorCode(TalerErrorCode.GENERIC_TIMEOUT)) { + const { requestMethod, requestUrl, timeoutMs } = error.errorDetail + return <Attention type="danger" title={i18n.str`The request reached a timeout, check your connection.`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, timeoutMs }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + case TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR: { + if (error.hasErrorCode(TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR)) { + const { requestMethod, requestUrl, timeoutMs } = error.errorDetail + return <Attention type="danger" title={i18n.str`The request was cancelled.`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, timeoutMs }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + case TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT: { + if (error.hasErrorCode(TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT)) { + const { requestMethod, requestUrl, timeoutMs } = error.errorDetail + return <Attention type="danger" title={i18n.str`The request reached a timeout, check your connection.`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, timeoutMs }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + case TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED: { + if (error.hasErrorCode(TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED)) { + const { requestMethod, requestUrl, throttleStats } = error.errorDetail + return <Attention type="danger" title={i18n.str`A lot of request were made to the same server and this action was throttled`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, throttleStats }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + case TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE: { + if (error.hasErrorCode(TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE)) { + const { requestMethod, requestUrl, httpStatusCode, validationError } = error.errorDetail + return <Attention type="danger" title={i18n.str`The response of the request is malformed.`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, httpStatusCode, validationError }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + case TalerErrorCode.WALLET_NETWORK_ERROR: { + if (error.hasErrorCode(TalerErrorCode.WALLET_NETWORK_ERROR)) { + const { requestMethod, requestUrl } = error.errorDetail + return <Attention type="danger" title={i18n.str`Could not complete the request due to a network problem.`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + case TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR: { + if (error.hasErrorCode(TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR)) { + const { requestMethod, requestUrl, httpStatusCode, errorResponse } = error.errorDetail + return <Attention type="danger" title={i18n.str`Unexpected request error`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, httpStatusCode, errorResponse }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + ////////////////// + // Every other error + ////////////////// + // case TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR: { + // return <Attention type="danger" title={i18n.str``}> + // </Attention> + // } + ////////////////// + // Default message for unhandled case + ////////////////// + default: return <Attention type="danger" title={i18n.str`Unexpected error`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify(error.errorDetail, undefined, 2)} + </pre> + } + </Attention> + } +} + diff --git a/packages/web-util/src/components/Footer.tsx b/packages/web-util/src/components/Footer.tsx new file mode 100644 index 000000000..58dd2a60a --- /dev/null +++ b/packages/web-util/src/components/Footer.tsx @@ -0,0 +1,48 @@ +import { useTranslationContext } from "../index.browser.js"; +import { h } from "preact"; + +export function Footer({ testingUrlKey, VERSION, GIT_HASH }: { VERSION?: string, GIT_HASH?: string, testingUrlKey?: string }) { + const { i18n } = useTranslationContext() + + const testingUrl = (testingUrlKey && typeof localStorage !== "undefined") && localStorage.getItem(testingUrlKey) ? + localStorage.getItem(testingUrlKey) ?? undefined : + undefined + const versionText = VERSION + ? GIT_HASH + ? <a href={`https://git.taler.net/wallet-core.git/tree/?id=${GIT_HASH}`} target="_blank" rel="noreferrer noopener"> + Version {VERSION} ({GIT_HASH.substring(0, 8)}) + </a> + : VERSION + : ""; + return ( + <footer class="bottom-4 my-4 mx-8 bg-slate-200"> + <div> + <p class="text-xs leading-5 text-gray-400"> + <i18n.Translate> + Learn more about <a target="_blank" rel="noreferrer noopener" class="font-semibold text-gray-500 hover:text-gray-400" href="https://taler.net">GNU Taler</a> + </i18n.Translate> + </p> + </div> + <div style="flex-grow:1" /> + <p class="text-xs leading-5 text-gray-400"> + Copyright © 2014—2023 Taler Systems SA. {versionText}{" "} + </p> + {testingUrlKey && testingUrl && + + <p class="text-xs leading-5 text-gray-300"> + Testing with {testingUrl}{" "} + <a + href="" + onClick={(e) => { + e.preventDefault(); + localStorage.removeItem(testingUrlKey); + window.location.reload(); + }} + > + stop testing + </a> + </p> + } + </footer> + ); +} diff --git a/packages/web-util/src/components/Header.tsx b/packages/web-util/src/components/Header.tsx new file mode 100644 index 000000000..29f4a4949 --- /dev/null +++ b/packages/web-util/src/components/Header.tsx @@ -0,0 +1,183 @@ +import { useState } from "preact/hooks"; +import { LangSelector, useNotifications, useTranslationContext } from "../index.browser.js"; +import { ComponentChildren, Fragment, VNode, h } from "preact"; +import logo from "../assets/logo-2021.svg"; + +interface Props { + title: string; + iconLinkURL: string; + profileURL?: string; + notificationURL?: string; + children?: ComponentChildren; + onLogout: (() => void) | undefined; + sites: Array<Array<string>>; + supportedLangs: string[] +} + +export function Header({ title, profileURL, notificationURL, iconLinkURL, sites, onLogout, children }: Props): VNode { + const { i18n } = useTranslationContext(); + const [open, setOpen] = useState(false) + const ns = useNotifications(); + + return <Fragment> + <header class="bg-indigo-600 w-full mx-auto px-2 border-b border-opacity-25 border-indigo-400"> + <div class="flex flex-row h-16 items-center "> + <div class="flex px-2 justify-start"> + <div class="flex-shrink-0 bg-white rounded-lg"> + <a href={iconLinkURL ?? "#"} name="logo"> + <img + class="h-8 w-auto" + src={logo} + alt="GNU Taler" + style={{ height: "1.5rem", margin: ".5rem" }} + /> + </a> + </div> + <span class="flex items-center text-white text-lg font-bold ml-4"> + {title} + </span> + </div> + <div class="flex-1 ml-6 "> + <div class="flex flex-1 space-x-4"> + {sites.map((site) => { + if (site.length !== 2) return; + const [name, url] = site + return <a href={url} name={`site header ${name}`} class="hidden sm:block text-white hover:bg-indigo-500 hover:bg-opacity-75 rounded-md py-2 px-3 text-sm font-medium">{name}</a> + })} + </div> + </div> + <div class="flex justify-end"> + {!notificationURL ? undefined : + <a href={notificationURL} name="notifications" class="relative inline-flex items-center justify-center rounded-md bg-indigo-600 p-1 mr-2 text-indigo-200 hover:bg-indigo-500 hover:bg-opacity-75 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600" aria-controls="mobile-menu" aria-expanded="false"> + <span class="absolute -inset-0.5"></span> + <span class="sr-only"><i18n.Translate>Show notifications</i18n.Translate></span> + {ns.length > 0 ? + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-10 h-10"> + <path d="M5.85 3.5a.75.75 0 0 0-1.117-1 9.719 9.719 0 0 0-2.348 4.876.75.75 0 0 0 1.479.248A8.219 8.219 0 0 1 5.85 3.5ZM19.267 2.5a.75.75 0 1 0-1.118 1 8.22 8.22 0 0 1 1.987 4.124.75.75 0 0 0 1.48-.248A9.72 9.72 0 0 0 19.266 2.5Z" /> + <path fill-rule="evenodd" d="M12 2.25A6.75 6.75 0 0 0 5.25 9v.75a8.217 8.217 0 0 1-2.119 5.52.75.75 0 0 0 .298 1.206c1.544.57 3.16.99 4.831 1.243a3.75 3.75 0 1 0 7.48 0 24.583 24.583 0 0 0 4.83-1.244.75.75 0 0 0 .298-1.205 8.217 8.217 0 0 1-2.118-5.52V9A6.75 6.75 0 0 0 12 2.25ZM9.75 18c0-.034 0-.067.002-.1a25.05 25.05 0 0 0 4.496 0l.002.1a2.25 2.25 0 1 1-4.5 0Z" clip-rule="evenodd" /> + </svg> + : + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-10 h-10"> + <path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" /> + </svg> + } + </a> + } + {!profileURL ? undefined : + <a href={profileURL} name="profile" class="relative inline-flex items-center justify-center rounded-md bg-indigo-600 p-1 mr-2 text-indigo-200 hover:bg-indigo-500 hover:bg-opacity-75 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600" aria-controls="mobile-menu" aria-expanded="false"> + <span class="absolute -inset-0.5"></span> + <span class="sr-only"><i18n.Translate>Open profile</i18n.Translate></span> + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-10 h-10"> + <path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /> + </svg> + </a> + } + <button type="button" name="toggle sidebar" class="relative inline-flex items-center justify-center rounded-md bg-indigo-600 p-1 text-indigo-200 hover:bg-indigo-500 hover:bg-opacity-75 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600" aria-controls="mobile-menu" aria-expanded="false" + onClick={(e) => { + setOpen(!open) + }}> + <span class="absolute -inset-0.5"></span> + <span class="sr-only"><i18n.Translate>Open settings</i18n.Translate></span> + <svg class="block h-10 w-10" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /> + </svg> + </button> + </div> + </div> + </header> + + { + open && + <div class="relative z-10" name="sidebar overlay" aria-labelledby="slide-over-title" role="dialog" aria-modal="true" + onClick={() => { + setOpen(false) + }}> + <div class="fixed inset-0"></div> + + <div class="fixed inset-0 overflow-hidden"> + <div class="absolute inset-0 overflow-hidden"> + <div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10"> + <div class="pointer-events-auto w-screen max-w-md" > + <div class="flex h-full flex-col overflow-y-scroll bg-white py-6 shadow-xl" onClick={(e) => { + //do not trigger close if clicking inside the sidebar + e.stopPropagation(); + }}> + <div class="px-4 sm:px-6" > + <div class="flex items-start justify-between" > + <h2 class="text-base font-semibold leading-6 text-gray-900" id="slide-over-title"> + <i18n.Translate>Menu</i18n.Translate> + </h2> + <div class="ml-3 flex h-7 items-center"> + <button type="button" name="close sidebar" class="relative rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" + onClick={(e) => { + setOpen(false) + }} + + > + <span class="absolute -inset-2.5"></span> + <span class="sr-only"> + <i18n.Translate>Close panel</i18n.Translate> + </span> + <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> + </svg> + </button> + </div> + </div> + </div> + <div class="relative mt-6 flex-1 px-4 sm:px-6"> + <nav class="flex flex-1 flex-col" aria-label="Sidebar"> + <ul role="list" class="flex flex-1 flex-col gap-y-7"> + {onLogout ? + <li> + <a href="#" + name="logout" + class="text-gray-700 hover:text-indigo-600 hover:bg-gray-100 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold" + onClick={() => { + onLogout(); + setOpen(false) + }} + > + <svg class="h-6 w-6 shrink-0 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /> + </svg> + <i18n.Translate>Log out</i18n.Translate> + </a> + </li> + : undefined} + <li> + <LangSelector /> + </li> + {/* CHILDREN */} + {children} + {/* /CHILDREN */} + {sites.length > 0 ? + <li class="block sm:hidden"> + <div class="text-xs font-semibold leading-6 text-gray-400"> + <i18n.Translate>Sites</i18n.Translate> + </div> + <ul role="list" class="space-y-1"> + {sites.map(([name, url]) => { + return <li> + <a href={url} name={`site ${name}`} target="_blank" rel="noopener noreferrer" class="text-gray-700 hover:text-indigo-600 hover:bg-gray-100 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"> + <span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border text-[0.625rem] font-medium bg-white text-gray-400 border-gray-200 group-hover:border-indigo-600 group-hover:text-indigo-600">></span> + <span class="truncate">{name}</span> + </a> + </li> + })} + </ul> + </li> + : undefined + } + </ul> + </nav> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + } + </Fragment > +} diff --git a/packages/web-util/src/components/LangSelector.tsx b/packages/web-util/src/components/LangSelector.tsx new file mode 100644 index 000000000..8e5a82f75 --- /dev/null +++ b/packages/web-util/src/components/LangSelector.tsx @@ -0,0 +1,115 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { Fragment, h, VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; +// import { strings as messages } from "../i18n/strings.js"; +import langIcon from "../assets/lang.svg"; +import { useTranslationContext } from "../index.browser.js"; + +type LangsNames = { + [P: string]: string; +}; + +const names: LangsNames = { + es: "Español [es]", + en: "English [en]", + fr: "Français [fr]", + de: "Deutsch [de]", + sv: "Svenska [sv]", + it: "Italiano [it]", +}; + +function getLangName(s: keyof LangsNames | string): string { + if (names[s]) return names[s]; + return String(s); +} + +export function LangSelector({ }: {}): VNode { + const [updatingLang, setUpdatingLang] = useState(false); + const { lang, changeLanguage, completeness, supportedLang } = useTranslationContext(); + const [hidden, setHidden] = useState(true); + + useEffect(() => { + function bodyKeyPress(event: KeyboardEvent) { + if (event.code === "Escape") setHidden(true); + } + function bodyOnClick(event: Event) { + setHidden(true); + } + document.body.addEventListener("click", bodyOnClick); + document.body.addEventListener("keydown", bodyKeyPress as any); + return () => { + document.body.removeEventListener("keydown", bodyKeyPress as any); + document.body.removeEventListener("click", bodyOnClick); + }; + }, []); + return ( + <div> + <div class="relative mt-2"> + <button type="button" class="relative w-full cursor-default rounded-md bg-white py-1.5 pl-3 pr-10 text-left text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" + onClick={(e) => { + setHidden(!hidden); + e.stopPropagation() + }}> + <span class="flex items-center"> + <img alt="language" class="h-5 w-5 flex-shrink-0 rounded-full" src={langIcon} /> + <span class="ml-3 block truncate">{getLangName(lang)}</span> + </span> + <span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> + <svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" clip-rule="evenodd" /> + </svg> + </span> + </button> + + {!hidden && + <ul class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" tabIndex={-1} role="listbox" aria-labelledby="listbox-label" aria-activedescendant="listbox-option-3"> + {Object.keys(supportedLang) + .filter((l) => l !== lang) + .map((lang) => ( + <li class="text-gray-900 hover:bg-indigo-600 hover:text-white cursor-pointer relative select-none py-2 pl-3 pr-9" role="option" + onClick={() => { + changeLanguage(lang); + setUpdatingLang(false); + setHidden(true) + }} + > + <span class="font-normal truncate flex justify-between "> + <span>{getLangName(lang)}</span> + <span>{(completeness as any)[lang]}%</span> + </span> + + <span class="text-indigo-600 absolute inset-y-0 right-0 flex items-center pr-4"> + {/* <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" /> + </svg> */} + </span> + </li> + ))} + + </ul> + } + + </div> + </div> + ); +} diff --git a/packages/web-util/src/components/Loading.tsx b/packages/web-util/src/components/Loading.tsx new file mode 100644 index 000000000..c5dcd90c1 --- /dev/null +++ b/packages/web-util/src/components/Loading.tsx @@ -0,0 +1,45 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { h, VNode } from "preact"; + +export function Loading(): VNode { + return ( + <div + class="columns is-centered is-vcentered" + style={{ + width: "100%", + height: "200px", + display: "flex", + margin: "auto", + justifyContent: "center", + }} + > + <Spinner /> + </div> + ); +} + +function Spinner(): VNode { + return ( + <div class="lds-ring" style={{ margin: "auto" }}> + <div /> + <div /> + <div /> + <div /> + </div> + ); +} diff --git a/packages/web-util/src/components/NotificationBanner.tsx b/packages/web-util/src/components/NotificationBanner.tsx new file mode 100644 index 000000000..31d5a5d01 --- /dev/null +++ b/packages/web-util/src/components/NotificationBanner.tsx @@ -0,0 +1,33 @@ +import { h, Fragment, VNode } from "preact"; +import { Attention } from "./Attention.js"; +import { Notification } from "../index.browser.js"; + +export function LocalNotificationBanner({ notification, showDebug }: { notification?: Notification, showDebug?: boolean }): VNode { + if (!notification) return <Fragment /> + switch (notification.message.type) { + case "error": + return <div class="relative"> + <div class="fixed top-0 left-0 right-0 z-20 w-full p-4"> + <Attention type="danger" title={notification.message.title} onClose={() => { + notification.acknowledge() + }}> + {notification.message.description && + <div class="mt-2 text-sm text-red-700"> + {notification.message.description} + </div> + } + {showDebug && <pre class="whitespace-break-spaces "> + {notification.message.debug} + </pre>} + </Attention> + </div> + </div> + case "info": + return <div class="relative"> + <div class="fixed top-0 left-0 right-0 z-20 w-full p-4"> + <Attention type="success" title={notification.message.title} onClose={() => { + notification.acknowledge(); + }} /></div></div> + } +} + diff --git a/packages/web-util/src/components/ShowInputErrorLabel.tsx b/packages/web-util/src/components/ShowInputErrorLabel.tsx new file mode 100644 index 000000000..c5840cad9 --- /dev/null +++ b/packages/web-util/src/components/ShowInputErrorLabel.tsx @@ -0,0 +1,29 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { Fragment, h, VNode } from "preact"; + +export function ShowInputErrorLabel({ + isDirty, + message, +}: { + message: string | undefined; + isDirty: boolean; +}): VNode { + if (message && isDirty) + return <div class="text-base" style={{ color: "red" }}>{message}</div>; + return <div class="text-base" style={{ }}> </div>; +} diff --git a/packages/web-util/src/components/ToastBanner.tsx b/packages/web-util/src/components/ToastBanner.tsx new file mode 100644 index 000000000..ece26285f --- /dev/null +++ b/packages/web-util/src/components/ToastBanner.tsx @@ -0,0 +1,61 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { Fragment, VNode, h } from "preact" +import { Attention, GLOBAL_NOTIFICATION_TIMEOUT as GLOBAL_TOAST_TIMEOUT, Notification, useNotifications } from "../index.browser.js" + +/** + * Toasts should be considered when displaying these types of information to the user: + * + * Low attention messages that do not require user action + * Singular status updates + * Confirmations + * Information that does not need to be followed up + * + * Do not use toasts if the information contains the following: + * + * High attention and crtitical information + * Time-sensitive information + * Requires user action or input + * Batch updates + * + * @returns + */ +export function ToastBanner(): VNode { + const notifs = useNotifications() + if (notifs.length === 0) return <Fragment /> + const show = notifs.filter(e => !e.message.ack && !e.message.timeout) + if (show.length === 0) return <Fragment /> + return <AttentionByType msg={show[0]} /> +} + +function AttentionByType({ msg }: { msg: Notification }) { + switch (msg.message.type) { + case "error": + return <Attention type="danger" title={msg.message.title} onClose={() => { + msg.acknowledge() + }} timeout={GLOBAL_TOAST_TIMEOUT}> + {msg.message.description && + <div class="mt-2 text-sm text-red-700"> + {msg.message.description} + </div> + } + </Attention> + case "info": + return <Attention type="success" title={msg.message.title} onClose={() => { + msg.acknowledge(); + }} timeout={GLOBAL_TOAST_TIMEOUT} /> + } +} diff --git a/packages/web-util/src/components/index.ts b/packages/web-util/src/components/index.ts new file mode 100644 index 000000000..d7ea41874 --- /dev/null +++ b/packages/web-util/src/components/index.ts @@ -0,0 +1,12 @@ +export * as utils from "./utils.js"; +export * from "./Attention.js"; +export * from "./CopyButton.js"; +export * from "./ErrorLoading.js"; +export * from "./LangSelector.js"; +export * from "./Loading.js"; +export * from "./Header.js"; +export * from "./Footer.js"; +export * from "./Button.js"; +export * from "./ShowInputErrorLabel.js"; +export * from "./NotificationBanner.js"; +export * from "./ToastBanner.js"; diff --git a/packages/web-util/src/components/utils.ts b/packages/web-util/src/components/utils.ts new file mode 100644 index 000000000..75c3fc0fe --- /dev/null +++ b/packages/web-util/src/components/utils.ts @@ -0,0 +1,111 @@ +import { createElement, VNode } from "preact"; + +export type StateFunc<S> = (p: S) => VNode; + +export type StateViewMap<StateType extends { status: string }> = { + [S in StateType as S["status"]]: StateFunc<S>; +}; + +export type RecursiveState<S extends object> = S | (() => RecursiveState<S>); + +export function compose<SType extends { status: string }, PType>( + hook: (p: PType) => RecursiveState<SType>, + viewMap: StateViewMap<SType>, +): (p: PType) => VNode { + + function withHook(stateHook: () => RecursiveState<SType>): () => VNode { + function ComposedComponent(): VNode { + const state = stateHook(); + + if (typeof state === "function") { + const subComponent = withHook(state); + return createElement(subComponent, {}); + } + + const statusName = state.status as unknown as SType["status"]; + const viewComponent = viewMap[statusName] as unknown as StateFunc<SType>; + return createElement(viewComponent, state); + } + + return ComposedComponent; + } + + return (p: PType) => { + const h = withHook(() => hook(p)); + return h(); + }; +} + +export function recursive<PType>( + hook: (p: PType) => RecursiveState<VNode>, +): (p: PType) => VNode { + + function withHook(stateHook: () => RecursiveState<VNode>): () => VNode { + function ComposedComponent(): VNode { + const state = stateHook(); + + if (typeof state === "function") { + const subComponent = withHook(state); + return createElement(subComponent, {}); + } + + return state; + } + + return ComposedComponent; + } + + return (p: PType) => { + const h = withHook(() => hook(p)); + return h(); + }; +} + + + +/** + * + * @param obj VNode + * @returns + */ +export function saveVNodeForInspection<T>(obj: T): T { + // @ts-ignore + window["showVNodeInfo"] = function showVNodeInfo() { + inspect(obj); + }; + return obj; +} +function inspect(obj: any) { + if (!obj) return; + if (obj.__c && obj.__c.__H) { + const componentName = obj.__c.constructor.name; + const hookState = obj.__c.__H; + const stateList = hookState.__ as Array<any>; + console.log("==============", componentName); + stateList.forEach((hook) => { + const { __: value, c: context, __h: factory, __H: args } = hook; + if (typeof context !== "undefined") { + const { __c: contextId } = context; + console.log("context:", contextId, hook); + } else if (typeof factory === "function") { + console.log("memo:", value, "deps:", args); + } else if (typeof value === "function") { + const effectName = value.name; + console.log("effect:", effectName, "deps:", args); + } else if (typeof value.current !== "undefined") { + const ref = value.current; + console.log("ref:", ref instanceof Element ? ref.outerHTML : ref); + } else if (value instanceof Array) { + console.log("state:", value[0]); + } else { + console.log(hook); + } + }); + } + const children = obj.__k; + if (children instanceof Array) { + children.forEach((e) => inspect(e)); + } else { + inspect(children); + } +} diff --git a/packages/web-util/src/context/activity.ts b/packages/web-util/src/context/activity.ts new file mode 100644 index 000000000..d12d1efb6 --- /dev/null +++ b/packages/web-util/src/context/activity.ts @@ -0,0 +1,76 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { ChallengerHttpClient, ObservabilityEvent, TalerAuthenticationHttpClient, TalerBankConversionHttpClient, TalerCoreBankHttpClient, TalerExchangeHttpClient, TalerMerchantInstanceHttpClient, TalerMerchantManagementHttpClient } from "@gnu-taler/taler-util"; + +type Listener<Event> = (e: Event) => void; +type Unsuscriber = () => void; +export type Subscriber<Event> = (fn: Listener<Event>) => Unsuscriber; + +export class ActiviyTracker<Event> { + private observers = new Array<Listener<Event>>(); + constructor() { + this.notify = this.notify.bind(this) + this.subscribe = this.subscribe.bind(this) + } + notify(data: Event): void { + this.observers.forEach((observer) => observer(data)) + } + subscribe(func: Listener<Event>): Unsuscriber { + this.observers.push(func); + return () => { + this.observers.forEach((observer, index) => { + if (observer === func) { + this.observers.splice(index, 1); + } + }); + }; + } +} + +/** + * build http client with cache breaker due to SWR + * @param url + * @returns + */ +export interface APIClient<T, C> { + getRemoteConfig(): Promise<C>; + VERSION: string; + lib: T, + onActivity: Subscriber<ObservabilityEvent>; + cancelRequest(id: string): void; +} + +export interface MerchantLib { + instance: TalerMerchantManagementHttpClient; + authenticate: TalerAuthenticationHttpClient; + subInstanceApi: (instanceId: string) => MerchantLib; +} + +export interface ExchangeLib { + exchange: TalerExchangeHttpClient; +} + +export interface BankLib { + bank: TalerCoreBankHttpClient; + conversion: TalerBankConversionHttpClient; + auth: (user: string) => TalerAuthenticationHttpClient; +} + +export interface ChallengerLib { + challenger: ChallengerHttpClient; +} + diff --git a/packages/web-util/src/context/api.ts b/packages/web-util/src/context/api.ts new file mode 100644 index 000000000..c1eaa37f8 --- /dev/null +++ b/packages/web-util/src/context/api.ts @@ -0,0 +1,49 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { TalerBankIntegrationHttpClient, TalerCoreBankHttpClient, TalerRevenueHttpClient, TalerWireGatewayHttpClient } from "@gnu-taler/taler-util"; +import { ComponentChildren, createContext, h, VNode } from "preact"; +import { useContext } from "preact/hooks"; +import { defaultRequestHandler } from "../utils/request.js"; + +interface Type { + /** + * @deprecated this show not be used + */ + request: typeof defaultRequestHandler; + bankCore: TalerCoreBankHttpClient, + bankIntegration: TalerBankIntegrationHttpClient, + bankWire: TalerWireGatewayHttpClient, + bankRevenue: TalerRevenueHttpClient, +} + +const Context = createContext<Type>({ request: defaultRequestHandler } as any); + +export const useApiContext = (): Type => useContext(Context); +export const ApiContextProvider = ({ + children, + value, +}: { + value: Type; + children: ComponentChildren; +}): VNode => { + return h(Context.Provider, { value, children }); +}; diff --git a/packages/web-util/src/context/bank-api.ts b/packages/web-util/src/context/bank-api.ts new file mode 100644 index 000000000..3f6a32f4b --- /dev/null +++ b/packages/web-util/src/context/bank-api.ts @@ -0,0 +1,224 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + CacheEvictor, + LibtoolVersion, + ObservabilityEvent, + ObservableHttpClientLibrary, + TalerAuthenticationHttpClient, + TalerBankConversionCacheEviction, + TalerBankConversionHttpClient, + TalerCoreBankCacheEviction, + TalerCoreBankHttpClient, + TalerCorebankApi, + TalerError, +} from "@gnu-taler/taler-util"; +import { + ComponentChildren, + FunctionComponent, + VNode, + createContext, + h, +} from "preact"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { APIClient, ActiviyTracker, BankLib, Subscriber } from "./activity.js"; +import { useTranslationContext } from "./translation.js"; +import { BrowserFetchHttpLib, ErrorLoading } from "../index.browser.js"; + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +export type BankContextType = { + url: URL; + config: TalerCorebankApi.Config; + lib: BankLib; + hints: VersionHint[]; + onActivity: Subscriber<ObservabilityEvent>; + cancelRequest: (eventId: string) => void; +}; + +// @ts-expect-error default value to undefined, should it be another thing? +const BankContext = createContext<BankContextType>(undefined); + +export const useBankCoreApiContext = (): BankContextType => + useContext(BankContext); + +enum VersionHint { + NONE, +} + +type Evictors = { + conversion?: CacheEvictor<TalerBankConversionCacheEviction>; + bank?: CacheEvictor<TalerCoreBankCacheEviction>; +}; + +type ConfigResult<T> = + | undefined + | { type: "ok"; config: T; hints: VersionHint[] } + | { type: "incompatible"; result: T; supported: string } + | { type: "error"; error: TalerError }; + +const CONFIG_FAIL_TRY_AGAIN_MS = 5000; + +export const BankApiProvider = ({ + baseUrl, + children, + frameOnError, + evictors = {}, +}: { + baseUrl: URL; + children: ComponentChildren; + evictors?: Evictors; + frameOnError: FunctionComponent<{ children: ComponentChildren }>; +}): VNode => { + const [checked, setChecked] = + useState<ConfigResult<TalerCorebankApi.Config>>(); + const { i18n } = useTranslationContext(); + + const { getRemoteConfig, VERSION, lib, cancelRequest, onActivity } = + buildBankApiClient(baseUrl, evictors); + + useEffect(() => { + let keepRetrying = true; + async function testConfig(): Promise<void> { + try { + const config = await getRemoteConfig(); + if (LibtoolVersion.compare(VERSION, config.version)) { + setChecked({ type: "ok", config, hints: [] }); + } else { + setChecked({ + type: "incompatible", + result: config, + supported: VERSION, + }); + } + } catch (error) { + if (error instanceof TalerError) { + if (keepRetrying) { + setTimeout(() => { + testConfig(); + }, CONFIG_FAIL_TRY_AGAIN_MS); + } + setChecked({ type: "error", error }); + } else { + setChecked({ type: "error", error: TalerError.fromException(error) }); + } + } + } + testConfig(); + return () => { + // on unload, stop retry + keepRetrying = false; + }; + }, []); + + if (checked === undefined) { + return h(frameOnError, { + children: h("div", {}, "checking compatibility with server..."), + }); + } + if (checked.type === "error") { + return h(frameOnError, { + children: h(ErrorLoading, { error: checked.error, showDetail: true }), + }); + } + if (checked.type === "incompatible") { + return h(frameOnError, { + children: h( + "div", + {}, + i18n.str`The server version is not supported. Supported version "${checked.supported}", server version "${checked.result.version}"`, + ), + }); + } + + const value: BankContextType = { + url: baseUrl, + config: checked.config, + onActivity: onActivity, + lib, + cancelRequest, + hints: checked.hints, + }; + return h(BankContext.Provider, { + value, + children, + }); +}; + +function buildBankApiClient( + url: URL, + evictors: Evictors, +): APIClient<BankLib, TalerCorebankApi.Config> { + const httpFetch = new BrowserFetchHttpLib({ + enableThrottling: true, + requireTls: false, + }); + const tracker = new ActiviyTracker<ObservabilityEvent>(); + const httpLib = new ObservableHttpClientLibrary(httpFetch, { + observe(ev) { + tracker.notify(ev); + }, + }); + + const bank = new TalerCoreBankHttpClient(url.href, httpLib, evictors.bank); + const conversion = new TalerBankConversionHttpClient( + bank.getConversionInfoAPI().href, + httpLib, + evictors.conversion, + ); + const auth = (user: string) => + new TalerAuthenticationHttpClient( + bank.getAuthenticationAPI(user).href, + httpLib, + ); + + async function getRemoteConfig(): Promise<TalerCorebankApi.Config> { + const resp = await bank.getConfig(); + if (resp.type === "fail") { + throw TalerError.fromUncheckedDetail(resp.detail); + } + return resp.body; + } + + return { + getRemoteConfig, + VERSION: bank.PROTOCOL_VERSION, + lib: { + bank, + conversion, + auth, + }, + onActivity: tracker.subscribe, + cancelRequest: httpLib.cancelRequest, + }; +} + +export const BankApiProviderTesting = ({ + children, + value, +}: { + value: BankContextType; + children: ComponentChildren; +}): VNode => { + return h(BankContext.Provider, { + value, + children, + }); +}; diff --git a/packages/web-util/src/context/challenger-api.ts b/packages/web-util/src/context/challenger-api.ts new file mode 100644 index 000000000..8748f5f69 --- /dev/null +++ b/packages/web-util/src/context/challenger-api.ts @@ -0,0 +1,213 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + CacheEvictor, + ChallengerApi, + ChallengerCacheEviction, + ChallengerHttpClient, + LibtoolVersion, + ObservabilityEvent, + ObservableHttpClientLibrary, + TalerError +} from "@gnu-taler/taler-util"; +import { + ComponentChildren, + FunctionComponent, + VNode, + createContext, + h, +} from "preact"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { BrowserFetchHttpLib, ErrorLoading } from "../index.browser.js"; +import { + APIClient, + ActiviyTracker, + ChallengerLib, + Subscriber +} from "./activity.js"; +import { useTranslationContext } from "./translation.js"; + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +export type ChallengerContextType = { + url: URL; + config: ChallengerApi.ChallengerTermsOfServiceResponse; + lib: ChallengerLib; + hints: VersionHint[]; + onActivity: Subscriber<ObservabilityEvent>; + cancelRequest: (eventId: string) => void; +}; + +// @ts-expect-error default value to undefined, should it be another thing? +const ChallengerContext = createContext<ChallengerContextType>(undefined); + +export const useChallengerApiContext = (): ChallengerContextType => + useContext(ChallengerContext); + +enum VersionHint { + NONE, +} + +type Evictors = { + challenger?: CacheEvictor<ChallengerCacheEviction>; +} + +type ConfigResult<T> = + | undefined + | { type: "ok"; config: T; hints: VersionHint[] } + | { type: "incompatible"; result: T; supported: string } + | { type: "error"; error: TalerError }; + +const CONFIG_FAIL_TRY_AGAIN_MS = 5000; + +export const ChallengerApiProvider = ({ + baseUrl, + children, + frameOnError, + evictors = {}, +}: { + baseUrl: URL; + children: ComponentChildren; + evictors?: Evictors; + frameOnError: FunctionComponent<{ children: ComponentChildren }>; +}): VNode => { + const [checked, setChecked] = + useState<ConfigResult<ChallengerApi.ChallengerTermsOfServiceResponse>>(); + const { i18n } = useTranslationContext(); + + const { getRemoteConfig, VERSION, lib, cancelRequest, onActivity } = + buildChallengerApiClient(baseUrl, evictors); + + useEffect(() => { + let keepRetrying = true; + async function testConfig(): Promise<void> { + try { + const config = await getRemoteConfig(); + if (LibtoolVersion.compare(VERSION, config.version)) { + setChecked({ type: "ok", config, hints: [] }); + } else { + setChecked({ + type: "incompatible", + result: config, + supported: VERSION, + }); + } + } catch (error) { + if (error instanceof TalerError) { + if (keepRetrying) { + setTimeout(() => { + testConfig(); + }, CONFIG_FAIL_TRY_AGAIN_MS); + } + setChecked({ type: "error", error }); + } else { + setChecked({ type: "error", error: TalerError.fromException(error) }); + } + } + } + testConfig(); + return () => { + // on unload, stop retry + keepRetrying = false; + }; + }, []); + + if (checked === undefined) { + return h(frameOnError, { + children: h("div", {}, "checking compatibility with server..."), + }); + } + if (checked.type === "error") { + return h(frameOnError, { + children: h(ErrorLoading, { error: checked.error, showDetail: true }), + }); + } + if (checked.type === "incompatible") { + return h(frameOnError, { + children: h( + "div", + {}, + i18n.str`The server version is not supported. Supported version "${checked.supported}", server version "${checked.result.version}"`, + ), + }); + } + + const value: ChallengerContextType = { + url: baseUrl, + config: checked.config, + onActivity: onActivity, + lib, + cancelRequest, + hints: checked.hints, + }; + return h(ChallengerContext.Provider, { + value, + children, + }); +}; + +function buildChallengerApiClient( + url: URL, + evictors: Evictors, +): APIClient<ChallengerLib, ChallengerApi.ChallengerTermsOfServiceResponse> { + const httpFetch = new BrowserFetchHttpLib({ + enableThrottling: true, + requireTls: false, + }); + const tracker = new ActiviyTracker<ObservabilityEvent>(); + const httpLib = new ObservableHttpClientLibrary(httpFetch, { + observe(ev) { + tracker.notify(ev); + }, + }); + + const challenger = new ChallengerHttpClient(url.href, httpLib, evictors.challenger); + + async function getRemoteConfig(): Promise<ChallengerApi.ChallengerTermsOfServiceResponse> { + const resp = await challenger.getConfig(); + if (resp.type === "fail") { + throw TalerError.fromUncheckedDetail(resp.detail); + } + return resp.body; + } + + return { + getRemoteConfig, + VERSION: challenger.PROTOCOL_VERSION, + lib: { + challenger, + }, + onActivity: tracker.subscribe, + cancelRequest: httpLib.cancelRequest, + }; +} + +export const ChallengerApiProviderTesting = ({ + children, + value, +}: { + value: ChallengerContextType; + children: ComponentChildren; +}): VNode => { + return h(ChallengerContext.Provider, { + value, + children, + }); +}; diff --git a/packages/web-util/src/context/exchange-api.ts b/packages/web-util/src/context/exchange-api.ts new file mode 100644 index 000000000..39f889ba9 --- /dev/null +++ b/packages/web-util/src/context/exchange-api.ts @@ -0,0 +1,217 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + CacheEvictor, + LibtoolVersion, + ObservabilityEvent, + ObservableHttpClientLibrary, + TalerError, + TalerExchangeApi, + TalerExchangeCacheEviction, + TalerExchangeHttpClient +} from "@gnu-taler/taler-util"; +import { + ComponentChildren, + FunctionComponent, + VNode, + createContext, + h, +} from "preact"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { BrowserFetchHttpLib, ErrorLoading, useTranslationContext } from "../index.browser.js"; +import { + APIClient, + ActiviyTracker, + ExchangeLib, + Subscriber, +} from "./activity.js"; + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +export type ExchangeContextType = { + url: URL; + config: TalerExchangeApi.ExchangeVersionResponse; + lib: ExchangeLib; + hints: VersionHint[]; + onActivity: Subscriber<ObservabilityEvent>; + cancelRequest: (eventId: string) => void; +}; + +// FIXME: below +// @ts-expect-error default value to undefined, should it be another thing? +const ExchangeContext = createContext<ExchangeContextType>(undefined); + +export const useExchangeApiContext = (): ExchangeContextType => + useContext(ExchangeContext); + +enum VersionHint { + NONE, +} + +type Evictors = { + exchange?: CacheEvictor<TalerExchangeCacheEviction>; +}; + +type ConfigResult<T> = + | undefined + | { type: "ok"; config: T; hints: VersionHint[] } + | ConfigResultFail<T>; + +type ConfigResultFail<T> = + | { type: "incompatible"; result: T; supported: string } + | { type: "error"; error: TalerError }; + +const CONFIG_FAIL_TRY_AGAIN_MS = 5000; + +export const ExchangeApiProvider = ({ + baseUrl, + children, + evictors = {}, + frameOnError, +}: { + baseUrl: URL; + evictors?: Evictors; + children: ComponentChildren; + frameOnError: FunctionComponent<{ children: ComponentChildren }>; +}): VNode => { + const [checked, setChecked] = + useState<ConfigResult<TalerExchangeApi.ExchangeVersionResponse>>(); + const { i18n } = useTranslationContext(); + + const { getRemoteConfig, VERSION, lib, cancelRequest, onActivity } = + buildExchangeApiClient(baseUrl, evictors); + + useEffect(() => { + let keepRetrying = true; + async function testConfig(): Promise<void> { + try { + const config = await getRemoteConfig(); + if (LibtoolVersion.compare(VERSION, config.version)) { + setChecked({ type: "ok", config, hints: [] }); + } else { + setChecked({ + type: "incompatible", + result: config, + supported: VERSION, + }); + } + } catch (error) { + if (error instanceof TalerError) { + if (keepRetrying) { + setTimeout(() => { + testConfig(); + }, CONFIG_FAIL_TRY_AGAIN_MS); + } + setChecked({ type: "error", error }); + } else { + setChecked({ type: "error", error: TalerError.fromException(error) }); + } + } + } + testConfig(); + return () => { + // on unload, stop retry + keepRetrying = false; + }; + }, []); + + if (checked === undefined) { + return h(frameOnError, { + children: h("div", {}, "checking compatibility with server..."), + }); + } + if (checked.type === "error") { + return h(frameOnError, { + children: h(ErrorLoading, { error: checked.error, showDetail: true }), + }); + } + if (checked.type === "incompatible") { + return h(frameOnError, { + children: h( + "div", + {}, + i18n.str`The server version is not supported. Supported version "${checked.supported}", server version "${checked.result.version}"`, + ), + }); + } + + const value: ExchangeContextType = { + url: baseUrl, + config: checked.config, + onActivity: onActivity, + lib, + cancelRequest, + hints: checked.hints, + }; + return h(ExchangeContext.Provider, { + value, + children, + }); +}; + +function buildExchangeApiClient( + url: URL, + evictors: Evictors, +): APIClient<ExchangeLib, TalerExchangeApi.ExchangeVersionResponse> { + const httpFetch = new BrowserFetchHttpLib({ + enableThrottling: true, + requireTls: false, + }); + const tracker = new ActiviyTracker<ObservabilityEvent>(); + + const httpLib = new ObservableHttpClientLibrary(httpFetch, { + observe(ev) { + tracker.notify(ev); + }, + }); + + const ex = new TalerExchangeHttpClient(url.href, httpLib, evictors.exchange); + + async function getRemoteConfig(): Promise<TalerExchangeApi.ExchangeVersionResponse> { + const resp = await ex.getConfig(); + if (resp.type === "fail") { + throw TalerError.fromUncheckedDetail(resp.detail); + } + return resp.body; + } + + return { + getRemoteConfig, + VERSION: ex.PROTOCOL_VERSION, + lib: { + exchange: ex, + }, + onActivity: tracker.subscribe, + cancelRequest: httpLib.cancelRequest, + }; +} + +export const ExchangeApiProviderTesting = ({ + children, + value, +}: { + value: ExchangeContextType; + children: ComponentChildren; +}): VNode => { + return h(ExchangeContext.Provider, { + value, + children, + }); +}; diff --git a/packages/web-util/src/context/index.ts b/packages/web-util/src/context/index.ts new file mode 100644 index 000000000..7e30ecd09 --- /dev/null +++ b/packages/web-util/src/context/index.ts @@ -0,0 +1,12 @@ +export { ApiContextProvider, useApiContext } from "./api.js"; +export { + InternationalizationAPI, + TranslationProvider, + useTranslationContext +} from "./translation.js"; +export * from "./bank-api.js"; +export * from "./challenger-api.js"; +export * from "./merchant-api.js"; +export * from "./exchange-api.js"; +export * from "./navigation.js"; +export * from "./wallet-integration.js"; diff --git a/packages/web-util/src/context/merchant-api.ts b/packages/web-util/src/context/merchant-api.ts new file mode 100644 index 000000000..03c95d48e --- /dev/null +++ b/packages/web-util/src/context/merchant-api.ts @@ -0,0 +1,228 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + CacheEvictor, + LibtoolVersion, + ObservabilityEvent, + ObservableHttpClientLibrary, + TalerAuthenticationHttpClient, + TalerError, + TalerMerchantApi, + TalerMerchantInstanceCacheEviction, + TalerMerchantManagementCacheEviction, + TalerMerchantManagementHttpClient, +} from "@gnu-taler/taler-util"; +import { + ComponentChildren, + FunctionComponent, + VNode, + createContext, + h, +} from "preact"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { BrowserFetchHttpLib } from "../index.browser.js"; +import { + APIClient, + ActiviyTracker, + MerchantLib, + Subscriber, +} from "./activity.js"; + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +export type MerchantContextType = { + url: URL; + config: TalerMerchantApi.VersionResponse; + lib: MerchantLib; + hints: VersionHint[]; + onActivity: Subscriber<ObservabilityEvent>; + cancelRequest: (eventId: string) => void; + changeBackend: (url: URL) => void; +}; + +// FIXME: below +// @ts-expect-error default value to undefined, should it be another thing? +const MerchantContext = createContext<MerchantContextType>(undefined); + +export const useMerchantApiContext = (): MerchantContextType => + useContext(MerchantContext); + +enum VersionHint { + NONE, +} + +type Evictors = { + management?: CacheEvictor< + TalerMerchantManagementCacheEviction | TalerMerchantInstanceCacheEviction + >; +}; + +type ConfigResult<T> = + | undefined + | { type: "ok"; config: T; hints: VersionHint[] } + | ConfigResultFail<T>; + +export type ConfigResultFail<T> = + | { type: "incompatible"; result: T; supported: string } + | { type: "error"; error: TalerError }; + +const CONFIG_FAIL_TRY_AGAIN_MS = 5000; + +export const MerchantApiProvider = ({ + baseUrl, + children, + evictors = {}, + frameOnError, +}: { + baseUrl: URL; + evictors?: Evictors; + children: ComponentChildren; + frameOnError: FunctionComponent<{ + state: ConfigResultFail<TalerMerchantApi.VersionResponse> | undefined; + }>; +}): VNode => { + const [checked, setChecked] = + useState<ConfigResult<TalerMerchantApi.VersionResponse>>(); + + const [merchantEndpoint, changeMerchantEndpoint] = useState(baseUrl); + + const { getRemoteConfig, VERSION, lib, cancelRequest, onActivity } = + buildMerchantApiClient(merchantEndpoint, evictors); + + useEffect(() => { + let keepRetrying = true; + async function testConfig(): Promise<void> { + try { + const config = await getRemoteConfig(); + if (LibtoolVersion.compare(VERSION, config.version)) { + setChecked({ type: "ok", config, hints: [] }); + } else { + setChecked({ + type: "incompatible", + result: config, + supported: VERSION, + }); + } + } catch (error) { + if (error instanceof TalerError) { + if (keepRetrying) { + setTimeout(() => { + testConfig(); + }, CONFIG_FAIL_TRY_AGAIN_MS); + } + setChecked({ type: "error", error }); + } else { + setChecked({ type: "error", error: TalerError.fromException(error) }); + } + } + } + testConfig(); + return () => { + // on unload, stop retry + keepRetrying = false; + }; + }, []); + + if (!checked || checked.type !== "ok") { + return h(frameOnError, { state: checked }, []); + } + + const value: MerchantContextType = { + url: merchantEndpoint, + config: checked.config, + onActivity: onActivity, + lib, + cancelRequest, + changeBackend: changeMerchantEndpoint, + hints: checked.hints, + }; + return h(MerchantContext.Provider, { + value, + children, + }); +}; + +function buildMerchantApiClient( + url: URL, + evictors: Evictors, +): APIClient<MerchantLib, TalerMerchantApi.VersionResponse> { + const httpFetch = new BrowserFetchHttpLib({ + enableThrottling: true, + requireTls: false, + }); + const tracker = new ActiviyTracker<ObservabilityEvent>(); + + const httpLib = new ObservableHttpClientLibrary(httpFetch, { + observe(ev) { + tracker.notify(ev); + }, + }); + + const instance = new TalerMerchantManagementHttpClient( + url.href, + httpLib, + evictors.management, + ); + const authenticate = new TalerAuthenticationHttpClient( + instance.getAuthenticationAPI().href, + httpLib, + ); + + function getSubInstanceAPI(instanceId: string): MerchantLib { + const api = buildMerchantApiClient( + instance.getSubInstanceAPI(instanceId) as URL, + evictors, + ); + return api.lib; + } + + async function getRemoteConfig(): Promise<TalerMerchantApi.VersionResponse> { + const resp = await instance.getConfig(); + if (resp.type === "fail") { + throw TalerError.fromUncheckedDetail(resp.detail); + } + return resp.body; + } + + return { + getRemoteConfig, + VERSION: instance.PROTOCOL_VERSION, + lib: { + instance, + authenticate, + subInstanceApi: getSubInstanceAPI, + }, + onActivity: tracker.subscribe, + cancelRequest: httpLib.cancelRequest, + }; +} + +export const MerchantApiProviderTesting = ({ + children, + value, +}: { + value: MerchantContextType; + children: ComponentChildren; +}): VNode => { + return h(MerchantContext.Provider, { + value, + children, + }); +}; diff --git a/packages/web-util/src/context/navigation.ts b/packages/web-util/src/context/navigation.ts new file mode 100644 index 000000000..c2f2bbbc1 --- /dev/null +++ b/packages/web-util/src/context/navigation.ts @@ -0,0 +1,114 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { ComponentChildren, createContext, h, VNode } from "preact"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { + AppLocation, + ObjectOf, + Location, + findMatch, + RouteDefinition, +} from "../utils/route.js"; + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +export type Type = { + path: string; + params: Record<string, string[]>; + navigateTo: (path: AppLocation) => void; + // addNavigationListener: (listener: (path: string, params: Record<string, string>) => void) => (() => void); +}; + +// @ts-expect-error should not be used without provider +const Context = createContext<Type>(undefined); + +export const useNavigationContext = (): Type => useContext(Context); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useCurrentLocation<T extends ObjectOf<RouteDefinition<any>>>( + pagesMap: T, +): Location<T> | undefined { + const pageList = Object.keys(pagesMap as object) as Array<keyof T>; + const { path, params } = useNavigationContext(); + + return findMatch(pagesMap, pageList, path, params); +} + +function getPathAndParamsFromWindow(): { + path: string; + params: Record<string, string[]>; +} { + const path = + typeof window !== "undefined" ? window.location.hash.substring(1) : "/"; + const params: Record<string, string[]> = {}; + if (typeof window !== "undefined") { + for (const [key, value] of new URLSearchParams(window.location.search)) { + if (!params[key]) { + params[key] = []; + } + params[key].push(value); + } + } + return { path, params }; +} + +const { path: initialPath, params: initialParams } = + getPathAndParamsFromWindow(); + +// there is a possibility that if the browser does a redirection +// (which doesn't go through navigatTo function) and that executed +// too early (before addEventListener runs) it won't be taking +// into account +const PopStateEventType = "popstate"; + +export const BrowserHashNavigationProvider = ({ + children, +}: { + children: ComponentChildren; +}): VNode => { + const [{ path, params }, setState] = useState({ + path: initialPath, + params: initialParams, + }); + if (typeof window === "undefined") { + throw Error( + "Can't use BrowserHashNavigationProvider if there is no window object", + ); + } + function navigateTo(path: string): void { + const { params } = getPathAndParamsFromWindow(); + setState({ path, params }); + window.location.href = path; + } + + useEffect(() => { + function eventListener(): void { + setState(getPathAndParamsFromWindow()); + } + window.addEventListener(PopStateEventType, eventListener); + return () => { + window.removeEventListener(PopStateEventType, eventListener); + }; + }, []); + return h(Context.Provider, { + value: { path, params, navigateTo }, + children, + }); +}; diff --git a/packages/web-util/src/context/translation.ts b/packages/web-util/src/context/translation.ts new file mode 100644 index 000000000..2725dc7e1 --- /dev/null +++ b/packages/web-util/src/context/translation.ts @@ -0,0 +1,119 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { i18n, setupI18n } from "@gnu-taler/taler-util"; +import { ComponentChildren, createContext, h, VNode } from "preact"; +import { useContext, useEffect } from "preact/hooks"; +import { useLang } from "../hooks/index.js"; +import { Locale } from "date-fns"; +import { + es as esLocale, + enGB as enLocale, + fr as frLocale, + de as deLocale +} from "date-fns/locale" + +export type InternationalizationAPI = typeof i18n; + +interface Type { + lang: string; + supportedLang: { [id in keyof typeof supportedLang]: string }; + changeLanguage: (l: string) => void; + i18n: InternationalizationAPI; + dateLocale: Locale, + completeness: { [id in keyof typeof supportedLang]: number } +} + +const supportedLang = { + es: "Espanol [es]", + en: "English [en]", + fr: "Francais [fr]", + de: "Deutsch [de]", + sv: "Svenska [sv]", + it: "Italiane [it]", +}; + +const initial: Type = { + lang: "en", + supportedLang, + changeLanguage: () => { + // do not change anything + }, + i18n, + dateLocale: enLocale, + completeness: { + de: 0, + en: 0, + es: 0, + fr: 0, + it: 0, + sv: 0, + } +}; +const Context = createContext<Type>(initial); + +interface Props { + initial?: string; + children: ComponentChildren; + forceLang?: string; + source: Record<string, any>; + completeness?: Record<string, number>; +} + +// Outmost UI wrapper. +export const TranslationProvider = ({ + initial, + children, + forceLang, + source, + completeness: completenessProp +}: Props): VNode => { + const completeness = { + en: 100, + de: !completenessProp || !completenessProp["de"] ? 0 : completenessProp["de"], + es: !completenessProp || !completenessProp["es"] ? 0 : completenessProp["es"], + fr: !completenessProp || !completenessProp["fr"] ? 0 : completenessProp["fr"], + it: !completenessProp || !completenessProp["it"] ? 0 : completenessProp["it"], + sv: !completenessProp || !completenessProp["sv"] ? 0 : completenessProp["sv"], + } + const { value: lang, update: changeLanguage } = useLang(initial, completeness); + + useEffect(() => { + if (forceLang) { + changeLanguage(forceLang); + } + }); + useEffect(() => { + setupI18n(lang, source); + }, [lang]); + if (forceLang) { + setupI18n(forceLang, source); + } else { + setupI18n(lang, source); + } + + const dateLocale = lang === "es" ? esLocale : + lang === "fr" ? frLocale : + lang === "de" ? deLocale : + enLocale; + + return h(Context.Provider, { + value: { lang, changeLanguage, supportedLang, i18n, dateLocale, completeness }, + children, + }); +}; + +export const useTranslationContext = (): Type => useContext(Context); diff --git a/packages/web-util/src/context/wallet-integration.ts b/packages/web-util/src/context/wallet-integration.ts new file mode 100644 index 000000000..e14988ed1 --- /dev/null +++ b/packages/web-util/src/context/wallet-integration.ts @@ -0,0 +1,83 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { stringifyTalerUri, TalerUri } from "@gnu-taler/taler-util"; +import { ComponentChildren, createContext, h, VNode } from "preact"; +import { useContext } from "preact/hooks"; + +/** + * https://docs.taler.net/design-documents/039-taler-browser-integration.html + * + * @param uri + */ +function createHeadMetaTag(uri: TalerUri, onNotFound?: () => void) { + const meta = document.createElement("meta"); + meta.setAttribute("name", "taler-uri"); + meta.setAttribute("content", stringifyTalerUri(uri)); + + document.head.appendChild(meta); + + let walletFound = false; + window.addEventListener("beforeunload", () => { + walletFound = true; + }); + setTimeout(() => { + if (!walletFound && onNotFound) { + onNotFound(); + } + }, 10); //very short timeout +} +interface Type { + /** + * Tell the active wallet that an action is found + * + * @param uri + * @returns + */ + publishTalerAction: (uri: TalerUri, onNotFound?: () => void) => void; +} + +// @ts-expect-error default value to undefined, should it be another thing? +const Context = createContext<Type>(undefined); + +export const useTalerWalletIntegrationAPI = (): Type => useContext(Context); + +export const TalerWalletIntegrationBrowserProvider = ({ + children, +}: { + children: ComponentChildren; +}): VNode => { + const value: Type = { + publishTalerAction: createHeadMetaTag, + }; + return h(Context.Provider, { + value, + children, + }); +}; + +export const TalerWalletIntegrationTestingProvider = ({ + children, + value, +}: { + children: ComponentChildren; + value: Type; +}): VNode => { + return h(Context.Provider, { + value, + children, + }); +}; diff --git a/packages/web-util/src/declaration.d.ts b/packages/web-util/src/declaration.d.ts new file mode 100644 index 000000000..c8ba3d576 --- /dev/null +++ b/packages/web-util/src/declaration.d.ts @@ -0,0 +1,35 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +declare module "*.css" { + const mapping: Record<string, string>; + export default mapping; +} +declare module "*.svg" { + const content: any; + export default content; +} +declare module "*.jpeg" { + const content: any; + export default content; +} +declare module "*.png" { + const content: any; + export default content; +} + +declare const __VERSION__: string; +declare const __GIT_HASH__: string; diff --git a/packages/web-util/src/forms/Calendar.tsx b/packages/web-util/src/forms/Calendar.tsx new file mode 100644 index 000000000..b08129f56 --- /dev/null +++ b/packages/web-util/src/forms/Calendar.tsx @@ -0,0 +1,186 @@ +import { AbsoluteTime } from "@gnu-taler/taler-util"; +import { + add as dateAdd, + sub as dateSub, + eachDayOfInterval, + endOfMonth, + endOfWeek, + format, + getMonth, + getYear, + isSameDay, + isSameMonth, + startOfDay, + startOfMonth, + startOfWeek, +} from "date-fns"; +import { VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { useTranslationContext } from "../index.browser.js"; + +export function Calendar({ + value, + onChange, +}: { + value: AbsoluteTime | undefined; + onChange: (v: AbsoluteTime) => void; +}): VNode { + const today = startOfDay(new Date()); + const selected = !value ? today : new Date(AbsoluteTime.toStampMs(value)); + const [showingDate, setShowingDate] = useState(selected); + const month = getMonth(showingDate); + const year = getYear(showingDate); + + const start = startOfWeek(startOfMonth(showingDate)); + const end = endOfWeek(endOfMonth(showingDate)); + const daysInMonth = eachDayOfInterval({ start, end }); + const { i18n } = useTranslationContext(); + const monthNames = [ + i18n.str`January`, + i18n.str`February`, + i18n.str`March`, + i18n.str`April`, + i18n.str`May`, + i18n.str`June`, + i18n.str`July`, + i18n.str`August`, + i18n.str`September`, + i18n.str`October`, + i18n.str`November`, + i18n.str`December`, + ]; + return ( + <div class="text-center p-2"> + <div class="flex items-center text-gray-900"> + <button + type="button" + class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm" + onClick={() => { + setShowingDate(dateSub(showingDate, { years: 1 })); + }} + > + <span class="sr-only">{i18n.str`Previous year`}</span> + <svg + class="h-5 w-5" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" + clip-rule="evenodd" + /> + </svg> + </button> + <div class="flex-auto text-sm font-semibold">{year}</div> + <button + type="button" + class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm" + onClick={() => { + setShowingDate(dateAdd(showingDate, { years: 1 })); + }} + > + <span class="sr-only">{i18n.str`Next year`}</span> + <svg + class="h-5 w-5" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" + clip-rule="evenodd" + /> + </svg> + </button> + </div> + <div class="mt-4 flex items-center text-gray-900"> + <button + type="button" + class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm" + onClick={() => { + setShowingDate(dateSub(showingDate, { months: 1 })); + }} + > + <span class="sr-only">{i18n.str`Previous month`}</span> + <svg + class="h-5 w-5" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" + clip-rule="evenodd" + /> + </svg> + </button> + <div class="flex-auto text-sm font-semibold">{monthNames[month]}</div> + <button + type="button" + class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 rounded-sm " + onClick={() => { + setShowingDate(dateAdd(showingDate, { months: 1 })); + }} + > + <span class="sr-only">{i18n.str`Next month`}</span> + <svg + class="h-5 w-5" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" + clip-rule="evenodd" + /> + </svg> + </button> + </div> + <div class="mt-6 grid grid-cols-7 text-xs leading-6 text-gray-500"> + <div>M</div> + <div>T</div> + <div>W</div> + <div>T</div> + <div>F</div> + <div>S</div> + <div>S</div> + </div> + <div class="isolate mt-2"> + <div class="grid grid-cols-7 gap-px rounded-lg bg-gray-200 text-sm shadow ring-1 ring-gray-200"> + {daysInMonth.map((current, idx) => ( + <button + type="button" + key={idx} + data-month={isSameMonth(current, showingDate)} + data-today={isSameDay(current, today)} + data-selected={isSameDay(current, selected)} + onClick={() => { + onChange(AbsoluteTime.fromStampMs(current.getTime())); + }} + class="text-gray-400 hover:bg-gray-700 focus:z-10 py-1.5 + data-[month=false]:bg-gray-100 data-[month=true]:bg-white + data-[today=true]:font-semibold + data-[month=true]:text-gray-900 + data-[today=true]:bg-red-300 data-[today=true]:hover:bg-red-200 + data-[month=true]:hover:bg-gray-200 + data-[selected=true]:!bg-blue-400 data-[selected=true]:hover:!bg-blue-300 " + > + <time + dateTime={format(current, "yyyy-MM-dd")} + class="mx-auto flex h-7 w-7 py-4 px-5 sm:px-8 items-center justify-center rounded-full" + > + {format(current, "dd")} + </time> + </button> + ))} + </div> + {daysInMonth.length < 40 ? <div class="w-7 h-7 m-1.5" /> : undefined} + </div> + </div> + ); +} diff --git a/packages/web-util/src/forms/Caption.tsx b/packages/web-util/src/forms/Caption.tsx new file mode 100644 index 000000000..be4725ffa --- /dev/null +++ b/packages/web-util/src/forms/Caption.tsx @@ -0,0 +1,27 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { VNode, h } from "preact"; +import { LabelWithTooltipMaybeRequired, RenderAddon } from "./InputLine.js"; +import { Addon } from "./FormProvider.js"; + +interface Props { + label: TranslatedString; + tooltip?: TranslatedString; + help?: TranslatedString; + before?: Addon; + after?: Addon; +} + +export function Caption({ before, after, label, tooltip, help }: Props): VNode { + return ( + <div class="sm:col-span-6 flex"> + {before !== undefined && <RenderAddon addon={before} />} + <LabelWithTooltipMaybeRequired label={label} tooltip={tooltip} /> + {after !== undefined && <RenderAddon addon={after} />} + {help && ( + <p class="mt-2 text-sm text-gray-500" id="email-description"> + {help} + </p> + )} + </div> + ); +} diff --git a/packages/web-util/src/forms/DefaultForm.tsx b/packages/web-util/src/forms/DefaultForm.tsx new file mode 100644 index 000000000..338460170 --- /dev/null +++ b/packages/web-util/src/forms/DefaultForm.tsx @@ -0,0 +1,84 @@ +import { Fragment, VNode, h } from "preact"; +import { FormProvider, FormProviderProps, FormState } from "./FormProvider.js"; +import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js"; +import { TranslatedString } from "@gnu-taler/taler-util"; +// import { FlexibleForm } from "./ui-form.js"; + +/** + * Flexible form uses a DoubleColumForm for design + * and may have a dynamic properties defined by + * behavior function. + */ +export interface FlexibleForm_Deprecated<T extends object> { + design: DoubleColumnForm_Deprecated; + behavior?: (form: Partial<T>) => FormState<T>; +} + +/** + * Double column form + * + * Form with sections, every sections have a title and may + * have a description. + * Every sections contain a set of fields. + */ +export type DoubleColumnForm_Deprecated = Array<DoubleColumnFormSection_Deprecated | undefined>; + +export type DoubleColumnFormSection_Deprecated = { + title: TranslatedString; + description?: TranslatedString; + fields: UIFormField[]; +}; + +/** + * Form Provider implementation that use FlexibleForm + * to defined behavior and fields. + */ +export function DefaultForm<T extends object>({ + initial, + onUpdate, + form, + onSubmit, + children, + readOnly, +}: Omit<FormProviderProps<T>, "computeFormState"> & { form: FlexibleForm_Deprecated<T> }): VNode { + return ( + <FormProvider + initial={initial} + onUpdate={onUpdate} + onSubmit={onSubmit} + readOnly={readOnly} + // computeFormState={form.behavior} + > + <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> + {form.design.map((section, i) => { + if (!section) return <Fragment />; + return ( + <div key={i} class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3"> + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + {section.title} + </h2> + {section.description && ( + <p class="mt-1 text-sm leading-6 text-gray-600"> + {section.description} + </p> + )} + </div> + <div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md md:col-span-2"> + <div class="p-3"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <RenderAllFieldsByUiConfig + key={i} + fields={section.fields} + /> + </div> + </div> + </div> + </div> + ); + })} + </div> + {children} + </FormProvider> + ); +} diff --git a/packages/web-util/src/forms/Dialog.tsx b/packages/web-util/src/forms/Dialog.tsx new file mode 100644 index 000000000..7b41fe487 --- /dev/null +++ b/packages/web-util/src/forms/Dialog.tsx @@ -0,0 +1,15 @@ +import { ComponentChildren, VNode, h } from "preact"; + +export function Dialog({ children, onClose }: { onClose?: () => void; children: ComponentChildren }): VNode { + return <div class="relative z-10" aria-labelledby="modal-title" role="dialog" aria-modal="true" onClick={onClose}> + <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div> + + <div class="fixed inset-0 z-10 w-screen overflow-y-auto"> + <div class="flex min-h-full items-center justify-center p-4 text-center "> + <div class="relative transform overflow-hidden rounded-lg bg-white p-1 text-left shadow-xl transition-all" onClick={(e) => e.stopPropagation()}> + {children} + </div> + </div> + </div> + </div> +} diff --git a/packages/web-util/src/forms/FormProvider.tsx b/packages/web-util/src/forms/FormProvider.tsx new file mode 100644 index 000000000..5e08efb32 --- /dev/null +++ b/packages/web-util/src/forms/FormProvider.tsx @@ -0,0 +1,150 @@ +import { + AbsoluteTime, + AmountJson, + TranslatedString, +} from "@gnu-taler/taler-util"; +import { ComponentChildren, VNode, createContext, h } from "preact"; +import { MutableRef, useState } from "preact/hooks"; + +export interface FormType<T extends object> { + value: MutableRef<Partial<T>>; + initial?: Partial<T>; + readOnly?: boolean; + onUpdate?: (v: Partial<T>) => void; + computeFormState?: (v: Partial<T>) => FormState<T>; +} + +export const FormContext = createContext<FormType<any>| undefined>(undefined); + +/** + * Map of {[field]:FieldUIOptions} + * for every field of type + * - any native (string, number, etc...) + * - absoluteTime + * - amountJson + * + * except for: + * - object => recurse into + * - array => behavior result and element field + */ +export type FormState<T extends object | undefined> = { + [field in keyof T]?: T[field] extends AbsoluteTime + ? FieldUIOptions + : T[field] extends AmountJson + ? FieldUIOptions + : T[field] extends Array<infer P extends object> + ? InputArrayFieldState<P> + : T[field] extends object | undefined + ? FormState<T[field]> + : FieldUIOptions; +}; + +/** + * Properties that can be defined by design or by computing state + */ +export type FieldUIOptions = { + /* instruction to be shown in the field */ + placeholder?: TranslatedString; + /* long text help to be shown on demand */ + tooltip?: TranslatedString; + /* short text to be shown next to the field*/ + + help?: TranslatedString; + /* should show as disabled and readonly */ + disabled?: boolean; + /* should not show */ + hidden?: boolean; + + /* show a mark as required*/ + required?: boolean; +}; + +/** + * properties only to be defined on design time + */ +export interface UIFormProps<T extends object, K extends keyof T> + extends FieldUIOptions { + // property name of the object + name: K; + + // label if the field + label: TranslatedString; + before?: Addon; + after?: Addon; + + // converter to string and back + converter?: StringConverter<T[K]>; + + handler?: UIFieldHandler; +} + +export type UIFieldHandler = { + value: string | undefined; + onChange: (s: string) => void; + state: FieldUIOptions; + error?: TranslatedString; +}; + +export interface IconAddon { + type: "icon"; + icon: VNode; +} +export interface ButtonAddon { + type: "button"; + onClick: () => void; + children: ComponentChildren; +} +export interface TextAddon { + type: "text"; + text: TranslatedString; +} +export type Addon = IconAddon | ButtonAddon | TextAddon; + +export interface StringConverter<T> { + toStringUI: (v?: T) => string; + fromStringUI: (v?: string) => T; +} + +export interface InputArrayFieldState<P extends object> extends FieldUIOptions { + elements?: FormState<P>[]; +} + +export type FormProviderProps<T extends object> = Omit<FormType<T>, "value"> & { + onSubmit?: (v: Partial<T>, s: FormState<T> | undefined) => void; + children?: ComponentChildren; +}; + +export function FormProvider<T extends object>({ + children, + initial, + onUpdate: notify, + onSubmit, + computeFormState, + readOnly, +}: FormProviderProps<T>): VNode { + const [state, setState] = useState<Partial<T>>(initial ?? {}); + const value = { current: state }; + const onUpdate = (v: typeof state) => { + setState(v); + if (notify) notify(v); + }; + return ( + <FormContext.Provider + value={{ initial, value, onUpdate, computeFormState, readOnly }} + > + <form + onSubmit={(e) => { + e.preventDefault(); + //@ts-ignore + if (onSubmit) + onSubmit( + value.current, + !computeFormState ? undefined : computeFormState(value.current), + ); + }} + > + {children} + </form> + </FormContext.Provider> + ); +} diff --git a/packages/web-util/src/forms/Group.tsx b/packages/web-util/src/forms/Group.tsx new file mode 100644 index 000000000..f63fa4a9b --- /dev/null +++ b/packages/web-util/src/forms/Group.tsx @@ -0,0 +1,44 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { VNode, h } from "preact"; +import { LabelWithTooltipMaybeRequired, RenderAddon } from "./InputLine.js"; +import { RenderAllFieldsByUiConfig, UIFormField, convertUiField } from "./forms.js"; +import { Addon, FormProvider } from "./FormProvider.js"; +import { useField } from "./useField.js"; +import { useTranslationContext } from "../index.browser.js"; +import { getConverterById } from "./converter.js"; + +interface Props { + label: TranslatedString; + tooltip?: TranslatedString; + help?: TranslatedString; + before?: Addon; + after?: Addon; + fields: UIFormField[]; +} + +export function Group({ + before, + after, + label, + tooltip, + help, + fields, +}: Props): VNode { + return ( + <div class="sm:col-span-6 p-4 rounded-lg border-r-2 border-2 bg-gray-50"> + {before !== undefined && <RenderAddon addon={before} />} + <LabelWithTooltipMaybeRequired label={label} tooltip={tooltip} /> + {after !== undefined && <RenderAddon addon={after} />} + {help && ( + <p class="mt-2 text-sm text-gray-500" id="email-description"> + {help} + </p> + )} + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-6"> + <RenderAllFieldsByUiConfig + fields={fields} + /> + </div> + </div> + ); +} diff --git a/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx b/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx new file mode 100644 index 000000000..6b792bfee --- /dev/null +++ b/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx @@ -0,0 +1,60 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Absolute Time", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + today: AbsoluteTime; +} +const initial: TargetObject = { + today: AbsoluteTime.now() +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "absoluteTimeText", + properties: { + label: "label of the field" as TranslatedString, + name: "today", + pattern: "dd/MM/yyyy HH:mm" + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputAbsoluteTime.tsx b/packages/web-util/src/forms/InputAbsoluteTime.tsx new file mode 100644 index 000000000..f5fd4fc50 --- /dev/null +++ b/packages/web-util/src/forms/InputAbsoluteTime.tsx @@ -0,0 +1,94 @@ +import { AbsoluteTime } from "@gnu-taler/taler-util"; +import { format, parse } from "date-fns"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { Calendar } from "./Calendar.js"; +import { Dialog } from "./Dialog.js"; +import { UIFormProps } from "./FormProvider.js"; +import { InputLine } from "./InputLine.js"; +import { useField } from "./useField.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; + +export function InputAbsoluteTime<T extends object, K extends keyof T>( + properties: { pattern?: string } & UIFormProps<T, K>, +): VNode { + const pattern = properties.pattern ?? "dd/MM/yyyy"; + const [open, setOpen] = useState(false); + + //FIXME: remove deprecated + const fieldCtx = useField<T, K>(properties.name); + const { value, onChange } = + properties.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(properties.name); + return ( + <Fragment> + <InputLine<T, K> + type="text" + after={{ + type: "button", + onClick: () => { + setOpen(true); + }, + // icon: <CalendarIcon class="h-6 w-6" />, + children: ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="w-6 h-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" + /> + </svg> + ), + }} + converter={{ + //@ts-ignore + fromStringUI: (v): AbsoluteTime | undefined => { + if (!v) return undefined; + try { + const t_ms = parse(v, pattern, Date.now()).getTime(); + return AbsoluteTime.fromMilliseconds(t_ms); + } catch (e) { + return undefined; + } + }, + //@ts-ignore + toStringUI: (v: AbsoluteTime | undefined) => { + return !v || !v.t_ms + ? undefined + : v.t_ms === "never" + ? "never" + : format(v.t_ms, pattern); + }, + }} + {...properties} + /> + {open && ( + <Dialog onClose={() => setOpen(false)}> + <Calendar + value={(value as AbsoluteTime) ?? AbsoluteTime.now()} + onChange={(v) => { + onChange(v as any); + setOpen(false); + }} + /> + </Dialog> + )} + {/* {open && + <Dialog onClose={() => setOpen(false)} > + <TimePicker value={value as AbsoluteTime ?? AbsoluteTime.now()} + onChange={(v) => { + onChange(v as any) + }} + onConfirm={() => { + setOpen(false) + }} /> + </Dialog>} */} + </Fragment> + ); +} diff --git a/packages/web-util/src/forms/InputAmount.stories.tsx b/packages/web-util/src/forms/InputAmount.stories.tsx new file mode 100644 index 000000000..f05887515 --- /dev/null +++ b/packages/web-util/src/forms/InputAmount.stories.tsx @@ -0,0 +1,59 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { AmountJson, Amounts, TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Amount", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + amount: AmountJson; +} +const initial: TargetObject = { + amount: Amounts.parseOrThrow("USD:10") +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "amount", + properties: { + label: "label of the field" as TranslatedString, + name: "amount", + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputAmount.tsx b/packages/web-util/src/forms/InputAmount.tsx new file mode 100644 index 000000000..647d2c823 --- /dev/null +++ b/packages/web-util/src/forms/InputAmount.tsx @@ -0,0 +1,43 @@ +import { AmountJson, Amounts, TranslatedString } from "@gnu-taler/taler-util"; +import { VNode, h } from "preact"; +import { UIFormProps } from "./FormProvider.js"; +import { InputLine } from "./InputLine.js"; +import { useField } from "./useField.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; + +export function InputAmount<T extends object, K extends keyof T>( + props: { currency?: string } & UIFormProps<T, K>, +): VNode { + //FIXME: remove deprecated + const fieldCtx = useField<T, K>(props.name); + const { value } = + props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + const currency = + !value || !(value as any).currency + ? props.currency + : (value as any).currency; + return ( + <InputLine<T, K> + {...props} + type="text" + before={{ + type: "text", + text: currency as TranslatedString, + }} + //@ts-ignore + converter={ + props.converter ?? { + fromStringUI: (v): AmountJson => { + return ( + Amounts.parse(`${currency}:${v}`) ?? + Amounts.zeroOfCurrency(currency) + ); + }, + toStringUI: (v: AmountJson) => { + return v === undefined ? "" : Amounts.stringifyValue(v); + }, + } + } + /> + ); +} diff --git a/packages/web-util/src/forms/InputArray.stories.tsx b/packages/web-util/src/forms/InputArray.stories.tsx new file mode 100644 index 000000000..143e73f02 --- /dev/null +++ b/packages/web-util/src/forms/InputArray.stories.tsx @@ -0,0 +1,79 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Array", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + people: { + name: string; + age: number; + }[]; +} +const initial: TargetObject = { + people: [{ + name: "me", + age: 17, + }] +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "array", + properties: { + label: "People" as TranslatedString, + name: "comment", + fields: [{ + type: "text", + properties: { + label: "the name" as TranslatedString, + name: "name", + } + }, { + type: "integer", + properties: { + label: "the age" as TranslatedString, + name: "age", + } + }], + labelField: "name" + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputArray.tsx b/packages/web-util/src/forms/InputArray.tsx new file mode 100644 index 000000000..d90028508 --- /dev/null +++ b/packages/web-util/src/forms/InputArray.tsx @@ -0,0 +1,226 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { FormProvider, UIFormProps } from "./FormProvider.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; +import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js"; +import { useField } from "./useField.js"; + +function Option({ + label, + disabled, + isFirst, + isLast, + isSelected, + onClick, +}: { + label: TranslatedString; + isFirst?: boolean; + isLast?: boolean; + isSelected?: boolean; + disabled?: boolean; + onClick: () => void; +}): VNode { + let clazz = "relative flex border p-4 focus:outline-none disabled:text-grey"; + if (isFirst) { + clazz += " rounded-tl-md rounded-tr-md "; + } + if (isLast) { + clazz += " rounded-bl-md rounded-br-md "; + } + if (isSelected) { + clazz += " z-10 border-indigo-200 bg-indigo-50 "; + } else { + clazz += " border-gray-200"; + } + if (disabled) { + clazz += + " cursor-not-allowed bg-gray-50 text-gray-500 ring-gray-200 text-gray"; + } else { + clazz += " cursor-pointer"; + } + return ( + <label class={clazz}> + <input + type="radio" + name="privacy-setting" + checked={isSelected} + disabled={disabled} + onClick={onClick} + class="mt-0.5 h-4 w-4 shrink-0 text-indigo-600 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200 focus:ring-indigo-600" + aria-labelledby="privacy-setting-0-label" + aria-describedby="privacy-setting-0-description" + /> + <span class="ml-3 flex flex-col"> + <span + id="privacy-setting-0-label" + disabled + class="block text-sm font-medium" + > + {label} + </span> + {/* <!-- Checked: "text-indigo-700", Not Checked: "text-gray-500" --> */} + {/* <span + id="privacy-setting-0-description" + class="block text-sm" + > + This project would be available to anyone who has the link + </span> */} + </span> + </label> + ); +} + +export function noHandlerPropsAndNoContextForField( + field: string | number | symbol, +): never { + throw Error( + `Field ${field.toString()} doesn't have handler and is not in a form provider context.`, + ); +} + +export function InputArray<T extends object, K extends keyof T>( + props: { + fields: UIFormField[]; + labelField: string; + } & UIFormProps<T, K>, +): VNode { + const { fields, labelField, name, label, required, tooltip } = props; + // const { value, onChange, state } = useField<T, K>(name); + //FIXME: remove deprecated + const fieldCtx = useField<T, K>(props.name); + if (!props.handler && !fieldCtx) { + throw Error(""); + } + const { value, onChange, state } = + props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + + const list = (value ?? []) as Array<Record<string, string | undefined>>; + const [selectedIndex, setSelected] = useState<number | undefined>(undefined); + const selected = + selectedIndex === undefined ? undefined : list[selectedIndex]; + + return ( + <div class="sm:col-span-6"> + <LabelWithTooltipMaybeRequired + label={label} + required={required} + tooltip={tooltip} + /> + + <div class="-space-y-px rounded-md bg-white "> + {list.map((v, idx) => { + const label = getValueDeeper(v, labelField.split(".")) + return ( + <Option + label={label as TranslatedString} + key={idx} + isSelected={selectedIndex === idx} + isLast={idx === list.length - 1} + disabled={selectedIndex !== undefined && selectedIndex !== idx} + isFirst={idx === 0} + onClick={() => { + setSelected(selectedIndex === idx ? undefined : idx); + }} + /> + ); + })} + {!state.disabled && ( + <div class="pt-2"> + <Option + label={"Add..." as TranslatedString} + isSelected={selectedIndex === list.length} + isLast + isFirst + disabled={ + selectedIndex !== undefined && selectedIndex !== list.length + } + onClick={() => { + setSelected( + selectedIndex === list.length ? undefined : list.length, + ); + }} + /> + </div> + )} + </div> + {selectedIndex !== undefined && ( + /** + * This form provider act as a substate of the parent form + * Consider creating an InnerFormProvider since not every feature is expected + */ + <FormProvider + initial={selected} + readOnly={state.disabled} + computeFormState={(v) => { + // current state is ignored + // the state is defined by the parent form + + // elements should be present in the state object since this is expected to be an array + //@ts-ignore + // return state.elements[selectedIndex]; + return {}; + }} + onSubmit={(v) => { + const newValue = [...list]; + newValue.splice(selectedIndex, 1, v); + onChange(newValue as any); + setSelected(undefined); + }} + onUpdate={(v) => { + const newValue = [...list]; + newValue.splice(selectedIndex, 1, v); + onChange(newValue as any); + }} + > + <div class="px-4 py-6"> + <div class="grid grid-cols-1 gap-y-8 "> + <RenderAllFieldsByUiConfig fields={fields} /> + </div> + </div> + </FormProvider> + )} + {selectedIndex !== undefined && ( + <div class="flex items-center pt-3"> + <div class="flex-auto"> + {selected !== undefined && ( + <button + type="button" + onClick={() => { + const newValue = [...list]; + newValue.splice(selectedIndex, 1); + onChange(newValue as any); + setSelected(undefined); + }} + class="block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 " + > + Remove + </button> + )} + </div> + </div> + )} + </div> + ); +} + + + +export function getValueDeeper( + object: Record<string, any>, + names: string[], +): string { + if (names.length === 0) { + return object as any as string; + } + const [head, ...rest] = names; + if (!head) { + return getValueDeeper(object, rest); + } + if (object === undefined) { + return "" + } + return getValueDeeper(object[head], rest); +} + + diff --git a/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx b/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx new file mode 100644 index 000000000..786dfe5bc --- /dev/null +++ b/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx @@ -0,0 +1,69 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Choice Horizontal", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +} +const initial: TargetObject = { + comment: "0" +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "choiceHorizontal", + properties: { + label: "label of the field" as TranslatedString, + name: "comment", + choices: [{ + label: "first choice" as TranslatedString, + value: "1" + }, { + label: "second choice" as TranslatedString, + value: "2" + }, { + label: "third choice" as TranslatedString, + value: "3" + },], + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputChoiceHorizontal.tsx b/packages/web-util/src/forms/InputChoiceHorizontal.tsx new file mode 100644 index 000000000..86d3aa926 --- /dev/null +++ b/packages/web-util/src/forms/InputChoiceHorizontal.tsx @@ -0,0 +1,82 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { Fragment, VNode, h } from "preact"; +import { UIFormProps } from "./FormProvider.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; +import { useField } from "./useField.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; + +export interface ChoiceH<V> { + label: TranslatedString; + value: V; +} + +export function InputChoiceHorizontal<T extends object, K extends keyof T>( + props: { + choices: ChoiceH<string>[]; + } & UIFormProps<T, K>, +): VNode { + const { choices, label, tooltip, help, required, converter } = props; + //FIXME: remove deprecated + const fieldCtx = useField<T, K>(props.name); + const { value, onChange, state } = + props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + if (state.hidden) { + return <Fragment />; + } + + return ( + <div class="sm:col-span-6"> + <LabelWithTooltipMaybeRequired + label={label} + required={required} + tooltip={tooltip} + /> + <fieldset class="mt-2"> + <div class="isolate inline-flex rounded-md shadow-sm"> + {choices.map((choice, idx) => { + const convertedValue = converter?.fromStringUI(choice.value as any) + const isFirst = idx === 0; + const isLast = idx === choices.length - 1; + let clazz = + "relative inline-flex items-center px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 focus:z-10"; + if (convertedValue !== undefined && convertedValue === value) { + clazz += + " text-white bg-indigo-600 hover:bg-indigo-500 ring-2 ring-indigo-600 hover:ring-indigo-500"; + } else { + clazz += " hover:bg-gray-100 border-gray-300"; + } + if (isFirst) { + clazz += " rounded-l-md"; + } else { + clazz += " -ml-px"; + } + if (isLast) { + clazz += " rounded-r-md"; + } + return ( + <button + type="button" + key={idx} + disabled={state.disabled} + label={choice.label} + class={clazz} + onClick={(e) => { + onChange( + (value === choice.value ? undefined : convertedValue) as any, + ); + }} + > + {choice.label} + </button> + ); + })} + </div> + </fieldset> + {help && ( + <p class="mt-2 text-sm text-gray-500" id="email-description"> + {help} + </p> + )} + </div> + ); +} diff --git a/packages/web-util/src/forms/InputChoiceStacked.stories.tsx b/packages/web-util/src/forms/InputChoiceStacked.stories.tsx new file mode 100644 index 000000000..9a634d05c --- /dev/null +++ b/packages/web-util/src/forms/InputChoiceStacked.stories.tsx @@ -0,0 +1,69 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Choice Stacked", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +} +const initial: TargetObject = { + comment: "some initial comment" +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "choiceStacked", + properties: { + label: "label of the field" as TranslatedString, + name: "comment", + choices: [{ + label: "first choice" as TranslatedString, + value: "1" + }, { + label: "second choice" as TranslatedString, + value: "2" + }, { + label: "third choice" as TranslatedString, + value: "3" + },], + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputChoiceStacked.tsx b/packages/web-util/src/forms/InputChoiceStacked.tsx new file mode 100644 index 000000000..1928f4365 --- /dev/null +++ b/packages/web-util/src/forms/InputChoiceStacked.tsx @@ -0,0 +1,119 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { Fragment, VNode, h } from "preact"; +import { UIFormProps } from "./FormProvider.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; +import { useField } from "./useField.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; + +export interface ChoiceS<V> { + label: TranslatedString; + description?: TranslatedString; + value: V; +} + +export function InputChoiceStacked<T extends object, K extends keyof T>( + props: { + choices: ChoiceS<T[K]>[]; + } & UIFormProps<T, K>, +): VNode { + const { + choices, + name, + label, + tooltip, + help, + placeholder, + required, + before, + after, + converter, + } = props; + + //FIXME: remove deprecated + const fieldCtx = useField<T, K>(props.name); + const { value, onChange, state } = + props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + + if (state.hidden) { + return <Fragment />; + } + + return ( + <div class="sm:col-span-6"> + <LabelWithTooltipMaybeRequired + label={label} + required={required} + tooltip={tooltip} + /> + <fieldset class="mt-2"> + <div class="space-y-4"> + {choices.map((choice, idx) => { + // const currentValue = !converter + // ? choice.value + // : converter.fromStringUI(choice.value) ?? ""; + + let clazz = + "border relative block cursor-pointer rounded-lg bg-white px-6 py-4 shadow-sm focus:outline-none sm:flex sm:justify-between"; + if (choice.value === value) { + clazz += + " border-transparent border-indigo-600 ring-2 ring-indigo-600"; + } else { + clazz += " border-gray-300"; + } + + return ( + <label key={idx} class={clazz}> + <input + type="radio" + name="server-size" + // defaultValue={choice.value} + disabled={state.disabled} + value={ + (!converter + ? (choice.value as string) + : converter?.toStringUI(choice.value)) ?? "" + } + onClick={(e) => { + onChange( + (value === choice.value + ? undefined + : choice.value) as any, + ); + }} + class="sr-only" + aria-labelledby="server-size-0-label" + aria-describedby="server-size-0-description-0 server-size-0-description-1" + /> + <span class="flex items-center"> + <span class="flex flex-col text-sm"> + <span + id="server-size-0-label" + class="font-medium text-gray-900" + > + {choice.label} + </span> + {choice.description !== undefined && ( + <span + id="server-size-0-description-0" + class="text-gray-500" + > + <span class="block sm:inline"> + {choice.description} + </span> + </span> + )} + </span> + </span> + </label> + ); + })} + </div> + </fieldset> + {help && ( + <p class="mt-2 text-sm text-gray-500" id="email-description"> + {help} + </p> + )} + </div> + ); +} diff --git a/packages/web-util/src/forms/InputFile.stories.tsx b/packages/web-util/src/forms/InputFile.stories.tsx new file mode 100644 index 000000000..eff18d071 --- /dev/null +++ b/packages/web-util/src/forms/InputFile.stories.tsx @@ -0,0 +1,64 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input File", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +} +const initial: TargetObject = { + comment: "some initial comment" +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "file", + properties: { + label: "label of the field" as TranslatedString, + name: "comment", + required: true, + maxBites: 2 * 1024 * 1024, + accept: ".png", + tooltip: "this is a very long tooltip that explain what the field does without being short" as TranslatedString, + help: "Max size of 2 mega bytes" as TranslatedString, + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputFile.tsx b/packages/web-util/src/forms/InputFile.tsx new file mode 100644 index 000000000..cd0a96d1c --- /dev/null +++ b/packages/web-util/src/forms/InputFile.tsx @@ -0,0 +1,139 @@ +import { Fragment, VNode, h } from "preact"; +import { UIFormProps } from "./FormProvider.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; +import { useField } from "./useField.js"; + +export function InputFile<T extends object, K extends keyof T>( + props: { maxBites: number; accept?: string } & UIFormProps<T, K>, +): VNode { + const { + label, + tooltip, + required, + help: propsHelp, + maxBites, + accept, + } = props; + //FIXME: remove deprecated + const fieldCtx = useField<T, K>(props.name); + const { value, onChange, state } = + props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + + const help = propsHelp ?? state.help; + if (state.hidden) { + return <div />; + } + + const valueStr = !value ? "" : value.toString(); + const firstColon = valueStr.indexOf(";"); + + const { fileName, dataUri } = valueStr.startsWith("file:") + ? { + fileName: valueStr.substring(5, firstColon), + dataUri: valueStr.substring(firstColon + 1), + } + : { + fileName: "", + dataUri: valueStr, + }; + + return ( + <div class="col-span-full"> + <LabelWithTooltipMaybeRequired + label={label} + tooltip={tooltip} + required={required} + /> + {!dataUri ? ( + <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 py-1"> + <div class="text-center"> + <svg + class="mx-auto h-12 w-12 text-gray-300" + viewBox="0 0 24 24" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M1.5 6a2.25 2.25 0 012.25-2.25h16.5A2.25 2.25 0 0122.5 6v12a2.25 2.25 0 01-2.25 2.25H3.75A2.25 2.25 0 011.5 18V6zM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0021 18v-1.94l-2.69-2.689a1.5 1.5 0 00-2.12 0l-.88.879.97.97a.75.75 0 11-1.06 1.06l-5.16-5.159a1.5 1.5 0 00-2.12 0L3 16.061zm10.125-7.81a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0z" + clip-rule="evenodd" + /> + </svg> + {!state.disabled && ( + <div class="my-2 flex text-sm leading-6 text-gray-600"> + <label + for={String(props.name)} + class="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500" + > + <span>Upload a file</span> + <input + id={String(props.name)} + type="file" + class="sr-only" + accept={accept} + onChange={(e) => { + const f: FileList | null = e.currentTarget.files; + if (!f || f.length != 1) { + return onChange(undefined!); + } + if (f[0].size > maxBites) { + return onChange(undefined!); + } + const fileName = f[0].name; + return f[0].arrayBuffer().then((b) => { + const b64 = window.btoa( + new Uint8Array(b).reduce( + (data, byte) => data + String.fromCharCode(byte), + "", + ), + ); + if (fileName) { + return onChange( + `file:${fileName};data:${f[0].type};base64,${b64}` as any, + ); + } else { + return onChange( + `data:${f[0].type};base64,${b64}` as any, + ); + } + }); + }} + /> + </label> + {/* <p class="pl-1">or drag and drop</p> */} + </div> + )} + </div> + </div> + ) : ( + <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 relative"> + {(dataUri as string).startsWith("data:image/") ? ( + <img src={dataUri} class=" h-24 w-full object-cover relative" /> + ) : ( + <div /> + )} + {fileName ? ( + <div class="absolute rounded-lg border flex justify-center text-xl items-center text-white "> + {fileName} + </div> + ) : ( + <Fragment /> + )} + + {!state.disabled && ( + <div + class="opacity-0 hover:opacity-70 duration-300 absolute rounded-lg border inset-0 z-10 flex justify-center text-xl items-center bg-black text-white cursor-pointer " + onClick={() => { + onChange(undefined!); + }} + > + Clear + </div> + )} + </div> + )} + {help && <p class="text-xs leading-5 text-gray-600 mt-2">{help}</p>} + </div> + ); +} diff --git a/packages/web-util/src/forms/InputInteger.stories.tsx b/packages/web-util/src/forms/InputInteger.stories.tsx new file mode 100644 index 000000000..378736a24 --- /dev/null +++ b/packages/web-util/src/forms/InputInteger.stories.tsx @@ -0,0 +1,55 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Integer", +}; + + +type TargetObject = { + age: number; +} +const initial: TargetObject = { + age: 5, +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "integer", + properties: { + label: "label of the field" as TranslatedString, + name: "age", + tooltip: "just numbers" as TranslatedString, + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputInteger.tsx b/packages/web-util/src/forms/InputInteger.tsx new file mode 100644 index 000000000..a6a02ad43 --- /dev/null +++ b/packages/web-util/src/forms/InputInteger.tsx @@ -0,0 +1,24 @@ +import { VNode, h } from "preact"; +import { InputLine } from "./InputLine.js"; +import { UIFormProps } from "./FormProvider.js"; + +export function InputInteger<T extends object, K extends keyof T>( + props: UIFormProps<T, K>, +): VNode { + return ( + <InputLine + type="number" + converter={{ + //@ts-ignore + fromStringUI: (v): number => { + return !v ? 0 : Number.parseInt(v, 10); + }, + //@ts-ignore + toStringUI: (v?: number): string => { + return v === undefined ? "" : String(v); + }, + }} + {...props} + /> + ); +} diff --git a/packages/web-util/src/forms/InputLine.stories.tsx b/packages/web-util/src/forms/InputLine.stories.tsx new file mode 100644 index 000000000..dea5c142a --- /dev/null +++ b/packages/web-util/src/forms/InputLine.stories.tsx @@ -0,0 +1,59 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Line", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +} +const initial: TargetObject = { + comment: "some initial comment" +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "text", + properties: { + label: "label of the field" as TranslatedString, + name: "comment", + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputLine.tsx b/packages/web-util/src/forms/InputLine.tsx new file mode 100644 index 000000000..eb3238ef9 --- /dev/null +++ b/packages/web-util/src/forms/InputLine.tsx @@ -0,0 +1,272 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { ComponentChildren, Fragment, VNode, h } from "preact"; +import { Addon, UIFormProps } from "./FormProvider.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { useField } from "./useField.js"; + +//@ts-ignore +const TooltipIcon = ( + <svg + class="w-5 h-5" + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 20 20" + fill="currentColor" + > + <path + fill-rule="evenodd" + d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" + clip-rule="evenodd" + /> + </svg> +); + +export function LabelWithTooltipMaybeRequired({ + label, + required, + tooltip, +}: { + label: TranslatedString; + required?: boolean; + tooltip?: TranslatedString; +}): VNode { + const Label = ( + <Fragment> + <div class="flex justify-between"> + <label + htmlFor="email" + class="block text-sm font-medium leading-6 text-gray-900" + > + {label} + </label> + </div> + </Fragment> + ); + const WithTooltip = tooltip ? ( + <div class="relative flex flex-grow items-stretch focus-within:z-10"> + {Label} + <span class="relative flex items-center group pl-2"> + {TooltipIcon} + <div class="absolute bottom-0 -ml-10 hidden flex-col items-center mb-6 group-hover:flex w-28"> + <div class="relative z-10 p-2 text-xs leading-none text-white whitespace-no-wrap bg-black shadow-lg"> + {tooltip} + </div> + <div class="w-3 h-3 -mt-2 rotate-45 bg-black"></div> + </div> + </span> + </div> + ) : ( + Label + ); + if (required) { + return ( + <div class="flex justify-between"> + {WithTooltip} + <span class="text-sm leading-6 text-red-600">*</span> + </div> + ); + } + return WithTooltip; +} + +export function RenderAddon({ disabled, addon }: { disabled?: boolean, addon: Addon }): VNode { + switch (addon.type) { + case "text": { + return ( + <span class="inline-flex items-center rounded-l-md border border-r-0 border-gray-300 px-3 text-gray-500 sm:text-sm"> + {addon.text} + </span> + ); + } + case "icon": { + return ( + <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> + {addon.icon} + </div> + ); + } + case "button": { + return ( + <button + type="button" + disabled={disabled} + onClick={addon.onClick} + class="relative -ml-px inline-flex items-center gap-x-1.5 rounded-l-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50" + > + {addon.children} + </button> + ); + } + } +} + +function InputWrapper<T extends object, K extends keyof T>({ + children, + label, + tooltip, + before, + after, + help, + error, + disabled, + required, +}: { + error?: string; + disabled: boolean; + children: ComponentChildren; +} & UIFormProps<T, K>): VNode { + return ( + <div class="sm:col-span-6"> + <LabelWithTooltipMaybeRequired + label={label} + required={required} + tooltip={tooltip} + /> + <div class="relative mt-2 flex rounded-md shadow-sm"> + {before && <RenderAddon disabled={disabled} addon={before} />} + + {children} + + {after && <RenderAddon disabled={disabled} addon={after} />} + </div> + {error && ( + <p class="mt-2 text-sm text-red-600" id="email-error"> + {error} + </p> + )} + {help && ( + <p class="mt-2 text-sm text-gray-500" id="email-description"> + {help} + </p> + )} + </div> + ); +} + +function defaultToString(v: unknown) { + return v === undefined ? "" : typeof v !== "object" ? String(v) : ""; +} +function defaultFromString(v: string) { + return v; +} + +type InputType = "text" | "text-area" | "password" | "email" | "number"; + +export function InputLine<T extends object, K extends keyof T>( + props: { type: InputType } & UIFormProps<T, K>, +): VNode { + const { name, placeholder, before, after, converter, type } = props; + //FIXME: remove deprecated + const fieldCtx = useField<T, K>(props.name); + const { value, onChange, state, error } = + props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + + // const [text, setText] = useState(""); + const fromString: (s: string) => any = + converter?.fromStringUI ?? defaultFromString; + const toString: (s: any) => string = converter?.toStringUI ?? defaultToString; + + // useEffect(() => { + // const newValue = toString(value); + // if (newValue) { + // setText(newValue); + // } + // }, [value]); + + if (state.hidden) return <div />; + + let clazz = + "block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200"; + if (before) { + switch (before.type) { + case "icon": { + clazz += " pl-10"; + break; + } + case "button": { + clazz += " rounded-none rounded-r-md "; + break; + } + case "text": { + clazz += " min-w-0 flex-1 rounded-r-md rounded-none "; + break; + } + } + } + if (after) { + switch (after.type) { + case "icon": { + clazz += " pr-10"; + break; + } + case "button": { + clazz += " rounded-none rounded-l-md"; + break; + } + case "text": { + clazz += " min-w-0 flex-1 rounded-l-md rounded-none "; + break; + } + } + } + const showError = value !== undefined && error; + if (showError) { + clazz += + " text-red-900 ring-red-300 placeholder:text-red-300 focus:ring-red-500"; + } else { + clazz += + " text-gray-900 ring-gray-300 placeholder:text-gray-400 focus:ring-indigo-600"; + } + + if (type === "text-area") { + return ( + <InputWrapper<T, K> + {...props} + help={props.help ?? state.help} + disabled={state.disabled ?? false} + error={showError ? error : undefined} + > + <textarea + rows={4} + name={String(name)} + onChange={(e) => { + onChange(fromString(e.currentTarget.value)); + }} + placeholder={placeholder ? placeholder : undefined} + value={toString(value) ?? ""} + // defaultValue={toString(value)} + disabled={state.disabled} + aria-invalid={showError} + // aria-describedby="email-error" + class={clazz} + /> + </InputWrapper> + ); + } + + return ( + <InputWrapper<T, K> + {...props} + help={props.help ?? state.help} + disabled={state.disabled ?? false} + error={showError ? error : undefined} + > + <input + name={String(name)} + type={type} + onChange={(e) => { + onChange(fromString(e.currentTarget.value)); + }} + placeholder={placeholder ? placeholder : undefined} + value={toString(value) ?? ""} + // onBlur={() => { + // onChange(fromString(value as any)); + // }} + // defaultValue={toString(value)} + disabled={state.disabled} + aria-invalid={showError} + // aria-describedby="email-error" + class={clazz} + /> + </InputWrapper> + ); +} diff --git a/packages/web-util/src/forms/InputSelectMultiple.stories.tsx b/packages/web-util/src/forms/InputSelectMultiple.stories.tsx new file mode 100644 index 000000000..ab17545f5 --- /dev/null +++ b/packages/web-util/src/forms/InputSelectMultiple.stories.tsx @@ -0,0 +1,90 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Select Multiple", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + pets: string[]; + things: string[]; +} +const initial: TargetObject = { + pets: [], + things: [], +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "selectMultiple", + properties: { + label: "allow diplicates" as TranslatedString, + name: "pets", + placeholder: "search..." as TranslatedString, + choices: [{ + label: "one label" as TranslatedString, + value: "one" + }, { + label: "two label" as TranslatedString, + value: "two" + }, { + label: "five label" as TranslatedString, + value: "five" + }] + }, + }, { + type: "selectMultiple", + properties: { + label: "unique values" as TranslatedString, + name: "things", + unique: true, + placeholder: "search..." as TranslatedString, + choices: [{ + label: "one label" as TranslatedString, + value: "one" + }, { + label: "two label" as TranslatedString, + value: "two" + }, { + label: "five label" as TranslatedString, + value: "five" + }] + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputSelectMultiple.tsx b/packages/web-util/src/forms/InputSelectMultiple.tsx new file mode 100644 index 000000000..1bcf85061 --- /dev/null +++ b/packages/web-util/src/forms/InputSelectMultiple.tsx @@ -0,0 +1,166 @@ +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { UIFormProps } from "./FormProvider.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { ChoiceS } from "./InputChoiceStacked.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; +import { useField } from "./useField.js"; + +export function InputSelectMultiple<T extends object, K extends keyof T>( + props: { + choices: ChoiceS<T[K]>[]; + unique?: boolean; + max?: number; + } & UIFormProps<T, K>, +): VNode { + const { converter, label, choices, placeholder, tooltip, required, unique, max } = props; + //FIXME: remove deprecated + const fieldCtx = useField<T, K>(props.name); + const { value, onChange, state } = + props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + + const [filter, setFilter] = useState<string | undefined>(undefined); + const regex = new RegExp(`.*${filter}.*`, "i"); + const choiceMap = choices.reduce( + (prev, curr) => { + return { ...prev, [curr.value as string]: curr.label }; + }, + {} as Record<string, string>, + ); + + const list = (value ?? []) as string[]; + const filteredChoices = + filter === undefined + ? undefined + : choices.filter((v) => { + return regex.test(v.label); + }); + return ( + <div class="sm:col-span-6"> + <LabelWithTooltipMaybeRequired + label={label} + required={required} + tooltip={tooltip} + /> + {list.map((v, idx) => { + return ( + <span + key={idx} + class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 p-1 mr-2 text-xs font-medium text-gray-600" + > + {choiceMap[v]} + <button + type="button" + disabled={state.disabled} + onClick={() => { + const newValue = [...list]; + newValue.splice(idx, 1); + onChange(newValue as any); + setFilter(undefined); + }} + class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20" + > + <span class="sr-only">Remove</span> + <svg + viewBox="0 0 14 14" + class="h-5 w-5 stroke-gray-700/50 group-hover:stroke-gray-700/75" + > + <path d="M4 4l6 6m0-6l-6 6" /> + </svg> + <span class="absolute -inset-1"></span> + </button> + </span> + ); + })} + + {!state.disabled && ( + <div class="relative mt-2"> + <input + id="combobox" + type="text" + value={filter ?? ""} + onChange={(e) => { + setFilter(e.currentTarget.value); + }} + placeholder={placeholder} + class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + role="combobox" + aria-controls="options" + aria-expanded="false" + /> + <button + type="button" + disabled={state.disabled} + onClick={() => { + setFilter(filter === undefined ? "" : undefined); + }} + class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + > + <svg + class="h-5 w-5 text-gray-400" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" + clip-rule="evenodd" + /> + </svg> + </button> + + {filteredChoices !== undefined && ( + <ul + class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + id="options" + role="listbox" + > + {filteredChoices.map((v, idx) => { + return ( + <li + key={idx} + class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600" + id="option-0" + role="option" + onClick={() => { + setFilter(undefined); + if (unique && list.indexOf(v.value as string) !== -1) { + return; + } + if (max !== undefined && list.length >= max) { + return; + } + const newValue = [...list]; + newValue.splice(0, 0, v.value as string); + onChange(newValue as any); + }} + + // tabindex="-1" + > + {/* <!-- Selected: "font-semibold" --> */} + <span class="block truncate">{v.label}</span> + + {/* <!-- + Checkmark, only display for selected option. + + Active: "text-white", Not Active: "text-indigo-600" + --> */} + </li> + ); + })} + + {/* <!-- + Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + + Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + --> */} + + {/* <!-- More items... --> */} + </ul> + )} + </div> + )} + </div> + ); +} diff --git a/packages/web-util/src/forms/InputSelectOne.stories.tsx b/packages/web-util/src/forms/InputSelectOne.stories.tsx new file mode 100644 index 000000000..2ebde3096 --- /dev/null +++ b/packages/web-util/src/forms/InputSelectOne.stories.tsx @@ -0,0 +1,70 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Select One", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + things: string; +} +const initial: TargetObject = { + things: "one" +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "selectOne", + properties: { + label: "label of the field" as TranslatedString, + name: "things", + placeholder: "search..." as TranslatedString, + choices: [{ + label: "one label" as TranslatedString, + value: "one" + }, { + label: "two label" as TranslatedString, + value: "two" + }, { + label: "five label" as TranslatedString, + value: "five" + }] + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputSelectOne.tsx b/packages/web-util/src/forms/InputSelectOne.tsx new file mode 100644 index 000000000..26f887b08 --- /dev/null +++ b/packages/web-util/src/forms/InputSelectOne.tsx @@ -0,0 +1,144 @@ +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { UIFormProps } from "./FormProvider.js"; +import { ChoiceS } from "./InputChoiceStacked.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; +import { useField } from "./useField.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; + +export function InputSelectOne<T extends object, K extends keyof T>( + props: { + choices: ChoiceS<T[K]>[]; + } & UIFormProps<T, K>, +): VNode { + const { label, choices, placeholder, tooltip, required } = props; + //FIXME: remove deprecated + const fieldCtx = useField<T, K>(props.name); + const { value, onChange } = + props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + + + const [filter, setFilter] = useState<string | undefined>(undefined); + const regex = new RegExp(`.*${filter}.*`, "i"); + const choiceMap = choices.reduce( + (prev, curr) => { + return { ...prev, [curr.value as string]: curr.label }; + }, + {} as Record<string, string>, + ); + + const filteredChoices = + filter === undefined + ? undefined + : choices.filter((v) => { + return regex.test(v.label); + }); + return ( + <div class="sm:col-span-6"> + <LabelWithTooltipMaybeRequired + label={label} + required={required} + tooltip={tooltip} + /> + {value ? ( + <span class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 p-1 mr-2 font-medium text-gray-600"> + {choiceMap[value as string]} + <button + type="button" + onClick={() => { + onChange(undefined!); + }} + class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20" + > + <span class="sr-only">Remove</span> + <svg + viewBox="0 0 14 14" + class="h-5 w-5 stroke-gray-700/50 group-hover:stroke-gray-700/75" + > + <path d="M4 4l6 6m0-6l-6 6" /> + </svg> + <span class="absolute -inset-1"></span> + </button> + </span> + ) : ( + <div class="relative mt-2"> + <input + id="combobox" + type="text" + value={filter ?? ""} + onChange={(e) => { + setFilter(e.currentTarget.value); + }} + placeholder={placeholder} + class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + role="combobox" + aria-controls="options" + aria-expanded="false" + /> + <button + type="button" + onClick={() => { + setFilter(filter === undefined ? "" : undefined); + }} + class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + > + <svg + class="h-5 w-5 text-gray-400" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" + clip-rule="evenodd" + /> + </svg> + </button> + + {filteredChoices !== undefined && ( + <ul + class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + id="options" + role="listbox" + > + {filteredChoices.map((v, idx) => { + return ( + <li + key={idx} + class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600" + id="option-0" + role="option" + onClick={() => { + setFilter(undefined); + onChange(v.value as any); + }} + + // tabindex="-1" + > + {/* <!-- Selected: "font-semibold" --> */} + <span class="block truncate">{v.label}</span> + + {/* <!-- + Checkmark, only display for selected option. + + Active: "text-white", Not Active: "text-indigo-600" + --> */} + </li> + ); + })} + + {/* <!-- + Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + + Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + --> */} + + {/* <!-- More items... --> */} + </ul> + )} + </div> + )} + </div> + ); +} diff --git a/packages/web-util/src/forms/InputText.stories.tsx b/packages/web-util/src/forms/InputText.stories.tsx new file mode 100644 index 000000000..60b6ca224 --- /dev/null +++ b/packages/web-util/src/forms/InputText.stories.tsx @@ -0,0 +1,59 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Text", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +} +const initial: TargetObject = { + comment: "some initial comment" +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "text", + properties: { + label: "label of the field" as TranslatedString, + name: "comment", + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputText.tsx b/packages/web-util/src/forms/InputText.tsx new file mode 100644 index 000000000..1c0c04225 --- /dev/null +++ b/packages/web-util/src/forms/InputText.tsx @@ -0,0 +1,9 @@ +import { VNode, h } from "preact"; +import { UIFormProps } from "./FormProvider.js"; +import { InputLine } from "./InputLine.js"; + +export function InputText<T extends object, K extends keyof T>( + props: UIFormProps<T, K>, +): VNode { + return <InputLine type="text" {...props} />; +} diff --git a/packages/web-util/src/forms/InputTextArea.stories.tsx b/packages/web-util/src/forms/InputTextArea.stories.tsx new file mode 100644 index 000000000..ab1a695f5 --- /dev/null +++ b/packages/web-util/src/forms/InputTextArea.stories.tsx @@ -0,0 +1,59 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + DefaultForm as TestedComponent, + FlexibleForm_Deprecated, +} from "./DefaultForm.js"; + +export default { + title: "Input Text Area", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +} +const initial: TargetObject = { + comment: "some initial comment" +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "text", + properties: { + label: "label of the field" as TranslatedString, + name: "comment", + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputTextArea.tsx b/packages/web-util/src/forms/InputTextArea.tsx new file mode 100644 index 000000000..6b76d8329 --- /dev/null +++ b/packages/web-util/src/forms/InputTextArea.tsx @@ -0,0 +1,9 @@ +import { VNode, h } from "preact"; +import { InputLine } from "./InputLine.js"; +import { UIFormProps } from "./FormProvider.js"; + +export function InputTextArea<T extends object, K extends keyof T>( + props: UIFormProps<T, K>, +): VNode { + return <InputLine type="text-area" {...props} />; +} diff --git a/packages/web-util/src/forms/InputToggle.stories.tsx b/packages/web-util/src/forms/InputToggle.stories.tsx new file mode 100644 index 000000000..fcc57ffe2 --- /dev/null +++ b/packages/web-util/src/forms/InputToggle.stories.tsx @@ -0,0 +1,59 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Toggle", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +} +const initial: TargetObject = { + comment: "some initial comment" +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "toggle", + properties: { + label: "label of the field" as TranslatedString, + name: "comment", + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputToggle.tsx b/packages/web-util/src/forms/InputToggle.tsx new file mode 100644 index 000000000..58386045c --- /dev/null +++ b/packages/web-util/src/forms/InputToggle.tsx @@ -0,0 +1,56 @@ +import { VNode, h } from "preact"; +import { UIFormProps } from "./FormProvider.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; +import { useField } from "./useField.js"; + +export function InputToggle<T extends object, K extends keyof T>( + props: UIFormProps<T, K>, +): VNode { + const { + name, + label, + tooltip, + help, + placeholder, + required, + before, + after, + converter, + } = props; + //FIXME: remove deprecated + const fieldCtx = useField<T, K>(props.name); + const { value, onChange } = + props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + + const isOn = !!value; + return ( + <div class="sm:col-span-6"> + <div class="flex items-center justify-between"> + <LabelWithTooltipMaybeRequired + label={label} + required={required} + tooltip={tooltip} + /> + <button + type="button" + data-enabled={isOn} + class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + role="switch" + aria-checked="false" + aria-labelledby="availability-label" + aria-describedby="availability-description" + onClick={() => { + onChange(!isOn as any); + }} + > + <span + aria-hidden="true" + data-enabled={isOn} + class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + ></span> + </button> + </div> + </div> + ); +} diff --git a/packages/web-util/src/forms/TimePicker.tsx b/packages/web-util/src/forms/TimePicker.tsx new file mode 100644 index 000000000..5e4e7a8fa --- /dev/null +++ b/packages/web-util/src/forms/TimePicker.tsx @@ -0,0 +1,109 @@ +import { AbsoluteTime } from "@gnu-taler/taler-util" +import { getHours, getMinutes, getSeconds, setHours } from "date-fns" +import { Fragment, VNode, h } from "preact" +import { useTranslationContext } from "../index.browser.js" + +export function TimePicker({ value, onChange, onConfirm }: { value: AbsoluteTime | undefined, onChange: (v: AbsoluteTime) => void, onConfirm: () => void }): VNode { + const date = !value ? new Date() : new Date(AbsoluteTime.toStampMs(value)) + const hours = getHours(date) % 12 + const minutes = getMinutes(date) + const seconds = getSeconds(date) + + const { i18n } = useTranslationContext() + + return <Fragment> + <div class="flex flex-col bg-white rounded-t-sm justify-around" > + {/* time selection */} + <div id="" class="bg-[#3b71ca] dark:bg-zinc-700 h-24 rounded-t-lg p-12 flex flex-row items-center justify-center"> + <div class="flex w-full justify-evenly"> + <div class=""> + <span class="relative h-full"> + <button type="button" class="py-1 px-3 text-[3.75rem] font-light leading-[1.2] text-white opacity-[.54] border-none bg-transparent p-0 cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none " + style="pointer-events: none;"> + {new String(hours).padStart(2, "0")} + </button> + </span> + <span type="button" class="font-light leading-[1.2] text-[3.75rem] opacity-[.54] border-none bg-transparent p-0 text-white " >:</span> + <span class="relative h-full"> + <button type="button" class="py-1 px-3 text-[3.75rem] font-light leading-[1.2] text-white opacity-[.54] border-none bg-transparent p-0 cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none " > + {new String(minutes).padStart(2, "0")} + </button> + </span> + <span type="button" class="font-light leading-[1.2] text-[3.75rem] opacity-[.54] border-none bg-transparent p-0 text-white " >:</span> + <span class="relative h-full"> + <button type="button" class="py-1 px-3 text-[3.75rem] font-light leading-[1.2] text-white opacity-[.54] border-none bg-transparent p-0 cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none " > + {new String(seconds).padStart(2, "0")} + </button> + </span> + </div> + <div class="flex flex-col justify-center text-[18px] text-[#ffffff8a] "> + <button type="button" class="py-1 px-3 bg-transparent border-none text-white cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none" > + AM + </button> + <button type="button" class="py-1 px-3 bg-transparent border-none text-white cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none" > + PM + </button> + </div> + </div> + </div> + {/* clock */} + <div id="" class="mt-2 min-w-[310px] max-w-[325px] min-h-[305px] overflow-x-hidden h-full flex justify-center mx-auto flex-col items-center dark:bg-zinc-500" > + <div class="relative rounded-[100%] w-[260px] h-[260px] cursor-default my-0 mx-auto bg-[#00000012] dark:bg-zinc-600/50 animate-[show-up-clock_350ms_linear]" > + + <span class="top-1/2 left-1/2 w-[6px] h-[6px] -translate-y-1/2 -translate-x-1/2 rounded-[50%] bg-[#3b71ca] absolute" ></span> + <div class="bg-[#3b71ca] bottom-1/2 h-2/5 left-[calc(50%-1px)] rtl:!left-auto origin-[center_bottom_0] rtl:!origin-[50%_50%_0] w-[2px] absolute" style={{ transform: "rotateZ(60deg)", height: "calc(35% + 1px)" }}> + {/* <div class="-top-[21px] -left-[15px] w-[4px] border-[14px] border-solid border-[#3b71ca] h-[4px] box-content rounded-[100%] absolute" style="background-color: rgb(25, 118, 210);"></div> */} + </div> + + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 12).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 114px; bottom: 224px;"> + <span>0</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 1).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 169px; bottom: 209.263px;"> + <span >1</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 2).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" data-selected={true} style="left: 209.263px; bottom: 169px;" > + <span >2</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 3).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 224px; bottom: 114px;"> + <span >3</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 4).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 209.263px; bottom: 59px;"> + <span >4</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 5).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 169px; bottom: 18.7372px;"> + <span >5</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 6).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 114px; bottom: 4px;"> + <span >6</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 7).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 59px; bottom: 18.7372px;"> + <span >7</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 8).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 18.7372px; bottom: 59px;"> + <span >8</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 9).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 4px; bottom: 114px;"> + <span >9</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 10).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 18.7372px; bottom: 169px;"> + <span >10</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 11).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 59px; bottom: 209.263px;"> + <span >11</span> + </span> + </div> + </div> + </div> + <div id="" class="rounded-b-lg flex justify-between items-center w-full h-[56px] px-[12px] bg-white dark:bg-zinc-500"> + <div class="w-full flex justify-end"> + <button + type="submit" + onClick={onConfirm} + class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Confirm</i18n.Translate> + </button> + </div> + </div> + </Fragment> +} diff --git a/packages/web-util/src/forms/converter.ts b/packages/web-util/src/forms/converter.ts new file mode 100644 index 000000000..eee891776 --- /dev/null +++ b/packages/web-util/src/forms/converter.ts @@ -0,0 +1,130 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AbsoluteTime, + AmountJson, + Amounts, + TalerExchangeApi, +} from "@gnu-taler/taler-util"; +import { format, parse } from "date-fns"; +import { StringConverter } from "./FormProvider.js"; + +export const amlStateConverter = { + toStringUI: stringifyAmlState, + fromStringUI: parseAmlState, +}; + +function stringifyAmlState(s: TalerExchangeApi.AmlState | undefined): string { + if (s === undefined) return ""; + switch (s) { + case TalerExchangeApi.AmlState.normal: + return "normal"; + case TalerExchangeApi.AmlState.pending: + return "pending"; + case TalerExchangeApi.AmlState.frozen: + return "frozen"; + } +} + +function parseAmlState(s: string | undefined): TalerExchangeApi.AmlState { + switch (s) { + case "normal": + return TalerExchangeApi.AmlState.normal; + case "pending": + return TalerExchangeApi.AmlState.pending; + case "frozen": + return TalerExchangeApi.AmlState.frozen; + default: + throw Error(`unknown AML state: ${s}`); + } +} + +const nullConverter: StringConverter<string> = { + fromStringUI(v: string | undefined): string { + return v ?? ""; + }, + toStringUI(v: unknown): string { + return v as string; + }, +}; + +function amountConverter(config: any): StringConverter<AmountJson> { + const currency = config["currency"]; + if (!currency || typeof currency !== "string") { + throw Error(`amount converter needs a currency`); + } + return { + fromStringUI(v: string | undefined): AmountJson { + // FIXME: requires currency + return ( + Amounts.parse(`${currency}:${v}`) ?? Amounts.zeroOfCurrency(currency) + ); + }, + toStringUI(v: unknown): string { + return v === undefined ? "" : Amounts.stringifyValue(v as AmountJson); + }, + }; +} + +function absTimeConverter(config: any): StringConverter<AbsoluteTime> { + const pattern = config["pattern"]; + if (!pattern || typeof pattern !== "string") { + throw Error(`absTime converter needs a pattern`); + } + return { + fromStringUI(v: string | undefined): AbsoluteTime { + if (v === undefined) { + return AbsoluteTime.never(); + } + try { + const time = parse(v, pattern, new Date()); + return AbsoluteTime.fromMilliseconds(time.getTime()); + } catch (e) { + return AbsoluteTime.never(); + } + }, + toStringUI(v: unknown): string { + if (v === undefined) return ""; + const d = v as AbsoluteTime; + if (d.t_ms === "never") return "never"; + try { + return format(d.t_ms, pattern); + } catch (e) { + return ""; + } + }, + }; +} + +export function getConverterById( + id: string | undefined, + config: unknown, +): StringConverter<unknown> { + if (id === "Taler.AbsoluteTime") { + // @ts-expect-error check this + return absTimeConverter(config); + } + if (id === "Taler.Amount") { + // @ts-expect-error check this + return amountConverter(config); + } + if (id === "TalerExchangeApi.AmlState") { + // @ts-expect-error check this + return amlStateConverter; + } + return nullConverter as StringConverter<unknown>; +} diff --git a/packages/web-util/src/forms/forms.ts b/packages/web-util/src/forms/forms.ts new file mode 100644 index 000000000..4c5050830 --- /dev/null +++ b/packages/web-util/src/forms/forms.ts @@ -0,0 +1,372 @@ +import { h as create, Fragment, VNode } from "preact"; +import { Caption } from "./Caption.js"; +import { Group } from "./Group.js"; +import { InputAbsoluteTime } from "./InputAbsoluteTime.js"; +import { InputAmount } from "./InputAmount.js"; +import { InputArray } from "./InputArray.js"; +import { InputChoiceHorizontal } from "./InputChoiceHorizontal.js"; +import { InputChoiceStacked } from "./InputChoiceStacked.js"; +import { InputFile } from "./InputFile.js"; +import { InputInteger } from "./InputInteger.js"; +import { InputSelectMultiple } from "./InputSelectMultiple.js"; +import { InputSelectOne } from "./InputSelectOne.js"; +import { InputText } from "./InputText.js"; +import { InputTextArea } from "./InputTextArea.js"; +import { InputToggle } from "./InputToggle.js"; +import { Addon, StringConverter, UIFieldHandler } from "./FormProvider.js"; +import { InternationalizationAPI, UIFieldElementDescription } from "../index.browser.js"; +import { assertUnreachable, TranslatedString } from "@gnu-taler/taler-util"; +import {UIFormFieldBaseConfig, UIFormElementConfig} from "./ui-form.js"; +/** + * Constrain the type with the ui props + */ +type FieldType<T extends object = any, K extends keyof T = any> = { + group: Parameters<typeof Group>[0]; + caption: Parameters<typeof Caption>[0]; + array: Parameters<typeof InputArray<T, K>>[0]; + file: Parameters<typeof InputFile<T, K>>[0]; + selectOne: Parameters<typeof InputSelectOne<T, K>>[0]; + selectMultiple: Parameters<typeof InputSelectMultiple<T, K>>[0]; + text: Parameters<typeof InputText<T, K>>[0]; + textArea: Parameters<typeof InputTextArea<T, K>>[0]; + choiceStacked: Parameters<typeof InputChoiceStacked<T, K>>[0]; + choiceHorizontal: Parameters<typeof InputChoiceHorizontal<T, K>>[0]; + absoluteTimeText: Parameters<typeof InputAbsoluteTime<T, K>>[0]; + integer: Parameters<typeof InputInteger<T, K>>[0]; + toggle: Parameters<typeof InputToggle<T, K>>[0]; + amount: Parameters<typeof InputAmount<T, K>>[0]; +}; + +/** + * List all the form fields so typescript can type-check the form instance + */ +export type UIFormField = + | { type: "group"; properties: FieldType["group"] } + | { type: "caption"; properties: FieldType["caption"] } + | { type: "array"; properties: FieldType["array"] } + | { type: "file"; properties: FieldType["file"] } + | { type: "amount"; properties: FieldType["amount"] } + | { type: "selectOne"; properties: FieldType["selectOne"] } + | { + type: "selectMultiple"; + properties: FieldType["selectMultiple"]; + } + | { type: "text"; properties: FieldType["text"] } + | { type: "textArea"; properties: FieldType["textArea"] } + | { + type: "choiceStacked"; + properties: FieldType["choiceStacked"]; + } + | { + type: "choiceHorizontal"; + properties: FieldType["choiceHorizontal"]; + } + | { type: "integer"; properties: FieldType["integer"] } + | { type: "toggle"; properties: FieldType["toggle"] } + | { + type: "absoluteTimeText"; + properties: FieldType["absoluteTimeText"]; + }; + +type FieldComponentFunction<key extends keyof FieldType> = ( + props: FieldType[key], +) => VNode; + +type UIFormFieldMap = { + [key in keyof FieldType]: FieldComponentFunction<key>; +}; + +/** + * Maps input type with component implementation + */ +const UIFormConfiguration: UIFormFieldMap = { + group: Group, + caption: Caption, + //@ts-ignore + array: InputArray, + text: InputText, + //@ts-ignore + file: InputFile, + textArea: InputTextArea, + //@ts-ignore + absoluteTimeText: InputAbsoluteTime, + //@ts-ignore + choiceStacked: InputChoiceStacked, + //@ts-ignore + choiceHorizontal: InputChoiceHorizontal, + integer: InputInteger, + //@ts-ignore + selectOne: InputSelectOne, + //@ts-ignore + selectMultiple: InputSelectMultiple, + //@ts-ignore + toggle: InputToggle, + //@ts-ignore + amount: InputAmount, +}; + +export function RenderAllFieldsByUiConfig({ + fields, +}: { + fields: UIFormField[]; +}): VNode { + return create( + Fragment, + {}, + fields.map((field, i) => { + const Component = UIFormConfiguration[ + field.type + ] as FieldComponentFunction<any>; + return Component(field.properties); + }), + ); +} + +// type FormSet<T extends object> = { +// Provider: typeof FormProvider<T>; +// InputLine: <K extends keyof T>() => typeof InputLine<T, K>; +// InputChoiceHorizontal: <K extends keyof T>() => typeof InputChoiceHorizontal<T, K>; +// }; + +/** + * Helper function that created a typed object. + * + * @returns + */ +// export function createNewForm<T extends object>() { +// const res: FormSet<T> = { +// Provider: FormProvider, +// InputLine: () => InputLine, +// InputChoiceHorizontal: () => InputChoiceHorizontal, +// }; +// return { +// Provider: res.Provider, +// InputLine: res.InputLine(), +// InputChoiceHorizontal: res.InputChoiceHorizontal(), +// }; +// } + +/** + * convert field configuration to render function + * + * @param i18n_ + * @param fieldConfig + * @param form + * @returns + */ +export function convertUiField( + i18n_: InternationalizationAPI, + fieldConfig: UIFormElementConfig[], + form: object, + getConverterById: GetConverterById, +): UIFormField[] { + return fieldConfig.map((config) => { + // NON input fields + switch (config.type) { + case "caption": { + const resp: UIFormField = { + type: config.type, + properties: converBaseFieldsProps(i18n_, config), + }; + return resp; + } + case "group": { + const resp: UIFormField = { + type: config.type, + properties: { + ...converBaseFieldsProps(i18n_, config), + fields: convertUiField(i18n_, config.fields, form, getConverterById), + }, + }; + return resp; + } + } + // Input Fields + switch (config.type) { + case "array": { + return { + type: "array", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + labelField: config.labelFieldId, + fields: convertUiField(i18n_, config.fields, form, getConverterById), + }, + } as UIFormField; + } + case "absoluteTimeText": { + return { + type: "absoluteTimeText", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + }, + } as UIFormField; + } + case "amount": { + return { + type: "amount", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + currency: config.currency, + }, + } as UIFormField; + } + case "choiceHorizontal": { + return { + type: "choiceHorizontal", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + choices: config.choices, + }, + } as UIFormField; + } + case "choiceStacked": { + return { + type: "choiceStacked", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + choices: config.choices, + + }, + }as UIFormField; + } + case "file":{ + return { + type: "file", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + accept: config.accept, + maxBites: config.maxBytes, + }, + } as UIFormField; + } + case "integer":{ + return { + type: "integer", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + }, + } as UIFormField; + } + case "selectMultiple":{ + return { + type: "selectMultiple", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + choices: config.choices, + }, + } as UIFormField; + } + case "selectOne": { + return { + type: "selectOne", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + choices: config.choices, + }, + } as UIFormField; + } + case "text": { + return { + type: "text", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + }, + } as UIFormField; + } + case "textArea": { + return { + type: "text", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + }, + } as UIFormField; + } + case "toggle": { + return { + type: "toggle", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + }, + } as UIFormField; + } + default: { + assertUnreachable(config); + } + } + }); +} + + + +function getAddonById(_id: string | undefined): Addon { + return undefined!; +} + + +type GetConverterById = ( + id: string | undefined, + config: unknown, +) => StringConverter<unknown>; + + +function converInputFieldsProps( + form: object, + p: UIFormFieldBaseConfig, + getConverterById: GetConverterById, +) { + return { + converter: getConverterById(p.converterId, p), + handler: getValueDeeper2(form, p.id.split(".")), + name: p.name, + required: p.required, + disabled: p.disabled, + help: p.help, + placeholder: p.placeholder, + tooltip: p.tooltip, + label: p.label as TranslatedString, + }; +} + +function converBaseFieldsProps( + i18n_: InternationalizationAPI, + p: UIFieldElementDescription, +) { + return { + after: getAddonById(p.addonAfterId), + before: getAddonById(p.addonBeforeId), + hidden: p.hidden, + name: p.name, + help: i18n_.str`${p.help}`, + label: i18n_.str`${p.label}`, + tooltip: i18n_.str`${p.tooltip}`, + }; +} + +export function getValueDeeper2( + object: Record<string, any>, + names: string[], +): UIFieldHandler { + if (names.length === 0) return object as UIFieldHandler; + const [head, ...rest] = names; + if (!head) { + return getValueDeeper2(object, rest); + } + if (object === undefined) { + throw Error("handler not found"); + } + return getValueDeeper2(object[head], rest); +} + + diff --git a/packages/web-util/src/forms/index.stories.ts b/packages/web-util/src/forms/index.stories.ts new file mode 100644 index 000000000..55878cb02 --- /dev/null +++ b/packages/web-util/src/forms/index.stories.ts @@ -0,0 +1,13 @@ +export * as a1 from "./InputAmount.stories.js"; +export * as a2 from "./InputArray.stories.js"; +export * as a3 from "./InputChoiceHorizontal.stories.js"; +export * as a4 from "./InputChoiceStacked.stories.js"; +export * as a5 from "./InputAbsoluteTime.stories.js"; +export * as a6 from "./InputFile.stories.js"; +export * as a7 from "./InputInteger.stories.js"; +export * as a8 from "./InputLine.stories.js"; +export * as a9 from "./InputSelectMultiple.stories.js"; +export * as a10 from "./InputSelectOne.stories.js"; +export * as a11 from "./InputText.stories.js"; +export * as a12 from "./InputTextArea.stories.js"; +export * as a13 from "./InputToggle.stories.js"; diff --git a/packages/web-util/src/forms/index.ts b/packages/web-util/src/forms/index.ts new file mode 100644 index 000000000..7320c70d0 --- /dev/null +++ b/packages/web-util/src/forms/index.ts @@ -0,0 +1,25 @@ +export * from "./Calendar.js" +export * from "./Caption.js" +export * from "./DefaultForm.js" +export * from "./Dialog.js" +export * from "./FormProvider.js" +export * from "./Group.js" +export * from "./InputAbsoluteTime.js" +export * from "./InputAmount.js" +export * from "./InputArray.js" +export * from "./InputChoiceHorizontal.js" +export * from "./InputChoiceStacked.js" +export * from "./InputFile.js" +export * from "./InputInteger.js" +export * from "./InputLine.js" +export * from "./InputSelectMultiple.js" +export * from "./InputSelectOne.js" +export * from "./InputText.js" +export * from "./InputTextArea.js" +export * from "./InputToggle.js" +export * from "./TimePicker.js" +export * from "./forms.js" +export * from "./ui-form.js" +export * from "./converter.js" +export * from "./useField.js" + diff --git a/packages/web-util/src/forms/ui-form.ts b/packages/web-util/src/forms/ui-form.ts new file mode 100644 index 000000000..012499d6d --- /dev/null +++ b/packages/web-util/src/forms/ui-form.ts @@ -0,0 +1,363 @@ +import { + buildCodecForObject, + buildCodecForUnion, + Codec, + codecForBoolean, + codecForConstString, + codecForLazy, + codecForList, + codecForNumber, + codecForString, + codecForTimestamp, + codecOptional, + Integer, + TalerProtocolTimestamp, +} from "@gnu-taler/taler-util"; + +export type FormConfiguration = DoubleColumnForm; + +export type DoubleColumnForm = { + type: "double-column"; + design: DoubleColumnFormSection[]; + // behavior?: (form: Partial<T>) => FormState<T>; +}; + +export type DoubleColumnFormSection = { + title: string; + description?: string; + fields: UIFormElementConfig[]; +}; + +// export interface BaseForm { +// state: TalerExchangeApi.AmlState; +// threshold: AmountJson; +// } + +export type UIFormElementConfig = + | UIFormElementGroup + | UIFormElementCaption + | UIFormFieldAbsoluteTime + | UIFormFieldAmount + | UIFormFieldArray + | UIFormFieldChoiseHorizontal + | UIFormFieldChoiseStacked + | UIFormFieldFile + | UIFormFieldInteger + | UIFormFieldSelectMultiple + | UIFormFieldSelectOne + | UIFormFieldText + | UIFormFieldTextArea + | UIFormFieldToggle; + +type UIFormFieldAbsoluteTime = { + type: "absoluteTimeText"; + max?: TalerProtocolTimestamp; + min?: TalerProtocolTimestamp; + pattern: string; +} & UIFormFieldBaseConfig; + +type UIFormFieldAmount = { + type: "amount"; + max?: Integer; + min?: Integer; + currency: string; +} & UIFormFieldBaseConfig; + +type UIFormFieldArray = { + type: "array"; + // id of the field shown when the array is collapsed + labelFieldId: UIHandlerId; + fields: UIFormElementConfig[]; +} & UIFormFieldBaseConfig; + +type UIFormElementCaption = { type: "caption" } & UIFieldElementDescription; + +type UIFormElementGroup = { + type: "group"; + fields: UIFormElementConfig[]; +} & UIFieldElementDescription; + +type UIFormFieldChoiseHorizontal = { + type: "choiceHorizontal"; + choices: Array<SelectUiChoice>; +} & UIFormFieldBaseConfig; + +type UIFormFieldChoiseStacked = { + type: "choiceStacked"; + choices: Array<SelectUiChoice>; +} & UIFormFieldBaseConfig; + +type UIFormFieldFile = { + type: "file"; + maxBytes?: Integer; + minBytes?: Integer; + // comma-separated list of one or more file types + // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept#unique_file_type_specifiers + accept?: string; +} & UIFormFieldBaseConfig; + +type UIFormFieldInteger = { + type: "integer"; + max?: Integer; + min?: Integer; +} & UIFormFieldBaseConfig; + +interface SelectUiChoice { + label: string; + description?: string; + value: string; +} + +type UIFormFieldSelectMultiple = { + type: "selectMultiple"; + max?: Integer; + min?: Integer; + unique?: boolean; + choices: Array<SelectUiChoice>; +} & UIFormFieldBaseConfig; + +type UIFormFieldSelectOne = { + type: "selectOne"; + choices: Array<SelectUiChoice>; +} & UIFormFieldBaseConfig; +type UIFormFieldText = { type: "text" } & UIFormFieldBaseConfig; +type UIFormFieldTextArea = { type: "textArea" } & UIFormFieldBaseConfig; +type UIFormFieldToggle = { type: "toggle" } & UIFormFieldBaseConfig; + +export type UIFieldElementDescription = { + /* label if the field, visible for the user */ + label: string; + + /* long text to be shown on user demand */ + tooltip?: string; + + /* short text to be shown close to the field, usually below and dimmer*/ + help?: string; + + /* name of the field, useful for a11y */ + name: string; + + /* if the field should be initially hidden */ + hidden?: boolean; + + /* ui element to show before */ + addonBeforeId?: string; + + /* ui element to show after */ + addonAfterId?: string; +}; + +export type UIFormFieldBaseConfig = UIFieldElementDescription & { + /* example to be shown inside the field */ + placeholder?: string; + + /* show a mark as required */ + required?: boolean; + + /* readonly and dim */ + disabled?: boolean; + + /* conversion id to convert the string into the value type + the id should be known to the ui impl + */ + converterId?: string; + + /* property id of the form */ + id: UIHandlerId; +}; + +declare const __handlerId: unique symbol; +export type UIHandlerId = string & { [__handlerId]: true }; + +// FIXME: validate well formed ui field id +const codecForUiFieldId = codecForString as () => Codec<UIHandlerId>; + +const codecForUIFormFieldBaseDescriptionTemplate = < + T extends UIFieldElementDescription, +>() => + buildCodecForObject<T>() + .property("addonAfterId", codecOptional(codecForString())) + .property("addonBeforeId", codecOptional(codecForString())) + .property("hidden", codecOptional(codecForBoolean())) + .property("help", codecOptional(codecForString())) + .property("label", codecForString()) + .property("name", codecForString()) + .property("tooltip", codecOptional(codecForString())); + +const codecForUIFormFieldBaseConfigTemplate = < + T extends UIFormFieldBaseConfig, +>() => + codecForUIFormFieldBaseDescriptionTemplate<T>() + .property("id", codecForUiFieldId()) + .property("converterId", codecOptional(codecForString())) + .property("disabled", codecOptional(codecForBoolean())) + .property("required", codecOptional(codecForBoolean())) + .property("placeholder", codecOptional(codecForString())); + +const codecForUiFormFieldAbsoluteTime = (): Codec<UIFormFieldAbsoluteTime> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldAbsoluteTime>() + .property("type", codecForConstString("absoluteTimeText")) + .property("pattern", codecForString()) + .property("max", codecOptional(codecForTimestamp)) + .property("min", codecOptional(codecForTimestamp)) + .build("UIFormFieldAbsoluteTime"); + +const codecForUiFormFieldAmount = (): Codec<UIFormFieldAmount> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldAmount>() + .property("type", codecForConstString("amount")) + .property("currency", codecForString()) + .property("max", codecOptional(codecForNumber())) + .property("min", codecOptional(codecForNumber())) + .build("UIFormFieldAmount"); + +const codecForUiFormFieldArray = (): Codec<UIFormFieldArray> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldArray>() + .property("type", codecForConstString("array")) + .property("labelFieldId", codecForUiFieldId()) + .property("tooltip", codecOptional(codecForString())) + // eslint-disable-next-line @typescript-eslint/no-use-before-define + .property("fields", codecForList(codecForUiFormField())) + .build("UIFormFieldArray"); + +const codecForUiFormFieldCaption = (): Codec<UIFormElementCaption> => + codecForUIFormFieldBaseDescriptionTemplate<UIFormElementCaption>() + .property("type", codecForConstString("caption")) + .build("UIFormFieldCaption"); + +const codecForUiFormSelectUiChoice = (): Codec<SelectUiChoice> => + buildCodecForObject<SelectUiChoice>() + .property("description", codecOptional(codecForString())) + .property("label", codecForString()) + .property("value", codecForString()) + .build("SelectUiChoice"); + +const codecForUiFormFieldChoiceHorizontal = + (): Codec<UIFormFieldChoiseHorizontal> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldChoiseHorizontal>() + .property("type", codecForConstString("choiceHorizontal")) + .property("choices", codecForList(codecForUiFormSelectUiChoice())) + .build("UIFormFieldChoiseHorizontal"); + +const codecForUiFormFieldChoiceStacked = (): Codec<UIFormFieldChoiseStacked> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldChoiseStacked>() + .property("type", codecForConstString("choiceStacked")) + .property("choices", codecForList(codecForUiFormSelectUiChoice())) + .build("UIFormFieldChoiseStacked"); + +const codecForUiFormFieldFile = (): Codec<UIFormFieldFile> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldFile>() + .property("type", codecForConstString("file")) + .property("accept", codecOptional(codecForString())) + .property("maxBytes", codecOptional(codecForNumber())) + .property("minBytes", codecOptional(codecForNumber())) + .build("UIFormFieldFile"); + +const codecForUiFormFieldGroup = (): Codec<UIFormElementGroup> => + codecForUIFormFieldBaseDescriptionTemplate<UIFormElementGroup>() + .property("type", codecForConstString("group")) + // eslint-disable-next-line @typescript-eslint/no-use-before-define + .property("fields", codecForList(codecForUiFormField())) + .build("UiFormFieldGroup"); + +const codecForUiFormFieldInteger = (): Codec<UIFormFieldInteger> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldInteger>() + .property("type", codecForConstString("integer")) + // .property("properties", codecForUIFormFieldBaseConfig()) + .property("max", codecOptional(codecForNumber())) + .property("min", codecOptional(codecForNumber())) + .build("UIFormFieldInteger"); + +const codecForUiFormFieldSelectMultiple = + (): Codec<UIFormFieldSelectMultiple> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldSelectMultiple>() + .property("type", codecForConstString("selectMultiple")) + .property("max", codecOptional(codecForNumber())) + .property("min", codecOptional(codecForNumber())) + .property("unique", codecOptional(codecForBoolean())) + .property("choices", codecForList(codecForUiFormSelectUiChoice())) + .build("UiFormFieldSelectMultiple"); + +const codecForUiFormFieldSelectOne = (): Codec<UIFormFieldSelectOne> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldSelectOne>() + .property("type", codecForConstString("selectOne")) + .property("choices", codecForList(codecForUiFormSelectUiChoice())) + .build("UIFormFieldSelectOne"); + +const codecForUiFormFieldText = (): Codec<UIFormFieldText> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldText>() + .property("type", codecForConstString("text")) + .build("UIFormFieldText"); + +const codecForUiFormFieldTextArea = (): Codec<UIFormFieldTextArea> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldTextArea>() + .property("type", codecForConstString("textArea")) + .build("UIFormFieldTextArea"); + +const codecForUiFormFieldToggle = (): Codec<UIFormFieldToggle> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldToggle>() + .property("type", codecForConstString("toggle")) + .build("UIFormFieldToggle"); + +const codecForUiFormField = (): Codec<UIFormElementConfig> => + buildCodecForUnion<UIFormElementConfig>() + .discriminateOn("type") + .alternative("array", codecForLazy(codecForUiFormFieldArray)) + .alternative("group", codecForLazy(codecForUiFormFieldGroup)) + .alternative("absoluteTimeText", codecForUiFormFieldAbsoluteTime()) + .alternative("amount", codecForUiFormFieldAmount()) + .alternative("caption", codecForUiFormFieldCaption()) + .alternative("choiceHorizontal", codecForUiFormFieldChoiceHorizontal()) + .alternative("choiceStacked", codecForUiFormFieldChoiceStacked()) + .alternative("file", codecForUiFormFieldFile()) + .alternative("integer", codecForUiFormFieldInteger()) + .alternative("selectMultiple", codecForUiFormFieldSelectMultiple()) + .alternative("selectOne", codecForUiFormFieldSelectOne()) + .alternative("text", codecForUiFormFieldText()) + .alternative("textArea", codecForUiFormFieldTextArea()) + .alternative("toggle", codecForUiFormFieldToggle()) + .build("UIFormField"); + +const codecForDoubleColumnFormSection = (): Codec<DoubleColumnFormSection> => + buildCodecForObject<DoubleColumnFormSection>() + .property("title", codecForString()) + .property("description", codecOptional(codecForString())) + .property("fields", codecForList(codecForUiFormField())) + .build("DoubleColumnFormSection"); + +const codecForDoubleColumnForm = (): Codec<DoubleColumnForm> => + buildCodecForObject<DoubleColumnForm>() + .property("type", codecForConstString("double-column")) + .property("design", codecForList(codecForDoubleColumnFormSection())) + .build("DoubleColumnForm"); + +const codecForFormConfiguration = (): Codec<FormConfiguration> => + buildCodecForUnion<FormConfiguration>() + .discriminateOn("type") + .alternative("double-column", codecForDoubleColumnForm()) + .build<FormConfiguration>("FormConfiguration"); + +const codecForFormMetadata = (): Codec<FormMetadata> => + buildCodecForObject<FormMetadata>() + .property("label", codecForString()) + .property("id", codecForString()) + .property("version", codecForNumber()) + .property("config", codecForFormConfiguration()) + .build("FormMetadata"); + +export const codecForUIForms = (): Codec<UiForms> => + buildCodecForObject<UiForms>() + .property("forms", codecForList(codecForFormMetadata())) + .build("UiForms"); + +export type FormMetadata = { + label: string; + id: string; + version: number; + config: FormConfiguration; +}; + +export interface UiForms { + // Where libeufin backend is localted + // default: window.origin without "webui/" + forms: Array<FormMetadata>; +} diff --git a/packages/web-util/src/forms/useField.ts b/packages/web-util/src/forms/useField.ts new file mode 100644 index 000000000..a250d3100 --- /dev/null +++ b/packages/web-util/src/forms/useField.ts @@ -0,0 +1,91 @@ +import { useContext } from "preact/compat"; +import { FieldUIOptions, FormContext } from "./FormProvider.js"; +import { TranslatedString } from "@gnu-taler/taler-util"; + +export interface InputFieldHandler<Type> { + value: Type; + onChange: (s: Type) => void; + state: FieldUIOptions; + error?: TranslatedString | undefined; +} + +/** + * @deprecated removing this so we don't depend on context to create a form + * @param name + * @returns + */ +export function useField<T extends object, K extends keyof T>( + name: K, +): InputFieldHandler<T[K]> | undefined { + const ctx = useContext(FormContext); + if (!ctx) { + //no context, can't be used + return undefined; + } + const { + value: formValue, + computeFormState, + onUpdate: notifyUpdate, + readOnly: readOnlyForm, + } = ctx + + type P = typeof name; + type V = T[P]; + const formState = computeFormState ? computeFormState(formValue.current) : {}; + + const fieldValue = readField(formValue.current, String(name)) as V; + + const fieldState = + readField<Partial<FieldUIOptions>>(formState, String(name)) ?? {}; + + //compute default state + const state = { + disabled: readOnlyForm ? true : (fieldState.disabled ?? false), + hidden: fieldState.hidden ?? false, + help: fieldState.help, + elements: "elements" in fieldState ? fieldState.elements ?? [] : [], + }; + + function onChange(value: V): void { + // setCurrentValue(value); + formValue.current = setValueDeeper( + formValue.current, + String(name).split("."), + value, + ); + if (notifyUpdate) { + notifyUpdate(formValue.current); + } + } + + return { + value: fieldValue, + onChange, + state, + }; +} + +/** + * read the field of an object an support accessing it using '.' + * + * @param object + * @param name + * @returns + */ +function readField<T>( + object: any, + name: string, +): T | undefined { + return name.split(".").reduce((prev, current) => { + return prev ? prev[current] : undefined; + }, object); +} + +function setValueDeeper(object: any, names: string[], value: any): any { + if (names.length === 0) return value; + const [head, ...rest] = names; + if (object === undefined) { + return { [head]: setValueDeeper({}, rest, value) }; + } + return { ...object, [head]: setValueDeeper(object[head] ?? {}, rest, value) }; +} diff --git a/packages/web-util/src/hooks/index.ts b/packages/web-util/src/hooks/index.ts index f18d61b9c..ba1b6e222 100644 --- a/packages/web-util/src/hooks/index.ts +++ b/packages/web-util/src/hooks/index.ts @@ -1,3 +1,13 @@ - export { useLang } from "./useLang.js"; -export { useLocalStorage, useNotNullLocalStorage } from "./useLocalStorage.js"
\ No newline at end of file +export { useLocalStorage, buildStorageKey, StorageKey, StorageState } from "./useLocalStorage.js"; +export { useMemoryStorage } from "./useMemoryStorage.js"; +export * from "./useNotifications.js"; +export { + useAsyncAsHook, + HookError, + HookOk, + HookResponse, + HookResponseWithRetry, + HookGenericError, + HookOperationalError, +} from "./useAsyncAsHook.js"; diff --git a/packages/web-util/src/hooks/useAsyncAsHook.ts b/packages/web-util/src/hooks/useAsyncAsHook.ts new file mode 100644 index 000000000..48d29aa45 --- /dev/null +++ b/packages/web-util/src/hooks/useAsyncAsHook.ts @@ -0,0 +1,91 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { TalerErrorDetail } from "@gnu-taler/taler-util"; +// import { TalerError } from "@gnu-taler/taler-wallet-core"; +import { useEffect, useMemo, useState } from "preact/hooks"; + +export interface HookOk<T> { + hasError: false; + response: T; +} + +export type HookError = HookGenericError | HookOperationalError; + +export interface HookGenericError { + hasError: true; + operational: false; + message: string; +} + +export interface HookOperationalError { + hasError: true; + operational: true; + details: TalerErrorDetail; +} + +interface WithRetry { + retry: () => void; +} + +export type HookResponse<T> = HookOk<T> | HookError | undefined; +export type HookResponseWithRetry<T> = + | ((HookOk<T> | HookError) & WithRetry) + | undefined; + +export function useAsyncAsHook<T>( + fn: () => Promise<T | false>, + deps?: any[], +): HookResponseWithRetry<T> { + const [result, setHookResponse] = useState<HookResponse<T>>(undefined); + + const args = useMemo( + () => ({ + fn, + // eslint-disable-next-line react-hooks/exhaustive-deps + }), + deps || [], + ); + + async function doAsync(): Promise<void> { + try { + const response = await args.fn(); + if (response === false) return; + setHookResponse({ hasError: false, response }); + } catch (e) { + // if (e instanceof TalerError) { + // setHookResponse({ + // hasError: true, + // operational: true, + // details: e.errorDetail, + // }); + // } else + if (e instanceof Error) { + setHookResponse({ + hasError: true, + operational: false, + message: e.message, + }); + } + } + } + + useEffect(() => { + doAsync(); + }, [args]); + + if (!result) return undefined; + return { ...result, retry: doAsync }; +} diff --git a/packages/web-util/src/hooks/useLang.ts b/packages/web-util/src/hooks/useLang.ts index 5b02c5255..5b1be0309 100644 --- a/packages/web-util/src/hooks/useLang.ts +++ b/packages/web-util/src/hooks/useLang.ts @@ -14,17 +14,48 @@ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { useNotNullLocalStorage } from "./useLocalStorage.js"; +import { + StorageState, + buildStorageKey, + useLocalStorage, +} from "./useLocalStorage.js"; + +const MIN_LANG_COVERAGE_THRESHOLD = 90; +/** + * choose the best from the browser config based on the completeness + * on the translation + */ +function getBrowserLang(completeness: Record<string, number>): string | undefined { + if (typeof window === "undefined") return undefined; + + if (window.navigator.language) { + if (completeness[window.navigator.language] >= MIN_LANG_COVERAGE_THRESHOLD) { + return window.navigator.language + } + } + if (window.navigator.languages) { + const match = Object.entries(completeness).filter(([code, value]) => { + if (value < MIN_LANG_COVERAGE_THRESHOLD) return false; //do not consider langs below 90% + return window.navigator.languages.findIndex(l => l.startsWith(code)) !== -1 + }).map(([code, value]) => ({ code, value })) + + if (match.length > 0) { + let max = match[0] + match.forEach(v => { + if (v.value > max.value) { + max = v + } + }) + return max.code + } + }; -function getBrowserLang(): string | undefined { - if (window.navigator.languages) return window.navigator.languages[0]; - if (window.navigator.language) return window.navigator.language; return undefined; } -export function useLang( - initial?: string, -): [string, (s: string) => void, boolean] { - const defaultLang = (getBrowserLang() || initial || "en").substring(0, 2); - return useNotNullLocalStorage("lang-preference", defaultLang); +const langPreferenceKey = buildStorageKey("lang-preference"); + +export function useLang(initial: string | undefined, completeness: Record<string, number>): Required<StorageState> { + const defaultValue = (getBrowserLang(completeness) || initial || "en").substring(0, 2); + return useLocalStorage(langPreferenceKey, defaultValue); } diff --git a/packages/web-util/src/hooks/useLocalStorage.ts b/packages/web-util/src/hooks/useLocalStorage.ts index 25df9dfcd..abd80bacc 100644 --- a/packages/web-util/src/hooks/useLocalStorage.ts +++ b/packages/web-util/src/hooks/useLocalStorage.ts @@ -19,90 +19,121 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { StateUpdater, useEffect, useState } from "preact/hooks"; - -export function useLocalStorage( - key: string, - initialValue?: string, -): [string | undefined, StateUpdater<string | undefined>] { - const [storedValue, setStoredValue] = useState<string | undefined>( - (): string | undefined => { - return typeof window !== "undefined" - ? window.localStorage.getItem(key) || initialValue - : initialValue; - }, - ); +import { AbsoluteTime, Codec, codecForString } from "@gnu-taler/taler-util"; +import { useEffect, useState } from "preact/hooks"; +import { + ObservableMap, + browserStorageMap, + localStorageMap, + memoryMap, +} from "../utils/observable.js"; - useEffect(() => { - const listener = buildListenerForKey(key, (newValue) => { - setStoredValue(newValue ?? initialValue) - }) - window.addEventListener('storage', listener) - return () => { - window.removeEventListener('storage', listener) - } - }, []) - - const setValue = ( - value?: string | ((val?: string) => string | undefined), - ): void => { - setStoredValue((p) => { - const toStore = value instanceof Function ? value(p) : value; - if (typeof window !== "undefined") { - if (!toStore) { - window.localStorage.removeItem(key); - } else { - window.localStorage.setItem(key, toStore); - } - } - return toStore; - }); - }; +declare const opaque_StorageKey: unique symbol; - return [storedValue, setValue]; +export type StorageKey<Key> = { + id: string; + [opaque_StorageKey]: true; + codec: Codec<Key>; +}; + +export function buildStorageKey<Key>( + name: string, + codec: Codec<Key>, +): StorageKey<Key>; +export function buildStorageKey(name: string): StorageKey<string>; +export function buildStorageKey<Key = string>( + name: string, + codec?: Codec<Key>, +): StorageKey<Key> { + return { + id: name, + codec: codec ?? (codecForString() as Codec<Key>), + } as StorageKey<Key>; } -function buildListenerForKey(key: string, onUpdate: (newValue: string | undefined) => void): () => void { - return function listenKeyChange() { - const value = window.localStorage.getItem(key) - onUpdate(value ?? undefined) - } +export interface StorageState<Type = string> { + value?: Type; + update: (s: Type) => void; + reset: () => void; } -//TODO: merge with the above function -export function useNotNullLocalStorage( - key: string, - initialValue: string, -): [string, StateUpdater<string>, boolean] { - const [storedValue, setStoredValue] = useState<string>((): string => { - return typeof window !== "undefined" - ? window.localStorage.getItem(key) || initialValue - : initialValue; - }); +const supportLocalStorage = typeof window !== "undefined"; +const supportBrowserStorage = + typeof chrome !== "undefined" && typeof chrome.storage !== "undefined"; + +/** + * Build setting storage + */ +const storage: ObservableMap<string, string> = (function buildStorage() { + if (supportBrowserStorage) { + //browser storage is like local storage but + //with app sync. + //Works for almost every browser + if (supportLocalStorage) { + return browserStorageMap(localStorageMap()); + } else { + // service worker doesn't have local storage + return browserStorageMap(memoryMap<string>()); + } + } else if (supportLocalStorage) { + // fallback if browser is too old + return localStorageMap(); + } else { + // new need to save settings somewhere + return memoryMap<string>(); + } +})(); +//with initial value +export function useLocalStorage<Type = string>( + key: StorageKey<Type>, + defaultValue: Type, +): Required<StorageState<Type>>; +//without initial value +export function useLocalStorage<Type = string>( + key: StorageKey<Type>, +): StorageState<Type>; +// impl +export function useLocalStorage<Type = string>( + key: StorageKey<Type>, + defaultValue?: Type, +): StorageState<Type> { + const current = convert(storage.get(key.id), key, defaultValue); + const [_, setStoredValue] = useState(AbsoluteTime.now().t_ms); useEffect(() => { - const listener = buildListenerForKey(key, (newValue) => { - setStoredValue(newValue ?? initialValue) - }) - window.localStorage.addEventListener('storage', listener) - return () => { - window.localStorage.removeEventListener('storage', listener) - } - }) - - const setValue = (value: string | ((val: string) => string)): void => { - const valueToStore = value instanceof Function ? value(storedValue) : value; - setStoredValue(valueToStore); - if (typeof window !== "undefined") { - if (!valueToStore) { - window.localStorage.removeItem(key); - } else { - window.localStorage.setItem(key, valueToStore); - } + return storage.onUpdate(key.id, () => { + // const newValue = storage.get(key.id); + setStoredValue(AbsoluteTime.now().t_ms); + }); + }, [key.id]); + + const setValue = (value?: Type): void => { + if (value === undefined) { + storage.delete(key.id); + } else { + storage.set( + key.id, + key.codec ? JSON.stringify(value) : (value as string), + ); } }; - const isSaved = window.localStorage.getItem(key) !== null; - return [storedValue, setValue, isSaved]; + return { + value: current, + update: setValue, + reset: () => { + setValue(defaultValue); + }, + }; +} + +function convert<Type>(updated: string | undefined, key: StorageKey<Type>, defaultValue?: Type): Type | undefined { + if (updated === undefined) return defaultValue; //optional + try { + return key.codec.decode(JSON.parse(updated)); + } catch (e) { + //decode error + return defaultValue; + } } diff --git a/packages/web-util/src/hooks/useMemoryStorage.ts b/packages/web-util/src/hooks/useMemoryStorage.ts new file mode 100644 index 000000000..ef186392f --- /dev/null +++ b/packages/web-util/src/hooks/useMemoryStorage.ts @@ -0,0 +1,71 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { useEffect, useState } from "preact/hooks"; +import { ObservableMap, memoryMap } from "../utils/observable.js"; +import { StorageKey, StorageState } from "./useLocalStorage.js"; + +const storage: ObservableMap<string, any> = memoryMap<any>(); + +//with initial value +export function useMemoryStorage<Type = string>( + key: string, + defaultValue: Type, +): Required<StorageState<Type>>; +//with initial value +export function useMemoryStorage<Type = string>( + key: string, +): StorageState<Type>; +// impl +export function useMemoryStorage<Type = string>( + key: string, + defaultValue?: Type, +): StorageState<Type> { + const [storedValue, setStoredValue] = useState<Type | undefined>( + (): Type | undefined => { + const prev = storage.get(key); + return prev === undefined ? defaultValue : prev; + }, + ); + + useEffect(() => { + return storage.onUpdate(key, () => { + const newValue = storage.get(key); + setStoredValue(newValue === undefined ? defaultValue : newValue); + }); + }, [key]); + + const setValue = (value?: Type): void => { + if (value === undefined) { + storage.delete(key); + } else { + storage.set(key, value); + } + }; + + return { + value: storedValue, + update: setValue, + reset: () => { + setValue(defaultValue); + }, + }; +} diff --git a/packages/web-util/src/hooks/useNotifications.ts b/packages/web-util/src/hooks/useNotifications.ts new file mode 100644 index 000000000..103b88c86 --- /dev/null +++ b/packages/web-util/src/hooks/useNotifications.ts @@ -0,0 +1,337 @@ +import { + AbsoluteTime, + Duration, + OperationAlternative, + OperationFail, + OperationOk, + OperationResult, + TalerError, + TalerErrorCode, + TranslatedString, +} from "@gnu-taler/taler-util"; +import { useEffect, useState } from "preact/hooks"; +import { ButtonHandler, OnOperationFailReturnType, OnOperationSuccesReturnType } from "../components/Button.js"; +import { + InternationalizationAPI, + memoryMap, + useTranslationContext, +} from "../index.browser.js"; + +export type NotificationMessage = ErrorNotification | InfoNotification; + +export interface ErrorNotification { + type: "error"; + title: TranslatedString; + ack?: boolean; + timeout?: boolean; + description?: TranslatedString; + debug?: any; + when: AbsoluteTime; +} +export interface InfoNotification { + type: "info"; + title: TranslatedString; + ack?: boolean; + timeout?: boolean; + when: AbsoluteTime; +} + +const storage = memoryMap<Map<string, NotificationMessage>>(); +const NOTIFICATION_KEY = "notification"; + +export const GLOBAL_NOTIFICATION_TIMEOUT = Duration.fromSpec({ + seconds: 5, +}); + +function updateInStorage(n: NotificationMessage) { + const h = hash(n); + const mem = storage.get(NOTIFICATION_KEY) ?? new Map(); + const newState = new Map(mem); + newState.set(h, n); + storage.set(NOTIFICATION_KEY, newState); +} + +export function notify(notif: NotificationMessage): void { + const currentState: Map<string, NotificationMessage> = + storage.get(NOTIFICATION_KEY) ?? new Map(); + const newState = currentState.set(hash(notif), notif); + + if (GLOBAL_NOTIFICATION_TIMEOUT.d_ms !== "forever") { + setTimeout(() => { + notif.timeout = true; + updateInStorage(notif); + }, GLOBAL_NOTIFICATION_TIMEOUT.d_ms); + } + + storage.set(NOTIFICATION_KEY, newState); +} +export function notifyError( + title: TranslatedString, + description: TranslatedString | undefined, + debug?: any, +) { + notify({ + type: "error" as const, + title, + description, + debug, + when: AbsoluteTime.now(), + }); +} +export function notifyException(title: TranslatedString, ex: Error) { + notify({ + type: "error" as const, + title, + description: ex.message as TranslatedString, + debug: ex.stack, + when: AbsoluteTime.now(), + }); +} +export function notifyInfo(title: TranslatedString) { + notify({ + type: "info" as const, + title, + when: AbsoluteTime.now(), + }); +} + +export type Notification = { + message: NotificationMessage; + acknowledge: () => void; +}; + +export function useNotifications(): Notification[] { + const [, setLastUpdate] = useState<number>(); + const value = storage.get(NOTIFICATION_KEY) ?? new Map(); + + useEffect(() => { + return storage.onUpdate(NOTIFICATION_KEY, () => { + setLastUpdate(Date.now()) + // const mem = storage.get(NOTIFICATION_KEY) ?? new Map(); + // setter(structuredClone(mem)); + }); + }); + + return Array.from(value.values()).map((message, idx) => { + return { + message, + acknowledge: () => { + message.ack = true; + updateInStorage(message); + }, + }; + }); +} + +function hashCode(str: string): string { + if (str.length === 0) return "0"; + let hash = 0; + let chr; + for (let i = 0; i < str.length; i++) { + chr = str.charCodeAt(i); + hash = (hash << 5) - hash + chr; + hash |= 0; // Convert to 32bit integer + } + return hash.toString(16); +} + +function hash(msg: NotificationMessage): string { + let str = (msg.type + ":" + msg.title) as string; + if (msg.type === "error") { + if (msg.description) { + str += ":" + msg.description; + } + if (msg.debug) { + str += ":" + msg.debug; + } + } + return hashCode(str); +} + +function errorMap<T extends OperationFail<unknown>>( + resp: T, + map: (d: T["case"]) => TranslatedString, +): void { + notify({ + type: "error", + title: map(resp.case), + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); +} + +export type ErrorNotificationHandler = ( + cb: (notify: typeof errorMap) => Promise<void>, +) => Promise<void>; + +/** + * @deprecated use useLocalNotificationHandler + * + * @returns + */ +export function useLocalNotification(): [ + Notification | undefined, + (n: NotificationMessage) => void, + ErrorNotificationHandler, +] { + const { i18n } = useTranslationContext(); + + const [value, setter] = useState<NotificationMessage>(); + const notif = !value + ? undefined + : { + message: value, + acknowledge: () => { + setter(undefined); + }, + }; + + async function errorHandling(cb: (notify: typeof errorMap) => Promise<void>) { + try { + return await cb(errorMap); + } catch (error: unknown) { + if (error instanceof TalerError) { + notify(buildUnifiedRequestErrorMessage(i18n, error)); + } else { + notifyError( + i18n.str`Operation failed, please report`, + (error instanceof Error + ? error.message + : JSON.stringify(error)) as TranslatedString, + ); + } + } + } + return [notif, setter, errorHandling]; +} + +type HandlerMaker = <T extends OperationResult<A, B>, A, B>( + onClick: () => Promise<T | undefined>, + onOperationSuccess: OnOperationSuccesReturnType<T>, + onOperationFail?: OnOperationFailReturnType<T>, + onOperationComplete?: () => void, +) => ButtonHandler<T, A, B>; + +export function useLocalNotificationHandler(): [ + Notification | undefined, + HandlerMaker, + (n: NotificationMessage) => void, +] { + const [value, setter] = useState<NotificationMessage>(); + const notif = !value + ? undefined + : { + message: value, + acknowledge: () => { + setter(undefined); + }, + }; + + function makeHandler<T extends OperationResult<A, B>, A, B>( + onClick: () => Promise<T | undefined>, + onOperationSuccess:OnOperationSuccesReturnType<T>, + onOperationFail?: OnOperationFailReturnType<T>, + onOperationComplete?: () => void, + ): ButtonHandler<T, A, B> { + return { + onClick, + onNotification: setter, + onOperationFail, + onOperationSuccess, + onOperationComplete, + }; + } + + return [notif, makeHandler, setter]; +} + +export function buildUnifiedRequestErrorMessage( + i18n: InternationalizationAPI, + cause: TalerError, +): ErrorNotification { + let result: ErrorNotification; + switch (cause.errorDetail.code) { + case TalerErrorCode.GENERIC_TIMEOUT: { + result = { + type: "error", + title: i18n.str`Request timeout`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), + }; + break; + } + case TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR: { + result = { + type: "error", + title: i18n.str`Request cancelled`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), + }; + break; + } + case TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT: { + result = { + type: "error", + title: i18n.str`Request timeout`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), + }; + break; + } + case TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED: { + result = { + type: "error", + title: i18n.str`Request throttled`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), + }; + break; + } + case TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE: { + result = { + type: "error", + title: i18n.str`Malformed response`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), + }; + break; + } + case TalerErrorCode.WALLET_NETWORK_ERROR: { + result = { + type: "error", + title: i18n.str`Network error`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), + }; + break; + } + case TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR: { + result = { + type: "error", + title: i18n.str`Unexpected request error`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), + }; + break; + } + default: { + result = { + type: "error", + title: i18n.str`Unexpected error`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), + }; + break; + } + } + return result; +} diff --git a/packages/web-util/src/index.browser.ts b/packages/web-util/src/index.browser.ts index 57c97e605..2f3b57b8d 100644 --- a/packages/web-util/src/index.browser.ts +++ b/packages/web-util/src/index.browser.ts @@ -1,2 +1,10 @@ -export * as hooks from "./hooks/index.js"; +export * from "./hooks/index.js"; +export * from "./utils/request.js"; +export * from "./utils/http-impl.browser.js"; +export * from "./utils/http-impl.sw.js"; +export * from "./utils/observable.js"; +export * from "./utils/route.js"; +export * from "./context/index.js"; +export * from "./components/index.js"; +export * from "./forms/index.js"; export { renderStories, parseGroupImport } from "./stories.js"; diff --git a/packages/web-util/src/index.build.ts b/packages/web-util/src/index.build.ts new file mode 100644 index 000000000..c0c5fc179 --- /dev/null +++ b/packages/web-util/src/index.build.ts @@ -0,0 +1,327 @@ +import esbuild, { PluginBuild } from "esbuild"; +import linaria from "@linaria/esbuild"; +import fs from "fs"; +import path from "path"; +import postcss from "postcss"; +import sass from "sass"; +import postcssrc from "postcss-load-config"; + +// this should give us the current directory where +// the project is being built +const BASE = process.cwd(); + +type Assets = { + base: string; + files: string[]; +}; + +export function getFilesInDirectory(startPath: string, regex?: RegExp): Assets { + if (!fs.existsSync(startPath)) { + return { + base: startPath, + files: [], + }; + } + const files = fs.readdirSync(startPath); + const result = files + .flatMap((file) => { + const filename = path.join(startPath, file); + + const stat = fs.lstatSync(filename); + if (stat.isDirectory()) { + return getFilesInDirectory(filename, regex).files; + } + if (!regex || regex.test(filename)) { + return [filename]; + } + return []; + }) + .filter((x) => !!x); + + return { + base: startPath, + files: result, + }; +} + +let GIT_ROOT = BASE; +while (!fs.existsSync(path.join(GIT_ROOT, ".git")) && GIT_ROOT !== "/") { + GIT_ROOT = path.join(GIT_ROOT, "../"); +} +if (GIT_ROOT === "/") { + // eslint-disable-next-line no-undef + console.log("not found"); + // eslint-disable-next-line no-undef + process.exit(1); +} +const GIT_HASH = git_hash(); + +const buf = fs.readFileSync(path.join(BASE, "package.json")); +let _package = JSON.parse(buf.toString("utf-8")); + +function git_hash() { + const rev = fs + .readFileSync(path.join(GIT_ROOT, ".git", "HEAD")) + .toString() + .trim() + .split(/.*[: ]/) + .slice(-1)[0]; + if (rev.indexOf("/") === -1) { + return rev; + } else { + return fs.readFileSync(path.join(GIT_ROOT, ".git", rev)).toString().trim(); + } +} + +// FIXME: Put this into some helper library. +function copyFilesPlugin(assets: Assets | Assets[]) { + return { + name: "copy-files", + setup(build: PluginBuild) { + if (!assets || (assets instanceof Array && !assets.length)) { + return; + } + const list = assets instanceof Array ? assets : [assets]; + + const outDir = build.initialOptions.outdir; + if (outDir === undefined) { + throw Error("esbuild build options does not specify outdir"); + } + build.onEnd(() => { + list.forEach((as) => { + for (const file of as.files) { + const destination = path.join(outDir, path.relative(as.base, file)); + const dirname = path.dirname(destination); + if (!fs.existsSync(dirname)) { + fs.mkdirSync(dirname, { recursive: true }); + } + fs.copyFileSync(file, destination); + } + }); + }); + }, + }; +} + +const DEFAULT_SASS_FILTER = /\.(s[ac]ss|css)$/; + +const sassPlugin: esbuild.Plugin = { + name: "custom-build-sass", + setup(build) { + build.onLoad({ filter: DEFAULT_SASS_FILTER }, ({ path: file }) => { + const resolveDir = path.dirname(file); + const { css: contents } = sass.compile(file, { loadPaths: ["./"] }); + + return { + resolveDir, + loader: "css", + contents, + }; + }); + }, +}; + + +/** + * Problem: + * No loader is configured for ".node" files: ../../node_modules/.pnpm/fsevents@2.3.3/node_modules/fsevents/fsevents.node + * + * Reference: + * https://github.com/evanw/esbuild/issues/1051#issuecomment-806325487 + */ +const nativeNodeModulesPlugin: esbuild.Plugin = { + name: 'native-node-modules', + setup(build) { + // If a ".node" file is imported within a module in the "file" namespace, resolve + // it to an absolute path and put it into the "node-file" virtual namespace. + build.onResolve({ filter: /\.node$/, namespace: 'file' }, args => ({ + path: require.resolve(args.path, { paths: [args.resolveDir] }), + namespace: 'node-file', + })) + + // Files in the "node-file" virtual namespace call "require()" on the + // path from esbuild of the ".node" file in the output directory. + build.onLoad({ filter: /.*/, namespace: 'node-file' }, args => ({ + contents: ` + import path from ${JSON.stringify(args.path)} + try { module.exports = require(path) } + catch {} + `, + })) + + // If a ".node" file is imported within a module in the "node-file" namespace, put + // it in the "file" namespace where esbuild's default loading behavior will handle + // it. It is already an absolute path since we resolved it to one above. + build.onResolve({ filter: /\.node$/, namespace: 'node-file' }, args => ({ + path: args.path, + namespace: 'file', + })) + + // Tell esbuild's default loading behavior to use the "file" loader for + // these ".node" files. + let opts = build.initialOptions + opts.loader = opts.loader || {} + opts.loader['.node'] = 'file' + }, +} + + +const postCssPlugin: esbuild.Plugin = { + name: "custom-build-postcss", + setup(build) { + build.onLoad({ filter: DEFAULT_SASS_FILTER }, async ({ path: file }) => { + const resolveDir = path.dirname(file); + const sourceBuffer = fs.readFileSync(file); + const source = sourceBuffer.toString("utf-8"); + const postCssConfig = await postcssrc(); + postCssConfig.options.from = file; + + const { css: contents } = await postcss(postCssConfig.plugins).process( + source, + postCssConfig.options, + ); + + return { + resolveDir, + loader: "css", + contents, + }; + }); + }, +}; + +/** + * This should be able to load the plugin but but some reason it does not work + * + * text: "Cannot find module '../plugins/preeval'\n" + + * + */ +function linariaPlugin() { + const linariaCssPlugin: esbuild.Plugin = (linaria as any)({ + babelOptions: { + presets: ["@babel/preset-typescript", "@babel/preset-react", "@linaria"], + }, + sourceMap: true, + }); + return linariaCssPlugin; +} + +const defaultEsBuildConfig: esbuild.BuildOptions = { + bundle: true, + loader: { + ".svg": "file", + ".inline.svg": "text", + ".png": "dataurl", + ".jpeg": "dataurl", + ".ttf": "file", + ".woff": "file", + ".woff2": "file", + ".eot": "file", + }, + target: ["es2020"], + format: "esm", + platform: "browser", + jsxFactory: "h", + jsxFragment: "Fragment", + alias: { + react: "preact/compat", + "react-dom": "preact/compat", + }, + define: { + __VERSION__: `"${_package.version}"`, + __GIT_HASH__: `"${GIT_HASH}"`, + }, +}; + +export interface BuildParams { + type: "development" | "test" | "production"; + source: { + assets: Assets | Assets[]; + js: string[]; + }; + public?: string; + destination: string; + css: "sass" | "postcss" | "linaria"; + linariaPlugin?: () => esbuild.Plugin; +} + +export function computeConfig(params: BuildParams): esbuild.BuildOptions { + const plugins: Array<esbuild.Plugin> = [ + copyFilesPlugin(params.source.assets), + ]; + + if (params.css) { + switch (params.css) { + case "sass": { + plugins.push(sassPlugin); + break; + } + case "postcss": { + plugins.push(postCssPlugin); + break; + } + + case "linaria": { + if (params.linariaPlugin) { + plugins.push(params.linariaPlugin()); + } + break; + } + + default: { + const cssType: never = params.css; + throw Error(`not supported: ${cssType}`); + } + } + } + if (!params.type) { + throw Error( + `missing build type, it should be "test", "development" or "production"`, + ); + } + + // if (!params.source.js) { + // throw Error(`no javascript entry points, nothing to compile?`); + // } + if (!params.destination) { + throw Error(`missing destination folder`); + } + + return { + ...defaultEsBuildConfig, + entryPoints: params.source.js, + publicPath: params.public, + outdir: params.destination, + minify: false, //params.type === "production", + sourcemap: true, //params.type !== "production", + define: { + ...defaultEsBuildConfig.define, + "process.env.NODE_ENV": JSON.stringify(params.type), + }, + plugins, + }; +} + +/** + * Build sources for prod environment + */ +export function build(config: BuildParams) { + return esbuild.build(computeConfig(config)); +} + +const LIVE_RELOAD_SCRIPT = + "./node_modules/@gnu-taler/web-util/lib/live-reload.mjs"; + +/** + * Do startup for development environment + */ +export function initializeDev( + config: BuildParams, +): () => Promise<esbuild.BuildResult> { + function buildDevelopment() { + const result = computeConfig(config); + result.inject = [LIVE_RELOAD_SCRIPT]; + return esbuild.build(result); + } + return buildDevelopment; +} diff --git a/packages/web-util/src/index.testing.ts b/packages/web-util/src/index.testing.ts new file mode 100644 index 000000000..2349debc5 --- /dev/null +++ b/packages/web-util/src/index.testing.ts @@ -0,0 +1,3 @@ +export * from "./tests/hook.js"; +export * from "./tests/swr.js"; +export * from "./tests/mock.js"; diff --git a/packages/web-util/src/index.ts b/packages/web-util/src/index.ts deleted file mode 100644 index ff8b4c563..000000000 --- a/packages/web-util/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export default {}; diff --git a/packages/web-util/src/live-reload.ts b/packages/web-util/src/live-reload.ts index 60c7cb565..cd3a7540d 100644 --- a/packages/web-util/src/live-reload.ts +++ b/packages/web-util/src/live-reload.ts @@ -1,7 +1,10 @@ /* eslint-disable no-undef */ function setupLiveReload(): void { - const ws = new WebSocket("wss://localhost:8080/ws"); + const stopWs = localStorage.getItem("stop-ws") + if (!!stopWs) return; + const protocol = window.location.protocol === "http:" ? "ws:" : "wss:"; + const ws = new WebSocket(`${protocol}//${window.location.hostname}:${window.location.port}/ws`); ws.addEventListener("message", (message) => { try { @@ -14,6 +17,31 @@ function setupLiveReload(): void { window.location.reload(); return; } + if (event.type === "file-updated-failed") { + const h1 = document.getElementById("overlay-text"); + if (h1) { + h1.innerHTML = "compilation failed"; + h1.style.color = "red"; + h1.style.margin = ""; + } + const div = document.getElementById("overlay"); + if (div) { + const content = JSON.stringify(event.data, undefined, 2); + const pre = document.createElement("pre"); + pre.id = "error-text"; + pre.style.margin = ""; + pre.textContent = content; + div.style.backgroundColor = "rgba(0,0,0,0.8)"; + div.style.flexDirection = "column"; + div.appendChild(pre); + } + console.error(event.data.error); + return; + } + if (event.type === "file-updated") { + window.location.reload(); + return; + } } catch (e) { return; } @@ -31,14 +59,17 @@ setupLiveReload(); function showReloadOverlay(): void { const d = document.createElement("div"); + d.id = "overlay"; d.style.position = "absolute"; d.style.width = "100%"; d.style.height = "100%"; d.style.color = "white"; d.style.backgroundColor = "rgba(0,0,0,0.5)"; d.style.display = "flex"; + d.style.zIndex = String(Number.MAX_SAFE_INTEGER); d.style.justifyContent = "center"; const h = document.createElement("h1"); + h.id = "overlay-text"; h.style.margin = "auto"; h.innerHTML = "reloading..."; d.appendChild(h); diff --git a/packages/web-util/src/serve.ts b/packages/web-util/src/serve.ts index 736e57430..1daea15bf 100644 --- a/packages/web-util/src/serve.ts +++ b/packages/web-util/src/serve.ts @@ -2,8 +2,9 @@ import { Logger } from "@gnu-taler/taler-util"; import chokidar from "chokidar"; import express from "express"; import https from "https"; +import http from "http"; import { parse } from "url"; -import WebSocket, { Server } from "ws"; +import WebSocket from "ws"; import locahostCrt from "./keys/localhost.crt"; import locahostKey from "./keys/localhost.key"; @@ -20,7 +21,6 @@ const logger = new Logger("serve.ts"); const PATHS = { WS: "/ws", - NOTIFY: "/notify", EXAMPLE: "/examples", APP: "/app", }; @@ -29,29 +29,38 @@ export async function serve(opts: { folder: string; port: number; source?: string; - development?: boolean; + tls?: boolean; examplesLocationJs?: string; examplesLocationCss?: string; - onUpdate?: () => Promise<void>; + onSourceUpdate?: () => Promise<void>; }): Promise<void> { const app = express(); app.use(PATHS.APP, express.static(opts.folder)); - const server = https.createServer(httpServerOptions, app); - server.listen(opts.port); - logger.info(`serving ${opts.folder} on ${opts.port}`); - logger.info(` ${PATHS.APP}: application`); - logger.info(` ${PATHS.EXAMPLE}: examples`); - logger.info(` ${PATHS.WS}: websocket`); - logger.info(` ${PATHS.NOTIFY}: broadcast`); - - if (opts.development) { - const wss = new Server({ noServer: true }); - - wss.on("connection", function connection(ws) { - ws.send("welcome"); - }); + const httpServer = http.createServer(app); + const httpPort = opts.port; + let httpsServer: typeof httpServer | undefined; + let httpsPort: number | undefined; + const servers = [httpServer]; + if (opts.tls) { + httpsServer = https.createServer(httpServerOptions, app); + httpsPort = opts.port + 1; + servers.push(httpsServer) + } + + logger.info(`Dev server. Endpoints:`); + logger.info(` ${PATHS.APP}: where root application can be tested`); + logger.info(` ${PATHS.EXAMPLE}: where examples can be found and browse`); + logger.info(` ${PATHS.WS}: websocket for live reloading`); + + const wss = new WebSocket.Server({ noServer: true }); + + wss.on("connection", function connection(ws) { + ws.send("welcome"); + }); + + servers.forEach(function addWSHandler(server) { server.on("upgrade", function upgrade(request, socket, head) { const { pathname } = parse(request.url || ""); if (pathname === PATHS.WS) { @@ -62,50 +71,63 @@ export async function serve(opts: { socket.destroy(); } }); + }); - const sendToAllClients = function (data: object): void { - wss.clients.forEach(function each(client) { - if (client.readyState === WebSocket.OPEN) { - client.send(JSON.stringify(data)); - } - }); - }; - const watchingFolder = opts.source ?? opts.folder; - logger.info(`watching ${watchingFolder} for change`); + const sendToAllClients = function (data: object): void { + wss.clients.forEach(function each(client) { + if (client.readyState === WebSocket.OPEN) { + client.send(JSON.stringify(data)); + } + }); + }; + const watchingFolder = opts.source ?? opts.folder; + logger.info(`watching ${watchingFolder} for changes`); - chokidar.watch(watchingFolder).on("change", (path, stats) => { - logger.trace(`changed ${path}`); + chokidar.watch(watchingFolder).on("change", (path, stats) => { + logger.info(`changed: ${path}`); + if (opts.onSourceUpdate) { sendToAllClients({ type: "file-updated-start", data: { path } }); - if (opts.onUpdate) { - opts.onUpdate().then((result) => { + opts + .onSourceUpdate() + .then((result) => { sendToAllClients({ type: "file-updated-done", data: { path, result }, }); + }) + .catch((error) => { + sendToAllClients({ + type: "file-updated-failed", + data: { path, error: JSON.stringify(error) }, + }); }); - } else { - sendToAllClients({ type: "file-change-done", data: { path } }); - } - }); + } else { + sendToAllClients({ type: "file-change", data: { path } }); + } + }); - app.get(PATHS.EXAMPLE, function (req: any, res: any) { - res.set("Content-Type", "text/html"); - res.send( - storiesHtml - .replace( - "__EXAMPLES_JS_FILE_LOCATION__", - opts.examplesLocationJs ?? `.${PATHS.APP}/stories.js`, - ) - .replace( - "__EXAMPLES_CSS_FILE_LOCATION__", - opts.examplesLocationCss ?? `.${PATHS.APP}/stories.css`, - ), - ); - }); + if (opts.onSourceUpdate) opts.onSourceUpdate(); - app.get(PATHS.NOTIFY, function (req: any, res: any) { - res.send("ok"); - }); + app.get(PATHS.EXAMPLE, function (req: any, res: any) { + res.set("Content-Type", "text/html"); + res.send( + storiesHtml + .replace( + "__EXAMPLES_JS_FILE_LOCATION__", + opts.examplesLocationJs ?? `.${PATHS.APP}/stories.js`, + ) + .replace( + "__EXAMPLES_CSS_FILE_LOCATION__", + opts.examplesLocationCss ?? `.${PATHS.APP}/stories.css`, + ), + ); + }); + + logger.info(`Serving ${opts.folder} on ${httpPort}: plain HTTP`); + httpServer.listen(httpPort); + if (httpsServer !== undefined) { + logger.info(`Serving ${opts.folder} on ${httpsPort}: HTTP + TLS`); + httpsServer.listen(httpsPort); } } diff --git a/packages/web-util/src/stories.tsx b/packages/web-util/src/stories.tsx index a8a9fdf77..d9c2406eb 100644 --- a/packages/web-util/src/stories.tsx +++ b/packages/web-util/src/stories.tsx @@ -19,7 +19,6 @@ * @author Sebastian Javier Marchano (sebasjm) */ import { setupI18n } from "@gnu-taler/taler-util"; -import e from "express"; import { ComponentChild, ComponentChildren, @@ -32,6 +31,7 @@ import { VNode, } from "preact"; import { useEffect, useErrorBoundary, useState } from "preact/hooks"; +import { ExampleItemSetup } from "./tests/hook.js"; const Page: FunctionalComponent = ({ children }): VNode => { return ( @@ -184,8 +184,8 @@ function ExampleList({ backgroundColor: isSelected ? "green" : i % 2 - ? "lightgray" - : "lightblue", + ? "lightgray" + : "lightblue", marginLeft: "1em", padding: 4, cursor: "pointer", @@ -323,6 +323,7 @@ function parseExampleImport( render: { component: exampleValue as FunctionComponent, props: {}, + contextProps: {}, }, }; } @@ -367,19 +368,16 @@ export interface Group { list: ComponentItem[]; } -export interface ComponentItem { +export interface ComponentItem<Props extends object = {}> { name: string; - examples: ExampleItem[]; + examples: ExampleItem<Props>[]; } -export interface ExampleItem { +export interface ExampleItem<Props extends object = {}> { group: string; component: string; name: string; - render: { - component: FunctionalComponent; - props: object; - }; + render: ExampleItemSetup<Props>; } type ComponentOrFolder = MaybeComponent | MaybeFolder; @@ -397,10 +395,10 @@ function folder(groupName: string, value: ComponentOrFolder): ComponentItem[] { try { title = typeof value === "object" && - typeof value.default === "object" && - value.default !== undefined && - "title" in value.default && - typeof value.default.title === "string" + typeof value.default === "object" && + value.default !== undefined && + "title" in value.default && + typeof value.default.title === "string" ? value.default.title : undefined; } catch (e) { @@ -432,12 +430,12 @@ function Application({ examplesInGroups, getWrapperForGroup, }: Props): VNode { + const url = new URL(window.location.href); const initialSelection = getSelectionFromLocationHash( - location.hash, + url.hash, examplesInGroups, ); - const url = new URL(window.location.href); const currentLang = url.searchParams.get("lang") || "en"; if (!langs["en"]) { @@ -450,15 +448,15 @@ function Application({ ); const [sidebarWidth, setSidebarWidth] = useState(200); useEffect(() => { - if (location.hash) { - const hash = location.hash.substring(1); + if (url.hash) { + const hash = url.hash.substring(1); const found = document.getElementById(hash); if (found) { setTimeout(() => { found.scrollIntoView({ block: "center", }); - }, 10); + }, 50); } } }, []); @@ -502,11 +500,11 @@ function Application({ ))} <hr /> </SideBar> - <ResizeHandle + {/* <ResizeHandle onUpdate={(x) => { setSidebarWidth((s) => s + x); }} - /> + /> */} <Content> <ErrorReport selected={selected}> <PreventLinkNavigation> diff --git a/packages/web-util/src/tests/hook.ts b/packages/web-util/src/tests/hook.ts new file mode 100644 index 000000000..59f17ba8d --- /dev/null +++ b/packages/web-util/src/tests/hook.ts @@ -0,0 +1,325 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + Fragment, + FunctionComponent, + FunctionalComponent, + VNode, + h as create, + options, + render as renderIntoDom, +} from "preact"; +import { render as renderToString } from "preact-render-to-string"; + +// This library is expected to be included in testing environment only +// When doing tests we want the requestAnimationFrame to be as fast as possible. +// without this option the RAF will timeout after 100ms making the tests slower +options.requestAnimationFrame = (fn: () => void) => { + return fn(); +}; + +export type ExampleItemSetup<Props extends object = {}> = { + component: FunctionalComponent<Props>; + props: Props; + contextProps: object; +}; + +/** + * + * @param Component component to be tested + * @param props allow partial props for easier example setup + * @param contextProps if the context requires params for this example + * @returns + */ +export function createExample<T extends object, Props extends object>( + Component: FunctionalComponent<Props>, + props: Partial<Props> | (() => Partial<Props>), + contextProps?: T | (() => T), +): ExampleItemSetup<Props> { + const evaluatedProps = typeof props === "function" ? props() : props; + const Render = (args: any): VNode => create(Component, args); + const evaluatedContextProps = + typeof contextProps === "function" ? contextProps() : contextProps; + return { + component: Render, + props: evaluatedProps as Props, + contextProps: !evaluatedContextProps ? {} : evaluatedContextProps, + }; +} + +/** + * Should render HTML on node and browser + * Browser: mount update and unmount + * Node: render to string + * + * @param Component + * @param args + */ +export function renderUI(example: ExampleItemSetup<any>, Context?: any): void { + const vdom = !Context + ? create(example.component, example.props) + : create(Context, { + ...example.contextProps, + children: [create(example.component, example.props)], + }); + + if (typeof window === "undefined") { + renderToString(vdom); + } else { + const div = document.createElement("div"); + document.body.appendChild(div); + renderIntoDom(vdom, div); + renderIntoDom(null, div); + document.body.removeChild(div); + } +} + +/** + * No need to render. + * Should mount, update and run effects. + * + * Browser: mount update and unmount + * Node: mount on a mock virtual dom + * + * Mounting hook doesn't use DOM api so is + * safe to use normal mounting api in node + * + * @param Component + * @param props + * @param Context + */ +function renderHook( + Component: FunctionComponent, + Context?: ({ children }: { children: any }) => VNode | null, +): void { + const vdom = !Context + ? create(Component, {}) + : create(Context, { children: [create(Component, {})] }); + + //use normal mounting API since we expect + //useEffect to be called ( and similar APIs ) + renderIntoDom(vdom, {} as Element); +} + +type RecursiveState<S> = S | (() => RecursiveState<S>); + +interface Mounted<T> { + pullLastResultOrThrow: () => Exclude<T, VoidFunction>; + assertNoPendingUpdate: () => Promise<boolean>; + waitForStateUpdate: () => Promise<boolean>; +} + +/** + * Manual API mount the hook and return testing API + * Consider using hookBehaveLikeThis() function + * + * @param hookToBeTested + * @param Context + * + * @returns testing API + */ +function mountHook<T extends object>( + hookToBeTested: () => RecursiveState<T>, + Context?: ({ children }: { children: any }) => VNode | null, +): Mounted<T> { + let lastResult: Exclude<T, VoidFunction> | Error | null = null; + + const listener: Array<() => void> = []; + + // component that's going to hold the hook + function Component(): VNode { + try { + let componentOrResult = hookToBeTested(); + + // special loop + // since Taler use a special type of hook that can return + // a function and it will be treated as a composed component + // then tests should be aware of it and reproduce the same behavior + while (typeof componentOrResult === "function") { + componentOrResult = componentOrResult(); + } + //typecheck fails here + const l: Exclude<T, () => void> = componentOrResult as any; + lastResult = l; + } catch (e) { + if (e instanceof Error) { + lastResult = e; + } else { + lastResult = new Error(`mounting the hook throw an exception: ${e}`); + } + } + + // notify to everyone waiting for an update and clean the queue + listener.splice(0, listener.length).forEach((cb) => cb()); + return create(Fragment, {}); + } + + renderHook(Component, Context); + + function pullLastResult(): Exclude<T | Error | null, VoidFunction> { + const copy: Exclude<T | Error | null, VoidFunction> = lastResult; + lastResult = null; + return copy; + } + + function pullLastResultOrThrow(): Exclude<T, VoidFunction> { + const r = pullLastResult(); + if (r instanceof Error) throw r; + //sanity check + if (!r) throw Error("there was no last result"); + return r; + } + + async function assertNoPendingUpdate(): Promise<boolean> { + await new Promise((res, rej) => { + const tid = setTimeout(() => { + res(true); + }, 10); + + listener.push(() => { + clearTimeout(tid); + res(false); + // Error(`Expecting no pending result but the hook got updated. + // If the update was not intended you need to check the hook dependencies + // (or dependencies of the internal state) but otherwise make + // sure to consume the result before ending the test.`), + // ); + }); + }); + + const r = pullLastResult(); + if (r) { + return Promise.resolve(false); + } + return Promise.resolve(true); + // This may happen because the hook did a new update but the test didn't consume the result using pullLastResult`); + } + async function waitForStateUpdate(): Promise<boolean> { + return await new Promise((res, rej) => { + const tid = setTimeout(() => { + res(false); + }, 10); + + listener.push(() => { + clearTimeout(tid); + res(true); + }); + }); + } + + return { + pullLastResultOrThrow, + waitForStateUpdate, + assertNoPendingUpdate, + }; +} + +export const nullFunction = (): void => { + null; +}; +export const nullAsyncFunction = (): Promise<void> => { + return Promise.resolve(); +}; + +type HookTestResult = HookTestResultOk | HookTestResultError; + +interface HookTestResultOk { + result: "ok"; +} +interface HookTestResultError { + result: "fail"; + error: string; + index: number; +} + +/** + * Main testing driver. + * It will assert that there are no more and no less hook updates than expected. + * + * @param hookFunction hook function to be tested + * @param props initial props for the hook + * @param checks step by step state validation + * @param Context additional testing context for overrides + * + * @returns testing result, should also be checked to be "ok" + */ +// eslint-disable-next-line @typescript-eslint/ban-types +export async function hookBehaveLikeThis<T extends object, PropsType>( + hookFunction: (p: PropsType) => RecursiveState<T>, + props: PropsType, + checks: Array<(state: Exclude<T, VoidFunction>) => void>, + Context?: ({ children }: { children: any }) => VNode | null, +): Promise<HookTestResult> { + const { pullLastResultOrThrow, waitForStateUpdate, assertNoPendingUpdate } = + mountHook<T>(() => hookFunction(props), Context); + + const [firstCheck, ...restOfTheChecks] = checks; + { + const state = pullLastResultOrThrow(); + const checkError = firstCheck(state); + if (checkError !== undefined) { + return { + result: "fail", + index: 0, + error: `First check returned with error: ${checkError}`, + }; + } + } + + let index = 1; + for (const check of restOfTheChecks) { + const hasNext = await waitForStateUpdate(); + if (!hasNext) { + return { + result: "fail", + error: "Component didn't update and the test expected one more state", + index, + }; + } + const state = pullLastResultOrThrow(); + const checkError = check(state); + if (checkError !== undefined) { + return { + result: "fail", + index, + error: `Check returned with error: ${checkError}`, + }; + } + index++; + } + + const hasNext = await waitForStateUpdate(); + if (hasNext) { + return { + result: "fail", + index, + error: "Component updated and test didn't expect more states", + }; + } + const noMoreUpdates = await assertNoPendingUpdate(); + if (noMoreUpdates === false) { + return { + result: "fail", + index, + error: "Component was updated but the test does not cover the update", + }; + } + + return { + result: "ok", + }; +} diff --git a/packages/web-util/src/tests/mock.ts b/packages/web-util/src/tests/mock.ts new file mode 100644 index 000000000..d09e8b4a6 --- /dev/null +++ b/packages/web-util/src/tests/mock.ts @@ -0,0 +1,503 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { Logger } from "@gnu-taler/taler-util"; + +type HttpMethod = + | "get" + | "GET" + | "delete" + | "DELETE" + | "head" + | "HEAD" + | "options" + | "OPTIONS" + | "post" + | "POST" + | "put" + | "PUT" + | "patch" + | "PATCH" + | "purge" + | "PURGE" + | "link" + | "LINK" + | "unlink" + | "UNLINK"; + +/** + * @deprecated do not use it, it will be removed + */ +export type Query<Req, Res> = { + method: HttpMethod; + url: string; + code?: number; +}; + +type ExpectationValues = { + query: Query<any, any>; + auth?: string; + params?: { + // eslint-disable-next-line @typescript-eslint/ban-types + request?: object; + qparam?: Record<string, string>; + // eslint-disable-next-line @typescript-eslint/ban-types + response?: object; + }; +}; + +type TestValues = { + currentExpectedQuery: ExpectationValues | undefined; + lastQuery: ExpectationValues | undefined; +}; + +const logger = new Logger("testing/mock.ts"); + +type MockedResponse = { + queryMade: ExpectationValues; + expectedQuery?: ExpectationValues; +}; + +/** + * @deprecated do not use it, it will be removed + */ +export abstract class MockEnvironment { + expectations: Array<ExpectationValues> = []; + queriesMade: Array<ExpectationValues> = []; + index = 0; + + debug: boolean; + constructor(debug: boolean) { + this.debug = debug; + this.saveRequestAndGetMockedResponse.bind(this); + } + + public addRequestExpectation< + // eslint-disable-next-line @typescript-eslint/ban-types + RequestType extends object, + // eslint-disable-next-line @typescript-eslint/ban-types + ResponseType extends object, + >( + query: Query<RequestType, ResponseType>, + params: { + auth?: string; + request?: RequestType; + qparam?: any; + response?: ResponseType; + }, + ): void { + const expected = { query, params, auth: params.auth }; + this.expectations.push(expected); + if (this.debug) { + logger.info("saving query as expected", expected); + } + } + + public saveRequestAndGetMockedResponse< + // eslint-disable-next-line @typescript-eslint/ban-types + RequestType extends object, + // eslint-disable-next-line @typescript-eslint/ban-types + ResponseType extends object, + >( + query: Query<RequestType, ResponseType>, + params: { + auth?: string; + request?: RequestType; + qparam?: any; + response?: ResponseType; + }, + ): MockedResponse { + const queryMade = { query, params, auth: params.auth }; + this.queriesMade.push(queryMade); + const expectedQuery = this.expectations[this.index]; + if (!expectedQuery) { + if (this.debug) { + logger.info("unexpected query made", queryMade); + } + return { queryMade }; + } + + if (this.debug) { + logger.info("tracking query made", { + queryMade, + expectedQuery, + }); + } + this.index++; + return { queryMade, expectedQuery }; + } + + public assertJustExpectedRequestWereMade(): AssertStatus { + let queryNumber = 0; + + while (queryNumber < this.expectations.length) { + const r = this.assertNextRequest(queryNumber); + if (r.result !== "ok") return r; + queryNumber++; + } + return this.assertNoMoreRequestWereMade(queryNumber); + } + + private getLastTestValues(idx: number): TestValues { + const currentExpectedQuery = this.expectations[idx]; + const lastQuery = this.queriesMade[idx]; + + return { currentExpectedQuery, lastQuery }; + } + + private assertNoMoreRequestWereMade(idx: number): AssertStatus { + const { currentExpectedQuery, lastQuery } = this.getLastTestValues(idx); + + if (lastQuery !== undefined) { + return { + result: "error-did-one-more", + made: lastQuery, + }; + } + if (currentExpectedQuery !== undefined) { + return { + result: "error-did-one-less", + expected: currentExpectedQuery, + }; + } + + return { + result: "ok", + }; + } + + private assertNextRequest(index: number): AssertStatus { + const { currentExpectedQuery, lastQuery } = this.getLastTestValues(index); + + if (!currentExpectedQuery) { + return { + result: "error-query-missing", + }; + } + + if (!lastQuery) { + return { + result: "error-did-one-less", + expected: currentExpectedQuery, + }; + } + + if (lastQuery.query.method) { + if (currentExpectedQuery.query.method !== lastQuery.query.method) { + return { + result: "error-difference", + diff: "method", + last: lastQuery.query.method, + expected: currentExpectedQuery.query.method, + index, + }; + } + if (currentExpectedQuery.query.url !== lastQuery.query.url) { + return { + result: "error-difference", + diff: "url", + last: lastQuery.query.url, + expected: currentExpectedQuery.query.url, + index, + }; + } + } + if ( + !deepEquals( + currentExpectedQuery.params?.request, + lastQuery.params?.request, + ) + ) { + return { + result: "error-difference", + diff: "query-body", + expected: currentExpectedQuery.params?.request, + last: lastQuery.params?.request, + index, + }; + } + if ( + !deepEquals(currentExpectedQuery.params?.qparam, lastQuery.params?.qparam) + ) { + return { + result: "error-difference", + diff: "query-params", + expected: currentExpectedQuery.params?.qparam, + last: lastQuery.params?.qparam, + index, + }; + } + if (!deepEquals(currentExpectedQuery.auth, lastQuery.auth)) { + return { + result: "error-difference", + diff: "query-auth", + expected: currentExpectedQuery.auth, + last: lastQuery.auth, + index, + }; + } + + return { + result: "ok", + }; + } +} + +type AssertStatus = + | AssertOk + | AssertQueryNotMadeButExpected + | AssertQueryMadeButNotExpected + | AssertQueryMissing + | AssertExpectedQueryMethodMismatch + | AssertExpectedQueryUrlMismatch + | AssertExpectedQueryAuthMismatch + | AssertExpectedQueryBodyMismatch + | AssertExpectedQueryParamsMismatch; + +interface AssertOk { + result: "ok"; +} + +//trying to assert for a expected query but there is +//no expected query in the queue +interface AssertQueryMissing { + result: "error-query-missing"; +} + +//tested component did one more query that expected +interface AssertQueryNotMadeButExpected { + result: "error-did-one-more"; + made: ExpectationValues; +} + +//tested component didn't make an expected query +interface AssertQueryMadeButNotExpected { + result: "error-did-one-less"; + expected: ExpectationValues; +} + +interface AssertExpectedQueryMethodMismatch { + result: "error-difference"; + diff: "method"; + last: string; + expected: string; + index: number; +} +interface AssertExpectedQueryUrlMismatch { + result: "error-difference"; + diff: "url"; + last: string; + expected: string; + index: number; +} +interface AssertExpectedQueryAuthMismatch { + result: "error-difference"; + diff: "query-auth"; + last: string | undefined; + expected: string | undefined; + index: number; +} +interface AssertExpectedQueryBodyMismatch { + result: "error-difference"; + diff: "query-body"; + last: any; + expected: any; + index: number; +} +interface AssertExpectedQueryParamsMismatch { + result: "error-difference"; + diff: "query-params"; + last: any; + expected: any; + index: number; +} + +/** + * helpers + * + */ +export type Tester = (a: any, b: any) => boolean | undefined; + +function deepEquals( + a: unknown, + b: unknown, + aStack: Array<unknown> = [], + bStack: Array<unknown> = [], +): boolean { + //one if the element is null or undefined + if (a === null || b === null || b === undefined || a === undefined) { + return a === b; + } + //both are errors + if (a instanceof Error && b instanceof Error) { + return a.message == b.message; + } + //is the same object + if (Object.is(a, b)) { + return true; + } + //both the same class + const name = Object.prototype.toString.call(a); + if (name != Object.prototype.toString.call(b)) { + return false; + } + // + switch (name) { + case "[object Boolean]": + case "[object String]": + case "[object Number]": + if (typeof a !== typeof b) { + // One is a primitive, one a `new Primitive()` + return false; + } else if (typeof a !== "object" && typeof b !== "object") { + // both are proper primitives + return Object.is(a, b); + } else { + // both are `new Primitive()`s + return Object.is(a.valueOf(), b.valueOf()); + } + case "[object Date]": { + const _a = a as Date; + const _b = b as Date; + return _a == _b; + } + case "[object RegExp]": { + const _a = a as RegExp; + const _b = b as RegExp; + return _a.source === _b.source && _a.flags === _b.flags; + } + case "[object Array]": { + const _a = a as Array<any>; + const _b = b as Array<any>; + if (_a.length !== _b.length) { + return false; + } + } + } + if (typeof a !== "object" || typeof b !== "object") { + return false; + } + + if ( + typeof a === "object" && + typeof b === "object" && + !Array.isArray(a) && + !Array.isArray(b) && + hasIterator(a) && + hasIterator(b) + ) { + return iterable(a, b); + } + + // Used to detect circular references. + let length = aStack.length; + while (length--) { + if (aStack[length] === a) { + return bStack[length] === b; + } else if (bStack[length] === b) { + return false; + } + } + aStack.push(a); + bStack.push(b); + + const aKeys = allKeysFromObject(a); + const bKeys = allKeysFromObject(b); + let keySize = aKeys.length; + + //same number of keys + if (bKeys.length !== keySize) { + return false; + } + + let keyIterator: string; + while (keySize--) { + // eslint-disable-next-line @typescript-eslint/ban-types + const _a = a as Record<string, object>; + // eslint-disable-next-line @typescript-eslint/ban-types + const _b = b as Record<string, object>; + + keyIterator = aKeys[keySize]; + + const de = deepEquals(_a[keyIterator], _b[keyIterator], aStack, bStack); + if (!de) { + return false; + } + } + + aStack.pop(); + bStack.pop(); + + return true; +} + +// eslint-disable-next-line @typescript-eslint/ban-types +function allKeysFromObject(obj: object): Array<string> { + const keys = []; + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + keys.push(key); + } + } + return keys; +} + +const IteratorSymbol = Symbol.iterator; + +function hasIterator(object: any): boolean { + return !!(object != null && object[IteratorSymbol]); +} + +function iterable( + a: unknown, + b: unknown, + aStack: Array<unknown> = [], + bStack: Array<unknown> = [], +): boolean { + if (a === null || b === null || b === undefined || a === undefined) { + return a === b; + } + if (a.constructor !== b.constructor) { + return false; + } + let length = aStack.length; + while (length--) { + if (aStack[length] === a) { + return bStack[length] === b; + } + } + aStack.push(a); + bStack.push(b); + + const aIterator = (a as any)[IteratorSymbol](); + const bIterator = (b as any)[IteratorSymbol](); + + const nextA = aIterator.next(); + while (nextA.done) { + const nextB = bIterator.next(); + if (nextB.done || !deepEquals(nextA.value, nextB.value)) { + return false; + } + } + if (!bIterator.next().done) { + return false; + } + + // Remove the first value from the stack of traversed values. + aStack.pop(); + bStack.pop(); + return true; +} diff --git a/packages/web-util/src/tests/swr.ts b/packages/web-util/src/tests/swr.ts new file mode 100644 index 000000000..d5f4341f3 --- /dev/null +++ b/packages/web-util/src/tests/swr.ts @@ -0,0 +1,105 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { ComponentChildren, FunctionalComponent, h, VNode } from "preact"; +import { MockEnvironment } from "./mock.js"; +import { SWRConfig } from "swr"; +import * as swr__internal from "swr/_internal"; +import { Logger } from "@gnu-taler/taler-util"; +import { buildRequestFailed, RequestError } from "../index.browser.js"; + +const logger = new Logger("tests/swr.ts"); + +/** + * Helper for hook that use SWR inside. + * + * buildTestingContext() will return a testing context + * + * @deprecated do not use it, it will be removed + */ +export class SwrMockEnvironment extends MockEnvironment { + constructor(debug = false) { + super(debug); + } + + public buildTestingContext(): FunctionalComponent<{ + children: ComponentChildren; + }> { + const __SAVE_REQUEST_AND_GET_MOCKED_RESPONSE = + this.saveRequestAndGetMockedResponse.bind(this); + + function testingFetcher(params: any): any { + const url = JSON.stringify(params); + const mocked = __SAVE_REQUEST_AND_GET_MOCKED_RESPONSE<any, any>( + { + method: "get", + url, + }, + {}, + ); + + //unexpected query + if (!mocked.expectedQuery) return undefined; + const status = mocked.expectedQuery.query.code ?? 200; + const requestPayload = mocked.expectedQuery.params?.request; + const responsePayload = mocked.expectedQuery.params?.response; + //simulated error + if (status >= 400) { + const error = buildRequestFailed( + url, + JSON.stringify(responsePayload), + status, + requestPayload, + ); + //example error handling from https://swr.vercel.app/docs/error-handling + throw new RequestError(error); + } + return responsePayload; + } + + const value: Partial<swr__internal.PublicConfiguration> & { + provider: () => Map<any, any>; + } = { + use: [ + (useSWRNext) => { + return (key, fetcher, config) => { + //prevent the request + //use the testing fetcher instead + return useSWRNext(key, testingFetcher, config); + }; + }, + ], + fetcher: testingFetcher, + //These options are set for ending the test faster + //otherwise SWR will create timeouts that will live after the test finished + loadingTimeout: 0, + dedupingInterval: 0, + shouldRetryOnError: false, + errorRetryInterval: 0, + errorRetryCount: 0, + //clean cache for every test + provider: () => new Map(), + }; + + return function TestingContext({ + children, + }: { + children: ComponentChildren; + }): VNode { + return h(SWRConfig, { value }, children); + }; + } +} diff --git a/packages/web-util/src/utils/base64.ts b/packages/web-util/src/utils/base64.ts new file mode 100644 index 000000000..0e075880f --- /dev/null +++ b/packages/web-util/src/utils/base64.ts @@ -0,0 +1,243 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + + +export function base64encode(str: string): string { + return base64EncArr(strToUTF8Arr(str)) +} + +export function base64decode(str: string): string { + return UTF8ArrToStr(base64DecToArr(str)) +} + +// from https://developer.mozilla.org/en-US/docs/Glossary/Base64 + +// Array of bytes to Base64 string decoding +function b64ToUint6(nChr: number): number { + return nChr > 64 && nChr < 91 + ? nChr - 65 + : nChr > 96 && nChr < 123 + ? nChr - 71 + : nChr > 47 && nChr < 58 + ? nChr + 4 + : nChr === 43 + ? 62 + : nChr === 47 + ? 63 + : 0; +} + +function base64DecToArr(sBase64: string, nBlocksSize?: number): Uint8Array { + const sB64Enc = sBase64.replace(/[^A-Za-z0-9+/]/g, ""); // Only necessary if the base64 includes whitespace such as line breaks. + const nInLen = sB64Enc.length; + const nOutLen = nBlocksSize + ? Math.ceil(((nInLen * 3 + 1) >> 2) / nBlocksSize) * nBlocksSize + : (nInLen * 3 + 1) >> 2; + const taBytes = new Uint8Array(nOutLen); + + let nMod3; + let nMod4; + let nUint24 = 0; + let nOutIdx = 0; + for (let nInIdx = 0; nInIdx < nInLen; nInIdx++) { + nMod4 = nInIdx & 3; + nUint24 |= b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << (6 * (3 - nMod4)); + if (nMod4 === 3 || nInLen - nInIdx === 1) { + nMod3 = 0; + while (nMod3 < 3 && nOutIdx < nOutLen) { + taBytes[nOutIdx] = (nUint24 >>> ((16 >>> nMod3) & 24)) & 255; + nMod3++; + nOutIdx++; + } + nUint24 = 0; + } + } + + return taBytes; +} + +/* Base64 string to array encoding */ +function uint6ToB64(nUint6: number): number { + return nUint6 < 26 + ? nUint6 + 65 + : nUint6 < 52 + ? nUint6 + 71 + : nUint6 < 62 + ? nUint6 - 4 + : nUint6 === 62 + ? 43 + : nUint6 === 63 + ? 47 + : 65; +} + +function base64EncArr(aBytes: Uint8Array): string { + let nMod3 = 2; + let sB64Enc = ""; + + const nLen = aBytes.length; + let nUint24 = 0; + for (let nIdx = 0; nIdx < nLen; nIdx++) { + nMod3 = nIdx % 3; + // To break your base64 into several 80-character lines, add: + // if (nIdx > 0 && ((nIdx * 4) / 3) % 76 === 0) { + // sB64Enc += "\r\n"; + // } + + nUint24 |= aBytes[nIdx] << ((16 >>> nMod3) & 24); + if (nMod3 === 2 || aBytes.length - nIdx === 1) { + sB64Enc += String.fromCodePoint( + uint6ToB64((nUint24 >>> 18) & 63), + uint6ToB64((nUint24 >>> 12) & 63), + uint6ToB64((nUint24 >>> 6) & 63), + uint6ToB64(nUint24 & 63) + ); + nUint24 = 0; + } + } + return ( + sB64Enc.substring(0, sB64Enc.length - 2 + nMod3) + + (nMod3 === 2 ? "" : nMod3 === 1 ? "=" : "==") + ); +} + +/* UTF-8 array to JS string and vice versa */ + +function UTF8ArrToStr(aBytes: Uint8Array): string { + let sView = ""; + let nPart; + const nLen = aBytes.length; + for (let nIdx = 0; nIdx < nLen; nIdx++) { + nPart = aBytes[nIdx]; + sView += String.fromCodePoint( + nPart > 251 && nPart < 254 && nIdx + 5 < nLen /* six bytes */ + ? /* (nPart - 252 << 30) may be not so safe in ECMAScript! So…: */ + (nPart - 252) * 1073741824 + + ((aBytes[++nIdx] - 128) << 24) + + ((aBytes[++nIdx] - 128) << 18) + + ((aBytes[++nIdx] - 128) << 12) + + ((aBytes[++nIdx] - 128) << 6) + + aBytes[++nIdx] - + 128 + : nPart > 247 && nPart < 252 && nIdx + 4 < nLen /* five bytes */ + ? ((nPart - 248) << 24) + + ((aBytes[++nIdx] - 128) << 18) + + ((aBytes[++nIdx] - 128) << 12) + + ((aBytes[++nIdx] - 128) << 6) + + aBytes[++nIdx] - + 128 + : nPart > 239 && nPart < 248 && nIdx + 3 < nLen /* four bytes */ + ? ((nPart - 240) << 18) + + ((aBytes[++nIdx] - 128) << 12) + + ((aBytes[++nIdx] - 128) << 6) + + aBytes[++nIdx] - + 128 + : nPart > 223 && nPart < 240 && nIdx + 2 < nLen /* three bytes */ + ? ((nPart - 224) << 12) + + ((aBytes[++nIdx] - 128) << 6) + + aBytes[++nIdx] - + 128 + : nPart > 191 && nPart < 224 && nIdx + 1 < nLen /* two bytes */ + ? ((nPart - 192) << 6) + aBytes[++nIdx] - 128 + : /* nPart < 127 ? */ /* one byte */ + nPart + ); + } + return sView; +} + +function strToUTF8Arr(sDOMStr: string): Uint8Array { + let nChr; + const nStrLen = sDOMStr.length; + let nArrLen = 0; + + /* mapping… */ + for (let nMapIdx = 0; nMapIdx < nStrLen; nMapIdx++) { + nChr = sDOMStr.codePointAt(nMapIdx); + if (nChr === undefined) { + throw Error(`No char at ${nMapIdx} on string with length: ${sDOMStr.length}`) + } + + if (nChr >= 0x10000) { + nMapIdx++; + } + + nArrLen += + nChr < 0x80 + ? 1 + : nChr < 0x800 + ? 2 + : nChr < 0x10000 + ? 3 + : nChr < 0x200000 + ? 4 + : nChr < 0x4000000 + ? 5 + : 6; + } + + const aBytes = new Uint8Array(nArrLen); + + /* transcription… */ + let nIdx = 0; + let nChrIdx = 0; + while (nIdx < nArrLen) { + nChr = sDOMStr.codePointAt(nChrIdx); + if (nChr === undefined) { + throw Error(`No char at ${nChrIdx} on string with length: ${sDOMStr.length}`) + } + if (nChr < 128) { + /* one byte */ + aBytes[nIdx++] = nChr; + } else if (nChr < 0x800) { + /* two bytes */ + aBytes[nIdx++] = 192 + (nChr >>> 6); + aBytes[nIdx++] = 128 + (nChr & 63); + } else if (nChr < 0x10000) { + /* three bytes */ + aBytes[nIdx++] = 224 + (nChr >>> 12); + aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63); + aBytes[nIdx++] = 128 + (nChr & 63); + } else if (nChr < 0x200000) { + /* four bytes */ + aBytes[nIdx++] = 240 + (nChr >>> 18); + aBytes[nIdx++] = 128 + ((nChr >>> 12) & 63); + aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63); + aBytes[nIdx++] = 128 + (nChr & 63); + nChrIdx++; + } else if (nChr < 0x4000000) { + /* five bytes */ + aBytes[nIdx++] = 248 + (nChr >>> 24); + aBytes[nIdx++] = 128 + ((nChr >>> 18) & 63); + aBytes[nIdx++] = 128 + ((nChr >>> 12) & 63); + aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63); + aBytes[nIdx++] = 128 + (nChr & 63); + nChrIdx++; + } /* if (nChr <= 0x7fffffff) */ else { + /* six bytes */ + aBytes[nIdx++] = 252 + (nChr >>> 30); + aBytes[nIdx++] = 128 + ((nChr >>> 24) & 63); + aBytes[nIdx++] = 128 + ((nChr >>> 18) & 63); + aBytes[nIdx++] = 128 + ((nChr >>> 12) & 63); + aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63); + aBytes[nIdx++] = 128 + (nChr & 63); + nChrIdx++; + } + nChrIdx++; + } + + return aBytes; +} diff --git a/packages/web-util/src/utils/http-impl.browser.ts b/packages/web-util/src/utils/http-impl.browser.ts new file mode 100644 index 000000000..1e5496071 --- /dev/null +++ b/packages/web-util/src/utils/http-impl.browser.ts @@ -0,0 +1,251 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import { + Logger, + RequestThrottler, + TalerErrorCode, + TalerError, + Duration, +} from "@gnu-taler/taler-util"; + +import { + HttpRequestLibrary, + HttpRequestOptions, + HttpResponse, + Headers, + getDefaultHeaders, + encodeBody, + DEFAULT_REQUEST_TIMEOUT_MS, + HttpLibArgs, +} from "@gnu-taler/taler-util/http"; + +const logger = new Logger("browserHttpLib"); + +/** + * An implementation of the [[HttpRequestLibrary]] using the + * browser's XMLHttpRequest. + * + * @deprecated use BrowserFetchHttpLib + */ +export class BrowserHttpLibDepreacted implements HttpRequestLibrary { + private throttle = new RequestThrottler(); + private throttlingEnabled = true; + private requireTls = false; + + constructor(args?: HttpLibArgs) { + this.throttlingEnabled = args?.enableThrottling ?? true; + this.requireTls = args?.requireTls ?? false; + } + + fetch( + requestUrl: string, + options?: HttpRequestOptions, + ): Promise<HttpResponse> { + const requestMethod = options?.method ?? "GET"; + const requestBody = options?.body; + const requestHeader = options?.headers; + const requestTimeout = + options?.timeout ?? Duration.fromMilliseconds(DEFAULT_REQUEST_TIMEOUT_MS); + + const parsedUrl = new URL(requestUrl); + if (this.throttlingEnabled && this.throttle.applyThrottle(requestUrl)) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, + { + requestMethod, + requestUrl, + throttleStats: this.throttle.getThrottleStats(requestUrl), + }, + `request to origin ${parsedUrl.origin} was throttled`, + ); + } + if (this.requireTls && parsedUrl.protocol !== "https:") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_NETWORK_ERROR, + { + requestMethod: requestMethod, + requestUrl: requestUrl, + }, + `request to ${parsedUrl.origin} is not possible with protocol ${parsedUrl.protocol}`, + ); + } + + let myBody: ArrayBuffer | undefined = + requestMethod === "POST" || requestMethod === "PUT" || requestMethod === "PATCH" + ? encodeBody(requestBody) + : undefined; + + const requestHeadersMap = getDefaultHeaders(requestMethod); + if (requestHeader) { + Object.entries(requestHeader).forEach(([key, value]) => { + if (value === undefined) return; + requestHeadersMap[key] = value + }) + } + + return new Promise<HttpResponse>((resolve, reject) => { + const myRequest = new XMLHttpRequest(); + + myRequest.onerror = (e) => { + logger.error("http request error"); + reject( + TalerError.fromDetail( + TalerErrorCode.WALLET_NETWORK_ERROR, + { + requestUrl, + requestMethod, + }, + "Could not make request", + ), + ); + }; + + myRequest.open(requestMethod, requestUrl); + + let timeoutId: any | undefined; + if (requestTimeout.d_ms !== "forever") { + timeoutId = setTimeout(() => { + myRequest.abort(); + reject( + TalerError.fromDetail( + TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT, + { + requestUrl, + requestMethod, + timeoutMs: requestTimeout.d_ms === "forever" ? 0 : requestTimeout.d_ms + }, + `request to ${requestUrl} timed out`, + ), + ); + }, requestTimeout.d_ms); + } + + Object.keys(requestHeadersMap).forEach((headerName) => { + myRequest.setRequestHeader(headerName, requestHeadersMap[headerName]); + }); + + myRequest.responseType = "arraybuffer"; + myRequest.send(myBody); + + myRequest.addEventListener("readystatechange", (e) => { + if (myRequest.readyState === XMLHttpRequest.DONE) { + if (myRequest.status === 0) { + const exc = TalerError.fromDetail( + TalerErrorCode.WALLET_NETWORK_ERROR, + { + requestUrl, + requestMethod, + }, + "HTTP request failed (status 0, maybe URI scheme was wrong?)", + ); + reject(exc); + return; + } + const makeText = async (): Promise<string> => { + const td = new TextDecoder(); + return td.decode(myRequest.response); + }; + let responseJson: unknown = undefined; + const makeJson = async (): Promise<any> => { + if (responseJson === undefined) { + try { + const td = new TextDecoder(); + const responseString = td.decode(myRequest.response); + responseJson = JSON.parse(responseString); + } catch (e) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + requestUrl, + requestMethod, + httpStatusCode: myRequest.status, + }, + "Invalid JSON from HTTP response", + ); + } + } + if (responseJson === null || typeof responseJson !== "object") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + requestUrl, + requestMethod, + httpStatusCode: myRequest.status, + }, + "Invalid JSON from HTTP response", + ); + } + return responseJson; + }; + + const headers = myRequest.getAllResponseHeaders(); + const arr = headers.trim().split(/[\r\n]+/); + + // Create a map of header names to values + const headerMap: Headers = new Headers(); + arr.forEach(function (line) { + const parts = line.split(": "); + const headerName = parts.shift(); + if (!headerName) { + logger.warn("skipping invalid header"); + return; + } + const value = parts.join(": "); + headerMap.set(headerName, value); + }); + const resp: HttpResponse = { + requestUrl: requestUrl, + status: myRequest.status, + headers: headerMap, + requestMethod: requestMethod, + json: makeJson, + text: makeText, + bytes: async () => myRequest.response, + }; + resolve(resp); + } + }); + }); + } + + get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> { + return this.fetch(url, { + method: "GET", + ...opt, + }); + } + + postJson( + url: string, + body: any, + opt?: HttpRequestOptions, + ): Promise<HttpResponse> { + return this.fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + ...opt, + }); + } + + stop(): void { + // Nothing to do + } +} diff --git a/packages/web-util/src/utils/http-impl.sw.ts b/packages/web-util/src/utils/http-impl.sw.ts new file mode 100644 index 000000000..9c820bb4b --- /dev/null +++ b/packages/web-util/src/utils/http-impl.sw.ts @@ -0,0 +1,217 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import { + Duration, + RequestThrottler, + TalerError, + TalerErrorCode +} from "@gnu-taler/taler-util"; + +import { + DEFAULT_REQUEST_TIMEOUT_MS, + Headers, + HttpLibArgs, + HttpRequestLibrary, + HttpRequestOptions, + HttpResponse, + encodeBody, + getDefaultHeaders, +} from "@gnu-taler/taler-util/http"; + +/** + * An implementation of the [[HttpRequestLibrary]] using the + * browser's XMLHttpRequest. + */ +export class BrowserFetchHttpLib implements HttpRequestLibrary { + private throttle = new RequestThrottler(); + private throttlingEnabled = true; + private requireTls = false; + + public constructor(args?: HttpLibArgs) { + this.throttlingEnabled = args?.enableThrottling ?? true; + this.requireTls = args?.requireTls ?? false; + } + + async fetch( + requestUrl: string, + options?: HttpRequestOptions, + ): Promise<HttpResponse> { + const requestMethod = options?.method ?? "GET"; + const requestBody = options?.body; + const requestHeader = options?.headers; + const requestTimeout = + options?.timeout ?? Duration.fromMilliseconds(DEFAULT_REQUEST_TIMEOUT_MS); + const requestCancel = options?.cancellationToken; + const requestRedirect = options?.redirect; + + const parsedUrl = new URL(requestUrl); + if (this.throttlingEnabled && this.throttle.applyThrottle(requestUrl)) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, + { + requestMethod, + requestUrl, + throttleStats: this.throttle.getThrottleStats(requestUrl), + }, + `request to origin ${parsedUrl.origin} was throttled`, + ); + } + if (this.requireTls && parsedUrl.protocol !== "https:") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_NETWORK_ERROR, + { + requestMethod: requestMethod, + requestUrl: requestUrl, + }, + `request to ${parsedUrl.origin} is not possible with protocol ${parsedUrl.protocol}`, + ); + } + + const myBody: ArrayBuffer | undefined = + requestMethod === "POST" || requestMethod === "PUT" || requestMethod === "PATCH" + ? encodeBody(requestBody) + : undefined; + + const requestHeadersMap = getDefaultHeaders(requestMethod); + if (requestHeader) { + Object.entries(requestHeader).forEach(([key, value]) => { + if (value === undefined) return; + requestHeadersMap[key] = value + }) + } + + const controller = new AbortController(); + let timeoutId: ReturnType<typeof setTimeout> | undefined; + if (requestTimeout.d_ms !== "forever") { + timeoutId = setTimeout(() => { + controller.abort(TalerErrorCode.GENERIC_TIMEOUT); + }, requestTimeout.d_ms); + } + if (requestCancel) { + requestCancel.onCancelled(() => { + controller.abort(TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR) + }); + } + + try { + const response = await fetch(requestUrl, { + headers: requestHeadersMap, + body: myBody, + method: requestMethod, + signal: controller.signal, + redirect: requestRedirect + }); + + if (timeoutId) { + clearTimeout(timeoutId); + } + + const headerMap = new Headers(); + response.headers.forEach((value, key) => { + headerMap.set(key, value); + }); + return { + headers: headerMap, + status: response.status, + requestMethod, + requestUrl, + json: makeJsonHandler(response, requestUrl, requestMethod), + text: makeTextHandler(response, requestUrl, requestMethod), + bytes: async () => (await response.blob()).arrayBuffer(), + }; + } catch (e) { + if (controller.signal) { + throw TalerError.fromDetail( + controller.signal.reason, + { + requestUrl, + requestMethod, + timeoutMs: requestTimeout.d_ms === "forever" ? 0 : requestTimeout.d_ms + }, + `HTTP request failed.`, + ); + } + throw e; + } + } + +} + +function makeTextHandler( + response: Response, + requestUrl: string, + requestMethod: string, +) { + return async function getTextFromResponse(): Promise<any> { + let respText; + try { + respText = await response.text(); + } catch (e) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + requestUrl, + requestMethod, + httpStatusCode: response.status, + }, + "Invalid text from HTTP response", + ); + } + return respText; + }; +} + +function makeJsonHandler( + response: Response, + requestUrl: string, + requestMethod: string, +) { + let responseJson: unknown = undefined; + return async function getJsonFromResponse(): Promise<any> { + if (responseJson === undefined) { + try { + responseJson = await response.json(); + } catch (e) { + const message = e instanceof Error ? `Invalid JSON from HTTP response: ${e.message}` : "Invalid JSON from HTTP response" + throw TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + requestUrl, + requestMethod, + httpStatusCode: response.status, + }, + message, + ); + } + } + if (responseJson === null || typeof responseJson !== "object") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + requestUrl, + requestMethod, + httpStatusCode: response.status, + }, + "Invalid JSON from HTTP response: null or not object", + ); + } + return responseJson; + }; +} diff --git a/packages/web-util/src/utils/observable.ts b/packages/web-util/src/utils/observable.ts new file mode 100644 index 000000000..16a33ae72 --- /dev/null +++ b/packages/web-util/src/utils/observable.ts @@ -0,0 +1,283 @@ +import { isArrayBufferView } from "util/types"; + +export type ObservableMap<K, V> = Map<K, V> & { + onAnyUpdate: (callback: () => void) => () => void; + onUpdate: (key: string, callback: () => void) => () => void; +}; + +//FIXME: allow different type for different properties +export function memoryMap<T>( + backend: Map<string, T> = new Map<string, T>(), +): ObservableMap<string, T> { + const obs = new EventTarget(); + const theMemoryMap: ObservableMap<string, T> = { + onAnyUpdate: (handler) => { + obs.addEventListener(`update`, handler); + obs.addEventListener(`clear`, handler); + return () => { + obs.removeEventListener(`update`, handler); + obs.removeEventListener(`clear`, handler); + }; + }, + onUpdate: (key, handler) => { + obs.addEventListener(`update-${key}`, handler); + obs.addEventListener(`clear`, handler); + return () => { + obs.removeEventListener(`update-${key}`, handler); + obs.removeEventListener(`clear`, handler); + }; + }, + delete: (key: string) => { + const result = backend.delete(key); + //@ts-ignore + theMemoryMap.size = backend.length; + obs.dispatchEvent(new Event(`update-${key}`)); + obs.dispatchEvent(new Event(`update`)); + return result; + }, + set: (key: string, value: T) => { + backend.set(key, value); + //@ts-ignore + theMemoryMap.size = backend.length; + obs.dispatchEvent(new Event(`update-${key}`)); + obs.dispatchEvent(new Event(`update`)); + return theMemoryMap; + }, + clear: () => { + backend.clear(); + obs.dispatchEvent(new Event(`clear`)); + }, + entries: backend.entries.bind(backend), + forEach: backend.forEach.bind(backend), + get: backend.get.bind(backend), + has: backend.has.bind(backend), + keys: backend.keys.bind(backend), + size: backend.size, + values: backend.values.bind(backend), + [Symbol.iterator]: backend[Symbol.iterator], + [Symbol.toStringTag]: "theMemoryMap", + }; + return theMemoryMap; +} + +//FIXME: change this implementation to match the +// browser storage. instead of creating a sync implementation +// of observable map it should reuse the memoryMap and +// sync the state with local storage +export function localStorageMap(): ObservableMap<string, string> { + const obs = new EventTarget(); + const theLocalStorageMap: ObservableMap<string, string> = { + onAnyUpdate: (handler) => { + obs.addEventListener(`update`, handler); + obs.addEventListener(`clear`, handler); + window.addEventListener("storage", handler); + return () => { + window.removeEventListener("storage", handler); + obs.removeEventListener(`update`, handler); + obs.removeEventListener(`clear`, handler); + }; + }, + onUpdate: (key, handler) => { + obs.addEventListener(`update-${key}`, handler); + obs.addEventListener(`clear`, handler); + function handleStorageEvent(ev: StorageEvent) { + if (ev.key === null || ev.key === key) { + handler(); + } + } + window.addEventListener("storage", handleStorageEvent); + return () => { + window.removeEventListener("storage", handleStorageEvent); + obs.removeEventListener(`update-${key}`, handler); + obs.removeEventListener(`clear`, handler); + }; + }, + delete: (key: string) => { + const exists = localStorage.getItem(key) !== null; + localStorage.removeItem(key); + //@ts-ignore + theLocalStorageMap.size = localStorage.length; + obs.dispatchEvent(new Event(`update-${key}`)); + obs.dispatchEvent(new Event(`update`)); + return exists; + }, + set: (key: string, v: string) => { + localStorage.setItem(key, v); + //@ts-ignore + theLocalStorageMap.size = localStorage.length; + obs.dispatchEvent(new Event(`update-${key}`)); + obs.dispatchEvent(new Event(`update`)); + return theLocalStorageMap; + }, + clear: () => { + localStorage.clear(); + obs.dispatchEvent(new Event(`clear`)); + }, + entries: (): IterableIterator<[string, string]> => { + let index = 0; + const total = localStorage.length; + return { + next() { + if (index === total) return { done: true, value: undefined }; + const key = localStorage.key(index); + if (key === null) { + //we are going from 0 until last, this should not happen + throw Error("key cant be null"); + } + const item = localStorage.getItem(key); + if (item === null) { + //the key exist, this should not happen + throw Error("value cant be null"); + } + index = index + 1; + return { done: false, value: [key, item] }; + }, + [Symbol.iterator]() { + return this; + }, + }; + }, + forEach: (cb) => { + for (let index = 0; index < localStorage.length; index++) { + const key = localStorage.key(index); + if (key === null) { + //we are going from 0 until last, this should not happen + throw Error("key cant be null"); + } + const item = localStorage.getItem(key); + if (item === null) { + //the key exist, this should not happen + throw Error("value cant be null"); + } + cb(key, item, theLocalStorageMap); + } + }, + get: (key: string) => { + const item = localStorage.getItem(key); + if (item === null) return undefined; + return item; + }, + has: (key: string) => { + return localStorage.getItem(key) === null; + }, + keys: () => { + let index = 0; + const total = localStorage.length; + return { + next() { + if (index === total) return { done: true, value: undefined }; + const key = localStorage.key(index); + if (key === null) { + //we are going from 0 until last, this should not happen + throw Error("key cant be null"); + } + index = index + 1; + return { done: false, value: key }; + }, + [Symbol.iterator]() { + return this; + }, + }; + }, + size: localStorage.length, + values: () => { + let index = 0; + const total = localStorage.length; + return { + next() { + if (index === total) return { done: true, value: undefined }; + const key = localStorage.key(index); + if (key === null) { + //we are going from 0 until last, this should not happen + throw Error("key cant be null"); + } + const item = localStorage.getItem(key); + if (item === null) { + //the key exist, this should not happen + throw Error("value cant be null"); + } + index = index + 1; + return { done: false, value: item }; + }, + [Symbol.iterator]() { + return this; + }, + }; + }, + [Symbol.iterator]: function (): IterableIterator<[string, string]> { + return theLocalStorageMap.entries(); + }, + [Symbol.toStringTag]: "theLocalStorageMap", + }; + return theLocalStorageMap; +} + +const isFirefox = + typeof (window as any) !== "undefined" && + typeof (window as any)["InstallTrigger"] !== "undefined"; + +async function getAllContent() { + //Firefox and Chrome has different storage api + if (isFirefox) { + // @ts-ignore + return browser.storage.local.get(); + } else { + return chrome.storage.local.get(); + } +} + +async function updateContent(obj: Record<string, any>) { + if (isFirefox) { + // @ts-ignore + return browser.storage.local.set(obj); + } else { + return chrome.storage.local.set(obj); + } +} +type Changes = { [key: string]: { oldValue?: any; newValue?: any } }; +function onBrowserStorageUpdate(cb: (changes: Changes) => void): void { + if (isFirefox) { + // @ts-ignore + browser.storage.local.onChanged.addListener(cb); + } else { + chrome.storage.local.onChanged.addListener(cb); + } +} + +export function browserStorageMap( + backend: ObservableMap<string, string>, +): ObservableMap<string, string> { + getAllContent().then(content => { + Object.entries(content ?? {}).forEach(([k, v]) => { + backend.set(k, v as string); + }); + }) + + backend.onAnyUpdate(async () => { + const result: Record<string, string> = {}; + for (const [key, value] of backend.entries()) { + result[key] = value; + } + await updateContent(result); + }); + + onBrowserStorageUpdate((changes) => { + //another chrome instance made the change + const changedItems = Object.keys(changes); + if (changedItems.length === 0) { + backend.clear(); + } else { + for (const key of changedItems) { + if (!changes[key].newValue) { + backend.delete(key); + } else { + if (changes[key].newValue !== changes[key].oldValue) { + backend.set(key, changes[key].newValue); + } + } + } + } + }); + + return backend; +} diff --git a/packages/web-util/src/utils/request.ts b/packages/web-util/src/utils/request.ts new file mode 100644 index 000000000..23d3af468 --- /dev/null +++ b/packages/web-util/src/utils/request.ts @@ -0,0 +1,477 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { base64encode } from "./base64.js"; + +/** + * @deprecated do not use it, it will be removed + */ +export enum ErrorType { + CLIENT, + SERVER, + UNREADABLE, + TIMEOUT, + UNEXPECTED, +} + + + +/** + * + * @param baseUrl URL where the service is located + * @param endpoint endpoint of the service to be called + * @param options auth, method and params + * @deprecated do not use it, it will be removed + * @returns + */ +export async function defaultRequestHandler<T>( + baseUrl: string, + endpoint: string, + options: RequestOptions = {}, +): Promise<HttpResponseOk<T>> { + const requestHeaders: Record<string, string> = {}; + if (options.token) { + requestHeaders.Authorization = `Bearer secret-token:${options.token}`; + } else if (options.basicAuth) { + requestHeaders.Authorization = `Basic ${base64encode( + `${options.basicAuth.username}:${options.basicAuth.password}`, + )}`; + } + requestHeaders["Content-Type"] = + !options.contentType || options.contentType === "json" ? "application/json" : "text/plain"; + + if (options.talerAmlOfficerSignature) { + requestHeaders["Taler-AML-Officer-Signature"] = + options.talerAmlOfficerSignature; + } + + const requestMethod = options?.method ?? "GET"; + const requestBody = options?.data; + const requestTimeout = options?.timeout ?? 5 * 1000; + const requestParams = options.params ?? {}; + const requestPreventCache = options.preventCache ?? false; + const requestPreventCors = options.preventCors ?? false; + + const validURL = validateURL(baseUrl, endpoint); + + if (!validURL) { + const error: HttpResponseUnexpectedError = { + info: { + url: `${baseUrl}${endpoint}`, + payload: {}, + hasToken: !!options.token, + status: 0, + options, + }, + type: ErrorType.UNEXPECTED, + exception: undefined, + loading: false, + message: `invalid URL: "${baseUrl}${endpoint}"`, + }; + throw new RequestError(error) + } + + Object.entries(requestParams).forEach(([key, value]) => { + validURL.searchParams.set(key, String(value)); + }); + + let payload: BodyInit | undefined = undefined; + if (requestBody != null) { + if (typeof requestBody === "string") { + payload = requestBody; + } else if (requestBody instanceof ArrayBuffer) { + payload = requestBody; + } else if (ArrayBuffer.isView(requestBody)) { + payload = requestBody; + } else if (typeof requestBody === "object") { + payload = JSON.stringify(requestBody); + } else { + const error: HttpResponseUnexpectedError = { + info: { + url: validURL.href, + payload: {}, + hasToken: !!options.token, + status: 0, + options, + }, + type: ErrorType.UNEXPECTED, + exception: undefined, + loading: false, + message: `unsupported request body type: "${typeof requestBody}"`, + }; + throw new RequestError(error) + } + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort("HTTP_REQUEST_TIMEOUT"); + }, requestTimeout); + + let response; + try { + response = await fetch(validURL.href, { + headers: requestHeaders, + method: requestMethod, + credentials: "omit", + mode: requestPreventCors ? "no-cors" : "cors", + cache: requestPreventCache ? "no-cache" : "default", + body: payload, + signal: controller.signal, + }); + } catch (ex) { + const info: RequestInfo = { + payload, + url: validURL.href, + hasToken: !!options.token, + status: 0, + options, + }; + + if (ex instanceof Error) { + if (ex.message === "HTTP_REQUEST_TIMEOUT") { + const error: HttpRequestTimeoutError = { + info, + type: ErrorType.TIMEOUT, + message: "request timeout", + }; + throw new RequestError(error); + } + } + + const error: HttpResponseUnexpectedError = { + info, + type: ErrorType.UNEXPECTED, + exception: ex, + loading: false, + message: (ex instanceof Error ? ex.message : ""), + }; + throw new RequestError(error); + } + + if (timeoutId) { + clearTimeout(timeoutId); + } + const headerMap = new Headers(); + response.headers.forEach((value, key) => { + headerMap.set(key, value); + }); + + if (response.ok) { + const result = await buildRequestOk<T>( + response, + validURL.href, + payload, + !!options.token, + options, + ); + return result; + } else { + const dataTxt = await response.text(); + const error = buildRequestFailed( + validURL.href, + dataTxt, + response.status, + payload, + options, + ); + throw new RequestError(error); + } +} + +/** + * @deprecated do not use it, it will be removed + */ +export type HttpResponse<T, ErrorDetail> = + | HttpResponseOk<T> + | HttpResponseLoading<T> + | HttpError<ErrorDetail>; + +/** + * @deprecated do not use it, it will be removed + */ +export type HttpResponsePaginated<T, ErrorDetail> = + | HttpResponseOkPaginated<T> + | HttpResponseLoading<T> + | HttpError<ErrorDetail>; + +/** + * @deprecated do not use it, it will be removed + */ +export interface RequestInfo { + url: string; + hasToken: boolean; + payload: any; + status: number; + options: RequestOptions; +} + +interface HttpResponseLoading<T> { + ok?: false; + loading: true; + clientError?: false; + serverError?: false; + + data?: T; +} +/** + * @deprecated do not use it, it will be removed + */ +export interface HttpResponseOk<T> { + ok: true; + loading?: false; + clientError?: false; + serverError?: false; + + data: T; + info?: RequestInfo; +} + +/** + * @deprecated do not use it, it will be removed + */ +export type HttpResponseOkPaginated<T> = HttpResponseOk<T> & WithPagination; + +/** + * @deprecated do not use it, it will be removed + */ +export interface WithPagination { + loadMore: () => void; + loadMorePrev: () => void; + isReachingEnd?: boolean; + isReachingStart?: boolean; +} + +/** + * @deprecated do not use it, it will be removed + */ +export type HttpError<ErrorDetail> = + | HttpRequestTimeoutError + | HttpResponseClientError<ErrorDetail> + | HttpResponseServerError<ErrorDetail> + | HttpResponseUnreadableError + | HttpResponseUnexpectedError; + +/** + * @deprecated do not use it, it will be removed + */ +export interface HttpResponseServerError<ErrorDetail> { + ok?: false; + loading?: false; + type: ErrorType.SERVER; + payload: ErrorDetail; + status: HttpStatusCode; + message: string; + info: RequestInfo; +} +interface HttpRequestTimeoutError { + ok?: false; + loading?: false; + type: ErrorType.TIMEOUT; + + info: RequestInfo; + + message: string; +} +interface HttpResponseClientError<ErrorDetail> { + ok?: false; + loading?: false; + type: ErrorType.CLIENT; + + info: RequestInfo; + status: HttpStatusCode; + payload: ErrorDetail; + message: string; +} + +interface HttpResponseUnexpectedError { + ok?: false; + loading: false; + type: ErrorType.UNEXPECTED; + + info: RequestInfo; + status?: HttpStatusCode; + exception: unknown; + message: string; +} + +interface HttpResponseUnreadableError { + ok?: false; + loading: false; + type: ErrorType.UNREADABLE; + + info: RequestInfo; + status: HttpStatusCode; + exception: unknown; + body: string; + message: string; +} +/** + * @deprecated do not use it, it will be removed + */ +export class RequestError<ErrorDetail> extends Error { + /** + * @deprecated use cause + */ + info: HttpError<ErrorDetail>; + cause: HttpError<ErrorDetail>; + constructor(d: HttpError<ErrorDetail>) { + super(d.message); + this.info = d; + this.cause = d; + } +} + +type Methods = "GET" | "POST" | "PATCH" | "DELETE" | "PUT"; + +/** + * @deprecated do not use it, it will be removed + */ +export interface RequestOptions { + method?: Methods; + token?: string; + basicAuth?: { + username: string; + password: string; + }; + preventCache?: boolean; + preventCors?: boolean; + data?: any; + params?: unknown; + timeout?: number; + contentType?: "text" | "json"; + talerAmlOfficerSignature?: string; +} + +/** + * @deprecated do not use it, it will be removed + */ +async function buildRequestOk<T>( + response: Response, + url: string, + payload: any, + hasToken: boolean, + options: RequestOptions, +): Promise<HttpResponseOk<T>> { + const dataTxt = await response.text(); + const data = dataTxt ? JSON.parse(dataTxt) : undefined; + return { + ok: true, + data, + info: { + payload, + url, + hasToken, + options, + status: response.status, + }, + }; +} + +/** + * @deprecated do not use it, it will be removed + */ +export function buildRequestFailed<ErrorDetail>( + url: string, + dataTxt: string, + status: number, + payload: any, + maybeOptions?: RequestOptions, +): + | HttpResponseClientError<ErrorDetail> + | HttpResponseServerError<ErrorDetail> + | HttpResponseUnreadableError + | HttpResponseUnexpectedError { + const options = maybeOptions ?? {}; + const info: RequestInfo = { + payload, + url, + hasToken: !!options.token, + options, + status: status || 0, + }; + + // const dataTxt = await response.text(); + try { + const data = dataTxt ? JSON.parse(dataTxt) : undefined; + const errorCode = !data || !data.code ? "" : `(code: ${data.code})`; + const errorHint = + !data || !data.hint ? "Not hint." : `${data.hint} ${errorCode}`; + + if (status && status >= 400 && status < 500) { + const message = + data === undefined + ? `Client error (${status}) without data.` + : errorHint; + + const error: HttpResponseClientError<ErrorDetail> = { + type: ErrorType.CLIENT, + status, + info, + message, + payload: data, + }; + return error; + } + if (status && status >= 500 && status < 600) { + const message = + data === undefined + ? `Server error (${status}) without data.` + : errorHint; + const error: HttpResponseServerError<ErrorDetail> = { + type: ErrorType.SERVER, + status, + info, + message, + payload: data, + }; + return error; + } + return { + info, + loading: false, + type: ErrorType.UNEXPECTED, + status, + exception: undefined, + message: `http status code not handled: ${status}`, + }; + } catch (ex) { + const error: HttpResponseUnreadableError = { + info, + loading: false, + status, + type: ErrorType.UNREADABLE, + exception: ex, + body: dataTxt, + message: "Could not parse body as json", + }; + + return error; + } +} + +/** + * @deprecated do not use it, it will be removed + */ +function validateURL(baseUrl: string, endpoint: string): URL | undefined { + try { + return new URL(`${baseUrl}${endpoint}`) + } catch (ex) { + return undefined + } + +}
\ No newline at end of file diff --git a/packages/web-util/src/utils/route.ts b/packages/web-util/src/utils/route.ts new file mode 100644 index 000000000..494a61efa --- /dev/null +++ b/packages/web-util/src/utils/route.ts @@ -0,0 +1,126 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +declare const __location: unique symbol; +/** + * special string that defined a location in the application + * + * this help to prevent wrong path + */ +export type AppLocation = string & { + [__location]: true; +}; + +export type EmptyObject = Record<string, never>; + +export function urlPattern< + T extends Record<string, string | undefined> = EmptyObject, +>(pattern: RegExp, reverse: (p: T) => string): RouteDefinition<T> { + const url = reverse as (p: T) => AppLocation; + return { + pattern: new RegExp(pattern), + url, + }; +} + +/** + * defines a location in the app + * + * pattern: how a string will trigger this location + * url(): how a state serialize to a location + */ + +export type ObjectOf<T> = Record<string, T> | EmptyObject; + +export type RouteDefinition< + T extends ObjectOf<string | undefined> = EmptyObject, +> = { + pattern: RegExp; + url: (p: T) => AppLocation; +}; + +const nullRountDef = { + pattern: new RegExp(/.*/), + url: () => "" as AppLocation, +}; +export function buildNullRoutDefinition< + T extends ObjectOf<string>, +>(): RouteDefinition<T> { + return nullRountDef; +} + +/** + * Search path in the pageList + * get the values from the path found + * add params from searchParams + * + * @param path + * @param params + */ +export function findMatch<T extends ObjectOf<RouteDefinition>>( + pagesMap: T, + pageList: Array<keyof T>, + path: string, + params: Record<string, string[]>, +): Location<T> | undefined { + for (let idx = 0; idx < pageList.length; idx++) { + const name = pageList[idx]; + const found = pagesMap[name].pattern.exec(path); + if (found !== null) { + const values = {} as Record<string, unknown>; + + if (found.groups !== undefined) { + Object.entries(found.groups).forEach(([key, value]) => { + values[key] = value; + }); + } + + // @ts-expect-error values is a map string which is equivalent to the RouteParamsType + return { name, parent: pagesMap, values, params }; + } + } + return undefined; +} + +/** + * get the type of the params of a location + * + */ +type RouteParamsType< + RouteType, + Key extends keyof RouteType, +> = RouteType[Key] extends RouteDefinition<infer ParamType> ? ParamType : never; + +/** + * Helps to create a map of a type with the key + */ +type MapKeyValue<Type> = { + [Key in keyof Type]: Key extends string + ? { + parent: Type; + name: Key; + values: RouteParamsType<Type, Key>; + params: Record<string, string[]>; + } + : never; +}; + +/** + * create a enumeration of value of a mapped type + */ +type EnumerationOf<T> = T[keyof T]; + +export type Location<T> = EnumerationOf<MapKeyValue<T>>; diff --git a/packages/web-util/tsconfig.json b/packages/web-util/tsconfig.json index aede0a0ac..a315dda1c 100644 --- a/packages/web-util/tsconfig.json +++ b/packages/web-util/tsconfig.json @@ -1,16 +1,16 @@ { "compilerOptions": { "composite": true, - "target": "ES6", - "module": "ESNext", + "declaration": true, + "declarationMap": true, + "target": "ES2020", + "module": "Node16", "jsx": "react", "jsxFactory": "h", "jsxFragmentFactory": "Fragment", - "moduleResolution": "Node", + "moduleResolution": "Node16", "sourceMap": true, - "lib": [ - "es6" - ], + "lib": ["DOM", "ES2020"], "outDir": "lib", "preserveSymlinks": true, "skipLibCheck": true, @@ -24,11 +24,7 @@ "esModuleInterop": true, "importHelpers": true, "rootDir": "./src", - "typeRoots": [ - "./node_modules/@types" - ] + "typeRoots": ["./node_modules/@types"] }, - "include": [ - "src/**/*" - ] -}
\ No newline at end of file + "include": ["src/**/*"] +} |