summaryrefslogtreecommitdiff
path: root/packages/web-util
diff options
context:
space:
mode:
Diffstat (limited to 'packages/web-util')
-rw-r--r--packages/web-util/README0
-rw-r--r--packages/web-util/README.md3
-rwxr-xr-xpackages/web-util/build.mjs198
-rw-r--r--packages/web-util/package.json58
-rw-r--r--packages/web-util/src/assets/lang.svg48
-rw-r--r--packages/web-util/src/assets/logo-2021.svg9
-rw-r--r--packages/web-util/src/assets/logo-white.svg45
-rw-r--r--packages/web-util/src/cli.ts4
-rw-r--r--packages/web-util/src/components/Attention.tsx80
-rw-r--r--packages/web-util/src/components/Button.tsx167
-rw-r--r--packages/web-util/src/components/CopyButton.tsx56
-rw-r--r--packages/web-util/src/components/ErrorLoading.tsx147
-rw-r--r--packages/web-util/src/components/ErrorLoadingMerchant.tsx147
-rw-r--r--packages/web-util/src/components/Footer.tsx48
-rw-r--r--packages/web-util/src/components/Header.tsx183
-rw-r--r--packages/web-util/src/components/LangSelector.tsx115
-rw-r--r--packages/web-util/src/components/Loading.tsx45
-rw-r--r--packages/web-util/src/components/NotificationBanner.tsx33
-rw-r--r--packages/web-util/src/components/ShowInputErrorLabel.tsx29
-rw-r--r--packages/web-util/src/components/ToastBanner.tsx61
-rw-r--r--packages/web-util/src/components/index.ts12
-rw-r--r--packages/web-util/src/components/utils.ts111
-rw-r--r--packages/web-util/src/context/activity.ts76
-rw-r--r--packages/web-util/src/context/api.ts49
-rw-r--r--packages/web-util/src/context/bank-api.ts224
-rw-r--r--packages/web-util/src/context/challenger-api.ts213
-rw-r--r--packages/web-util/src/context/exchange-api.ts217
-rw-r--r--packages/web-util/src/context/index.ts12
-rw-r--r--packages/web-util/src/context/merchant-api.ts228
-rw-r--r--packages/web-util/src/context/navigation.ts114
-rw-r--r--packages/web-util/src/context/translation.ts119
-rw-r--r--packages/web-util/src/context/wallet-integration.ts83
-rw-r--r--packages/web-util/src/declaration.d.ts35
-rw-r--r--packages/web-util/src/forms/Calendar.tsx186
-rw-r--r--packages/web-util/src/forms/Caption.tsx27
-rw-r--r--packages/web-util/src/forms/DefaultForm.tsx84
-rw-r--r--packages/web-util/src/forms/Dialog.tsx15
-rw-r--r--packages/web-util/src/forms/FormProvider.tsx150
-rw-r--r--packages/web-util/src/forms/Group.tsx44
-rw-r--r--packages/web-util/src/forms/InputAbsoluteTime.stories.tsx60
-rw-r--r--packages/web-util/src/forms/InputAbsoluteTime.tsx94
-rw-r--r--packages/web-util/src/forms/InputAmount.stories.tsx59
-rw-r--r--packages/web-util/src/forms/InputAmount.tsx43
-rw-r--r--packages/web-util/src/forms/InputArray.stories.tsx79
-rw-r--r--packages/web-util/src/forms/InputArray.tsx226
-rw-r--r--packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx69
-rw-r--r--packages/web-util/src/forms/InputChoiceHorizontal.tsx82
-rw-r--r--packages/web-util/src/forms/InputChoiceStacked.stories.tsx69
-rw-r--r--packages/web-util/src/forms/InputChoiceStacked.tsx119
-rw-r--r--packages/web-util/src/forms/InputFile.stories.tsx64
-rw-r--r--packages/web-util/src/forms/InputFile.tsx139
-rw-r--r--packages/web-util/src/forms/InputInteger.stories.tsx55
-rw-r--r--packages/web-util/src/forms/InputInteger.tsx24
-rw-r--r--packages/web-util/src/forms/InputLine.stories.tsx59
-rw-r--r--packages/web-util/src/forms/InputLine.tsx272
-rw-r--r--packages/web-util/src/forms/InputSelectMultiple.stories.tsx90
-rw-r--r--packages/web-util/src/forms/InputSelectMultiple.tsx166
-rw-r--r--packages/web-util/src/forms/InputSelectOne.stories.tsx70
-rw-r--r--packages/web-util/src/forms/InputSelectOne.tsx144
-rw-r--r--packages/web-util/src/forms/InputText.stories.tsx59
-rw-r--r--packages/web-util/src/forms/InputText.tsx9
-rw-r--r--packages/web-util/src/forms/InputTextArea.stories.tsx59
-rw-r--r--packages/web-util/src/forms/InputTextArea.tsx9
-rw-r--r--packages/web-util/src/forms/InputToggle.stories.tsx59
-rw-r--r--packages/web-util/src/forms/InputToggle.tsx56
-rw-r--r--packages/web-util/src/forms/TimePicker.tsx109
-rw-r--r--packages/web-util/src/forms/converter.ts130
-rw-r--r--packages/web-util/src/forms/forms.ts372
-rw-r--r--packages/web-util/src/forms/index.stories.ts13
-rw-r--r--packages/web-util/src/forms/index.ts25
-rw-r--r--packages/web-util/src/forms/ui-form.ts363
-rw-r--r--packages/web-util/src/forms/useField.ts91
-rw-r--r--packages/web-util/src/hooks/index.ts14
-rw-r--r--packages/web-util/src/hooks/useAsyncAsHook.ts91
-rw-r--r--packages/web-util/src/hooks/useLang.ts49
-rw-r--r--packages/web-util/src/hooks/useLocalStorage.ts179
-rw-r--r--packages/web-util/src/hooks/useMemoryStorage.ts71
-rw-r--r--packages/web-util/src/hooks/useNotifications.ts337
-rw-r--r--packages/web-util/src/index.browser.ts10
-rw-r--r--packages/web-util/src/index.build.ts327
-rw-r--r--packages/web-util/src/index.testing.ts3
-rw-r--r--packages/web-util/src/index.ts1
-rw-r--r--packages/web-util/src/live-reload.ts33
-rw-r--r--packages/web-util/src/serve.ts126
-rw-r--r--packages/web-util/src/stories.tsx40
-rw-r--r--packages/web-util/src/tests/hook.ts325
-rw-r--r--packages/web-util/src/tests/mock.ts503
-rw-r--r--packages/web-util/src/tests/swr.ts105
-rw-r--r--packages/web-util/src/utils/base64.ts243
-rw-r--r--packages/web-util/src/utils/http-impl.browser.ts251
-rw-r--r--packages/web-util/src/utils/http-impl.sw.ts217
-rw-r--r--packages/web-util/src/utils/observable.ts283
-rw-r--r--packages/web-util/src/utils/request.ts477
-rw-r--r--packages/web-util/src/utils/route.ts126
-rw-r--r--packages/web-util/tsconfig.json22
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 &copy; 2014&mdash;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">&gt;</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/**/*"]
+}