From 41bf68a94c991e8ae8ad74e38a88b19a79873c09 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Sat, 25 Nov 2023 19:59:04 +0000 Subject: [PATCH] Add `react-select` (#8) --- package-lock.json | 493 +++++++++++++++++- packages/fastui-bootstrap/src/index.tsx | 3 + packages/fastui/package.json | 1 + packages/fastui/src/components/FormField.tsx | 201 +++++-- packages/fastui/src/components/ServerLoad.tsx | 13 +- packages/fastui/src/components/form.tsx | 3 +- packages/fastui/src/components/index.tsx | 7 +- packages/fastui/src/tools.ts | 41 +- python/demo/main.py | 35 +- python/fastui/components/__init__.py | 2 - python/fastui/components/forms.py | 24 +- python/fastui/forms.py | 34 +- python/fastui/json_schema.py | 107 ++-- 13 files changed, 852 insertions(+), 112 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5272734c..9c5434f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,186 @@ "node": ">=0.10.0" } }, + "node_modules/@babel/code-frame": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.4.tgz", + "integrity": "sha512-r1IONyb6Ia+jYR2vvIDhdWdlTGhqbBoFqLTQidzZ4kepUFH15ejXvFHxCVbtl7BOXIudsIubf4E81xeA3h3IXA==", + "dependencies": { + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/code-frame/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/code-frame/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dependencies": { + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/runtime": { "version": "7.23.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.4.tgz", @@ -44,6 +224,122 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.4.tgz", + "integrity": "sha512-7uIFwVYpoplT5jp/kVv6EF93VaJ8H+Yn5IczYiaAi98ajzjfoZfslet/e0sLh+wVBjb2qqIut1b0S26VSafsSQ==", + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", + "integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.1", + "@emotion/memoize": "^0.8.1", + "@emotion/serialize": "^1.1.2", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz", + "integrity": "sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==", + "dependencies": { + "@emotion/memoize": "^0.8.1", + "@emotion/sheet": "^1.2.2", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", + "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" + }, + "node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + }, + "node_modules/@emotion/react": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.1.tgz", + "integrity": "sha512-5mlW1DquU5HaxjLkfkGN1GA/fvVGdyHURRiX/0FHl2cfIfRxSOfmxEH5YS43edp0OldZrZ+dkBKbngxcNCdZvA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.11.0", + "@emotion/cache": "^11.11.0", + "@emotion/serialize": "^1.1.2", + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.2.tgz", + "integrity": "sha512-zR6a/fkFP4EAcCMQtLOhIgpprZOwNmCldtpaISpvz348+DP4Mz8ZoKaGGCQpbzepNIUWbq4w6hNZkwDyKoS+HA==", + "dependencies": { + "@emotion/hash": "^0.9.1", + "@emotion/memoize": "^0.8.1", + "@emotion/unitless": "^0.8.1", + "@emotion/utils": "^1.2.1", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz", + "integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==" + }, + "node_modules/@emotion/unitless": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", + "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", + "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" + }, "node_modules/@esbuild/android-arm": { "version": "0.19.5", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.5.tgz", @@ -452,6 +748,28 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.0.tgz", + "integrity": "sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==", + "dependencies": { + "@floating-ui/utils": "^0.1.3" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz", + "integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==", + "dependencies": { + "@floating-ui/core": "^1.4.2", + "@floating-ui/utils": "^0.1.3" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", + "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -994,6 +1312,11 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" + }, "node_modules/@types/prop-types": { "version": "15.7.10", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.10.tgz", @@ -1492,6 +1815,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -1595,7 +1932,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "engines": { "node": ">=6" } @@ -1727,6 +2063,26 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -1864,6 +2220,14 @@ "csstype": "^3.0.2" } }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-abstract": { "version": "1.22.3", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", @@ -2020,7 +2384,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "engines": { "node": ">=10" }, @@ -2588,6 +2951,11 @@ "node": ">=8" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2664,7 +3032,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2913,7 +3280,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", - "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -3029,6 +3395,14 @@ "node": "*" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.0.tgz", @@ -3056,7 +3430,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -3156,6 +3529,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, "node_modules/is-async-function": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", @@ -3242,7 +3620,6 @@ "version": "2.13.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dev": true, "dependencies": { "hasown": "^2.0.0" }, @@ -3557,6 +3934,11 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -3619,6 +4001,11 @@ "node": ">= 0.8.0" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3901,6 +4288,11 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4694,7 +5086,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "dependencies": { "callsites": "^3.0.0" }, @@ -4728,6 +5119,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4758,14 +5166,12 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, "engines": { "node": ">=8" } @@ -4992,6 +5398,26 @@ "react": ">=18" } }, + "node_modules/react-select": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.8.0.tgz", + "integrity": "sha512-TfjLDo58XrhP6VG5M/Mi56Us0Yt8X7xD6cDybC7yoRMUNm7BGO7qk8J0TLQOua/prb8vUOtsfnXZwfm30HGsAA==", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.1.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-syntax-highlighter": { "version": "15.5.0", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.5.0.tgz", @@ -5163,8 +5589,6 @@ "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, - "peer": true, "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -5181,7 +5605,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "engines": { "node": ">=4" } @@ -5416,6 +5839,14 @@ "node": ">=8" } }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", @@ -5540,6 +5971,11 @@ "inline-style-parser": "0.1.1" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -5556,7 +5992,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -5570,6 +6005,14 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "engines": { + "node": ">=4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5856,6 +6299,19 @@ "punycode": "^2.1.0" } }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", + "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/vanilla": { "resolved": "packages/vanilla", "link": true @@ -6061,6 +6517,14 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "engines": { + "node": ">= 6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -6088,6 +6552,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^9.0.1", + "react-select": "^5.8.0", "react-syntax-highlighter": "^15.5.0", "remark-gfm": "^4.0.0" }, diff --git a/packages/fastui-bootstrap/src/index.tsx b/packages/fastui-bootstrap/src/index.tsx index 8584454a..e57a93bf 100644 --- a/packages/fastui-bootstrap/src/index.tsx +++ b/packages/fastui-bootstrap/src/index.tsx @@ -39,6 +39,7 @@ export const classNameGenerator: ClassNameGenerator = ({ props, fullPath, subEle case 'FormFieldInput': case 'FormFieldCheckbox': case 'FormFieldSelect': + case 'FormFieldSelectSearch': case 'FormFieldFile': return formFieldClassName(props, subElement) case 'Navbar': @@ -54,6 +55,8 @@ function formFieldClassName(props: components.FormFieldProps, subElement?: strin return props.error ? 'is-invalid form-control' : 'form-control' case 'select': return 'form-select' + case 'select-react': + return '' case 'label': return { 'form-label': true, 'fw-bold': props.required } case 'error': diff --git a/packages/fastui/package.json b/packages/fastui/package.json index 9cf5f34f..1bb82218 100644 --- a/packages/fastui/package.json +++ b/packages/fastui/package.json @@ -9,6 +9,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^9.0.1", + "react-select": "^5.8.0", "react-syntax-highlighter": "^15.5.0", "remark-gfm": "^4.0.0" }, diff --git a/packages/fastui/src/components/FormField.tsx b/packages/fastui/src/components/FormField.tsx index 94c9eeb4..71270958 100644 --- a/packages/fastui/src/components/FormField.tsx +++ b/packages/fastui/src/components/FormField.tsx @@ -1,6 +1,9 @@ import { FC, useState } from 'react' +import AsyncSelect from 'react-select/async' +import Select, { StylesConfig } from 'react-select' import { ClassName, useClassName } from '../hooks/className' +import { debounce, useRequest } from '../tools' interface BaseFormFieldProps { name: string @@ -12,7 +15,12 @@ interface BaseFormFieldProps { className?: ClassName } -export type FormFieldProps = FormFieldInputProps | FormFieldCheckboxProps | FormFieldSelectProps | FormFieldFileProps +export type FormFieldProps = + | FormFieldInputProps + | FormFieldCheckboxProps + | FormFieldFileProps + | FormFieldSelectProps + | FormFieldSelectSearchProps interface FormFieldInputProps extends BaseFormFieldProps { type: 'FormFieldInput' @@ -23,7 +31,6 @@ interface FormFieldInputProps extends BaseFormFieldProps { export const FormFieldInputComp: FC = (props) => { const { name, placeholder, required, htmlType, locked } = props - const [value, setValue] = useState(props.initial ?? '') return (
@@ -31,8 +38,7 @@ export const FormFieldInputComp: FC = (props) => { setValue(e.target.value)} + defaultValue={props.initial} id={inputId(props)} name={name} required={required} @@ -71,62 +77,185 @@ export const FormFieldCheckboxComp: FC = (props) => { ) } -interface FormFieldSelectProps extends BaseFormFieldProps { - type: 'FormFieldSelect' - choices: [string, string][] - initial?: string +interface FormFieldFileProps extends BaseFormFieldProps { + type: 'FormFieldFile' + multiple?: boolean + accept?: string } -export const FormFieldSelectComp: FC = (props) => { - const { name, required, locked, choices } = props - const [value, setValue] = useState(props.initial ?? '') +export const FormFieldFileComp: FC = (props) => { + const { name, required, locked, multiple, accept } = props return (
) } -interface FormFieldFileProps extends BaseFormFieldProps { - type: 'FormFieldFile' - multiple: boolean - accept?: string +interface SelectOption { + value: string + label: string } -export const FormFieldFileComp: FC = (props) => { - const { name, required, locked, multiple, accept } = props +interface SelectGroup { + label: string + options: SelectOption[] +} + +type SelectOptions = SelectOption[] | SelectGroup[] + +// cheat slightly and match bootstrap 😱 +// TODO make this configurable as an argument to `FastUI` +const styles: StylesConfig = { + control: (base) => ({ ...base, borderRadius: '0.375rem', border: '1px solid #dee2e6' }), +} + +interface FormFieldSelectProps extends BaseFormFieldProps { + type: 'FormFieldSelect' + options: SelectOptions + initial?: string + multiple?: boolean + vanilla?: boolean +} + +export const FormFieldSelectComp: FC = (props) => { + const { name, required, locked, options, multiple, initial, vanilla } = props + + const className = useClassName(props) + const classNameSelect = useClassName(props, { el: 'select' }) + const classNameSelectReact = useClassName(props, { el: 'select-react' }) + if (vanilla) { + return ( +
+
+ ) + } else { + return ( +
+
diff --git a/packages/fastui/src/components/ServerLoad.tsx b/packages/fastui/src/components/ServerLoad.tsx index e4b2e495..5564bb36 100644 --- a/packages/fastui/src/components/ServerLoad.tsx +++ b/packages/fastui/src/components/ServerLoad.tsx @@ -2,7 +2,7 @@ import { FC, useContext, useEffect, useState } from 'react' import { ErrorContext } from '../hooks/error' import { ReloadContext } from '../hooks/dev' -import { request } from '../tools' +import { useRequest } from '../tools' import { DefaultLoading } from '../DefaultLoading' import { ConfigContext } from '../hooks/config' @@ -19,9 +19,9 @@ export const ServerLoadComp: FC = ({ url }) => { const { error, setError } = useContext(ErrorContext) const reloadValue = useContext(ReloadContext) const { rootUrl, pathSendMode, Loading } = useContext(ConfigContext) + const request = useRequest() useEffect(() => { - // setViewData(null) let fetchUrl = rootUrl if (pathSendMode === 'query') { fetchUrl += `?path=${encodeURIComponent(url)}` @@ -31,15 +31,12 @@ export const ServerLoadComp: FC = ({ url }) => { const promise = request({ url: fetchUrl }) - promise - .then(([, data]) => setComponentProps(data as FastProps[])) - .catch((e) => { - setError({ title: 'Request Error', description: e.message }) - }) + promise.then(([, data]) => setComponentProps(data as FastProps[])) + return () => { promise.then(() => null) } - }, [rootUrl, pathSendMode, url, setError, reloadValue]) + }, [rootUrl, pathSendMode, url, setError, reloadValue, request]) if (componentProps === null) { if (error) { diff --git a/packages/fastui/src/components/form.tsx b/packages/fastui/src/components/form.tsx index b92818b2..25ae6f48 100644 --- a/packages/fastui/src/components/form.tsx +++ b/packages/fastui/src/components/form.tsx @@ -2,7 +2,7 @@ import { FC, FormEvent, useState } from 'react' import { ClassName, useClassName } from '../hooks/className' import { useFireEvent, AnyEvent } from '../hooks/events' -import { request } from '../tools' +import { useRequest } from '../tools' import { FastProps, AnyCompList } from './index' @@ -37,6 +37,7 @@ export const FormComp: FC = (props) => { const [fieldErrors, setFieldErrors] = useState>({}) const [error, setError] = useState(null) const { fireEvent } = useFireEvent() + const request = useRequest() const onSubmit = async (e: FormEvent) => { e.preventDefault() diff --git a/packages/fastui/src/components/index.tsx b/packages/fastui/src/components/index.tsx index 3f7b61b9..ea9f31bc 100644 --- a/packages/fastui/src/components/index.tsx +++ b/packages/fastui/src/components/index.tsx @@ -17,6 +17,7 @@ import { FormFieldInputComp, FormFieldCheckboxComp, FormFieldSelectComp, + FormFieldSelectSearchComp, FormFieldFileComp, } from './FormField' import { ButtonComp, ButtonProps } from './button' @@ -136,10 +137,12 @@ export const AnyComp: FC = (props) => { return case 'FormFieldCheckbox': return - case 'FormFieldSelect': - return case 'FormFieldFile': return + case 'FormFieldSelect': + return + case 'FormFieldSelectSearch': + return case 'Modal': return case 'Table': diff --git a/packages/fastui/src/tools.ts b/packages/fastui/src/tools.ts index 3e4a2da9..201952d7 100644 --- a/packages/fastui/src/tools.ts +++ b/packages/fastui/src/tools.ts @@ -1,8 +1,29 @@ +import { useCallback, useContext } from 'react' + +import { ErrorContext } from './hooks/error' + +export function useRequest(): (args: Request) => Promise<[number, any]> { + const { setError } = useContext(ErrorContext) + + return useCallback( + async (args: Request) => { + try { + return await request(args) + } catch (e) { + setError({ title: 'Request Error', description: (e as any)?.message }) + throw e + } + }, + [setError], + ) +} + interface Request { url: string method?: 'GET' | 'POST' | 'PUT' | 'DELETE' // defaults to 200 expectedStatus?: number[] + query?: Record json?: Record formData?: FormData headers?: Record @@ -18,10 +39,11 @@ class RequestError extends Error { } } -export async function request({ +async function request({ url, method, headers, + query, json, expectedStatus, formData, @@ -39,6 +61,11 @@ export async function request({ method = method ?? 'POST' } + if (query) { + const searchParams = new URLSearchParams(query) + url = `${url}?${searchParams.toString()}` + } + headers = headers ?? {} if (contentType && !headers['Content-Type']) { headers['Content-Type'] = contentType @@ -99,3 +126,15 @@ function responseOk(response: Response, expectedStatus?: number[]) { export function unreachable(msg: string, unexpectedValue: never, args?: any) { console.warn(msg, { unexpectedValue }, args) } + +type Callable = (...args: any[]) => void + +export function debounce(fn: C, delay: number): C { + let timerId: any + + // @ts-expect-error - functions are contravariant, so this should be fine, no idea how to satisfy TS though + return (...args: any[]) => { + clearTimeout(timerId) + timerId = setTimeout(() => fn(...args), delay) + } +} diff --git a/python/demo/main.py b/python/demo/main.py index 90d6e103..0b3a29d3 100644 --- a/python/demo/main.py +++ b/python/demo/main.py @@ -1,6 +1,7 @@ from __future__ import annotations as _annotations import asyncio +from collections import defaultdict from datetime import date from enum import StrEnum from typing import Annotated, Literal @@ -10,7 +11,8 @@ from fastui import components as c from fastui.display import Display from fastui.events import BackEvent, GoToEvent, PageEvent -from fastui.forms import FormFile, FormResponse, fastui_form +from fastui.forms import FormFile, FormResponse, SelectSearchResponse, fastui_form +from httpx import AsyncClient from pydantic import BaseModel, Field, SecretStr, field_validator from pydantic_core import PydanticCustomError @@ -94,7 +96,6 @@ def read_root() -> list[AnyComponent]: ], open_trigger=PageEvent(name='dynamic-modal'), ), - c.Code(text='print("Hello World")', language='python'), ], ), ] @@ -153,8 +154,9 @@ class ToolEnum(StrEnum): class MyFormModel(BaseModel): name: str = Field(default='foobar', title='Name', min_length=3, description='Your name') - # tool: ToolEnum = Field(json_schema_extra={'enum_display_values': {'hammer': 'Big Hammer'}}) - task: Literal['build', 'destroy'] | None = None + # tool: ToolEnum = Field(json_schema_extra={'enum_labels': {'hammer': 'Big Hammer'}}) + task: Literal['build', 'destroy'] | None = 'build' + tasks: set[Literal['build', 'destroy']] profile_pic: Annotated[UploadFile, FormFile(accept='image/*', max_size=16_000)] # profile_pics: Annotated[list[UploadFile], FormFile(accept='image/*', max_size=400)] # binary: bytes @@ -165,6 +167,8 @@ class MyFormModel(BaseModel): # enabled: bool = False # nested: NestedFormModel password: SecretStr + search: str = Field(json_schema_extra={'search_url': '/api/search'}) + searches: list[str] = Field(json_schema_extra={'search_url': '/api/search'}) @field_validator('name') def name_validator(cls, v: str) -> str: @@ -173,6 +177,29 @@ def name_validator(cls, v: str) -> str: return v +@app.get('/api/search', response_model=SelectSearchResponse) +async def search_view(q: str) -> SelectSearchResponse: + async with AsyncClient() as client: + path_ends = f'name/{q}' if q else 'all' + r = await client.get(f'https://restcountries.com/v3.1/{path_ends}') + if r.status_code == 404: + options = [] + else: + r.raise_for_status() + data = r.json() + if path_ends == 'all': + # if we got all, filter to the 20 most populous countries + data.sort(key=lambda x: x['population'], reverse=True) + data = data[0:20] + data.sort(key=lambda x: x['name']['common']) + + regions = defaultdict(list) + for co in data: + regions[co['region']].append({'value': co['cca3'], 'label': co['name']['common']}) + options = [{'label': k, 'options': v} for k, v in regions.items()] + return SelectSearchResponse(options=options) + + @app.get('/api/form', response_model=FastUI, response_model_exclude_none=True) def form_view() -> list[AnyComponent]: return [ diff --git a/python/fastui/components/__init__.py b/python/fastui/components/__init__.py index 859d4eb5..ec530c02 100644 --- a/python/fastui/components/__init__.py +++ b/python/fastui/components/__init__.py @@ -25,8 +25,6 @@ 'Div', 'Page', 'Heading', - 'Row', - 'Col', 'Button', 'Modal', 'ModelForm', diff --git a/python/fastui/components/forms.py b/python/fastui/components/forms.py index 45a85005..df2affd9 100644 --- a/python/fastui/components/forms.py +++ b/python/fastui/components/forms.py @@ -5,6 +5,7 @@ import pydantic +from .. import forms from . import extra if typing.TYPE_CHECKING: @@ -35,19 +36,30 @@ class FormFieldCheckbox(BaseFormField): type: typing.Literal['FormFieldCheckbox'] = 'FormFieldCheckbox' +class FormFieldFile(BaseFormField): + multiple: bool | None = None + accept: str | None = None + type: typing.Literal['FormFieldFile'] = 'FormFieldFile' + + class FormFieldSelect(BaseFormField): - choices: list[tuple[str, str]] + options: list[forms.SelectOption] | list[forms.SelectGroup] + multiple: bool | None = None initial: str | None = None + vanilla: bool | None = None type: typing.Literal['FormFieldSelect'] = 'FormFieldSelect' -class FormFieldFile(BaseFormField): - multiple: bool = False - accept: str | None = None - type: typing.Literal['FormFieldFile'] = 'FormFieldFile' +class FormFieldSelectSearch(BaseFormField): + search_url: str = pydantic.Field(serialization_alias='searchUrl') + multiple: bool | None = None + initial: forms.SelectOption | None = None + # time in ms to debounce requests by, defaults to 300ms + debounce: int | None = None + type: typing.Literal['FormFieldSelectSearch'] = 'FormFieldSelectSearch' -FormField = FormFieldInput | FormFieldCheckbox | FormFieldSelect | FormFieldFile +FormField = FormFieldInput | FormFieldCheckbox | FormFieldFile | FormFieldSelect | FormFieldSelectSearch class BaseForm(pydantic.BaseModel, ABC, defer_build=True): diff --git a/python/fastui/forms.py b/python/fastui/forms.py index cc292dac..f81a612a 100644 --- a/python/fastui/forms.py +++ b/python/fastui/forms.py @@ -8,12 +8,16 @@ import fastapi import pydantic import pydantic_core +import typing_extensions from pydantic_core import core_schema from starlette import datastructures as ds -from . import events, json_schema +from . import events -__all__ = 'FastUIForm', 'fastui_form', 'FormResponse', 'FormFile' +if typing.TYPE_CHECKING: + from . import json_schema + +__all__ = 'FastUIForm', 'fastui_form', 'FormResponse', 'FormFile', 'SelectSearchResponse', 'SelectOption' FormModel = typing.TypeVar('FormModel', bound=pydantic.BaseModel) @@ -119,12 +123,16 @@ def __get_pydantic_core_schema__(self, source_type: type[typing.Any], *_args) -> raise TypeError(f'FormFile can only be used with `UploadFile` or `list[UploadFile]`, not {source_type}') - def __get_pydantic_json_schema__(self, core_schema_: core_schema.CoreSchema, *_args) -> json_schema.JsonSchemaFile: - function = core_schema_.get('function', {}).get('function') - multiple = bool(function and function.__name__ == 'validate_multiple') - s = json_schema.JsonSchemaFile(type='string', format='binary', multiple=multiple) + def __get_pydantic_json_schema__(self, core_schema_: core_schema.CoreSchema, *_args) -> json_schema.JsonSchemaAny: + from . import json_schema + + s = json_schema.JsonSchemaFile(type='string', format='binary') if self.accept: s['accept'] = self.accept + + function = core_schema_.get('function', {}).get('function') + if function and function.__name__ == 'validate_multiple': + s = json_schema.JsonSchemaArray(type='array', items=s) return s def __repr__(self): @@ -136,6 +144,20 @@ class FormResponse(pydantic.BaseModel): type: typing.Literal['FormResponse'] = 'FormResponse' +class SelectOption(typing_extensions.TypedDict): + value: str + label: str + + +class SelectGroup(typing_extensions.TypedDict): + label: str + options: list[SelectOption] + + +class SelectSearchResponse(pydantic.BaseModel): + options: list[SelectOption] | list[SelectGroup] + + NestedDict: typing.TypeAlias = 'dict[str | int, NestedDict | str | list[str] | ds.UploadFile | list[ds.UploadFile]]' diff --git a/python/fastui/json_schema.py b/python/fastui/json_schema.py index 768f6643..f3f2eb9d 100644 --- a/python/fastui/json_schema.py +++ b/python/fastui/json_schema.py @@ -13,9 +13,15 @@ FormFieldFile, FormFieldInput, FormFieldSelect, + FormFieldSelectSearch, InputHtmlType, ) +if typing.TYPE_CHECKING: + from .forms import SelectOption +else: + SelectOption = dict + __all__ = 'model_json_schema_to_fields', 'SchemeLocation' @@ -41,20 +47,25 @@ class JsonSchemaBase(TypedDict, total=False): class JsonSchemaString(JsonSchemaBase): type: Required[Literal['string']] default: str - format: Literal['date', 'date-time', 'time', 'email', 'uri', 'uuid'] + format: Literal['date', 'date-time', 'time', 'email', 'uri', 'uuid', 'password'] class JsonSchemaStringEnum(JsonSchemaBase, total=False): type: Required[Literal['string']] enum: Required[list[str]] default: str - enum_display_values: dict[str, str] + enum_labels: dict[str, str] + + +class JsonSchemaStringSearch(JsonSchemaBase, total=False): + type: Required[Literal['string']] + search_url: Required[str] + initial: SelectOption class JsonSchemaFile(JsonSchemaBase, total=False): type: Required[Literal['string']] format: Required[Literal['binary']] - multiple: bool accept: str @@ -85,10 +96,12 @@ class JsonSchemaNumber(JsonSchemaBase, total=False): class JsonSchemaArray(JsonSchemaBase, total=False): type: Required[Literal['array']] + uniqueItems: bool minItems: int maxItems: int prefixItems: list[JsonSchemaAny] items: JsonSchemaAny + search_url: str JsonSchemaDefs = dict[str, JsonSchemaConcrete] @@ -136,16 +149,11 @@ def json_schema_any_to_fields( schema: JsonSchemaAny, loc: SchemeLocation, title: list[str], required: bool, defs: JsonSchemaDefs ) -> Iterable[FormField]: schema, required = deference_json_schema(schema, defs, required) + title = title + [schema.get('title') or loc_to_title(loc)] + if schema_is_field(schema): yield json_schema_field_to_field(schema, loc, title, required) - return - - if schema_title := schema.get('title'): - title = title + [schema_title] - elif loc: - title = title + [loc_to_title(loc)] - - if schema_is_array(schema): + elif schema_is_array(schema): yield from json_schema_array_to_fields(schema, loc, title, required, defs) else: assert schema_is_object(schema), f'Unexpected schema type {schema}' @@ -157,7 +165,6 @@ def json_schema_field_to_field( schema: JsonSchemaField, loc: SchemeLocation, title: list[str], required: bool ) -> FormField: name = loc_to_name(loc) - title = title + [schema.get('title') or loc_to_title(loc)] if schema['type'] == 'boolean': return FormFieldCheckbox( name=name, @@ -166,25 +173,8 @@ def json_schema_field_to_field( initial=schema.get('default'), description=schema.get('description'), ) - elif schema['type'] == 'string' and (enum := schema.get('enum')): - enum_display_values = schema.get('enum_display_values', {}) - return FormFieldSelect( - name=name, - title=title, - required=required, - choices=[(v, enum_display_values.get(v) or as_title(v)) for v in enum], - initial=schema.get('default'), - description=schema.get('description'), - ) - elif schema['type'] == 'string' and schema.get('format') == 'binary': - return FormFieldFile( - name=name, - title=title, - required=required, - multiple=schema.get('multiple', False), - accept=schema.get('accept'), - description=schema.get('description'), - ) + elif field := special_string_field(schema, name, title, required, False): + return field else: return FormFieldInput( name=name, @@ -202,10 +192,58 @@ def loc_to_title(loc: SchemeLocation) -> str: def json_schema_array_to_fields( schema: JsonSchemaArray, loc: SchemeLocation, title: list[str], required: bool, defs: JsonSchemaDefs -) -> list[FormField]: +) -> Iterable[FormField]: + items_schema = schema.get('items') + if items_schema: + items_schema, required = deference_json_schema(items_schema, defs, required) + if search_url := schema.get('search_url'): + items_schema['search_url'] = search_url # type: ignore + if field := special_string_field(items_schema, loc_to_name(loc), title, required, True): + return [field] raise NotImplementedError('todo') +def special_string_field( + schema: JsonSchemaConcrete, name: str, title: list[str], required: bool, multiple: bool +) -> FormField | None: + if schema['type'] == 'string': + if schema.get('format') == 'binary': + return FormFieldFile( + name=name, + title=title, + required=required, + multiple=multiple, + accept=schema.get('accept'), + description=schema.get('description'), + ) + elif enum := schema.get('enum'): + enum_labels = schema.get('enum_labels', {}) + return FormFieldSelect( + name=name, + title=title, + required=required, + multiple=multiple, + options=[SelectOption(value=v, label=enum_labels.get(v) or as_title(v)) for v in enum], + initial=schema.get('default'), + description=schema.get('description'), + ) + elif search_url := schema.get('search_url'): + return FormFieldSelectSearch( + search_url=search_url, + name=name, + title=title, + required=required, + multiple=multiple, + initial=schema.get('initial'), + description=schema.get('description'), + ) + + +def select_options(schema: JsonSchemaStringEnum) -> list[SelectOption]: + enum_labels = schema.get('enum_labels', {}) + return [SelectOption(value=v, label=enum_labels.get(v) or as_title(v)) for v in schema['enum']] + + def loc_to_name(loc: SchemeLocation) -> str: """ Convert a loc to a string if any item contains a '.' or the first item starts with '[' then encode with JSON, @@ -238,6 +276,11 @@ def deference_json_schema( if len(any_of) == 2 and sum(s.get('type') == 'null' for s in any_of) == 1: # If anyOf is a single type and null, then it is optional not_null_schema = next(s for s in any_of if s.get('type') != 'null') + + # is there anything else apart from `default` we need to copy over? + if default := schema.get('default'): + not_null_schema['default'] = default # type: ignore + return deference_json_schema(not_null_schema, defs, False) else: raise NotImplementedError('`anyOf` schemas which are not simply `X | None` are not yet supported')