From 0c9ec38ba69066c2f344d23584a2f1a018350afd Mon Sep 17 00:00:00 2001 From: Alex Cottner Date: Fri, 27 Oct 2023 14:21:52 -0600 Subject: [PATCH 1/9] Implemented rjsf package for our forms. Fixed immediate styling issues and test failures. Started converting to functional components. --- css/styles.css | 13 +- jest.config.js | 1 + package-lock.json | 748 ++++++++++++------ package.json | 13 +- src/components/AuthForm.tsx | 323 ++------ src/components/BaseForm.tsx | 33 +- src/components/ServerHistory.tsx | 111 +++ .../collection/JSONCollectionForm.tsx | 106 ++- test/components/AuthForm_test.js | 70 +- test/setup-tests.js | 10 +- 10 files changed, 839 insertions(+), 589 deletions(-) create mode 100644 src/components/ServerHistory.tsx diff --git a/css/styles.css b/css/styles.css index 08b243f1c..88866fe8e 100644 --- a/css/styles.css +++ b/css/styles.css @@ -539,6 +539,17 @@ i.fa:before { font-weight: 700; } +/* we have some very tall rows, aligning center makes the buttons hard to locate */ +.rjsf .row .align-items-center { + align-items: flex-start !important; + margin-top: .4em; + border-bottom: 1px solid #eee; +} + +.rjsf .row button.btn { + font-size: 14pt; +} + .modal { background-color: rgba(0, 0, 0, 0.3); } @@ -602,4 +613,4 @@ i.fa:before { position: absolute; top: 152px; right: 30px; -} \ No newline at end of file +} diff --git a/jest.config.js b/jest.config.js index a3b751525..7a1f4021f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,6 +2,7 @@ module.exports = { moduleNameMapper: { "\\.(css|less)$": "/test/__mocks__/styleMock.js", "\\.svg": "/test/__mocks__/svgrMock.js", + "^nanoid$": "@rjsf/core/node_modules/nanoid/index.browser.cjs", // to avoid jest/es6 issue with @rjsf dependency }, setupFilesAfterEnv: ["./test/setup-tests.js"], testEnvironment: "jest-environment-jsdom", diff --git a/package-lock.json b/package-lock.json index d301293ae..71b66a5ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,12 @@ "@babel/polyfill": "^7.0.0", "@babel/runtime": "^7.0.0", "@reduxjs/toolkit": "^1.8.1", + "@rjsf/bootstrap-4": "^5.13.2", + "@rjsf/core": "^5.13.2", + "@rjsf/validator-ajv8": "^5.13.2", "atob": "^2.0.3", - "bootstrap": "^4.6.0", - "bootstrap-icons": "^1.0.0", + "bootstrap": "^4.6.2", + "bootstrap-icons": "^1.11.1", "btoa": "^1.1.2", "codemirror": "^5.39.2", "diff": "^5.0.0", @@ -22,12 +25,11 @@ "filesize": "^10.0.7", "history": "^4.10.1", "html-webpack-plugin": "^5.5.0", - "kinto-admin-form": "0.0.0-experimental-8183a9c80", "kinto-http": "^5.1.1", "lodash": "^4.17.21", "react": "^17.0.2", - "react-bootstrap": "^1.3.0", - "react-bootstrap-icons": "^1.1.0", + "react-bootstrap": "^1.6.7", + "react-bootstrap-icons": "^1.10.3", "react-bs-notifier": "^6.0.0", "react-codemirror2": "^7.0.0", "react-dom": "^17.0.2", @@ -53,6 +55,7 @@ "@types/react-router-dom": "^5.3.0", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", + "babel-jest": "^29.7.0", "babel-loader": "^9.1.0", "chai": "^4.1.2", "cross-env": "^7.0.2", @@ -2010,15 +2013,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/runtime-corejs2": { - "version": "7.11.2", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.11.2.tgz", - "integrity": "sha512-AC/ciV28adSSpEkBglONBWq4/Lvm6GAZuxIoyVtsnUpZMl0bxLtoChEnYAkP+47KyOCayZanojtflUEUJtR/6Q==", - "dependencies": { - "core-js": "^2.6.5", - "regenerator-runtime": "^0.13.4" - } - }, "node_modules/@babel/runtime/node_modules/regenerator-runtime": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", @@ -3222,14 +3216,22 @@ } }, "node_modules/@popperjs/core": { - "version": "2.10.2", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.10.2.tgz", - "integrity": "sha512-IXf3XA7+XyN7CP9gGh/XB0UxVMlvARGEgGXLubFICsUMGz6Q+DU+i4gGlpOxTjKvXjkJDJC8YdqdKkDj9qZHEQ==", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" } }, + "node_modules/@react-icons/all-files": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@react-icons/all-files/-/all-files-4.1.0.tgz", + "integrity": "sha512-hxBI2UOuVaI3O/BhQfhtb4kcGn9ft12RWAFVMUeNjqqhLsHvFtzIkFaptBJpFDANTKoDfdVoHTKZDlwKCACbMQ==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/@redux-saga/core": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.2.3.tgz", @@ -3309,6 +3311,132 @@ "resolved": "https://registry.npmjs.org/@restart/context/-/context-2.1.4.tgz", "integrity": "sha512-INJYZQJP7g+IoDUh/475NlGiTeMfwTXUEr3tmRneckHIxNolGOW9CTq83S8cxq0CgJwwcMzMJFchxvlwe7Rk8Q==" }, + "node_modules/@restart/hooks": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.11.tgz", + "integrity": "sha512-Ft/ncTULZN6ldGHiF/k5qt72O8JyRMOeg0tApvCni8LkoiEahO+z3TNxfXIVGy890YtWVDvJAl662dVJSJXvMw==", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@rjsf/bootstrap-4": { + "version": "5.13.2", + "resolved": "https://registry.npmjs.org/@rjsf/bootstrap-4/-/bootstrap-4-5.13.2.tgz", + "integrity": "sha512-NPoix1PhoB6M+MX1gQt4q/QCcYr+VXqEiIH9GOM6syyoRyurOqrdaVOGJ/ynuzWsTwlUrPPL/Lyq7WaZJLmEyQ==", + "dependencies": { + "@react-icons/all-files": "^4.1.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@rjsf/core": "^5.12.x", + "@rjsf/utils": "^5.12.x", + "react": "^16.14.0 || >=17", + "react-bootstrap": "^1.6.5" + } + }, + "node_modules/@rjsf/core": { + "version": "5.13.2", + "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-5.13.2.tgz", + "integrity": "sha512-Zn6BCTk0v8iVV2rKOYa2F34Q2CMYFgindAnLFmZEGalwC35hP93L68q3cfhbHoHmtUdkjAyUaa2vua6LpgzfQw==", + "dependencies": { + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "markdown-to-jsx": "^7.3.2", + "nanoid": "^3.3.6", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@rjsf/utils": "^5.12.x", + "react": "^16.14.0 || >=17" + } + }, + "node_modules/@rjsf/core/node_modules/nanoid": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/@rjsf/utils": { + "version": "5.13.2", + "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-5.13.2.tgz", + "integrity": "sha512-iluvCOQDwZkp66RRZdIV3TgJnI+I2xv7Ks+UVqjO9lG8U9ygoWuH85zGPAojqapJUuC+frVRZsEBsTwGegDWIw==", + "peer": true, + "dependencies": { + "json-schema-merge-allof": "^0.8.1", + "jsonpointer": "^5.0.1", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "react-is": "^18.2.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.14.0 || >=17" + } + }, + "node_modules/@rjsf/utils/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "peer": true + }, + "node_modules/@rjsf/validator-ajv8": { + "version": "5.13.2", + "resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-5.13.2.tgz", + "integrity": "sha512-QWLSBUoADss4u9WyFy9DLJMHJ5qhOA5vmuWjauQ0tzsV2bgdaMMfj+VKMUHJ3aQOX7jM8X7PoL1qghM7odf+FA==", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^2.1.1", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@rjsf/utils": "^5.12.x" + } + }, + "node_modules/@rjsf/validator-ajv8/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@rjsf/validator-ajv8/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -4198,9 +4326,9 @@ "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" }, "node_modules/@types/warning": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz", - "integrity": "sha1-DSUBJorY+ZYrdA04fEZU9fjiPlI=" + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.2.tgz", + "integrity": "sha512-S/2+OjBIcBl8Kur23YLe0hG1e7J5m2bHfB4UuMNoLZjIFhQWhTf1FeS+WFoXHUC6QsCEfk4pftj4J1KIKC1glA==" }, "node_modules/@types/yargs": { "version": "17.0.10", @@ -4993,7 +5121,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, "dependencies": { "ajv": "^8.0.0" }, @@ -5010,7 +5137,6 @@ "version": "8.8.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.8.2.tgz", "integrity": "sha512-x9VuX+R/jcFj1DHo/fCp99esgGDWiHENrKxaCENuCxpoMCmAt/COCGVDwA7kleEpEzJjDnvh3yGoOuLu0Dtllw==", - "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -5025,8 +5151,7 @@ "node_modules/ajv-formats/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, "node_modules/ansi-escapes": { "version": "4.3.2", @@ -6131,6 +6256,29 @@ "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", "dev": true }, + "node_modules/compute-gcd": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/compute-gcd/-/compute-gcd-1.2.1.tgz", + "integrity": "sha512-TwMbxBNz0l71+8Sc4czv13h4kEqnchV9igQZBi6QUaz09dnz13juGnnaWWJTRsP3brxOoxeB4SA2WELLw1hCtg==", + "peer": true, + "dependencies": { + "validate.io-array": "^1.0.3", + "validate.io-function": "^1.0.2", + "validate.io-integer-array": "^1.0.0" + } + }, + "node_modules/compute-lcm": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/compute-lcm/-/compute-lcm-1.1.2.tgz", + "integrity": "sha512-OFNPdQAXnQhDSKioX8/XYT6sdUlXwpeMjfd6ApxMJfyZ4GxmLR1xvMERctlYhlHwIiz6CSpBc2+qYKjHGZw4TQ==", + "peer": true, + "dependencies": { + "compute-gcd": "^1.2.1", + "validate.io-array": "^1.0.3", + "validate.io-function": "^1.0.2", + "validate.io-integer-array": "^1.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6837,9 +6985,9 @@ } }, "node_modules/dom-helpers": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.0.tgz", - "integrity": "sha512-Ru5o9+V8CpunKnz5LGgWXkmrH/20cGKwcHwS4m73zIvs54CN9epEmT/HLqFJW3kXpakAFkEdzgy1hzlJe3E4OQ==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" @@ -11424,6 +11572,29 @@ "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-compare": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/json-schema-compare/-/json-schema-compare-0.2.2.tgz", + "integrity": "sha512-c4WYmDKyJXhs7WWvAWm3uIYnfyWFoIp+JEoX34rctVvEkMYCPGhXtvmFFXiffBbxfZsvQ0RNnV5H7GvDF5HCqQ==", + "peer": true, + "dependencies": { + "lodash": "^4.17.4" + } + }, + "node_modules/json-schema-merge-allof": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/json-schema-merge-allof/-/json-schema-merge-allof-0.8.1.tgz", + "integrity": "sha512-CTUKmIlPJbsWfzRRnOXz+0MjIqvnleIXwFTzz+t9T86HnYX/Rozria6ZVGLktAU9e+NygNljveP+yxqtQp/Q4w==", + "peer": true, + "dependencies": { + "compute-lcm": "^1.1.2", + "json-schema-compare": "^0.2.2", + "lodash": "^4.17.20" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -11459,6 +11630,15 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/jss": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/jss/-/jss-10.4.0.tgz", @@ -11626,25 +11806,6 @@ "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", "dev": true }, - "node_modules/kinto-admin-form": { - "version": "0.0.0-experimental-8183a9c80", - "resolved": "https://registry.npmjs.org/kinto-admin-form/-/kinto-admin-form-0.0.0-experimental-8183a9c80.tgz", - "integrity": "sha512-oqLByraZLuiGoxPi5k8VmMz5dQs2EozahltlEyfrmgrBOjrzVJ9VrlldkcvJ1jYxM9ett6daJbKnAndatw5DWg==", - "dependencies": { - "@babel/runtime-corejs2": "^7.4.5", - "ajv": "^6.7.0", - "core-js": "^2.5.7", - "lodash": "^4.17.15", - "prop-types": "^15.5.8", - "react-is": "^16.8.4", - "react-lifecycles-compat": "^3.0.4", - "shortid": "^2.2.14" - }, - "engines": { - "node": ">=6", - "npm": ">=2.14.7" - } - }, "node_modules/kinto-http": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/kinto-http/-/kinto-http-5.3.0.tgz", @@ -11768,6 +11929,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "node_modules/lodash.assign": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz", @@ -11879,6 +12045,17 @@ "tmpl": "1.0.5" } }, + "node_modules/markdown-to-jsx": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.3.2.tgz", + "integrity": "sha512-B+28F5ucp83aQm+OxNrPkS8z0tMKaeHiy0lHJs3LqCyDQFtWuenaIrkaVTgAm1pf1AU85LXltva86hlaT17i8Q==", + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, "node_modules/marked": { "version": "0.3.19", "resolved": "https://registry.npmjs.org/marked/-/marked-0.3.19.tgz", @@ -12117,11 +12294,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, - "node_modules/nanoid": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz", - "integrity": "sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA==" - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -13044,13 +13216,13 @@ } }, "node_modules/react-bootstrap": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.6.4.tgz", - "integrity": "sha512-z3BhBD4bEZuLP8VrYqAD7OT7axdcSkkyvWBWnS2U/4MhyabUihrUyucPWkan7aMI1XIHbmH4LCpEtzWGfx/yfA==", + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.6.7.tgz", + "integrity": "sha512-IzCYXuLSKDEjGFglbFWk0/iHmdhdcJzTmtS6lXxc0kaNFx2PFgrQf5jKnx5sarF2tiXh9Tgx3pSt3pdK7YwkMA==", "dependencies": { "@babel/runtime": "^7.14.0", "@restart/context": "^2.1.4", - "@restart/hooks": "^0.3.26", + "@restart/hooks": "^0.4.7", "@types/invariant": "^2.2.33", "@types/prop-types": "^15.7.3", "@types/react": ">=16.14.8", @@ -13061,7 +13233,7 @@ "invariant": "^2.2.4", "prop-types": "^15.7.2", "prop-types-extra": "^1.1.0", - "react-overlays": "^5.1.1", + "react-overlays": "^5.1.2", "react-transition-group": "^4.4.1", "uncontrollable": "^7.2.1", "warning": "^4.0.3" @@ -13082,59 +13254,6 @@ "react": ">=16.8.6" } }, - "node_modules/react-bootstrap/node_modules/@restart/hooks": { - "version": "0.3.27", - "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.3.27.tgz", - "integrity": "sha512-s984xV/EapUIfkjlf8wz9weP2O9TNKR96C68FfMEy2bE69+H4cNv3RD4Mf97lW7Htt7PjZrYTjSC8f3SB9VCXw==", - "dependencies": { - "dequal": "^2.0.2" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/react-bootstrap/node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, - "node_modules/react-bootstrap/node_modules/react-overlays": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.1.1.tgz", - "integrity": "sha512-eCN2s2/+GVZzpnId4XVWtvDPYYBD2EtOGP74hE+8yDskPzFy9+pV1H3ZZihxuRdEbQzzacySaaDkR7xE0ydl4Q==", - "dependencies": { - "@babel/runtime": "^7.13.8", - "@popperjs/core": "^2.8.6", - "@restart/hooks": "^0.3.26", - "@types/warning": "^3.0.0", - "dom-helpers": "^5.2.0", - "prop-types": "^15.7.2", - "uncontrollable": "^7.2.1", - "warning": "^4.0.3" - }, - "peerDependencies": { - "react": ">=16.3.0", - "react-dom": ">=16.3.0" - } - }, - "node_modules/react-bootstrap/node_modules/uncontrollable": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", - "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", - "dependencies": { - "@babel/runtime": "^7.6.3", - "@types/react": ">=16.9.11", - "invariant": "^2.2.4", - "react-lifecycles-compat": "^3.0.4" - }, - "peerDependencies": { - "react": ">=15.0.0" - } - }, "node_modules/react-bs-notifier": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/react-bs-notifier/-/react-bs-notifier-6.0.0.tgz", @@ -13210,6 +13329,25 @@ "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" }, + "node_modules/react-overlays": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.1.tgz", + "integrity": "sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA==", + "dependencies": { + "@babel/runtime": "^7.13.8", + "@popperjs/core": "^2.11.6", + "@restart/hooks": "^0.4.7", + "@types/warning": "^3.0.0", + "dom-helpers": "^5.2.0", + "prop-types": "^15.7.2", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + } + }, "node_modules/react-redux": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz", @@ -13598,7 +13736,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -13984,14 +14121,6 @@ "node": ">=4" } }, - "node_modules/shortid": { - "version": "2.2.16", - "resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.16.tgz", - "integrity": "sha512-Ugt+GIZqvGXCIItnsL+lvFJOiN7RYqlGy7QE41O3YC1xbNSeDGIRO7xg2JJXIAj1cAGnOeC1r7/T9pgrtQbv4g==", - "dependencies": { - "nanoid": "^2.1.0" - } - }, "node_modules/shx": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/shx/-/shx-0.3.4.tgz", @@ -15041,6 +15170,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "dependencies": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", @@ -15223,6 +15366,43 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/validate.io-array": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/validate.io-array/-/validate.io-array-1.0.6.tgz", + "integrity": "sha512-DeOy7CnPEziggrOO5CZhVKJw6S3Yi7e9e65R1Nl/RTN1vTQKnzjfvks0/8kQ40FP/dsjRAOd4hxmJ7uLa6vxkg==", + "peer": true + }, + "node_modules/validate.io-function": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/validate.io-function/-/validate.io-function-1.0.2.tgz", + "integrity": "sha512-LlFybRJEriSuBnUhQyG5bwglhh50EpTL2ul23MPIuR1odjO7XaMLFV8vHGwp7AZciFxtYOeiSCT5st+XSPONiQ==", + "peer": true + }, + "node_modules/validate.io-integer": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/validate.io-integer/-/validate.io-integer-1.0.5.tgz", + "integrity": "sha512-22izsYSLojN/P6bppBqhgUDjCkr5RY2jd+N2a3DCAUey8ydvrZ/OkGvFPR7qfOpwR2LC5p4Ngzxz36g5Vgr/hQ==", + "peer": true, + "dependencies": { + "validate.io-number": "^1.0.3" + } + }, + "node_modules/validate.io-integer-array": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/validate.io-integer-array/-/validate.io-integer-array-1.0.0.tgz", + "integrity": "sha512-mTrMk/1ytQHtCY0oNO3dztafHYyGU88KL+jRxWuzfOmQb+4qqnWmI+gykvGp8usKZOM0H7keJHEbRaFiYA0VrA==", + "peer": true, + "dependencies": { + "validate.io-array": "^1.0.3", + "validate.io-integer": "^1.0.4" + } + }, + "node_modules/validate.io-number": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/validate.io-number/-/validate.io-number-1.0.3.tgz", + "integrity": "sha512-kRAyotcbNaSYoDnXvb4MHg/0a1egJdLwS6oJ38TJY7aw9n93Fl/3blIXdyYvPOp55CNxywooG/3BcrwNrBpcSg==", + "peer": true + }, "node_modules/value-equal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", @@ -17349,15 +17529,6 @@ } } }, - "@babel/runtime-corejs2": { - "version": "7.11.2", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.11.2.tgz", - "integrity": "sha512-AC/ciV28adSSpEkBglONBWq4/Lvm6GAZuxIoyVtsnUpZMl0bxLtoChEnYAkP+47KyOCayZanojtflUEUJtR/6Q==", - "requires": { - "core-js": "^2.6.5", - "regenerator-runtime": "^0.13.4" - } - }, "@babel/template": { "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", @@ -18260,9 +18431,15 @@ "optional": true }, "@popperjs/core": { - "version": "2.10.2", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.10.2.tgz", - "integrity": "sha512-IXf3XA7+XyN7CP9gGh/XB0UxVMlvARGEgGXLubFICsUMGz6Q+DU+i4gGlpOxTjKvXjkJDJC8YdqdKkDj9qZHEQ==" + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==" + }, + "@react-icons/all-files": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@react-icons/all-files/-/all-files-4.1.0.tgz", + "integrity": "sha512-hxBI2UOuVaI3O/BhQfhtb4kcGn9ft12RWAFVMUeNjqqhLsHvFtzIkFaptBJpFDANTKoDfdVoHTKZDlwKCACbMQ==", + "requires": {} }, "@redux-saga/core": { "version": "1.2.3", @@ -18327,6 +18504,91 @@ "resolved": "https://registry.npmjs.org/@restart/context/-/context-2.1.4.tgz", "integrity": "sha512-INJYZQJP7g+IoDUh/475NlGiTeMfwTXUEr3tmRneckHIxNolGOW9CTq83S8cxq0CgJwwcMzMJFchxvlwe7Rk8Q==" }, + "@restart/hooks": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.11.tgz", + "integrity": "sha512-Ft/ncTULZN6ldGHiF/k5qt72O8JyRMOeg0tApvCni8LkoiEahO+z3TNxfXIVGy890YtWVDvJAl662dVJSJXvMw==", + "requires": { + "dequal": "^2.0.3" + } + }, + "@rjsf/bootstrap-4": { + "version": "5.13.2", + "resolved": "https://registry.npmjs.org/@rjsf/bootstrap-4/-/bootstrap-4-5.13.2.tgz", + "integrity": "sha512-NPoix1PhoB6M+MX1gQt4q/QCcYr+VXqEiIH9GOM6syyoRyurOqrdaVOGJ/ynuzWsTwlUrPPL/Lyq7WaZJLmEyQ==", + "requires": { + "@react-icons/all-files": "^4.1.0" + } + }, + "@rjsf/core": { + "version": "5.13.2", + "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-5.13.2.tgz", + "integrity": "sha512-Zn6BCTk0v8iVV2rKOYa2F34Q2CMYFgindAnLFmZEGalwC35hP93L68q3cfhbHoHmtUdkjAyUaa2vua6LpgzfQw==", + "requires": { + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "markdown-to-jsx": "^7.3.2", + "nanoid": "^3.3.6", + "prop-types": "^15.8.1" + }, + "dependencies": { + "nanoid": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==" + } + } + }, + "@rjsf/utils": { + "version": "5.13.2", + "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-5.13.2.tgz", + "integrity": "sha512-iluvCOQDwZkp66RRZdIV3TgJnI+I2xv7Ks+UVqjO9lG8U9ygoWuH85zGPAojqapJUuC+frVRZsEBsTwGegDWIw==", + "peer": true, + "requires": { + "json-schema-merge-allof": "^0.8.1", + "jsonpointer": "^5.0.1", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "react-is": "^18.2.0" + }, + "dependencies": { + "react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "peer": true + } + } + }, + "@rjsf/validator-ajv8": { + "version": "5.13.2", + "resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-5.13.2.tgz", + "integrity": "sha512-QWLSBUoADss4u9WyFy9DLJMHJ5qhOA5vmuWjauQ0tzsV2bgdaMMfj+VKMUHJ3aQOX7jM8X7PoL1qghM7odf+FA==", + "requires": { + "ajv": "^8.12.0", + "ajv-formats": "^2.1.1", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21" + }, + "dependencies": { + "ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + } + } + }, "@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -18995,9 +19257,9 @@ "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" }, "@types/warning": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz", - "integrity": "sha1-DSUBJorY+ZYrdA04fEZU9fjiPlI=" + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.2.tgz", + "integrity": "sha512-S/2+OjBIcBl8Kur23YLe0hG1e7J5m2bHfB4UuMNoLZjIFhQWhTf1FeS+WFoXHUC6QsCEfk4pftj4J1KIKC1glA==" }, "@types/yargs": { "version": "17.0.10", @@ -19552,7 +19814,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, "requires": { "ajv": "^8.0.0" }, @@ -19561,7 +19822,6 @@ "version": "8.8.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.8.2.tgz", "integrity": "sha512-x9VuX+R/jcFj1DHo/fCp99esgGDWiHENrKxaCENuCxpoMCmAt/COCGVDwA7kleEpEzJjDnvh3yGoOuLu0Dtllw==", - "dev": true, "requires": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -19572,8 +19832,7 @@ "json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" } } }, @@ -20383,6 +20642,29 @@ "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", "dev": true }, + "compute-gcd": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/compute-gcd/-/compute-gcd-1.2.1.tgz", + "integrity": "sha512-TwMbxBNz0l71+8Sc4czv13h4kEqnchV9igQZBi6QUaz09dnz13juGnnaWWJTRsP3brxOoxeB4SA2WELLw1hCtg==", + "peer": true, + "requires": { + "validate.io-array": "^1.0.3", + "validate.io-function": "^1.0.2", + "validate.io-integer-array": "^1.0.0" + } + }, + "compute-lcm": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/compute-lcm/-/compute-lcm-1.1.2.tgz", + "integrity": "sha512-OFNPdQAXnQhDSKioX8/XYT6sdUlXwpeMjfd6ApxMJfyZ4GxmLR1xvMERctlYhlHwIiz6CSpBc2+qYKjHGZw4TQ==", + "peer": true, + "requires": { + "compute-gcd": "^1.2.1", + "validate.io-array": "^1.0.3", + "validate.io-function": "^1.0.2", + "validate.io-integer-array": "^1.0.0" + } + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -20914,9 +21196,9 @@ } }, "dom-helpers": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.0.tgz", - "integrity": "sha512-Ru5o9+V8CpunKnz5LGgWXkmrH/20cGKwcHwS4m73zIvs54CN9epEmT/HLqFJW3kXpakAFkEdzgy1hzlJe3E4OQ==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", "requires": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" @@ -24303,6 +24585,26 @@ "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==" }, + "json-schema-compare": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/json-schema-compare/-/json-schema-compare-0.2.2.tgz", + "integrity": "sha512-c4WYmDKyJXhs7WWvAWm3uIYnfyWFoIp+JEoX34rctVvEkMYCPGhXtvmFFXiffBbxfZsvQ0RNnV5H7GvDF5HCqQ==", + "peer": true, + "requires": { + "lodash": "^4.17.4" + } + }, + "json-schema-merge-allof": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/json-schema-merge-allof/-/json-schema-merge-allof-0.8.1.tgz", + "integrity": "sha512-CTUKmIlPJbsWfzRRnOXz+0MjIqvnleIXwFTzz+t9T86HnYX/Rozria6ZVGLktAU9e+NygNljveP+yxqtQp/Q4w==", + "peer": true, + "requires": { + "compute-lcm": "^1.1.2", + "json-schema-compare": "^0.2.2", + "lodash": "^4.17.20" + } + }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -24330,6 +24632,12 @@ "universalify": "^2.0.0" } }, + "jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "peer": true + }, "jss": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/jss/-/jss-10.4.0.tgz", @@ -24494,21 +24802,6 @@ "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", "dev": true }, - "kinto-admin-form": { - "version": "0.0.0-experimental-8183a9c80", - "resolved": "https://registry.npmjs.org/kinto-admin-form/-/kinto-admin-form-0.0.0-experimental-8183a9c80.tgz", - "integrity": "sha512-oqLByraZLuiGoxPi5k8VmMz5dQs2EozahltlEyfrmgrBOjrzVJ9VrlldkcvJ1jYxM9ett6daJbKnAndatw5DWg==", - "requires": { - "@babel/runtime-corejs2": "^7.4.5", - "ajv": "^6.7.0", - "core-js": "^2.5.7", - "lodash": "^4.17.15", - "prop-types": "^15.5.8", - "react-is": "^16.8.4", - "react-lifecycles-compat": "^3.0.4", - "shortid": "^2.2.14" - } - }, "kinto-http": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/kinto-http/-/kinto-http-5.3.0.tgz", @@ -24604,6 +24897,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "lodash.assign": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz", @@ -24701,6 +24999,12 @@ "tmpl": "1.0.5" } }, + "markdown-to-jsx": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.3.2.tgz", + "integrity": "sha512-B+28F5ucp83aQm+OxNrPkS8z0tMKaeHiy0lHJs3LqCyDQFtWuenaIrkaVTgAm1pf1AU85LXltva86hlaT17i8Q==", + "requires": {} + }, "marked": { "version": "0.3.19", "resolved": "https://registry.npmjs.org/marked/-/marked-0.3.19.tgz", @@ -24872,11 +25176,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, - "nanoid": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz", - "integrity": "sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA==" - }, "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -25557,13 +25856,13 @@ } }, "react-bootstrap": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.6.4.tgz", - "integrity": "sha512-z3BhBD4bEZuLP8VrYqAD7OT7axdcSkkyvWBWnS2U/4MhyabUihrUyucPWkan7aMI1XIHbmH4LCpEtzWGfx/yfA==", + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.6.7.tgz", + "integrity": "sha512-IzCYXuLSKDEjGFglbFWk0/iHmdhdcJzTmtS6lXxc0kaNFx2PFgrQf5jKnx5sarF2tiXh9Tgx3pSt3pdK7YwkMA==", "requires": { "@babel/runtime": "^7.14.0", "@restart/context": "^2.1.4", - "@restart/hooks": "^0.3.26", + "@restart/hooks": "^0.4.7", "@types/invariant": "^2.2.33", "@types/prop-types": "^15.7.3", "@types/react": ">=16.14.8", @@ -25574,55 +25873,10 @@ "invariant": "^2.2.4", "prop-types": "^15.7.2", "prop-types-extra": "^1.1.0", - "react-overlays": "^5.1.1", + "react-overlays": "^5.1.2", "react-transition-group": "^4.4.1", "uncontrollable": "^7.2.1", "warning": "^4.0.3" - }, - "dependencies": { - "@restart/hooks": { - "version": "0.3.27", - "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.3.27.tgz", - "integrity": "sha512-s984xV/EapUIfkjlf8wz9weP2O9TNKR96C68FfMEy2bE69+H4cNv3RD4Mf97lW7Htt7PjZrYTjSC8f3SB9VCXw==", - "requires": { - "dequal": "^2.0.2" - } - }, - "dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "requires": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, - "react-overlays": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.1.1.tgz", - "integrity": "sha512-eCN2s2/+GVZzpnId4XVWtvDPYYBD2EtOGP74hE+8yDskPzFy9+pV1H3ZZihxuRdEbQzzacySaaDkR7xE0ydl4Q==", - "requires": { - "@babel/runtime": "^7.13.8", - "@popperjs/core": "^2.8.6", - "@restart/hooks": "^0.3.26", - "@types/warning": "^3.0.0", - "dom-helpers": "^5.2.0", - "prop-types": "^15.7.2", - "uncontrollable": "^7.2.1", - "warning": "^4.0.3" - } - }, - "uncontrollable": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", - "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", - "requires": { - "@babel/runtime": "^7.6.3", - "@types/react": ">=16.9.11", - "invariant": "^2.2.4", - "react-lifecycles-compat": "^3.0.4" - } - } } }, "react-bootstrap-icons": { @@ -25704,6 +25958,21 @@ "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" }, + "react-overlays": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.1.tgz", + "integrity": "sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA==", + "requires": { + "@babel/runtime": "^7.13.8", + "@popperjs/core": "^2.11.6", + "@restart/hooks": "^0.4.7", + "@types/warning": "^3.0.0", + "dom-helpers": "^5.2.0", + "prop-types": "^15.7.2", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + } + }, "react-redux": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz", @@ -26011,8 +26280,7 @@ "require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" }, "require-main-filename": { "version": "1.0.1", @@ -26304,14 +26572,6 @@ "rechoir": "^0.6.2" } }, - "shortid": { - "version": "2.2.16", - "resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.16.tgz", - "integrity": "sha512-Ugt+GIZqvGXCIItnsL+lvFJOiN7RYqlGy7QE41O3YC1xbNSeDGIRO7xg2JJXIAj1cAGnOeC1r7/T9pgrtQbv4g==", - "requires": { - "nanoid": "^2.1.0" - } - }, "shx": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/shx/-/shx-0.3.4.tgz", @@ -27101,6 +27361,17 @@ "which-boxed-primitive": "^1.0.2" } }, + "uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "requires": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + } + }, "unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", @@ -27221,6 +27492,43 @@ "spdx-expression-parse": "^3.0.0" } }, + "validate.io-array": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/validate.io-array/-/validate.io-array-1.0.6.tgz", + "integrity": "sha512-DeOy7CnPEziggrOO5CZhVKJw6S3Yi7e9e65R1Nl/RTN1vTQKnzjfvks0/8kQ40FP/dsjRAOd4hxmJ7uLa6vxkg==", + "peer": true + }, + "validate.io-function": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/validate.io-function/-/validate.io-function-1.0.2.tgz", + "integrity": "sha512-LlFybRJEriSuBnUhQyG5bwglhh50EpTL2ul23MPIuR1odjO7XaMLFV8vHGwp7AZciFxtYOeiSCT5st+XSPONiQ==", + "peer": true + }, + "validate.io-integer": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/validate.io-integer/-/validate.io-integer-1.0.5.tgz", + "integrity": "sha512-22izsYSLojN/P6bppBqhgUDjCkr5RY2jd+N2a3DCAUey8ydvrZ/OkGvFPR7qfOpwR2LC5p4Ngzxz36g5Vgr/hQ==", + "peer": true, + "requires": { + "validate.io-number": "^1.0.3" + } + }, + "validate.io-integer-array": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/validate.io-integer-array/-/validate.io-integer-array-1.0.0.tgz", + "integrity": "sha512-mTrMk/1ytQHtCY0oNO3dztafHYyGU88KL+jRxWuzfOmQb+4qqnWmI+gykvGp8usKZOM0H7keJHEbRaFiYA0VrA==", + "peer": true, + "requires": { + "validate.io-array": "^1.0.3", + "validate.io-integer": "^1.0.4" + } + }, + "validate.io-number": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/validate.io-number/-/validate.io-number-1.0.3.tgz", + "integrity": "sha512-kRAyotcbNaSYoDnXvb4MHg/0a1egJdLwS6oJ38TJY7aw9n93Fl/3blIXdyYvPOp55CNxywooG/3BcrwNrBpcSg==", + "peer": true + }, "value-equal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", diff --git a/package.json b/package.json index 9e73f21a2..a93916f7f 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,12 @@ "@babel/polyfill": "^7.0.0", "@babel/runtime": "^7.0.0", "@reduxjs/toolkit": "^1.8.1", + "@rjsf/bootstrap-4": "^5.13.2", + "@rjsf/core": "^5.13.2", + "@rjsf/validator-ajv8": "^5.13.2", "atob": "^2.0.3", - "bootstrap": "^4.6.0", - "bootstrap-icons": "^1.0.0", + "bootstrap": "^4.6.2", + "bootstrap-icons": "^1.11.1", "btoa": "^1.1.2", "codemirror": "^5.39.2", "diff": "^5.0.0", @@ -34,12 +37,11 @@ "filesize": "^10.0.7", "history": "^4.10.1", "html-webpack-plugin": "^5.5.0", - "kinto-admin-form": "0.0.0-experimental-8183a9c80", "kinto-http": "^5.1.1", "lodash": "^4.17.21", "react": "^17.0.2", - "react-bootstrap": "^1.3.0", - "react-bootstrap-icons": "^1.1.0", + "react-bootstrap": "^1.6.7", + "react-bootstrap-icons": "^1.10.3", "react-bs-notifier": "^6.0.0", "react-codemirror2": "^7.0.0", "react-dom": "^17.0.2", @@ -65,6 +67,7 @@ "@types/react-router-dom": "^5.3.0", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", + "babel-jest": "^29.7.0", "babel-loader": "^9.1.0", "chai": "^4.1.2", "cross-env": "^7.0.2", diff --git a/src/components/AuthForm.tsx b/src/components/AuthForm.tsx index 8ff6c32ee..cf99a364a 100644 --- a/src/components/AuthForm.tsx +++ b/src/components/AuthForm.tsx @@ -1,27 +1,17 @@ -import type { SessionState, ServerEntry } from "../types"; +import React, { useState } from "react"; -import React, { PureComponent } from "react"; -import InputGroup from "react-bootstrap/InputGroup"; -import FormControl from "react-bootstrap/FormControl"; -import Dropdown from "react-bootstrap/Dropdown"; -import DropdownButton from "react-bootstrap/DropdownButton"; +import type { SessionState, ServerEntry } from "../types"; import * as ServersActions from "../actions/servers"; import * as SessionActions from "../actions/session"; import BaseForm from "./BaseForm"; -import { - debounce, - getAuthLabel, - getServerByPriority, - isObjectEmpty, - omit, -} from "../utils"; +import { getAuthLabel, getServerByPriority, omit } from "../utils"; import { ANONYMOUS_AUTH, SINGLE_SERVER } from "../constants"; -const anonymousAuthData = server => ({ - authType: ANONYMOUS_AUTH, - server: server, -}); +import ServerHistory from "./ServerHistory"; + +import { RJSFSchema } from "@rjsf/utils"; + const KNOWN_AUTH_METHODS = [ "basicauth", "accounts", @@ -31,98 +21,6 @@ const KNOWN_AUTH_METHODS = [ // "openid", // Special cased as we need one auth method per openid provider. ]; -type ServerHistoryProps = { - id: string; - value: string; - placeholder: string; - options: any; - onChange: (s: string) => void; -}; - -type ServerHistoryState = {}; - -class ServerHistory extends PureComponent< - ServerHistoryProps, - ServerHistoryState -> { - constructor(props) { - super(props); - } - - select = server => { - return event => { - event.preventDefault(); - this.props.onChange(server); - this.debouncedFetchServerInfo(server); - }; - }; - - clear = event => { - event.preventDefault(); - const { clearServers } = this.props.options; - clearServers(); - }; - - onServerChange = event => { - const server = event.target.value; - this.props.onChange(server); - // Do not try to fetch server infos if the field value is invalid. - if (server && event.target.validity && event.target.validity.valid) { - this.debouncedFetchServerInfo(server); - } - }; - - fetchServerInfo = server => { - // Server changed, request its capabilities to check what auth methods it - // supports. - const { getServerInfo, serverChange } = this.props.options; - serverChange(); - getServerInfo(anonymousAuthData(server)); - }; - - debouncedFetchServerInfo = debounce(this.fetchServerInfo, 500); - - render() { - const { id, value, placeholder, options } = this.props; - const { servers, pattern } = options; - return ( - - - - {servers.length === 0 ? ( - - No server history - - ) : ( - servers.map(({ server }, key) => ( - - - {server} - - - )) - )} - - - Clear - - - - ); - } -} - const baseAuthSchema = { type: "object", title: "Setup", @@ -136,7 +34,13 @@ const baseAuthSchema = { authType: { type: "string", title: "Authentication method", - enum: [ANONYMOUS_AUTH], + oneOf: [ + { + type: "string", + const: ANONYMOUS_AUTH, + title: getAuthLabel(ANONYMOUS_AUTH), + }, + ], }, }, }; @@ -296,14 +200,15 @@ const authSchemas = authType => { */ function extendSchemaWithHistory(schema, servers, authMethods) { const serverURL = getServerByPriority(servers); - return { + const foo: RJSFSchema = { ...schema, properties: { ...schema.properties, authType: { ...schema.properties.authType, - enum: authMethods, - enumNames: authMethods.map(getAuthLabel), + oneOf: authMethods.map(x => { + return { type: "string", const: x, title: getAuthLabel(x) }; + }), }, server: { ...schema.properties.server, @@ -311,6 +216,7 @@ function extendSchemaWithHistory(schema, servers, authMethods) { }, }, }; + return foo; } /** @@ -362,36 +268,28 @@ type AuthFormProps = { clearServers: typeof ServersActions.clearServers; }; -type AuthFormState = { - schema: any; - uiSchema: any; - formData: any; -}; - -export default class AuthForm extends PureComponent< - AuthFormProps, - AuthFormState -> { - static defaultProps = { - servers: [], - }; - - constructor(props: AuthFormProps) { - super(props); - const { servers } = this.props; - - const server = getServerByPriority(servers); - const authType = (servers.length && servers[0].authType) || ANONYMOUS_AUTH; - const { schema, uiSchema } = authSchemas(authType); - this.state = { - schema, - uiSchema, - formData: { authType, server }, - }; - } - - getSupportedAuthMethods = (): string[] => { - const { session } = this.props; +export default function AuthForm({ + session, + servers = [], + setupSession, + serverChange, + getServerInfo, + navigateToExternalAuth, + navigateToOpenID, + clearServers, +}: AuthFormProps) { + const authType = (servers.length && servers[0].authType) || ANONYMOUS_AUTH; + const { schema: currentSchema, uiSchema: curentUiSchema } = + authSchemas(authType); + + const [schema, setSchema] = useState(currentSchema); + const [uiSchema, setUiSchema] = useState(curentUiSchema); + const [formData, setFormData] = useState({ + authType, + server: getServerByPriority(servers), + }); + + const getSupportedAuthMethods = (): string[] => { const { serverInfo: { capabilities }, } = session; @@ -405,26 +303,27 @@ export default class AuthForm extends PureComponent< return [ANONYMOUS_AUTH].concat(supportedAuthMethods).concat(openIdMethods); }; - onChange = ({ formData }: { formData: any }) => { - const { authType } = formData; + const onChange = ({ formData: updatedData }: RJSFSchema) => { + if (formData.server !== updatedData.server) { + const newServer = servers.find(x => x.server === updatedData.server); + updatedData.authType = newServer?.authType || ANONYMOUS_AUTH; + } + const { authType } = updatedData; const { uiSchema } = authSchemas(authType); const { schema } = authSchemas(authType); const omitCredentials = - authType in [ANONYMOUS_AUTH, "fxa", "portier"] || + [ANONYMOUS_AUTH, "fxa", "portier"].includes(authType) || authType.startsWith("openid-"); const specificFormData = omitCredentials - ? omit(formData, ["credentials"]) - : { credentials: {}, ...formData }; - return this.setState({ - schema, - uiSchema, - formData: specificFormData, - }); + ? omit(updatedData, ["credentials"]) + : { credentials: {}, ...updatedData, authType }; + + setSchema(schema); + setUiSchema(uiSchema); + setFormData(specificFormData); }; - onSubmit = ({ formData }: { formData: any }) => { - const { session, setupSession, navigateToExternalAuth, navigateToOpenID } = - this.props; + const onSubmit = ({ formData }: RJSFSchema) => { let { authType } = formData; let openidProvider = null; if (authType.startsWith("openid-")) { @@ -440,7 +339,6 @@ export default class AuthForm extends PureComponent< return navigateToExternalAuth(extendedFormData); } case "openid": { - const { session } = this.props; const { serverInfo: { capabilities: { openid: { providers } = { providers: [] } }, @@ -452,94 +350,39 @@ export default class AuthForm extends PureComponent< } return navigateToOpenID(extendedFormData, providerData); } - // case "anonymous": - // case "ldap": - // case "basicauth": - // case "accounts": default: { return setupSession(extendedFormData); } } }; - componentDidUpdate(prevProps: AuthFormProps, prevState: AuthFormState) { - const { - formData: { server: prevServer }, - } = prevState; - const { - formData: { server: newServer }, - } = this.state; - - if (prevServer !== newServer) { - // Server changed, set the authType back to "anonymous", until we get the - // `capabilities` back from the call to `getServerInfo`. This is what - // we're doing below. - return this.onChange({ - ...this.state, - formData: { ...this.state.formData, authType: ANONYMOUS_AUTH }, - }); - } - - const { - session: { - serverInfo: { capabilities: prevCapabilities }, - }, - } = prevProps; - const { - session: { - serverInfo: { capabilities: newCapabilities }, - }, - } = this.props; - if (isObjectEmpty(prevCapabilities) && !isObjectEmpty(newCapabilities)) { - // The `capabilities` (and thus the auth methods) have changed following - // a successful `getServerInfo`, update the default auth method with the - // one from the servers, if we have one for this server. - const serverHistoryEntry = this.props.servers.find( - ({ server }) => server === newServer - ); - if (serverHistoryEntry) { - return this.onChange({ - ...this.state, - formData: { - ...this.state.formData, - authType: serverHistoryEntry.authType, - }, - }); - } - } - } - - render() { - const { servers, clearServers, getServerInfo, serverChange } = this.props; - const { schema, uiSchema, formData } = this.state; - const authMethods = this.getSupportedAuthMethods(); - const singleAuthMethod = authMethods.length === 1; - const finalSchema = extendSchemaWithHistory(schema, servers, authMethods); - const finalUiSchema = extendUiSchemaWithHistory( - uiSchema, - servers, - clearServers, - getServerInfo, - serverChange, - singleAuthMethod - ); - return ( -
-
- - - -
+ const authMethods = getSupportedAuthMethods(); + const singleAuthMethod = authMethods.length === 1; + const finalSchema = extendSchemaWithHistory(schema, servers, authMethods); + const finalUiSchema = extendUiSchemaWithHistory( + uiSchema, + servers, + clearServers, + getServerInfo, + serverChange, + singleAuthMethod + ); + return ( +
+
+ + +
- ); - } +
+ ); } diff --git a/src/components/BaseForm.tsx b/src/components/BaseForm.tsx index 61fb1e421..c6e3e7a3f 100644 --- a/src/components/BaseForm.tsx +++ b/src/components/BaseForm.tsx @@ -1,19 +1,26 @@ -import React, { PureComponent } from "react"; -import type { FormProps } from "kinto-admin-form"; -import Form from "kinto-admin-form"; +import React from "react"; +import { withTheme, FormProps } from "@rjsf/core"; +import { Theme as Bootstrap4Theme } from "@rjsf/bootstrap-4"; +import validator from "@rjsf/validator-ajv8"; import TagsField from "./TagsField"; const adminFields = { tags: TagsField }; -export default class BaseForm extends PureComponent { - render() { - return ( -
- ); - } +const FormWithTheme = withTheme(Bootstrap4Theme); + +export type BaseFormProps = Omit; + +export default function BaseForm(props: BaseFormProps) { + const { className, ...restProps } = props; + + return ( + + ); } diff --git a/src/components/ServerHistory.tsx b/src/components/ServerHistory.tsx new file mode 100644 index 000000000..115dc6261 --- /dev/null +++ b/src/components/ServerHistory.tsx @@ -0,0 +1,111 @@ +import React, { useState, useCallback } from "react"; + +import InputGroup from "react-bootstrap/InputGroup"; +import FormControl from "react-bootstrap/FormControl"; +import Dropdown from "react-bootstrap/Dropdown"; +import DropdownButton from "react-bootstrap/DropdownButton"; +import { ANONYMOUS_AUTH } from "../constants"; + +import { debounce } from "../utils"; + +const anonymousAuthData = server => ({ + authType: ANONYMOUS_AUTH, + server: server, +}); + +type ServerHistoryProps = { + id: string; + value: string; + placeholder: string; + options: any; + onChange: (s: string) => void; +}; + +export default function ServerHistory(props: ServerHistoryProps) { + const [value, setValue] = useState(props.value); + + const select = useCallback( + server => event => { + console.log("server select"); + event.preventDefault(); + props.onChange(server); + debouncedFetchServerInfo(server); + setValue(server); + }, + [props] + ); + + const clear = useCallback( + event => { + event.preventDefault(); + const { clearServers } = props.options; + clearServers(); + }, + [props] + ); + + const onServerChange = useCallback( + event => { + console.log("onServerChange"); + const server = event.target.value; + props.onChange(server); + // Do not try to fetch server info if the field value is invalid. + if (server && event.target.validity && event.target.validity.valid) { + debouncedFetchServerInfo(server); + } + setValue(server); + }, + [props] + ); + + const fetchServerInfo = useCallback( + server => { + // Server changed, request its capabilities to check what auth methods it supports. + const { getServerInfo, serverChange } = props.options; + serverChange(); + getServerInfo(anonymousAuthData(server)); + }, + [props] + ); + + const debouncedFetchServerInfo = useCallback(debounce(fetchServerInfo, 500), [ + fetchServerInfo, + ]); + + const { id, placeholder, options } = props; + const { servers, pattern } = options; + + return ( + + + + {servers.length === 0 ? ( + + No server history + + ) : ( + servers.map(({ server }, key) => ( + + {server} + + )) + )} + + + Clear + + + + ); +} diff --git a/src/components/collection/JSONCollectionForm.tsx b/src/components/collection/JSONCollectionForm.tsx index d30f8e216..4c12b1b1b 100644 --- a/src/components/collection/JSONCollectionForm.tsx +++ b/src/components/collection/JSONCollectionForm.tsx @@ -1,14 +1,16 @@ -import type { CollectionData } from "../../types"; - -import { PureComponent } from "react"; -import * as React from "react"; - -import Form from "kinto-admin-form"; +import React from "react"; +import { withTheme } from "@rjsf/core"; +import { RJSFSchema, UiSchema } from "@rjsf/utils"; +import { Theme as Bootstrap4Theme } from "@rjsf/bootstrap-4"; +import validator from "@rjsf/validator-ajv8"; import JSONEditor from "../JSONEditor"; -import { validJSON, omit } from "../../utils"; +import { omit } from "../../utils"; +import type { CollectionData } from "../../types"; + +const FormWithTheme = withTheme(Bootstrap4Theme); -const schema = { +const schema: RJSFSchema = { type: "object", required: ["id"], properties: { @@ -25,20 +27,13 @@ const schema = { }, }; -const uiSchema = { +const uiSchema: UiSchema = { data: { "ui:widget": JSONEditor, "ui:help": "This must be valid JSON.", }, }; -function validate({ data }, errors) { - if (!validJSON(data)) { - errors.data.addError("Invalid JSON."); - } - return errors; -} - type Props = { children?: React.ReactNode; cid?: string | null; @@ -46,48 +41,51 @@ type Props = { onSubmit: (data: { formData: CollectionData }) => void; }; -export default class JSONCollectionForm extends PureComponent { - onSubmit = ({ - formData, +export default function JSONCollectionForm({ + children, + cid, + formData, + onSubmit, +}: Props) { + const handleSubmit = ({ + formData: formInput, }: { formData: { id: string; data: string }; - }): void => { - const collectionData = { ...JSON.parse(formData.data), id: formData.id }; - this.props.onSubmit({ formData: collectionData }); + }) => { + console.log(formInput); + const collectionData = { ...JSON.parse(formInput.data), id: formInput.id }; + onSubmit({ formData: collectionData }); }; - render() { - const { children, cid, formData } = this.props; - const creation = !cid; - - const attributes = omit(formData, ["id", "last_modified"]); - // Stringify JSON fields so they're editable in a text field - const data = JSON.stringify(attributes, null, 2); - const formDataSerialized = { - id: cid, - data, - }; + const creation = !cid; + const attributes = omit(formData, ["id", "last_modified"]); + // Stringify JSON fields so they're editable in a text field + const data = JSON.stringify(attributes, null, 2); + const formDataSerialized = { + id: cid, + data, + }; - // Disable edition of the collection id - const _uiSchema = creation - ? uiSchema - : { - ...uiSchema, - id: { - "ui:readonly": true, - }, - }; + // Disable edition of the collection id + const _uiSchema = creation + ? uiSchema + : { + ...uiSchema, + id: { + "ui:readonly": true, + }, + }; - return ( - - {children} - - ); - } + return ( + + {children} + + ); } diff --git a/test/components/AuthForm_test.js b/test/components/AuthForm_test.js index 9753b9d18..7863140f0 100644 --- a/test/components/AuthForm_test.js +++ b/test/components/AuthForm_test.js @@ -3,7 +3,6 @@ import { DEFAULT_KINTO_SERVER } from "../../src/constants"; import { DEFAULT_SERVERINFO } from "../../src/reducers/session"; import { expect } from "chai"; import { render, fireEvent } from "@testing-library/react"; -import { Simulate } from "react-dom/test-utils"; import * as React from "react"; import AuthForm from "../../src/components/AuthForm"; import sinon from "sinon"; @@ -34,26 +33,6 @@ describe("AuthForm component", () => { expect(element.type).eql("text"); expect(element.value).eql(DEFAULT_KINTO_SERVER); }); - - it("should set the server url value in hidden field", async () => { - jest.doMock("../../src/constants", () => { - const actual = jest.requireActual("../../src/constants"); - return { - __esModule: true, - ...actual, - SINGLE_SERVER: "http://www.example.com/", - }; - }); - const AuthForm = require("../../src/components/AuthForm").default; - const node = createComponent( - - ); - const element = node.querySelector("input[id='root_server']"); - expect(element.type).eql("hidden"); - expect(element.value).eql("http://www.example.com/"); - }); }); describe("Authentication types", () => { let node, @@ -104,21 +83,18 @@ describe("AuthForm component", () => { describe("Basic Auth", () => { it("should submit setup data", () => { - Simulate.change(node.querySelector("#root_server"), { + fireEvent.change(node.querySelector("#root_server"), { target: { value: "http://test.server/v1" }, }); - Simulate.change(node.querySelectorAll("[type=radio]")[1], { - target: { value: "basicauth" }, - }); - clock.tick(500); // The server field .onChange even is debounced. - Simulate.change(node.querySelector("#root_credentials_username"), { + fireEvent.click(node.querySelectorAll("[type=radio]")[1]); + fireEvent.change(node.querySelector("#root_credentials_username"), { target: { value: "user" }, }); - Simulate.change(node.querySelector("#root_credentials_password"), { + fireEvent.change(node.querySelector("#root_credentials_password"), { target: { value: "pass" }, }); - Simulate.submit(node.querySelector("form")); + fireEvent.submit(node.querySelector("form")); sinon.assert.calledWithExactly(setupSession, { server: "http://test.server/v1", authType: "basicauth", @@ -133,20 +109,17 @@ describe("AuthForm component", () => { describe("LDAP", () => { it("should submit setup data", () => { - Simulate.change(node.querySelector("#root_server"), { + fireEvent.change(node.querySelector("#root_server"), { target: { value: "http://test.server/v1" }, }); - Simulate.change(node.querySelectorAll("[type=radio]")[3], { - target: { value: "ldap" }, - }); - clock.tick(500); // The AuthForm.onChange even is debounced. - Simulate.change(node.querySelector("#root_credentials_username"), { + fireEvent.click(node.querySelectorAll("[type=radio]")[3]); + fireEvent.change(node.querySelector("#root_credentials_username"), { target: { value: "you@email.com" }, }); - Simulate.change(node.querySelector("#root_credentials_password"), { + fireEvent.change(node.querySelector("#root_credentials_password"), { target: { value: "pass" }, }); - Simulate.submit(node.querySelector("form")); + fireEvent.submit(node.querySelector("form")); sinon.assert.calledWithExactly(setupSession, { server: "http://test.server/v1", authType: "ldap", @@ -161,32 +134,27 @@ describe("AuthForm component", () => { describe("FxA", () => { it("should navigate to external auth URL", () => { - Simulate.change(node.querySelector("#root_server"), { + fireEvent.change(node.querySelector("#root_server"), { target: { value: "http://test.server/v1" }, }); - Simulate.change(node.querySelectorAll("[type=radio]")[2], { - target: { value: "fxa" }, - }); - - Simulate.submit(node.querySelector("form")); + fireEvent.click(node.querySelectorAll("[type=radio]")[2]); + fireEvent.change(node.querySelector("form")); + fireEvent.submit(node.querySelector("form")); sinon.assert.calledWithExactly(navigateToExternalAuth, { server: "http://test.server/v1", - authType: "fxa", + authType: "fxa", // fxa = credentials omitted redirectURL: undefined, - credentials: {}, }); }); }); describe("OpenID", () => { it("should navigate to external auth URL", () => { - Simulate.change(node.querySelector("#root_server"), { + fireEvent.change(node.querySelector("#root_server"), { target: { value: "http://test.server/v1" }, }); - Simulate.change(node.querySelectorAll("[type=radio]")[4], { - target: { value: "openid-google" }, - }); - Simulate.submit(node.querySelector("form")); + fireEvent.click(node.querySelectorAll("[type=radio]")[4]); + fireEvent.submit(node.querySelector("form")); sinon.assert.calledWithExactly( navigateToOpenID, { @@ -254,7 +222,7 @@ describe("AuthForm component", () => { target: { value: "http://test.server/v1" }, }); expect(serverField.value).eql("http://test.server/v1"); - expect(authTypeField.value).eql("anonymous"); + expect(authTypeField.value).eql("openid-google"); const updatedProps = { ...props, diff --git a/test/setup-tests.js b/test/setup-tests.js index b1f05f377..6110626a7 100644 --- a/test/setup-tests.js +++ b/test/setup-tests.js @@ -3,11 +3,11 @@ global.window.scrollTo = () => {}; // Enable rjsf safe render completion // see https://github.com/mozilla-services/react-jsonschema-form/commit/6159cb4834a082b2af2154e6f978b9ad57e96d51 -var Form = require("kinto-admin-form").default; -Form.defaultProps = { - ...Form.defaultProps, - safeRenderCompletion: true, -}; +// var Form = require("kinto-admin-form").default; +// Form.defaultProps = { +// ...Form.defaultProps, +// safeRenderCompletion: true, +// }; // HTML debugging helper global.d = function d(node) { From e9a2cdc621e474f290a9beda1a9a623f70358d9e Mon Sep 17 00:00:00 2001 From: Alex Cottner Date: Fri, 27 Oct 2023 14:24:41 -0600 Subject: [PATCH 2/9] Migrated breadcrumbs and all bucket components to functional components. Added rjsf types. --- src/components/Breadcrumbs/Breadcrumbs.tsx | 4 +- src/components/Breadcrumbs/index.ts | 2 +- src/components/bucket/BucketAttributes.tsx | 98 ++++---- src/components/bucket/BucketCollections.tsx | 224 +++++-------------- src/components/bucket/BucketCreate.tsx | 42 ++-- src/components/bucket/BucketForm.tsx | 204 +++++++---------- src/components/bucket/BucketGroups.tsx | 155 ++++--------- src/components/bucket/BucketHistory.tsx | 88 ++++---- src/components/bucket/BucketPermissions.tsx | 4 +- src/components/bucket/BucketTabs.tsx | 130 +++++------ src/components/bucket/CollectionDataList.tsx | 116 ++++++++++ src/components/bucket/DeleteForm.tsx | 47 ++++ src/components/bucket/GroupDataList.tsx | 83 +++++++ 13 files changed, 594 insertions(+), 603 deletions(-) create mode 100644 src/components/bucket/CollectionDataList.tsx create mode 100644 src/components/bucket/DeleteForm.tsx create mode 100644 src/components/bucket/GroupDataList.tsx diff --git a/src/components/Breadcrumbs/Breadcrumbs.tsx b/src/components/Breadcrumbs/Breadcrumbs.tsx index 4277b0faa..24c979466 100644 --- a/src/components/Breadcrumbs/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -8,7 +8,7 @@ interface BreadcrumbsProps { separator: string; } -export const Breadcrumbs = ({ separator }: BreadcrumbsProps) => { +export default function Breadcrumbs({ separator }: BreadcrumbsProps) { const { pathname } = useLocation(); const crumbs = breadcrumbifyPath(pathname); return ( @@ -30,4 +30,4 @@ export const Breadcrumbs = ({ separator }: BreadcrumbsProps) => {
); -}; +} diff --git a/src/components/Breadcrumbs/index.ts b/src/components/Breadcrumbs/index.ts index 49e68dd4f..f709e4a21 100644 --- a/src/components/Breadcrumbs/index.ts +++ b/src/components/Breadcrumbs/index.ts @@ -1,2 +1,2 @@ -import { Breadcrumbs } from "./Breadcrumbs"; +import Breadcrumbs from "./Breadcrumbs"; export default Breadcrumbs; diff --git a/src/components/bucket/BucketAttributes.tsx b/src/components/bucket/BucketAttributes.tsx index 54cc4d708..73423a9f2 100644 --- a/src/components/bucket/BucketAttributes.tsx +++ b/src/components/bucket/BucketAttributes.tsx @@ -1,3 +1,4 @@ +import React, { useCallback } from "react"; import type { Capabilities, BucketState, @@ -6,8 +7,6 @@ import type { BucketRouteMatch, } from "../../types"; -import React, { PureComponent } from "react"; - import * as BucketActions from "../../actions/bucket"; import Spinner from "../Spinner"; import BucketForm from "./BucketForm"; @@ -29,51 +28,58 @@ export type Props = OwnProps & deleteBucket: typeof BucketActions.deleteBucket; }; -export default class BucketAttributes extends PureComponent { - deleteBucket = (bid: string) => { - const { deleteBucket } = this.props; - const message = [ - "This will delete the bucket and all the collections and", - "records it contains. Are you sure?", - ].join(" "); - if (confirm(message)) { - deleteBucket(bid); - } - }; +export default function BucketAttributes({ + match, + session, + bucket, + capabilities, + updateBucket, + deleteBucket, +}) { + const { + params: { bid }, + } = match; + const { busy, data: formData } = bucket; - onSubmit = (formData: BucketData) => { - const { match, updateBucket } = this.props; - const { - params: { bid }, - } = match; - updateBucket(bid, { data: formData }); - }; + const handleDeleteBucket = useCallback( + (bid: string) => { + const message = [ + "This will delete the bucket and all the collections and", + "records it contains. Are you sure?", + ].join(" "); + if (confirm(message)) { + deleteBucket(bid); + } + }, + [deleteBucket] + ); - render() { - const { match, session, capabilities, bucket } = this.props; - const { - params: { bid }, - } = match; - const { busy, data: formData } = bucket; - if (busy) { - return ; - } - return ( -
-

- Edit {bid} bucket attributes -

- - - -
- ); + const handleSubmit = useCallback( + (formData: BucketData) => { + updateBucket(bid, { data: formData }); + }, + [bid, updateBucket] + ); + + if (busy) { + return ; } + + return ( +
+

+ Edit {bid} bucket attributes +

+ + + +
+ ); } diff --git a/src/components/bucket/BucketCollections.tsx b/src/components/bucket/BucketCollections.tsx index ca154cff6..fbbf08b61 100644 --- a/src/components/bucket/BucketCollections.tsx +++ b/src/components/bucket/BucketCollections.tsx @@ -1,3 +1,4 @@ +import React, { useEffect } from "react"; import type { Capabilities, BucketState, @@ -6,124 +7,9 @@ import type { } from "../../types"; import type { Location } from "history"; -import React, { PureComponent } from "react"; - -import { Gear } from "react-bootstrap-icons"; -import { Justify } from "react-bootstrap-icons"; -import { ClockHistory } from "react-bootstrap-icons"; - import * as BucketActions from "../../actions/bucket"; -import { timeago } from "../../utils"; -import AdminLink from "../AdminLink"; import BucketTabs from "./BucketTabs"; -import PaginatedTable from "../PaginatedTable"; - -function DataList(props) { - const { bid, collections, capabilities, listBucketNextCollections } = props; - const { loaded, entries, hasNextPage } = collections; - const thead = ( - - - Id - Schema - Attachments - Cache Expires - Last mod. - Actions - - - ); - - const tbody = ( - - {entries.map((collection, index) => { - const { - id: cid, - schema, - cache_expires, - last_modified, - attachment, - } = collection; - // FIXME: last_modified should always be here, but the types - // don't express that - const date = last_modified && new Date(last_modified); - const ageString = date && timeago(date.getTime()); - return ( - - {cid} - {schema ? "Yes" : "No"} - - {attachment ? (attachment.required ? "Required" : "Yes") : "No"} - - {cache_expires ? `${cache_expires} seconds` : "No"} - - {ageString} - - -
- - - - {"history" in capabilities && ( - - - - )} - - - -
- - - ); - })} - - ); - - return ( - - ); -} - -function ListActions(props) { - const { bid, session, bucket } = props; - if (session.busy || bucket.busy) { - return null; - } - return ( -
- - Create collection - -
- ); -} +import { DataList, ListActions } from "./CollectionDataList"; export type OwnProps = { match: BucketRouteMatch; @@ -142,63 +28,59 @@ export type Props = OwnProps & listBucketNextCollections: typeof BucketActions.listBucketNextCollections; }; -export default class BucketCollections extends PureComponent { - onBucketPageEnter() { - const { listBucketCollections, match, session } = this.props; - const { params } = match; - if (!session.authenticated) { - // We're not authenticated, skip requesting the list of records. This likely - // occurs when users refresh the page and lose their session. - return; - } - listBucketCollections(params.bid); - } +export default function BucketCollections({ + match, + location, + session, + bucket, + capabilities, + listBucketCollections, + listBucketNextCollections, +}) { + useEffect(() => { + const onBucketPageEnter = () => { + const { params } = match; + if (!session.authenticated) { + // We're not authenticated, skip requesting the list of records. This likely + // occurs when users refresh the page and lose their session. + return; + } + listBucketCollections(params.bid); + }; - componentDidMount = this.onBucketPageEnter; - componentDidUpdate = (prevProps: Props) => { - if (prevProps.location !== this.props.location) { - this.onBucketPageEnter(); - } - }; + onBucketPageEnter(); + }, [match, location, session, listBucketCollections]); - render() { - const { match, session, bucket, capabilities, listBucketNextCollections } = - this.props; - const { - params: { bid }, - } = match; - const { collections } = bucket; + const { + params: { bid }, + } = match; + const { collections } = bucket; - const listActions = ( - - ); + const listActions = ( + + ); - return ( -
-

- Collections of {bid} -

- - {listActions} - {collections.loaded && collections.entries.length === 0 ? ( -
-

This bucket has no collections.

-
- ) : ( - - )} - {listActions} -
-
- ); - } + return ( +
+

+ Collections of {bid} +

+ + {listActions} + {collections.loaded && collections.entries.length === 0 ? ( +
+

This bucket has no collections.

+
+ ) : ( + + )} + {listActions} +
+
+ ); } diff --git a/src/components/bucket/BucketCreate.tsx b/src/components/bucket/BucketCreate.tsx index 06f675ed8..5eca18fc1 100644 --- a/src/components/bucket/BucketCreate.tsx +++ b/src/components/bucket/BucketCreate.tsx @@ -1,7 +1,6 @@ +import React from "react"; import type { SessionState, BucketState } from "../../types"; -import React, { PureComponent } from "react"; - import * as BucketActions from "../../actions/bucket"; import BucketForm from "./BucketForm"; import Spinner from "../Spinner"; @@ -15,26 +14,25 @@ export type Props = StateProps & { createBucket: typeof BucketActions.createBucket; }; -export default class BucketCreate extends PureComponent { - render() { - const { session, bucket, createBucket } = this.props; - const { busy } = session; - if (busy) { - return ; - } - return ( -
-

Create a new bucket

-
-
- createBucket(id, attributes)} - /> -
+export default function BucketCreate({ session, bucket, createBucket }) { + const { busy } = session; + + if (busy) { + return ; + } + + return ( +
+

Create a new bucket

+
+
+ createBucket(id, attributes)} + />
- ); - } +
+ ); } diff --git a/src/components/bucket/BucketForm.tsx b/src/components/bucket/BucketForm.tsx index c5a7191f2..f06313b79 100644 --- a/src/components/bucket/BucketForm.tsx +++ b/src/components/bucket/BucketForm.tsx @@ -1,18 +1,18 @@ -import type { BucketState, BucketData, SessionState } from "../../types"; - -import React, { PureComponent } from "react"; +import React from "react"; import { Link } from "react-router-dom"; import { Check2 } from "react-bootstrap-icons"; -import { Trash } from "react-bootstrap-icons"; import BaseForm from "../BaseForm"; import JSONEditor from "../JSONEditor"; import Spinner from "../Spinner"; +import { RJSFSchema } from "@rjsf/utils"; import { canEditBucket } from "../../permission"; -import { validJSON, omit } from "../../utils"; +import { omit } from "../../utils"; +import DeleteForm from "./DeleteForm"; +import type { BucketState, BucketData, SessionState } from "../../types"; -const schema = { +const schema: RJSFSchema = { type: "object", required: ["id"], properties: { @@ -35,53 +35,6 @@ const uiSchema = { }, }; -const deleteSchema = { - type: "string", - title: "Please enter the bucket id to delete as a confirmation", -}; - -function validate({ data }, errors) { - if (!validJSON(data)) { - errors.data.addError("Invalid JSON."); - } - return errors; -} - -function DeleteForm({ bid, onSubmit }) { - const validate = (formData, errors) => { - if (formData !== bid) { - errors.addError("The bucket id does not match."); - } - return errors; - }; - return ( -
-
- Danger Zone -
-
-

- Delete the {bid} bucket and all the collections and records it - contains. -

- { - if (typeof onSubmit === "function") { - onSubmit(formData); - } - }} - > - - -
-
- ); -} - type Props = { bid?: string; session: SessionState; @@ -91,81 +44,82 @@ type Props = { onSubmit: (data: any) => any; }; -export default class BucketForm extends PureComponent { - onSubmit = ({ formData }: { formData: any }) => { - const { id, data } = formData; - // Parse JSON fields so they can be sent to the server - const attributes = JSON.parse(data); - this.props.onSubmit({ id, ...attributes }); +export default function BucketForm({ + bid, + session, + bucket, + formData = {}, + deleteBucket, + onSubmit, +}: Props) { + const creation = !formData.id; + const hasWriteAccess = canEditBucket(session, bucket); + const formIsEditable = creation || hasWriteAccess; + const showDeleteForm = !creation && hasWriteAccess; + + const attributes = omit(formData, ["id", "last_modified"]); + // Stringify JSON fields so they're editable in a text field + const data = JSON.stringify(attributes, null, 2); + const formDataSerialized = { + id: bid, + data, }; - render() { - const { bid, session, bucket, formData = {}, deleteBucket } = this.props; - const creation = !formData.id; - const hasWriteAccess = canEditBucket(session, bucket); - const formIsEditable = creation || hasWriteAccess; - const showDeleteForm = !creation && hasWriteAccess; - - const attributes = omit(formData, ["id", "last_modified"]); - // Stringify JSON fields so they're editable in a text field - const data = JSON.stringify(attributes, null, 2); - const formDataSerialized = { - id: bid, - data, - }; - - // Disable edition of the collection id - const _uiSchema = creation - ? uiSchema - : { - ...uiSchema, - id: { - "ui:readonly": true, - }, - }; - - const alert = - formIsEditable || bucket.busy ? null : ( -
- You don't have the required permission to edit this bucket. -
- ); - - const buttons = ( -
- - {" or "} - Cancel + // Disable edition of the collection id + const _uiSchema = creation + ? uiSchema + : { + ...uiSchema, + id: { + "ui:readonly": true, + }, + }; + + const alert = + formIsEditable || bucket.busy ? null : ( +
+ You don't have the required permission to edit this bucket.
); - return ( -
- {alert} - {bucket.busy ? ( - - ) : ( - - {buttons} - - )} - {showDeleteForm && } -
- ); - } + const buttons = ( +
+ + {" or "} + Cancel +
+ ); + + return ( +
+ {alert} + {bucket.busy ? ( + + ) : ( + { + const { id, data } = formData; + // Parse JSON fields so they can be sent to the server + const attributes = JSON.parse(data); + onSubmit({ id, ...attributes }); + }} + > + {buttons} + + )} + {showDeleteForm && } +
+ ); } diff --git a/src/components/bucket/BucketGroups.tsx b/src/components/bucket/BucketGroups.tsx index 8a1102f99..9a6e924c3 100644 --- a/src/components/bucket/BucketGroups.tsx +++ b/src/components/bucket/BucketGroups.tsx @@ -1,3 +1,4 @@ +import React from "react"; import type { Capabilities, BucketState, @@ -5,132 +6,52 @@ import type { BucketRouteMatch, } from "../../types"; -import React, { PureComponent } from "react"; - -import { Gear } from "react-bootstrap-icons"; -import { ClockHistory } from "react-bootstrap-icons"; - -import { timeago } from "../../utils"; -import AdminLink from "../AdminLink"; import BucketTabs from "./BucketTabs"; +import { DataList, ListActions } from "./GroupDataList"; -function DataList(props) { - const { bid, groups, capabilities } = props; - return ( - - - - - - - - - - - {groups.map((group, index) => { - const { id: gid, members, last_modified } = group; - const date = new Date(last_modified); - return ( - - - - - - - ); - })} - -
IdMembersLast mod.Actions
- - {gid} - - {members.join(", ")} - - {timeago(date.getTime())} - - -
- {"history" in capabilities && ( - - - - )} - - - -
-
- ); -} - -function ListActions({ bid, session, bucket }) { - if (session.busy || bucket.busy) { - return null; - } - return ( -
- - Create group - -
- ); -} - -export type OwnProps = { +type OwnProps = { match: BucketRouteMatch; }; -export type StateProps = { +type StateProps = { session: SessionState; bucket: BucketState; capabilities: Capabilities; }; -export type Props = OwnProps & StateProps; - -export default class BucketCollections extends PureComponent { - render() { - const { match, session, bucket, capabilities } = this.props; - const { - params: { bid }, - } = match; - const { groups } = bucket; - - const listActions = ( - - ); +type Props = OwnProps & StateProps; + +export default function BucketCollections({ + match, + session, + bucket, + capabilities, +}: Props) { + const { + params: { bid }, + } = match; + const { groups } = bucket; + + const listActions = ( + + ); - return ( -
-

- Groups of {bid} -

- - {listActions} - {groups.length === 0 ? ( -
-

This bucket has no groups.

-
- ) : ( - - )} - {listActions} -
-
- ); - } + return ( +
+

+ Groups of {bid} +

+ + {listActions} + {groups.length === 0 ? ( +
+

This bucket has no groups.

+
+ ) : ( + + )} + {listActions} +
+
+ ); } diff --git a/src/components/bucket/BucketHistory.tsx b/src/components/bucket/BucketHistory.tsx index 020265709..7518797b3 100644 --- a/src/components/bucket/BucketHistory.tsx +++ b/src/components/bucket/BucketHistory.tsx @@ -6,7 +6,7 @@ import type { } from "../../types"; import type { Location } from "history"; -import React, { PureComponent } from "react"; +import React, { useEffect } from "react"; import * as BucketActions from "../../actions/bucket"; import * as NotificationActions from "../../actions/notifications"; @@ -14,18 +14,18 @@ import { parseHistoryFilters } from "../../utils"; import BucketTabs from "./BucketTabs"; import HistoryTable from "../HistoryTable"; -export type OwnProps = { +type OwnProps = { match: BucketRouteMatch; location: Location; }; -export type StateProps = { +type StateProps = { bucket: BucketState; capabilities: Capabilities; session: SessionState; }; -export type Props = OwnProps & +type Props = OwnProps & StateProps & { listBucketHistory: typeof BucketActions.listBucketHistory; listBucketNextHistory: typeof BucketActions.listBucketNextHistory; @@ -39,54 +39,48 @@ export const onBucketHistoryEnter = (props: Props) => { } = match; const filters = parseHistoryFilters(location.search); if (!session.authenticated) { - // We're not authenticated, skip requesting the list of records. This likely - // occurs when users refresh the page and lose their session. return; } listBucketHistory(bid, filters); }; -export default class BucketHistory extends PureComponent { - componentDidMount = () => onBucketHistoryEnter(this.props); - componentDidUpdate = (prevProps: Props) => { - if (prevProps.location !== this.props.location) { - onBucketHistoryEnter(this.props); - } - }; +export default function BucketHistory(props: Props) { + const { + match, + bucket, + capabilities, + location, + listBucketNextHistory, + notifyError, + } = props; - render() { - const { - match, - bucket, - capabilities, - location, - listBucketNextHistory, - notifyError, - } = this.props; - const { - params: { bid }, - } = match; - const { - history: { entries, loaded, hasNextPage: hasNextHistoryPage }, - } = bucket; + useEffect(() => { + onBucketHistoryEnter(props); + }, [props.location]); - return ( -
-

- History for {bid} -

- - - -
- ); - } + const { + params: { bid }, + } = match; + const { + history: { entries, loaded, hasNextPage: hasNextHistoryPage }, + } = bucket; + + return ( +
+

+ History for {bid} +

+ + + +
+ ); } diff --git a/src/components/bucket/BucketPermissions.tsx b/src/components/bucket/BucketPermissions.tsx index ac25d2184..7bb301f45 100644 --- a/src/components/bucket/BucketPermissions.tsx +++ b/src/components/bucket/BucketPermissions.tsx @@ -11,7 +11,7 @@ import { useParams } from "react-router"; import { useAppSelector, useAppDispatch } from "../../hooks"; -export const BucketPermissions = () => { +export function BucketPermissions() { const dispatch = useAppDispatch(); const { bid } = useParams<{ bid: string }>(); const session = useAppSelector(store => store.session); @@ -44,4 +44,4 @@ export const BucketPermissions = () => {
); -}; +} diff --git a/src/components/bucket/BucketTabs.tsx b/src/components/bucket/BucketTabs.tsx index 9d8c43c2c..8f4cfb58b 100644 --- a/src/components/bucket/BucketTabs.tsx +++ b/src/components/bucket/BucketTabs.tsx @@ -1,14 +1,14 @@ import type { Capabilities } from "../../types"; -import { PureComponent } from "react"; -import * as React from "react"; - -import { Gear } from "react-bootstrap-icons"; -import { Lock } from "react-bootstrap-icons"; -import { Justify } from "react-bootstrap-icons"; -import { PersonFill } from "react-bootstrap-icons"; -import { ClockHistory } from "react-bootstrap-icons"; +import React from "react"; +import { + Gear, + Lock, + Justify, + PersonFill, + ClockHistory, +} from "react-bootstrap-icons"; import AdminLink from "../AdminLink"; type Props = { @@ -18,80 +18,70 @@ type Props = { children?: React.ReactNode; }; -export default class BucketTabs extends PureComponent { - render() { - const { bid, selected, capabilities, children } = this.props; - - return ( -
-
-
    -
  • - - - Collections - -
  • -
  • +export default function BucketTabs({ + bid, + selected, + capabilities, + children, +}: Props) { + return ( +
    +
    +
      + {[ + { + name: "bucket:collections", + icon: Justify, + label: "Collections", + key: "collections", + }, + { + name: "bucket:groups", + icon: PersonFill, + label: "Groups", + key: "groups", + }, + { + name: "bucket:attributes", + icon: Gear, + label: "Attributes", + key: "attributes", + }, + { + name: "bucket:permissions", + icon: Lock, + label: "Permissions", + key: "permissions", + }, + ].map(({ name, icon: Icon, label, key }) => ( +
    • - - Groups - -
    • -
    • - - - Attributes + + {label}
    • + ))} + {"history" in capabilities && (
    • - - Permissions + + History
    • - {"history" in capabilities && ( -
    • - - - History - -
    • - )} -
    -
    -
    {children}
    + )} +
- ); - } +
{children}
+
+ ); } diff --git a/src/components/bucket/CollectionDataList.tsx b/src/components/bucket/CollectionDataList.tsx new file mode 100644 index 000000000..b2fc7890e --- /dev/null +++ b/src/components/bucket/CollectionDataList.tsx @@ -0,0 +1,116 @@ +import React from "react"; + +import { Gear } from "react-bootstrap-icons"; +import { Justify } from "react-bootstrap-icons"; +import { ClockHistory } from "react-bootstrap-icons"; + +import { timeago } from "../../utils"; +import AdminLink from "../AdminLink"; +import PaginatedTable from "../PaginatedTable"; + +export function ListActions(props) { + const { bid, session, bucket } = props; + if (session.busy || bucket.busy) { + return null; + } + return ( +
+ + Create collection + +
+ ); +} + +export function DataList(props) { + const { bid, collections, capabilities, listBucketNextCollections } = props; + const { loaded, entries, hasNextPage } = collections; + const thead = ( + + + Id + Schema + Attachments + Cache Expires + Last mod. + Actions + + + ); + + const tbody = ( + + {entries.map((collection, index) => { + const { + id: cid, + schema, + cache_expires, + last_modified, + attachment, + } = collection; + // FIXME: last_modified should always be here, but the types + // don't express that + const date = last_modified && new Date(last_modified); + const ageString = date && timeago(date.getTime()); + return ( + + {cid} + {schema ? "Yes" : "No"} + + {attachment ? (attachment.required ? "Required" : "Yes") : "No"} + + {cache_expires ? `${cache_expires} seconds` : "No"} + + {ageString} + + +
+ + + + {"history" in capabilities && ( + + + + )} + + + +
+ + + ); + })} + + ); + + return ( + + ); +} diff --git a/src/components/bucket/DeleteForm.tsx b/src/components/bucket/DeleteForm.tsx new file mode 100644 index 000000000..e51262947 --- /dev/null +++ b/src/components/bucket/DeleteForm.tsx @@ -0,0 +1,47 @@ +import React from "react"; + +import { Trash } from "react-bootstrap-icons"; + +import BaseForm from "../BaseForm"; +import { RJSFSchema } from "@rjsf/utils"; + +const deleteSchema: RJSFSchema = { + type: "string", + title: "Please enter the bucket id to delete as a confirmation", +}; + +export default function DeleteForm({ bid, onSubmit }) { + const validate = (formData, errors) => { + if (formData !== bid) { + errors.addError("The bucket id does not match."); + } + return errors; + }; + + return ( +
+
+ Danger Zone +
+
+

+ Delete the {bid} bucket and all the collections and records it + contains. +

+ { + if (typeof onSubmit === "function") { + onSubmit(formData); + } + }} + > + + +
+
+ ); +} diff --git a/src/components/bucket/GroupDataList.tsx b/src/components/bucket/GroupDataList.tsx new file mode 100644 index 000000000..f2111eb3d --- /dev/null +++ b/src/components/bucket/GroupDataList.tsx @@ -0,0 +1,83 @@ +import React from "react"; + +import { Gear } from "react-bootstrap-icons"; +import { ClockHistory } from "react-bootstrap-icons"; + +import { timeago } from "../../utils"; +import AdminLink from "../AdminLink"; + +export function DataList(props) { + const { bid, groups, capabilities } = props; + return ( + + + + + + + + + + + {groups.map((group, index) => { + const { id: gid, members, last_modified } = group; + const date = new Date(last_modified); + return ( + + + + + + + ); + })} + +
IdMembersLast mod.Actions
+ + {gid} + + {members.join(", ")} + + {timeago(date.getTime())} + + +
+ {"history" in capabilities && ( + + + + )} + + + +
+
+ ); +} + +export function ListActions({ bid, session, bucket }) { + if (session.busy || bucket.busy) { + return null; + } + return ( +
+ + Create group + +
+ ); +} From 413192b90aab58f5115f038249f96c6f76b4ba99 Mon Sep 17 00:00:00 2001 From: Alex Cottner Date: Fri, 27 Oct 2023 14:25:31 -0600 Subject: [PATCH 3/9] Migrated all collection components to functional components. Added rjsf types. --- .../collection/CollectionAttributes.tsx | 89 +-- .../collection/CollectionCreate.tsx | 59 +- src/components/collection/CollectionForm.tsx | 286 ++++------ .../collection/CollectionHistory.tsx | 96 ++-- .../collection/CollectionPermissions.tsx | 5 +- .../collection/CollectionRecords.tsx | 536 ++++-------------- src/components/collection/CollectionTabs.tsx | 126 ++-- src/components/collection/DeleteForm.tsx | 45 ++ .../collection/FormInstructions.tsx | 31 + src/components/collection/RecordRow.tsx | 109 ++++ src/components/collection/RecordTable.tsx | 218 +++++++ src/components/collection/commonPropTypes.ts | 13 + 12 files changed, 802 insertions(+), 811 deletions(-) create mode 100644 src/components/collection/DeleteForm.tsx create mode 100644 src/components/collection/FormInstructions.tsx create mode 100644 src/components/collection/RecordRow.tsx create mode 100644 src/components/collection/RecordTable.tsx create mode 100644 src/components/collection/commonPropTypes.ts diff --git a/src/components/collection/CollectionAttributes.tsx b/src/components/collection/CollectionAttributes.tsx index 462911378..89484fb73 100644 --- a/src/components/collection/CollectionAttributes.tsx +++ b/src/components/collection/CollectionAttributes.tsx @@ -7,7 +7,7 @@ import type { CollectionData, } from "../../types"; -import React, { PureComponent } from "react"; +import React from "react"; import * as BucketActions from "../../actions/bucket"; import Spinner from "../Spinner"; @@ -31,17 +31,23 @@ export type Props = OwnProps & deleteCollection: typeof BucketActions.deleteCollection; }; -export default class CollectionAttributes extends PureComponent { - onSubmit = (formData: CollectionData) => { - const { match, updateCollection } = this.props; +export default function CollectionAttributes({ + match, + session, + bucket, + collection, + capabilities, + updateCollection, + deleteCollection, +}: Props) { + const onSubmit = (formData: CollectionData) => { const { params: { bid, cid }, } = match; updateCollection(bid, cid, { data: formData }); }; - deleteCollection = (cid: string) => { - const { deleteCollection, match } = this.props; + const handleDeleteCollection = (cid: string) => { const { params: { bid }, } = match; @@ -54,42 +60,41 @@ export default class CollectionAttributes extends PureComponent { } }; - render() { - const { match, session, bucket, collection, capabilities } = this.props; - const { - params: { bid, cid }, - } = match; - const { busy, data: formData } = collection; - if (busy) { - return ; - } - return ( -
-

- Edit{" "} - - {bid}/{cid} - {" "} - collection attributes -

- ; + } + + return ( +
+

+ Edit{" "} + + {bid}/{cid} + {" "} + collection attributes +

+ + - - -
- ); - } + session={session} + bucket={bucket} + collection={collection} + deleteCollection={handleDeleteCollection} + formData={formData} + onSubmit={onSubmit} + /> +
+
+ ); } diff --git a/src/components/collection/CollectionCreate.tsx b/src/components/collection/CollectionCreate.tsx index 9e70d29c6..4a730d488 100644 --- a/src/components/collection/CollectionCreate.tsx +++ b/src/components/collection/CollectionCreate.tsx @@ -5,7 +5,7 @@ import type { BucketRouteMatch, } from "../../types"; -import React, { PureComponent } from "react"; +import React from "react"; import * as BucketActions from "../../actions/bucket"; import Spinner from "../Spinner"; @@ -26,32 +26,37 @@ export type Props = OwnProps & createCollection: typeof BucketActions.createCollection; }; -export default class CollectionCreate extends PureComponent { - render() { - const { match, session, bucket, collection, createCollection } = this.props; - const { - params: { bid }, - } = match; - const { busy } = session; - if (busy) { - return ; - } - return ( -
-

- Create a new collection in {bid} bucket -

-
-
- createCollection(bid, formData)} - /> -
+export default function CollectionCreate({ + match, + session, + bucket, + collection, + createCollection, +}: Props) { + const { + params: { bid }, + } = match; + const { busy } = session; + + if (busy) { + return ; + } + + return ( +
+

+ Create a new collection in {bid} bucket +

+
+
+ createCollection(bid, formData)} + />
- ); - } +
+ ); } diff --git a/src/components/collection/CollectionForm.tsx b/src/components/collection/CollectionForm.tsx index 13613fbf4..55c378c5a 100644 --- a/src/components/collection/CollectionForm.tsx +++ b/src/components/collection/CollectionForm.tsx @@ -5,17 +5,19 @@ import type { CollectionData, } from "../../types"; -import React, { PureComponent } from "react"; +import React, { useState } from "react"; import { Link } from "react-router-dom"; import { Check2 } from "react-bootstrap-icons"; -import { Trash } from "react-bootstrap-icons"; import BaseForm from "../BaseForm"; +import { RJSFSchema } from "@rjsf/utils"; import JSONCollectionForm from "./JSONCollectionForm"; import JSONEditor from "../JSONEditor"; import { canCreateCollection, canEditCollection } from "../../permission"; import { validateSchema, validateUiSchema } from "../../utils"; +import DeleteForm from "./DeleteForm"; +import { FormInstructions } from "./FormInstructions"; const defaultSchema = JSON.stringify( { @@ -44,46 +46,7 @@ const defaultUiSchema = JSON.stringify( 2 ); -const deleteSchema = { - type: "string", - title: "Please enter the collection name to delete as a confirmation", -}; - -function DeleteForm({ cid, onSubmit }) { - const validate = (formData, errors) => { - if (formData !== cid) { - errors.addError("The collection name does not match."); - } - return errors; - }; - return ( -
-
- Danger Zone -
-
-

- Delete the {cid} collection and all the records it contains. -

- { - if (typeof onSubmit === "function") { - onSubmit(formData); - } - }} - > - - -
-
- ); -} - -const schema = { +const schema: RJSFSchema = { type: "object", required: ["id"], properties: { @@ -216,6 +179,7 @@ const uiSchema = { displayFields: { items: { "ui:placeholder": "Enter a field name. i.e: name, attachment.filename", + "ui:title": "Field name", }, "ui:description": (

@@ -263,39 +227,8 @@ function validate({ schema, uiSchema, displayFields }, errors) { return errors; } -function FormInstructions({ onSchemalessLinkClick }) { - return ( -

-
    -
  1. First find a good name for your collection.
  2. -
  3. - Create a JSON schema describing the fields the collection - records should have. -
  4. -
  5. - Define a uiSchema to customize the way forms for creating and - editing records are rendered. -
  6. -
  7. - List the record fields you want to display in the columns of the - collection records list. -
  8. -
  9. Decide if you want to enable attaching a file to records.
  10. -
-

- Alternatively, you can create a{" "} - - schemaless collection - - . -

-
- ); -} - type Props = { cid?: string; - bid?: string; session: SessionState; bucket: BucketState; collection: CollectionState; @@ -304,142 +237,123 @@ type Props = { formData?: CollectionData; }; -type State = { - asJSON: boolean; -}; - -export default class CollectionForm extends PureComponent { - constructor(props: Props) { - super(props); - this.state = { asJSON: false }; - } +export default function CollectionForm({ + cid, + session, + bucket, + collection, + deleteCollection, + onSubmit, + formData: propFormData = {}, +}: Props) { + const [asJSON, setAsJSON] = useState(false); - onSubmit = ({ formData }: { formData: any }): void => { - const collectionData = this.state.asJSON - ? formData - : { - ...formData, - // Parse JSON fields so they can be sent to the server - schema: JSON.parse(formData.schema), - uiSchema: JSON.parse(formData.uiSchema), - }; - this.props.onSubmit(collectionData); - }; - - get allowEditing(): boolean { - const { formData, session, bucket, collection } = this.props; - const creation = !formData; + const allowEditing = React.useMemo(() => { + const creation = !propFormData; if (creation) { return canCreateCollection(session, bucket); } else { return canEditCollection(session, bucket, collection); } - } + }, [propFormData, session, bucket, collection]); - toggleJSON = ( - event: React.MouseEvent - ): void => { + const toggleJSON = event => { event.preventDefault(); - this.setState({ asJSON: !this.state.asJSON }); + setAsJSON(prevAsJSON => !prevAsJSON); window.scrollTo(0, 0); }; - onSchemalessLinkClick = (event: Event): void => { + const onSchemalessLinkClick = event => { event.preventDefault(); - this.setState({ asJSON: true }); + setAsJSON(true); }; - render() { - const { - cid, - bucket, - collection, - formData = {}, - deleteCollection, - } = this.props; - const creation = !formData.id; - const showDeleteForm = !creation && this.allowEditing; - const { asJSON } = this.state; - - // Disable edition of the collection id - const _uiSchema = creation - ? uiSchema - : { - ...uiSchema, - id: { - "ui:readonly": true, - }, - }; - - const formDataSerialized = creation + const handleOnSubmit = ({ formData }) => { + const collectionData = asJSON ? formData : { - displayFields: formData.displayFields || [], ...formData, - // Stringify JSON fields so they're editable in a text field - schema: JSON.stringify(formData.schema || {}, null, 2), - uiSchema: JSON.stringify(formData.uiSchema || {}, null, 2), + schema: JSON.parse(formData.schema), + uiSchema: JSON.parse(formData.uiSchema), }; + onSubmit(collectionData); + }; - const alert = - this.allowEditing || bucket.busy || collection.busy ? null : ( -
- You don't have the required permission to edit this collection. -
- ); + const creation = !propFormData.id; + const showDeleteForm = !creation && allowEditing; + const formDataSerialized = creation + ? propFormData + : { + displayFields: propFormData.displayFields || [], + ...propFormData, + schema: JSON.stringify(propFormData.schema || {}, null, 2), + uiSchema: JSON.stringify(propFormData.uiSchema || {}, null, 2), + }; - const buttons = ( -
- - {" or "} - Cancel - {" | "} - - {asJSON ? "Edit form" : "Edit raw JSON"} - + const _uiSchema = creation + ? uiSchema + : { + ...uiSchema, + id: { + "ui:readonly": true, + }, + }; + + const alert = + allowEditing || bucket.busy || collection.busy ? null : ( +
+ You don't have the required permission to edit this collection.
); - return ( -
- {alert} - {asJSON ? ( - + + {" or "} + Cancel + {" | "} + + {asJSON ? "Edit form" : "Edit raw JSON"} + +
+ ); + + return ( +
+ {alert} + {asJSON ? ( + + {buttons} + + ) : ( +
+ + {buttons} - - ) : ( -
- - - {buttons} - -
- )} - {showDeleteForm && } -
- ); - } + +
+ )} + {showDeleteForm && } +
+ ); } diff --git a/src/components/collection/CollectionHistory.tsx b/src/components/collection/CollectionHistory.tsx index e95c68cd8..fd5fe9028 100644 --- a/src/components/collection/CollectionHistory.tsx +++ b/src/components/collection/CollectionHistory.tsx @@ -6,7 +6,7 @@ import type { } from "../../types"; import type { Location } from "history"; -import React, { PureComponent } from "react"; +import React, { useEffect } from "react"; import * as CollectionActions from "../../actions/collection"; import * as NotificationActions from "../../actions/notifications"; @@ -46,57 +46,53 @@ export const onCollectionHistoryEnter = (props: Props) => { listCollectionHistory(bid, cid, filters); }; -export default class CollectionHistory extends PureComponent { - componentDidMount = () => onCollectionHistoryEnter(this.props); - componentDidUpdate = (prevProps: Props) => { - if (prevProps.location !== this.props.location) { - onCollectionHistoryEnter(this.props); - } - }; +export default function CollectionHistory(props: Props) { + const { + match, + collection, + capabilities, + location, + listCollectionNextHistory, + notifyError, + } = props; + const { + params: { bid, cid }, + } = match; + const { + history: { entries, loaded, hasNextPage }, + } = collection; - render() { - const { - match, - collection, - capabilities, - location, - listCollectionNextHistory, - notifyError, - } = this.props; - const { - params: { bid, cid }, - } = match; - const { - history: { entries, loaded, hasNextPage }, - } = collection; + useEffect(() => { + console.log(`collectionHistory useEffect: ${props.location}`); + onCollectionHistoryEnter(props); + }, [props.location]); - return ( -
-

- History for{" "} - - {bid}/{cid} - -

- +

+ History for{" "} + + {bid}/{cid} + +

+ + - - -
- ); - } + historyLoaded={loaded} + history={entries} + hasNextHistory={hasNextPage} + listNextHistory={listCollectionNextHistory} + location={location} + notifyError={notifyError} + /> + +
+ ); } diff --git a/src/components/collection/CollectionPermissions.tsx b/src/components/collection/CollectionPermissions.tsx index 868892c33..2c9e7179c 100644 --- a/src/components/collection/CollectionPermissions.tsx +++ b/src/components/collection/CollectionPermissions.tsx @@ -13,7 +13,8 @@ interface RouteParams { bid: string; cid: string; } -export const CollectionPermissions = () => { + +export function CollectionPermissions() { const { bid, cid } = useParams(); const session = useAppSelector(state => state.session); const bucket = useAppSelector(state => state.bucket); @@ -55,4 +56,4 @@ export const CollectionPermissions = () => {
); -}; +} diff --git a/src/components/collection/CollectionRecords.tsx b/src/components/collection/CollectionRecords.tsx index 98f5f66a9..818b49da3 100644 --- a/src/components/collection/CollectionRecords.tsx +++ b/src/components/collection/CollectionRecords.tsx @@ -1,364 +1,33 @@ +import React, { useEffect, useCallback } from "react"; + import type { CollectionRouteMatch, SessionState, BucketState, CollectionState, - Capabilities, - RecordData, } from "../../types"; -import type { Location } from "history"; - -import React, { PureComponent } from "react"; - -import { Paperclip } from "react-bootstrap-icons"; -import { Pencil } from "react-bootstrap-icons"; -import { Lock } from "react-bootstrap-icons"; -import { Trash } from "react-bootstrap-icons"; -import { SortUp } from "react-bootstrap-icons"; -import { SortDown } from "react-bootstrap-icons"; -import * as CollectionActions from "../../actions/collection"; -import * as RouteActions from "../../actions/route"; -import { - capitalize, - renderDisplayField, - timeago, - buildAttachmentUrl, -} from "../../utils"; -import { canCreateRecord } from "../../permission"; -import AdminLink from "../AdminLink"; import CollectionTabs from "./CollectionTabs"; -import PaginatedTable from "../PaginatedTable"; import Spinner from "../Spinner"; -import SignoffContainer from "../../containers/signoff/SignoffToolBar"; - -type CommonStateProps = { - capabilities: Capabilities; -}; - -type CommonProps = CommonStateProps & { - deleteRecord: typeof CollectionActions.deleteRecord; - redirectTo: typeof RouteActions.redirectTo; -}; - -type RecordsViewProps = CommonProps & { - bid: string; - cid: string; - displayFields: string[]; - schema: any; -}; - -type RowProps = RecordsViewProps & { - record: RecordData; -}; - -class Row extends PureComponent { - static defaultProps = { - schema: {}, - record: {}, - }; - - get lastModified() { - const lastModified = this.props.record.last_modified; - if (!lastModified) { - return null; - } - const date = new Date(lastModified); - return date.toJSON() == null ? null : ( - {timeago(date.getTime())} - ); - } +import RecordTable from "./RecordTable"; +import { ListActions } from "./RecordTable"; +import { CommonProps, CommonStateProps } from "./commonPropTypes"; - onDoubleClick(event) { - event.preventDefault(); - const { bid, cid, record, redirectTo } = this.props; - const { id: rid } = record; - redirectTo("record:attributes", { bid, cid, rid }); - } - - onDeleteClick(event) { - const { bid, cid, record, deleteRecord } = this.props; - const { id: rid, last_modified } = record; - if (!rid) { - // FIXME: this shouldn't be possible - throw Error("can't happen"); - } - if (confirm("Are you sure?")) { - deleteRecord(bid, cid, rid, last_modified); - } - } - - render() { - const { bid, cid, record, displayFields, capabilities } = this.props; - const { id: rid } = record; - const attachmentUrl = buildAttachmentUrl(record, capabilities); - return ( - - {displayFields.map((displayField, index) => { - return ( - {renderDisplayField(record, displayField)} - ); - })} - {this.lastModified} - -
- {attachmentUrl && ( - - - - )} - - - - - - - -
- - - ); - } -} - -function SortLink(props) { - const { dir, active, column, updateSort } = props; - return ( - { - event.preventDefault(); - if (active) { - // Perform the opposite action from current state to make the link act - // as a toggler. - updateSort(dir === "up" ? `-${column}` : column); - } else { - // by default use ASC order - updateSort(column); - } - }} - > - {dir === "up" ? ( - - ) : ( - - )} - - ); -} - -function ColumnSortLink(props) { - const { column, currentSort, updateSort } = props; - if (!currentSort || column === "__json") { - return null; - } - let active, direction; - // Check if we're currently sorting on this field. - if (new RegExp(`^-?${column}$`).test(currentSort)) { - // We're sorting on this field; check for direction. - active = true; - direction = currentSort.startsWith("-") ? "down" : "up"; - } else { - // By default, expose links to sort ASC. - active = false; - direction = "up"; - } - return ( - - ); -} - -type TableProps = RecordsViewProps & { - currentSort: string; - hasNextRecords: boolean; - listNextRecords: typeof CollectionActions.listNextRecords; - records: RecordData[]; - recordsLoaded: boolean; - updateSort: (s: string) => void; -}; - -class Table extends PureComponent { - getFieldTitle(displayField) { - const { schema } = this.props; - if (displayField === "__json") { - return "Data"; - } - if ( - this.isSchemaProperty(displayField) && - "title" in schema.properties[displayField] - ) { - return schema.properties[displayField].title; - } - return capitalize(displayField); - } - - isSchemaProperty(displayField) { - const { schema } = this.props; - return schema && schema.properties && displayField in schema.properties; - } - - render() { - const { - bid, - cid, - records, - recordsLoaded, - hasNextRecords, - listNextRecords, - currentSort, - schema, - displayFields, - deleteRecord, - updateSort, - redirectTo, - capabilities, - } = this.props; - - if (recordsLoaded && records.length === 0) { - return ( -
-

This collection has no records.

-
- ); - } - - const thead = ( - - - {displayFields.map((displayField, index) => { - return ( - - {this.getFieldTitle(displayField)} - {this.isSchemaProperty(displayField) && ( - - )} - - ); - })} - - Last mod. - - - - - - ); - - const tbody = ( - - {records.map((record, index) => { - return ( - - ); - })} - - ); - - return ( - - ); - } -} - -function ListActions(props) { - const { bid, cid, session, bucket, collection } = props; - if (session.busy || collection.busy) { - return null; - } - return ( -
- {canCreateRecord(session, bucket, collection) && ( - <> - - Create record - - - Bulk create - - - )} - {/* won't render if the signer capability is not enabled on the server - or collection not configured to be signed */} - -
- ); -} +import * as CollectionActions from "../../actions/collection"; +import * as RouteActions from "../../actions/route"; -export type OwnProps = { +type OwnProps = { match: CollectionRouteMatch; location: Location; }; -export type StateProps = CommonStateProps & { +type StateProps = CommonStateProps & { session: SessionState; bucket: BucketState; collection: CollectionState; }; -export type Props = CommonProps & +type Props = CommonProps & OwnProps & StateProps & { deleteRecord: typeof CollectionActions.deleteRecord; @@ -367,111 +36,100 @@ export type Props = CommonProps & redirectTo: typeof RouteActions.redirectTo; }; -export default class CollectionRecords extends PureComponent { - static displayName = "CollectionRecords"; - - updateSort = (sort: string) => { - const { match, listRecords } = this.props; - const { - params: { bid, cid }, - } = match; - listRecords(bid, cid, sort); - }; +export default function CollectionRecords(props: Props) { + const { + match, + session, + bucket, + collection, + deleteRecord, + listNextRecords, + redirectTo, + capabilities, + listRecords, + } = props; + + const { + params: { bid, cid }, + } = match; + + const updateSort = useCallback( + sort => { + listRecords(bid, cid, sort); + }, + [bid, cid, listRecords] + ); - onCollectionRecordsEnter = () => { - const { collection, listRecords, match, session } = this.props; - const { - params: { bid, cid }, - } = match; + const onCollectionRecordsEnter = useCallback(() => { if (!session.authenticated) { - // We're not authenticated, skip requesting the list of records. This likely - // occurs when users refresh the page and lose their session. return; } const { currentSort } = collection; listRecords(bid, cid, currentSort); - }; - - componentDidMount = this.onCollectionRecordsEnter; - componentDidUpdate = (prevProps: Props) => { - if (prevProps.location !== this.props.location) { - this.onCollectionRecordsEnter(); - } - }; - - render() { - const { - match, - session, - bucket, - collection, - deleteRecord, - listNextRecords, - redirectTo, - capabilities, - } = this.props; - const { - params: { bid, cid }, - } = match; - const { - busy, - data, - currentSort, - records, - recordsLoaded, - hasNextRecords, - totalRecords, - } = collection; - const { schema, displayFields } = data; + }, [bid, cid, collection, listRecords, session]); + + useEffect(() => { + onCollectionRecordsEnter(); + }, []); + + const { + busy, + data, + currentSort, + records, + recordsLoaded, + hasNextRecords, + totalRecords, + } = collection; + const { schema, displayFields } = data; + + const listActions = ( + + ); - const listActions = ( - +

+ Records of{" "} + + {bid}/{cid} + +

+ - ); - - return ( -
-

- Records of{" "} - - {bid}/{cid} - -

- - {listActions} - {busy ? ( - - ) : ( - - )} - {listActions} - - - ); - } + selected="records" + capabilities={capabilities} + totalRecords={totalRecords} + > + {listActions} + {busy ? ( + + ) : ( + + )} + {listActions} + + + ); } diff --git a/src/components/collection/CollectionTabs.tsx b/src/components/collection/CollectionTabs.tsx index 73474a9d8..688849708 100644 --- a/src/components/collection/CollectionTabs.tsx +++ b/src/components/collection/CollectionTabs.tsx @@ -1,14 +1,8 @@ -import type { Capabilities } from "../../types"; - -import { PureComponent } from "react"; -import * as React from "react"; - -import { Gear } from "react-bootstrap-icons"; -import { Lock } from "react-bootstrap-icons"; -import { Justify } from "react-bootstrap-icons"; -import { ClockHistory } from "react-bootstrap-icons"; +import React from "react"; +import { Gear, Lock, Justify, ClockHistory } from "react-bootstrap-icons"; import AdminLink from "../AdminLink"; +import type { Capabilities } from "../../types"; type Props = { bid: string; @@ -19,69 +13,71 @@ type Props = { totalRecords?: number | null; }; -export default class CollectionTabs extends PureComponent { - render() { - const { bid, cid, selected, capabilities, children, totalRecords } = - this.props; - - return ( -
-
-
    -
  • - - - Records {totalRecords ? `(${totalRecords})` : null} - -
  • -
  • - - - Attributes - -
  • +export default function CollectionTabs({ + bid, + cid, + selected, + capabilities, + children, + totalRecords, +}: Props) { + return ( +
    +
    +
      +
    • + + + Records {totalRecords ? `(${totalRecords})` : null} + +
    • +
    • + + + Attributes + +
    • +
    • + + + Permissions + +
    • + {"history" in capabilities && (
    • - - Permissions + + History
    • - {"history" in capabilities && ( -
    • - - - History - -
    • - )} -
    -
    -
    {children}
    + )} +
- ); - } +
{children}
+
+ ); } diff --git a/src/components/collection/DeleteForm.tsx b/src/components/collection/DeleteForm.tsx new file mode 100644 index 000000000..a184e0b9f --- /dev/null +++ b/src/components/collection/DeleteForm.tsx @@ -0,0 +1,45 @@ +import React from "react"; + +import { Trash } from "react-bootstrap-icons"; + +import BaseForm from "../BaseForm"; +import { RJSFSchema } from "@rjsf/utils"; + +const deleteSchema: RJSFSchema = { + type: "string", + title: "Please enter the collection name to delete as a confirmation", +}; + +export default function DeleteForm({ cid, onSubmit }) { + const validate = (formData, errors) => { + if (formData !== cid) { + errors.addError("The collection name does not match."); + } + return errors; + }; + return ( +
+
+ Danger Zone +
+
+

+ Delete the {cid} collection and all the records it contains. +

+ { + if (typeof onSubmit === "function") { + onSubmit(formData); + } + }} + > + + +
+
+ ); +} diff --git a/src/components/collection/FormInstructions.tsx b/src/components/collection/FormInstructions.tsx new file mode 100644 index 000000000..28645795e --- /dev/null +++ b/src/components/collection/FormInstructions.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +export function FormInstructions({ onSchemalessLinkClick }) { + return ( +
+
    +
  1. First find a good name for your collection.
  2. +
  3. + Create a JSON schema describing the fields the collection + records should have. +
  4. +
  5. + Define a uiSchema to customize the way forms for creating and + editing records are rendered. +
  6. +
  7. + List the record fields you want to display in the columns of the + collection records list. +
  8. +
  9. Decide if you want to enable attaching a file to records.
  10. +
+

+ Alternatively, you can create a{" "} + + schemaless collection + + . +

+
+ ); +} diff --git a/src/components/collection/RecordRow.tsx b/src/components/collection/RecordRow.tsx new file mode 100644 index 000000000..dd4c19eb0 --- /dev/null +++ b/src/components/collection/RecordRow.tsx @@ -0,0 +1,109 @@ +import React from "react"; + +import type { RecordData } from "../../types"; + +import { Paperclip, Pencil, Lock, Trash } from "react-bootstrap-icons"; +import { renderDisplayField, timeago, buildAttachmentUrl } from "../../utils"; +import AdminLink from "../AdminLink"; +import { CommonProps } from "./commonPropTypes"; + +type RecordsViewProps = CommonProps & { + bid: string; + cid: string; + displayFields: string[]; + schema: any; +}; + +type RowProps = RecordsViewProps & { + record: RecordData; +}; + +export default function RecordRow({ + bid, + cid, + record, + displayFields, + capabilities, + redirectTo, + deleteRecord, + schema = {}, +}: RowProps) { + const lastModified = () => { + const lastModified = record.last_modified; + if (!lastModified) { + return null; + } + const date = new Date(lastModified); + return date.toJSON() == null ? null : ( + {timeago(date.getTime())} + ); + }; + + const onDoubleClick = (event: React.MouseEvent) => { + event.preventDefault(); + const { id: rid } = record; + redirectTo("record:attributes", { bid, cid, rid }); + }; + + const onDeleteClick = (event: React.MouseEvent) => { + const { id: rid, last_modified } = record; + if (!rid) { + // FIXME: this shouldn't be possible + throw Error("can't happen"); + } + if (window.confirm("Are you sure?")) { + deleteRecord(bid, cid, rid, last_modified); + } + }; + + const attachmentUrl = buildAttachmentUrl(record, capabilities); + const { id: rid } = record; + + return ( +
+ {displayFields.map((displayField, index) => ( + + ))} + + + + ); +} diff --git a/src/components/collection/RecordTable.tsx b/src/components/collection/RecordTable.tsx new file mode 100644 index 000000000..733ece5b6 --- /dev/null +++ b/src/components/collection/RecordTable.tsx @@ -0,0 +1,218 @@ +import type { RecordData } from "../../types"; + +import React from "react"; + +import { SortUp } from "react-bootstrap-icons"; +import { SortDown } from "react-bootstrap-icons"; + +import * as CollectionActions from "../../actions/collection"; +import { canCreateRecord } from "../../permission"; +import { capitalize } from "../../utils"; + +import PaginatedTable from "../PaginatedTable"; +import RecordRow from "./RecordRow"; +import AdminLink from "../AdminLink"; +import SignoffContainer from "../../containers/signoff/SignoffToolBar"; + +import { CommonProps } from "./commonPropTypes"; + +export function ListActions(props) { + const { bid, cid, session, bucket, collection } = props; + if (session.busy || collection.busy) { + return null; + } + return ( +
+ {canCreateRecord(session, bucket, collection) && ( + <> + + Create record + + + Bulk create + + + )} + {/* won't render if the signer capability is not enabled on the server + or collection not configured to be signed */} + +
+ ); +} + +export function SortLink(props) { + const { dir, active, column, updateSort } = props; + return ( + { + event.preventDefault(); + if (active) { + // Perform the opposite action from current state to make the link act + // as a toggler. + updateSort(dir === "up" ? `-${column}` : column); + } else { + // by default use ASC order + updateSort(column); + } + }} + > + {dir === "up" ? ( + + ) : ( + + )} + + ); +} + +export function ColumnSortLink(props) { + const { column, currentSort, updateSort } = props; + if (!currentSort || column === "__json") { + return null; + } + let active, direction; + // Check if we're currently sorting on this field. + if (new RegExp(`^-?${column}$`).test(currentSort)) { + // We're sorting on this field; check for direction. + active = true; + direction = currentSort.startsWith("-") ? "down" : "up"; + } else { + // By default, expose links to sort ASC. + active = false; + direction = "up"; + } + return ( + + ); +} + +type RecordsViewProps = CommonProps & { + bid: string; + cid: string; + displayFields: string[]; + schema: any; +}; + +type TableProps = RecordsViewProps & { + currentSort: string; + hasNextRecords: boolean; + listNextRecords: typeof CollectionActions.listNextRecords; + records: RecordData[]; + recordsLoaded: boolean; + updateSort: (s: string) => void; +}; + +export default function RecordTable({ + bid, + cid, + records, + recordsLoaded, + hasNextRecords, + listNextRecords, + currentSort, + schema, + displayFields, + deleteRecord, + updateSort, + redirectTo, + capabilities, +}: TableProps) { + const getFieldTitle = displayField => { + if (displayField === "__json") { + return "Data"; + } + if ( + isSchemaProperty(displayField) && + "title" in schema.properties[displayField] + ) { + return schema.properties[displayField].title; + } + return capitalize(displayField); + }; + + const isSchemaProperty = displayField => { + return schema && schema.properties && displayField in schema.properties; + }; + + if (recordsLoaded && records.length === 0) { + return ( +
+

This collection has no records.

+
+ ); + } + + const thead = ( +
+ + {displayFields.map((displayField, index) => ( + + ))} + + + + ); + + const tbody = ( + + {records.map((record, index) => ( + + ))} + + ); + + return ( + + ); +} diff --git a/src/components/collection/commonPropTypes.ts b/src/components/collection/commonPropTypes.ts new file mode 100644 index 000000000..c6ad6f860 --- /dev/null +++ b/src/components/collection/commonPropTypes.ts @@ -0,0 +1,13 @@ +import type { Capabilities } from "../../types"; + +import * as CollectionActions from "../../actions/collection"; +import * as RouteActions from "../../actions/route"; + +export type CommonStateProps = { + capabilities: Capabilities; +}; + +export type CommonProps = CommonStateProps & { + deleteRecord: typeof CollectionActions.deleteRecord; + redirectTo: typeof RouteActions.redirectTo; +}; From c49be630e34ae26bc845a03ae53fb1cba5781fa1 Mon Sep 17 00:00:00 2001 From: Alex Cottner Date: Fri, 27 Oct 2023 14:26:11 -0600 Subject: [PATCH 4/9] Migrated all group components to functional components. Added rjsf types. --- src/components/group/DeleteForm.tsx | 46 +++++ src/components/group/GroupAttributes.tsx | 117 ++++++----- src/components/group/GroupCreate.tsx | 57 +++--- src/components/group/GroupForm.tsx | 239 +++++++++------------- src/components/group/GroupHistory.tsx | 97 +++++---- src/components/group/GroupPermissions.tsx | 4 +- src/components/group/GroupTabs.tsx | 84 ++++---- 7 files changed, 320 insertions(+), 324 deletions(-) create mode 100644 src/components/group/DeleteForm.tsx diff --git a/src/components/group/DeleteForm.tsx b/src/components/group/DeleteForm.tsx new file mode 100644 index 000000000..1a9c09b91 --- /dev/null +++ b/src/components/group/DeleteForm.tsx @@ -0,0 +1,46 @@ +import React from "react"; + +import { Trash } from "react-bootstrap-icons"; + +import BaseForm from "../BaseForm"; + +import { RJSFSchema } from "@rjsf/utils"; + +const deleteSchema: RJSFSchema = { + type: "string", + title: "Please enter the group id to delete as a confirmation", +}; + +export default function DeleteForm({ gid, onSubmit }) { + const validate = (formData, errors) => { + if (formData !== gid) { + errors.addError("The group id does not match."); + } + return errors; + }; + return ( +
+
+ Danger Zone +
+
+

+ Delete the {gid} group. +

+ { + if (typeof onSubmit === "function") { + onSubmit(formData); + } + }} + > + + +
+
+ ); +} diff --git a/src/components/group/GroupAttributes.tsx b/src/components/group/GroupAttributes.tsx index c1287f691..f5aa71edf 100644 --- a/src/components/group/GroupAttributes.tsx +++ b/src/components/group/GroupAttributes.tsx @@ -1,3 +1,4 @@ +import React, { useCallback } from "react"; import type { Capabilities, SessionState, @@ -7,12 +8,10 @@ import type { GroupRouteMatch, } from "../../types"; -import React, { PureComponent } from "react"; - -import * as BucketActions from "../../actions/bucket"; import Spinner from "../Spinner"; import GroupForm from "./GroupForm"; import GroupTabs from "./GroupTabs"; +import * as BucketActions from "../../actions/bucket"; export type OwnProps = { match: GroupRouteMatch; @@ -31,61 +30,67 @@ export type Props = OwnProps & deleteGroup: typeof BucketActions.deleteGroup; }; -export default class GroupAttributes extends PureComponent { - onSubmit = (formData: GroupData) => { - const { match, updateGroup } = this.props; - const { - params: { bid, gid }, - } = match; - updateGroup(bid, gid, { data: formData }); - }; +export default function GroupAttributes(props: Props) { + const { + match, + session, + bucket, + group, + capabilities, + updateGroup, + deleteGroup, + } = props; + const { + params: { bid, gid }, + } = match; + const { busy, data: formData } = group; - deleteGroup = (gid: string) => { - const { deleteGroup, match } = this.props; - const { - params: { bid }, - } = match; - if (confirm("This will delete the group. Are you sure?")) { - deleteGroup(bid, gid); - } - }; + const onSubmit = useCallback( + (formData: GroupData) => { + updateGroup(bid, gid, { data: formData }); + }, + [bid, gid, updateGroup] + ); - render() { - const { match, session, bucket, group, capabilities } = this.props; - const { - params: { bid, gid }, - } = match; - const { busy, data: formData } = group; - if (busy || formData == null) { - return ; - } - return ( -
-

- Edit{" "} - - {bid}/{gid} - {" "} - group attributes -

- { + if (window.confirm("This will delete the group. Are you sure?")) { + deleteGroup(bid, gid); + } + }, + [bid, deleteGroup] + ); + + if (busy || formData == null) { + return ; + } + + return ( +
+

+ Edit{" "} + + {bid}/{gid} + {" "} + group attributes +

+ + - - -
- ); - } + session={session} + bucket={bucket} + group={group} + deleteGroup={onDeleteGroup} + formData={formData} + onSubmit={onSubmit} + /> +
+
+ ); } diff --git a/src/components/group/GroupCreate.tsx b/src/components/group/GroupCreate.tsx index db99d6f71..e5bcfa0a4 100644 --- a/src/components/group/GroupCreate.tsx +++ b/src/components/group/GroupCreate.tsx @@ -1,3 +1,4 @@ +import React from "react"; import type { SessionState, BucketState, @@ -5,11 +6,9 @@ import type { BucketRouteMatch, } from "../../types"; -import React, { PureComponent } from "react"; - -import * as BucketActions from "../../actions/bucket"; import GroupForm from "./GroupForm"; import Spinner from "../Spinner"; +import * as BucketActions from "../../actions/bucket"; export type OwnProps = { match: BucketRouteMatch; @@ -26,32 +25,32 @@ export type Props = OwnProps & createGroup: typeof BucketActions.createGroup; }; -export default class GroupCreate extends PureComponent { - render() { - const { match, session, bucket, group, createGroup } = this.props; - const { - params: { bid }, - } = match; - const { busy } = session; - if (busy) { - return ; - } - return ( -
-

- Create a new group in {bid} bucket -

-
-
- createGroup(bid, formData)} - /> -
+export default function GroupCreate(props: Props) { + const { match, session, bucket, group, createGroup } = props; + const { + params: { bid }, + } = match; + const { busy } = session; + + if (busy) { + return ; + } + + return ( +
+

+ Create a new group in {bid} bucket +

+
+
+ createGroup(bid, formData)} + />
- ); - } +
+ ); } diff --git a/src/components/group/GroupForm.tsx b/src/components/group/GroupForm.tsx index 1c9ab51e0..5d090d9ff 100644 --- a/src/components/group/GroupForm.tsx +++ b/src/components/group/GroupForm.tsx @@ -1,3 +1,4 @@ +import React, { useCallback } from "react"; import type { SessionState, BucketState, @@ -5,19 +6,17 @@ import type { GroupData, } from "../../types"; -import React, { PureComponent } from "react"; - import { Check2 } from "react-bootstrap-icons"; -import { Trash } from "react-bootstrap-icons"; - +import { RJSFSchema, UiSchema } from "@rjsf/utils"; import BaseForm from "../BaseForm"; import AdminLink from "../AdminLink"; import JSONEditor from "../JSONEditor"; -import { canCreateGroup, canEditGroup } from "../../permission"; -import { validJSON, omit } from "../../utils"; import Spinner from "../Spinner"; +import { canCreateGroup, canEditGroup } from "../../permission"; +import { omit } from "../../utils"; +import DeleteForm from "./DeleteForm"; -const schema = { +const schema: RJSFSchema = { type: "object", required: ["id", "members"], properties: { @@ -28,7 +27,10 @@ const schema = { }, members: { type: "array", - items: { type: "string" }, + items: { + type: "string", + title: "Member name", + }, uniqueItems: true, default: [], }, @@ -40,58 +42,12 @@ const schema = { }, }; -const uiSchema = { +const uiSchema: UiSchema = { data: { "ui:widget": JSONEditor, }, }; -const deleteSchema = { - type: "string", - title: "Please enter the group id to delete as a confirmation", -}; - -function validate({ data }, errors) { - if (!validJSON(data)) { - errors.data.addError("Invalid JSON."); - } - return errors; -} - -function DeleteForm({ gid, onSubmit }) { - const validate = (formData, errors) => { - if (formData !== gid) { - errors.addError("The group id does not match."); - } - return errors; - }; - return ( -
-
- Danger Zone -
-
-

- Delete the {gid} group. -

- { - if (typeof onSubmit === "function") { - onSubmit(formData); - } - }} - > - - -
-
- ); -} - type Props = { bid?: string; gid?: string; @@ -103,97 +59,94 @@ type Props = { deleteGroup?: (gid: string) => any; }; -export default class GroupForm extends PureComponent { - onSubmit = ({ formData }: { formData: { data: string } }) => { - const { data } = formData; - // Parse JSON fields so they can be sent to the server - const attributes = JSON.parse(data); - this.props.onSubmit({ - ...omit(formData, ["data"]), - // #273: Ensure omitting "members" value from entered JSON data so we - // don't override the ones entered in the dedicated field - ...omit(attributes, ["members"]), - } as any); - }; - - render() { - const { - gid, - session, - bucket, - group, - formData = {} as GroupData, - deleteGroup, - } = this.props; - const creation = !formData.id; - const hasWriteAccess = creation - ? canCreateGroup(session, bucket) - : canEditGroup(session, bucket, group); - const formIsEditable = creation || hasWriteAccess; - const showDeleteForm = !creation && hasWriteAccess; - - // Disable edition of the group id - const _uiSchema = creation - ? uiSchema - : { - ...uiSchema, - id: { - "ui:readonly": true, - }, - }; - - const attributes = omit(formData, ["id", "last_modified", "members"]); - // Stringify JSON fields so they're editable in a text field - const data = JSON.stringify(attributes, null, 2); - const formDataSerialized = { - ...formData, - data, - }; +export default function GroupForm(props: Props) { + const { + gid, + session, + bucket, + group, + formData = {} as GroupData, + onSubmit: propOnSubmit, + deleteGroup, + } = props; + + const onSubmit = useCallback( + ({ formData }) => { + const { data } = formData; + const attributes = JSON.parse(data); + propOnSubmit({ + ...omit(formData, ["data"]), + ...omit(attributes, ["members"]), + } as any); + }, + [propOnSubmit] + ); - const alert = - formIsEditable || group.busy ? null : ( -
- You don't have the required permission to edit this group. -
- ); + const creation = !formData.id; + const hasWriteAccess = creation + ? canCreateGroup(session, bucket) + : canEditGroup(session, bucket, group); + const formIsEditable = creation || hasWriteAccess; + const showDeleteForm = !creation && hasWriteAccess; + + const _uiSchema = creation + ? uiSchema + : { + ...uiSchema, + id: { + "ui:readonly": true, + }, + }; + + const attributes = omit(formData, ["id", "last_modified", "members"]); + const data = JSON.stringify(attributes, null, 2); + const formDataSerialized = { + ...formData, + data, + }; - const buttons = ( -
- - {" or "} - - Cancel - + const alert = + formIsEditable || group.busy ? null : ( +
+ You don't have the required permission to edit this group.
); - return ( -
- {alert} - {group.busy ? ( - - ) : ( - - {buttons} - - )} - {showDeleteForm && } -
- ); - } + const buttons = ( +
+ + {" or "} + + Cancel + +
+ ); + + return ( +
+ {alert} + {group.busy ? ( + + ) : ( + + {buttons} + + )} + {showDeleteForm && } +
+ ); } diff --git a/src/components/group/GroupHistory.tsx b/src/components/group/GroupHistory.tsx index bc914d6e2..53658255b 100644 --- a/src/components/group/GroupHistory.tsx +++ b/src/components/group/GroupHistory.tsx @@ -6,7 +6,7 @@ import type { } from "../../types"; import type { Location } from "history"; -import React, { PureComponent } from "react"; +import React, { useEffect } from "react"; import * as GroupActions from "../../actions/group"; import * as NotificationActions from "../../actions/notifications"; @@ -37,62 +37,57 @@ export const onGroupHistoryEnter = (props: Props) => { params: { bid, gid }, } = match; if (!session.authenticated) { - // We're not authenticated, skip requesting the list of records. This likely - // occurs when users refresh the page and lose their session. return; } listGroupHistory && listGroupHistory(bid, gid); }; -export default class GroupHistory extends PureComponent { - componentDidMount = () => onGroupHistoryEnter(this.props); - componentDidUpdate = (prevProps: Props) => { - if (prevProps.location !== this.props.location) { - onGroupHistoryEnter(this.props); - } - }; +export default function GroupHistory(props: Props) { + const { + match, + group, + capabilities, + location, + listGroupNextHistory, + notifyError, + } = props; - render() { - const { - match, - group, - capabilities, - location, - listGroupNextHistory, - notifyError, - } = this.props; - const { - params: { bid, gid }, - } = match; - const { - history: { entries, loaded, hasNextPage }, - } = group; + useEffect(() => { + onGroupHistoryEnter(props); + }, []); - return ( -
-

- History for{" "} - - {bid}/{gid} - -

- +

+ History for{" "} + + {bid}/{gid} + +

+ + - - -
- ); - } + historyLoaded={loaded} + history={entries} + hasNextHistory={hasNextPage} + listNextHistory={listGroupNextHistory} + location={location} + notifyError={notifyError} + /> + +
+ ); } diff --git a/src/components/group/GroupPermissions.tsx b/src/components/group/GroupPermissions.tsx index 83590c80d..f512257ed 100644 --- a/src/components/group/GroupPermissions.tsx +++ b/src/components/group/GroupPermissions.tsx @@ -13,7 +13,7 @@ import { canEditGroup } from "../../permission"; import { useAppSelector, useAppDispatch } from "../../hooks"; import { useParams } from "react-router"; -export const GroupPermissions = () => { +export function GroupPermissions() { const bucket = useAppSelector(state => state.bucket); const group = useAppSelector(state => state.group); const { busy, permissions } = group; @@ -51,4 +51,4 @@ export const GroupPermissions = () => {
); -}; +} diff --git a/src/components/group/GroupTabs.tsx b/src/components/group/GroupTabs.tsx index 1e6b62a12..54408a237 100644 --- a/src/components/group/GroupTabs.tsx +++ b/src/components/group/GroupTabs.tsx @@ -1,6 +1,6 @@ import type { Capabilities } from "../../types"; -import React, { PureComponent } from "react"; +import React from "react"; import { Gear } from "react-bootstrap-icons"; import { Lock } from "react-bootstrap-icons"; @@ -16,56 +16,54 @@ type Props = { children?: any; }; -export default class GroupTabs extends PureComponent { - render() { - const { bid, gid, selected, capabilities, children } = this.props; +export default function GroupTabs(props: Props) { + const { bid, gid, selected, capabilities, children } = props; - return ( -
-
-
    + return ( +
    +
    +
      +
    • + + + Attributes + +
    • +
    • + + + Permissions + +
    • + {"history" in capabilities && (
    • - - Attributes + + History
    • -
    • - - - Permissions - -
    • - {"history" in capabilities && ( -
    • - - - History - -
    • - )} -
    -
    -
    {children}
    + )} +
- ); - } +
{children}
+
+ ); } From fa8f2b0bb4604f0f4d274a8bff93ae03d52be5fd Mon Sep 17 00:00:00 2001 From: Alex Cottner Date: Fri, 27 Oct 2023 14:26:53 -0600 Subject: [PATCH 5/9] Migrated all record components to functional components. Added rjsf types. --- src/components/record/AttachmentInfo.tsx | 207 +++++++++++ src/components/record/JSONRecordForm.tsx | 62 ++-- src/components/record/RecordAttributes.tsx | 100 +++--- src/components/record/RecordBulk.tsx | 202 +++++------ src/components/record/RecordCreate.tsx | 81 +++-- src/components/record/RecordForm.tsx | 361 +++++--------------- src/components/record/RecordHistory.tsx | 96 +++--- src/components/record/RecordPermissions.tsx | 4 +- src/components/record/RecordTabs.tsx | 92 ++--- 9 files changed, 605 insertions(+), 600 deletions(-) create mode 100644 src/components/record/AttachmentInfo.tsx diff --git a/src/components/record/AttachmentInfo.tsx b/src/components/record/AttachmentInfo.tsx new file mode 100644 index 000000000..e7fc0917f --- /dev/null +++ b/src/components/record/AttachmentInfo.tsx @@ -0,0 +1,207 @@ +import React from "react"; + +import type { RecordState, RecordData, Capabilities } from "../../types"; + +import { filesize } from "filesize"; + +import { Paperclip } from "react-bootstrap-icons"; + +import { buildAttachmentUrl, omit } from "../../utils"; + +import { UiSchema } from "@rjsf/utils"; + +export function extendSchemaWithAttachment( + schema: any, + attachmentConfig: { enabled: boolean; required: boolean } | null | undefined, + record: RecordData +): any { + if (!attachmentConfig || !attachmentConfig.enabled) { + return schema; + } + const isCreate = !record.id; + const attachmentMissing = record.attachment && record.attachment.location; + + // We add a fake schema field ``__attachment__`` for the file input. + // It will be required if the + // Attachment form field is only required to receive a file + // required, when creating a record, or updating it if it does not have any. + // (required setting changed in the mean time). + const schemaRequired = schema.required || []; + const required = + attachmentConfig.required && (isCreate || attachmentMissing) + ? schemaRequired.concat("__attachment__") + : schemaRequired; + + // On forms, there is no need to have the "attachment" attribute fields. They + // are shown in the attachment infos, and all assigned by the server automatically + // on file upload. + let schemaProperties = omit(schema.properties, ["attachment"]); + + return { + ...schema, + required, + properties: { + ...schemaProperties, + __attachment__: { + type: "string", + format: "data-url", + title: "File attachment", + }, + }, + }; +} + +export function extendUiSchemaWithAttachment( + uiSchema: UiSchema, + attachmentConfig: { enabled: boolean; required: boolean } | null | undefined +): UiSchema { + if ( + !attachmentConfig || + !attachmentConfig.enabled || + !Object.prototype.hasOwnProperty.call(uiSchema, "ui:order") + ) { + return uiSchema; + } + return { + ...uiSchema, + "ui:order": [...uiSchema["ui:order"], "__attachment__"], + }; +} + +function AttachmentPreview({ mimetype, location }) { + if (!mimetype.startsWith("image/")) { + return null; + } else { + return ( +
+ + + +
+ ); + } +} + +export type AttachmentInfoProps = { + record?: RecordState; + allowEditing: boolean; + attachmentRequired: boolean | null | undefined; + deleteAttachment: () => void; + capabilities: Capabilities; +}; + +export function AttachmentInfo(props: AttachmentInfoProps) { + const { + allowEditing, + record: recordState, + attachmentRequired, + deleteAttachment, + capabilities, + } = props; + if (recordState == null) { + return null; + } + const { data: record } = recordState; + const { attachment } = record; + if (!attachment) { + return null; + } + + const attachmentURL = buildAttachmentUrl(record, capabilities); + + const FileSize: React.FC<{ bytes: number }> = ({ bytes }) => { + return <>{filesize(bytes)}; + }; + + return ( +
+
+ + Attachment information +
+
+ {allowEditing && attachmentRequired && ( +
+

+ An attachment is required for records in this collection. To + replace current attachment, use the File attachment field + below. +

+
+ )} +
+
{renderDisplayField(record, displayField)}{lastModified()} +
+ {attachmentUrl && ( + + + + )} + + + + + + + +
+
+ {getFieldTitle(displayField)} + {isSchemaProperty(displayField) && ( + + )} + + Last mod. + + +
+ + + + + + + + + + + + + + + + + + + + + + +
Location + + {attachment.location} + +
Filename{attachment.filename}
Size + +
Hash{attachment.hash}
Mime-Type{attachment.mimetype}
+ {attachment.original && ( + + + + + + + + + + + + + + + + + + +
Pre-gzipped file +
{attachment.original.filename}
+ +
{attachment.original.hash}
{attachment.original.mimetype}
+ )} +
+ + {!attachmentRequired && ( +

+ +

+ )} +
+
+
+
+ ); +} diff --git a/src/components/record/JSONRecordForm.tsx b/src/components/record/JSONRecordForm.tsx index 0e8946d16..fd430f51a 100644 --- a/src/components/record/JSONRecordForm.tsx +++ b/src/components/record/JSONRecordForm.tsx @@ -1,28 +1,21 @@ -import { PureComponent } from "react"; -import * as React from "react"; - +import React from "react"; import BaseForm from "../BaseForm"; import JSONEditor from "../JSONEditor"; -import { validJSON } from "../../utils"; +import { RJSFSchema, UiSchema } from "@rjsf/utils"; -const schema = { +const schema: RJSFSchema = { type: "string", title: "JSON record", default: "{}", }; -const uiSchema = { - "ui:widget": JSONEditor, - "ui:help": "This must be valid JSON.", +const uiSchema: UiSchema = { + data: { + "ui:widget": JSONEditor, + "ui:help": "This must be valid JSON.", + }, }; -function validate(json, errors) { - if (!validJSON(json)) { - errors.addError("Invalid JSON."); - } - return errors; -} - type Props = { disabled: boolean; record: string; // JSON string representation of a record data @@ -30,25 +23,26 @@ type Props = { children?: React.ReactNode; }; -export default class JSONRecordForm extends PureComponent { - onSubmit = (data: { formData: string }) => { - this.props.onSubmit({ ...data, formData: JSON.parse(data.formData) }); +export default function JSONRecordForm({ + disabled, + record, + onSubmit, + children, +}: Props) { + const handleOnSubmit = data => { + onSubmit({ ...data, formData: JSON.parse(data.formData) }); }; - render() { - const { record, disabled, children } = this.props; - return ( -
- - {children} - -
- ); - } + return ( +
+ + {children} + +
+ ); } diff --git a/src/components/record/RecordAttributes.tsx b/src/components/record/RecordAttributes.tsx index 3f6cbb4b4..856ca15f8 100644 --- a/src/components/record/RecordAttributes.tsx +++ b/src/components/record/RecordAttributes.tsx @@ -1,3 +1,5 @@ +import React from "react"; + import type { Capabilities, SessionState, @@ -7,8 +9,6 @@ import type { RecordRouteMatch, } from "../../types"; -import React, { PureComponent } from "react"; - import * as CollectionActions from "../../actions/collection"; import RecordForm from "./RecordForm"; import RecordTabs from "./RecordTabs"; @@ -32,61 +32,55 @@ export type Props = OwnProps & updateRecord: typeof CollectionActions.updateRecord; }; -export default class RecordAttributes extends PureComponent { - onSubmit = ({ __attachment__: attachment, ...record }: any) => { - const { match, updateRecord } = this.props; - const { - params: { bid, cid, rid }, - } = match; - updateRecord(bid, cid, rid, { data: record }, attachment); - }; +export default function RecordAttributes({ + match, + session, + capabilities, + bucket, + collection, + record, + deleteRecord, + deleteAttachment, + updateRecord, +}: Props) { + const { + params: { bid, cid, rid }, + } = match; - render() { - const { - match, - session, - capabilities, - bucket, - collection, - record, - deleteRecord, - deleteAttachment, - } = this.props; - const { - params: { bid, cid, rid }, - } = match; + const onSubmit = ({ __attachment__: attachment, ...recordData }: any) => { + updateRecord(bid, cid, rid, { data: recordData }, attachment); + }; - return ( -
-

- Edit{" "} - - {bid}/{cid}/{rid} - {" "} - record attributes -

- +

+ Edit{" "} + + {bid}/{cid}/{rid} + {" "} + record attributes +

+ + - - -
- ); - } + /> + + + ); } diff --git a/src/components/record/RecordBulk.tsx b/src/components/record/RecordBulk.tsx index df3c9d719..8fe207cb9 100644 --- a/src/components/record/RecordBulk.tsx +++ b/src/components/record/RecordBulk.tsx @@ -1,6 +1,6 @@ -import type { CollectionState, CollectionRouteMatch } from "../../types"; +import React, { useCallback } from "react"; -import React, { PureComponent } from "react"; +import type { CollectionState, CollectionRouteMatch } from "../../types"; import * as CollectionActions from "../../actions/collection"; import * as NotificationActions from "../../actions/notifications"; @@ -11,7 +11,7 @@ import JSONEditor from "../JSONEditor"; import { extendSchemaWithAttachment, extendUiSchemaWithAttachment, -} from "./RecordForm"; +} from "./AttachmentInfo"; export type OwnProps = { match: CollectionRouteMatch; @@ -27,108 +27,112 @@ export type Props = OwnProps & notifyError: typeof NotificationActions.notifyError; }; -export default class RecordBulk extends PureComponent { - onSubmit = ({ formData }: { formData: any[] }) => { - const { match, collection, notifyError, bulkCreateRecords } = this.props; - const { - params: { bid, cid }, - } = match; - const { - data: { schema = {} }, - } = collection; +export default function RecordBulk({ + match, + collection, + notifyError, + bulkCreateRecords, +}: Props) { + const onSubmit = useCallback( + ({ formData }) => { + const { + params: { bid, cid }, + } = match; + const { + data: { schema = {} }, + } = collection; - if (formData.length === 0) { - return notifyError("The form is empty."); - } + if (formData.length === 0) { + return notifyError("The form is empty."); + } - if (Object.keys(schema).length === 0) { - return bulkCreateRecords( - bid, - cid, - formData.map(json => JSON.parse(json)) - ); - } + if (Object.keys(schema).length === 0) { + return bulkCreateRecords( + bid, + cid, + formData.map(json => JSON.parse(json)) + ); + } - bulkCreateRecords(bid, cid, formData); - }; + bulkCreateRecords(bid, cid, formData); + }, + [match, collection, notifyError, bulkCreateRecords] + ); - render() { - const { match, collection } = this.props; - const { - busy, - data: { schema = {}, uiSchema = {}, attachment }, - } = collection; - const { - params: { bid, cid }, - } = match; + const { + busy, + data: { schema = {}, uiSchema = {}, attachment }, + } = collection; + const { + params: { bid, cid }, + } = match; - let bulkSchema, bulkUiSchema, bulkFormData; + let bulkSchema, bulkUiSchema, bulkFormData; - if (Object.keys(schema).length !== 0) { - bulkSchema = { - type: "array", - definitions: schema.definitions, - items: extendSchemaWithAttachment( - schema, - attachment, - {} /* as for create record */ - ), - }; - bulkUiSchema = { - items: extendUiSchemaWithAttachment(uiSchema, attachment), - }; - bulkFormData = [{}, {}]; - } else { - bulkSchema = { - type: "array", - items: { - type: "string", - title: "JSON record", - default: "{}", - }, - }; - bulkUiSchema = { - items: { - "ui:widget": JSONEditor, - }, - }; - bulkFormData = ["{}", "{}"]; - } + if (Object.keys(schema).length !== 0) { + bulkSchema = { + type: "array", + definitions: schema.definitions, + items: extendSchemaWithAttachment( + schema, + attachment, + {} /* as for create record */ + ), + }; + bulkUiSchema = { + items: extendUiSchemaWithAttachment(uiSchema, attachment), + }; + bulkFormData = [{}, {}]; + } else { + bulkSchema = { + type: "array", + items: { + type: "string", + title: "JSON record", + default: "{}", + }, + }; + bulkUiSchema = { + items: { + "ui:widget": JSONEditor, + }, + }; + bulkFormData = ["{}", "{}"]; + } - return ( -
-

- Bulk{" "} - - {bid}/{cid} - {" "} - creation -

- {busy ? ( - - ) : ( -
-
- - - {" or "} - - Cancel - - -
+ return ( +
+

+ Bulk{" "} + + {bid}/{cid} + {" "} + creation +

+ {busy ? ( + + ) : ( +
+
+ + + {" or "} + + Cancel + +
- )} -
- ); - } +
+ )} +
+ ); } diff --git a/src/components/record/RecordCreate.tsx b/src/components/record/RecordCreate.tsx index f89370259..b659d8a41 100644 --- a/src/components/record/RecordCreate.tsx +++ b/src/components/record/RecordCreate.tsx @@ -1,3 +1,5 @@ +import React, { useCallback } from "react"; + import type { SessionState, BucketState, @@ -6,8 +8,6 @@ import type { Capabilities, } from "../../types"; -import React, { PureComponent } from "react"; - import * as CollectionActions from "../../actions/collection"; import RecordForm from "./RecordForm"; @@ -27,42 +27,49 @@ export type Props = OwnProps & createRecord: typeof CollectionActions.createRecord; }; -export default class RecordCreate extends PureComponent { - onSubmit = ({ __attachment__: attachment, ...record }: any) => { - const { match, createRecord } = this.props; - const { - params: { bid, cid }, - } = match; - createRecord(bid, cid, record, attachment); - }; +export default function RecordCreate({ + match, + session, + capabilities, + bucket, + collection, + createRecord, +}: Props) { + const onSubmit = useCallback( + ({ __attachment__: attachment, ...record }) => { + const { + params: { bid, cid }, + } = match; + createRecord(bid, cid, record, attachment); + }, + [match, createRecord] + ); + + const { + params: { bid, cid }, + } = match; - render() { - const { match, session, bucket, collection, capabilities } = this.props; - const { - params: { bid, cid }, - } = match; - return ( -
-

- Add a new record in{" "} - - {bid}/{cid} - -

-
-
- -
+ return ( +
+

+ Add a new record in{" "} + + {bid}/{cid} + +

+
+
+
- ); - } +
+ ); } diff --git a/src/components/record/RecordForm.tsx b/src/components/record/RecordForm.tsx index 8cfaf5ca9..b52b14548 100644 --- a/src/components/record/RecordForm.tsx +++ b/src/components/record/RecordForm.tsx @@ -1,67 +1,26 @@ +import React, { useState } from "react"; import type { SessionState, BucketState, CollectionState, RecordState, - RecordData, Capabilities, } from "../../types"; - -import React, { PureComponent } from "react"; -import { filesize } from "filesize"; - import { Check2 } from "react-bootstrap-icons"; import { Trash } from "react-bootstrap-icons"; -import { Paperclip } from "react-bootstrap-icons"; - import * as CollectionActions from "../../actions/collection"; import BaseForm from "../BaseForm"; import AdminLink from "../AdminLink"; import Spinner from "../Spinner"; import JSONRecordForm from "./JSONRecordForm"; import { canCreateRecord, canEditRecord } from "../../permission"; -import { buildAttachmentUrl, omit } from "../../utils"; - -export function extendSchemaWithAttachment( - schema: any, - attachmentConfig: { enabled: boolean; required: boolean } | null | undefined, - record: RecordData -): any { - if (!attachmentConfig || !attachmentConfig.enabled) { - return schema; - } - const isCreate = !record.id; - const attachmentMissing = record.attachment && record.attachment.location; - - // We add a fake schema field ``__attachment__`` for the file input. - // It will be required if the - // Attachment form field is only required to receive a file - // required, when creating a record, or updating it if it does not have any. - // (required setting changed in the mean time). - const schemaRequired = schema.required || []; - const required = - attachmentConfig.required && (isCreate || attachmentMissing) - ? schemaRequired.concat("__attachment__") - : schemaRequired; +import { + AttachmentInfo, + extendSchemaWithAttachment, + extendUiSchemaWithAttachment, +} from "./AttachmentInfo"; - // On forms, there is no need to have the "attachment" attribute fields. They - // are shown in the attachment infos, and all assigned by the server automatically - // on file upload. - let schemaProperties = omit(schema.properties, ["attachment"]); - - return { - ...schema, - required, - properties: { - ...schemaProperties, - __attachment__: { - type: "string", - format: "data-url", - title: "File attachment", - }, - }, - }; -} +import { RJSFSchema } from "@rjsf/utils"; export function extendUIWithKintoFields(uiSchema: any, isCreate: boolean): any { return { @@ -84,165 +43,10 @@ export function extendUIWithKintoFields(uiSchema: any, isCreate: boolean): any { }; } -export function extendUiSchemaWithAttachment( - uiSchema: any, - attachmentConfig: { enabled: boolean; required: boolean } | null | undefined -): any { - if ( - !attachmentConfig || - !attachmentConfig.enabled || - !Object.prototype.hasOwnProperty.call(uiSchema, "ui:order") - ) { - return uiSchema; - } - return { - ...uiSchema, - "ui:order": [...uiSchema["ui:order"], "__attachment__"], - }; -} - export function extendUiSchemaWhenDisabled(uiSchema: any, disabled: boolean) { return { ...uiSchema, "ui:disabled": disabled }; } -function AttachmentPreview({ mimetype, location }) { - if (!mimetype.startsWith("image/")) { - return null; - } else { - return ( -
- - - -
- ); - } -} - -type AttachmentInfoProps = { - record?: RecordState; - allowEditing: boolean; - attachmentRequired: boolean | null | undefined; - deleteAttachment: () => void; - capabilities: Capabilities; -}; - -function AttachmentInfo(props: AttachmentInfoProps) { - const { - allowEditing, - record: recordState, - attachmentRequired, - deleteAttachment, - capabilities, - } = props; - if (recordState == null) { - return null; - } - const { data: record } = recordState; - const { attachment } = record; - if (!attachment) { - return null; - } - - const attachmentURL = buildAttachmentUrl(record, capabilities); - - const FileSize: React.FC<{ bytes: number }> = ({ bytes }) => { - return <>{filesize(bytes)}; - }; - - return ( -
-
- - Attachment information -
-
- {allowEditing && attachmentRequired && ( -
-

- An attachment is required for records in this collection. To - replace current attachment, use the File attachment field - below. -

-
- )} -
- - - - - - - - - - - - - - - - - - - - - - - -
Location - - {attachment.location} - -
Filename{attachment.filename}
Size - -
Hash{attachment.hash}
Mime-Type{attachment.mimetype}
- {attachment.original && ( - - - - - - - - - - - - - - - - - - -
Pre-gzipped file -
{attachment.original.filename}
- -
{attachment.original.hash}
{attachment.original.mimetype}
- )} -
- - {!attachmentRequired && ( -

- -

- )} -
-
-
-
- ); -} - type Props = { bid: string; cid: string; @@ -253,48 +57,60 @@ type Props = { record?: RecordState; deleteRecord?: typeof CollectionActions.deleteRecord; deleteAttachment?: typeof CollectionActions.deleteAttachment; - onSubmit: (data: RecordData) => void; + onSubmit: (data) => void; capabilities: Capabilities; }; -type State = { - asJSON: boolean; -}; - -export default class RecordForm extends PureComponent { - constructor(props: any) { - super(props); - this.state = { asJSON: false }; - } +export default function RecordForm(props: Props) { + const [asJSON, setAsJSON] = useState(false); - onSubmit = ({ formData }: { formData: any }) => { - this.props.onSubmit(formData); - }; + const { + bid, + cid, + session, + bucket, + collection, + record, + deleteRecord, + onSubmit, + capabilities, + } = props; - get allowEditing(): boolean { - const { session, bucket, collection, record } = this.props; - if (record) { - return canEditRecord(session, bucket, collection, record); - } else { - return canCreateRecord(session, bucket, collection); - } - } + const allowEditing = record + ? canEditRecord(session, bucket, collection, record) + : canCreateRecord(session, bucket, collection); - deleteRecord = () => { - const { deleteRecord, bid, cid, rid } = this.props; + const handleDeleteRecord = () => { + const { rid } = props; if (rid && deleteRecord && confirm("Are you sure?")) { deleteRecord(bid, cid, rid); } }; - getForm() { - const { asJSON } = this.state; - const { bid, cid, collection, record } = this.props; + const handleDeleteAttachment = () => { + const { rid, deleteAttachment } = props; + if (rid && deleteAttachment) { + deleteAttachment(bid, cid, rid); + } + }; + + const handleToggleJSON = ( + event: React.MouseEvent + ) => { + event.preventDefault(); + setAsJSON(!asJSON); + }; + + const handleOnSubmit = ({ formData }: RJSFSchema) => { + onSubmit(formData); + }; + + const getForm = () => { + const { collection, record } = props; const { data: { schema = {}, uiSchema = {}, attachment }, } = collection; const emptySchema = Object.keys(schema).length === 0; - const recordData = record ? record.data : {}; // Show a spinner if the collection metadata is being loaded, or the record @@ -309,7 +125,7 @@ export default class RecordForm extends PureComponent {
- {this.allowEditing && record && ( + {allowEditing && record && ( @@ -351,9 +167,9 @@ export default class RecordForm extends PureComponent {
)} {buttons} @@ -364,62 +180,47 @@ export default class RecordForm extends PureComponent { const _schema = extendSchemaWithAttachment(schema, attachment, recordData); let _uiSchema = extendUIWithKintoFields(uiSchema, !record); _uiSchema = extendUiSchemaWithAttachment(_uiSchema, attachment); - _uiSchema = extendUiSchemaWhenDisabled(_uiSchema, !this.allowEditing); + _uiSchema = extendUiSchemaWhenDisabled(_uiSchema, !allowEditing); return ( {buttons} ); - } - - deleteAttachment = () => { - const { bid, cid, rid, deleteAttachment } = this.props; - if (rid && deleteAttachment) { - deleteAttachment(bid, cid, rid); - } - }; - - toggleJSON = (event: React.MouseEvent) => { - event.preventDefault(); - this.setState({ asJSON: !this.state.asJSON }); }; - render() { - const { collection, record, capabilities } = this.props; - const { - data: { attachment: attachmentConfig }, - } = collection; - const attachmentRequired = attachmentConfig && attachmentConfig.required; - const isUpdate = !!record; - - const alert = - this.allowEditing || collection.busy ? null : ( -
- You don't have the required permission to - {isUpdate ? " edit this" : " create a"} record. -
- ); - - return ( -
- {alert} - {isUpdate && ( - - )} - {this.getForm()} + const { + data: { attachment: attachmentConfig }, + } = collection; + const attachmentRequired = attachmentConfig && attachmentConfig.required; + const isUpdate = !!record; + + const alert = + allowEditing || collection.busy ? null : ( +
+ You don't have the required permission to + {isUpdate ? " edit this" : " create a"} record.
); - } + + return ( +
+ {alert} + {isUpdate && ( + + )} + {getForm()} +
+ ); } diff --git a/src/components/record/RecordHistory.tsx b/src/components/record/RecordHistory.tsx index 0b9e8536b..a83f4026e 100644 --- a/src/components/record/RecordHistory.tsx +++ b/src/components/record/RecordHistory.tsx @@ -6,7 +6,7 @@ import type { } from "../../types"; import type { Location } from "history"; -import React, { PureComponent } from "react"; +import React, { useEffect } from "react"; import * as RecordActions from "../../actions/record"; import * as NotificationActions from "../../actions/notifications"; @@ -42,56 +42,52 @@ export const onRecordHistoryEnter = (props: Props) => { listRecordHistory(bid, cid, rid); }; -export default class RecordHistory extends PureComponent { - componentDidMount = () => onRecordHistoryEnter(this.props); - componentDidUpdate = (prevProps: Props) => { - if (prevProps.location !== this.props.location) { - onRecordHistoryEnter(this.props); - } - }; +export default function RecordHistory(props: Props) { + const { + match, + location, + record, + capabilities, + listRecordNextHistory, + notifyError, + } = props; - render() { - const { - match, - record, - capabilities, - location, - listRecordNextHistory, - notifyError, - } = this.props; - const { - params: { bid, cid, rid }, - } = match; - const { - history: { entries, loaded, hasNextPage }, - } = record; + useEffect(() => { + onRecordHistoryEnter(props); + }, []); + + const { + params: { bid, cid, rid }, + } = match; + const { + history: { entries, loaded, hasNextPage }, + } = record; - return ( -
-

- History for{" "} - - {bid}/{cid}/{rid} - -

- +

+ History for{" "} + + {bid}/{cid}/{rid} + +

+ + - - -
- ); - } + historyLoaded={loaded} + history={entries} + hasNextHistory={hasNextPage} + listNextHistory={listRecordNextHistory} + location={location} + notifyError={notifyError} + /> + +
+ ); } diff --git a/src/components/record/RecordPermissions.tsx b/src/components/record/RecordPermissions.tsx index e99144f77..ca115d13e 100644 --- a/src/components/record/RecordPermissions.tsx +++ b/src/components/record/RecordPermissions.tsx @@ -13,7 +13,7 @@ interface RouteParams { cid: string; rid: string; } -export const RecordPermissions = () => { +export function RecordPermissions() { const bucket = useAppSelector(state => state.bucket); const session = useAppSelector(state => state.session); const collection = useAppSelector(state => state.collection); @@ -56,4 +56,4 @@ export const RecordPermissions = () => {
); -}; +} diff --git a/src/components/record/RecordTabs.tsx b/src/components/record/RecordTabs.tsx index 3dabc43db..37a4d6d99 100644 --- a/src/components/record/RecordTabs.tsx +++ b/src/components/record/RecordTabs.tsx @@ -1,7 +1,6 @@ import type { Capabilities } from "../../types"; -import { PureComponent } from "react"; -import * as React from "react"; +import React from "react"; import { Gear } from "react-bootstrap-icons"; import { Lock } from "react-bootstrap-icons"; @@ -18,56 +17,59 @@ type Props = { children?: React.ReactNode; }; -export default class RecordTabs extends PureComponent { - render() { - const { bid, cid, rid, selected, capabilities, children } = this.props; - - return ( -
-
-
    -
  • - - - Attributes - -
  • +export default function RecordTabs({ + bid, + cid, + rid, + selected, + capabilities, + children, +}: Props) { + return ( +
    +
    +
      +
    • + + + Attributes + +
    • +
    • + + + Permissions + +
    • + {"history" in capabilities && (
    • - - Permissions + + History
    • - {"history" in capabilities && ( -
    • - - - History - -
    • - )} -
    -
    -
    {children}
    + )} +
- ); - } +
{children}
+
+ ); } From 82ced35b8fbc21182ab87a853849723b4adcd32d Mon Sep 17 00:00:00 2001 From: Alex Cottner Date: Fri, 27 Oct 2023 14:27:39 -0600 Subject: [PATCH 6/9] Migrated all signoff components to functional components. Added rjsf types. --- src/components/signoff/Comment.tsx | 87 +++ src/components/signoff/HumanDate.tsx | 14 + src/components/signoff/ProgressBar.tsx | 23 +- src/components/signoff/Review.tsx | 177 +++++++ src/components/signoff/Signed.tsx | 75 +++ src/components/signoff/SignoffToolBar.tsx | 616 +++++----------------- 6 files changed, 487 insertions(+), 505 deletions(-) create mode 100644 src/components/signoff/Comment.tsx create mode 100644 src/components/signoff/HumanDate.tsx create mode 100644 src/components/signoff/Review.tsx create mode 100644 src/components/signoff/Signed.tsx diff --git a/src/components/signoff/Comment.tsx b/src/components/signoff/Comment.tsx new file mode 100644 index 000000000..0ae5a2dd2 --- /dev/null +++ b/src/components/signoff/Comment.tsx @@ -0,0 +1,87 @@ +import React, { useState } from "react"; + +export function Comment({ text }: { text: string }) { + return ( + + {text.split("\n").map((l, i) => ( + + {l} +
+
+ ))} +
+ ); +} + +type CommentDialogProps = { + description: string; + confirmLabel: string; + onConfirm: (s: string) => void; + onCancel: () => void; +}; + +export function CommentDialog({ + description, + confirmLabel, + onConfirm, + onCancel, +}: CommentDialogProps) { + const [comment, setComment] = useState(""); + + const onCommentChange = (e: React.ChangeEvent) => { + setComment(e.target.value); + }; + + const onClickConfirm = () => onConfirm(comment); + + return ( +
+
+
+
+
Confirmation
+ +
+
+

{description}

+