diff --git a/babel.config.js b/babel.config.js index dccab04623..7762bd8bbe 100644 --- a/babel.config.js +++ b/babel.config.js @@ -9,5 +9,9 @@ module.exports = { ], '@babel/preset-typescript', ], - plugins: ['@babel/plugin-proposal-class-properties', '@babel/plugin-proposal-optional-chaining'], + plugins: [ + '@babel/plugin-proposal-class-properties', + '@babel/plugin-proposal-optional-chaining', + '@babel/plugin-transform-class-static-block', + ], }; diff --git a/package-lock.json b/package-lock.json index 0d7dd40883..a1f87fc303 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ ], "devDependencies": { "@babel/eslint-parser": "^7.23.10", + "@babel/plugin-transform-class-static-block": "^7.26.0", "@nrwl/nx-cloud": "^15.3.5", "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^14.2.1", @@ -67,10 +68,11 @@ } }, "node_modules/@adobe/css-tools": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.3.tgz", - "integrity": "sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==", - "dev": true + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.2.tgz", + "integrity": "sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==", + "dev": true, + "license": "MIT" }, "node_modules/@algolia/autocomplete-core": { "version": "1.9.3", @@ -354,81 +356,19 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", - "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "license": "MIT", "dependencies": { - "@babel/highlight": "^7.23.4", - "chalk": "^2.4.2" + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/code-frame/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/code-frame/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/code-frame/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/compat-data": { "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", @@ -485,25 +425,28 @@ } }, "node_modules/@babel/generator": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", - "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", + "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.23.6", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", - "jsesc": "^2.5.1" + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", - "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -536,18 +479,17 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.23.10", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.23.10.tgz", - "integrity": "sha512-2XpP2XhkXzgxecPNEEK8Vz8Asj9aRxt08oKOqtiZoqV2UGZ5T+EkyP9sXQ9nwMxBIG34a7jmasVqoMop7VdPUw==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.26.9.tgz", + "integrity": "sha512-ubbUqCofvxPRurw5L8WTsCLSkQiVpov4Qx0WMA+jUN+nXBK8ADPlJO1grkFw5CWKC5+sZSOfuGMdX1aI1iT9Sg==", + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-member-expression-to-functions": "^7.23.0", - "@babel/helper-optimise-call-expression": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.20", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/helper-replace-supers": "^7.26.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/traverse": "^7.26.9", "semver": "^6.3.1" }, "engines": { @@ -620,11 +562,13 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz", - "integrity": "sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", + "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.23.0" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -660,20 +604,22 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz", - "integrity": "sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", + "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", - "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", + "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -695,13 +641,14 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.22.20.tgz", - "integrity": "sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.26.5.tgz", + "integrity": "sha512-bJ6iIVdYX1YooY2X7w1q6VITt+LnUILtNk7zT78ykuwStx8BauCzxvFqFaHjOpW1bVnSUM1PN1f0p5P21wHxvg==", + "license": "MIT", "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-member-expression-to-functions": "^7.22.15", - "@babel/helper-optimise-call-expression": "^7.22.5" + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/traverse": "^7.26.5" }, "engines": { "node": ">=6.9.0" @@ -722,11 +669,13 @@ } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz", - "integrity": "sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", + "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -744,17 +693,19 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -793,87 +744,14 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/highlight": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", - "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", - "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "node_modules/@babel/parser": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", + "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "license": "MIT", "dependencies": { - "has-flag": "^3.0.0" + "@babel/types": "^7.26.9" }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/parser": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", - "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==", "bin": { "parser": "bin/babel-parser.js" }, @@ -1340,13 +1218,13 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.23.4.tgz", - "integrity": "sha512-nsWu/1M+ggti1SOALj3hfx5FXzAY06fwPJsUZD4/A5e1bWi46VUIWtD+kOX6/IdhXGsXBWllLFDSnqSCdUNydQ==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", + "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", + "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-class-static-block": "^7.14.5" + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2314,31 +2192,30 @@ } }, "node_modules/@babel/template": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.23.9.tgz", - "integrity": "sha512-+xrD2BWLpvHKNmX2QbpdpsBaWnRxahMwJjO+KZk2JOElj5nSmKezyS1B4u+QbHMTX69t4ukm6hh9lsYQ7GHCKA==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/parser": "^7.23.9", - "@babel/types": "^7.23.9" + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.9.tgz", - "integrity": "sha512-I/4UJ9vs90OkBtY6iiiTORVMyIhJ4kAVmsKo9KFc8UOxMeUfi2hvtIBsET5u9GizXE6/GFSuKCTNfgCswuEjRg==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", + "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.9", - "@babel/types": "^7.23.9", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -2355,13 +2232,13 @@ } }, "node_modules/@babel/types": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.9.tgz", - "integrity": "sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", + "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -6511,13 +6388,14 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.0.1", + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" @@ -6532,9 +6410,10 @@ } }, "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "license": "MIT", "engines": { "node": ">=6.0.0" } @@ -6554,9 +6433,10 @@ "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.22", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", - "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -9903,48 +9783,24 @@ "dev": true }, "node_modules/@testing-library/jest-dom": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.2.tgz", - "integrity": "sha512-CzqH0AFymEMG48CpzXFriYYkOjk6ZGPCLMhW9e9jg3KMCn5OfJecF8GtGW7yGfR/IgCe3SX8BSwjdzI6BBbZLw==", + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", + "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", "dev": true, + "license": "MIT", "dependencies": { - "@adobe/css-tools": "^4.3.2", - "@babel/runtime": "^7.9.2", + "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "chalk": "^3.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", - "lodash": "^4.17.15", + "lodash": "^4.17.21", "redent": "^3.0.0" }, "engines": { "node": ">=14", "npm": ">=6", "yarn": ">=1" - }, - "peerDependencies": { - "@jest/globals": ">= 28", - "@types/bun": "latest", - "@types/jest": ">= 28", - "jest": ">= 28", - "vitest": ">= 0.32" - }, - "peerDependenciesMeta": { - "@jest/globals": { - "optional": true - }, - "@types/bun": { - "optional": true - }, - "@types/jest": { - "optional": true - }, - "jest": { - "optional": true - }, - "vitest": { - "optional": true - } } }, "node_modules/@testing-library/jest-dom/node_modules/chalk": { @@ -9978,6 +9834,20 @@ "react-dom": "^18.0.0" } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -20635,14 +20505,15 @@ } }, "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, "engines": { - "node": ">=4" + "node": ">=6" } }, "node_modules/json-buffer": { @@ -30828,14 +30699,6 @@ "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "dev": true }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "engines": { - "node": ">=4" - } - }, "node_modules/to-readable-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz", @@ -34235,6 +34098,9 @@ "@rjsf/snapshot-tests": "^6.0.0-alpha.0", "@rjsf/utils": "^6.0.0-alpha.0", "@rjsf/validator-ajv8": "^6.0.0-alpha.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^14.6.1", "@types/jest": "^29.5.12", "@types/lodash": "^4.14.202", "@types/react": "^18.2.58", @@ -34267,6 +34133,34 @@ "react": "^16.14.0 || >=17" } }, + "packages/core/node_modules/@testing-library/react": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.2.0.tgz", + "integrity": "sha512-2cSskAvA1QNtKc8Y9VJQRv0tm3hLVgxRGDB+KYhIaPQJ1I+RHbhIXcM+zClKXzMes/wshsMVzf4B9vS4IZpqDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "packages/core/node_modules/ajv": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", diff --git a/package.json b/package.json index 0f51885349..b6a71a85f6 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "homepage": "https://github.com/rjsf-team/react-jsonschema-form", "devDependencies": { "@babel/eslint-parser": "^7.23.10", + "@babel/plugin-transform-class-static-block": "^7.26.0", "@nrwl/nx-cloud": "^15.3.5", "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^14.2.1", diff --git a/packages/core/package.json b/packages/core/package.json index 4db01b6888..59ad100a1b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -57,6 +57,9 @@ "@rjsf/snapshot-tests": "^6.0.0-alpha.0", "@rjsf/utils": "^6.0.0-alpha.0", "@rjsf/validator-ajv8": "^6.0.0-alpha.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^14.6.1", "@types/jest": "^29.5.12", "@types/lodash": "^4.14.202", "@types/react": "^18.2.58", diff --git a/packages/core/src/components/fields/ArrayField.tsx b/packages/core/src/components/fields/ArrayField.tsx index 4bc2713b8b..40d74b7e5f 100644 --- a/packages/core/src/components/fields/ArrayField.tsx +++ b/packages/core/src/components/fields/ArrayField.tsx @@ -612,7 +612,7 @@ class ArrayField(itemsSchema, uiSchema); + const enumOptions = optionsList(itemsSchema, uiSchema); const { widget = 'select', title: uiTitle, ...options } = getUiOptions(uiSchema, globalUiOptions); const Widget = getWidget(schema, widget, widgets); const label = uiTitle ?? schema.title ?? name; diff --git a/packages/core/src/components/fields/BooleanField.tsx b/packages/core/src/components/fields/BooleanField.tsx index 38b95d83b2..34db0751b3 100644 --- a/packages/core/src/components/fields/BooleanField.tsx +++ b/packages/core/src/components/fields/BooleanField.tsx @@ -52,7 +52,7 @@ function BooleanField[] | undefined; const label = uiTitle ?? schemaTitle ?? title ?? name; if (Array.isArray(schema.oneOf)) { - enumOptions = optionsList( + enumOptions = optionsList( { oneOf: schema.oneOf .map((option) => { @@ -84,7 +84,7 @@ function BooleanField( + enumOptions = optionsList( { enum: enums, // NOTE: enumNames is deprecated, but still supported for now. diff --git a/packages/core/src/components/fields/LayoutGridField.tsx b/packages/core/src/components/fields/LayoutGridField.tsx new file mode 100644 index 0000000000..f136fe9aa3 --- /dev/null +++ b/packages/core/src/components/fields/LayoutGridField.tsx @@ -0,0 +1,946 @@ +import { ComponentType, PureComponent, ReactNode } from 'react'; +import { + ANY_OF_KEY, + ErrorSchema, + FieldProps, + FormContextType, + GenericObjectType, + getDiscriminatorFieldFromSchema, + getTemplate, + getTestIds, + getUiOptions, + hashObject, + ID_KEY, + IdSchema, + lookupFromFormContext, + mergeObjects, + ONE_OF_KEY, + PROPERTIES_KEY, + READONLY_KEY, + RJSFSchema, + Registry, + SchemaUtilsType, + StrictRJSFSchema, + UI_OPTIONS_KEY, + UiSchema, +} from '@rjsf/utils'; +import cloneDeep from 'lodash/cloneDeep'; +import flatten from 'lodash/flatten'; +import get from 'lodash/get'; +import has from 'lodash/has'; +import includes from 'lodash/includes'; +import intersection from 'lodash/intersection'; +import isEmpty from 'lodash/isEmpty'; +import isFunction from 'lodash/isFunction'; +import isEqual from 'lodash/isEqual'; +import isObject from 'lodash/isObject'; +import isPlainObject from 'lodash/isPlainObject'; +import isString from 'lodash/isString'; +import isUndefined from 'lodash/isUndefined'; +import set from 'lodash/set'; + +/** The enumeration of the three different Layout GridTemplate type values + */ +export enum GridType { + ROW = 'ui:row', + COLUMN = 'ui:col', + COLUMNS = 'ui:columns', + CONDITION = 'ui:condition', +} + +/** The enumeration of the different operators within a condition + */ +export enum Operators { + ALL = 'all', + SOME = 'some', + NONE = 'none', +} + +/** Type used to represent an object that contains anything */ +type ConfigObject = Record; + +export interface GridProps extends GenericObjectType { + /** The optional operator to use when comparing a field's value with the expected value for `GridType.CONDITION` + */ + operator?: Operators; + /** The optional name of the field from which to get the value for `GridType.CONDITION` + */ + field?: string; + /** The optional expected value against which to compare the field's value using the `operator` + */ + value?: unknown; +} + +export type GridSchemaType = { + /** The limited set of props which are keyed using the `GridType` enumeration and return an object + */ + [gridType in GridType]?: object; +}; + +/** The types which comprise the possibilities for the `layoutGridSchema` prop + */ +export type LayoutGridSchemaType = GridSchemaType | ConfigObject | string; + +export interface LayoutGridFieldProps + extends FieldProps { + /** Optional string or object used to describe the current level of the `LayoutGridField` + */ + layoutGridSchema?: LayoutGridSchemaType; +} + +/** The constant representing the main layout grid schema option name in the `uiSchema` + */ +export const LAYOUT_GRID_UI_OPTION = 'layoutGrid'; + +/** The constant representing the main layout grid schema option name in the `uiSchema` + */ +export const LAYOUT_GRID_OPTION = `ui:${LAYOUT_GRID_UI_OPTION}`; + +/** The constant representing the global UI Options object potentially contained within the `uiSchema` + */ +export const UI_GLOBAL_OPTIONS = 'ui:global_options'; + +/** Type used to return options list and whether it has a discriminator */ +type OneOfOptionsInfoType = { options: S[]; hasDiscriminator: boolean }; + +/** Type used to represent a React-based rendering component */ +type RenderComponent = ComponentType; + +/** Type used to determine what are the UIComponent and props from the grid schema */ +type UIComponentPropsType = { + /** The name of the component */ + name: string; + /** The render component if specified */ + UIComponent: RenderComponent | null; + /** Any uiProps associated with the render component */ + uiProps: ConfigObject; + /** The special case where the component is immediately rendered */ + rendered: ReactNode; +}; + +/** Returns either the `value` if it is non-nullish or the fallback + * + * @param [value] - The potential value to return if it is non-nullish + * @param [fallback] - The fallback value to return if `value` is nullish + * @returns - `value` if it is non-nullish otherwise `fallback` + */ +function getNonNullishValue(value?: T, fallback?: T): T | undefined { + return value ?? fallback; +} + +/** The `LayoutGridField` will render a schema, uiSchema and formData combination out into a GridTemplate in the shape + * described in the uiSchema. To define the grid to use to render the elements within a field in the schema, provide in + * the uiSchema for that field the object contained under a `ui:LayoutGridField` element. E.g. (as a JSON object): + * + * ``` + * { + * "field1" : { + * "ui:field": "LayoutGridField", + * "ui:LayoutGridField": { + * "ui:row": { ... } + * } + * } + * } + * ``` + * + * The outermost level of a `LayoutGridField` is the `ui:row` that defines the nested rows, columns, and/or condition + * elements (i.e. "grid elements") in the grid. This definition is either a simple "grid elements" OR an object with + * native `GridTemplate` implementation specific props and a `children` array of "grid elements". E.g. (as JSON objects): + * + * Simple `ui:row` definition, without additional `GridTemplate` props: + * ``` + * "ui:row": [ + * { "ui:row"|"ui:column"|"ui:condition": ... }, + * ... + * ] + * ``` + * + * Complex `ui:row` definition, with additional `GridTemplate` (this example uses @mui/material/Grid2 native props): + * ``` + * "ui:row": { + * "spacing": 2, + * "size": { md": 4 }, + * "alignContent": "flex-start", + * "className": "GridRow", + * "children": [ + * { "ui:row"|"ui:column"|"ui:condition": ... }, + * ... + * ] + * } + * ``` + * + * NOTE: Special note about the native `className` prop values. All className values will automatically be looked up in + * the `formContext.lookupMap` in case they have been defined using a CSS-in-JS approach. In other words, from the + * example above, if the `Form` was constructed with a `lookupMap` set to `{ GridRow: cssInJs.GridRowClass }` + * then when rendered, the native `GridTemplate` will get the `className` with the value from + * `cssInJs.GridRowClass`. This automatic lookup will happen for any of the "grid elements" when rendering with + * `GridTemplate` props. If multiple className values are present, for example: + * `{ className: 'GridRow GridColumn' }`, the classNames are split apart, looked up individually, and joined + * together to form one className with the values from `cssInJs.GridRowClass` and `cssInJs.GridColumnClass`. + * + * The `ui:col` grid element is used to specify the list of columns within a grid row. A `ui:col` element can take on + * several forms: 1) a simple list of dotted-path field names within the root field; 2) a list of objects containing the + * dotted-path field `name` any other props that are gathered into `ui:options` for the field; 3) a list with a one-off + * `render` functional component with or without a non-field `name` identifier and any other to-be-spread props; and + * 4) an object with native `GridTemplate` implementation specific props and a `children` array with 1) or 2) or even a + * nested `ui:row` or a `ui:condition` containing a `ui:row` (although this should be used carefully). E.g. + * (as JSON objects): + * + * Simple `ui:col` definition, without additional `GridTemplate` props and form 1 only children: + * ``` + * "ui:col": ["innerField", "inner.grandChild", ...] + * ``` + * + * Complicated `ui:col` definition, without additional `GridTemplate` props and form 2 only children: + * ``` + * "ui:col": [ + * { "name": "innerField", "fullWidth": true }, + * { "name": "inner.grandChild", "convertOther": true }, + * ... + * ] + * ``` + * + * More complicated `ui:col` definition, without additional `GridTemplate` props and form 2 children, one being a + * one-off `render` functional component without a non-field `name` identifier + * ``` + * "ui:col": [ + * "innerField", + * { + * "render": "WizardNavButton", + * "isNext": true, + * "size": "large" + * } + * ] + * ``` + * + * Most complicated `ui:col` definition, additional `GridTemplate` props and form 1, 2 and 3 children (this example + * uses @mui/material/Grid2 native props): + * ``` + * "ui:col": { + * "size": { "md": 4 }, + * "className": "GridColumn", + * "children": [ + * "innerField", + * { "name": "inner.grandChild", "convertOther": true }, + * { "name": "customRender", "render": "CustomRender", toSpread: "prop-value" } + * { "ui:row|ui:condition": ... } + * ... + * ] + * } + * ``` + * + * NOTE: If a `name` prop does not exist or its value does not match any field in a schema, then it is assumed to be a + * custom `render` component. If the `render` prop does not exist, a null render will occur. If `render` is a + * string, its value will be looked up in the `formContext.lookupMap` first before defaulting to a null render. + * + * The `ui:columns` grid element is syntactic sugar to specify a set of `ui:col` columns that all share the same set of + * native `GridTemplate` props. In other words rather than writing the following configuration that renders a + * `` element with 3 `` nodes and 2 + * `` nodes within it (one for each of the fields contained in the `children` + * list): + * + * ``` + * "ui:row": { + * "children": [ + * { + * "ui:col": { + * "className": "GridColumn col-md-4", + * "children": ["innerField"], + * } + * }, + * { + * "ui:col": { + * "className": "GridColumn col-md-4", + * "children": ["inner.grandChild"], + * } + * }, + * { + * "ui:col": { + * "className": "GridColumn col-md-4", + * "children": [{ "name": "inner.grandChild2" }], + * } + * }, + * { + * "ui:col": { + * "className": "col-md-6", + * "children": ["innerField2"], + * } + * }, + * { + * "ui:col": { + * "className": "col-md-6", + * "children": ["inner.grandChild3"], + * } + * }, + + * ] + * } + * ``` + * + * One can write this instead: + * ``` + * "ui:row": { + * "children": [ + * { + * "ui:columns": { + * "className": "GridColumn col-md-4", + * "children": ["innerField", "inner.grandChild", { "name": "inner.grandChild2", "convertOther": true }], + * } + * }, + * { + * "ui:columns": { + * "className": "col-md-6", + * "children": ["innerField2", "inner.grandChild3"], + * } + * } + * ] + * } + * ``` + * + * NOTE: This syntax differs from + * `"ui:col": { "className": "col-md-6", "children": ["innerField2", "inner.grandChild3"] }` in that + * the `ui:col` will render the two children fields inside a single `` + * element. + * + * The final grid element, `ui:condition`, allows for conditionally displaying "grid elements" within a row based on the + * current value of a field as it relates to a (list of) hard-coded value(s). There are four elements that make up a + * `ui:condition`: 1) the dotted-path `field` name within the root field that makes up the left-side of the condition; + * 2) the hard-coded `value` (single or list) that makes up the right-side of the condition; 3) the `operator` that + * controls how the left and right sides of the condition are compared; and 4) the `children` array that defines the + * "grid elements" to display if the condition passes. + * + * A `ui:condition` uses one of three `operators` when deciding if a condition passes: 1) The `all` operator will pass + * when the right-side and left-side contains all the same value(s); 2) the `some` operator will pass when the + * right-side and left-side contain as least one value in common; 3) the `none` operator will pass when the right-side + * and left-side do not contain any values in common. E.g. (as JSON objects): + * + * Here is how to render an if-then-else for `field2` which is an enum that has 3 known values and supports allowing + * any other value: + * ``` + * "ui:row": [ + * { + * "ui:condition": { + * "field": "field2", + * "operator": "all", + * "value": "value1", + * "children": [ + * { "ui:row": [...] }, + * ], + * } + * }, + * { + * "ui:condition": { + * "field": "field2", + * "operator": "some", + * "value": ["value2", "value3"], + * "children": [ + * { "ui:row": [...] }, + * ], + * } + * }, + * { + * "ui:condition": { + * "field": "field2", + * "operator": "none", + * "value": ["value1", "value2", "value3"], + * "children": [ + * { "ui:row": [...] }, + * ], + * } + * } + * ] + * ``` + */ +export default class LayoutGridField< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +> extends PureComponent> { + static defaultProps = { + layoutGridSchema: undefined, + }; + + static TEST_IDS = getTestIds(); + /** Computes the uiSchema for the field with `name` from the `uiProps` and `uiSchema` provided. The field UI Schema + * will always contain a copy of the global options from the `uiSchema` (so they can be passed down) as well as + * copying them into the local ui options. When the `forceReadonly` flag is true, then the field UI Schema is + * updated to make "readonly" be true. When the `schemaReadonly` flag is true AND the field UI Schema does NOT have + * the flag already provided, then we also make "readonly" true. We always make sure to return the final value of the + * field UI Schema's "readonly" flag as `uiReadonly` along with the `fieldUiSchema` in the return value. + * + * @param field - The name of the field to pull the existing UI Schema for + * @param uiProps - Any props that should be put into the field's uiSchema + * @param [uiSchema] - The optional UI Schema from which to get the UI schema for the field + * @param [schemaReadonly] - Optional flag indicating whether the schema indicates the field is readonly + * @param [forceReadonly] - Optional flag indicating whether the Form itself is in readonly mode + */ + static computeFieldUiSchema( + field: string, + uiProps: ConfigObject, + uiSchema?: UiSchema, + schemaReadonly?: boolean, + forceReadonly?: boolean + ) { + const globalUiOptions = get(uiSchema, [UI_GLOBAL_OPTIONS], {}); + const localUiSchema = get(uiSchema, field); + const localUiOptions = { ...get(localUiSchema, [UI_OPTIONS_KEY], {}), ...uiProps, ...globalUiOptions }; + const fieldUiSchema = { ...localUiSchema }; + if (!isEmpty(localUiOptions)) { + set(fieldUiSchema, [UI_OPTIONS_KEY], localUiOptions); + } + if (!isEmpty(globalUiOptions)) { + // pass the global uiOptions down to the field uiSchema so that they can be applied to all nested fields + set(fieldUiSchema, [UI_GLOBAL_OPTIONS], globalUiOptions); + } + let { readonly: uiReadonly } = getUiOptions(fieldUiSchema); + if (forceReadonly === true || (isUndefined(uiReadonly) && schemaReadonly === true)) { + // If we are forcing all widgets to be readonly, OR the schema indicates it is readonly AND the uiSchema does not + // have an overriding value, then update the uiSchema to set readonly to true. Doing this will + uiReadonly = true; + if (has(localUiOptions, READONLY_KEY)) { + // If the local options has the key value provided in it, then set that one to true + set(fieldUiSchema, [UI_OPTIONS_KEY, READONLY_KEY], true); + } else { + // otherwise set the `ui:` version + set(fieldUiSchema, `ui:${READONLY_KEY}`, true); + } + } + return { fieldUiSchema, uiReadonly }; + } + + /** Given an `operator`, `datum` and `value` determines whether this condition is considered matching. Matching + * depends on the `operator`. The `datum` and `value` are converted into arrays if they aren't already and then the + * contents of the two arrays are compared using the `operator`. When `operator` is All, then the two arrays must be + * equal to match. When `operator` is SOME then the intersection of the two arrays must have at least one value in + * common to match. When `operator` is NONE then the intersection of the two arrays must not have any values in common + * to match. + * + * @param [operator] - The optional operator for the condition + * @param [datum] - The optional datum for the condition, this can be an item or a list of items of type unknown + * @param [value='$0m3tH1nG Un3xP3cT3d'] The optional value for the condition, defaulting to a highly unlikely value + * to avoid comparing two undefined elements when `value` was forgotten in the condition definition. + * This can be an item or a list of items of type unknown + * @returns - True if the condition matches, false otherwise + */ + static conditionMatches(operator?: Operators, datum?: unknown, value: unknown = '$0m3tH1nG Un3xP3cT3d'): boolean { + const data = flatten([datum]).sort(); + const values = flatten([value]).sort(); + switch (operator) { + case Operators.ALL: + return isEqual(data, values); + case Operators.SOME: + return intersection(data, values).length > 0; + case Operators.NONE: + return intersection(data, values).length === 0; + default: + return false; + } + } + + /** From within the `layoutGridSchema` finds the `children` and any extra `gridProps` from the object keyed by + * `schemaKey`. If the `children` contains extra `gridProps` and those props contain a `className` string, try to + * lookup whether that `className` has a replacement value in the `registry` using the `FORM_CONTEXT_LOOKUP_BASE`. + * When the `className` value contains multiple classNames separated by a space, the lookup will look for a + * replacement value for each `className` and combine them into one. + * + * @param layoutGridSchema - The GridSchemaType instance from which to obtain the `schemaKey` children and extra props + * @param schemaKey - A `GridType` value, used to get the children and extra props from within the `layoutGridSchema` + * @param registry - The `@rjsf` Registry from which to look up `classNames` if they are present in the extra props + * @returns - An object containing the list of `LayoutGridSchemaType` `children` and any extra `gridProps` + * @throws - A `TypeError` when the `children` is not an array + */ + static findChildrenAndProps( + layoutGridSchema: GridSchemaType, + schemaKey: GridType, + registry: Registry + ) { + let gridProps: GridProps = {}; + let children = layoutGridSchema[schemaKey]; + if (isPlainObject(children)) { + const { children: elements, className: toMapClassNames, ...otherProps } = children as ConfigObject; + children = elements; + if (toMapClassNames) { + const classes = toMapClassNames.split(' '); + const className = classes.map((ele: string) => lookupFromFormContext(registry, ele, ele)).join(' '); + gridProps = { ...otherProps, className }; + } else { + gridProps = otherProps; + } + } + if (!Array.isArray(children)) { + throw new TypeError(`Expected array for "${schemaKey}" in ${JSON.stringify(layoutGridSchema)}`); + } + return { children: children as LayoutGridSchemaType[], gridProps }; + } + + /** Generates an idSchema for the `schema` using `@rjsf`'s `toIdSchema` util, passing the `baseIdSchema`'s `$id` value + * as the id prefix. + * + * @param schemaUtils - The `SchemaUtilsType` used to call `toIdSchema` + * @param schema - The schema to generate the idSchema for + * @param baseIdSchema - The IdSchema for the base + * @param formData - The formData to pass the `toIdSchema` + * @param [idSeparator] - The param to pass into the `toIdSchema` util which will use it to join the `idSchema` paths + * @returns - The generated `idSchema` for the `schema` + */ + static getIdSchema( + schemaUtils: SchemaUtilsType, + baseIdSchema: IdSchema, + formData: FieldProps['formData'], + schema: S = {} as S, + idSeparator?: string + ): FieldProps['idSchema'] { + const baseId = get(baseIdSchema, ID_KEY); + return schemaUtils.toIdSchema(schema, baseId, formData, baseId, idSeparator); + } + + /** Given a `dottedPath` to a field in the `initialSchema`, iterate through each individual path in the schema until + * the leaf path is found and returned (along with whether that leaf path `isRequired`) OR no schema exists for an + * element in the path. If the leaf schema element happens to be a oneOf/anyOf then also return the oneOf/anyOf as + * `options`. + * + * @param schemaUtils - The `SchemaUtilsType` used to call `retrieveSchema` + * @param dottedPath - The dotted-path to the field for which to get the schema + * @param initialSchema - The initial schema to start the search from + * @param formData - The formData, useful for resolving a oneOf/anyOf selection in the path hierarchy + * @param initialIdSchema - The initial idSchema to start the search from + * @param [idSeparator] - The param to pass into the `toIdSchema` util which will use it to join the `idSchema` paths + * @returns - An object containing the destination schema, isRequired and isReadonly flags for the field and options + * info if a oneOf/anyOf + */ + static getSchemaDetailsForField( + schemaUtils: SchemaUtilsType, + dottedPath: string, + initialSchema: S, + formData: FieldProps['formData'], + initialIdSchema: IdSchema, + idSeparator?: string + ): { + schema?: S; + isRequired: boolean; + isReadonly?: boolean; + optionsInfo?: OneOfOptionsInfoType; + idSchema: IdSchema; + } { + let rawSchema: S = initialSchema; + let idSchema = initialIdSchema; + const parts: string[] = dottedPath.split('.'); + const leafPath: string | undefined = parts.pop(); // pop off the last element in the list as the leaf + let schema: S | undefined = schemaUtils.retrieveSchema(rawSchema, formData); // always returns an object + let innerData = formData; + let isReadonly: boolean | undefined = schema.readOnly; + + // For all the remaining path parts + parts.forEach((part) => { + // dive into the properties of the current schema (when it exists) and get the schema for the next part + if (has(schema, PROPERTIES_KEY)) { + rawSchema = get(schema, [PROPERTIES_KEY, part]); + idSchema = get(idSchema, part, {}) as IdSchema; + } else if (schema && (has(schema, ONE_OF_KEY) || has(schema, ANY_OF_KEY))) { + const xxx = has(schema, ONE_OF_KEY) ? ONE_OF_KEY : ANY_OF_KEY; + // When the schema represents a oneOf/anyOf, find the selected schema for it and grab the inner part + const selectedSchema = schemaUtils.findSelectedOptionInXxxOf(schema, part, xxx, innerData); + const selectedIdSchema = LayoutGridField.getIdSchema( + schemaUtils, + idSchema, + formData, + selectedSchema, + idSeparator + ); + rawSchema = get(selectedSchema, [PROPERTIES_KEY, part], {}) as S; + idSchema = get(selectedIdSchema, part, {}) as IdSchema; + } else { + rawSchema = {} as S; + } + // Now drill into the innerData for the part, returning an empty object by default if it doesn't exist + innerData = get(innerData, part, {}) as T; + // Resolve any `$ref`s for the current rawSchema + schema = schemaUtils.retrieveSchema(rawSchema, innerData); + isReadonly = getNonNullishValue(schema.readOnly, isReadonly); + }); + + let optionsInfo: OneOfOptionsInfoType | undefined; + let isRequired = false; + // retrieveSchema will return an empty schema in the worst case scenario, convert it to undefined + if (isEmpty(schema)) { + schema = undefined; + } + if (schema && leafPath) { + // When we have both a schema and a leafPath... + if (schema && (has(schema, ONE_OF_KEY) || has(schema, ANY_OF_KEY))) { + const xxx = has(schema, ONE_OF_KEY) ? ONE_OF_KEY : ANY_OF_KEY; + // Grab the selected schema for the oneOf/anyOf value for the leafPath using the innerData + schema = schemaUtils.findSelectedOptionInXxxOf(schema, leafPath, xxx, innerData); + // Generate the idSchema for the oneOf/anyOf value then merge with the existing `idSchema` + const rawIdSchema = LayoutGridField.getIdSchema(schemaUtils, idSchema, formData, schema, idSeparator); + idSchema = mergeObjects(rawIdSchema, idSchema) as IdSchema; + } + isRequired = schema !== undefined && Array.isArray(schema.required) && includes(schema.required, leafPath); + // Now grab the schema from the leafPath of the current schema properties + schema = get(schema, [PROPERTIES_KEY, leafPath]) as S | undefined; + // Resolve any `$ref`s for the current schema + schema = schema ? schemaUtils.retrieveSchema(schema) : schema; + idSchema = get(idSchema, leafPath, {}) as IdSchema; + isReadonly = getNonNullishValue(schema?.readOnly, isReadonly); + if (schema && (has(schema, ONE_OF_KEY) || has(schema, ANY_OF_KEY))) { + const xxx = has(schema, ONE_OF_KEY) ? ONE_OF_KEY : ANY_OF_KEY; + // Set the options if we have a schema with a oneOf/anyOf + const discriminator = getDiscriminatorFieldFromSchema(schema); + optionsInfo = { options: schema[xxx] as S[], hasDiscriminator: !!discriminator }; + } + } + + return { schema, isRequired, isReadonly, optionsInfo, idSchema }; + } + + /** Gets the custom render component from the `render`, by either determining that it is either already a function or + * it is a non-function value that can be used to look up the function in the registry. If no function can be found, + * null is returned. + * + * @param render - The potential render function or lookup name to one + * @param registry - The `@rjsf` Registry from which to look up `classNames` if they are present in the extra props + * @returns - Either a render function if available, or null if not + */ + static getCustomRenderComponent( + render: string | RenderComponent, + registry: Registry + ): RenderComponent | null { + let customRenderer = render; + if (isString(customRenderer)) { + customRenderer = lookupFromFormContext(registry, customRenderer); + } + if (isFunction(customRenderer)) { + return customRenderer; + } + return null; + } + + /** Constructs an `LayoutGridField` with the given `props` + * + * @param props - The `LayoutGridField` for this template + */ + constructor(props: LayoutGridFieldProps) { + super(props); + } + + /** Generates an `onChange` handler for the field associated with the `dottedPath`. This handler will clone and update + * the `formData` with the new `value` and the `errorSchema` if an `errSchema` is provided. After updating those two + * elements, they will then be passed on to the `onChange` handler of the `LayoutFieldGrid`. + * + * @param dottedPath - The dotted-path to the field for which to generate the onChange handler + * @returns - The `onChange` handling function for the `dottedPath` field + */ + onFieldChange = (dottedPath: string) => { + return (value: unknown, errSchema?: ErrorSchema, id?: string) => { + const { onChange, errorSchema, formData } = this.props; + const newFormData = cloneDeep(formData || ({} as T)); + let newErrorSchema = errorSchema; + if (errSchema && errorSchema) { + newErrorSchema = cloneDeep(errorSchema); + set(newErrorSchema, dottedPath, errSchema); + } + set(newFormData as object, dottedPath, value); + onChange(newFormData, newErrorSchema, id); + }; + }; + + /** Extract the `name`, and optional `render` and all other props from the `gridSchema`. We look up the `render` to + * see if can be resolved to a UIComponent. If `name` does not exist and there is an optional `render` UIComponent, we + * set the `rendered` component with only specified props for that component in the object. + * + * @param gridSchema - The string or object that represents the configuration for the grid field + * @returns - The UIComponentPropsType computed from the gridSchema + */ + computeUIComponentPropsFromGridSchema(gridSchema?: string | ConfigObject): UIComponentPropsType { + const { registry } = this.props; + let name: string; + let UIComponent: RenderComponent | null = null; + let uiProps: ConfigObject = {}; + let rendered: ReactNode | undefined; + if (isString(gridSchema) || isUndefined(gridSchema)) { + name = gridSchema ?? ''; + } else { + const { name: innerName, render, ...innerProps } = gridSchema; + name = innerName; + uiProps = innerProps; + UIComponent = LayoutGridField.getCustomRenderComponent(render, registry); + if (!innerName && UIComponent) { + rendered = ; + } + } + return { name, UIComponent, uiProps, rendered }; + } + + /** Renders the `children` of the `GridType.CONDITION` if it passes. The `layoutGridSchema` for the + * `GridType.CONDITION` is separated into the `children` and other `gridProps`. The `gridProps` are used to extract + * the `operator`, `field` and `value` of the condition. If the condition matches, then all of the `children` are + * rendered, otherwise null is returned. + * + * @param layoutGridSchema - The string or object that represents the configuration for the grid field + * @returns - The rendered the children for the `GridType.CONDITION` or null + */ + renderCondition(layoutGridSchema: GridSchemaType) { + const { formData, registry } = this.props; + const { children, gridProps } = LayoutGridField.findChildrenAndProps( + layoutGridSchema, + GridType.CONDITION, + registry + ); + const { operator, field = '', value } = gridProps; + const fieldData = get(formData, field, null); + if (LayoutGridField.conditionMatches(operator, fieldData, value)) { + return this.renderChildren(children); + } + return null; + } + + /** Renders a material-ui `GridTemplate` as an item. The `layoutGridSchema` for the `GridType.COLUMN` is separated + * into the `children` and other `gridProps`. The `gridProps` will be spread onto the outer `GridTemplate`. Inside + * the `GridTemplate` all the `children` are rendered. + * + * @param layoutGridSchema - The string or object that represents the configuration for the grid field + * @returns - The rendered `GridTemplate` containing the children for the `GridType.COLUMN` + */ + renderCol(layoutGridSchema: GridSchemaType) { + const { registry, uiSchema } = this.props; + const { children, gridProps } = LayoutGridField.findChildrenAndProps( + layoutGridSchema, + GridType.COLUMN, + registry + ); + const uiOptions = getUiOptions(uiSchema); + const GridTemplate = getTemplate<'GridTemplate', T, S, F>('GridTemplate', registry, uiOptions); + + return ( + + {this.renderChildren(children)} + + ); + } + + /** Renders a material-ui `GridTemplate` as an item. The `layoutGridSchema` for the `GridType.COLUMNS` is separated + * into the `children` and other `gridProps`. The `children` is iterated on and `gridProps` will be spread onto the + * outer `GridTemplate`. Each child will have their own rendered `GridTemplate`. + * + * @param layoutGridSchema - The string or object that represents the configuration for the grid field + * @returns - The rendered `GridTemplate` containing the children for the `GridType.COLUMNS` + */ + renderColumns(layoutGridSchema: GridSchemaType) { + const { registry, uiSchema } = this.props; + const { children, gridProps } = LayoutGridField.findChildrenAndProps( + layoutGridSchema, + GridType.COLUMNS, + registry + ); + const uiOptions = getUiOptions(uiSchema); + const GridTemplate = getTemplate<'GridTemplate', T, S, F>('GridTemplate', registry, uiOptions); + + return children.map((child) => ( + + {this.renderChildren([child])} + + )); + } + + /** Renders a material-ui `GridTemplate` as a container. The + * `layoutGridSchema` for the `GridType.ROW` is separated into the `children` and other `gridProps`. The `gridProps` + * will be spread onto the outer `GridTemplate`. Inside of the `GridTemplate` all of the `children` are rendered. + * + * @param layoutGridSchema - The string or object that represents the configuration for the grid field + * @returns - The rendered `GridTemplate` containing the children for the `GridType.ROW` + */ + renderRow(layoutGridSchema: GridSchemaType) { + const { registry, uiSchema } = this.props; + const { children, gridProps } = LayoutGridField.findChildrenAndProps( + layoutGridSchema, + GridType.ROW, + registry + ); + const uiOptions = getUiOptions(uiSchema); + const GridTemplate = getTemplate<'GridTemplate', T, S, F>('GridTemplate', registry, uiOptions); + + return ( + + {this.renderChildren(children)} + + ); + } + + /** Iterates through all the `childrenLayoutGridSchema`, rendering a nested `LayoutGridField` for each item in the + * list, passing all the props for the current `LayoutGridField` along, updating the `schema` by calling + * `retrieveSchema()` on it to resolve any `$ref`s. In addition to the updated `schema`, each item in + * `childrenLayoutGridSchema` is passed as `layoutGridSchema`. + * + * @param childrenLayoutGridSchema - The list of strings or objects that represents the configurations for the + * children fields + * @returns - The nested `LayoutGridField`s + */ + renderChildren(childrenLayoutGridSchema: LayoutGridSchemaType[]) { + const { registry, schema: rawSchema, formData } = this.props; + const { schemaUtils } = registry; + const schema = schemaUtils.retrieveSchema(rawSchema, formData); + + return childrenLayoutGridSchema.map((layoutGridSchema) => ( + + {...this.props} + key={`layoutGrid-${hashObject(layoutGridSchema)}`} + schema={schema} + layoutGridSchema={layoutGridSchema} + /> + )); + } + + /** Renders the field described by `gridSchema`. If `gridSchema` is not an object, then is will be assumed + * to be the dotted-path to the field in the schema. Otherwise, we extract the `name`, and optional `render` and all + * other props. If `name` does not exist and there is an optional `render`, we return the `render` component with only + * specified props for that component. If `name` exists, we take the name, the initial & root schemas and the formData + * and get the destination schema, is required state and optional oneOf/anyOf options for it. If the destination + * schema was located along with oneOf/anyOf options then a `LayoutMultiSchemaField` will be rendered with the + * `uiSchema`, `errorSchema`, `idSchema` and `formData` drilled down to the dotted-path field, spreading any other + * props from `gridSchema` into the `ui:options`. If the destination schema located without any oneOf/anyOf options, + * then a `SchemaField` will be rendered with the same props as mentioned in the previous sentence. If no destination + * schema was located, but a custom render component was found, then it will be rendered with many of the non-event + * handling props. If none of the previous render paths are valid, then a null is returned. + * + * @param gridSchema - The string or object that represents the configuration for the grid field + * @returns - One of `LayoutMultiSchemaField`, `SchemaField`, a custom render component or null, depending + */ + renderField(gridSchema?: ConfigObject | string) { + const { + schema: initialSchema, + uiSchema, + errorSchema, + idSchema, + onBlur, + onFocus, + formData, + readonly, + registry, + idSeparator, + layoutGridSchema, // Used to pull this out of otherProps since we don't want to pass it through + ...otherProps + } = this.props; + const { fields, schemaUtils } = registry; + const { SchemaField, LayoutMultiSchemaField } = fields; + const uiComponentProps = this.computeUIComponentPropsFromGridSchema(gridSchema); + if (uiComponentProps.rendered) { + return uiComponentProps.rendered; + } + const { name, UIComponent, uiProps } = uiComponentProps; + const { + schema, + isRequired, + isReadonly, + optionsInfo, + idSchema: fieldIdSchema, + } = LayoutGridField.getSchemaDetailsForField( + schemaUtils, + name, + initialSchema, + formData, + idSchema, + idSeparator + ); + + if (schema) { + const Field = optionsInfo?.hasDiscriminator ? LayoutMultiSchemaField : SchemaField; + // Call this function to get the appropriate UISchema, which will always have its `readonly` state matching the + // `uiReadonly` flag that it returns. This is done since the `SchemaField` will always defer to the `readonly` + // state in the uiSchema over anything in the props or schema. Because we are implementing the "readonly" state of + // the `Form` via the prop passed to `LayoutGridField` we need to make sure the uiSchema always has a true value + // when it is needed + const { fieldUiSchema, uiReadonly } = LayoutGridField.computeFieldUiSchema( + name, + uiProps, + uiSchema, + isReadonly, + readonly + ); + + return ( + + ); + } + + if (UIComponent) { + return ( + + ); + } + return null; + } + + /** Renders the `LayoutGridField`. If there isn't a `layoutGridSchema` prop defined, then try pulling it out of the + * `uiSchema` via `ui:LayoutGridField`. If `layoutGridSchema` is an object, then check to see if any of the properties + * match one of the `GridType`s. If so, call the appropriate render function for the type. Otherwise, just call the + * generic `renderField()` function with the `layoutGridSchema`. + * + * @returns - the rendered `LayoutGridField` + */ + render() { + const { uiSchema } = this.props; + let { layoutGridSchema } = this.props; + const uiOptions = getUiOptions(uiSchema); + if (!layoutGridSchema && LAYOUT_GRID_UI_OPTION in uiOptions && isObject(uiOptions[LAYOUT_GRID_UI_OPTION])) { + layoutGridSchema = uiOptions[LAYOUT_GRID_UI_OPTION]; + } + + if (isObject(layoutGridSchema)) { + if (GridType.ROW in layoutGridSchema) { + return this.renderRow(layoutGridSchema as GridSchemaType); + } + if (GridType.COLUMN in layoutGridSchema) { + return this.renderCol(layoutGridSchema as GridSchemaType); + } + if (GridType.COLUMNS in layoutGridSchema) { + return this.renderColumns(layoutGridSchema as GridSchemaType); + } + if (GridType.CONDITION in layoutGridSchema) { + return this.renderCondition(layoutGridSchema as GridSchemaType); + } + } + return this.renderField(layoutGridSchema); + } +} diff --git a/packages/core/src/components/fields/LayoutMultiSchemaField.tsx b/packages/core/src/components/fields/LayoutMultiSchemaField.tsx new file mode 100644 index 0000000000..e59749a4dd --- /dev/null +++ b/packages/core/src/components/fields/LayoutMultiSchemaField.tsx @@ -0,0 +1,206 @@ +import { useState, useEffect } from 'react'; +import { + ANY_OF_KEY, + CONST_KEY, + DEFAULT_KEY, + EnumOptionsType, + ERRORS_KEY, + FieldProps, + FormContextType, + getDiscriminatorFieldFromSchema, + hashObject, + ID_KEY, + ONE_OF_KEY, + optionsList, + PROPERTIES_KEY, + RJSFSchema, + getTemplate, + getUiOptions, + getWidget, + SchemaUtilsType, + StrictRJSFSchema, + UiSchema, +} from '@rjsf/utils'; +import get from 'lodash/get'; +import has from 'lodash/has'; +import isEmpty from 'lodash/isEmpty'; +import omit from 'lodash/omit'; +import set from 'lodash/set'; + +/** Gets the selected option from the list of `options`, using the `selectorField` to search inside each `option` for + * the `properties[selectorField].default(or const)` that matches the given `value`. + * + * @param options - The list of schemas each representing a choice in the `oneOf` + * @param selectorField - The name of the field that is common in all of the schemas that represents the selector field + * @param value - The current value of the selector field from the data + */ +export function getSelectedOption( + options: EnumOptionsType[], + selectorField: string, + value: unknown +): S | undefined { + const defaultValue = '!@#!@$@#$!@$#'; + const schemaOptions: S[] = options.map(({ schema }) => schema!); + return schemaOptions.find((option) => { + const selector = get(option, [PROPERTIES_KEY, selectorField]); + const result = get(selector, DEFAULT_KEY, get(selector, CONST_KEY, defaultValue)); + return result === value; + }); +} + +/** Computes the `enumOptions` array from the schema and options. + * + * @param schema - The schema that contains the `options` + * @param options - The options from the `schema` + * @param schemaUtils - The SchemaUtilsType object used to call retrieveSchema, + * @param [uiSchema] - The optional uiSchema for the schema + * @param [formData] - The optional formData associated with the schema + * @returns - The list of enumOptions for the `schema` and `options` + * @throws - Error when no enum options were computed + */ +export function computeEnumOptions( + schema: S, + options: S[], + schemaUtils: SchemaUtilsType, + uiSchema?: UiSchema, + formData?: T +): EnumOptionsType[] { + const realOptions = options.map((opt: S) => schemaUtils.retrieveSchema(opt, formData)); + let tempSchema = schema; + if (has(schema, ONE_OF_KEY)) { + tempSchema = { ...schema, [ONE_OF_KEY]: realOptions }; + } else if (has(schema, ANY_OF_KEY)) { + tempSchema = { ...schema, [ANY_OF_KEY]: realOptions }; + } + const enumOptions = optionsList(tempSchema, uiSchema); + if (!enumOptions) { + throw new Error(`No enumOptions were computed from the schema ${JSON.stringify(tempSchema)}`); + } + return enumOptions; +} + +/** The `LayoutMultiSchemaField` is an adaptation of the `MultiSchemaField` but changed considerably to only + * support `anyOf`/`oneOf` fields that are being displayed in a `LayoutGridField` where the field selection is shown as + * a radio group by default. It expects that a `selectorField` is provided (either directly via the `discriminator` + * field or indirectly via `ui:optionsSchemaSelector` in the `uiSchema`) to help determine which `anyOf`/`oneOf` schema + * is active. If no `selectorField` is specified, then an error is thrown. + */ +export default function LayoutMultiSchemaField< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: FieldProps) { + const { + name, + baseType, + disabled = false, + formData, + idSchema, + onBlur, + onChange, + options, + onFocus, + registry, + uiSchema, + schema, + formContext, + autofocus, + readonly, + required, + errorSchema, + hideError = false, + } = props; + const { widgets, schemaUtils } = registry; + const [enumOptions, setEnumOptions] = useState(computeEnumOptions(schema, options, schemaUtils, uiSchema, formData)!); + const id = get(idSchema, ID_KEY); + const discriminator = getDiscriminatorFieldFromSchema(schema); + const FieldErrorTemplate = getTemplate<'FieldErrorTemplate', T, S, F>('FieldErrorTemplate', registry, options); + const schemaHash = hashObject(schema); + const optionsHash = hashObject(options); + const uiSchemaHash = uiSchema ? hashObject(uiSchema) : ''; + const formDataHash = formData ? hashObject(formData) : ''; + + useEffect(() => { + setEnumOptions(computeEnumOptions(schema, options, schemaUtils, uiSchema, formData)); + // We are using hashes in place of the dependencies + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [schemaHash, optionsHash, schemaUtils, uiSchemaHash, formDataHash]); + const { + widget = discriminator ? 'radio' : 'select', + title = '', + optionsSchemaSelector: selectorField = discriminator, + hideError: uiSchemaHideError, + ...uiOptions + } = getUiOptions(uiSchema); + if (!selectorField) { + throw new Error('No selector field provided for the LayoutMultiSchemaField'); + } + const selectedOption = get(formData, selectorField); + let optionSchema = get(enumOptions[0]?.schema, [PROPERTIES_KEY, selectorField]); + const option = getSelectedOption(enumOptions, selectorField, selectedOption); + // If the subschema doesn't declare a type, infer the type from the parent schema + optionSchema = optionSchema?.type ? optionSchema : ({ ...optionSchema, type: option?.type || baseType } as S); + const Widget = getWidget(optionSchema!, widget, widgets); + + // The following code was copied from `@rjsf`'s `SchemaField` + // Set hideError to the value provided in the uiSchema, otherwise stick with the prop to propagate to children + const hideFieldError = uiSchemaHideError === undefined ? hideError : Boolean(uiSchemaHideError); + + const rawErrors = get(errorSchema, [ERRORS_KEY], []) as string[]; + const fieldErrorSchema = omit(errorSchema, [ERRORS_KEY]); + + /** Callback function that updates the selected option and adjusts the form data based on the structure of the new + * option, calling the `onChange` callback with the adjusted formData. + * + * @param opt - If the option is undefined, we are going to clear the selection otherwise we + * will use it as the index of the new option to select + */ + const onOptionChange = (opt?: unknown) => { + const newOption = getSelectedOption(enumOptions, selectorField, opt); + const oldOption = getSelectedOption(enumOptions, selectorField, selectedOption); + + let newFormData = schemaUtils.sanitizeDataForNewSchema(newOption, oldOption, formData); + if (newFormData && newOption) { + // Call getDefaultFormState to make sure defaults are populated on change. + newFormData = schemaUtils.getDefaultFormState(newOption, newFormData, 'excludeObjectChildren') as T; + } + if (newFormData) { + set(newFormData, selectorField, opt); + } + onChange(newFormData); + }; + + // filtering the options based on the type of widget because `selectField` does not recognize the `convertOther` prop + const widgetOptions = { enumOptions, ...uiOptions }; + + return ( + <> + + {!hideFieldError && rawErrors.length > 0 && ( + + )} + + ); +} diff --git a/packages/core/src/components/fields/StringField.tsx b/packages/core/src/components/fields/StringField.tsx index d825fd2c5c..867c021891 100644 --- a/packages/core/src/components/fields/StringField.tsx +++ b/packages/core/src/components/fields/StringField.tsx @@ -35,7 +35,7 @@ function StringField(schema, uiSchema) : undefined; + const enumOptions = schemaUtils.isSelect(schema) ? optionsList(schema, uiSchema) : undefined; let defaultWidget = enumOptions ? 'select' : 'text'; if (format && hasWidget(schema, format, widgets)) { defaultWidget = format; diff --git a/packages/core/src/components/fields/index.ts b/packages/core/src/components/fields/index.ts index 466381429c..95eeb8f1c2 100644 --- a/packages/core/src/components/fields/index.ts +++ b/packages/core/src/components/fields/index.ts @@ -2,6 +2,8 @@ import { Field, FormContextType, RegistryFieldsType, RJSFSchema, StrictRJSFSchem import ArrayField from './ArrayField'; import BooleanField from './BooleanField'; +import LayoutGridField from './LayoutGridField'; +import LayoutMultiSchemaField from './LayoutMultiSchemaField'; import MultiSchemaField from './MultiSchemaField'; import NumberField from './NumberField'; import ObjectField from './ObjectField'; @@ -19,6 +21,8 @@ function fields< ArrayField: ArrayField as unknown as Field, // ArrayField falls back to SchemaField if ArraySchemaField is not defined, which it isn't by default BooleanField, + LayoutGridField, + LayoutMultiSchemaField, NumberField, ObjectField, OneOfField: MultiSchemaField, diff --git a/packages/core/src/components/widgets/RadioWidget.tsx b/packages/core/src/components/widgets/RadioWidget.tsx index 85edddc2ae..6ac0e22b42 100644 --- a/packages/core/src/components/widgets/RadioWidget.tsx +++ b/packages/core/src/components/widgets/RadioWidget.tsx @@ -32,17 +32,17 @@ function RadioWidget) => onBlur(id, enumOptionsValueForIndex(target && target.value, enumOptions, emptyValue)), - [onBlur, id] + [onBlur, enumOptions, emptyValue, id] ); const handleFocus = useCallback( ({ target }: FocusEvent) => onFocus(id, enumOptionsValueForIndex(target && target.value, enumOptions, emptyValue)), - [onFocus, id] + [onFocus, enumOptions, emptyValue, id] ); return ( -
+
{Array.isArray(enumOptions) && enumOptions.map((option, i) => { const checked = enumOptionsIsSelected(option.value, value); diff --git a/packages/core/src/components/widgets/SelectWidget.tsx b/packages/core/src/components/widgets/SelectWidget.tsx index 1bebfa371a..f235d1c7f0 100644 --- a/packages/core/src/components/widgets/SelectWidget.tsx +++ b/packages/core/src/components/widgets/SelectWidget.tsx @@ -47,7 +47,7 @@ function SelectWidget(newValue, enumOptions, optEmptyVal)); }, - [onFocus, id, schema, multiple, enumOptions, optEmptyVal] + [onFocus, id, multiple, enumOptions, optEmptyVal] ); const handleBlur = useCallback( @@ -55,7 +55,7 @@ function SelectWidget(newValue, enumOptions, optEmptyVal)); }, - [onBlur, id, schema, multiple, enumOptions, optEmptyVal] + [onBlur, id, multiple, enumOptions, optEmptyVal] ); const handleChange = useCallback( @@ -63,7 +63,7 @@ function SelectWidget(newValue, enumOptions, optEmptyVal)); }, - [onChange, schema, multiple, enumOptions, optEmptyVal] + [onChange, multiple, enumOptions, optEmptyVal] ); const selectedIndexes = enumOptionsIndexForValue(value, enumOptions, multiple); @@ -74,6 +74,7 @@ function SelectWidget) { + // eslint-disable-next-line no-unused-vars + const { uiSchema, registry, ...otherProps } = props; + const { ...otherUIOptions } = getUiOptions(uiSchema); + return sortedJSONStringify({ ...otherProps, otherUIOptions: otherUIOptions }); +} + +// Render a strong with the props stringified +function TestRenderer({ 'data-testid': testId, ...props }: Readonly) { + return {stringifyProps(props)}; +} + +// Render a div with the props stringified in a span, also render an input to test the onXXXX callbacks +function FakeSchemaField({ 'data-testid': testId, ...props }: Readonly) { + const { idSchema, formData, onChange, onBlur, onFocus, uiSchema } = props; + const { [ID_KEY]: id } = idSchema; + // Special test case that will pass an error schema into on change to allow coverage + const error = has(uiSchema, UI_GLOBAL_OPTIONS) ? EXTRA_ERROR : undefined; + const onTextChange = ({ target: { value: val } }: ChangeEvent) => { + onChange(val, error, id); + }; + const onTextBlur = ({ target: { value: val } }: FocusEvent) => onBlur(id, val); + const onTextFocus = ({ target: { value: val } }: FocusEvent) => onFocus(id, val); + return ( +
+ {stringifyProps(props)} + +
+ ); +} + +// eslint-disable-next-line no-unused-vars +const LOOKUP_MAP: { [index: string]: string | ((props: FieldProps) => ReactElement) } = { + FooClass: 'Foo', + BarClass: 'Bar', + TestRenderer, +}; + +const REGISTRY_FIELDS = { SchemaField: FakeSchemaField, LayoutMultiSchemaField: FakeSchemaField }; +const REGISTRY_FORM_CONTEXT = { [LOOKUP_MAP_NAME]: LOOKUP_MAP }; + +const TEST_LAYOUT_GRID_CHILDREN = { + [GridType.ROW]: [{ [GridType.COLUMN]: ['column1'] }, { [GridType.COLUMN]: ['column2'] }], + [GridType.COLUMN]: { + className: 'FooClass', + children: ['column1'], + }, + [GridType.CONDITION]: { + operator: Operators.ALL, + field: 'column2', + value: 'blah', + className: 'FooClass BarClass', + children: [], + }, +}; + +const GRID_FORM_SCHEMA = gridFormSchema as RJSFSchema; +const NO_SCHEMA_OR_OPTIONS = { + schema: undefined, + isRequired: false, + isReadonly: undefined, + optionsInfo: undefined, + idSchema: ID_SCHEMA, +}; + +const simpleOneOfRegistry = getTestRegistry(SIMPLE_ONEOF, REGISTRY_FIELDS, {}, {}, REGISTRY_FORM_CONTEXT); +const gridFormSchemaRegistry = getTestRegistry(GRID_FORM_SCHEMA, REGISTRY_FIELDS, {}, {}, REGISTRY_FORM_CONTEXT); +const sampleSchemaRegistry = getTestRegistry(SAMPLE_SCHEMA, REGISTRY_FIELDS, {}, {}, REGISTRY_FORM_CONTEXT); +const readonlySchemaRegistry = getTestRegistry(readonlySchema, REGISTRY_FIELDS, {}, {}, REGISTRY_FORM_CONTEXT); +const GRID_FORM_ID_SCHEMA = gridFormSchemaRegistry.schemaUtils.toIdSchema(GRID_FORM_SCHEMA); +const SAMPLE_SCHEMA_ID_SCHEMA = sampleSchemaRegistry.schemaUtils.toIdSchema(SAMPLE_SCHEMA); +const READONLY_ID_SCHEMA = readonlySchemaRegistry.schemaUtils.toIdSchema(readonlySchema); + +/** Simple mock idSchema generator that will take a dotted path string, and return the path joined by the `idSeparator` + * and appended to `root` (default idPrefix in `toIdSchema`) + * ex. testGetIdSchema('billing.payer.address', '-') // returns "root-billing-payer-address" + */ +const testGetIdSchema = (path: string, idSeparator = '_') => ({ $id: ['root', ...path.split('.')].join(idSeparator) }); + +/** The list of props that will always be forwarded to fields + */ +const FORWARDED_PROPS = ['disabled', 'autofocus', 'readonly', 'formContext']; + +/** Children for rows and columns + */ +const GRID_CHILDREN = ['simpleString', 'simpleInt']; + +/** Function used to transform `props` the `field` additional `otherProps` and `otherUiProps` into a set of + * props that match the expected props from the `LayoutGridField` + * + * @param props - The props passed to the component + * @param field - The fieldName being rendered + * @param otherProps - Any other props that may be added by the `LayoutGridField` + * @param otherUiProps - Any other uiSchema props that may be added by the `LayoutGridField` + */ +function getExpectedPropsForField( + props: Partial, + field: string, + otherProps: GenericObjectType = {}, + otherUiProps: GenericObjectType = {} +) { + const { schemaUtils } = props.registry!; + let { required } = props; + // Drill down, with schema retrieval, to the field name, also tracking whether the field is required + const schema = field.split('.').reduce((result, name) => { + const schema1 = schemaUtils.retrieveSchema(get(result, [PROPERTIES_KEY, name]) as RJSFSchema, props.schema); + required = result?.required?.includes(name) || false; + return schema1; + }, props.schema); + // Get the readonly options from the schema, if any + const readonly = get(schema, 'readOnly'); + // Get the options from the schema's oneOf, if any + const options = get(schema, ONE_OF_KEY); + // Drill down in the uiSchema, errorSchema, idSchema and formData to the field + const uiSchema = get(props.uiSchema, field); + const errorSchema = get(props.errorSchema, field); + const idSchema = get(props.idSchema, field)!; + const formData = get(props.formData, field); + // Also extract any global props + const global = get(props.uiSchema, [UI_GLOBAL_OPTIONS]); + const fieldUISchema = get(props.uiSchema, field); + const { readonly: uiReadonly } = getUiOptions(fieldUISchema); + // The expected props are the FORWARDED_PROPS, the field name, sub-schema, sub-uiSchema and sub-idSchema + return { + ...pick(props, FORWARDED_PROPS), + ...otherProps, + required, + readonly: uiReadonly ?? readonly, + options, + formData, + name: field, + schema, + uiSchema: { + ...uiSchema, + [UI_OPTIONS_KEY]: { ...global, ...otherUiProps }, // spread the global and other ui keys into the ui:options + ...(global ? { [UI_GLOBAL_OPTIONS]: global } : {}), // ensure the globals are maintained + }, + idSchema, + errorSchema, + }; +} + +describe('LayoutGridField', () => { + function getProps(overrideProps: Partial = {}): LayoutGridFieldProps { + const { + formData, + schema = {}, + errorSchema = {}, + uiSchema = {}, + disabled = false, + layoutGridSchema, + registry = getTestRegistry(schema, REGISTRY_FIELDS, {}, {}, REGISTRY_FORM_CONTEXT), + } = overrideProps; + return { + // required FieldProps stubbed + autofocus: false, + name: '', + readonly: false, + required: false, + // end required FieldProps + layoutGridSchema, + disabled, + formData, + errorSchema, + idSchema: schema ? registry.schemaUtils.toIdSchema(schema) : ID_SCHEMA, + formContext: registry.formContext, + registry, + schema, + uiSchema, + onBlur: jest.fn(), + onChange: jest.fn(), + onFocus: jest.fn(), + }; + } + + let registry: Registry; + let retrieveSchemaSpy: jest.SpyInstance; + let toIdSchemaSpy: jest.SpyInstance; + let findSelectedOptionInXxxOf: jest.SpyInstance; + beforeAll(() => { + registry = getTestRegistry({}, REGISTRY_FIELDS, {}, {}, REGISTRY_FORM_CONTEXT); + }); + describe('LayoutGridField.conditionMatches()', () => { + test('returns false when no operator is passed', () => { + expect(LayoutGridField.conditionMatches()).toBe(false); + }); + test('returns false for ALL operator and values !== data, non-arrays', () => { + expect(LayoutGridField.conditionMatches(Operators.ALL, '5', '6')).toBe(false); + }); + test('returns false for ALL operator and values !== data, arrays', () => { + expect(LayoutGridField.conditionMatches(Operators.ALL, ['5', '6'], ['6'])).toBe(false); + }); + test('returns false for ALL operator and values !== data, mixed non-array and array', () => { + expect(LayoutGridField.conditionMatches(Operators.ALL, ['5', '6'], '6')).toBe(false); + }); + test('returns true for ALL operator and values === data, non-arrays', () => { + expect(LayoutGridField.conditionMatches(Operators.ALL, '6', '6')).toBe(true); + }); + test('returns true for ALL operator and values === data, arrays', () => { + expect(LayoutGridField.conditionMatches(Operators.ALL, ['6', '7'], ['7', '6'])).toBe(true); + }); + test('returns false for SOME operator and values !∩ data, non-arrays', () => { + expect(LayoutGridField.conditionMatches(Operators.SOME, '6', '7')).toBe(false); + }); + test('returns false for SOME operator and values !∩ data, arrays', () => { + expect(LayoutGridField.conditionMatches(Operators.SOME, ['6'], ['7', '8'])).toBe(false); + }); + test('returns true for SOME operator and values ∩ data, non-array', () => { + expect(LayoutGridField.conditionMatches(Operators.SOME, '6', '6')).toBe(true); + }); + test('returns true for SOME operator and values ∩ data, array', () => { + expect(LayoutGridField.conditionMatches(Operators.SOME, ['6', '7'], ['6', '8'])).toBe(true); + }); + test('returns true for SOME operator and values ∩ data, mixed non-array and array', () => { + expect(LayoutGridField.conditionMatches(Operators.SOME, '6', ['6', '8'])).toBe(true); + }); + test('returns false for NONE operator and values ∩ data, non-array', () => { + expect(LayoutGridField.conditionMatches(Operators.NONE, '6', '6')).toBe(false); + }); + test('returns false for NONE operator and values ∩ data, array', () => { + expect(LayoutGridField.conditionMatches(Operators.NONE, ['6', '7'], ['6', '8'])).toBe(false); + }); + test('returns false for NONE operator and values ∩ data, mixed non-array and array', () => { + expect(LayoutGridField.conditionMatches(Operators.NONE, '6', ['6', '8'])).toBe(false); + }); + test('returns true for NONE operator and values !∩ data, non-arrays', () => { + expect(LayoutGridField.conditionMatches(Operators.NONE, '6', '7')).toBe(true); + }); + test('returns true for NONE operator and values !∩ data, arrays', () => { + expect(LayoutGridField.conditionMatches(Operators.NONE, ['6'], ['7', '8'])).toBe(true); + }); + }); + describe('LayoutGridField.findChildrenAndProps()', () => { + test('throws TypeError when children is not an array', () => { + expect(() => LayoutGridField.findChildrenAndProps({}, GridType.ROW, registry)).toThrow( + new TypeError('Expected array for "ui:row" in {}') + ); + }); + test('returns the children array and empty grid props for the row', () => { + expect(LayoutGridField.findChildrenAndProps(TEST_LAYOUT_GRID_CHILDREN, GridType.ROW, registry)).toEqual({ + children: TEST_LAYOUT_GRID_CHILDREN[GridType.ROW], + gridProps: {}, + }); + }); + test('returns the children array and looked up className in grid props for the column', () => { + const expectedResult = { + children: TEST_LAYOUT_GRID_CHILDREN[GridType.COLUMN].children, + gridProps: { className: LOOKUP_MAP[TEST_LAYOUT_GRID_CHILDREN[GridType.COLUMN].className] }, + }; + expect(LayoutGridField.findChildrenAndProps(TEST_LAYOUT_GRID_CHILDREN, GridType.COLUMN, registry)).toEqual( + expectedResult + ); + }); + test('returns the children array and expected looked up className values in grid props for the condition', () => { + const classNames: string[] = TEST_LAYOUT_GRID_CHILDREN[GridType.CONDITION].className.split(' '); + const className: string = classNames.map((ele: string) => LOOKUP_MAP[ele]).join(' '); + const expectedResult = { + children: TEST_LAYOUT_GRID_CHILDREN[GridType.CONDITION].children, + gridProps: { ...omit(TEST_LAYOUT_GRID_CHILDREN[GridType.CONDITION], ['children']), className }, + }; + expect(LayoutGridField.findChildrenAndProps(TEST_LAYOUT_GRID_CHILDREN, GridType.CONDITION, registry)).toEqual( + expectedResult + ); + }); + }); + describe('LayoutGridField.getIdSchema()', () => { + test('deals with unspecified schema', () => { + const { schemaUtils } = simpleOneOfRegistry; + expect(LayoutGridField.getIdSchema(schemaUtils, ID_SCHEMA, {})).toEqual(ID_SCHEMA); + }); + }); + describe('LayoutGridField.getSchemaDetailsForField(), blank schema', () => { + beforeAll(() => { + retrieveSchemaSpy = jest.spyOn(registry.schemaUtils, 'retrieveSchema'); + toIdSchemaSpy = jest.spyOn(registry.schemaUtils, 'toIdSchema'); + findSelectedOptionInXxxOf = jest.spyOn(registry.schemaUtils, 'findSelectedOptionInXxxOf'); + }); + afterEach(() => { + findSelectedOptionInXxxOf.mockClear(); + retrieveSchemaSpy.mockClear(); + toIdSchemaSpy.mockClear(); + }); + afterAll(() => { + retrieveSchemaSpy.mockRestore(); + toIdSchemaSpy.mockRestore(); + }); + test('returns no schema or options when name is empty string', () => { + expect(LayoutGridField.getSchemaDetailsForField(registry.schemaUtils, '', {}, {}, ID_SCHEMA)).toEqual( + NO_SCHEMA_OR_OPTIONS + ); + expect(retrieveSchemaSpy).toHaveBeenCalledTimes(1); + expect(toIdSchemaSpy).not.toHaveBeenCalled(); + }); + test('returns no schema or options when schema is empty', () => { + expect(LayoutGridField.getSchemaDetailsForField(registry.schemaUtils, 'name', {}, {}, ID_SCHEMA)).toEqual( + NO_SCHEMA_OR_OPTIONS + ); + expect(retrieveSchemaSpy).toHaveBeenCalledTimes(1); + expect(toIdSchemaSpy).not.toHaveBeenCalled(); + }); + }); + describe('LayoutGridField.getSchemaDetailsForField(), sampleSchema', () => { + beforeAll(() => { + retrieveSchemaSpy = jest.spyOn(sampleSchemaRegistry.schemaUtils, 'retrieveSchema'); + toIdSchemaSpy = jest.spyOn(sampleSchemaRegistry.schemaUtils, 'toIdSchema'); + findSelectedOptionInXxxOf = jest.spyOn(sampleSchemaRegistry.schemaUtils, 'findSelectedOptionInXxxOf'); + }); + afterEach(() => { + findSelectedOptionInXxxOf.mockClear(); + retrieveSchemaSpy.mockClear(); + toIdSchemaSpy.mockClear(); + }); + afterAll(() => { + retrieveSchemaSpy.mockRestore(); + toIdSchemaSpy.mockRestore(); + }); + test('returns no schema or options when schema is missing the leaf field', () => { + expect( + LayoutGridField.getSchemaDetailsForField( + sampleSchemaRegistry.schemaUtils, + 'path.is.bad', // Need two bad paths, plus a bad leaf for test coverage + SAMPLE_SCHEMA, + {}, + SAMPLE_SCHEMA_ID_SCHEMA + ) + ).toEqual({ ...NO_SCHEMA_OR_OPTIONS, idSchema: {} }); // `path` digs into `idSchema` + expect(retrieveSchemaSpy).toHaveBeenCalledTimes(3); + expect(toIdSchemaSpy).not.toHaveBeenCalled(); + }); + test('returns no schema or options when leaf field is not found in the schema', () => { + expect( + LayoutGridField.getSchemaDetailsForField( + sampleSchemaRegistry.schemaUtils, + 'ignored', + SAMPLE_SCHEMA, + {}, + SAMPLE_SCHEMA_ID_SCHEMA + ) + ).toEqual({ ...NO_SCHEMA_OR_OPTIONS, idSchema: {} }); // `path` digs into `idSchema` + expect(retrieveSchemaSpy).toHaveBeenCalledTimes(1); + expect(toIdSchemaSpy).not.toHaveBeenCalled(); + }); + test('returns schema, isRequired: true, isReadonly: undefined, options: undefined, and idSchema when simple schema is used', () => { + const path = 'ranges'; + const schema = retrieveSchema( + validator, + get(SAMPLE_SCHEMA, [PROPERTIES_KEY, path]) as RJSFSchema, + SAMPLE_SCHEMA, + {} + ); + expect( + LayoutGridField.getSchemaDetailsForField( + sampleSchemaRegistry.schemaUtils, + path, + SAMPLE_SCHEMA, + {}, + SAMPLE_SCHEMA_ID_SCHEMA + ) + ).toEqual({ + schema, + isRequired: true, + isReadonly: undefined, + optionsInfo: undefined, + idSchema: get(SAMPLE_SCHEMA_ID_SCHEMA, path), + }); + expect(retrieveSchemaSpy).toHaveBeenCalledTimes(2); + expect(toIdSchemaSpy).not.toHaveBeenCalled(); + }); + }); + describe('LayoutGridField.getSchemaDetailsForField(), simpleOneOfSchema', () => { + beforeAll(() => { + retrieveSchemaSpy = jest.spyOn(simpleOneOfRegistry.schemaUtils, 'retrieveSchema'); + toIdSchemaSpy = jest.spyOn(simpleOneOfRegistry.schemaUtils, 'toIdSchema'); + findSelectedOptionInXxxOf = jest.spyOn(simpleOneOfRegistry.schemaUtils, 'findSelectedOptionInXxxOf'); + }); + afterEach(() => { + findSelectedOptionInXxxOf.mockClear(); + retrieveSchemaSpy.mockClear(); + toIdSchemaSpy.mockClear(); + }); + afterAll(() => { + retrieveSchemaSpy.mockRestore(); + toIdSchemaSpy.mockRestore(); + }); + test('returns schema, isRequired: false, isReadonly: true, options: undefined when oneOf schema is used, passed idSeparator', () => { + const path = get(SIMPLE_ONEOF, DISCRIMINATOR_PATH); + const selectedSchema = get(SIMPLE_ONEOF, [ONE_OF_KEY, 1]); + const schema = get(selectedSchema, [PROPERTIES_KEY, path]); + const initialIdSchema = toIdSchema(validator, SIMPLE_ONEOF, null, SIMPLE_ONEOF); + const formData = { [path]: SIMPLE_ONEOF_OPTIONS[1].value }; + const idSeparator = '~'; + expect( + LayoutGridField.getSchemaDetailsForField( + simpleOneOfRegistry.schemaUtils, + path, + SIMPLE_ONEOF, + formData, + initialIdSchema, + idSeparator + ) + ).toEqual({ + schema, + isRequired: false, + isReadonly: true, + optionsInfo: undefined, + idSchema: testGetIdSchema(path, idSeparator), + }); + expect(findSelectedOptionInXxxOf).toHaveBeenCalledWith(SIMPLE_ONEOF, path, ONE_OF_KEY, formData); + expect(toIdSchemaSpy).toHaveBeenCalledWith( + selectedSchema, + initialIdSchema[ID_KEY], + formData, + initialIdSchema[ID_KEY], + idSeparator + ); + }); + }); + describe('LayoutGridField.getSchemaDetailsForField(), gridFormSchema', () => { + beforeAll(() => { + retrieveSchemaSpy = jest.spyOn(gridFormSchemaRegistry.schemaUtils, 'retrieveSchema'); + toIdSchemaSpy = jest.spyOn(gridFormSchemaRegistry.schemaUtils, 'toIdSchema'); + findSelectedOptionInXxxOf = jest.spyOn(gridFormSchemaRegistry.schemaUtils, 'findSelectedOptionInXxxOf'); + }); + afterEach(() => { + findSelectedOptionInXxxOf.mockClear(); + retrieveSchemaSpy.mockClear(); + toIdSchemaSpy.mockClear(); + }); + afterAll(() => { + retrieveSchemaSpy.mockRestore(); + toIdSchemaSpy.mockRestore(); + }); + test('returns schema, isRequired: true, isReadonly: undefined, options when oneOf schema is requested', () => { + const path = 'employment'; + const { field: schema } = gridFormSchemaRegistry.schemaUtils.findFieldInSchema(GRID_FORM_SCHEMA, path); + retrieveSchemaSpy.mockClear(); + expect( + LayoutGridField.getSchemaDetailsForField( + gridFormSchemaRegistry.schemaUtils, + path, + GRID_FORM_SCHEMA, + {}, + GRID_FORM_ID_SCHEMA + ) + ).toEqual({ + schema, + isRequired: false, + isReadonly: undefined, + idSchema: testGetIdSchema(path), + optionsInfo: { options: get(schema, [ONE_OF_KEY]), hasDiscriminator: true }, + }); + expect(retrieveSchemaSpy).toHaveBeenCalledTimes(2); + expect(toIdSchemaSpy).not.toHaveBeenCalled(); + }); + test('returns schema, isRequired: true, isReadonly: undefined, options: undefined when drilling through oneOf schema to prop', () => { + const path = 'employment.location.state'; + const formData = { employment: { job_type: 'company' } }; + const schema: RJSFSchema = gridFormSchemaRegistry.schemaUtils.getFromSchema( + GRID_FORM_SCHEMA, + [DEFINITIONS_KEY, 'Location', PROPERTIES_KEY, 'state'], + {} + ); + expect( + LayoutGridField.getSchemaDetailsForField( + gridFormSchemaRegistry.schemaUtils, + path, + GRID_FORM_SCHEMA, + formData, + GRID_FORM_ID_SCHEMA + ) + ).toEqual({ + schema, + isRequired: true, + isReadonly: undefined, + optionsInfo: undefined, + idSchema: testGetIdSchema(path), + }); + const subschemaSchema = gridFormSchemaRegistry.schemaUtils.getFromSchema( + GRID_FORM_SCHEMA, + [PROPERTIES_KEY, 'employment'], + {} + ); + expect(findSelectedOptionInXxxOf).toHaveBeenCalledWith( + subschemaSchema, + path.split('.')[1], + ONE_OF_KEY, + formData.employment + ); + expect(toIdSchemaSpy).toHaveBeenCalledTimes(1); + }); + }); + describe('LayoutGridField.getSchemaDetailsForField(), readonlySchema', () => { + beforeAll(() => { + retrieveSchemaSpy = jest.spyOn(gridFormSchemaRegistry.schemaUtils, 'retrieveSchema'); + toIdSchemaSpy = jest.spyOn(gridFormSchemaRegistry.schemaUtils, 'toIdSchema'); + findSelectedOptionInXxxOf = jest.spyOn(gridFormSchemaRegistry.schemaUtils, 'findSelectedOptionInXxxOf'); + }); + afterEach(() => { + findSelectedOptionInXxxOf.mockClear(); + retrieveSchemaSpy.mockClear(); + toIdSchemaSpy.mockClear(); + }); + afterAll(() => { + retrieveSchemaSpy.mockRestore(); + toIdSchemaSpy.mockRestore(); + }); + test('returns schema, isRequired: false, isReadonly: undefined, options when oneOf schema is requested', () => { + const path = 'stringSelect'; + const { field: schema } = readonlySchemaRegistry.schemaUtils.findFieldInSchema(readonlySchema, path); + retrieveSchemaSpy.mockClear(); + expect( + LayoutGridField.getSchemaDetailsForField( + readonlySchemaRegistry.schemaUtils, + path, + readonlySchema, + {}, + READONLY_ID_SCHEMA + ) + ).toEqual({ + schema, + isRequired: false, + isReadonly: undefined, + idSchema: testGetIdSchema(path), + optionsInfo: { options: get(schema, [ONE_OF_KEY]), hasDiscriminator: false }, + }); + expect(retrieveSchemaSpy).not.toHaveBeenCalled(); + expect(toIdSchemaSpy).not.toHaveBeenCalled(); + }); + test('returns schema, isRequired: true, isReadonly: true, options: undefined when selecting readonly field', () => { + const path = 'roString'; + const schema = readonlySchema.properties![path]; + expect( + LayoutGridField.getSchemaDetailsForField( + readonlySchemaRegistry.schemaUtils, + path, + readonlySchema, + {}, + READONLY_ID_SCHEMA + ) + ).toEqual({ + schema, + isRequired: false, + isReadonly: true, + idSchema: testGetIdSchema(path), + optionsInfo: undefined, + }); + expect(retrieveSchemaSpy).not.toHaveBeenCalled(); + expect(toIdSchemaSpy).not.toHaveBeenCalled(); + }); + test('returns schema, isRequired: true, isReadonly: true, options: undefined when selecting field on readonly parent', () => { + const path = 'nested.roNumber'; + const schema = get(readonlySchema, [PROPERTIES_KEY, 'nested', PROPERTIES_KEY, 'roNumber']); + expect( + LayoutGridField.getSchemaDetailsForField( + readonlySchemaRegistry.schemaUtils, + path, + readonlySchema, + {}, + READONLY_ID_SCHEMA + ) + ).toEqual({ + schema, + isRequired: false, + isReadonly: true, + idSchema: testGetIdSchema(path), + optionsInfo: undefined, + }); + expect(retrieveSchemaSpy).not.toHaveBeenCalled(); + expect(toIdSchemaSpy).not.toHaveBeenCalled(); + }); + test('returns schema, isRequired: true, isReadonly: false, options: undefined when selecting explicitly readonly false field', () => { + const path = 'nested.number'; + const { field: schema } = readonlySchemaRegistry.schemaUtils.findFieldInSchema(readonlySchema, path); + retrieveSchemaSpy.mockClear(); + expect( + LayoutGridField.getSchemaDetailsForField( + readonlySchemaRegistry.schemaUtils, + path, + readonlySchema, + {}, + READONLY_ID_SCHEMA + ) + ).toEqual({ + schema, + isRequired: true, + isReadonly: false, + idSchema: testGetIdSchema(path), + optionsInfo: undefined, + }); + expect(retrieveSchemaSpy).not.toHaveBeenCalled(); + expect(toIdSchemaSpy).not.toHaveBeenCalled(); + }); + }); + describe('LayoutGridField.getCustomRenderComponent()', () => { + test('returns null when render is not a string or function', () => { + expect(LayoutGridField.getCustomRenderComponent({} as string, registry)).toBeNull(); + }); + test('returns null when render is a string without a lookup', () => { + expect(LayoutGridField.getCustomRenderComponent('nonexistant', registry)).toBeNull(); + }); + test('returns the render function when render is a string with a lookup', () => { + expect(LayoutGridField.getCustomRenderComponent('TestRenderer', registry)).toBe(TestRenderer); + }); + test('returns the given function when render is a function', () => { + expect(LayoutGridField.getCustomRenderComponent(TestRenderer, registry)).toBe(TestRenderer); + }); + }); + describe('LayoutGridField.computeFieldUiSchema()', () => { + test('field with empty uiProps', () => { + const uiProps = {}; + expect(LayoutGridField.computeFieldUiSchema('foo', uiProps)).toEqual({ + fieldUiSchema: uiProps, + uiReadonly: undefined, + }); + }); + test('field with non-empty uiProps', () => { + const uiProps = { fullWidth: true }; + expect(LayoutGridField.computeFieldUiSchema('foo', uiProps)).toEqual({ + fieldUiSchema: { [UI_OPTIONS_KEY]: uiProps }, + uiReadonly: undefined, + }); + }); + test('field with empty uiProps, ui:options and uiSchema for the field', () => { + const uiProps = {}; + const uiOptions = { classNames: 'baz' }; + const uiSchema = { foo: { 'ui:widget': 'bar', [UI_OPTIONS_KEY]: uiOptions } }; + expect(LayoutGridField.computeFieldUiSchema('foo', uiProps, uiSchema)).toEqual({ + fieldUiSchema: { ...uiSchema.foo, [UI_OPTIONS_KEY]: uiOptions }, + uiReadonly: undefined, + }); + }); + test('field with uiProps and uiSchema with global options for the field', () => { + const uiProps = { fullWidth: true }; + const globalOptions = { label: false }; + const uiSchema = { foo: { 'ui:widget': 'bar' }, [UI_GLOBAL_OPTIONS]: globalOptions }; + expect(LayoutGridField.computeFieldUiSchema('foo', uiProps, uiSchema)).toEqual({ + fieldUiSchema: { + ...uiSchema.foo, + [UI_OPTIONS_KEY]: { ...uiProps, ...globalOptions }, + [UI_GLOBAL_OPTIONS]: globalOptions, + }, + uiReadonly: undefined, + }); + }); + test('field with empty uiProps, uiSchema for the field and schemaReadonly true', () => { + const uiSchema = { foo: { 'ui:widget': 'bar' } }; + expect(LayoutGridField.computeFieldUiSchema('foo', {}, uiSchema, true)).toEqual({ + fieldUiSchema: { + ...uiSchema.foo, + 'ui:readonly': true, + }, + uiReadonly: true, + }); + }); + test('field with empty uiProps, uiSchema having readonly false in ui:options for the field and schemaReadonly true', () => { + const uiOptions = { readonly: false }; + const uiSchema = { foo: { 'ui:widget': 'bar', [UI_OPTIONS_KEY]: uiOptions } }; + expect(LayoutGridField.computeFieldUiSchema('foo', {}, uiSchema, true)).toEqual({ + fieldUiSchema: { + ...uiSchema.foo, + [UI_OPTIONS_KEY]: { readonly: false }, + }, + uiReadonly: false, + }); + }); + test('field with empty uiProps, uiSchema for the field and schemaReadonly false and forceReadonly true', () => { + const uiSchema = { foo: { 'ui:widget': 'bar' } }; + expect(LayoutGridField.computeFieldUiSchema('foo', {}, uiSchema, false, true)).toEqual({ + fieldUiSchema: { + ...uiSchema.foo, + 'ui:readonly': true, + }, + uiReadonly: true, + }); + }); + test('field with empty uiProps, uiSchema having readonly false in ui:options for the field and schemaReadonly unspecified and forceReadonly true', () => { + const uiOptions = { readonly: false }; + const uiSchema = { foo: { 'ui:widget': 'bar', [UI_OPTIONS_KEY]: uiOptions } }; + expect(LayoutGridField.computeFieldUiSchema('foo', {}, uiSchema, undefined, true)).toEqual({ + fieldUiSchema: { + ...uiSchema.foo, + [UI_OPTIONS_KEY]: { readonly: true }, + }, + uiReadonly: true, + }); + }); + }); + test('renders nothing when there is no uiSchema', () => { + const props = getProps(); + render(); + + // Check for all the possible things rendered by the grid + const uiComponent = screen.queryByTestId(LayoutGridField.TEST_IDS.uiComponent); + const field = screen.queryByTestId(LayoutGridField.TEST_IDS.field); + const layoutMultiSchemaField = screen.queryByTestId(LayoutGridField.TEST_IDS.layoutMultiSchemaField); + const row = screen.queryByTestId(LayoutGridField.TEST_IDS.uiComponent); + const col = screen.queryByTestId(LayoutGridField.TEST_IDS.uiComponent); + + // and find none of them + expect(uiComponent).not.toBeInTheDocument(); + expect(field).not.toBeInTheDocument(); + expect(layoutMultiSchemaField).not.toBeInTheDocument(); + expect(row).not.toBeInTheDocument(); + expect(col).not.toBeInTheDocument(); + }); + test('renderField with render=TestRenderer via LAYOUT_GRID_OPTION', () => { + const options = { name: 'foo', myProp: true }; + const props = getProps({ + uiSchema: { + [LAYOUT_GRID_OPTION]: { ...options, render: TestRenderer }, + }, + }); + render(); + // The props readonly flag is transformed to readOnly + const expectedProps = { ...props, ...options, readOnly: props.readonly, readonly: undefined }; + // Renders the uiComponent with the props and options forwarded + const uiComponent = screen.getByTestId(LayoutGridField.TEST_IDS.uiComponent); + expect(uiComponent).toHaveTextContent(stringifyProps(expectedProps)); + }); + test('renderField with render=TestRenderer via LAYOUT_GRID_OPTION and name is not provided', () => { + const options = { myProp: true }; + const props = getProps({ + uiSchema: { + [LAYOUT_GRID_OPTION]: { ...options, render: TestRenderer }, + }, + }); + render(); + // Renders the uiComponent with only the options forwarded + const uiComponent = screen.getByTestId(LayoutGridField.TEST_IDS.uiComponent); + expect(uiComponent).toHaveTextContent(stringifyProps(options)); + }); + test('renderField via name explicit layoutGridSchema', async () => { + const fieldName = 'simpleString'; + const props = getProps({ + schema: SAMPLE_SCHEMA, + uiSchema: sampleUISchema, + layoutGridSchema: fieldName, + idSeparator: '.', + registry: sampleSchemaRegistry, + }); + const fieldId = get(props.idSchema, [fieldName, ID_KEY]); + render(); + // Renders a field + const field = screen.getByTestId(LayoutGridField.TEST_IDS.field); + expect(field).toHaveTextContent(stringifyProps(getExpectedPropsForField(props, fieldName))); + // Test onChange, onFocus, onBlur + const input = within(field).getByRole('textbox'); + // Click on the input to cause the focus + await userEvent.click(input); + expect(props.onFocus).toHaveBeenCalledWith(fieldId, ''); + // Type to trigger the onChange + await userEvent.type(input, 'foo'); + expect(props.onChange).toHaveBeenCalledWith({ [fieldName]: 'foo' }, props.errorSchema, fieldId); + // Tab out of the input field to cause the blur + await userEvent.tab(); + expect(props.onBlur).toHaveBeenCalledWith(fieldId, 'foo'); + }); + test('renderField via object explicit layoutGridSchema, otherProps', () => { + const fieldName = 'employment'; + const globalUiOptions = { propToApplyToAllFields: 'foobar' }; + const otherProps = { hideError: true, name: 'value will be overridden by name from layoutGridSchema' }; + const otherUIProps = { inline: true }; + const props = getProps({ + schema: GRID_FORM_SCHEMA, + uiSchema: { ...gridFormUISchema, [UI_GLOBAL_OPTIONS]: globalUiOptions }, + formData: {}, + errorSchema: { employment: {} }, + // IdSchema is weirdly recursive and it's easier to just ignore the error + idSchema: { [ID_KEY]: 'root', employment: { [ID_KEY]: 'employment' } }, + idSeparator: '.', + layoutGridSchema: { + name: fieldName, + ...otherUIProps, + }, + registry: gridFormSchemaRegistry, + }); + render(); + const field = screen.getByTestId(LayoutGridField.TEST_IDS.layoutMultiSchemaField); + expect(field).toHaveTextContent( + stringifyProps(getExpectedPropsForField(props, fieldName, otherProps, otherUIProps)) + ); + }); + test('renderField via object explicit readonlySchema, and uiSchema readonly override', () => { + const fieldName = 'string'; + const props = getProps({ + schema: readonlySchema, + uiSchema: readonlyUISchema, + formData: {}, + errorSchema: { string: {} }, + // IdSchema is weirdly recursive and it's easier to just ignore the error + idSchema: { [ID_KEY]: 'root', string: { [ID_KEY]: 'string' } }, + idSeparator: '.', + layoutGridSchema: { + name: fieldName, + }, + registry: readonlySchemaRegistry, + }); + render(); + const field = screen.getByTestId(LayoutGridField.TEST_IDS.field); + expect(field).toHaveTextContent(stringifyProps(getExpectedPropsForField(props, fieldName))); + }); + test('renderRow not nested', () => { + const gridProps = { spacing: 2 }; + const props = getProps({ + schema: SAMPLE_SCHEMA, + uiSchema: sampleUISchema, + formData: {}, + layoutGridSchema: { [GridType.ROW]: { ...gridProps, children: GRID_CHILDREN } }, + registry: sampleSchemaRegistry, + }); + render(); + // Renders an outer grid row + const row = screen.getByTestId(LayoutGridField.TEST_IDS.row); + expect(row).toHaveClass('row'); + // Renders 2 fields in the row + const fields = within(row).getAllByTestId(LayoutGridField.TEST_IDS.field); + expect(fields).toHaveLength(GRID_CHILDREN.length); + // First field as the first child + expect(fields[0]).toHaveTextContent(stringifyProps(getExpectedPropsForField(props, GRID_CHILDREN[0]))); + // Second field as the second child + expect(fields[1]).toHaveTextContent(stringifyProps(getExpectedPropsForField(props, GRID_CHILDREN[1]))); + }); + test('renderRow nested', () => { + const props = getProps({ + schema: SAMPLE_SCHEMA, + uiSchema: sampleUISchema, + formData: {}, + layoutGridSchema: { [GridType.ROW]: { className: ColumnWidth6, children: GRID_CHILDREN } }, + isNested: true, + registry: sampleSchemaRegistry, + }); + render(); + // Renders an outer grid row item with width 6 + const row = screen.getByTestId(LayoutGridField.TEST_IDS.row); + expect(row).toHaveClass(`row ${ColumnWidth6}`); + // Renders 2 fields in the row + const fields = within(row).getAllByTestId(LayoutGridField.TEST_IDS.field); + expect(fields).toHaveLength(GRID_CHILDREN.length); + // First field as the first child + expect(fields[0]).toHaveTextContent(stringifyProps(getExpectedPropsForField(props, GRID_CHILDREN[0]))); + // Second field as the second child + expect(fields[1]).toHaveTextContent(stringifyProps(getExpectedPropsForField(props, GRID_CHILDREN[1]))); + }); + test('renderCol', () => { + const props = getProps({ + schema: SAMPLE_SCHEMA, + uiSchema: sampleUISchema, + formData: {}, + layoutGridSchema: { [GridType.COLUMN]: { className: ColumnWidth6, children: GRID_CHILDREN } }, + registry: sampleSchemaRegistry, + }); + render(); + // Renders an outer grid item with width 6, but not a row + const col = screen.getByTestId(LayoutGridField.TEST_IDS.col); + expect(col).toHaveClass(ColumnWidth6); + expect(col).not.toHaveClass('row'); + // Renders 2 fields in the column + const fields = within(col).getAllByTestId(LayoutGridField.TEST_IDS.field); + expect(fields).toHaveLength(GRID_CHILDREN.length); + // First field as the first child + expect(fields[0]).toHaveTextContent(stringifyProps(getExpectedPropsForField(props, GRID_CHILDREN[0]))); + // Second field as the second child + expect(fields[1]).toHaveTextContent(stringifyProps(getExpectedPropsForField(props, GRID_CHILDREN[1]))); + }); + test('renderColumns', () => { + const props = getProps({ + schema: SAMPLE_SCHEMA, + uiSchema: sampleUISchema, + formData: {}, + layoutGridSchema: { [GridType.COLUMNS]: { className: ColumnWidth6, children: GRID_CHILDREN } }, + registry: sampleSchemaRegistry, + }); + render(); + // Renders two outer columns + const cols = screen.getAllByTestId(LayoutGridField.TEST_IDS.col); + expect(cols).toHaveLength(GRID_CHILDREN.length); + // First column is a grid item with width 6 + expect(cols[0]).toHaveClass(ColumnWidth6); + expect(cols[0]).not.toHaveClass('row'); + // Renders first field in the first column + let field = within(cols[0]).getByTestId(LayoutGridField.TEST_IDS.field); + expect(field).toHaveTextContent(stringifyProps(getExpectedPropsForField(props, GRID_CHILDREN[0]))); + // First column is a grid item with 6 + expect(cols[1]).toHaveClass(ColumnWidth6); + expect(cols[1]).not.toHaveClass('row'); + // Renders second field in the second column + field = within(cols[1]).getByTestId(LayoutGridField.TEST_IDS.field); + expect(field).toHaveTextContent(stringifyProps(getExpectedPropsForField(props, GRID_CHILDREN[1]))); + }); + test('renderCondition, condition passes, field and empty value, NONE operator, has formData', async () => { + const fieldName = 'simpleString'; + const gridProps = { operator: Operators.NONE, field: fieldName, value: null }; + const props = getProps({ + schema: SAMPLE_SCHEMA, + uiSchema: { ...sampleUISchema, [UI_GLOBAL_OPTIONS]: { always: 'there' } }, + formData: { [fieldName]: 'foo' }, + layoutGridSchema: { [GridType.CONDITION]: { ...gridProps, children: GRID_CHILDREN } }, + registry: sampleSchemaRegistry, + }); + const fieldId = get(props.idSchema, [fieldName, ID_KEY]); + render(); + // Renders 2 fields + const fields = screen.getAllByTestId(LayoutGridField.TEST_IDS.field); + expect(fields).toHaveLength(GRID_CHILDREN.length); + // First field as the first child + expect(fields[0]).toHaveTextContent(stringifyProps(getExpectedPropsForField(props, GRID_CHILDREN[0]))); + // Second field as the second child + expect(fields[1]).toHaveTextContent(stringifyProps(getExpectedPropsForField(props, GRID_CHILDREN[1]))); + // Test onChange and value in the input + const input = within(fields[0]).getByRole('textbox'); + expect(input).toHaveValue(props.formData[fieldName]); + await userEvent.type(input, '!'); + const expectedErrors = new ErrorSchemaBuilder().addErrors(ERRORS, fieldName).ErrorSchema; + expect(props.onChange).toHaveBeenCalledWith({ [fieldName]: 'foo!' }, expectedErrors, fieldId); + }); + test('renderCondition, condition fails, field and null value, NONE operator, no data', () => { + const gridProps = { operator: Operators.NONE, field: 'simpleString', value: null }; + const props = getProps({ + schema: SAMPLE_SCHEMA, + uiSchema: sampleUISchema, + layoutGridSchema: { [GridType.CONDITION]: { ...gridProps, children: GRID_CHILDREN } }, + registry: sampleSchemaRegistry, + }); + render(); + // Check for all the possible things rendered by the grid + const uiComponent = screen.queryByTestId(LayoutGridField.TEST_IDS.uiComponent); + const field = screen.queryByTestId(LayoutGridField.TEST_IDS.field); + const layoutMultiSchemaField = screen.queryByTestId(LayoutGridField.TEST_IDS.layoutMultiSchemaField); + const row = screen.queryByTestId(LayoutGridField.TEST_IDS.uiComponent); + const col = screen.queryByTestId(LayoutGridField.TEST_IDS.uiComponent); + // and find none of them + expect(uiComponent).not.toBeInTheDocument(); + expect(field).not.toBeInTheDocument(); + expect(layoutMultiSchemaField).not.toBeInTheDocument(); + expect(row).not.toBeInTheDocument(); + expect(col).not.toBeInTheDocument(); + }); + test('renderCondition, condition fails, no field or value specified', () => { + const gridProps = { operator: Operators.ALL }; + const props = getProps({ + schema: SAMPLE_SCHEMA, + uiSchema: sampleUISchema, + layoutGridSchema: { [GridType.CONDITION]: { ...gridProps, children: GRID_CHILDREN } }, + registry: sampleSchemaRegistry, + }); + render(); + // Check for all the possible things rendered by the grid + const uiComponent = screen.queryByTestId(LayoutGridField.TEST_IDS.uiComponent); + const field = screen.queryByTestId(LayoutGridField.TEST_IDS.field); + const layoutMultiSchemaField = screen.queryByTestId(LayoutGridField.TEST_IDS.layoutMultiSchemaField); + const row = screen.queryByTestId(LayoutGridField.TEST_IDS.uiComponent); + const col = screen.queryByTestId(LayoutGridField.TEST_IDS.uiComponent); + // and find none of them + expect(uiComponent).not.toBeInTheDocument(); + expect(field).not.toBeInTheDocument(); + expect(layoutMultiSchemaField).not.toBeInTheDocument(); + expect(row).not.toBeInTheDocument(); + expect(col).not.toBeInTheDocument(); + }); +}); diff --git a/packages/core/test/LayoutMultiSchemaField.test.tsx b/packages/core/test/LayoutMultiSchemaField.test.tsx new file mode 100644 index 0000000000..8e9201d4e2 --- /dev/null +++ b/packages/core/test/LayoutMultiSchemaField.test.tsx @@ -0,0 +1,545 @@ +import { + ANY_OF_KEY, + DEFAULT_KEY, + DEFINITIONS_KEY, + EnumOptionsType, + ERRORS_KEY, + ErrorSchemaBuilder, + FieldErrorProps, + FieldProps, + getDiscriminatorFieldFromSchema, + ID_KEY, + IdSchema, + ONE_OF_KEY, + optionsList, + PROPERTIES_KEY, + RJSFSchema, + UI_OPTIONS_KEY, + UI_WIDGET_KEY, + WidgetProps, +} from '@rjsf/utils'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { get } from 'lodash'; + +import LayoutMultiSchemaField, { + computeEnumOptions, + getSelectedOption, +} from '../src/components/fields/LayoutMultiSchemaField'; +import RadioWidget from '../src/components/widgets/RadioWidget'; +import SelectWidget from '../src/components/widgets/SelectWidget'; +import { SIMPLE_ONEOF, SIMPLE_ONEOF_OPTIONS } from './testData/layoutData'; +import getTestRegistry from './testData/getTestRegistry'; + +jest.mock('@rjsf/utils', () => ({ + ...jest.requireActual('@rjsf/utils'), + getWidget: jest.fn().mockImplementation((_schema, widget, widgets) => { + const widgetToUse = widget === 'select' ? 'SelectWidget' : 'RadioWidget'; + // The real implementation wraps the resulting widget in another component, so we'll just do the simple thing + return widgets[widgetToUse]; + }), +})); + +const oneOfSchema = { + type: 'object', + title: 'Testing OneOfs', + definitions: { + first_option_def: { + type: 'object', + properties: { + name: { + type: 'string', + default: 'first_option', + readOnly: true, + }, + flag: { + type: 'boolean', + default: false, + }, + unlabeled_options: { + oneOf: [ + { + type: 'integer', + }, + { + type: 'array', + items: { + type: 'integer', + }, + }, + ], + }, + }, + required: ['name'], + additionalProperties: false, + }, + second_option_def: { + type: 'object', + properties: { + name: { + type: 'string', + default: 'second_option', + readOnly: true, + }, + flag: { + type: 'boolean', + default: false, + }, + unique_to_second: { + type: 'integer', + }, + labeled_options: { + oneOf: [ + { + type: 'string', + }, + { + type: 'array', + items: { + type: 'string', + }, + }, + ], + }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + oneOf: [ + { + $ref: '#/definitions/first_option_def', + title: 'first option', + }, + { + $ref: '#/definitions/second_option_def', + title: 'second option', + }, + ], +}; + +const oneOfData = { + name: 'second_option', + flag: true, +}; + +const anyOfSchema = { + discriminator: { + propertyName: 'answer', + }, + [ANY_OF_KEY]: SIMPLE_ONEOF[ONE_OF_KEY], +}; + +const DEFAULT_ID = 'test-id'; +const FIELD_ERROR_TEST_ID = 'FakeFieldErrorTemplate-testId'; + +const NOT_SHOWN_ERROR_SCHEMA = new ErrorSchemaBuilder().addErrors( + 'error message will not be rendered due to hideError flag' +).ErrorSchema; +const NESTED_ERROR_SCHEMA = new ErrorSchemaBuilder() + .addErrors(['first error', 'second error']) + .addErrors('bar', 'nestedFieldErrors.foo').ErrorSchema; + +const user = userEvent.setup(); + +function FakeFieldErrorTemplate(props: FieldErrorProps) { + const { errors } = props; + return {errors}; +} + +const SelectWidgetTestId = 'select-widget-testid'; + +function WrappedSelectWidget(props: WidgetProps) { + return ( +
+ +
+ ); +} + +const RadioWidgetTestId = 'select-widget-testid'; + +function WrappedRadioWidget(props: WidgetProps) { + return ( +
+ +
+ ); +} + +describe('LayoutMultiSchemaField', () => { + function getProps(overrideProps: Partial = {}): FieldProps { + const { + formData, + idSchema = { [ID_KEY]: DEFAULT_ID } as IdSchema, + options = SIMPLE_ONEOF[ONE_OF_KEY], + schema = SIMPLE_ONEOF as RJSFSchema, + uiSchema = {}, + disabled = false, + hideError = false, + errorSchema = {}, + required = false, + autofocus = false, + } = overrideProps; + return { + // required FieldProps stubbed + autofocus, + formContext: {}, + name: '', + readonly: false, + required, + // end required FieldProps + baseType: 'object', + disabled, + formData, + idSchema, + options, + registry: getTestRegistry( + schema, + {}, + { FieldErrorTemplate: FakeFieldErrorTemplate }, + { SelectWidget: WrappedSelectWidget, RadioWidget: WrappedRadioWidget } + ), + schema, + uiSchema, + errorSchema, + hideError, + onBlur: jest.fn(), + onChange: jest.fn(), + onFocus: jest.fn(), + }; + } + let consoleErrorSpy: jest.SpyInstance; + beforeAll(() => { + // silence the error reporting + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + }); + afterEach(() => { + consoleErrorSpy.mockClear(); + }); + afterAll(() => { + consoleErrorSpy.mockRestore(); + }); + test('throws when no selectorField is provided', () => { + const expectedError = 'No selector field provided for the LayoutMultiSchemaField'; + const schema: RJSFSchema = { + oneOf: [ + { + title: 'Choice 1', + type: 'string', + const: '1', + }, + { + title: 'Choice 2', + type: 'string', + const: '2', + }, + ], + }; + const props = getProps({ schema, options: schema[ONE_OF_KEY] }); + expect(() => render()).toThrow(expectedError); + expect(consoleErrorSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + message: expect.stringContaining(expectedError), + type: 'unhandled exception', + }) + ); + }); + test('default render with SIMPLE_ONEOF schema', async () => { + const selectorField = getDiscriminatorFieldFromSchema(SIMPLE_ONEOF)!; + const props = getProps({ schema: { ...SIMPLE_ONEOF, title: undefined } }); + + const { rerender } = render(); + + // Renders the formControl that is the outer wrapper of the RadioWidget + const formControl = screen.getByTestId(RadioWidgetTestId); + expect(formControl).toBeInTheDocument(); + + // Renders formGroup + const formGroup = within(formControl).getByRole('radiogroup'); + expect(formGroup).toBeInTheDocument(); + + // Renders formLabel for each source + const radios = within(formControl).getAllByRole('radio'); + expect(radios).toHaveLength(SIMPLE_ONEOF_OPTIONS.length); + + radios.forEach((radio, index) => { + expect(radio).toBeInTheDocument(); + // Renders the correct label for each source + expect(radio.parentElement).toHaveTextContent(SIMPLE_ONEOF_OPTIONS[index].label); + }); + + // Radio button should not be checked + const input = radios[1]; + expect(input).not.toBeChecked(); + + await user.click(input); + + // OnChange was called with the correct event + expect(props.onChange).toHaveBeenCalledWith({ [selectorField]: '2' }); + + // Rerender to simulate the onChange updating the value + const newFormData = { [selectorField]: SIMPLE_ONEOF_OPTIONS[1].value }; + rerender(); + + // Checkbox should now be checked + expect(input).toBeChecked(); + }); + test('custom selector field, title and widget in uiSchema, formData, has error', async () => { + const selectorField = 'name'; + const props = getProps({ + options: oneOfSchema[ONE_OF_KEY], + schema: oneOfSchema as RJSFSchema, + formData: oneOfData, + uiSchema: { + [UI_OPTIONS_KEY]: { optionsSchemaSelector: selectorField, title: 'Test Title' }, + [UI_WIDGET_KEY]: 'select', + }, + errorSchema: NESTED_ERROR_SCHEMA, + }); + render(); + + // Renders a form control + const formControl = screen.getByTestId(SelectWidgetTestId); + expect(formControl).toBeInTheDocument(); + + // Renders the select button with correct text + const button = screen.getByRole('combobox'); + expect(button).toHaveTextContent(oneOfSchema.oneOf[1].title); + + // Renders the FakeFieldErrorTemplate with correct text + const fakeFieldErrorTemplate = screen.getByTestId(FIELD_ERROR_TEST_ID); + expect(fakeFieldErrorTemplate).toHaveTextContent(get(props.errorSchema, [ERRORS_KEY])!.join('')); + + await user.click(button); + // Verify the focus function was called + expect(props.onFocus).toHaveBeenCalledWith(DEFAULT_ID, oneOfData.name); + + // Menu list has the expected items with expected text and style + const items = within(formControl).getAllByRole('option'); + expect(items.length).toBe(oneOfSchema.oneOf.length + 1); // add one for clear selection text + + items.forEach((item, index) => { + if (index === 0) { + expect(item).toHaveTextContent(''); + expect(item).toHaveAttribute('value', ''); + } else { + expect(item).toHaveTextContent(oneOfSchema.oneOf[index - 1].title); + expect(item).toHaveAttribute('value', String(index - 1)); + } + }); + + // select the option with the '0' value + await user.selectOptions(button, '0'); + + // Verify the blur function was called + await user.tab(); + expect(props.onBlur).toHaveBeenCalledWith(DEFAULT_ID, oneOfData.name); + + // OnChange was called with the correct event + const retrievedOptions = props.options.map((opt: object) => + props.registry.schemaUtils.retrieveSchema(opt, props.formData) + ); + const sanitizedFormData = props.registry.schemaUtils.sanitizeDataForNewSchema( + retrievedOptions[0], + retrievedOptions[1], + props.formData + ); + await waitFor(() => { + expect(props.onChange).toHaveBeenCalledWith({ + ...props.registry.schemaUtils.getDefaultFormState(retrievedOptions[0], sanitizedFormData), + [selectorField]: 'first_option', + }); + }); + }); + test('custom selector field, ui:hideError false, props.hideError true, required true, autofocus true', async () => { + const selectorField = 'name'; + const props = getProps({ + autofocus: true, + required: true, + options: oneOfSchema[ONE_OF_KEY], + schema: oneOfSchema as RJSFSchema, + formData: oneOfData, + errorSchema: NESTED_ERROR_SCHEMA, + uiSchema: { [UI_OPTIONS_KEY]: { optionsSchemaSelector: selectorField, hideError: false } }, + hideError: true, + }); + render(); + + // onFocus is called automatically because autofocus is true + expect(props.onFocus).toHaveBeenCalledTimes(1); + + // Renders a form control + const formControl = screen.getByTestId(SelectWidgetTestId); + expect(formControl).toBeInTheDocument(); + + // Renders the select button + const button = screen.getByRole('combobox'); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent(oneOfSchema.oneOf[1].title); + + // Renders the FakeFieldErrorTemplate because 'ui:hideError' takes precedence over props.hideError + const fakeFieldErrorTemplate = screen.queryByTestId(FIELD_ERROR_TEST_ID); + expect(fakeFieldErrorTemplate).toBeInTheDocument(); + expect(fakeFieldErrorTemplate).toHaveTextContent(get(props.errorSchema, [ERRORS_KEY])!.join('')); + + await user.click(button); + + // Menu list has the expected items with expected text and style + const items = within(formControl).getAllByRole('option'); + expect(items.length).toBe(oneOfSchema.oneOf.length + 1); // add one for clear selection text + + items.forEach((item, index) => { + if (index === 0) { + expect(item).toHaveTextContent(''); + expect(item).toHaveAttribute('value', ''); + } else { + expect(item).toHaveTextContent(oneOfSchema.oneOf[index - 1].title); + expect(item).toHaveAttribute('value', String(index - 1)); + } + }); + + // select the option with the '0' value + await user.selectOptions(button, ''); + + // OnChange was called with the correct event + expect(props.onChange).toHaveBeenCalledWith(undefined); + }); + test('no options for radio widget, ui:hideError true, props.hideError false, no errors to hide', () => { + const props = getProps({ options: [], uiSchema: { 'ui:hideError': true }, hideError: false }); + render(); + + // Renders a form control + const formControl = screen.getByTestId(RadioWidgetTestId); + expect(formControl).toBeInTheDocument(); + + // renders the radio group + expect(screen.queryByRole('radiogroup')).toBeInTheDocument(); + + // radio group has no radios because there are no options + expect(screen.queryAllByRole('radio').length).toBe(0); + + // Does not render the FakeFieldErrorTemplate because 'ui:hideError' takes precedence over props.hideError + const fakeFieldErrorTemplate = screen.queryByTestId(FIELD_ERROR_TEST_ID); + expect(fakeFieldErrorTemplate).not.toBeInTheDocument(); + }); + test('implicitly disabled due to no options for select widget, ui:hideError true, props.hideError false, no errors to hide', () => { + const selectorField = 'name'; + const props = getProps({ + schema: oneOfSchema as RJSFSchema, + options: [], + uiSchema: { [UI_OPTIONS_KEY]: { optionsSchemaSelector: selectorField, hideError: false } }, + hideError: false, + }); + render(); + + // Renders a form control + const formControl = screen.getByTestId(SelectWidgetTestId); + expect(formControl).toBeInTheDocument(); + + // Renders the select button + const button = screen.getByRole('combobox'); + expect(button).toBeInTheDocument(); + expect(button).toBeDisabled(); + + // Does not render the FakeFieldErrorTemplate because 'ui:hideError' takes precedence over props.hideError + const fakeFieldErrorTemplate = screen.queryByTestId(FIELD_ERROR_TEST_ID); + expect(fakeFieldErrorTemplate).not.toBeInTheDocument(); + }); + test('explicitly disabled, additional ui props, idSchema, has error, hideError prop true', () => { + const props = getProps({ + idSchema: { [ID_KEY]: 'testid' } as IdSchema, + disabled: true, + options: SIMPLE_ONEOF[ONE_OF_KEY], + schema: SIMPLE_ONEOF, + hideError: true, + errorSchema: NOT_SHOWN_ERROR_SCHEMA, + }); + render(); + + // Renders the formControl that is the outer wrapper of the RadioWidget + const formControl = screen.getByTestId(RadioWidgetTestId); + expect(formControl).toBeInTheDocument(); + + const formGroup = within(formControl).getByRole('radiogroup'); + expect(formGroup).toBeInTheDocument(); + + // Renders formLabel for each source + const radios = within(formControl).getAllByRole('radio'); + expect(radios).toHaveLength(SIMPLE_ONEOF_OPTIONS.length); + + radios.forEach((radio, index) => { + expect(radio).toBeDisabled(); + // Renders the correct label for each source + expect(radio.parentElement).toHaveTextContent(SIMPLE_ONEOF_OPTIONS[index].label); + }); + + // Does not render the FakeFieldErrorTemplate + const fakeFieldErrorTemplate = screen.queryByTestId(FIELD_ERROR_TEST_ID); + expect(fakeFieldErrorTemplate).not.toBeInTheDocument(); + }); + describe('computeEnumOptions', () => { + test('Reads oneOfs from refs', () => { + const schema = oneOfSchema as RJSFSchema; + const uiSchema = { [UI_OPTIONS_KEY]: { optionsSchemaSelector: 'name' } }; + const { schemaUtils } = getTestRegistry(schema); + const option1 = schemaUtils.retrieveSchema(oneOfSchema[ONE_OF_KEY][0]); + const option2 = schemaUtils.retrieveSchema(oneOfSchema[ONE_OF_KEY][1]); + const enumOptions = computeEnumOptions(schema, oneOfSchema[ONE_OF_KEY], schemaUtils, uiSchema); + expect(enumOptions).toEqual([ + { + schema: option1, + label: get(oneOfSchema, [ONE_OF_KEY, 0, 'title']), + value: get(oneOfSchema, [DEFINITIONS_KEY, 'first_option_def', PROPERTIES_KEY, 'name', DEFAULT_KEY]), + }, + { + schema: option2, + label: get(oneOfSchema, [ONE_OF_KEY, 1, 'title']), + value: get(oneOfSchema, [DEFINITIONS_KEY, 'second_option_def', PROPERTIES_KEY, 'name', DEFAULT_KEY]), + }, + ]); + }); + test('Reads anyOf', () => { + const schema = anyOfSchema as RJSFSchema; + const options = anyOfSchema[ANY_OF_KEY] as RJSFSchema[]; + const { schemaUtils } = getTestRegistry(schema); + const enumOptions = computeEnumOptions(schema, options, schemaUtils); + expect(enumOptions).toEqual([ + { + schema: options[0], + label: options[0].title, + value: get(anyOfSchema, [ANY_OF_KEY, 0, PROPERTIES_KEY, 'answer', DEFAULT_KEY]), + }, + { + schema: options[1], + label: options[1].title, + value: get(anyOfSchema, [ANY_OF_KEY, 1, PROPERTIES_KEY, 'answer', DEFAULT_KEY]), + }, + ]); + }); + test('throws error when no enumOptions are generated', () => { + const { schemaUtils } = getTestRegistry({}); + expect(() => computeEnumOptions({}, [], schemaUtils)).toThrow('No enumOptions were computed from the schema {}'); + }); + }); + describe('getSelectedOption', () => { + let selectorField: string; + let enumOptions: EnumOptionsType[]; + beforeAll(() => { + selectorField = getDiscriminatorFieldFromSchema(SIMPLE_ONEOF)!; + enumOptions = optionsList(SIMPLE_ONEOF)!; + }); + test('no value, returns undefined', () => { + expect(getSelectedOption(enumOptions, selectorField, undefined)).toBeUndefined(); + }); + test('existing value,returns option with existing value', () => { + expect(getSelectedOption(enumOptions, selectorField, SIMPLE_ONEOF_OPTIONS[0].value)).toBe( + SIMPLE_ONEOF[ONE_OF_KEY]![0] + ); + }); + test('non-existing value, returns undefined', () => { + expect(getSelectedOption(enumOptions, selectorField, 'randomValue')).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/test/__snapshots__/ArraySnap.test.jsx.snap b/packages/core/test/__snapshots__/ArraySnap.test.jsx.snap index 26bcb23b80..9bab6c942f 100644 --- a/packages/core/test/__snapshots__/ArraySnap.test.jsx.snap +++ b/packages/core/test/__snapshots__/ArraySnap.test.jsx.snap @@ -302,6 +302,7 @@ exports[`array fields checkboxes 1`] = ` onChange={[Function]} onFocus={[Function]} required={false} + role="combobox" value={[]} >