mirror of
https://github.com/infiniflow/ragflow.git
synced 2025-12-08 12:32:30 +08:00
### What problem does this PR solve? Feat: Configure structured data output for agent forms #10866 ### Type of change - [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
386
web/package-lock.json
generated
386
web/package-lock.json
generated
@ -48,6 +48,8 @@
|
||||
"@uiw/react-markdown-preview": "^5.1.3",
|
||||
"@xyflow/react": "^12.3.6",
|
||||
"ahooks": "^3.7.10",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"antd": "^5.12.7",
|
||||
"axios": "^1.12.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
@ -154,108 +156,6 @@
|
||||
"node": ">=18.20.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@@xyflow/react/background": {
|
||||
"version": "11.3.12",
|
||||
"resolved": "https://registry.npmmirror.com/@@xyflow/react/background/-/background-11.3.12.tgz",
|
||||
"integrity": "sha512-jBuWVb43JQy5h4WOS7G0PU8voGTEJNA+qDmx8/jyBtrjbasTesLNfQvboTGjnQYYiJco6mw5vrtQItAJDNoIqw==",
|
||||
"extraneous": true,
|
||||
"dependencies": {
|
||||
"@@xyflow/react/core": "11.11.2",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@@xyflow/react/controls": {
|
||||
"version": "11.2.12",
|
||||
"resolved": "https://registry.npmmirror.com/@@xyflow/react/controls/-/controls-11.2.12.tgz",
|
||||
"integrity": "sha512-L9F3+avFRShoprdT+5oOijm5gVsz2rqWCXBzOAgD923L1XFGIspdiHLLf8IlPGsT+mfl0GxbptZhaEeEzl1e3g==",
|
||||
"extraneous": true,
|
||||
"dependencies": {
|
||||
"@@xyflow/react/core": "11.11.2",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@@xyflow/react/core": {
|
||||
"version": "11.11.2",
|
||||
"resolved": "https://registry.npmmirror.com/@@xyflow/react/core/-/core-11.11.2.tgz",
|
||||
"integrity": "sha512-+GfgyskweL1PsgRSguUwfrT2eDotlFgaKfDLm7x0brdzzPJY2qbCzVetaxedaiJmIli3817iYbILvE9qLKwbRA==",
|
||||
"extraneous": true,
|
||||
"dependencies": {
|
||||
"@types/d3": "^7.4.0",
|
||||
"@types/d3-drag": "^3.0.1",
|
||||
"@types/d3-selection": "^3.0.3",
|
||||
"@types/d3-zoom": "^3.0.1",
|
||||
"classcat": "^5.0.3",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@@xyflow/react/minimap": {
|
||||
"version": "11.7.12",
|
||||
"resolved": "https://registry.npmmirror.com/@@xyflow/react/minimap/-/minimap-11.7.12.tgz",
|
||||
"integrity": "sha512-SRDU77c2PCF54PV/MQfkz7VOW46q7V1LZNOQlXAp7dkNyAOI6R+tb9qBUtUJOvILB+TCN6pRfD9fQ+2T99bW3Q==",
|
||||
"extraneous": true,
|
||||
"dependencies": {
|
||||
"@@xyflow/react/core": "11.11.2",
|
||||
"@types/d3-selection": "^3.0.3",
|
||||
"@types/d3-zoom": "^3.0.1",
|
||||
"classcat": "^5.0.3",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@@xyflow/react/node-resizer": {
|
||||
"version": "2.2.12",
|
||||
"resolved": "https://registry.npmmirror.com/@@xyflow/react/node-resizer/-/node-resizer-2.2.12.tgz",
|
||||
"integrity": "sha512-6LHJGuI1zHyRrZHw5gGlVLIWnvVxid9WIqw8FMFSg+oF2DuS3pAPwSoZwypy7W22/gDNl9eD1Dcl/OtFtDFQ+w==",
|
||||
"extraneous": true,
|
||||
"dependencies": {
|
||||
"@@xyflow/react/core": "11.11.2",
|
||||
"classcat": "^5.0.4",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@@xyflow/react/node-toolbar": {
|
||||
"version": "1.3.12",
|
||||
"resolved": "https://registry.npmmirror.com/@@xyflow/react/node-toolbar/-/node-toolbar-1.3.12.tgz",
|
||||
"integrity": "sha512-4kJRvNna/E3y2MZW9/80wTKwkhw4pLJiz3D5eQrD13XcmojSb1rArO9CiwyrI+rMvs5gn6NlCFB4iN1F+Q+lxQ==",
|
||||
"extraneous": true,
|
||||
"dependencies": {
|
||||
"@@xyflow/react/core": "11.11.2",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@aashutoshrathi/word-wrap": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmmirror.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz",
|
||||
@ -2681,6 +2581,23 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
|
||||
@ -2711,6 +2628,13 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/type-fest": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz",
|
||||
@ -12907,34 +12831,6 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-2.1.1.tgz",
|
||||
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
@ -12950,18 +12846,21 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ajv-keywords": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmmirror.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
|
||||
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
|
||||
"node_modules/ajv-formats": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "^6.9.1"
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/align-text": {
|
||||
@ -18043,21 +17942,22 @@
|
||||
"@types/json-schema": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-webpack-plugin/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"node_modules/eslint-webpack-plugin/node_modules/ajv-formats": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-2.1.1.tgz",
|
||||
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-webpack-plugin/node_modules/ajv-keywords": {
|
||||
@ -18099,13 +17999,6 @@
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-webpack-plugin/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-webpack-plugin/node_modules/schema-utils": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/schema-utils/-/schema-utils-4.3.2.tgz",
|
||||
@ -18142,6 +18035,23 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
@ -18267,6 +18177,13 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/eslint/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz",
|
||||
@ -24096,9 +24013,10 @@
|
||||
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stable-stringify-without-jsonify": {
|
||||
"version": "1.0.1",
|
||||
@ -29366,6 +29284,33 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dev-utils/node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dev-utils/node_modules/ajv-keywords": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmmirror.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
|
||||
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"ajv": "^6.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dev-utils/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
@ -29503,6 +29448,13 @@
|
||||
"integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/react-dev-utils/node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-dev-utils/node_modules/loader-utils": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/loader-utils/-/loader-utils-3.2.1.tgz",
|
||||
@ -31946,6 +31898,37 @@
|
||||
"node": ">= 10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/schema-utils/node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/schema-utils/node_modules/ajv-keywords": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmmirror.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
|
||||
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"ajv": "^6.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/schema-utils/node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/screenfull": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/screenfull/-/screenfull-5.2.0.tgz",
|
||||
@ -34108,24 +34091,6 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/table/node_modules/ajv": {
|
||||
"version": "8.12.0",
|
||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz",
|
||||
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2",
|
||||
"uri-js": "^4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/table/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-2.5.4.tgz",
|
||||
@ -34373,20 +34338,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/terser-webpack-plugin/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"node_modules/terser-webpack-plugin/node_modules/ajv-formats": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-2.1.1.tgz",
|
||||
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/terser-webpack-plugin/node_modules/ajv-keywords": {
|
||||
@ -34422,12 +34388,6 @@
|
||||
"node": ">= 10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/terser-webpack-plugin/node_modules/schema-utils": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/schema-utils/-/schema-utils-4.3.0.tgz",
|
||||
@ -35592,6 +35552,7 @@
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz",
|
||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
@ -35600,6 +35561,7 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@ -36550,21 +36512,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-middleware/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"node_modules/webpack-dev-middleware/node_modules/ajv-formats": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-2.1.1.tgz",
|
||||
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-middleware/node_modules/ajv-keywords": {
|
||||
@ -36580,13 +36543,6 @@
|
||||
"ajv": "^8.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-middleware/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webpack-dev-middleware/node_modules/schema-utils": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/schema-utils/-/schema-utils-4.3.2.tgz",
|
||||
|
||||
@ -61,6 +61,8 @@
|
||||
"@uiw/react-markdown-preview": "^5.1.3",
|
||||
"@xyflow/react": "^12.3.6",
|
||||
"ahooks": "^3.7.10",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"antd": "^5.12.7",
|
||||
"axios": "^1.12.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
|
||||
@ -0,0 +1,240 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { CirclePlus, HelpCircle, Info } from 'lucide-react';
|
||||
import { useId, useState, type FC, type FormEvent } from 'react';
|
||||
import { useTranslation } from '../../hooks/use-translation';
|
||||
import type { NewField, SchemaType } from '../../types/jsonSchema';
|
||||
import SchemaTypeSelector from './SchemaTypeSelector';
|
||||
|
||||
interface AddFieldButtonProps {
|
||||
onAddField: (field: NewField) => void;
|
||||
variant?: 'primary' | 'secondary';
|
||||
}
|
||||
|
||||
const AddFieldButton: FC<AddFieldButtonProps> = ({
|
||||
onAddField,
|
||||
variant = 'primary',
|
||||
}) => {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [fieldName, setFieldName] = useState('');
|
||||
const [fieldType, setFieldType] = useState<SchemaType>('string');
|
||||
const [fieldDesc, setFieldDesc] = useState('');
|
||||
const [fieldRequired, setFieldRequired] = useState(false);
|
||||
const fieldNameId = useId();
|
||||
const fieldDescId = useId();
|
||||
const fieldRequiredId = useId();
|
||||
const fieldTypeId = useId();
|
||||
|
||||
const t = useTranslation();
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!fieldName.trim()) return;
|
||||
|
||||
onAddField({
|
||||
name: fieldName,
|
||||
type: fieldType,
|
||||
description: fieldDesc,
|
||||
required: fieldRequired,
|
||||
});
|
||||
|
||||
setFieldName('');
|
||||
setFieldType('string');
|
||||
setFieldDesc('');
|
||||
setFieldRequired(false);
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
variant={variant === 'primary' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="flex items-center gap-1.5 group"
|
||||
>
|
||||
<CirclePlus
|
||||
size={16}
|
||||
className="group-hover:scale-110 transition-transform"
|
||||
/>
|
||||
<span>{t.fieldAddNewButton}</span>
|
||||
</Button>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="md:max-w-[1200px] max-h-[85vh] w-[95vw] p-4 sm:p-6 jsonjoy">
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle className="text-xl flex flex-wrap items-center gap-2">
|
||||
{t.fieldAddNewLabel}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t.fieldAddNewBadge}
|
||||
</Badge>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
{t.fieldAddNewDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="space-y-4 min-w-[280px]">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-1.5">
|
||||
<label
|
||||
htmlFor={fieldNameId}
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t.fieldNameLabel}
|
||||
</label>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[90vw]">
|
||||
<p>{t.fieldNameTooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Input
|
||||
id={fieldNameId}
|
||||
value={fieldName}
|
||||
onChange={(e) => setFieldName(e.target.value)}
|
||||
placeholder={t.fieldNamePlaceholder}
|
||||
className="font-mono text-sm w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-1.5">
|
||||
<label
|
||||
htmlFor={fieldDescId}
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t.fieldDescription}
|
||||
</label>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[90vw]">
|
||||
<p>{t.fieldDescriptionTooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Input
|
||||
id={fieldDescId}
|
||||
value={fieldDesc}
|
||||
onChange={(e) => setFieldDesc(e.target.value)}
|
||||
placeholder={t.fieldDescriptionPlaceholder}
|
||||
className="text-sm w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border bg-muted/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={fieldRequiredId}
|
||||
checked={fieldRequired}
|
||||
onChange={(e) => setFieldRequired(e.target.checked)}
|
||||
className="rounded border-gray-300 shrink-0"
|
||||
/>
|
||||
<label htmlFor={fieldRequiredId} className="text-sm">
|
||||
{t.fieldRequiredLabel}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 min-w-[280px]">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-1.5">
|
||||
<label
|
||||
htmlFor={fieldTypeId}
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t.fieldType}
|
||||
</label>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
className="w-72 max-w-[90vw]"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||
<div>• {t.fieldTypeTooltipString}</div>
|
||||
<div>• {t.fieldTypeTooltipNumber}</div>
|
||||
<div>• {t.fieldTypeTooltipBoolean}</div>
|
||||
<div>• {t.fieldTypeTooltipObject}</div>
|
||||
<div className="col-span-2">
|
||||
• {t.fieldTypeTooltipArray}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<SchemaTypeSelector
|
||||
id={fieldTypeId}
|
||||
value={fieldType}
|
||||
onChange={setFieldType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/50 p-3 hidden md:block">
|
||||
<p className="text-xs font-medium mb-2">
|
||||
{t.fieldTypeExample}
|
||||
</p>
|
||||
<code className="text-sm bg-background/80 p-2 rounded block overflow-x-auto">
|
||||
{fieldType === 'string' && '"example"'}
|
||||
{fieldType === 'number' && '42'}
|
||||
{fieldType === 'boolean' && 'true'}
|
||||
{fieldType === 'object' && '{ "key": "value" }'}
|
||||
{fieldType === 'array' && '["item1", "item2"]'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6 gap-2 flex-wrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
{t.fieldAddNewCancel}
|
||||
</Button>
|
||||
<Button type="submit" size="sm">
|
||||
{t.fieldAddNewConfirm}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddFieldButton;
|
||||
@ -0,0 +1,181 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Maximize2 } from 'lucide-react';
|
||||
import {
|
||||
useRef,
|
||||
useState,
|
||||
type FC,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import { useTranslation } from '../../hooks/use-translation';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { JSONSchema } from '../../types/jsonSchema';
|
||||
import JsonSchemaVisualizer from './JsonSchemaVisualizer';
|
||||
import SchemaVisualEditor from './SchemaVisualEditor';
|
||||
|
||||
/** @public */
|
||||
export interface JsonSchemaEditorProps {
|
||||
schema?: JSONSchema;
|
||||
setSchema?: (schema: JSONSchema) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
const JsonSchemaEditor: FC<JsonSchemaEditorProps> = ({
|
||||
schema = { type: 'object' },
|
||||
setSchema,
|
||||
className,
|
||||
}) => {
|
||||
// Handle schema changes and propagate to parent if needed
|
||||
const handleSchemaChange = (newSchema: JSONSchema) => {
|
||||
setSchema(newSchema);
|
||||
};
|
||||
|
||||
const t = useTranslation();
|
||||
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [leftPanelWidth, setLeftPanelWidth] = useState(50); // percentage
|
||||
const resizeRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
setIsFullscreen(!isFullscreen);
|
||||
};
|
||||
|
||||
const fullscreenClass = isFullscreen
|
||||
? 'fixed inset-0 z-50 bg-background'
|
||||
: '';
|
||||
|
||||
const handleMouseDown = (e: ReactMouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDraggingRef.current || !containerRef.current) return;
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const newWidth =
|
||||
((e.clientX - containerRect.left) / containerRect.width) * 100;
|
||||
|
||||
// Limit the minimum and maximum width
|
||||
if (newWidth >= 20 && newWidth <= 80) {
|
||||
setLeftPanelWidth(newWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDraggingRef.current = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'json-editor-container w-full',
|
||||
fullscreenClass,
|
||||
className,
|
||||
'jsonjoy',
|
||||
)}
|
||||
>
|
||||
{/* For mobile screens - show as tabs */}
|
||||
<div className="block lg:hidden w-full">
|
||||
<Tabs defaultValue="visual" className="w-full">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b w-full">
|
||||
<h3 className="font-medium">{t.schemaEditorTitle}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFullscreen}
|
||||
className="p-1.5 rounded-md hover:bg-secondary transition-colors"
|
||||
aria-label="Toggle fullscreen"
|
||||
>
|
||||
<Maximize2 size={16} />
|
||||
</button>
|
||||
<TabsList className="grid grid-cols-2 w-[200px]">
|
||||
<TabsTrigger value="visual">
|
||||
{t.schemaEditorEditModeVisual}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="json">
|
||||
{t.schemaEditorEditModeJson}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent
|
||||
value="visual"
|
||||
className={cn(
|
||||
'focus:outline-hidden w-full',
|
||||
isFullscreen ? 'h-screen' : 'h-[500px]',
|
||||
)}
|
||||
>
|
||||
<SchemaVisualEditor schema={schema} onChange={handleSchemaChange} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="json"
|
||||
className={cn(
|
||||
'focus:outline-hidden w-full',
|
||||
isFullscreen ? 'h-screen' : 'h-[500px]',
|
||||
)}
|
||||
>
|
||||
<JsonSchemaVisualizer
|
||||
schema={schema}
|
||||
onChange={handleSchemaChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* For large screens - show side by side */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'hidden lg:flex lg:flex-col w-full',
|
||||
isFullscreen ? 'h-screen' : 'h-[600px]',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b w-full shrink-0">
|
||||
<h3 className="font-medium">{t.schemaEditorTitle}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFullscreen}
|
||||
className="p-1.5 rounded-md hover:bg-secondary transition-colors"
|
||||
aria-label={t.schemaEditorToggleFullscreen}
|
||||
>
|
||||
<Maximize2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-row w-full grow min-h-0">
|
||||
<div
|
||||
className="h-full min-h-0"
|
||||
style={{ width: `${leftPanelWidth}%` }}
|
||||
>
|
||||
<SchemaVisualEditor schema={schema} onChange={handleSchemaChange} />
|
||||
</div>
|
||||
{/** biome-ignore lint/a11y/noStaticElementInteractions: What exactly does this div do? */}
|
||||
<div
|
||||
ref={resizeRef}
|
||||
className="w-1 bg-border hover:bg-primary cursor-col-resize shrink-0"
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
<div
|
||||
className="h-full min-h-0"
|
||||
style={{ width: `${100 - leftPanelWidth}%` }}
|
||||
>
|
||||
<JsonSchemaVisualizer
|
||||
schema={schema}
|
||||
onChange={handleSchemaChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JsonSchemaEditor;
|
||||
@ -0,0 +1,112 @@
|
||||
import Editor, { type BeforeMount, type OnMount } from '@monaco-editor/react';
|
||||
import { Download, FileJson, Loader2 } from 'lucide-react';
|
||||
import { useRef, type FC } from 'react';
|
||||
import { useMonacoTheme } from '../../hooks/use-monaco-theme';
|
||||
import { useTranslation } from '../../hooks/use-translation';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { JSONSchema } from '../../types/jsonSchema';
|
||||
|
||||
/** @public */
|
||||
export interface JsonSchemaVisualizerProps {
|
||||
schema: JSONSchema;
|
||||
className?: string;
|
||||
onChange?: (schema: JSONSchema) => void;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
const JsonSchemaVisualizer: FC<JsonSchemaVisualizerProps> = ({
|
||||
schema,
|
||||
className,
|
||||
onChange,
|
||||
}) => {
|
||||
const editorRef = useRef<Parameters<OnMount>[0] | null>(null);
|
||||
const {
|
||||
currentTheme,
|
||||
defineMonacoThemes,
|
||||
configureJsonDefaults,
|
||||
defaultEditorOptions,
|
||||
} = useMonacoTheme();
|
||||
|
||||
const t = useTranslation();
|
||||
|
||||
const handleBeforeMount: BeforeMount = (monaco) => {
|
||||
defineMonacoThemes(monaco);
|
||||
configureJsonDefaults(monaco);
|
||||
};
|
||||
|
||||
const handleEditorDidMount: OnMount = (editor) => {
|
||||
editorRef.current = editor;
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
const handleEditorChange = (value: string | undefined) => {
|
||||
if (!value) return;
|
||||
|
||||
try {
|
||||
const parsedJson = JSON.parse(value);
|
||||
if (onChange) {
|
||||
onChange(parsedJson);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Monaco will show the error inline, no need for additional error handling
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const content = JSON.stringify(schema, null, 2);
|
||||
const blob = new Blob([content], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = t.visualizerDownloadFileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden h-full flex flex-col',
|
||||
className,
|
||||
'jsonjoy',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between bg-secondary/80 backdrop-blur-xs px-4 py-2 border-b shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileJson size={18} />
|
||||
<span className="font-medium text-sm">{t.visualizerSource}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="p-1.5 hover:bg-secondary rounded-md transition-colors"
|
||||
title={t.visualizerDownloadTitle}
|
||||
>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grow flex min-h-0">
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="json"
|
||||
value={JSON.stringify(schema, null, 2)}
|
||||
onChange={handleEditorChange}
|
||||
beforeMount={handleBeforeMount}
|
||||
onMount={handleEditorDidMount}
|
||||
className="monaco-editor-container w-full h-full"
|
||||
loading={
|
||||
<div className="flex items-center justify-center h-full w-full bg-secondary/30">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
}
|
||||
options={defaultEditorOptions}
|
||||
theme={currentTheme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JsonSchemaVisualizer;
|
||||
@ -0,0 +1,168 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { useTranslation } from '../../hooks/use-translation';
|
||||
import type {
|
||||
JSONSchema as JSONSchemaType,
|
||||
NewField,
|
||||
ObjectJSONSchema,
|
||||
SchemaType,
|
||||
} from '../../types/jsonSchema';
|
||||
import {
|
||||
asObjectSchema,
|
||||
getSchemaDescription,
|
||||
withObjectSchema,
|
||||
} from '../../types/jsonSchema';
|
||||
import SchemaPropertyEditor from './SchemaPropertyEditor';
|
||||
|
||||
// This component is now just a simple wrapper around SchemaPropertyEditor
|
||||
// to maintain backward compatibility during migration
|
||||
interface SchemaFieldProps {
|
||||
name: string;
|
||||
schema: JSONSchemaType;
|
||||
required?: boolean;
|
||||
onDelete: () => void;
|
||||
onEdit: (updatedField: NewField) => void;
|
||||
onAddField?: (newField: NewField) => void;
|
||||
isNested?: boolean;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
const SchemaField: React.FC<SchemaFieldProps> = (props) => {
|
||||
const { name, schema, required = false, onDelete, onEdit, depth = 0 } = props;
|
||||
|
||||
// Handle name change
|
||||
const handleNameChange = (newName: string) => {
|
||||
if (newName === name) return;
|
||||
|
||||
// Get type in a safe way
|
||||
const type = withObjectSchema(
|
||||
schema,
|
||||
(s) => s.type || 'object',
|
||||
'object',
|
||||
) as SchemaType;
|
||||
|
||||
// Get description in a safe way
|
||||
const description = getSchemaDescription(schema);
|
||||
|
||||
onEdit({
|
||||
name: newName,
|
||||
type: Array.isArray(type) ? type[0] : type,
|
||||
description,
|
||||
required,
|
||||
validation: asObjectSchema(schema),
|
||||
});
|
||||
};
|
||||
|
||||
// Handle required status change
|
||||
const handleRequiredChange = (isRequired: boolean) => {
|
||||
if (isRequired === required) return;
|
||||
|
||||
// Get type in a safe way
|
||||
const type = withObjectSchema(
|
||||
schema,
|
||||
(s) => s.type || 'object',
|
||||
'object',
|
||||
) as SchemaType;
|
||||
|
||||
// Get description in a safe way
|
||||
const description = getSchemaDescription(schema);
|
||||
|
||||
onEdit({
|
||||
name,
|
||||
type: Array.isArray(type) ? type[0] : type,
|
||||
description,
|
||||
required: isRequired,
|
||||
validation: asObjectSchema(schema),
|
||||
});
|
||||
};
|
||||
|
||||
// Handle schema change
|
||||
const handleSchemaChange = (newSchema: ObjectJSONSchema) => {
|
||||
// Type will be defined in the schema
|
||||
const type = newSchema.type || 'object';
|
||||
|
||||
// Description will be defined in the schema
|
||||
const description = newSchema.description || '';
|
||||
|
||||
onEdit({
|
||||
name,
|
||||
type: Array.isArray(type) ? type[0] : type,
|
||||
description,
|
||||
required,
|
||||
validation: newSchema,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SchemaPropertyEditor
|
||||
name={name}
|
||||
schema={schema}
|
||||
required={required}
|
||||
onDelete={onDelete}
|
||||
onNameChange={handleNameChange}
|
||||
onRequiredChange={handleRequiredChange}
|
||||
onSchemaChange={handleSchemaChange}
|
||||
depth={depth}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchemaField;
|
||||
|
||||
// ExpandButton - extract for reuse
|
||||
export interface ExpandButtonProps {
|
||||
expanded: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const ExpandButton: React.FC<ExpandButtonProps> = ({
|
||||
expanded,
|
||||
onClick,
|
||||
}) => {
|
||||
const t = useTranslation();
|
||||
const ChevronDown = React.lazy(() =>
|
||||
import('lucide-react').then((mod) => ({ default: mod.ChevronDown })),
|
||||
);
|
||||
const ChevronRight = React.lazy(() =>
|
||||
import('lucide-react').then((mod) => ({ default: mod.ChevronRight })),
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={onClick}
|
||||
aria-label={expanded ? t.collapse : t.expand}
|
||||
>
|
||||
<Suspense fallback={<div className="w-[18px] h-[18px]" />}>
|
||||
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
</Suspense>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// FieldActions - extract for reuse
|
||||
export interface FieldActionsProps {
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const FieldActions: React.FC<FieldActionsProps> = ({ onDelete }) => {
|
||||
const t = useTranslation();
|
||||
const X = React.lazy(() =>
|
||||
import('lucide-react').then((mod) => ({ default: mod.X })),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="p-1 rounded-md hover:bg-secondary hover:text-destructive transition-colors opacity-0 group-hover:opacity-100"
|
||||
aria-label={t.fieldDelete}
|
||||
>
|
||||
<Suspense fallback={<div className="w-[16px] h-[16px]" />}>
|
||||
<X size={16} />
|
||||
</Suspense>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,130 @@
|
||||
import { useMemo, type FC } from 'react';
|
||||
import { useTranslation } from '../../hooks/use-translation';
|
||||
import { getSchemaProperties } from '../../lib/schemaEditor';
|
||||
import type {
|
||||
JSONSchema as JSONSchemaType,
|
||||
NewField,
|
||||
ObjectJSONSchema,
|
||||
SchemaType,
|
||||
} from '../../types/jsonSchema';
|
||||
import { buildValidationTree } from '../../types/validation';
|
||||
import SchemaPropertyEditor from './SchemaPropertyEditor';
|
||||
|
||||
interface SchemaFieldListProps {
|
||||
schema: JSONSchemaType;
|
||||
onAddField: (newField: NewField) => void;
|
||||
onEditField: (name: string, updatedField: NewField) => void;
|
||||
onDeleteField: (name: string) => void;
|
||||
}
|
||||
|
||||
const SchemaFieldList: FC<SchemaFieldListProps> = ({
|
||||
schema,
|
||||
onEditField,
|
||||
onDeleteField,
|
||||
}) => {
|
||||
const t = useTranslation();
|
||||
|
||||
// Get the properties from the schema
|
||||
const properties = getSchemaProperties(schema);
|
||||
|
||||
// Get schema type as a valid SchemaType
|
||||
const getValidSchemaType = (propSchema: JSONSchemaType): SchemaType => {
|
||||
if (typeof propSchema === 'boolean') return 'object';
|
||||
|
||||
// Handle array of types by picking the first one
|
||||
const type = propSchema.type;
|
||||
if (Array.isArray(type)) {
|
||||
return type[0] || 'object';
|
||||
}
|
||||
|
||||
return type || 'object';
|
||||
};
|
||||
|
||||
// Handle field name change (generates an edit event)
|
||||
const handleNameChange = (oldName: string, newName: string) => {
|
||||
const property = properties.find((prop) => prop.name === oldName);
|
||||
if (!property) return;
|
||||
|
||||
onEditField(oldName, {
|
||||
name: newName,
|
||||
type: getValidSchemaType(property.schema),
|
||||
description:
|
||||
typeof property.schema === 'boolean'
|
||||
? ''
|
||||
: property.schema.description || '',
|
||||
required: property.required,
|
||||
validation:
|
||||
typeof property.schema === 'boolean'
|
||||
? { type: 'object' }
|
||||
: property.schema,
|
||||
});
|
||||
};
|
||||
|
||||
// Handle required status change
|
||||
const handleRequiredChange = (name: string, required: boolean) => {
|
||||
const property = properties.find((prop) => prop.name === name);
|
||||
if (!property) return;
|
||||
|
||||
onEditField(name, {
|
||||
name,
|
||||
type: getValidSchemaType(property.schema),
|
||||
description:
|
||||
typeof property.schema === 'boolean'
|
||||
? ''
|
||||
: property.schema.description || '',
|
||||
required,
|
||||
validation:
|
||||
typeof property.schema === 'boolean'
|
||||
? { type: 'object' }
|
||||
: property.schema,
|
||||
});
|
||||
};
|
||||
|
||||
// Handle schema change
|
||||
const handleSchemaChange = (
|
||||
name: string,
|
||||
updatedSchema: ObjectJSONSchema,
|
||||
) => {
|
||||
const property = properties.find((prop) => prop.name === name);
|
||||
if (!property) return;
|
||||
|
||||
const type = updatedSchema.type || 'object';
|
||||
// Ensure we're using a single type, not an array of types
|
||||
const validType = Array.isArray(type) ? type[0] || 'object' : type;
|
||||
|
||||
onEditField(name, {
|
||||
name,
|
||||
type: validType,
|
||||
description: updatedSchema.description || '',
|
||||
required: property.required,
|
||||
validation: updatedSchema,
|
||||
});
|
||||
};
|
||||
|
||||
const validationTree = useMemo(
|
||||
() => buildValidationTree(schema, t),
|
||||
[schema, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 animate-in">
|
||||
{properties.map((property) => (
|
||||
<SchemaPropertyEditor
|
||||
key={property.name}
|
||||
name={property.name}
|
||||
schema={property.schema}
|
||||
required={property.required}
|
||||
validationNode={validationTree.children[property.name] ?? undefined}
|
||||
onDelete={() => onDeleteField(property.name)}
|
||||
onNameChange={(newName) => handleNameChange(property.name, newName)}
|
||||
onRequiredChange={(required) =>
|
||||
handleRequiredChange(property.name, required)
|
||||
}
|
||||
onSchemaChange={(schema) => handleSchemaChange(property.name, schema)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchemaFieldList;
|
||||
@ -0,0 +1,237 @@
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ChevronDown, ChevronRight, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from '../../hooks/use-translation';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type {
|
||||
JSONSchema,
|
||||
ObjectJSONSchema,
|
||||
SchemaType,
|
||||
} from '../../types/jsonSchema';
|
||||
import {
|
||||
asObjectSchema,
|
||||
getSchemaDescription,
|
||||
withObjectSchema,
|
||||
} from '../../types/jsonSchema';
|
||||
import type { ValidationTreeNode } from '../../types/validation';
|
||||
import { Badge } from '../ui/badge';
|
||||
import TypeDropdown from './TypeDropdown';
|
||||
import TypeEditor from './TypeEditor';
|
||||
|
||||
export interface SchemaPropertyEditorProps {
|
||||
name: string;
|
||||
schema: JSONSchema;
|
||||
required: boolean;
|
||||
validationNode?: ValidationTreeNode;
|
||||
onDelete: () => void;
|
||||
onNameChange: (newName: string) => void;
|
||||
onRequiredChange: (required: boolean) => void;
|
||||
onSchemaChange: (schema: ObjectJSONSchema) => void;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export const SchemaPropertyEditor: React.FC<SchemaPropertyEditorProps> = ({
|
||||
name,
|
||||
schema,
|
||||
required,
|
||||
validationNode,
|
||||
onDelete,
|
||||
onNameChange,
|
||||
onRequiredChange,
|
||||
onSchemaChange,
|
||||
depth = 0,
|
||||
}) => {
|
||||
const t = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [isEditingDesc, setIsEditingDesc] = useState(false);
|
||||
const [tempName, setTempName] = useState(name);
|
||||
const [tempDesc, setTempDesc] = useState(getSchemaDescription(schema));
|
||||
const type = withObjectSchema(
|
||||
schema,
|
||||
(s) => (s.type || 'object') as SchemaType,
|
||||
'object' as SchemaType,
|
||||
);
|
||||
|
||||
// Update temp values when props change
|
||||
useEffect(() => {
|
||||
setTempName(name);
|
||||
setTempDesc(getSchemaDescription(schema));
|
||||
}, [name, schema]);
|
||||
|
||||
const handleNameSubmit = () => {
|
||||
const trimmedName = tempName.trim();
|
||||
if (trimmedName && trimmedName !== name) {
|
||||
onNameChange(trimmedName);
|
||||
} else {
|
||||
setTempName(name);
|
||||
}
|
||||
setIsEditingName(false);
|
||||
};
|
||||
|
||||
const handleDescSubmit = () => {
|
||||
const trimmedDesc = tempDesc.trim();
|
||||
if (trimmedDesc !== getSchemaDescription(schema)) {
|
||||
onSchemaChange({
|
||||
...asObjectSchema(schema),
|
||||
description: trimmedDesc || undefined,
|
||||
});
|
||||
} else {
|
||||
setTempDesc(getSchemaDescription(schema));
|
||||
}
|
||||
setIsEditingDesc(false);
|
||||
};
|
||||
|
||||
// Handle schema changes, preserving description
|
||||
const handleSchemaUpdate = (updatedSchema: ObjectJSONSchema) => {
|
||||
const description = getSchemaDescription(schema);
|
||||
onSchemaChange({
|
||||
...updatedSchema,
|
||||
description: description || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mb-2 animate-in rounded-lg border transition-all duration-200',
|
||||
depth > 0 && 'ml-0 sm:ml-4 border-l border-l-border/40',
|
||||
)}
|
||||
>
|
||||
<div className="relative json-field-row justify-between group">
|
||||
<div className="flex items-center gap-2 grow min-w-0">
|
||||
{/* Expand/collapse button */}
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
aria-label={expanded ? t.collapse : t.expand}
|
||||
>
|
||||
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
</button>
|
||||
|
||||
{/* Property name */}
|
||||
<div className="flex items-center gap-2 grow min-w-0 overflow-visible">
|
||||
<div className="flex items-center gap-2 min-w-0 grow overflow-visible">
|
||||
{isEditingName ? (
|
||||
<Input
|
||||
value={tempName}
|
||||
onChange={(e) => setTempName(e.target.value)}
|
||||
onBlur={handleNameSubmit}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleNameSubmit()}
|
||||
className="h-8 text-sm font-medium min-w-[120px] max-w-full z-10"
|
||||
autoFocus
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditingName(true)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && setIsEditingName(true)}
|
||||
className="json-field-label font-medium cursor-text px-2 py-0.5 -mx-0.5 rounded-sm hover:bg-secondary/30 hover:shadow-xs hover:ring-1 hover:ring-ring/20 transition-all text-left truncate min-w-[80px] max-w-[50%]"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{isEditingDesc ? (
|
||||
<Input
|
||||
value={tempDesc}
|
||||
onChange={(e) => setTempDesc(e.target.value)}
|
||||
onBlur={handleDescSubmit}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleDescSubmit()}
|
||||
placeholder={t.propertyDescriptionPlaceholder}
|
||||
className="h-8 text-xs text-muted-foreground italic flex-1 min-w-[150px] z-10"
|
||||
autoFocus
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
) : tempDesc ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditingDesc(true)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && setIsEditingDesc(true)}
|
||||
className="text-xs text-muted-foreground italic cursor-text px-2 py-0.5 -mx-0.5 rounded-sm hover:bg-secondary/30 hover:shadow-xs hover:ring-1 hover:ring-ring/20 transition-all text-left truncate flex-1 max-w-[40%] mr-2"
|
||||
>
|
||||
{tempDesc}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditingDesc(true)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && setIsEditingDesc(true)}
|
||||
className="text-xs text-muted-foreground/50 italic cursor-text px-2 py-0.5 -mx-0.5 rounded-sm hover:bg-secondary/30 hover:shadow-xs hover:ring-1 hover:ring-ring/20 transition-all opacity-0 group-hover:opacity-100 text-left truncate flex-1 max-w-[40%] mr-2"
|
||||
>
|
||||
{t.propertyDescriptionButton}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type display */}
|
||||
<div className="flex items-center gap-2 justify-end shrink-0">
|
||||
<TypeDropdown
|
||||
value={type}
|
||||
onChange={(newType) => {
|
||||
onSchemaChange({
|
||||
...asObjectSchema(schema),
|
||||
type: newType,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Required toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRequiredChange(!required)}
|
||||
className={cn(
|
||||
'text-xs px-2 py-1 rounded-md font-medium min-w-[80px] text-center cursor-pointer hover:shadow-xs hover:ring-2 hover:ring-ring/30 active:scale-95 transition-all whitespace-nowrap',
|
||||
required
|
||||
? 'bg-red-50 text-red-500'
|
||||
: 'bg-secondary text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{required ? t.propertyRequired : t.propertyOptional}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error badge */}
|
||||
{validationNode?.cumulativeChildrenErrors > 0 && (
|
||||
<Badge
|
||||
className="h-5 min-w-5 rounded-full px-1 font-mono tabular-nums justify-center"
|
||||
variant="destructive"
|
||||
>
|
||||
{validationNode.cumulativeChildrenErrors}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Delete button */}
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="p-1 rounded-md hover:bg-secondary hover:text-destructive transition-colors opacity-0 group-hover:opacity-100"
|
||||
aria-label={t.propertyDelete}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type-specific editor */}
|
||||
{expanded && (
|
||||
<div className="pt-1 pb-2 px-2 sm:px-3 animate-in">
|
||||
<TypeEditor
|
||||
schema={schema}
|
||||
validationNode={validationNode}
|
||||
onChange={handleSchemaUpdate}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchemaPropertyEditor;
|
||||
@ -0,0 +1,81 @@
|
||||
import type { FC } from 'react';
|
||||
import { useTranslation } from '../../hooks/use-translation';
|
||||
import type { Translation } from '../../i18n/translation-keys';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { SchemaType } from '../../types/jsonSchema';
|
||||
|
||||
interface SchemaTypeSelectorProps {
|
||||
id?: string;
|
||||
value: SchemaType;
|
||||
onChange: (value: SchemaType) => void;
|
||||
}
|
||||
|
||||
interface TypeOption {
|
||||
id: SchemaType;
|
||||
label: keyof Translation;
|
||||
description: keyof Translation;
|
||||
}
|
||||
|
||||
const typeOptions: TypeOption[] = [
|
||||
{
|
||||
id: 'string',
|
||||
label: 'fieldTypeTextLabel',
|
||||
description: 'fieldTypeTextDescription',
|
||||
},
|
||||
{
|
||||
id: 'number',
|
||||
label: 'fieldTypeNumberLabel',
|
||||
description: 'fieldTypeNumberDescription',
|
||||
},
|
||||
{
|
||||
id: 'boolean',
|
||||
label: 'fieldTypeBooleanLabel',
|
||||
description: 'fieldTypeBooleanDescription',
|
||||
},
|
||||
{
|
||||
id: 'object',
|
||||
label: 'fieldTypeObjectLabel',
|
||||
description: 'fieldTypeObjectDescription',
|
||||
},
|
||||
{
|
||||
id: 'array',
|
||||
label: 'fieldTypeArrayLabel',
|
||||
description: 'fieldTypeArrayDescription',
|
||||
},
|
||||
];
|
||||
|
||||
const SchemaTypeSelector: FC<SchemaTypeSelectorProps> = ({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const t = useTranslation();
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-3 gap-2"
|
||||
>
|
||||
{typeOptions.map((type) => (
|
||||
<button
|
||||
type="button"
|
||||
key={type.id}
|
||||
title={t[type.description]}
|
||||
className={cn(
|
||||
'p-2.5 rounded-lg border-2 text-left transition-all duration-200',
|
||||
value === type.id
|
||||
? 'border-primary bg-primary/5 shadow-xs'
|
||||
: 'border-border hover:border-primary/30 hover:bg-secondary',
|
||||
)}
|
||||
onClick={() => onChange(type.id)}
|
||||
>
|
||||
<div className="font-medium text-sm">{t[type.label]}</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{t[type.description]}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchemaTypeSelector;
|
||||
@ -0,0 +1,146 @@
|
||||
import type { FC } from 'react';
|
||||
import { useTranslation } from '../../hooks/use-translation';
|
||||
import {
|
||||
createFieldSchema,
|
||||
updateObjectProperty,
|
||||
updatePropertyRequired,
|
||||
} from '../../lib/schemaEditor';
|
||||
import type { JSONSchema, NewField } from '../../types/jsonSchema';
|
||||
import { asObjectSchema, isBooleanSchema } from '../../types/jsonSchema';
|
||||
import AddFieldButton from './AddFieldButton';
|
||||
import SchemaFieldList from './SchemaFieldList';
|
||||
|
||||
/** @public */
|
||||
export interface SchemaVisualEditorProps {
|
||||
schema: JSONSchema;
|
||||
onChange: (schema: JSONSchema) => void;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
const SchemaVisualEditor: FC<SchemaVisualEditorProps> = ({
|
||||
schema,
|
||||
onChange,
|
||||
}) => {
|
||||
const t = useTranslation();
|
||||
// Handle adding a top-level field
|
||||
const handleAddField = (newField: NewField) => {
|
||||
// Create a field schema based on the new field data
|
||||
const fieldSchema = createFieldSchema(newField);
|
||||
|
||||
// Add the field to the schema
|
||||
let newSchema = updateObjectProperty(
|
||||
asObjectSchema(schema),
|
||||
newField.name,
|
||||
fieldSchema,
|
||||
);
|
||||
|
||||
// Update required status if needed
|
||||
if (newField.required) {
|
||||
newSchema = updatePropertyRequired(newSchema, newField.name, true);
|
||||
}
|
||||
|
||||
// Update the schema
|
||||
onChange(newSchema);
|
||||
};
|
||||
|
||||
// Handle editing a top-level field
|
||||
const handleEditField = (name: string, updatedField: NewField) => {
|
||||
// Create a field schema based on the updated field data
|
||||
const fieldSchema = createFieldSchema(updatedField);
|
||||
|
||||
// Update the field in the schema
|
||||
let newSchema = updateObjectProperty(
|
||||
asObjectSchema(schema),
|
||||
updatedField.name,
|
||||
fieldSchema,
|
||||
);
|
||||
|
||||
// Update required status
|
||||
newSchema = updatePropertyRequired(
|
||||
newSchema,
|
||||
updatedField.name,
|
||||
updatedField.required || false,
|
||||
);
|
||||
|
||||
// If name changed, we need to remove the old field
|
||||
if (name !== updatedField.name) {
|
||||
const { properties, ...rest } = newSchema;
|
||||
const { [name]: _, ...remainingProps } = properties || {};
|
||||
|
||||
newSchema = {
|
||||
...rest,
|
||||
properties: remainingProps,
|
||||
};
|
||||
|
||||
// Re-add the field with the new name
|
||||
newSchema = updateObjectProperty(
|
||||
newSchema,
|
||||
updatedField.name,
|
||||
fieldSchema,
|
||||
);
|
||||
|
||||
// Re-update required status if needed
|
||||
if (updatedField.required) {
|
||||
newSchema = updatePropertyRequired(newSchema, updatedField.name, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the schema
|
||||
onChange(newSchema);
|
||||
};
|
||||
|
||||
// Handle deleting a top-level field
|
||||
const handleDeleteField = (name: string) => {
|
||||
// Check if the schema is valid first
|
||||
if (isBooleanSchema(schema) || !schema.properties) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new schema without the field
|
||||
const { [name]: _, ...remainingProps } = schema.properties;
|
||||
|
||||
const newSchema = {
|
||||
...schema,
|
||||
properties: remainingProps,
|
||||
};
|
||||
|
||||
// Remove from required array if present
|
||||
if (newSchema.required) {
|
||||
newSchema.required = newSchema.required.filter((field) => field !== name);
|
||||
}
|
||||
|
||||
// Update the schema
|
||||
onChange(newSchema);
|
||||
};
|
||||
|
||||
const hasFields =
|
||||
!isBooleanSchema(schema) &&
|
||||
schema.properties &&
|
||||
Object.keys(schema.properties).length > 0;
|
||||
|
||||
return (
|
||||
<div className="p-4 h-full flex flex-col overflow-auto jsonjoy">
|
||||
<div className="mb-6 shrink-0">
|
||||
<AddFieldButton onAddField={handleAddField} />
|
||||
</div>
|
||||
|
||||
<div className="grow overflow-auto">
|
||||
{!hasFields ? (
|
||||
<div className="text-center py-10 text-muted-foreground">
|
||||
<p className="mb-3">{t.visualEditorNoFieldsHint1}</p>
|
||||
<p className="text-sm">{t.visualEditorNoFieldsHint2}</p>
|
||||
</div>
|
||||
) : (
|
||||
<SchemaFieldList
|
||||
schema={schema}
|
||||
onAddField={handleAddField}
|
||||
onEditField={handleEditField}
|
||||
onDeleteField={handleDeleteField}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchemaVisualEditor;
|
||||
@ -0,0 +1,94 @@
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from '../../hooks/use-translation';
|
||||
import { cn, getTypeColor, getTypeLabel } from '../../lib/utils';
|
||||
import type { SchemaType } from '../../types/jsonSchema';
|
||||
|
||||
export interface TypeDropdownProps {
|
||||
value: SchemaType;
|
||||
onChange: (value: SchemaType) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const typeOptions: SchemaType[] = [
|
||||
'string',
|
||||
'number',
|
||||
'boolean',
|
||||
'object',
|
||||
'array',
|
||||
'null',
|
||||
];
|
||||
|
||||
export const TypeDropdown: React.FC<TypeDropdownProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}) => {
|
||||
const t = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'text-xs px-3.5 py-1.5 rounded-md font-medium w-[92px] text-center flex items-center justify-between',
|
||||
getTypeColor(value),
|
||||
'hover:shadow-xs hover:ring-1 hover:ring-ring/30 active:scale-95 transition-all',
|
||||
className,
|
||||
)}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<span>{getTypeLabel(t, value)}</span>
|
||||
<ChevronDown size={14} className="ml-1" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-1 w-[140px] rounded-md border bg-popover shadow-lg animate-in fade-in-50 zoom-in-95">
|
||||
<div className="py-1">
|
||||
{typeOptions.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-1.5 text-xs flex items-center justify-between',
|
||||
'hover:bg-muted/50 transition-colors',
|
||||
value === type && 'font-medium',
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(type);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className={cn('px-2 py-0.5 rounded', getTypeColor(type))}>
|
||||
{getTypeLabel(t, type)}
|
||||
</span>
|
||||
{value === type && <Check size={14} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TypeDropdown;
|
||||
@ -0,0 +1,91 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { withObjectSchema } from '../../types/jsonSchema';
|
||||
import type {
|
||||
JSONSchema,
|
||||
ObjectJSONSchema,
|
||||
SchemaType,
|
||||
} from '../../types/jsonSchema.ts';
|
||||
import type { ValidationTreeNode } from '../../types/validation';
|
||||
|
||||
// Lazy load specific type editors to avoid circular dependencies
|
||||
const StringEditor = lazy(() => import('./types/StringEditor'));
|
||||
const NumberEditor = lazy(() => import('./types/NumberEditor'));
|
||||
const BooleanEditor = lazy(() => import('./types/BooleanEditor'));
|
||||
const ObjectEditor = lazy(() => import('./types/ObjectEditor'));
|
||||
const ArrayEditor = lazy(() => import('./types/ArrayEditor'));
|
||||
|
||||
export interface TypeEditorProps {
|
||||
schema: JSONSchema;
|
||||
validationNode: ValidationTreeNode | undefined;
|
||||
onChange: (schema: ObjectJSONSchema) => void;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
const TypeEditor: React.FC<TypeEditorProps> = ({
|
||||
schema,
|
||||
validationNode,
|
||||
onChange,
|
||||
depth = 0,
|
||||
}) => {
|
||||
const type = withObjectSchema(
|
||||
schema,
|
||||
(s) => (s.type || 'object') as SchemaType,
|
||||
'string' as SchemaType,
|
||||
);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<div>Loading editor...</div>}>
|
||||
{type === 'string' && (
|
||||
<StringEditor
|
||||
schema={schema}
|
||||
onChange={onChange}
|
||||
depth={depth}
|
||||
validationNode={validationNode}
|
||||
/>
|
||||
)}
|
||||
{type === 'number' && (
|
||||
<NumberEditor
|
||||
schema={schema}
|
||||
onChange={onChange}
|
||||
depth={depth}
|
||||
validationNode={validationNode}
|
||||
/>
|
||||
)}
|
||||
{type === 'integer' && (
|
||||
<NumberEditor
|
||||
schema={schema}
|
||||
onChange={onChange}
|
||||
depth={depth}
|
||||
validationNode={validationNode}
|
||||
integer
|
||||
/>
|
||||
)}
|
||||
{type === 'boolean' && (
|
||||
<BooleanEditor
|
||||
schema={schema}
|
||||
onChange={onChange}
|
||||
depth={depth}
|
||||
validationNode={validationNode}
|
||||
/>
|
||||
)}
|
||||
{type === 'object' && (
|
||||
<ObjectEditor
|
||||
schema={schema}
|
||||
onChange={onChange}
|
||||
depth={depth}
|
||||
validationNode={validationNode}
|
||||
/>
|
||||
)}
|
||||
{type === 'array' && (
|
||||
<ArrayEditor
|
||||
schema={schema}
|
||||
onChange={onChange}
|
||||
depth={depth}
|
||||
validationNode={validationNode}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default TypeEditor;
|
||||
@ -0,0 +1,204 @@
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
import { useTranslation } from '../../../hooks/use-translation';
|
||||
import { getArrayItemsSchema } from '../../../lib/schemaEditor';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { ObjectJSONSchema, SchemaType } from '../../../types/jsonSchema';
|
||||
import { isBooleanSchema, withObjectSchema } from '../../../types/jsonSchema';
|
||||
import TypeDropdown from '../TypeDropdown';
|
||||
import type { TypeEditorProps } from '../TypeEditor';
|
||||
import TypeEditor from '../TypeEditor';
|
||||
|
||||
const ArrayEditor: React.FC<TypeEditorProps> = ({
|
||||
schema,
|
||||
validationNode,
|
||||
onChange,
|
||||
depth = 0,
|
||||
}) => {
|
||||
const t = useTranslation();
|
||||
const [minItems, setMinItems] = useState<number | undefined>(
|
||||
withObjectSchema(schema, (s) => s.minItems, undefined),
|
||||
);
|
||||
const [maxItems, setMaxItems] = useState<number | undefined>(
|
||||
withObjectSchema(schema, (s) => s.maxItems, undefined),
|
||||
);
|
||||
const [uniqueItems, setUniqueItems] = useState<boolean>(
|
||||
withObjectSchema(schema, (s) => s.uniqueItems || false, false),
|
||||
);
|
||||
|
||||
const minItemsId = useId();
|
||||
const maxItemsId = useId();
|
||||
const uniqueItemsId = useId();
|
||||
|
||||
// Get the array's item schema
|
||||
const itemsSchema = getArrayItemsSchema(schema) || { type: 'string' };
|
||||
|
||||
// Get the type of the array items
|
||||
const itemType = withObjectSchema(
|
||||
itemsSchema,
|
||||
(s) => (s.type || 'string') as SchemaType,
|
||||
'string' as SchemaType,
|
||||
);
|
||||
|
||||
// Handle validation settings change
|
||||
const handleValidationChange = () => {
|
||||
const validationProps: ObjectJSONSchema = {
|
||||
type: 'array',
|
||||
...(isBooleanSchema(schema) ? {} : schema),
|
||||
minItems: minItems,
|
||||
maxItems: maxItems,
|
||||
uniqueItems: uniqueItems || undefined,
|
||||
};
|
||||
|
||||
// Keep the items schema
|
||||
if (validationProps.items === undefined && itemsSchema) {
|
||||
validationProps.items = itemsSchema;
|
||||
}
|
||||
|
||||
// Clean up undefined values
|
||||
const propsToKeep: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(validationProps)) {
|
||||
if (value !== undefined) {
|
||||
propsToKeep[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
onChange(propsToKeep as ObjectJSONSchema);
|
||||
};
|
||||
|
||||
// Handle item schema changes
|
||||
const handleItemSchemaChange = (updatedItemSchema: ObjectJSONSchema) => {
|
||||
const updatedSchema: ObjectJSONSchema = {
|
||||
type: 'array',
|
||||
...(isBooleanSchema(schema) ? {} : schema),
|
||||
items: updatedItemSchema,
|
||||
};
|
||||
|
||||
onChange(updatedSchema);
|
||||
};
|
||||
|
||||
const minMaxError = useMemo(
|
||||
() =>
|
||||
validationNode?.validation.errors?.find((err) => err.path[0] === 'minmax')
|
||||
?.message,
|
||||
[validationNode],
|
||||
);
|
||||
|
||||
const minItemsError = useMemo(
|
||||
() =>
|
||||
validationNode?.validation.errors?.find(
|
||||
(err) => err.path[0] === 'minItems',
|
||||
)?.message,
|
||||
[validationNode],
|
||||
);
|
||||
|
||||
const maxItemsError = useMemo(
|
||||
() =>
|
||||
validationNode?.validation.errors?.find(
|
||||
(err) => err.path[0] === 'maxItems',
|
||||
)?.message,
|
||||
[validationNode],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Array validation settings */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={minItemsId}
|
||||
className={(!!minMaxError || !!minItemsError) && 'text-destructive'}
|
||||
>
|
||||
{t.arrayMinimumLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id={minItemsId}
|
||||
type="number"
|
||||
min={0}
|
||||
value={minItems ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value ? Number(e.target.value) : undefined;
|
||||
setMinItems(value);
|
||||
// Don't update immediately to avoid too many rerenders
|
||||
}}
|
||||
onBlur={handleValidationChange}
|
||||
placeholder={t.arrayMinimumPlaceholder}
|
||||
className={cn('h-8', !!minMaxError && 'border-destructive')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={maxItemsId}
|
||||
className={(!!minMaxError || !!maxItemsError) && 'text-destructive'}
|
||||
>
|
||||
{t.arrayMaximumLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id={maxItemsId}
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxItems ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value ? Number(e.target.value) : undefined;
|
||||
setMaxItems(value);
|
||||
// Don't update immediately to avoid too many rerenders
|
||||
}}
|
||||
onBlur={handleValidationChange}
|
||||
placeholder={t.arrayMaximumPlaceholder}
|
||||
className={cn('h-8', !!minMaxError && 'border-destructive')}
|
||||
/>
|
||||
</div>
|
||||
{(!!minMaxError || !!minItemsError || !!maxItemsError) && (
|
||||
<div className="text-xs text-destructive italic md:col-span-2 whitespace-pre-line">
|
||||
{[minMaxError, minItemsError ?? maxItemsError]
|
||||
.filter(Boolean)
|
||||
.join('\n')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id={uniqueItemsId}
|
||||
checked={uniqueItems}
|
||||
onCheckedChange={(checked) => {
|
||||
setUniqueItems(checked);
|
||||
setTimeout(handleValidationChange, 0);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={uniqueItemsId} className="cursor-pointer">
|
||||
{t.arrayForceUniqueItemsLabel}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Array item type editor */}
|
||||
<div className="space-y-2 pt-4 border-t border-border/40">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Label>{t.arrayItemTypeLabel}</Label>
|
||||
<TypeDropdown
|
||||
value={itemType}
|
||||
onChange={(newType) => {
|
||||
handleItemSchemaChange({
|
||||
...withObjectSchema(itemsSchema, (s) => s, {}),
|
||||
type: newType,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Item schema editor */}
|
||||
<TypeEditor
|
||||
schema={itemsSchema}
|
||||
validationNode={validationNode}
|
||||
onChange={handleItemSchemaChange}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArrayEditor;
|
||||
@ -0,0 +1,115 @@
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from '../../../hooks/use-translation';
|
||||
import type { ObjectJSONSchema } from '../../../types/jsonSchema';
|
||||
import { withObjectSchema } from '../../../types/jsonSchema';
|
||||
import type { TypeEditorProps } from '../TypeEditor';
|
||||
|
||||
const BooleanEditor: React.FC<TypeEditorProps> = ({ schema, onChange }) => {
|
||||
const t = useTranslation();
|
||||
const allowTrueId = useId();
|
||||
const allowFalseId = useId();
|
||||
|
||||
// Extract boolean-specific validation
|
||||
const enumValues = withObjectSchema(
|
||||
schema,
|
||||
(s) => s.enum as boolean[] | undefined,
|
||||
null,
|
||||
);
|
||||
|
||||
// Determine if we have enum restrictions
|
||||
const hasRestrictions = Array.isArray(enumValues);
|
||||
const allowsTrue = !hasRestrictions || enumValues?.includes(true) || false;
|
||||
const allowsFalse = !hasRestrictions || enumValues?.includes(false) || false;
|
||||
|
||||
// Handle changing the allowed values
|
||||
const handleAllowedChange = (value: boolean, allowed: boolean) => {
|
||||
let newEnum: boolean[] | undefined;
|
||||
|
||||
if (allowed) {
|
||||
// If allowing this value
|
||||
if (!hasRestrictions) {
|
||||
// No current restrictions, nothing to do
|
||||
return;
|
||||
}
|
||||
|
||||
if (enumValues?.includes(value)) {
|
||||
// Already allowed, nothing to do
|
||||
return;
|
||||
}
|
||||
|
||||
// Add this value to enum
|
||||
newEnum = enumValues ? [...enumValues, value] : [value];
|
||||
|
||||
// If both are now allowed, we can remove the enum constraint
|
||||
if (newEnum.includes(true) && newEnum.includes(false)) {
|
||||
newEnum = undefined;
|
||||
}
|
||||
} else {
|
||||
// If disallowing this value
|
||||
if (hasRestrictions && !enumValues?.includes(value)) {
|
||||
// Already disallowed, nothing to do
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new enum with just the opposite value
|
||||
newEnum = [!value];
|
||||
}
|
||||
|
||||
// Create a new validation object with just the type and enum
|
||||
const updatedValidation: ObjectJSONSchema = {
|
||||
type: 'boolean',
|
||||
};
|
||||
|
||||
if (newEnum) {
|
||||
updatedValidation.enum = newEnum;
|
||||
} else {
|
||||
// Remove enum property if no restrictions
|
||||
onChange({ type: 'boolean' });
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(updatedValidation);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 pt-2">
|
||||
<Label>Allowed Values</Label>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id={allowTrueId}
|
||||
checked={allowsTrue}
|
||||
onCheckedChange={(checked) => handleAllowedChange(true, checked)}
|
||||
/>
|
||||
<Label htmlFor={allowTrueId} className="cursor-pointer">
|
||||
{t.booleanAllowTrueLabel}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id={allowFalseId}
|
||||
checked={allowsFalse}
|
||||
onCheckedChange={(checked) => handleAllowedChange(false, checked)}
|
||||
/>
|
||||
<Label htmlFor={allowFalseId} className="cursor-pointer">
|
||||
{t.booleanAllowFalseLabel}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!allowsTrue && !allowsFalse && (
|
||||
<p className="text-xs text-amber-600 mt-2">
|
||||
{t.booleanNeitherWarning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BooleanEditor;
|
||||
@ -0,0 +1,433 @@
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { X } from 'lucide-react';
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
import { useTranslation } from '../../../hooks/use-translation';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { ObjectJSONSchema } from '../../../types/jsonSchema';
|
||||
import { isBooleanSchema, withObjectSchema } from '../../../types/jsonSchema';
|
||||
import type { TypeEditorProps } from '../TypeEditor';
|
||||
|
||||
interface NumberEditorProps extends TypeEditorProps {
|
||||
integer?: boolean;
|
||||
}
|
||||
|
||||
type Property =
|
||||
| 'minimum'
|
||||
| 'maximum'
|
||||
| 'exclusiveMinimum'
|
||||
| 'exclusiveMaximum'
|
||||
| 'multipleOf'
|
||||
| 'enum';
|
||||
|
||||
const NumberEditor: React.FC<NumberEditorProps> = ({
|
||||
schema,
|
||||
validationNode,
|
||||
onChange,
|
||||
integer = false,
|
||||
}) => {
|
||||
const [enumValue, setEnumValue] = useState('');
|
||||
const t = useTranslation();
|
||||
|
||||
const maximumId = useId();
|
||||
const minimumId = useId();
|
||||
const exclusiveMinimumId = useId();
|
||||
const exclusiveMaximumId = useId();
|
||||
const multipleOfId = useId();
|
||||
|
||||
// Extract number-specific validations
|
||||
const minimum = withObjectSchema(schema, (s) => s.minimum, undefined);
|
||||
const maximum = withObjectSchema(schema, (s) => s.maximum, undefined);
|
||||
const exclusiveMinimum = withObjectSchema(
|
||||
schema,
|
||||
(s) => s.exclusiveMinimum,
|
||||
undefined,
|
||||
);
|
||||
const exclusiveMaximum = withObjectSchema(
|
||||
schema,
|
||||
(s) => s.exclusiveMaximum,
|
||||
undefined,
|
||||
);
|
||||
const multipleOf = withObjectSchema(schema, (s) => s.multipleOf, undefined);
|
||||
const enumValues = withObjectSchema(
|
||||
schema,
|
||||
(s) => (s.enum as number[]) || [],
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle validation change
|
||||
const handleValidationChange = (property: Property, value: unknown) => {
|
||||
// Create a safe base schema with necessary properties
|
||||
const baseProperties: Partial<ObjectJSONSchema> = {
|
||||
type: integer ? 'integer' : 'number',
|
||||
};
|
||||
|
||||
// Copy existing validation properties (except type and description) if schema is an object
|
||||
if (!isBooleanSchema(schema)) {
|
||||
if (schema.minimum !== undefined) baseProperties.minimum = schema.minimum;
|
||||
if (schema.maximum !== undefined) baseProperties.maximum = schema.maximum;
|
||||
if (schema.exclusiveMinimum !== undefined)
|
||||
baseProperties.exclusiveMinimum = schema.exclusiveMinimum;
|
||||
if (schema.exclusiveMaximum !== undefined)
|
||||
baseProperties.exclusiveMaximum = schema.exclusiveMaximum;
|
||||
if (schema.multipleOf !== undefined)
|
||||
baseProperties.multipleOf = schema.multipleOf;
|
||||
if (schema.enum !== undefined) baseProperties.enum = schema.enum;
|
||||
}
|
||||
|
||||
// Only add the property if the value is defined, otherwise remove it
|
||||
if (value !== undefined) {
|
||||
// Create updated object with modified property
|
||||
const updatedProperties: Partial<ObjectJSONSchema> = {
|
||||
...baseProperties,
|
||||
};
|
||||
|
||||
if (property === 'minimum') updatedProperties.minimum = value as number;
|
||||
else if (property === 'maximum')
|
||||
updatedProperties.maximum = value as number;
|
||||
else if (property === 'exclusiveMinimum')
|
||||
updatedProperties.exclusiveMinimum = value as number;
|
||||
else if (property === 'exclusiveMaximum')
|
||||
updatedProperties.exclusiveMaximum = value as number;
|
||||
else if (property === 'multipleOf')
|
||||
updatedProperties.multipleOf = value as number;
|
||||
else if (property === 'enum') updatedProperties.enum = value as unknown[];
|
||||
|
||||
onChange(updatedProperties as ObjectJSONSchema);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle removing a property (value is undefined)
|
||||
if (property === 'minimum') {
|
||||
const { minimum: _, ...rest } = baseProperties;
|
||||
onChange(rest as ObjectJSONSchema);
|
||||
return;
|
||||
}
|
||||
|
||||
if (property === 'maximum') {
|
||||
const { maximum: _, ...rest } = baseProperties;
|
||||
onChange(rest as ObjectJSONSchema);
|
||||
return;
|
||||
}
|
||||
|
||||
if (property === 'exclusiveMinimum') {
|
||||
const { exclusiveMinimum: _, ...rest } = baseProperties;
|
||||
onChange(rest as ObjectJSONSchema);
|
||||
return;
|
||||
}
|
||||
|
||||
if (property === 'exclusiveMaximum') {
|
||||
const { exclusiveMaximum: _, ...rest } = baseProperties;
|
||||
onChange(rest as ObjectJSONSchema);
|
||||
return;
|
||||
}
|
||||
|
||||
if (property === 'multipleOf') {
|
||||
const { multipleOf: _, ...rest } = baseProperties;
|
||||
onChange(rest as ObjectJSONSchema);
|
||||
return;
|
||||
}
|
||||
|
||||
if (property === 'enum') {
|
||||
const { enum: _, ...rest } = baseProperties;
|
||||
onChange(rest as ObjectJSONSchema);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback case - just use the base properties
|
||||
onChange(baseProperties as ObjectJSONSchema);
|
||||
};
|
||||
|
||||
// Handle adding enum value
|
||||
const handleAddEnumValue = () => {
|
||||
if (!enumValue.trim()) return;
|
||||
|
||||
const numValue = Number(enumValue);
|
||||
if (Number.isNaN(numValue)) return;
|
||||
|
||||
// For integer type, ensure the value is an integer
|
||||
const validValue = integer ? Math.floor(numValue) : numValue;
|
||||
|
||||
if (!enumValues.includes(validValue)) {
|
||||
handleValidationChange('enum', [...enumValues, validValue]);
|
||||
}
|
||||
|
||||
setEnumValue('');
|
||||
};
|
||||
|
||||
// Handle removing enum value
|
||||
const handleRemoveEnumValue = (index: number) => {
|
||||
const newEnumValues = [...enumValues];
|
||||
newEnumValues.splice(index, 1);
|
||||
|
||||
if (newEnumValues.length === 0) {
|
||||
// If empty, remove the enum property entirely by setting it to undefined
|
||||
handleValidationChange('enum', undefined);
|
||||
} else {
|
||||
handleValidationChange('enum', newEnumValues);
|
||||
}
|
||||
};
|
||||
|
||||
const minMaxError = useMemo(
|
||||
() =>
|
||||
validationNode?.validation.errors?.find((err) => err.path[0] === 'minMax')
|
||||
?.message,
|
||||
[validationNode],
|
||||
);
|
||||
|
||||
const redundantMinError = useMemo(
|
||||
() =>
|
||||
validationNode?.validation.errors?.find(
|
||||
(err) => err.path[0] === 'redundantMinimum',
|
||||
)?.message,
|
||||
[validationNode],
|
||||
);
|
||||
|
||||
const redundantMaxError = useMemo(
|
||||
() =>
|
||||
validationNode?.validation.errors?.find(
|
||||
(err) => err.path[0] === 'redundantMaximum',
|
||||
)?.message,
|
||||
[validationNode],
|
||||
);
|
||||
|
||||
const enumError = useMemo(
|
||||
() =>
|
||||
validationNode?.validation.errors?.find((err) => err.path[0] === 'enum')
|
||||
?.message,
|
||||
[validationNode],
|
||||
);
|
||||
|
||||
const multipleOfError = useMemo(
|
||||
() =>
|
||||
validationNode?.validation.errors?.find(
|
||||
(err) => err.path[0] === 'multipleOf',
|
||||
)?.message,
|
||||
[validationNode],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-0 md:col-span-2">
|
||||
{!!minMaxError && (
|
||||
<div className="text-xs text-destructive italic">{minMaxError}</div>
|
||||
)}
|
||||
{!!redundantMinError && (
|
||||
<div className="text-xs text-destructive italic">
|
||||
{redundantMinError}
|
||||
</div>
|
||||
)}
|
||||
{!!redundantMaxError && (
|
||||
<div className="text-xs text-destructive italic">
|
||||
{redundantMaxError}
|
||||
</div>
|
||||
)}
|
||||
{!!enumError && (
|
||||
<div className="text-xs text-destructive italic">{enumError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={minimumId}
|
||||
className={
|
||||
minimum !== undefined &&
|
||||
(!!minMaxError || !!redundantMinError) &&
|
||||
'text-destructive'
|
||||
}
|
||||
>
|
||||
{t.numberMinimumLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id={minimumId}
|
||||
type="number"
|
||||
value={minimum !== undefined ? minimum : ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value ? Number(e.target.value) : undefined;
|
||||
handleValidationChange('minimum', value);
|
||||
}}
|
||||
placeholder={t.numberMinimumPlaceholder}
|
||||
className={cn(
|
||||
'h-8',
|
||||
minimum !== undefined &&
|
||||
(!!minMaxError || !!redundantMinError) &&
|
||||
'border-destructive',
|
||||
)}
|
||||
step={integer ? 1 : 'any'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={maximumId}
|
||||
className={
|
||||
maximum !== undefined &&
|
||||
(!!minMaxError || !!redundantMaxError) &&
|
||||
'text-destructive'
|
||||
}
|
||||
>
|
||||
{t.numberMaximumLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id={maximumId}
|
||||
type="number"
|
||||
value={maximum ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value ? Number(e.target.value) : undefined;
|
||||
handleValidationChange('maximum', value);
|
||||
}}
|
||||
placeholder={t.numberMaximumPlaceholder}
|
||||
className={cn(
|
||||
'h-8',
|
||||
maximum !== undefined &&
|
||||
(!!minMaxError || !!redundantMaxError) &&
|
||||
'border-destructive',
|
||||
)}
|
||||
step={integer ? 1 : 'any'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={exclusiveMinimumId}
|
||||
className={
|
||||
exclusiveMinimum !== undefined &&
|
||||
(!!minMaxError || !!redundantMinError) &&
|
||||
'text-destructive'
|
||||
}
|
||||
>
|
||||
{t.numberExclusiveMinimumLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id={exclusiveMinimumId}
|
||||
type="number"
|
||||
value={exclusiveMinimum ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value ? Number(e.target.value) : undefined;
|
||||
handleValidationChange('exclusiveMinimum', value);
|
||||
}}
|
||||
placeholder={t.numberExclusiveMinimumPlaceholder}
|
||||
className={cn(
|
||||
'h-8',
|
||||
exclusiveMinimum !== undefined &&
|
||||
(!!minMaxError || !!redundantMinError) &&
|
||||
'border-destructive',
|
||||
)}
|
||||
step={integer ? 1 : 'any'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={exclusiveMaximumId}
|
||||
className={
|
||||
exclusiveMaximum !== undefined &&
|
||||
(!!minMaxError || !!redundantMaxError) &&
|
||||
'text-destructive'
|
||||
}
|
||||
>
|
||||
{t.numberExclusiveMaximumLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id={exclusiveMaximumId}
|
||||
type="number"
|
||||
value={exclusiveMaximum ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value ? Number(e.target.value) : undefined;
|
||||
handleValidationChange('exclusiveMaximum', value);
|
||||
}}
|
||||
placeholder={t.numberExclusiveMaximumPlaceholder}
|
||||
className={cn(
|
||||
'h-8',
|
||||
exclusiveMaximum !== undefined &&
|
||||
(!!minMaxError || !!redundantMaxError) &&
|
||||
'border-destructive',
|
||||
)}
|
||||
step={integer ? 1 : 'any'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={multipleOfId}
|
||||
className={!!multipleOfError && 'text-destructive'}
|
||||
>
|
||||
{t.numberMultipleOfLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id={multipleOfId}
|
||||
type="number"
|
||||
value={multipleOf ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value ? Number(e.target.value) : undefined;
|
||||
handleValidationChange('multipleOf', value);
|
||||
}}
|
||||
placeholder={t.numberMultipleOfPlaceholder}
|
||||
className={cn('h-8', !!multipleOfError && 'border-destructive')}
|
||||
min={0}
|
||||
step={integer ? 1 : 'any'}
|
||||
/>
|
||||
{!!multipleOfError && (
|
||||
<div className="text-xs text-destructive italic whitespace-pre-line">
|
||||
{multipleOfError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-border/40">
|
||||
<Label className={!!enumError && 'text-destructive'}>
|
||||
{t.numberAllowedValuesEnumLabel}
|
||||
</Label>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{enumValues.length > 0 ? (
|
||||
enumValues.map((value, index) => (
|
||||
<div
|
||||
key={`enum-number-${value}`}
|
||||
className="flex items-center bg-muted/40 border rounded-md px-2 py-1 text-xs"
|
||||
>
|
||||
<span className="mr-1">{value}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveEnumValue(index)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{t.numberAllowedValuesEnumNone}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={enumValue}
|
||||
onChange={(e) => setEnumValue(e.target.value)}
|
||||
placeholder={t.numberAllowedValuesEnumAddPlaceholder}
|
||||
className="h-8 text-xs flex-1"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddEnumValue()}
|
||||
step={integer ? 1 : 'any'}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddEnumValue}
|
||||
className="px-3 py-1 h-8 rounded-md bg-secondary text-xs font-medium hover:bg-secondary/80"
|
||||
>
|
||||
{t.numberAllowedValuesEnumAddLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NumberEditor;
|
||||
@ -0,0 +1,149 @@
|
||||
import { useTranslation } from '../../../hooks/use-translation';
|
||||
import {
|
||||
getSchemaProperties,
|
||||
removeObjectProperty,
|
||||
updateObjectProperty,
|
||||
updatePropertyRequired,
|
||||
} from '../../../lib/schemaEditor';
|
||||
import type { NewField, ObjectJSONSchema } from '../../../types/jsonSchema';
|
||||
import { asObjectSchema, isBooleanSchema } from '../../../types/jsonSchema';
|
||||
import AddFieldButton from '../AddFieldButton';
|
||||
import SchemaPropertyEditor from '../SchemaPropertyEditor';
|
||||
import type { TypeEditorProps } from '../TypeEditor';
|
||||
|
||||
const ObjectEditor: React.FC<TypeEditorProps> = ({
|
||||
schema,
|
||||
validationNode,
|
||||
onChange,
|
||||
depth = 0,
|
||||
}) => {
|
||||
const t = useTranslation();
|
||||
|
||||
// Get object properties
|
||||
const properties = getSchemaProperties(schema);
|
||||
|
||||
// Create a normalized schema object
|
||||
const normalizedSchema: ObjectJSONSchema = isBooleanSchema(schema)
|
||||
? { type: 'object', properties: {} }
|
||||
: { ...schema, type: 'object', properties: schema.properties || {} };
|
||||
|
||||
// Handle adding a new property
|
||||
const handleAddProperty = (newField: NewField) => {
|
||||
// Create field schema from the new field data
|
||||
const fieldSchema = {
|
||||
type: newField.type,
|
||||
description: newField.description || undefined,
|
||||
...(newField.validation || {}),
|
||||
} as ObjectJSONSchema;
|
||||
|
||||
// Add the property to the schema
|
||||
let newSchema = updateObjectProperty(
|
||||
normalizedSchema,
|
||||
newField.name,
|
||||
fieldSchema,
|
||||
);
|
||||
|
||||
// Update required status if needed
|
||||
if (newField.required) {
|
||||
newSchema = updatePropertyRequired(newSchema, newField.name, true);
|
||||
}
|
||||
|
||||
// Update the schema
|
||||
onChange(newSchema);
|
||||
};
|
||||
|
||||
// Handle deleting a property
|
||||
const handleDeleteProperty = (propertyName: string) => {
|
||||
const newSchema = removeObjectProperty(normalizedSchema, propertyName);
|
||||
onChange(newSchema);
|
||||
};
|
||||
|
||||
// Handle property name change
|
||||
const handlePropertyNameChange = (oldName: string, newName: string) => {
|
||||
if (oldName === newName) return;
|
||||
|
||||
const property = properties.find((p) => p.name === oldName);
|
||||
if (!property) return;
|
||||
|
||||
const propertySchemaObj = asObjectSchema(property.schema);
|
||||
|
||||
// Add property with new name
|
||||
let newSchema = updateObjectProperty(
|
||||
normalizedSchema,
|
||||
newName,
|
||||
propertySchemaObj,
|
||||
);
|
||||
|
||||
if (property.required) {
|
||||
newSchema = updatePropertyRequired(newSchema, newName, true);
|
||||
}
|
||||
|
||||
newSchema = removeObjectProperty(newSchema, oldName);
|
||||
|
||||
onChange(newSchema);
|
||||
};
|
||||
|
||||
// Handle property required status change
|
||||
const handlePropertyRequiredChange = (
|
||||
propertyName: string,
|
||||
required: boolean,
|
||||
) => {
|
||||
const newSchema = updatePropertyRequired(
|
||||
normalizedSchema,
|
||||
propertyName,
|
||||
required,
|
||||
);
|
||||
onChange(newSchema);
|
||||
};
|
||||
|
||||
const handlePropertySchemaChange = (
|
||||
propertyName: string,
|
||||
propertySchema: ObjectJSONSchema,
|
||||
) => {
|
||||
const newSchema = updateObjectProperty(
|
||||
normalizedSchema,
|
||||
propertyName,
|
||||
propertySchema,
|
||||
);
|
||||
onChange(newSchema);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{properties.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{properties.map((property) => (
|
||||
<SchemaPropertyEditor
|
||||
key={property.name}
|
||||
name={property.name}
|
||||
schema={property.schema}
|
||||
required={property.required}
|
||||
validationNode={validationNode?.children[property.name]}
|
||||
onDelete={() => handleDeleteProperty(property.name)}
|
||||
onNameChange={(newName) =>
|
||||
handlePropertyNameChange(property.name, newName)
|
||||
}
|
||||
onRequiredChange={(required) =>
|
||||
handlePropertyRequiredChange(property.name, required)
|
||||
}
|
||||
onSchemaChange={(schema) =>
|
||||
handlePropertySchemaChange(property.name, schema)
|
||||
}
|
||||
depth={depth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground italic p-2 text-center border rounded-md">
|
||||
{t.objectPropertiesNone}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<AddFieldButton onAddField={handleAddProperty} variant="secondary" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ObjectEditor;
|
||||
@ -0,0 +1,305 @@
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { X } from 'lucide-react';
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
import { useTranslation } from '../../../hooks/use-translation';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { ObjectJSONSchema } from '../../../types/jsonSchema';
|
||||
import { isBooleanSchema, withObjectSchema } from '../../../types/jsonSchema';
|
||||
import type { TypeEditorProps } from '../TypeEditor';
|
||||
|
||||
type Property = 'enum' | 'minLength' | 'maxLength' | 'pattern' | 'format';
|
||||
|
||||
const StringEditor: React.FC<TypeEditorProps> = ({
|
||||
schema,
|
||||
validationNode,
|
||||
onChange,
|
||||
}) => {
|
||||
const t = useTranslation();
|
||||
const [enumValue, setEnumValue] = useState('');
|
||||
|
||||
const minLengthId = useId();
|
||||
const maxLengthId = useId();
|
||||
const patternId = useId();
|
||||
const formatId = useId();
|
||||
|
||||
// Extract string-specific validations
|
||||
const minLength = withObjectSchema(schema, (s) => s.minLength, undefined);
|
||||
const maxLength = withObjectSchema(schema, (s) => s.maxLength, undefined);
|
||||
const pattern = withObjectSchema(schema, (s) => s.pattern, undefined);
|
||||
const format = withObjectSchema(schema, (s) => s.format, undefined);
|
||||
const enumValues = withObjectSchema(
|
||||
schema,
|
||||
(s) => (s.enum as string[]) || [],
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle validation change
|
||||
const handleValidationChange = (property: Property, value: unknown) => {
|
||||
// Create a safe base schema
|
||||
const baseSchema = isBooleanSchema(schema)
|
||||
? { type: 'string' as const }
|
||||
: { ...schema };
|
||||
|
||||
// Get all validation props except type and description
|
||||
const { type: _, description: __, ...validationProps } = baseSchema;
|
||||
|
||||
// Create the updated validation schema
|
||||
const updatedValidation: ObjectJSONSchema = {
|
||||
...validationProps,
|
||||
type: 'string',
|
||||
[property]: value,
|
||||
};
|
||||
|
||||
// Call onChange with the updated schema (even if there are validation errors)
|
||||
onChange(updatedValidation);
|
||||
};
|
||||
|
||||
// Handle adding enum value
|
||||
const handleAddEnumValue = () => {
|
||||
if (!enumValue.trim()) return;
|
||||
|
||||
if (!enumValues.includes(enumValue)) {
|
||||
handleValidationChange('enum', [...enumValues, enumValue]);
|
||||
}
|
||||
|
||||
setEnumValue('');
|
||||
};
|
||||
|
||||
// Handle removing enum value
|
||||
const handleRemoveEnumValue = (index: number) => {
|
||||
const newEnumValues = [...enumValues];
|
||||
newEnumValues.splice(index, 1);
|
||||
|
||||
if (newEnumValues.length === 0) {
|
||||
// If empty, remove the enum property entirely
|
||||
const baseSchema = isBooleanSchema(schema)
|
||||
? { type: 'string' as const }
|
||||
: { ...schema };
|
||||
|
||||
// Use a type safe approach
|
||||
if (!isBooleanSchema(baseSchema) && 'enum' in baseSchema) {
|
||||
const { enum: _, ...rest } = baseSchema;
|
||||
onChange(rest as ObjectJSONSchema);
|
||||
} else {
|
||||
onChange(baseSchema as ObjectJSONSchema);
|
||||
}
|
||||
} else {
|
||||
handleValidationChange('enum', newEnumValues);
|
||||
}
|
||||
};
|
||||
|
||||
const minMaxError = useMemo(
|
||||
() =>
|
||||
validationNode?.validation.errors?.find((err) => err.path[0] === 'length')
|
||||
?.message,
|
||||
[validationNode],
|
||||
);
|
||||
|
||||
const minLengthError = useMemo(
|
||||
() =>
|
||||
validationNode?.validation.errors?.find(
|
||||
(err) => err.path[0] === 'minLength',
|
||||
)?.message,
|
||||
[validationNode],
|
||||
);
|
||||
|
||||
const maxLengthError = useMemo(
|
||||
() =>
|
||||
validationNode?.validation.errors?.find(
|
||||
(err) => err.path[0] === 'maxLength',
|
||||
)?.message,
|
||||
[validationNode],
|
||||
);
|
||||
|
||||
const patternError = useMemo(
|
||||
() =>
|
||||
validationNode?.validation.errors?.find(
|
||||
(err) => err.path[0] === 'pattern',
|
||||
)?.message,
|
||||
[validationNode],
|
||||
);
|
||||
|
||||
const formatError = useMemo(
|
||||
() =>
|
||||
validationNode?.validation.errors?.find((err) => err.path[0] === 'format')
|
||||
?.message,
|
||||
[validationNode],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={minLengthId}
|
||||
className={
|
||||
(!!minMaxError || !!minLengthError) && 'text-destructive'
|
||||
}
|
||||
>
|
||||
{t.stringMinimumLengthLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id={minLengthId}
|
||||
type="number"
|
||||
min={0}
|
||||
value={minLength ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value ? Number(e.target.value) : undefined;
|
||||
handleValidationChange('minLength', value);
|
||||
}}
|
||||
placeholder={t.stringMinimumLengthPlaceholder}
|
||||
className={cn(
|
||||
'h-8',
|
||||
(!!minMaxError || !!minLengthError) && 'border-destructive',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={maxLengthId}
|
||||
className={
|
||||
(!!minMaxError || !!maxLengthError) && 'text-destructive'
|
||||
}
|
||||
>
|
||||
{t.stringMaximumLengthLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id={maxLengthId}
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxLength ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value ? Number(e.target.value) : undefined;
|
||||
handleValidationChange('maxLength', value);
|
||||
}}
|
||||
placeholder={t.stringMaximumLengthPlaceholder}
|
||||
className={cn(
|
||||
'h-8',
|
||||
(!!minMaxError || !!maxLengthError) && 'border-destructive',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{(!!minMaxError || !!minLengthError || !!maxLengthError) && (
|
||||
<div className="text-xs text-destructive italic md:col-span-2 whitespace-pre-line">
|
||||
{[minMaxError, minLengthError ?? maxLengthError]
|
||||
.filter(Boolean)
|
||||
.join('\n')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={patternId}
|
||||
className={!!patternError && 'text-destructive'}
|
||||
>
|
||||
{t.stringPatternLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id={patternId}
|
||||
type="text"
|
||||
value={pattern ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value || undefined;
|
||||
handleValidationChange('pattern', value);
|
||||
}}
|
||||
placeholder={t.stringPatternPlaceholder}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={formatId}
|
||||
className={!!formatError && 'text-destructive'}
|
||||
>
|
||||
{t.stringFormatLabel}
|
||||
</Label>
|
||||
<Select
|
||||
value={format || 'none'}
|
||||
onValueChange={(value) => {
|
||||
handleValidationChange(
|
||||
'format',
|
||||
value === 'none' ? undefined : value,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={formatId} className="h-8">
|
||||
<SelectValue placeholder="Select format" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t.stringFormatNone}</SelectItem>
|
||||
<SelectItem value="date-time">{t.stringFormatDateTime}</SelectItem>
|
||||
<SelectItem value="date">{t.stringFormatDate}</SelectItem>
|
||||
<SelectItem value="time">{t.stringFormatTime}</SelectItem>
|
||||
<SelectItem value="email">{t.stringFormatEmail}</SelectItem>
|
||||
<SelectItem value="uri">{t.stringFormatUri}</SelectItem>
|
||||
<SelectItem value="uuid">{t.stringFormatUuid}</SelectItem>
|
||||
<SelectItem value="hostname">{t.stringFormatHostname}</SelectItem>
|
||||
<SelectItem value="ipv4">{t.stringFormatIpv4}</SelectItem>
|
||||
<SelectItem value="ipv6">{t.stringFormatIpv6}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-border/40">
|
||||
<Label>{t.stringAllowedValuesEnumLabel}</Label>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{enumValues.length > 0 ? (
|
||||
enumValues.map((value) => (
|
||||
<div
|
||||
key={`enum-string-${value}`}
|
||||
className="flex items-center bg-muted/40 border rounded-md px-2 py-1 text-xs"
|
||||
>
|
||||
<span className="mr-1">{value}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleRemoveEnumValue(enumValues.indexOf(value))
|
||||
}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{t.stringAllowedValuesEnumNone}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={enumValue}
|
||||
onChange={(e) => setEnumValue(e.target.value)}
|
||||
placeholder={t.stringAllowedValuesEnumAddPlaceholder}
|
||||
className="h-8 text-xs flex-1"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddEnumValue()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddEnumValue}
|
||||
className="px-3 py-1 h-8 rounded-md bg-secondary text-xs font-medium hover:bg-secondary/80"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StringEditor;
|
||||
@ -0,0 +1,266 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import Editor, { type BeforeMount, type OnMount } from '@monaco-editor/react';
|
||||
import { AlertCircle, Check, Loader2 } from 'lucide-react';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useMonacoTheme } from '../../hooks/use-monaco-theme';
|
||||
import { formatTranslation, useTranslation } from '../../hooks/use-translation';
|
||||
import type { JSONSchema } from '../../types/jsonSchema';
|
||||
import { validateJson, type ValidationResult } from '../../utils/jsonValidator';
|
||||
|
||||
/** @public */
|
||||
export interface JsonValidatorProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
schema: JSONSchema;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function JsonValidator({
|
||||
open,
|
||||
onOpenChange,
|
||||
schema,
|
||||
}: JsonValidatorProps) {
|
||||
const t = useTranslation();
|
||||
const [jsonInput, setJsonInput] = useState('');
|
||||
const [validationResult, setValidationResult] =
|
||||
useState<ValidationResult | null>(null);
|
||||
const editorRef = useRef<Parameters<OnMount>[0] | null>(null);
|
||||
const debounceTimerRef = useRef<number | null>(null);
|
||||
const monacoRef = useRef<typeof Monaco | null>(null);
|
||||
const schemaMonacoRef = useRef<typeof Monaco | null>(null);
|
||||
const {
|
||||
currentTheme,
|
||||
defineMonacoThemes,
|
||||
configureJsonDefaults,
|
||||
defaultEditorOptions,
|
||||
} = useMonacoTheme();
|
||||
|
||||
const validateJsonAgainstSchema = useCallback(() => {
|
||||
if (!jsonInput.trim()) {
|
||||
setValidationResult(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = validateJson(jsonInput, schema);
|
||||
setValidationResult(result);
|
||||
}, [jsonInput, schema]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
validateJsonAgainstSchema();
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [validateJsonAgainstSchema]);
|
||||
|
||||
const handleJsonEditorBeforeMount: BeforeMount = (monaco) => {
|
||||
monacoRef.current = monaco;
|
||||
defineMonacoThemes(monaco);
|
||||
configureJsonDefaults(monaco, schema);
|
||||
};
|
||||
|
||||
const handleSchemaEditorBeforeMount: BeforeMount = (monaco) => {
|
||||
schemaMonacoRef.current = monaco;
|
||||
defineMonacoThemes(monaco);
|
||||
};
|
||||
|
||||
const handleEditorDidMount: OnMount = (editor) => {
|
||||
editorRef.current = editor;
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
const handleEditorChange = (value: string | undefined) => {
|
||||
setJsonInput(value || '');
|
||||
};
|
||||
|
||||
const goToError = (line: number, column: number) => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.revealLineInCenter(line);
|
||||
editorRef.current.setPosition({ lineNumber: line, column: column });
|
||||
editorRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// Create a modified version of defaultEditorOptions for the editor
|
||||
const editorOptions = {
|
||||
...defaultEditorOptions,
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
// Create read-only options for the schema viewer
|
||||
const schemaViewerOptions = {
|
||||
...defaultEditorOptions,
|
||||
readOnly: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-5xl max-h-[700px] flex flex-col jsonjoy">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.validatorTitle}</DialogTitle>
|
||||
<DialogDescription>{t.validatorDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 flex flex-col md:flex-row gap-4 py-4 overflow-hidden h-[600px]">
|
||||
<div className="flex-1 flex flex-col h-full">
|
||||
<div className="text-sm font-medium mb-2">{t.validatorContent}</div>
|
||||
<div className="border rounded-md flex-1 h-full">
|
||||
<Editor
|
||||
height="600px"
|
||||
defaultLanguage="json"
|
||||
value={jsonInput}
|
||||
onChange={handleEditorChange}
|
||||
beforeMount={handleJsonEditorBeforeMount}
|
||||
onMount={handleEditorDidMount}
|
||||
loading={
|
||||
<div className="flex items-center justify-center h-full w-full bg-secondary/30">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
}
|
||||
options={editorOptions}
|
||||
theme={currentTheme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col h-full">
|
||||
<div className="text-sm font-medium mb-2">
|
||||
{t.validatorCurrentSchema}
|
||||
</div>
|
||||
<div className="border rounded-md flex-1 h-full">
|
||||
<Editor
|
||||
height="600px"
|
||||
defaultLanguage="json"
|
||||
value={JSON.stringify(schema, null, 2)}
|
||||
beforeMount={handleSchemaEditorBeforeMount}
|
||||
loading={
|
||||
<div className="flex items-center justify-center h-full w-full bg-secondary/30">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
}
|
||||
options={schemaViewerOptions}
|
||||
theme={currentTheme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validationResult && (
|
||||
<div
|
||||
className={`rounded-md p-4 ${validationResult.valid ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'} transition-all duration-300 ease-in-out`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{validationResult.valid ? (
|
||||
<>
|
||||
<Check className="h-5 w-5 text-green-500 mr-2" />
|
||||
<p className="text-green-700 font-medium">
|
||||
{t.validatorValid}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-5 w-5 text-red-500 mr-2" />
|
||||
<p className="text-red-700 font-medium">
|
||||
{validationResult.errors.length === 1
|
||||
? validationResult.errors[0].path === '/'
|
||||
? t.validatorErrorInvalidSyntax
|
||||
: t.validatorErrorSchemaValidation
|
||||
: formatTranslation(t.validatorErrorCount, {
|
||||
count: validationResult.errors.length,
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!validationResult.valid &&
|
||||
validationResult.errors &&
|
||||
validationResult.errors.length > 0 && (
|
||||
<div className="mt-3 max-h-[200px] overflow-y-auto">
|
||||
{validationResult.errors[0] && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-red-700">
|
||||
{validationResult.errors[0].path === '/'
|
||||
? t.validatorErrorPathRoot
|
||||
: validationResult.errors[0].path}
|
||||
</span>
|
||||
{validationResult.errors[0].line && (
|
||||
<span className="text-xs bg-gray-100 px-2 py-1 rounded text-gray-600">
|
||||
{validationResult.errors[0].column
|
||||
? formatTranslation(
|
||||
t.validatorErrorLocationLineAndColumn,
|
||||
{
|
||||
line: validationResult.errors[0].line,
|
||||
column: validationResult.errors[0].column,
|
||||
},
|
||||
)
|
||||
: formatTranslation(
|
||||
t.validatorErrorLocationLineOnly,
|
||||
{ line: validationResult.errors[0].line },
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{validationResult.errors.map((error, index) => (
|
||||
<button
|
||||
key={`error-${error.path}-${index}`}
|
||||
type="button"
|
||||
className="w-full text-left bg-white border border-red-100 rounded-md p-3 shadow-xs hover:shadow-md transition-shadow duration-200 cursor-pointer"
|
||||
onClick={() =>
|
||||
error.line &&
|
||||
error.column &&
|
||||
goToError(error.line, error.column)
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-red-700">
|
||||
{error.path === '/'
|
||||
? t.validatorErrorPathRoot
|
||||
: error.path}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{error.message}
|
||||
</p>
|
||||
</div>
|
||||
{error.line && (
|
||||
<div className="text-xs bg-gray-100 px-2 py-1 rounded text-gray-600">
|
||||
{error.column
|
||||
? formatTranslation(
|
||||
t.validatorErrorLocationLineAndColumn,
|
||||
{ line: error.line, column: error.column },
|
||||
)
|
||||
: formatTranslation(
|
||||
t.validatorErrorLocationLineOnly,
|
||||
{ line: error.line },
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,116 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import Editor, { type BeforeMount, type OnMount } from '@monaco-editor/react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useMonacoTheme } from '../../hooks/use-monaco-theme';
|
||||
import { useTranslation } from '../../hooks/use-translation';
|
||||
import { createSchemaFromJson } from '../../lib/schema-inference';
|
||||
import type { JSONSchema } from '../../types/jsonSchema';
|
||||
|
||||
/** @public */
|
||||
export interface SchemaInferencerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSchemaInferred: (schema: JSONSchema) => void;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function SchemaInferencer({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSchemaInferred,
|
||||
}: SchemaInferencerProps) {
|
||||
const t = useTranslation();
|
||||
const [jsonInput, setJsonInput] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const editorRef = useRef<Parameters<OnMount>[0] | null>(null);
|
||||
const {
|
||||
currentTheme,
|
||||
defineMonacoThemes,
|
||||
configureJsonDefaults,
|
||||
defaultEditorOptions,
|
||||
} = useMonacoTheme();
|
||||
|
||||
const handleBeforeMount: BeforeMount = (monaco) => {
|
||||
defineMonacoThemes(monaco);
|
||||
configureJsonDefaults(monaco);
|
||||
};
|
||||
|
||||
const handleEditorDidMount: OnMount = (editor) => {
|
||||
editorRef.current = editor;
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
const handleEditorChange = (value: string | undefined) => {
|
||||
setJsonInput(value || '');
|
||||
};
|
||||
|
||||
const inferSchemaFromJson = () => {
|
||||
try {
|
||||
const jsonObject = JSON.parse(jsonInput);
|
||||
setError(null);
|
||||
|
||||
// Use the schema inference service to create a schema
|
||||
const inferredSchema = createSchemaFromJson(jsonObject);
|
||||
|
||||
onSchemaInferred(inferredSchema);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error('Invalid JSON input:', error);
|
||||
setError(t.inferrerErrorInvalidJson);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setJsonInput('');
|
||||
setError(null);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[90vh] flex flex-col jsonjoy">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.inferrerTitle}</DialogTitle>
|
||||
<DialogDescription>{t.inferrerDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 min-h-0 py-4 flex flex-col">
|
||||
<div className="border rounded-md flex-1 overflow-hidden h-full">
|
||||
<Editor
|
||||
height="450px"
|
||||
defaultLanguage="json"
|
||||
value={jsonInput}
|
||||
onChange={handleEditorChange}
|
||||
beforeMount={handleBeforeMount}
|
||||
onMount={handleEditorDidMount}
|
||||
options={defaultEditorOptions}
|
||||
theme={currentTheme}
|
||||
loading={
|
||||
<div className="flex items-center justify-center h-full w-full bg-secondary/30">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive mt-2">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
{t.inferrerCancel}
|
||||
</Button>
|
||||
<Button type="button" onClick={inferSchemaFromJson}>
|
||||
{t.inferrerGenerate}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
36
web/src/components/jsonjoy-builder/components/ui/badge.tsx
Normal file
36
web/src/components/jsonjoy-builder/components/ui/badge.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { cn } from '../../lib/utils.ts';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
56
web/src/components/jsonjoy-builder/components/ui/button.tsx
Normal file
56
web/src/components/jsonjoy-builder/components/ui/button.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { forwardRef, type ButtonHTMLAttributes } from 'react';
|
||||
import { cn } from '../../lib/utils.ts';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
136
web/src/components/jsonjoy-builder/components/ui/dialog.tsx
Normal file
136
web/src/components/jsonjoy-builder/components/ui/dialog.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useId,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ComponentRef,
|
||||
type HTMLAttributes,
|
||||
} from 'react';
|
||||
import { cn } from '../../lib/utils.ts';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = forwardRef<
|
||||
ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 jsonjoy',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = forwardRef<
|
||||
ComponentRef<typeof DialogPrimitive.Content>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const dialogDescriptionId = useId();
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
aria-describedby={dialogDescriptionId}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Description
|
||||
id={dialogDescriptionId}
|
||||
className="sr-only"
|
||||
>
|
||||
Dialog content
|
||||
</DialogPrimitive.Description>
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-1.5 text-center sm:text-left',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = forwardRef<
|
||||
ComponentRef<typeof DialogPrimitive.Title>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = forwardRef<
|
||||
ComponentRef<typeof DialogPrimitive.Description>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
21
web/src/components/jsonjoy-builder/components/ui/input.tsx
Normal file
21
web/src/components/jsonjoy-builder/components/ui/input.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { forwardRef, type ComponentProps } from 'react';
|
||||
import { cn } from '../../lib/utils.ts';
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
28
web/src/components/jsonjoy-builder/components/ui/label.tsx
Normal file
28
web/src/components/jsonjoy-builder/components/ui/label.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ComponentRef,
|
||||
} from 'react';
|
||||
import { cn } from '../../lib/utils.ts';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
);
|
||||
|
||||
const Label = forwardRef<
|
||||
ComponentRef<typeof LabelPrimitive.Root>,
|
||||
ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
163
web/src/components/jsonjoy-builder/components/ui/select.tsx
Normal file
163
web/src/components/jsonjoy-builder/components/ui/select.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ComponentRef,
|
||||
} from 'react';
|
||||
import { cn } from '../../lib/utils.ts';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = forwardRef<
|
||||
ComponentRef<typeof SelectPrimitive.Trigger>,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = forwardRef<
|
||||
ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = forwardRef<
|
||||
ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = forwardRef<
|
||||
ComponentRef<typeof SelectPrimitive.Content>,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
'jsonjoy',
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = forwardRef<
|
||||
ComponentRef<typeof SelectPrimitive.Label>,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = forwardRef<
|
||||
ComponentRef<typeof SelectPrimitive.Item>,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = forwardRef<
|
||||
ComponentRef<typeof SelectPrimitive.Separator>,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
31
web/src/components/jsonjoy-builder/components/ui/switch.tsx
Normal file
31
web/src/components/jsonjoy-builder/components/ui/switch.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ComponentRef,
|
||||
} from 'react';
|
||||
import { cn } from '../../lib/utils.ts';
|
||||
|
||||
const Switch = forwardRef<
|
||||
ComponentRef<typeof SwitchPrimitives.Root>,
|
||||
ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
57
web/src/components/jsonjoy-builder/components/ui/tabs.tsx
Normal file
57
web/src/components/jsonjoy-builder/components/ui/tabs.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ComponentRef,
|
||||
} from 'react';
|
||||
import { cn } from '../../lib/utils.ts';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = forwardRef<
|
||||
ComponentRef<typeof TabsPrimitive.List>,
|
||||
ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = forwardRef<
|
||||
ComponentRef<typeof TabsPrimitive.Trigger>,
|
||||
ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = forwardRef<
|
||||
ComponentRef<typeof TabsPrimitive.Content>,
|
||||
ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||
32
web/src/components/jsonjoy-builder/components/ui/tooltip.tsx
Normal file
32
web/src/components/jsonjoy-builder/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ComponentRef,
|
||||
} from 'react';
|
||||
import { cn } from '../../lib/utils.ts';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = forwardRef<
|
||||
ComponentRef<typeof TooltipPrimitive.Content>,
|
||||
ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||
208
web/src/components/jsonjoy-builder/hooks/use-monaco-theme.ts
Normal file
208
web/src/components/jsonjoy-builder/hooks/use-monaco-theme.ts
Normal file
@ -0,0 +1,208 @@
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { JSONSchema } from '../types/jsonSchema.ts';
|
||||
|
||||
export interface MonacoEditorOptions {
|
||||
minimap?: { enabled: boolean };
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
lineNumbers?: 'on' | 'off';
|
||||
roundedSelection?: boolean;
|
||||
scrollBeyondLastLine?: boolean;
|
||||
readOnly?: boolean;
|
||||
automaticLayout?: boolean;
|
||||
formatOnPaste?: boolean;
|
||||
formatOnType?: boolean;
|
||||
tabSize?: number;
|
||||
insertSpaces?: boolean;
|
||||
detectIndentation?: boolean;
|
||||
folding?: boolean;
|
||||
foldingStrategy?: 'auto' | 'indentation';
|
||||
renderLineHighlight?: 'all' | 'line' | 'none' | 'gutter';
|
||||
matchBrackets?: 'always' | 'near' | 'never';
|
||||
autoClosingBrackets?:
|
||||
| 'always'
|
||||
| 'languageDefined'
|
||||
| 'beforeWhitespace'
|
||||
| 'never';
|
||||
autoClosingQuotes?:
|
||||
| 'always'
|
||||
| 'languageDefined'
|
||||
| 'beforeWhitespace'
|
||||
| 'never';
|
||||
guides?: {
|
||||
bracketPairs?: boolean;
|
||||
indentation?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultEditorOptions: MonacoEditorOptions = {
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
fontFamily: "var(--font-sans), 'SF Mono', Monaco, Menlo, Consolas, monospace",
|
||||
lineNumbers: 'on',
|
||||
roundedSelection: false,
|
||||
scrollBeyondLastLine: false,
|
||||
readOnly: false,
|
||||
automaticLayout: true,
|
||||
formatOnPaste: true,
|
||||
formatOnType: true,
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
detectIndentation: true,
|
||||
folding: true,
|
||||
foldingStrategy: 'indentation',
|
||||
renderLineHighlight: 'all',
|
||||
matchBrackets: 'always',
|
||||
autoClosingBrackets: 'always',
|
||||
autoClosingQuotes: 'always',
|
||||
guides: {
|
||||
bracketPairs: true,
|
||||
indentation: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function useMonacoTheme() {
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
// Check for dark mode by examining CSS variables
|
||||
useEffect(() => {
|
||||
const checkDarkMode = () => {
|
||||
// Get the current background color value
|
||||
const backgroundColor = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--background')
|
||||
.trim();
|
||||
|
||||
// If the background color HSL has a low lightness value, it's likely dark mode
|
||||
const isDark =
|
||||
backgroundColor.includes('222.2') ||
|
||||
backgroundColor.includes('84% 4.9%');
|
||||
|
||||
setIsDarkMode(isDark);
|
||||
};
|
||||
|
||||
// Check initially
|
||||
checkDarkMode();
|
||||
|
||||
// Set up a mutation observer to detect theme changes
|
||||
const observer = new MutationObserver(checkDarkMode);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style'],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const defineMonacoThemes = (monaco: typeof Monaco) => {
|
||||
// Define custom light theme that matches app colors
|
||||
monaco.editor.defineTheme('appLightTheme', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [
|
||||
// JSON syntax highlighting based on utils.ts type colors
|
||||
{ token: 'string', foreground: '3B82F6' }, // text-blue-500
|
||||
{ token: 'number', foreground: 'A855F7' }, // text-purple-500
|
||||
{ token: 'keyword', foreground: '3B82F6' }, // text-blue-500
|
||||
{ token: 'delimiter', foreground: '0F172A' }, // text-slate-900
|
||||
{ token: 'keyword.json', foreground: 'A855F7' }, // text-purple-500
|
||||
{ token: 'string.key.json', foreground: '2563EB' }, // text-blue-600
|
||||
{ token: 'string.value.json', foreground: '3B82F6' }, // text-blue-500
|
||||
{ token: 'boolean', foreground: '22C55E' }, // text-green-500
|
||||
{ token: 'null', foreground: '64748B' }, // text-gray-500
|
||||
],
|
||||
colors: {
|
||||
// Light theme colors (using hex values instead of CSS variables)
|
||||
'editor.background': '#f8fafc', // --background
|
||||
'editor.foreground': '#0f172a', // --foreground
|
||||
'editorCursor.foreground': '#0f172a', // --foreground
|
||||
'editor.lineHighlightBackground': '#f1f5f9', // --muted
|
||||
'editorLineNumber.foreground': '#64748b', // --muted-foreground
|
||||
'editor.selectionBackground': '#e2e8f0', // --accent
|
||||
'editor.inactiveSelectionBackground': '#e2e8f0', // --accent
|
||||
'editorIndentGuide.background': '#e2e8f0', // --border
|
||||
'editor.findMatchBackground': '#cbd5e1', // --accent
|
||||
'editor.findMatchHighlightBackground': '#cbd5e133', // --accent with opacity
|
||||
},
|
||||
});
|
||||
|
||||
// Define custom dark theme that matches app colors
|
||||
monaco.editor.defineTheme('appDarkTheme', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
// JSON syntax highlighting based on utils.ts type colors
|
||||
{ token: 'string', foreground: '3B82F6' }, // text-blue-500
|
||||
{ token: 'number', foreground: 'A855F7' }, // text-purple-500
|
||||
{ token: 'keyword', foreground: '3B82F6' }, // text-blue-500
|
||||
{ token: 'delimiter', foreground: 'F8FAFC' }, // text-slate-50
|
||||
{ token: 'keyword.json', foreground: 'A855F7' }, // text-purple-500
|
||||
{ token: 'string.key.json', foreground: '60A5FA' }, // text-blue-400
|
||||
{ token: 'string.value.json', foreground: '3B82F6' }, // text-blue-500
|
||||
{ token: 'boolean', foreground: '22C55E' }, // text-green-500
|
||||
{ token: 'null', foreground: '94A3B8' }, // text-gray-400
|
||||
],
|
||||
colors: {
|
||||
// Dark theme colors (using hex values instead of CSS variables)
|
||||
'editor.background': '#0f172a', // --background
|
||||
'editor.foreground': '#f8fafc', // --foreground
|
||||
'editorCursor.foreground': '#f8fafc', // --foreground
|
||||
'editor.lineHighlightBackground': '#1e293b', // --muted
|
||||
'editorLineNumber.foreground': '#64748b', // --muted-foreground
|
||||
'editor.selectionBackground': '#334155', // --accent
|
||||
'editor.inactiveSelectionBackground': '#334155', // --accent
|
||||
'editorIndentGuide.background': '#1e293b', // --border
|
||||
'editor.findMatchBackground': '#475569', // --accent
|
||||
'editor.findMatchHighlightBackground': '#47556933', // --accent with opacity
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to configure JSON language validation
|
||||
const configureJsonDefaults = (
|
||||
monaco: typeof Monaco,
|
||||
schema?: JSONSchema,
|
||||
) => {
|
||||
// Create a new diagnostics options object
|
||||
const diagnosticsOptions: Monaco.languages.json.DiagnosticsOptions = {
|
||||
validate: true,
|
||||
allowComments: false,
|
||||
schemaValidation: 'error',
|
||||
enableSchemaRequest: true,
|
||||
schemas: schema
|
||||
? [
|
||||
{
|
||||
uri:
|
||||
typeof schema === 'object' && schema.$id
|
||||
? schema.$id
|
||||
: 'https://jsonjoy-builder/schema',
|
||||
fileMatch: ['*'],
|
||||
schema,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
uri: 'http://json-schema.org/draft-07/schema',
|
||||
fileMatch: ['*'],
|
||||
schema: {
|
||||
$schema: 'http://json-schema.org/draft-07/schema',
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
monaco.languages.json.jsonDefaults.setDiagnosticsOptions(
|
||||
diagnosticsOptions,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
isDarkMode,
|
||||
currentTheme: isDarkMode ? 'appDarkTheme' : 'appLightTheme',
|
||||
defineMonacoThemes,
|
||||
configureJsonDefaults,
|
||||
defaultEditorOptions,
|
||||
};
|
||||
}
|
||||
18
web/src/components/jsonjoy-builder/hooks/use-translation.ts
Normal file
18
web/src/components/jsonjoy-builder/hooks/use-translation.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useContext } from 'react';
|
||||
import { en } from '../i18n/locales/en';
|
||||
import { TranslationContext } from '../i18n/translation-context';
|
||||
|
||||
export function useTranslation() {
|
||||
const translation = useContext(TranslationContext);
|
||||
return translation ?? en;
|
||||
}
|
||||
|
||||
export function formatTranslation(
|
||||
template: string,
|
||||
values: Record<string, string | number>,
|
||||
) {
|
||||
return template.replace(/\{(\w+)\}/g, (_, key) => {
|
||||
const value = values[key];
|
||||
return value !== undefined ? String(value) : `{${key}}`;
|
||||
});
|
||||
}
|
||||
154
web/src/components/jsonjoy-builder/i18n/locales/de.ts
Normal file
154
web/src/components/jsonjoy-builder/i18n/locales/de.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import type { Translation } from '../translation-keys.ts';
|
||||
|
||||
export const de: Translation = {
|
||||
collapse: 'Einklappen',
|
||||
expand: 'Ausklappen',
|
||||
|
||||
fieldDescriptionPlaceholder: 'Zweck dieses Felds beschreiben',
|
||||
fieldDelete: 'Feld löschen',
|
||||
fieldDescription: 'Beschreibung',
|
||||
fieldDescriptionTooltip: 'Kontext zur Bedeutung dieses Felds hinzufügen',
|
||||
fieldNameLabel: 'Feldname',
|
||||
fieldNamePlaceholder: 'z.B. firstName, age, isActive',
|
||||
fieldNameTooltip:
|
||||
'CamelCase für bessere Lesbarkeit verwenden (z.B. firstName)',
|
||||
fieldRequiredLabel: 'Pflichtfeld',
|
||||
fieldType: 'Feldart',
|
||||
fieldTypeExample: 'Beispiel:',
|
||||
fieldTypeTooltipString: 'string: Text',
|
||||
fieldTypeTooltipNumber: 'number: Zahl',
|
||||
fieldTypeTooltipBoolean: 'boolean: Wahr/Falsch',
|
||||
fieldTypeTooltipObject: 'object: Verschachteltes JSON',
|
||||
fieldTypeTooltipArray: 'array: Liste von Werten',
|
||||
fieldAddNewButton: 'Feld hinzufügen',
|
||||
fieldAddNewBadge: 'Schema-Builder',
|
||||
fieldAddNewCancel: 'Abbrechen',
|
||||
fieldAddNewConfirm: 'Feld hinzufügen',
|
||||
fieldAddNewDescription: 'Neues Feld für das JSON-Schema erstellen',
|
||||
fieldAddNewLabel: 'Neues Feld hinzufügen',
|
||||
|
||||
fieldTypeTextLabel: 'Text',
|
||||
fieldTypeTextDescription: 'Für Textwerte wie Namen, Beschreibungen usw.',
|
||||
fieldTypeNumberLabel: 'Zahl',
|
||||
fieldTypeNumberDescription: 'Für Dezimal- oder Ganzzahlen',
|
||||
fieldTypeBooleanLabel: 'Ja/Nein',
|
||||
fieldTypeBooleanDescription: 'Für Wahr/Falsch-Werte',
|
||||
fieldTypeObjectLabel: 'Gruppe',
|
||||
fieldTypeObjectDescription: 'Zum Gruppieren verwandter Felder',
|
||||
fieldTypeArrayLabel: 'Liste',
|
||||
fieldTypeArrayDescription: 'Für Sammlungen von Elementen',
|
||||
|
||||
propertyDescriptionPlaceholder: 'Beschreibung hinzufügen...',
|
||||
propertyDescriptionButton: 'Beschreibung hinzufügen...',
|
||||
propertyRequired: 'Erforderlich',
|
||||
propertyOptional: 'Optional',
|
||||
propertyDelete: 'Feld löschen',
|
||||
|
||||
schemaEditorTitle: 'JSON-Schema-Editor',
|
||||
schemaEditorToggleFullscreen: 'Vollbild umschalten',
|
||||
schemaEditorEditModeVisual: 'Visuell',
|
||||
schemaEditorEditModeJson: 'JSON',
|
||||
|
||||
arrayMinimumLabel: 'Mindestanzahl an Elementen',
|
||||
arrayMinimumPlaceholder: 'Kein Minimum',
|
||||
arrayMaximumLabel: 'Maximale Anzahl an Elemente',
|
||||
arrayMaximumPlaceholder: 'Kein Maximum',
|
||||
arrayForceUniqueItemsLabel: 'Nur eindeutige Elemente erlauben',
|
||||
arrayItemTypeLabel: 'Elementtyp',
|
||||
arrayValidationErrorMinMax:
|
||||
"'minItems' darf nicht größer als 'maxItems' sein.",
|
||||
arrayValidationErrorContainsMinMax:
|
||||
"'minContains' darf nicht größer als 'maxContains' sein.",
|
||||
|
||||
booleanAllowFalseLabel: 'Falsch-Werte erlauben',
|
||||
booleanAllowTrueLabel: 'Wahr-Werte erlauben',
|
||||
booleanNeitherWarning:
|
||||
'Achtung: Mindestens einer von beiden Werten muss erlaubt sein.',
|
||||
|
||||
numberMinimumLabel: 'Minimalwert',
|
||||
numberMinimumPlaceholder: 'Kein Minimum',
|
||||
numberMaximumLabel: 'Maximalwert',
|
||||
numberMaximumPlaceholder: 'Kein Maximum',
|
||||
numberExclusiveMinimumLabel: 'Exklusives Minimum',
|
||||
numberExclusiveMinimumPlaceholder: 'Kein exklusives Minimum',
|
||||
numberExclusiveMaximumLabel: 'Exklusives Maximum',
|
||||
numberExclusiveMaximumPlaceholder: 'Kein exklusives Maximum',
|
||||
numberMultipleOfLabel: 'Vielfaches von',
|
||||
numberMultipleOfPlaceholder: 'Beliebig',
|
||||
numberAllowedValuesEnumLabel: 'Erlaubte Werte (Enum)',
|
||||
numberAllowedValuesEnumNone: 'Keine Einschränkung für Werte festgelegt',
|
||||
numberAllowedValuesEnumAddLabel: 'Hinzufügen',
|
||||
numberAllowedValuesEnumAddPlaceholder: 'Erlaubten Wert hinzufügen...',
|
||||
numberValidationErrorMinMax: 'Minimum und Maximum müssen konsistent sein.',
|
||||
numberValidationErrorBothExclusiveAndInclusiveMin:
|
||||
"Sowohl 'exclusiveMinimum' als auch 'minimum' dürfen nicht gleichzeitig festgelegt werden.",
|
||||
numberValidationErrorBothExclusiveAndInclusiveMax:
|
||||
"Sowohl 'exclusiveMaximum' als auch 'maximum' dürfen nicht gleichzeitig festgelegt werden.",
|
||||
numberValidationErrorEnumOutOfRange:
|
||||
'Enum-Werte müssen innerhalb des definierten Bereichs liegen.',
|
||||
|
||||
objectPropertiesNone: 'Keine Eigenschaften definiert',
|
||||
objectValidationErrorMinMax:
|
||||
"'minProperties' darf nicht größer als 'maxProperties' sein.",
|
||||
|
||||
stringMinimumLengthLabel: 'Minimale Länge',
|
||||
stringMinimumLengthPlaceholder: 'Kein Minimum',
|
||||
stringMaximumLengthLabel: 'Maximale Länge',
|
||||
stringMaximumLengthPlaceholder: 'Kein Maximum',
|
||||
stringPatternLabel: 'Muster (Regex)',
|
||||
stringPatternPlaceholder: '^[a-zA-Z]+$',
|
||||
stringFormatLabel: 'Format',
|
||||
stringFormatNone: 'Keins',
|
||||
stringFormatDateTime: 'Datum und Uhrzeit',
|
||||
stringFormatDate: 'Datum',
|
||||
stringFormatTime: 'Uhrzeit',
|
||||
stringFormatEmail: 'E-Mail',
|
||||
stringFormatUri: 'URI',
|
||||
stringFormatUuid: 'UUID',
|
||||
stringFormatHostname: 'Hostname',
|
||||
stringFormatIpv4: 'IPv4-Adresse',
|
||||
stringFormatIpv6: 'IPv6-Adresse',
|
||||
stringAllowedValuesEnumLabel: 'Erlaubte Werte (Enum)',
|
||||
stringAllowedValuesEnumNone: 'Keine Einschränkung für Werte festgelegt',
|
||||
stringAllowedValuesEnumAddPlaceholder: 'Erlaubten Wert hinzufügen...',
|
||||
stringValidationErrorLengthRange:
|
||||
"'Minimale Länge' darf nicht größer als 'Maximale Länge' sein.",
|
||||
|
||||
schemaTypeArray: 'Liste',
|
||||
schemaTypeBoolean: 'Ja/Nein',
|
||||
schemaTypeNumber: 'Zahl',
|
||||
schemaTypeObject: 'Objekt',
|
||||
schemaTypeString: 'Text',
|
||||
schemaTypeNull: 'Leer',
|
||||
|
||||
inferrerTitle: 'JSON-Schema ableiten',
|
||||
inferrerDescription:
|
||||
'JSON-Dokument unten einfügen, um ein Schema daraus zu erstellen.',
|
||||
inferrerCancel: 'Abbrechen',
|
||||
inferrerGenerate: 'Schema erstellen',
|
||||
inferrerErrorInvalidJson: 'Ungültiges JSON-Format. Bitte Eingabe prüfen.',
|
||||
|
||||
validatorTitle: 'JSON validieren',
|
||||
validatorDescription:
|
||||
'JSON-Dokument einfügen, um es gegen das aktuelle Schema zu prüfen. Die Validierung erfolgt automatisch beim Tippen.',
|
||||
validatorCurrentSchema: 'Aktuelles Schema:',
|
||||
validatorContent: 'Zu prüfendes JSON:',
|
||||
validatorValid: 'JSON ist gültig zum Schema!',
|
||||
validatorErrorInvalidSyntax: 'Ungültige JSON-Syntax',
|
||||
validatorErrorSchemaValidation: 'Schema-Validierungsfehler',
|
||||
validatorErrorCount: '{count} Validierungsfehler gefunden',
|
||||
validatorErrorPathRoot: 'Wurzel',
|
||||
validatorErrorLocationLineAndColumn: 'Zeile {line}, Spalte {column}',
|
||||
validatorErrorLocationLineOnly: 'Zeile {line}',
|
||||
|
||||
visualizerDownloadTitle: 'Schema herunterladen',
|
||||
visualizerDownloadFileName: 'schema.json',
|
||||
visualizerSource: 'JSON-Schema-Quelle',
|
||||
|
||||
visualEditorNoFieldsHint1: 'Noch keine Felder definiert',
|
||||
visualEditorNoFieldsHint2: 'Erstes Feld hinzufügen, um zu starten',
|
||||
|
||||
typeValidationErrorNegativeLength: 'Längenwerte dürfen nicht negativ sein.',
|
||||
typeValidationErrorIntValue: 'Der Wert muss eine ganze Zahl sein.',
|
||||
typeValidationErrorPositive: 'Der Wert muss positiv sein.',
|
||||
};
|
||||
151
web/src/components/jsonjoy-builder/i18n/locales/en.ts
Normal file
151
web/src/components/jsonjoy-builder/i18n/locales/en.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import type { Translation } from '../translation-keys.ts';
|
||||
|
||||
export const en: Translation = {
|
||||
collapse: 'Collapse',
|
||||
expand: 'Expand',
|
||||
|
||||
fieldDescriptionPlaceholder: 'Describe the purpose of this field',
|
||||
fieldDelete: 'Delete field',
|
||||
fieldDescription: 'Description',
|
||||
fieldDescriptionTooltip: 'Add context about what this field represents',
|
||||
fieldNameLabel: 'Field Name',
|
||||
fieldNamePlaceholder: 'e.g. firstName, age, isActive',
|
||||
fieldNameTooltip: 'Use camelCase for better readability (e.g., firstName)',
|
||||
fieldRequiredLabel: 'Required Field',
|
||||
fieldType: 'Field Type',
|
||||
fieldTypeExample: 'Example:',
|
||||
fieldTypeTooltipString: 'string: Text',
|
||||
fieldTypeTooltipNumber: 'number: Numeric',
|
||||
fieldTypeTooltipBoolean: 'boolean: True/false',
|
||||
fieldTypeTooltipObject: 'object: Nested JSON',
|
||||
fieldTypeTooltipArray: 'array: Lists of values',
|
||||
fieldAddNewButton: 'Add Field',
|
||||
fieldAddNewBadge: 'Schema Builder',
|
||||
fieldAddNewCancel: 'Cancel',
|
||||
fieldAddNewConfirm: 'Add Field',
|
||||
fieldAddNewDescription: 'Create a new field for your JSON schema',
|
||||
fieldAddNewLabel: 'Add New Field',
|
||||
|
||||
fieldTypeTextLabel: 'Text',
|
||||
fieldTypeTextDescription: 'For text values like names, descriptions, etc.',
|
||||
fieldTypeNumberLabel: 'Number',
|
||||
fieldTypeNumberDescription: 'For decimal or whole numbers',
|
||||
fieldTypeBooleanLabel: 'Yes/No',
|
||||
fieldTypeBooleanDescription: 'For true/false values',
|
||||
fieldTypeObjectLabel: 'Group',
|
||||
fieldTypeObjectDescription: 'For grouping related fields together',
|
||||
fieldTypeArrayLabel: 'List',
|
||||
fieldTypeArrayDescription: 'For collections of items',
|
||||
|
||||
propertyDescriptionPlaceholder: 'Add description...',
|
||||
propertyDescriptionButton: 'Add description...',
|
||||
propertyRequired: 'Required',
|
||||
propertyOptional: 'Optional',
|
||||
propertyDelete: 'Delete field',
|
||||
|
||||
schemaEditorTitle: 'JSON Schema Editor',
|
||||
schemaEditorToggleFullscreen: 'Toggle fullscreen',
|
||||
schemaEditorEditModeVisual: 'Visual',
|
||||
schemaEditorEditModeJson: 'JSON',
|
||||
|
||||
arrayMinimumLabel: 'Minimum Items',
|
||||
arrayMinimumPlaceholder: 'No minimum',
|
||||
arrayMaximumLabel: 'Maximum Items',
|
||||
arrayMaximumPlaceholder: 'No maximum',
|
||||
arrayForceUniqueItemsLabel: 'Force unique items',
|
||||
arrayItemTypeLabel: 'Item Type',
|
||||
arrayValidationErrorMinMax: "'minItems' cannot be greater than 'maxItems'.",
|
||||
arrayValidationErrorContainsMinMax:
|
||||
"'minContains' cannot be greater than 'maxContains'.",
|
||||
|
||||
booleanAllowFalseLabel: 'Allow false value',
|
||||
booleanAllowTrueLabel: 'Allow true value',
|
||||
booleanNeitherWarning: 'Warning: You must allow at least one value.',
|
||||
|
||||
numberMinimumLabel: 'Minimum Value',
|
||||
numberMinimumPlaceholder: 'No minimum',
|
||||
numberMaximumLabel: 'Maximum Value',
|
||||
numberMaximumPlaceholder: 'No maximum',
|
||||
numberExclusiveMinimumLabel: 'Exclusive Minimum',
|
||||
numberExclusiveMinimumPlaceholder: 'No exclusive min',
|
||||
numberExclusiveMaximumLabel: 'Exclusive Maximum',
|
||||
numberExclusiveMaximumPlaceholder: 'No exclusive max',
|
||||
numberMultipleOfLabel: 'Multiple Of',
|
||||
numberMultipleOfPlaceholder: 'Any',
|
||||
numberAllowedValuesEnumLabel: 'Allowed Values (enum)',
|
||||
numberAllowedValuesEnumNone: 'No restricted values set',
|
||||
numberAllowedValuesEnumAddLabel: 'Add',
|
||||
numberAllowedValuesEnumAddPlaceholder: 'Add allowed value...',
|
||||
numberValidationErrorMinMax: 'Minimum and maximum values must be consistent.',
|
||||
numberValidationErrorBothExclusiveAndInclusiveMin:
|
||||
"Both 'exclusiveMinimum' and 'minimum' cannot be set at the same time.",
|
||||
numberValidationErrorBothExclusiveAndInclusiveMax:
|
||||
"Both 'exclusiveMaximum' and 'maximum' cannot be set at the same time.",
|
||||
numberValidationErrorEnumOutOfRange:
|
||||
'Enum values must be within the defined range.',
|
||||
|
||||
objectPropertiesNone: 'No properties defined',
|
||||
objectValidationErrorMinMax:
|
||||
"'minProperties' cannot be greater than 'maxProperties'.",
|
||||
|
||||
stringMinimumLengthLabel: 'Minimum Length',
|
||||
stringMinimumLengthPlaceholder: 'No minimum',
|
||||
stringMaximumLengthLabel: 'Maximum Length',
|
||||
stringMaximumLengthPlaceholder: 'No maximum',
|
||||
stringPatternLabel: 'Pattern (regex)',
|
||||
stringPatternPlaceholder: '^[a-zA-Z]+$',
|
||||
stringFormatLabel: 'Format',
|
||||
stringFormatNone: 'None',
|
||||
stringFormatDateTime: 'Date-Time',
|
||||
stringFormatDate: 'Date',
|
||||
stringFormatTime: 'Time',
|
||||
stringFormatEmail: 'Email',
|
||||
stringFormatUri: 'URI',
|
||||
stringFormatUuid: 'UUID',
|
||||
stringFormatHostname: 'Hostname',
|
||||
stringFormatIpv4: 'IPv4 Address',
|
||||
stringFormatIpv6: 'IPv6 Address',
|
||||
stringAllowedValuesEnumLabel: 'Allowed Values (enum)',
|
||||
stringAllowedValuesEnumNone: 'No restricted values set',
|
||||
stringAllowedValuesEnumAddPlaceholder: 'Add allowed value...',
|
||||
stringValidationErrorLengthRange:
|
||||
"'Minimum Length' cannot be greater than 'Maximum Length'.",
|
||||
|
||||
schemaTypeArray: 'List',
|
||||
schemaTypeBoolean: 'Yes/No',
|
||||
schemaTypeNumber: 'Number',
|
||||
schemaTypeObject: 'Object',
|
||||
schemaTypeString: 'Text',
|
||||
schemaTypeNull: 'Empty',
|
||||
|
||||
inferrerTitle: 'Infer JSON Schema',
|
||||
inferrerDescription:
|
||||
'Paste your JSON document below to generate a schema from it.',
|
||||
inferrerCancel: 'Cancel',
|
||||
inferrerGenerate: 'Generate Schema',
|
||||
inferrerErrorInvalidJson: 'Invalid JSON format. Please check your input.',
|
||||
|
||||
validatorTitle: 'Validate JSON',
|
||||
validatorDescription:
|
||||
'Paste your JSON document to validate against the current schema. Validation occurs automatically as you type.',
|
||||
validatorCurrentSchema: 'Current Schema:',
|
||||
validatorContent: 'Your JSON:',
|
||||
validatorValid: 'JSON is valid according to the schema!',
|
||||
validatorErrorInvalidSyntax: 'Invalid JSON syntax',
|
||||
validatorErrorSchemaValidation: 'Schema validation error',
|
||||
validatorErrorCount: '{count} validation errors detected',
|
||||
validatorErrorPathRoot: 'Root',
|
||||
validatorErrorLocationLineAndColumn: 'Line {line}, Col {column}',
|
||||
validatorErrorLocationLineOnly: 'Line {line}',
|
||||
|
||||
visualizerDownloadTitle: 'Download Schema',
|
||||
visualizerDownloadFileName: 'schema.json',
|
||||
visualizerSource: 'JSON Schema Source',
|
||||
|
||||
visualEditorNoFieldsHint1: 'No fields defined yet',
|
||||
visualEditorNoFieldsHint2: 'Add your first field to get started',
|
||||
|
||||
typeValidationErrorNegativeLength: 'Length values cannot be negative.',
|
||||
typeValidationErrorIntValue: 'Value must be an integer.',
|
||||
typeValidationErrorPositive: 'Value must be positive.',
|
||||
};
|
||||
157
web/src/components/jsonjoy-builder/i18n/locales/fr.ts
Normal file
157
web/src/components/jsonjoy-builder/i18n/locales/fr.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import type { Translation } from '../translation-keys.ts';
|
||||
|
||||
export const fr: Translation = {
|
||||
collapse: 'Réduire',
|
||||
expand: 'Étendre',
|
||||
|
||||
fieldDescriptionPlaceholder: 'Décrivez le but de ce champ',
|
||||
fieldDelete: 'Supprimer le champ',
|
||||
fieldDescription: 'Description',
|
||||
fieldDescriptionTooltip: 'Ajoutez du contexte sur ce que ce champ représente',
|
||||
fieldNameLabel: 'Nom du champ',
|
||||
fieldNamePlaceholder: 'ex. prenom, age, estActif',
|
||||
fieldNameTooltip:
|
||||
'Utilisez camelCase pour une meilleure lisibilité (ex. prenom)',
|
||||
fieldRequiredLabel: 'Champ obligatoire',
|
||||
fieldType: 'Type de champ',
|
||||
fieldTypeExample: 'Exemple:',
|
||||
fieldTypeTooltipString: 'chaîne: Texte',
|
||||
fieldTypeTooltipNumber: 'nombre: Numérique',
|
||||
fieldTypeTooltipBoolean: 'booléen: Vrai/faux',
|
||||
fieldTypeTooltipObject: 'objet: JSON imbriqué',
|
||||
fieldTypeTooltipArray: 'tableau: Listes de valeurs',
|
||||
fieldAddNewButton: 'Ajouter un champ',
|
||||
fieldAddNewBadge: 'Constructeur de schéma',
|
||||
fieldAddNewCancel: 'Annuler',
|
||||
fieldAddNewConfirm: 'Ajouter un champ',
|
||||
fieldAddNewDescription: 'Créez un nouveau champ pour votre schéma JSON',
|
||||
fieldAddNewLabel: 'Ajouter un nouveau champ',
|
||||
|
||||
fieldTypeTextLabel: 'Texte',
|
||||
fieldTypeTextDescription:
|
||||
'Pour les valeurs textuelles comme les noms, descriptions, etc.',
|
||||
fieldTypeNumberLabel: 'Nombre',
|
||||
fieldTypeNumberDescription: 'Pour les nombres décimaux ou entiers',
|
||||
fieldTypeBooleanLabel: 'Oui/Non',
|
||||
fieldTypeBooleanDescription: 'Pour les valeurs vrai/faux',
|
||||
fieldTypeObjectLabel: 'Groupe',
|
||||
fieldTypeObjectDescription: 'Pour regrouper des champs connexes',
|
||||
fieldTypeArrayLabel: 'Liste',
|
||||
fieldTypeArrayDescription: "Pour les collections d'éléments",
|
||||
|
||||
propertyDescriptionPlaceholder: 'Ajouter une description...',
|
||||
propertyDescriptionButton: 'Ajouter une description...',
|
||||
propertyRequired: 'Obligatoire',
|
||||
propertyOptional: 'Facultatif',
|
||||
propertyDelete: 'Supprimer le champ',
|
||||
|
||||
schemaEditorTitle: 'Éditeur de schéma JSON',
|
||||
schemaEditorToggleFullscreen: 'Basculer en plein écran',
|
||||
schemaEditorEditModeVisual: 'Visuel',
|
||||
schemaEditorEditModeJson: 'JSON',
|
||||
|
||||
arrayMinimumLabel: 'Éléments minimum',
|
||||
arrayMinimumPlaceholder: 'Pas de minimum',
|
||||
arrayMaximumLabel: 'Éléments maximum',
|
||||
arrayMaximumPlaceholder: 'Pas de maximum',
|
||||
arrayForceUniqueItemsLabel: 'Forcer les éléments uniques',
|
||||
arrayItemTypeLabel: "Type d'élément",
|
||||
arrayValidationErrorMinMax:
|
||||
"'minItems' ne peut pas être supérieur à 'maxItems'.",
|
||||
arrayValidationErrorContainsMinMax:
|
||||
"'minContains' ne peut pas être supérieur à 'maxContains'.",
|
||||
|
||||
booleanAllowFalseLabel: 'Autoriser la valeur faux',
|
||||
booleanAllowTrueLabel: 'Autoriser la valeur vrai',
|
||||
booleanNeitherWarning:
|
||||
'Avertissement: Vous devez autoriser au moins une valeur.',
|
||||
|
||||
numberMinimumLabel: 'Valeur minimale',
|
||||
numberMinimumPlaceholder: 'Pas de minimum',
|
||||
numberMaximumLabel: 'Valeur maximale',
|
||||
numberMaximumPlaceholder: 'Pas de maximum',
|
||||
numberExclusiveMinimumLabel: 'Minimum exclusif',
|
||||
numberExclusiveMinimumPlaceholder: 'Pas de min exclusif',
|
||||
numberExclusiveMaximumLabel: 'Maximum exclusif',
|
||||
numberExclusiveMaximumPlaceholder: 'Pas de max exclusif',
|
||||
numberMultipleOfLabel: 'Multiple de',
|
||||
numberMultipleOfPlaceholder: 'Quelconque',
|
||||
numberAllowedValuesEnumLabel: 'Valeurs autorisées (enum)',
|
||||
numberAllowedValuesEnumNone: 'Aucune valeur restreinte définie',
|
||||
numberAllowedValuesEnumAddLabel: 'Ajouter',
|
||||
numberAllowedValuesEnumAddPlaceholder: 'Ajouter une valeur autorisée...',
|
||||
numberValidationErrorMinMax: 'Minimum et maximum doivent être cohérents.',
|
||||
numberValidationErrorBothExclusiveAndInclusiveMin:
|
||||
"Les champs 'exclusiveMinimum' et 'minimum' ne peuvent pas être définis en même temps.",
|
||||
numberValidationErrorBothExclusiveAndInclusiveMax:
|
||||
"Les champs 'exclusiveMaximum' et 'maximum' ne peuvent pas être définis en même temps.",
|
||||
numberValidationErrorEnumOutOfRange:
|
||||
"Les valeurs d'énumération doivent être dans la plage définie.",
|
||||
|
||||
objectPropertiesNone: 'Aucune propriété définie',
|
||||
objectValidationErrorMinMax:
|
||||
"'minProperties' ne peut pas être supérieur à 'maxProperties'.",
|
||||
|
||||
stringMinimumLengthLabel: 'Longueur minimale',
|
||||
stringMinimumLengthPlaceholder: 'Pas de minimum',
|
||||
stringMaximumLengthLabel: 'Longueur maximale',
|
||||
stringMaximumLengthPlaceholder: 'Pas de maximum',
|
||||
stringPatternLabel: 'Motif (regex)',
|
||||
stringPatternPlaceholder: '^[a-zA-Z]+$',
|
||||
stringFormatLabel: 'Format',
|
||||
stringFormatNone: 'Aucun',
|
||||
stringFormatDateTime: 'Date-Heure',
|
||||
stringFormatDate: 'Date',
|
||||
stringFormatTime: 'Heure',
|
||||
stringFormatEmail: 'Email',
|
||||
stringFormatUri: 'URI',
|
||||
stringFormatUuid: 'UUID',
|
||||
stringFormatHostname: "Nom d'hôte",
|
||||
stringFormatIpv4: 'Adresse IPv4',
|
||||
stringFormatIpv6: 'Adresse IPv6',
|
||||
stringAllowedValuesEnumLabel: 'Valeurs autorisées (enum)',
|
||||
stringAllowedValuesEnumNone: 'Aucune valeur restreinte définie',
|
||||
stringAllowedValuesEnumAddPlaceholder: 'Ajouter une valeur autorisée...',
|
||||
stringValidationErrorLengthRange:
|
||||
"'Longueur minimale' ne peut pas être supérieure à 'Longueur maximale'.",
|
||||
|
||||
schemaTypeArray: 'Liste',
|
||||
schemaTypeBoolean: 'Oui/Non',
|
||||
schemaTypeNumber: 'Nombre',
|
||||
schemaTypeObject: 'Objet',
|
||||
schemaTypeString: 'Texte',
|
||||
schemaTypeNull: 'Vide',
|
||||
|
||||
inferrerTitle: 'Déduire le schéma JSON',
|
||||
inferrerDescription:
|
||||
'Collez votre document JSON ci-dessous pour en générer un schéma.',
|
||||
inferrerCancel: 'Annuler',
|
||||
inferrerGenerate: 'Générer le schéma',
|
||||
inferrerErrorInvalidJson:
|
||||
'Format JSON invalide. Veuillez vérifier votre saisie.',
|
||||
|
||||
validatorTitle: 'Valider le JSON',
|
||||
validatorDescription:
|
||||
'Collez votre document JSON pour le valider par rapport au schéma actuel. La validation se produit automatiquement pendant que vous tapez.',
|
||||
validatorCurrentSchema: 'Schéma actuel:',
|
||||
validatorContent: 'Votre JSON:',
|
||||
validatorValid: 'Le JSON est valide selon le schéma!',
|
||||
validatorErrorInvalidSyntax: 'Syntaxe JSON invalide',
|
||||
validatorErrorSchemaValidation: 'Erreur de validation du schéma',
|
||||
validatorErrorCount: '{count} erreurs de validation détectées',
|
||||
validatorErrorPathRoot: 'Élément racine',
|
||||
validatorErrorLocationLineAndColumn: 'Ligne {line}, Col {column}',
|
||||
validatorErrorLocationLineOnly: 'Ligne {line}',
|
||||
|
||||
visualizerDownloadTitle: 'Télécharger le schéma',
|
||||
visualizerDownloadFileName: 'schema.json',
|
||||
visualizerSource: 'Source du schéma JSON',
|
||||
|
||||
visualEditorNoFieldsHint1: 'Aucun champ défini pour le moment',
|
||||
visualEditorNoFieldsHint2: 'Ajoutez votre premier champ pour commencer',
|
||||
|
||||
typeValidationErrorNegativeLength:
|
||||
'Les valeurs de longueur ne peuvent pas être négatives.',
|
||||
typeValidationErrorIntValue: 'La valeur doit être un nombre entier.',
|
||||
typeValidationErrorPositive: 'La valeur doit être positive.',
|
||||
};
|
||||
156
web/src/components/jsonjoy-builder/i18n/locales/ru.ts
Normal file
156
web/src/components/jsonjoy-builder/i18n/locales/ru.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import type { Translation } from '../translation-keys.ts';
|
||||
|
||||
export const ru: Translation = {
|
||||
collapse: 'Свернуть',
|
||||
expand: 'Развернуть',
|
||||
|
||||
fieldDescriptionPlaceholder: 'Опишите назначение этого поля',
|
||||
fieldDelete: 'Удалить поле',
|
||||
fieldDescription: 'Описание',
|
||||
fieldDescriptionTooltip: 'Добавьте контекст о том, что представляет это поле',
|
||||
fieldNameLabel: 'Имя поля',
|
||||
fieldNamePlaceholder: 'например, имя, возраст, активен',
|
||||
fieldNameTooltip:
|
||||
'Используйте camelCase для лучшей читаемости (например, firstName)',
|
||||
fieldRequiredLabel: 'Обязательное поле',
|
||||
fieldType: 'Тип поля',
|
||||
fieldTypeExample: 'Пример:',
|
||||
fieldTypeTooltipString: 'строка: Текст',
|
||||
fieldTypeTooltipNumber: 'число: Числовое значение',
|
||||
fieldTypeTooltipBoolean: 'логическое: Истина/ложь',
|
||||
fieldTypeTooltipObject: 'объект: Вложенный JSON',
|
||||
fieldTypeTooltipArray: 'массив: Списки значений',
|
||||
fieldAddNewButton: 'Добавить поле',
|
||||
fieldAddNewBadge: 'Конструктор схем',
|
||||
fieldAddNewCancel: 'Отмена',
|
||||
fieldAddNewConfirm: 'Добавить поле',
|
||||
fieldAddNewDescription: 'Создайте новое поле для вашей схемы JSON',
|
||||
fieldAddNewLabel: 'Добавить новое поле',
|
||||
|
||||
fieldTypeTextLabel: 'Текст',
|
||||
fieldTypeTextDescription:
|
||||
'Для текстовых значений, таких как имена, описания и т.д.',
|
||||
fieldTypeNumberLabel: 'Число',
|
||||
fieldTypeNumberDescription: 'Для десятичных или целых чисел',
|
||||
fieldTypeBooleanLabel: 'Да/Нет',
|
||||
fieldTypeBooleanDescription: 'Для значений истина/ложь',
|
||||
fieldTypeObjectLabel: 'Группа',
|
||||
fieldTypeObjectDescription: 'Для группировки связанных полей вместе',
|
||||
fieldTypeArrayLabel: 'Список',
|
||||
fieldTypeArrayDescription: 'Для коллекций элементов',
|
||||
|
||||
propertyDescriptionPlaceholder: 'Добавить описание...',
|
||||
propertyDescriptionButton: 'Добавить описание...',
|
||||
propertyRequired: 'Обязательное',
|
||||
propertyOptional: 'Необязательное',
|
||||
propertyDelete: 'Удалить поле',
|
||||
|
||||
schemaEditorTitle: 'Редактор JSON схем',
|
||||
schemaEditorToggleFullscreen: 'Переключить полноэкранный режим',
|
||||
schemaEditorEditModeVisual: 'Визуальный',
|
||||
schemaEditorEditModeJson: 'JSON',
|
||||
|
||||
arrayMinimumLabel: 'Минимум элементов',
|
||||
arrayMinimumPlaceholder: 'Нет минимума',
|
||||
arrayMaximumLabel: 'Максимум элементов',
|
||||
arrayMaximumPlaceholder: 'Нет максимума',
|
||||
arrayForceUniqueItemsLabel: 'Требовать уникальные элементы',
|
||||
arrayItemTypeLabel: 'Тип элемента',
|
||||
arrayValidationErrorMinMax: "'minItems' не может быть больше 'maxItems'.",
|
||||
arrayValidationErrorContainsMinMax:
|
||||
"'minContains' не может быть больше 'maxContains'.",
|
||||
|
||||
booleanAllowFalseLabel: 'Разрешить значение ложь',
|
||||
booleanAllowTrueLabel: 'Разрешить значение истина',
|
||||
booleanNeitherWarning: 'Внимание: Вы должны разрешить хотя бы одно значение.',
|
||||
|
||||
numberMinimumLabel: 'Минимальное значение',
|
||||
numberMinimumPlaceholder: 'Нет минимума',
|
||||
numberMaximumLabel: 'Максимальное значение',
|
||||
numberMaximumPlaceholder: 'Нет максимума',
|
||||
numberExclusiveMinimumLabel: 'Исключающее минимальное',
|
||||
numberExclusiveMinimumPlaceholder: 'Нет исключающего минимума',
|
||||
numberExclusiveMaximumLabel: 'Исключающее максимальное',
|
||||
numberExclusiveMaximumPlaceholder: 'Нет исключающего максимума',
|
||||
numberMultipleOfLabel: 'Кратно',
|
||||
numberMultipleOfPlaceholder: 'Любое',
|
||||
numberAllowedValuesEnumLabel: 'Разрешенные значения (enum)',
|
||||
numberAllowedValuesEnumNone: 'Нет ограниченных значений',
|
||||
numberAllowedValuesEnumAddLabel: 'Добавить',
|
||||
numberAllowedValuesEnumAddPlaceholder: 'Добавить разрешенное значение...',
|
||||
numberValidationErrorMinMax:
|
||||
'Минимальное и максимальное значения должны быть согласованы.',
|
||||
numberValidationErrorBothExclusiveAndInclusiveMin:
|
||||
"Оба поля 'exclusiveMinimum' и 'minimum' не могут быть установлены одновременно.",
|
||||
numberValidationErrorBothExclusiveAndInclusiveMax:
|
||||
"Оба поля 'exclusiveMaximum' и 'maximum' не могут быть установлены одновременно.",
|
||||
numberValidationErrorEnumOutOfRange:
|
||||
'Значения перечисления должны быть в пределах определенного диапазона.',
|
||||
|
||||
objectPropertiesNone: 'Нет определенных свойств',
|
||||
objectValidationErrorMinMax:
|
||||
"'minProperties' не может быть больше 'maxProperties'.",
|
||||
|
||||
stringMinimumLengthLabel: 'Минимальная длина',
|
||||
stringMinimumLengthPlaceholder: 'Нет минимума',
|
||||
stringMaximumLengthLabel: 'Максимальная длина',
|
||||
stringMaximumLengthPlaceholder: 'Нет максимума',
|
||||
stringPatternLabel: 'Шаблон (regex)',
|
||||
stringPatternPlaceholder: '^[a-zA-Z]+$',
|
||||
stringFormatLabel: 'Формат',
|
||||
stringFormatNone: 'Нет',
|
||||
stringFormatDateTime: 'Дата-Время',
|
||||
stringFormatDate: 'Дата',
|
||||
stringFormatTime: 'Время',
|
||||
stringFormatEmail: 'Email',
|
||||
stringFormatUri: 'URI',
|
||||
stringFormatUuid: 'UUID',
|
||||
stringFormatHostname: 'Имя хоста',
|
||||
stringFormatIpv4: 'Адрес IPv4',
|
||||
stringFormatIpv6: 'Адрес IPv6',
|
||||
stringAllowedValuesEnumLabel: 'Разрешенные значения (enum)',
|
||||
stringAllowedValuesEnumNone: 'Нет ограниченных значений',
|
||||
stringAllowedValuesEnumAddPlaceholder: 'Добавить разрешенное значение...',
|
||||
stringValidationErrorLengthRange:
|
||||
"'Минимальная длина' не может быть больше 'Максимальной длины'.",
|
||||
|
||||
schemaTypeArray: 'Список',
|
||||
schemaTypeBoolean: 'Да/Нет',
|
||||
schemaTypeNumber: 'Число',
|
||||
schemaTypeObject: 'Объект',
|
||||
schemaTypeString: 'Текст',
|
||||
schemaTypeNull: 'Пусто',
|
||||
|
||||
inferrerTitle: 'Вывести схему JSON',
|
||||
inferrerDescription:
|
||||
'Вставьте ваш документ JSON ниже, чтобы сгенерировать из него схему.',
|
||||
inferrerCancel: 'Отмена',
|
||||
inferrerGenerate: 'Сгенерировать схему',
|
||||
inferrerErrorInvalidJson:
|
||||
'Неверный формат JSON. Пожалуйста, проверьте ваши данные.',
|
||||
|
||||
validatorTitle: 'Проверить JSON',
|
||||
validatorDescription:
|
||||
'Вставьте ваш документ JSON для проверки по текущей схеме. Проверка происходит автоматически по мере ввода.',
|
||||
validatorCurrentSchema: 'Текущая схема:',
|
||||
validatorContent: 'Ваш JSON:',
|
||||
validatorValid: 'JSON действителен в соответствии со схемой!',
|
||||
validatorErrorInvalidSyntax: 'Неверный синтаксис JSON',
|
||||
validatorErrorSchemaValidation: 'Ошибка проверки схемы',
|
||||
validatorErrorCount: 'Обнаружено ошибок проверки: {count}',
|
||||
validatorErrorPathRoot: 'Корень',
|
||||
validatorErrorLocationLineAndColumn: 'Строка {line}, столбец {column}',
|
||||
validatorErrorLocationLineOnly: 'Строка {line}',
|
||||
|
||||
visualizerDownloadTitle: 'Скачать схему',
|
||||
visualizerDownloadFileName: 'schema.json',
|
||||
visualizerSource: 'Источник схемы JSON',
|
||||
|
||||
visualEditorNoFieldsHint1: 'Пока не определено ни одного поля',
|
||||
visualEditorNoFieldsHint2: 'Добавьте ваше первое поле, чтобы начать',
|
||||
|
||||
typeValidationErrorNegativeLength:
|
||||
'Значения длины не могут быть отрицательными.',
|
||||
typeValidationErrorIntValue: 'Значение должно быть целым числом.',
|
||||
typeValidationErrorPositive: 'Значение должно быть положительным.',
|
||||
};
|
||||
@ -0,0 +1,5 @@
|
||||
import { createContext } from 'react';
|
||||
import { en } from './locales/en';
|
||||
import type { Translation } from './translation-keys.ts';
|
||||
|
||||
export const TranslationContext = createContext<Translation>(en);
|
||||
762
web/src/components/jsonjoy-builder/i18n/translation-keys.ts
Normal file
762
web/src/components/jsonjoy-builder/i18n/translation-keys.ts
Normal file
@ -0,0 +1,762 @@
|
||||
export interface Translation {
|
||||
/**
|
||||
* The translation for the key `collapse`. English default is:
|
||||
*
|
||||
* > Collapse
|
||||
*/
|
||||
readonly collapse: string;
|
||||
/**
|
||||
* The translation for the key `expand`. English default is:
|
||||
*
|
||||
* > Expand
|
||||
*/
|
||||
readonly expand: string;
|
||||
|
||||
/**
|
||||
* The translation for the key `fieldDelete`. English default is:
|
||||
*
|
||||
* > Delete field
|
||||
*/
|
||||
readonly fieldDelete: string;
|
||||
/**
|
||||
* The translation for the key `fieldDescriptionPlaceholder`. English default is:
|
||||
*
|
||||
* > Describe the purpose of this field
|
||||
*/
|
||||
readonly fieldDescriptionPlaceholder: string;
|
||||
/**
|
||||
* The translation for the key `fieldNamePlaceholder`. English default is:
|
||||
*
|
||||
* > e.g. firstName, age, isActive
|
||||
*/
|
||||
readonly fieldNamePlaceholder: string;
|
||||
/**
|
||||
* The translation for the key `fieldNameLabel`. English default is:
|
||||
*
|
||||
* > Field Name
|
||||
*/
|
||||
readonly fieldNameLabel: string;
|
||||
/**
|
||||
* The translation for the key `fieldNameTooltip`. English default is:
|
||||
*
|
||||
* > Use camelCase for better readability (e.g., firstName)
|
||||
*/
|
||||
readonly fieldNameTooltip: string;
|
||||
/**
|
||||
* The translation for the key `fieldRequiredLabel`. English default is:
|
||||
*
|
||||
* > Required Field
|
||||
*/
|
||||
readonly fieldRequiredLabel: string;
|
||||
/**
|
||||
* The translation for the key `fieldDescription`. English default is:
|
||||
*
|
||||
* > Description
|
||||
*/
|
||||
readonly fieldDescription: string;
|
||||
/**
|
||||
* The translation for the key `fieldDescriptionTooltip`. English default is:
|
||||
*
|
||||
* > Add context about what this field represents
|
||||
*/
|
||||
readonly fieldDescriptionTooltip: string;
|
||||
/**
|
||||
* The translation for the key `fieldType`. English default is:
|
||||
*
|
||||
* > Field Type
|
||||
*/
|
||||
readonly fieldType: string;
|
||||
/**
|
||||
* The translation for the key `fieldTypeExample`. English default is:
|
||||
*
|
||||
* > Example:
|
||||
*/
|
||||
readonly fieldTypeExample: string;
|
||||
/**
|
||||
* The translation for the key `fieldTypeTooltipString`. English default is:
|
||||
*
|
||||
* > string: Text
|
||||
*/
|
||||
readonly fieldTypeTooltipString: string;
|
||||
/**
|
||||
* The translation for the key `fieldTypeTooltipNumber`. English default is:
|
||||
*
|
||||
* > number: Numeric
|
||||
*/
|
||||
readonly fieldTypeTooltipNumber: string;
|
||||
/**
|
||||
* The translation for the key `fieldTypeTooltipBoolean`. English default is:
|
||||
*
|
||||
* > boolean: True/false
|
||||
*/
|
||||
readonly fieldTypeTooltipBoolean: string;
|
||||
/**
|
||||
* The translation for the key `fieldTypeTooltipObject`. English default is:
|
||||
*
|
||||
* > object: Nested JSON
|
||||
*/
|
||||
readonly fieldTypeTooltipObject: string;
|
||||
/**
|
||||
* The translation for the key `fieldTypeTooltipArray`. English default is:
|
||||
*
|
||||
* > array: Lists of values
|
||||
*/
|
||||
readonly fieldTypeTooltipArray: string;
|
||||
|
||||
/**
|
||||
* The translation for the key `fieldAddNewButton`. English default is:
|
||||
*
|
||||
* > Add Field
|
||||
*/
|
||||
readonly fieldAddNewButton: string;
|
||||
/**
|
||||
* The translation for the key `fieldAddNewLabel`. English default is:
|
||||
*
|
||||
* > Add New Field
|
||||
*/
|
||||
readonly fieldAddNewLabel: string;
|
||||
/**
|
||||
* The translation for the key `fieldAddNewDescription`. English default is:
|
||||
*
|
||||
* > Create a new field for your JSON schema
|
||||
*/
|
||||
readonly fieldAddNewDescription: string;
|
||||
/**
|
||||
* The translation for the key `fieldAddNewBadge`. English default is:
|
||||
*
|
||||
* > Schema Builder
|
||||
*/
|
||||
readonly fieldAddNewBadge: string;
|
||||
/**
|
||||
* The translation for the key `fieldAddNewCancel`. English default is:
|
||||
*
|
||||
* > Cancel
|
||||
*/
|
||||
readonly fieldAddNewCancel: string;
|
||||
/**
|
||||
* The translation for the key `fieldAddNewConfirm`. English default is:
|
||||
*
|
||||
* > Add Field
|
||||
*/
|
||||
readonly fieldAddNewConfirm: string;
|
||||
|
||||
/**
|
||||
* The translation for the key `fieldTypeTextLabel`. English default is:
|
||||
*
|
||||
* > Text
|
||||
*/
|
||||
readonly fieldTypeTextLabel: string;
|
||||
/**
|
||||
* The translation for the key `fieldTypeTextDescription`. English default is:
|
||||
*
|
||||
* > For text values like names, descriptions, etc.
|
||||
*/
|
||||
readonly fieldTypeTextDescription: string;
|
||||
/**
|
||||
* The translation for the key `fieldTypeNumberLabel`. English default is:
|
||||
*
|
||||
* > Number
|
||||
*/
|
||||
readonly fieldTypeNumberLabel: string;
|
||||
/**
|
||||
* The translation for the key `fieldTypeNumberDescription`. English default is:
|
||||
*
|
||||
* > For decimal or whole numbers
|
||||
*/
|
||||
readonly fieldTypeNumberDescription: string;
|
||||
/**
|
||||
* The translation for the key `fieldTypeBooleanLabel`. English default is:
|
||||
*
|
||||
* > Yes/No
|
||||
*/
|
||||
readonly fieldTypeBooleanLabel: string;
|
||||
/**
|
||||
* The translation for the key `fieldTypeBooleanDescription`. English default is:
|
||||
*
|
||||
* > For true/false values
|
||||
*/
|
||||
readonly fieldTypeBooleanDescription: string;
|
||||
/**
|
||||
* The translation for the key `fieldTypeObjectLabel`. English default is:
|
||||
*
|
||||
* > Group
|
||||
*/
|
||||
readonly fieldTypeObjectLabel: string;
|
||||
/**
|
||||
* The translation for the key `fieldTypeObjectDescription`. English default is:
|
||||
*
|
||||
* > For grouping related fields together
|
||||
*/
|
||||
readonly fieldTypeObjectDescription: string;
|
||||
/**
|
||||
* The translation for the key `fieldTypeArrayLabel`. English default is:
|
||||
*
|
||||
* > List
|
||||
*/
|
||||
readonly fieldTypeArrayLabel: string;
|
||||
/**
|
||||
* The translation for the key `fieldTypeArrayDescription`. English default is:
|
||||
*
|
||||
* > For collections of items
|
||||
*/
|
||||
readonly fieldTypeArrayDescription: string;
|
||||
|
||||
/**
|
||||
* The translation for the key `propertyDescriptionPlaceholder`. English default is:
|
||||
*
|
||||
* > Add description...
|
||||
*/
|
||||
readonly propertyDescriptionPlaceholder: string;
|
||||
/**
|
||||
* The translation for the key `propertyDescriptionButton`. English default is:
|
||||
*
|
||||
* > Add description...
|
||||
*/
|
||||
readonly propertyDescriptionButton: string;
|
||||
/**
|
||||
* The translation for the key `propertyRequired`. English default is:
|
||||
*
|
||||
* > Required
|
||||
*/
|
||||
readonly propertyRequired: string;
|
||||
/**
|
||||
* The translation for the key `propertyOptional`. English default is:
|
||||
*
|
||||
* > Optional
|
||||
*/
|
||||
readonly propertyOptional: string;
|
||||
/**
|
||||
* The translation for the key `propertyDelete`. English default is:
|
||||
*
|
||||
* > Delete field
|
||||
*/
|
||||
readonly propertyDelete: string;
|
||||
|
||||
/**
|
||||
* The translation for the key `arrayMinimumLabel`. English default is:
|
||||
*
|
||||
* > Minimum Items
|
||||
*/
|
||||
readonly arrayMinimumLabel: string;
|
||||
/**
|
||||
* The translation for the key `arrayMinimumPlaceholder`. English default is:
|
||||
*
|
||||
* > No minimum
|
||||
*/
|
||||
readonly arrayMinimumPlaceholder: string;
|
||||
/**
|
||||
* The translation for the key `arrayMaximumLabel`. English default is:
|
||||
*
|
||||
* > Maximum Items
|
||||
*/
|
||||
readonly arrayMaximumLabel: string;
|
||||
/**
|
||||
* The translation for the key `arrayMaximumPlaceholder`. English default is:
|
||||
*
|
||||
* > No maximum
|
||||
*/
|
||||
readonly arrayMaximumPlaceholder: string;
|
||||
/**
|
||||
* The translation for the key `arrayForceUniqueItemsLabel`. English default is:
|
||||
*
|
||||
* > Force unique items
|
||||
*/
|
||||
readonly arrayForceUniqueItemsLabel: string;
|
||||
/**
|
||||
* The translation for the key `arrayItemTypeLabel`. English default is:
|
||||
*
|
||||
* > Item Type
|
||||
*/
|
||||
readonly arrayItemTypeLabel: string;
|
||||
/**
|
||||
* The translation for the key `arrayValidationErrorMinMax`. English default is:
|
||||
*
|
||||
* > 'minItems' cannot be greater than 'maxItems'.
|
||||
*/
|
||||
readonly arrayValidationErrorMinMax: string;
|
||||
/**
|
||||
* The translation for the key `arrayValidationErrorContainsMinMax`. English default is:
|
||||
*
|
||||
* > 'minContains' cannot be greater than 'maxContains'.
|
||||
*/
|
||||
readonly arrayValidationErrorContainsMinMax: string;
|
||||
|
||||
/**
|
||||
* The translation for the key `booleanAllowTrueLabel`. English default is:
|
||||
*
|
||||
* > Allow true value
|
||||
*/
|
||||
readonly booleanAllowTrueLabel: string;
|
||||
/**
|
||||
* The translation for the key `booleanAllowFalseLabel`. English default is:
|
||||
*
|
||||
* > Allow false value
|
||||
*/
|
||||
readonly booleanAllowFalseLabel: string;
|
||||
/**
|
||||
* The translation for the key `booleanNeitherWarning`. English default is:
|
||||
*
|
||||
* > Warning: You must allow at least one value.
|
||||
*/
|
||||
readonly booleanNeitherWarning: string;
|
||||
|
||||
/**
|
||||
* The translation for the key `numberMinimumLabel`. English default is:
|
||||
*
|
||||
* > Minimum Value
|
||||
*/
|
||||
readonly numberMinimumLabel: string;
|
||||
/**
|
||||
* The translation for the key `numberMinimumPlaceholder`. English default is:
|
||||
*
|
||||
* > No minimum
|
||||
*/
|
||||
readonly numberMinimumPlaceholder: string;
|
||||
/**
|
||||
* The translation for the key `numberMaximumLabel`. English default is:
|
||||
*
|
||||
* > Maximum Value
|
||||
*/
|
||||
readonly numberMaximumLabel: string;
|
||||
/**
|
||||
* The translation for the key `numberMaximumPlaceholder`. English default is:
|
||||
*
|
||||
* > No maximum
|
||||
*/
|
||||
readonly numberMaximumPlaceholder: string;
|
||||
/**
|
||||
* The translation for the key `numberExclusiveMinimumLabel`. English default is:
|
||||
*
|
||||
* > Exclusive Minimum
|
||||
*/
|
||||
readonly numberExclusiveMinimumLabel: string;
|
||||
/**
|
||||
* The translation for the key `numberExclusiveMinimumPlaceholder`. English default is:
|
||||
*
|
||||
* > No exclusive min
|
||||
*/
|
||||
readonly numberExclusiveMinimumPlaceholder: string;
|
||||
/**
|
||||
* The translation for the key `numberExclusiveMaximumLabel`. English default is:
|
||||
*
|
||||
* > Exclusive Maximum
|
||||
*/
|
||||
readonly numberExclusiveMaximumLabel: string;
|
||||
/**
|
||||
* The translation for the key `numberExclusiveMaximumPlaceholder`. English default is:
|
||||
*
|
||||
* > No exclusive max
|
||||
*/
|
||||
readonly numberExclusiveMaximumPlaceholder: string;
|
||||
/**
|
||||
* The translation for the key `numberMultipleOfLabel`. English default is:
|
||||
*
|
||||
* > Multiple Of
|
||||
*/
|
||||
readonly numberMultipleOfLabel: string;
|
||||
/**
|
||||
* The translation for the key `numberMultipleOfPlaceholder`. English default is:
|
||||
*
|
||||
* > Any
|
||||
*/
|
||||
readonly numberMultipleOfPlaceholder: string;
|
||||
/**
|
||||
* The translation for the key `numberAllowedValuesEnumLabel`. English default is:
|
||||
*
|
||||
* > Allowed Values (enum)
|
||||
*/
|
||||
readonly numberAllowedValuesEnumLabel: string;
|
||||
/**
|
||||
* The translation for the key `numberAllowedValuesEnumNone`. English default is:
|
||||
*
|
||||
* > No restricted values set
|
||||
*/
|
||||
readonly numberAllowedValuesEnumNone: string;
|
||||
/**
|
||||
* The translation for the key `numberAllowedValuesEnumAddPlaceholder`. English default is:
|
||||
*
|
||||
* > Add allowed value...
|
||||
*/
|
||||
readonly numberAllowedValuesEnumAddPlaceholder: string;
|
||||
/**
|
||||
* The translation for the key `numberAllowedValuesEnumAddLabel`. English default is:
|
||||
*
|
||||
* > Add
|
||||
*/
|
||||
readonly numberAllowedValuesEnumAddLabel: string;
|
||||
/**
|
||||
* The translation for the key `numberValidationErrorExclusiveMinMax`. English default is:
|
||||
*
|
||||
* > Minimum and maximum values must be consistent.
|
||||
*/
|
||||
readonly numberValidationErrorMinMax: string;
|
||||
/**
|
||||
* The translation for the key `numberValidationErrorBothExclusiveAndInclusive`. English default is:
|
||||
*
|
||||
* > Both 'exclusiveMinimum' and 'minimum' cannot be set at the same time.
|
||||
*/
|
||||
readonly numberValidationErrorBothExclusiveAndInclusiveMin: string;
|
||||
/**
|
||||
* The translation for the key `numberValidationErrorBothExclusiveAndInclusiveMax`. English default is:
|
||||
*
|
||||
* > Both 'exclusiveMaximum' and 'maximum' cannot be set at the same time.
|
||||
*/
|
||||
readonly numberValidationErrorBothExclusiveAndInclusiveMax: string;
|
||||
/**
|
||||
* The translation for the key `numberValidationErrorEnumOutOfRange`. English default is:
|
||||
*
|
||||
* > Enum values must be within the defined range.
|
||||
*/
|
||||
readonly numberValidationErrorEnumOutOfRange: string;
|
||||
|
||||
/**
|
||||
* The translation for the key `objectPropertiesNone`. English default is:
|
||||
*
|
||||
* > No properties defined
|
||||
*/
|
||||
readonly objectPropertiesNone: string;
|
||||
/**
|
||||
* The translation for the key `objectValidationErrorMinMax`. English default is:
|
||||
*
|
||||
* > 'minProperties' cannot be greater than 'maxProperties'.
|
||||
*/
|
||||
readonly objectValidationErrorMinMax: string;
|
||||
|
||||
/**
|
||||
* The translation for the key `stringMinimumLengthLabel`. English default is:
|
||||
*
|
||||
* > Minimum Length
|
||||
*/
|
||||
readonly stringMinimumLengthLabel: string;
|
||||
/**
|
||||
* The translation for the key `stringMinimumLengthPlaceholder`. English default is:
|
||||
*
|
||||
* > No minimum
|
||||
*/
|
||||
readonly stringMinimumLengthPlaceholder: string;
|
||||
/**
|
||||
* The translation for the key `stringMaximumLengthLabel`. English default is:
|
||||
*
|
||||
* > Maximum Length
|
||||
*/
|
||||
readonly stringMaximumLengthLabel: string;
|
||||
/**
|
||||
* The translation for the key `stringMaximumLengthPlaceholder`. English default is:
|
||||
*
|
||||
* > No maximum
|
||||
*/
|
||||
readonly stringMaximumLengthPlaceholder: string;
|
||||
/**
|
||||
* The translation for the key `stringPatternLabel`. English default is:
|
||||
*
|
||||
* > Pattern (regex)
|
||||
*/
|
||||
readonly stringPatternLabel: string;
|
||||
/**
|
||||
* The translation for the key `stringPatternPlaceholder`. English default is:
|
||||
*
|
||||
* > ^[a-zA-Z]+$
|
||||
*/
|
||||
readonly stringPatternPlaceholder: string;
|
||||
/**
|
||||
* The translation for the key `stringFormatLabel`. English default is:
|
||||
*
|
||||
* > Format
|
||||
*/
|
||||
readonly stringFormatLabel: string;
|
||||
/**
|
||||
* The translation for the key `stringFormatNone`. English default is:
|
||||
*
|
||||
* > None
|
||||
*/
|
||||
readonly stringFormatNone: string;
|
||||
/**
|
||||
* The translation for the key `stringFormatDateTime`. English default is:
|
||||
*
|
||||
* > Date-Time
|
||||
*/
|
||||
readonly stringFormatDateTime: string;
|
||||
/**
|
||||
* The translation for the key `stringFormatDate`. English default is:
|
||||
*
|
||||
* > Date
|
||||
*/
|
||||
readonly stringFormatDate: string;
|
||||
/**
|
||||
* The translation for the key `stringFormatTime`. English default is:
|
||||
*
|
||||
* > Time
|
||||
*/
|
||||
readonly stringFormatTime: string;
|
||||
/**
|
||||
* The translation for the key `stringFormatEmail`. English default is:
|
||||
*
|
||||
* > Email
|
||||
*/
|
||||
readonly stringFormatEmail: string;
|
||||
/**
|
||||
* The translation for the key `stringFormatUri`. English default is:
|
||||
*
|
||||
* > URI
|
||||
*/
|
||||
readonly stringFormatUri: string;
|
||||
/**
|
||||
* The translation for the key `stringFormatUuid`. English default is:
|
||||
*
|
||||
* > UUID
|
||||
*/
|
||||
readonly stringFormatUuid: string;
|
||||
/**
|
||||
* The translation for the key `stringFormatHostname`. English default is:
|
||||
*
|
||||
* > Hostname
|
||||
*/
|
||||
readonly stringFormatHostname: string;
|
||||
/**
|
||||
* The translation for the key `stringFormatIpv4`. English default is:
|
||||
*
|
||||
* > IPv4 Address
|
||||
*/
|
||||
readonly stringFormatIpv4: string;
|
||||
/**
|
||||
* The translation for the key `stringFormatIpv6`. English default is:
|
||||
*
|
||||
* > IPv6 Address
|
||||
*/
|
||||
readonly stringFormatIpv6: string;
|
||||
/**
|
||||
* The translation for the key `stringAllowedValuesEnumLabel`. English default is:
|
||||
*
|
||||
* > Allowed Values (enum)
|
||||
*/
|
||||
readonly stringAllowedValuesEnumLabel: string;
|
||||
/**
|
||||
* The translation for the key `stringAllowedValuesEnumNone`. English default is:
|
||||
*
|
||||
* > No restricted values set
|
||||
*/
|
||||
readonly stringAllowedValuesEnumNone: string;
|
||||
/**
|
||||
* The translation for the key `stringAllowedValuesEnumAddPlaceholder`. English default is:
|
||||
*
|
||||
* > Add allowed value...
|
||||
*/
|
||||
readonly stringAllowedValuesEnumAddPlaceholder: string;
|
||||
/**
|
||||
* The translation for the key `stringValidationErrorMinLength`. English default is:
|
||||
*
|
||||
* > 'minLength' cannot be greater than 'maxLength'.
|
||||
*/
|
||||
readonly stringValidationErrorLengthRange: string;
|
||||
|
||||
/**
|
||||
* The translation for the key `schemaTypeString`. English default is:
|
||||
*
|
||||
* > Text
|
||||
*/
|
||||
readonly schemaTypeString: string;
|
||||
/**
|
||||
* The translation for the key `schemaTypeNumber`. English default is:
|
||||
*
|
||||
* > Number
|
||||
*/
|
||||
readonly schemaTypeNumber: string;
|
||||
/**
|
||||
* The translation for the key `schemaTypeBoolean`. English default is:
|
||||
*
|
||||
* > Yes/No
|
||||
*/
|
||||
readonly schemaTypeBoolean: string;
|
||||
/**
|
||||
* The translation for the key `schemaTypeObject`. English default is:
|
||||
*
|
||||
* > Object
|
||||
*/
|
||||
readonly schemaTypeObject: string;
|
||||
/**
|
||||
* The translation for the key `schemaTypeArray`. English default is:
|
||||
*
|
||||
* > List
|
||||
*/
|
||||
readonly schemaTypeArray: string;
|
||||
/**
|
||||
* The translation for the key `schemaTypeNull`. English default is:
|
||||
*
|
||||
* > Empty
|
||||
*/
|
||||
readonly schemaTypeNull: string;
|
||||
|
||||
/**
|
||||
* The translation for the key `schemaEditorTitle`. English default is:
|
||||
*
|
||||
* > JSON Schema Editor
|
||||
*/
|
||||
readonly schemaEditorTitle: string;
|
||||
/**
|
||||
* The translation for the key `schemaEditorToggleFullscreen`. English default is:
|
||||
*
|
||||
* > Toggle fullscreen
|
||||
*/
|
||||
readonly schemaEditorToggleFullscreen: string;
|
||||
/**
|
||||
* The translation for the key `schemaEditorEditModeVisual`. English default is:
|
||||
*
|
||||
* > Visual
|
||||
*/
|
||||
readonly schemaEditorEditModeVisual: string;
|
||||
/**
|
||||
* The translation for the key `schemaEditorEditModeJson`. English default is:
|
||||
*
|
||||
* > JSON
|
||||
*/
|
||||
readonly schemaEditorEditModeJson: string;
|
||||
|
||||
/**
|
||||
* The translation for the key `inferrerTitle`. English default is:
|
||||
*
|
||||
* > Infer JSON Schema
|
||||
*/
|
||||
readonly inferrerTitle: string;
|
||||
/**
|
||||
* The translation for the key `inferrerDescription`. English default is:
|
||||
*
|
||||
* > Paste your JSON document below to generate a schema from it.
|
||||
*/
|
||||
readonly inferrerDescription: string;
|
||||
/**
|
||||
* The translation for the key `inferrerGenerate`. English default is:
|
||||
*
|
||||
* > Generate Schema
|
||||
*/
|
||||
readonly inferrerGenerate: string;
|
||||
/**
|
||||
* The translation for the key `inferrerCancel`. English default is:
|
||||
*
|
||||
* > Cancel
|
||||
*/
|
||||
readonly inferrerCancel: string;
|
||||
/**
|
||||
* The translation for the key `inferrerErrorInvalidJson`. English default is:
|
||||
*
|
||||
* > Invalid JSON format. Please check your input.
|
||||
*/
|
||||
readonly inferrerErrorInvalidJson: string;
|
||||
|
||||
/**
|
||||
* The translation for the key `validatorTitle`. English default is:
|
||||
*
|
||||
* > Validate JSON
|
||||
*/
|
||||
readonly validatorTitle: string;
|
||||
/**
|
||||
* The translation for the key `validatorDescription`. English default is:
|
||||
*
|
||||
* > Paste your JSON document to validate against the current schema. Validation occurs automatically as you type.
|
||||
*/
|
||||
readonly validatorDescription: string;
|
||||
/**
|
||||
* The translation for the key `validatorCurrentSchema`. English default is:
|
||||
*
|
||||
* > Current Schema:
|
||||
*/
|
||||
readonly validatorCurrentSchema: string;
|
||||
/**
|
||||
* The translation for the key `validatorContent`. English default is:
|
||||
*
|
||||
* > Your JSON:
|
||||
*/
|
||||
readonly validatorContent: string;
|
||||
/**
|
||||
* The translation for the key `validatorValid`. English default is:
|
||||
*
|
||||
* > JSON is valid according to the schema!
|
||||
*/
|
||||
readonly validatorValid: string;
|
||||
/**
|
||||
* The translation for the key `validatorErrorInvalidSyntax`. English default is:
|
||||
*
|
||||
* > Invalid JSON syntax
|
||||
*/
|
||||
readonly validatorErrorInvalidSyntax: string;
|
||||
/**
|
||||
* The translation for the key `validatorErrorSchemaValidation`. English default is:
|
||||
*
|
||||
* > Schema validation error
|
||||
*/
|
||||
readonly validatorErrorSchemaValidation: string;
|
||||
/**
|
||||
* The translation for the key `validatorErrorCount`. English default is:
|
||||
*
|
||||
* > {count} validation errors detected
|
||||
*/
|
||||
readonly validatorErrorCount: string;
|
||||
/**
|
||||
* The translation for the key `validatorErrorPathRoot`. English default is:
|
||||
*
|
||||
* > Root
|
||||
*/
|
||||
readonly validatorErrorPathRoot: string;
|
||||
/**
|
||||
* The translation for the key `validatorErrorLocationLineAndColumn`. English default is:
|
||||
*
|
||||
* > Line {line}, Col {column}
|
||||
*/
|
||||
readonly validatorErrorLocationLineAndColumn: string;
|
||||
/**
|
||||
* The translation for the key `validatorErrorLocationLineOnly`. English default is:
|
||||
*
|
||||
* > Line {line}
|
||||
*/
|
||||
readonly validatorErrorLocationLineOnly: string;
|
||||
|
||||
/**
|
||||
* The translation for the key `visualizerDownloadTitle`. English default is:
|
||||
*
|
||||
* > Download Schema
|
||||
*/
|
||||
readonly visualizerDownloadTitle: string;
|
||||
/**
|
||||
* The translation for the key `visualizerDownloadFileName`. English default is:
|
||||
*
|
||||
* > schema.json
|
||||
*/
|
||||
readonly visualizerDownloadFileName: string;
|
||||
/**
|
||||
* The translation for the key `visualizerSource`. English default is:
|
||||
*
|
||||
* > JSON Schema Source
|
||||
*/
|
||||
readonly visualizerSource: string;
|
||||
|
||||
/**
|
||||
* The translation for the key `visualEditorNoFieldsHint1`. English default is:
|
||||
*
|
||||
* > No fields defined yet
|
||||
*/
|
||||
readonly visualEditorNoFieldsHint1: string;
|
||||
/**
|
||||
* The translation for the key `visualEditorNoFieldsHint2`. English default is:
|
||||
*
|
||||
* > Add your first field to get started
|
||||
*/
|
||||
readonly visualEditorNoFieldsHint2: string;
|
||||
|
||||
/**
|
||||
* The translation for the key `typeValidationErrorNegativeLength`. English default is:
|
||||
*
|
||||
* > Length values cannot be negative.
|
||||
*/
|
||||
readonly typeValidationErrorNegativeLength: string;
|
||||
/**
|
||||
* The translation for the key `typeValidationErrorIntValue`. English default is:
|
||||
*
|
||||
* > Value must be an integer.
|
||||
*/
|
||||
readonly typeValidationErrorIntValue: string;
|
||||
/**
|
||||
* The translation for the key `typeValidationErrorPositive`. English default is:
|
||||
*
|
||||
* > Value must be positive.
|
||||
*/
|
||||
readonly typeValidationErrorPositive: string;
|
||||
}
|
||||
280
web/src/components/jsonjoy-builder/index.css
Normal file
280
web/src/components/jsonjoy-builder/index.css
Normal file
@ -0,0 +1,280 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
--animate-fade-in: jsonjoy-fade-in 0.3s ease-out;
|
||||
--animate-fade-out: jsonjoy-fade-out 0.3s ease-out;
|
||||
--animate-scale-in: jsonjoy-scale-in 0.2s ease-out;
|
||||
--animate-scale-out: jsonjoy-scale-out 0.2s ease-out;
|
||||
--animate-float: jsonjoy-float 3s ease-in-out infinite;
|
||||
--animate-pulse-subtle: pulse-subtle 3s ease-in-out infinite;
|
||||
--animate-enter: jsonjoy-fade-in 0.4s ease-out, jsonjoy-scale-in 0.3s ease-out;
|
||||
--animate-exit: jsonjoy-fade-out 0.3s ease-out,
|
||||
jsonjoy-scale-out 0.2s ease-out;
|
||||
|
||||
--font-sans: var(--font-sans), system-ui, sans-serif;
|
||||
|
||||
--transition-property-height: height;
|
||||
--transition-property-spacing: margin, padding;
|
||||
|
||||
--ease-bounce-in: cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
@keyframes jsonjoy-fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes jsonjoy-fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
}
|
||||
@keyframes jsonjoy-scale-in {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes jsonjoy-scale-out {
|
||||
from {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes jsonjoy-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility container {
|
||||
margin-inline: auto;
|
||||
padding-inline: 2rem;
|
||||
@media (width >= --theme(--breakpoint-sm)) {
|
||||
max-width: none;
|
||||
}
|
||||
@media (width >= 1400px) {
|
||||
max-width: 1400px;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
@utility json-field-row {
|
||||
@apply flex items-center gap-2 py-2 px-3 rounded-md hover:bg-secondary/50 transition-colors;
|
||||
}
|
||||
|
||||
@utility json-field-label {
|
||||
@apply text-sm font-medium text-foreground/80;
|
||||
}
|
||||
|
||||
@utility json-editor-container {
|
||||
@apply bg-white backdrop-blur-md rounded-xl border border-border shadow-xs;
|
||||
}
|
||||
|
||||
@utility glass-panel {
|
||||
@apply bg-white/90 backdrop-blur-md rounded-xl border border-border shadow-xs;
|
||||
}
|
||||
|
||||
@utility animate-in {
|
||||
@apply animate-enter;
|
||||
}
|
||||
|
||||
@utility animate-out {
|
||||
@apply animate-exit;
|
||||
}
|
||||
|
||||
@utility field-button {
|
||||
@apply flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium bg-secondary hover:bg-secondary/80 text-secondary-foreground transition-colors;
|
||||
}
|
||||
|
||||
@utility hover-action {
|
||||
@apply opacity-0 group-hover:opacity-100 transition-opacity;
|
||||
}
|
||||
|
||||
@utility monaco-editor-container {
|
||||
@apply w-full h-full;
|
||||
|
||||
& > div {
|
||||
@apply h-full;
|
||||
}
|
||||
}
|
||||
|
||||
@utility monaco-editor {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
.jsonjoy {
|
||||
--background: hsl(210 40% 98%);
|
||||
--foreground: hsl(222.2 84% 4.9%);
|
||||
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(222.2 84% 4.9%);
|
||||
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(222.2 84% 4.9%);
|
||||
|
||||
--primary: hsl(210 100% 50%);
|
||||
--primary-foreground: hsl(210 40% 98%);
|
||||
|
||||
--secondary: hsl(210 40% 96.1%);
|
||||
--secondary-foreground: hsl(222.2 47.4% 11.2%);
|
||||
|
||||
--muted: hsl(210 40% 96.1%);
|
||||
--muted-foreground: hsl(215.4 16.3% 46.9%);
|
||||
|
||||
--accent: hsl(210 40% 96.1%);
|
||||
--accent-foreground: hsl(222.2 47.4% 11.2%);
|
||||
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: hsl(210 40% 98%);
|
||||
|
||||
--border: hsl(214.3 31.8% 91.4%);
|
||||
--input: hsl(214.3 31.8% 91.4%);
|
||||
--ring: hsl(222.2 84% 4.9%);
|
||||
|
||||
--radius: 0.8rem;
|
||||
|
||||
--font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont,
|
||||
'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.jsonjoy.dark {
|
||||
--background: hsl(222.2 84% 4.9%);
|
||||
--foreground: hsl(210 40% 98%);
|
||||
|
||||
--card: hsl(222.2 84% 4.9%);
|
||||
--card-foreground: hsl(210 40% 98%);
|
||||
|
||||
--popover: hsl(222.2 84% 4.9%);
|
||||
--popover-foreground: hsl(210 40% 98%);
|
||||
|
||||
--primary: hsl(210 100% 65%);
|
||||
--primary-foreground: hsl(222.2 47.4% 11.2%);
|
||||
|
||||
--secondary: hsl(217.2 32.6% 17.5%);
|
||||
--secondary-foreground: hsl(210 40% 98%);
|
||||
|
||||
--muted: hsl(217.2 32.6% 17.5%);
|
||||
--muted-foreground: hsl(215 20.2% 65.1%);
|
||||
|
||||
--accent: hsl(217.2 32.6% 17.5%);
|
||||
--accent-foreground: hsl(210 40% 98%);
|
||||
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(210 40% 98%);
|
||||
|
||||
--border: hsl(217.2 32.6% 17.5%);
|
||||
--input: hsl(217.2 32.6% 17.5%);
|
||||
--ring: hsl(212.7 26.8% 83.9%);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply font-medium tracking-tight;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
@apply focus-visible:outline-hidden;
|
||||
}
|
||||
}
|
||||
31
web/src/components/jsonjoy-builder/index.ts
Normal file
31
web/src/components/jsonjoy-builder/index.ts
Normal file
@ -0,0 +1,31 @@
|
||||
// https://github.com/lovasoa/jsonjoy-builder v0.1.0
|
||||
// exports for public API
|
||||
|
||||
import JsonSchemaEditor, {
|
||||
type JsonSchemaEditorProps,
|
||||
} from './components/SchemaEditor/JsonSchemaEditor';
|
||||
import JsonSchemaVisualizer, {
|
||||
type JsonSchemaVisualizerProps,
|
||||
} from './components/SchemaEditor/JsonSchemaVisualizer';
|
||||
import SchemaVisualEditor, {
|
||||
type SchemaVisualEditorProps,
|
||||
} from './components/SchemaEditor/SchemaVisualEditor';
|
||||
|
||||
export * from './i18n/locales/de';
|
||||
export * from './i18n/locales/en';
|
||||
export * from './i18n/translation-context';
|
||||
export * from './i18n/translation-keys';
|
||||
|
||||
export * from './components/features/JsonValidator';
|
||||
export * from './components/features/SchemaInferencer';
|
||||
|
||||
export {
|
||||
JsonSchemaEditor,
|
||||
JsonSchemaVisualizer,
|
||||
SchemaVisualEditor,
|
||||
type JsonSchemaEditorProps,
|
||||
type JsonSchemaVisualizerProps,
|
||||
type SchemaVisualEditorProps,
|
||||
};
|
||||
|
||||
export type { JSONSchema, baseSchema } from './types/jsonSchema.ts';
|
||||
388
web/src/components/jsonjoy-builder/lib/schema-inference.ts
Normal file
388
web/src/components/jsonjoy-builder/lib/schema-inference.ts
Normal file
@ -0,0 +1,388 @@
|
||||
import { asObjectSchema, type JSONSchema } from '../types/jsonSchema.ts';
|
||||
|
||||
/**
|
||||
* Merges two JSON schemas.
|
||||
* If schemas are compatible (e.g., integer and number), attempts to merge.
|
||||
* If schemas are identical, returns the first schema.
|
||||
* If schemas are incompatible, returns a schema with oneOf.
|
||||
*/
|
||||
function mergeSchemas(schema1: JSONSchema, schema2: JSONSchema): JSONSchema {
|
||||
const s1 = asObjectSchema(schema1);
|
||||
const s2 = asObjectSchema(schema2);
|
||||
|
||||
// Deep comparison for equality
|
||||
if (JSON.stringify(s1) === JSON.stringify(s2)) {
|
||||
return schema1;
|
||||
}
|
||||
|
||||
// Handle basic type merging (e.g., integer into number)
|
||||
if (s1.type === 'integer' && s2.type === 'number') return { type: 'number' };
|
||||
if (s1.type === 'number' && s2.type === 'integer') return { type: 'number' };
|
||||
|
||||
// If types are different or complex merging is needed, use oneOf
|
||||
const existingOneOf = Array.isArray(s1.oneOf) ? s1.oneOf : [s1];
|
||||
const newSchemaToAdd = s2;
|
||||
|
||||
// Avoid adding duplicate schemas to oneOf
|
||||
if (
|
||||
!existingOneOf.some(
|
||||
(s) => JSON.stringify(s) === JSON.stringify(newSchemaToAdd),
|
||||
)
|
||||
) {
|
||||
const mergedOneOf = [...existingOneOf, newSchemaToAdd];
|
||||
// Simplify oneOf if it contains only one unique schema after potential merge attempts
|
||||
const uniqueSchemas = [
|
||||
...new Map(mergedOneOf.map((s) => [JSON.stringify(s), s])).values(),
|
||||
];
|
||||
if (uniqueSchemas.length === 1) {
|
||||
return uniqueSchemas[0];
|
||||
}
|
||||
return { oneOf: uniqueSchemas };
|
||||
}
|
||||
|
||||
return s1.oneOf ? s1 : { oneOf: [s1] }; // Return existing oneOf or create new if only s1 existed
|
||||
}
|
||||
|
||||
// --- Helper Functions for Type Inference ---
|
||||
|
||||
function inferObjectSchema(obj: Record<string, unknown>): JSONSchema {
|
||||
const properties: Record<string, JSONSchema> = {};
|
||||
const required: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
properties[key] = inferSchema(value); // Recursive call
|
||||
if (value !== undefined && value !== null) {
|
||||
required.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
properties,
|
||||
required: required.length > 0 ? required.sort() : undefined, // Sort required keys
|
||||
};
|
||||
}
|
||||
|
||||
function detectEnumsInArrayItems(
|
||||
mergedProperties: Record<string, JSONSchema>,
|
||||
originalArray: Record<string, unknown>[],
|
||||
totalItems: number,
|
||||
): Record<string, JSONSchema> {
|
||||
if (totalItems < 10 || Object.keys(mergedProperties).length === 0) {
|
||||
return mergedProperties; // Not enough data or no properties to check
|
||||
}
|
||||
|
||||
const valueMap: Record<string, Set<string | number>> = {};
|
||||
|
||||
// Collect distinct values
|
||||
for (const item of originalArray) {
|
||||
for (const key in mergedProperties) {
|
||||
if (Object.prototype.hasOwnProperty.call(item, key)) {
|
||||
const value = item[key];
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
if (!valueMap[key]) valueMap[key] = new Set();
|
||||
valueMap[key].add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedProperties = { ...mergedProperties };
|
||||
// Update schema for properties that look like enums
|
||||
for (const key in valueMap) {
|
||||
const distinctValues = Array.from(valueMap[key]);
|
||||
if (
|
||||
distinctValues.length > 1 &&
|
||||
distinctValues.length <= 10 &&
|
||||
distinctValues.length < totalItems / 2
|
||||
) {
|
||||
const currentSchema = asObjectSchema(updatedProperties[key]);
|
||||
if (
|
||||
currentSchema.type === 'string' ||
|
||||
currentSchema.type === 'number' ||
|
||||
currentSchema.type === 'integer'
|
||||
) {
|
||||
updatedProperties[key] = {
|
||||
type: currentSchema.type,
|
||||
enum: distinctValues.sort(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return updatedProperties;
|
||||
}
|
||||
|
||||
function detectSemanticFormatsInArrayItems(
|
||||
mergedProperties: Record<string, JSONSchema>,
|
||||
originalArray: Record<string, unknown>[],
|
||||
): Record<string, JSONSchema> {
|
||||
const updatedProperties = { ...mergedProperties };
|
||||
|
||||
for (const key in updatedProperties) {
|
||||
const currentSchema = asObjectSchema(updatedProperties[key]);
|
||||
|
||||
// Coordinates Detection
|
||||
if (
|
||||
/coordinates?|coords?|latLon|lonLat|point/i.test(key) &&
|
||||
currentSchema.type === 'array'
|
||||
) {
|
||||
const itemsSchema = asObjectSchema(currentSchema.items);
|
||||
if (itemsSchema?.type === 'number' || itemsSchema?.type === 'integer') {
|
||||
let isValidCoordArray = true;
|
||||
let coordLength: number | null = null;
|
||||
for (const item of originalArray) {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(item, key) &&
|
||||
Array.isArray(item[key])
|
||||
) {
|
||||
const arr = item[key] as unknown[];
|
||||
if (coordLength === null) coordLength = arr.length;
|
||||
if (
|
||||
arr.length !== coordLength ||
|
||||
(arr.length !== 2 && arr.length !== 3) ||
|
||||
!arr.every((v) => typeof v === 'number')
|
||||
) {
|
||||
isValidCoordArray = false;
|
||||
break;
|
||||
}
|
||||
} else if (Object.prototype.hasOwnProperty.call(item, key)) {
|
||||
isValidCoordArray = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isValidCoordArray && coordLength !== null) {
|
||||
updatedProperties[key] = {
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
minItems: coordLength,
|
||||
maxItems: coordLength,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timestamp Detection
|
||||
if (
|
||||
/timestamp|createdAt|updatedAt|occurredAt/i.test(key) &&
|
||||
currentSchema.type === 'integer'
|
||||
) {
|
||||
let isTimestampLike = true;
|
||||
const now = Date.now();
|
||||
const fiftyYearsAgo = now - 50 * 365 * 24 * 60 * 60 * 1000;
|
||||
for (const item of originalArray) {
|
||||
if (Object.prototype.hasOwnProperty.call(item, key)) {
|
||||
const val = item[key];
|
||||
if (
|
||||
typeof val !== 'number' ||
|
||||
!Number.isInteger(val) ||
|
||||
val < fiftyYearsAgo
|
||||
) {
|
||||
isTimestampLike = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isTimestampLike) {
|
||||
updatedProperties[key] = {
|
||||
type: 'integer',
|
||||
format: 'unix-timestamp',
|
||||
description: 'Unix timestamp (likely milliseconds)',
|
||||
};
|
||||
}
|
||||
}
|
||||
// Add more semantic detections here
|
||||
}
|
||||
return updatedProperties;
|
||||
}
|
||||
|
||||
function processArrayOfObjects(
|
||||
itemSchemas: JSONSchema[],
|
||||
originalArray: Record<string, unknown>[],
|
||||
): JSONSchema {
|
||||
let mergedProperties: Record<string, JSONSchema> = {};
|
||||
const propertyCounts: Record<string, number> = {};
|
||||
const totalItems = itemSchemas.length;
|
||||
|
||||
for (const schema of itemSchemas) {
|
||||
const objSchema = asObjectSchema(schema);
|
||||
if (!objSchema.properties) continue;
|
||||
for (const [key, value] of Object.entries(objSchema.properties)) {
|
||||
propertyCounts[key] = (propertyCounts[key] || 0) + 1;
|
||||
if (key in mergedProperties) {
|
||||
mergedProperties[key] = mergeSchemas(mergedProperties[key], value);
|
||||
} else {
|
||||
mergedProperties[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requiredProps = Object.entries(propertyCounts)
|
||||
.filter(([_, count]) => count === totalItems)
|
||||
.map(([key, _]) => key);
|
||||
|
||||
// Apply Enum Detection
|
||||
mergedProperties = detectEnumsInArrayItems(
|
||||
mergedProperties,
|
||||
originalArray,
|
||||
totalItems,
|
||||
);
|
||||
|
||||
// Apply Semantic Detection
|
||||
mergedProperties = detectSemanticFormatsInArrayItems(
|
||||
mergedProperties,
|
||||
originalArray,
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
properties: mergedProperties,
|
||||
required: requiredProps.length > 0 ? requiredProps.sort() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function inferArraySchema(obj: unknown[]): JSONSchema {
|
||||
if (obj.length === 0) return { type: 'array', items: {} };
|
||||
|
||||
const itemSchemas = obj.map((item) => inferSchema(item)); // Recursive call
|
||||
|
||||
const firstItemSchema = asObjectSchema(itemSchemas[0]);
|
||||
const allSameType = itemSchemas.every(
|
||||
(schema) => asObjectSchema(schema).type === firstItemSchema.type,
|
||||
);
|
||||
|
||||
if (allSameType) {
|
||||
if (firstItemSchema.type === 'object') {
|
||||
const itemsSchema = processArrayOfObjects(
|
||||
itemSchemas,
|
||||
obj as Record<string, unknown>[],
|
||||
);
|
||||
return {
|
||||
type: 'array',
|
||||
items: itemsSchema,
|
||||
minItems: 0, // Keep minItems consistent
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'array',
|
||||
items: itemSchemas[0],
|
||||
minItems: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Mixed type arrays
|
||||
const uniqueSchemas = [
|
||||
...new Map(itemSchemas.map((s) => [JSON.stringify(s), s])).values(),
|
||||
];
|
||||
|
||||
// Check if merged schemas result in a single object type
|
||||
if (
|
||||
uniqueSchemas.length === 1 &&
|
||||
asObjectSchema(uniqueSchemas[0]).type === 'object'
|
||||
) {
|
||||
return {
|
||||
type: 'array',
|
||||
items: uniqueSchemas[0],
|
||||
minItems: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'array',
|
||||
items:
|
||||
uniqueSchemas.length === 1 ? uniqueSchemas[0] : { oneOf: uniqueSchemas },
|
||||
minItems: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function inferStringSchema(str: string): JSONSchema {
|
||||
const formats: Record<string, RegExp> = {
|
||||
date: /^\d{4}-\d{2}-\d{2}$/,
|
||||
'date-time':
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/,
|
||||
email: /^[^@]+@[^@]+\.[^@]+$/,
|
||||
uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
||||
uri: /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/i,
|
||||
};
|
||||
|
||||
for (const [format, regex] of Object.entries(formats)) {
|
||||
if (regex.test(str)) {
|
||||
return { type: 'string', format };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'string' };
|
||||
}
|
||||
|
||||
function inferNumberSchema(num: number): JSONSchema {
|
||||
return Number.isInteger(num) ? { type: 'integer' } : { type: 'number' };
|
||||
}
|
||||
|
||||
// --- Main Inference Function ---
|
||||
|
||||
/**
|
||||
* Infers a JSON Schema from a JSON object
|
||||
* Based on json-schema-generator approach
|
||||
*/
|
||||
export function inferSchema(obj: unknown): JSONSchema {
|
||||
if (obj === null) return { type: 'null' };
|
||||
|
||||
const type = Array.isArray(obj) ? 'array' : typeof obj;
|
||||
|
||||
switch (type) {
|
||||
case 'object':
|
||||
return inferObjectSchema(obj as Record<string, unknown>); // Cast needed
|
||||
case 'array':
|
||||
return inferArraySchema(obj as unknown[]); // Cast needed
|
||||
case 'string':
|
||||
return inferStringSchema(obj as string);
|
||||
case 'number':
|
||||
return inferNumberSchema(obj as number);
|
||||
case 'boolean':
|
||||
return { type: 'boolean' }; // Simple enough to keep inline
|
||||
default:
|
||||
// Should not happen for valid JSON, but return empty schema as fallback
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a full JSON Schema document from a JSON object
|
||||
*/
|
||||
export function createSchemaFromJson(jsonObject: unknown): JSONSchema {
|
||||
const inferredSchema = inferSchema(jsonObject);
|
||||
|
||||
// Ensure the root schema is always an object, even if input is array/primitive
|
||||
const rootSchema = asObjectSchema(inferredSchema);
|
||||
const finalSchema: Record<string, unknown> = {
|
||||
$schema: 'https://json-schema.org/draft-07/schema',
|
||||
title: 'Generated Schema',
|
||||
description: 'Generated from JSON data',
|
||||
};
|
||||
|
||||
if (rootSchema.type === 'object' || rootSchema.properties) {
|
||||
finalSchema.type = 'object';
|
||||
finalSchema.properties = rootSchema.properties;
|
||||
if (rootSchema.required) finalSchema.required = rootSchema.required;
|
||||
} else if (rootSchema.type === 'array' || rootSchema.items) {
|
||||
finalSchema.type = 'array';
|
||||
finalSchema.items = rootSchema.items;
|
||||
if (rootSchema.minItems !== undefined)
|
||||
finalSchema.minItems = rootSchema.minItems;
|
||||
if (rootSchema.maxItems !== undefined)
|
||||
finalSchema.maxItems = rootSchema.maxItems;
|
||||
} else if (rootSchema.type) {
|
||||
// Handle primitive types at the root (e.g., input is just "hello")
|
||||
// This might be less common, but good to handle. Wrap it in an object.
|
||||
finalSchema.type = 'object';
|
||||
finalSchema.properties = { value: rootSchema };
|
||||
finalSchema.required = ['value'];
|
||||
finalSchema.title = 'Generated Schema (Primitive Root)';
|
||||
finalSchema.description =
|
||||
'Input was a primitive value, wrapped in an object.';
|
||||
} else {
|
||||
// Default empty object if inference fails completely
|
||||
finalSchema.type = 'object';
|
||||
}
|
||||
|
||||
return finalSchema as JSONSchema;
|
||||
}
|
||||
175
web/src/components/jsonjoy-builder/lib/schemaEditor.ts
Normal file
175
web/src/components/jsonjoy-builder/lib/schemaEditor.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import type {
|
||||
JSONSchema,
|
||||
NewField,
|
||||
ObjectJSONSchema,
|
||||
} from '../types/jsonSchema.ts';
|
||||
import { isBooleanSchema, isObjectSchema } from '../types/jsonSchema.ts';
|
||||
|
||||
export type Property = {
|
||||
name: string;
|
||||
schema: JSONSchema;
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
export function copySchema<T extends JSONSchema>(schema: T): T {
|
||||
if (typeof structuredClone === 'function') return structuredClone(schema);
|
||||
return JSON.parse(JSON.stringify(schema));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a property in an object schema
|
||||
*/
|
||||
export function updateObjectProperty(
|
||||
schema: ObjectJSONSchema,
|
||||
propertyName: string,
|
||||
propertySchema: JSONSchema,
|
||||
): ObjectJSONSchema {
|
||||
if (!isObjectSchema(schema)) return schema;
|
||||
|
||||
const newSchema = copySchema(schema);
|
||||
if (!newSchema.properties) {
|
||||
newSchema.properties = {};
|
||||
}
|
||||
|
||||
newSchema.properties[propertyName] = propertySchema;
|
||||
return newSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a property from an object schema
|
||||
*/
|
||||
export function removeObjectProperty(
|
||||
schema: ObjectJSONSchema,
|
||||
propertyName: string,
|
||||
): ObjectJSONSchema {
|
||||
if (!isObjectSchema(schema) || !schema.properties) return schema;
|
||||
|
||||
const newSchema = copySchema(schema);
|
||||
const { [propertyName]: _, ...remainingProps } = newSchema.properties;
|
||||
newSchema.properties = remainingProps;
|
||||
|
||||
// Also remove from required array if present
|
||||
if (newSchema.required) {
|
||||
newSchema.required = newSchema.required.filter(
|
||||
(name) => name !== propertyName,
|
||||
);
|
||||
}
|
||||
|
||||
return newSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the 'required' status of a property
|
||||
*/
|
||||
export function updatePropertyRequired(
|
||||
schema: ObjectJSONSchema,
|
||||
propertyName: string,
|
||||
required: boolean,
|
||||
): ObjectJSONSchema {
|
||||
if (!isObjectSchema(schema)) return schema;
|
||||
|
||||
const newSchema = copySchema(schema);
|
||||
if (!newSchema.required) {
|
||||
newSchema.required = [];
|
||||
}
|
||||
|
||||
if (required) {
|
||||
// Add to required array if not already there
|
||||
if (!newSchema.required.includes(propertyName)) {
|
||||
newSchema.required.push(propertyName);
|
||||
}
|
||||
} else {
|
||||
// Remove from required array
|
||||
newSchema.required = newSchema.required.filter(
|
||||
(name) => name !== propertyName,
|
||||
);
|
||||
}
|
||||
|
||||
return newSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an array schema's items
|
||||
*/
|
||||
export function updateArrayItems(
|
||||
schema: JSONSchema,
|
||||
itemsSchema: JSONSchema,
|
||||
): JSONSchema {
|
||||
if (isObjectSchema(schema) && schema.type === 'array') {
|
||||
return {
|
||||
...schema,
|
||||
items: itemsSchema,
|
||||
};
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a schema for a new field
|
||||
*/
|
||||
export function createFieldSchema(field: NewField): JSONSchema {
|
||||
const { type, description, validation } = field;
|
||||
if (isObjectSchema(validation)) {
|
||||
return {
|
||||
type,
|
||||
description,
|
||||
...validation,
|
||||
};
|
||||
}
|
||||
return validation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a field name
|
||||
*/
|
||||
export function validateFieldName(name: string): boolean {
|
||||
if (!name || name.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that the name doesn't contain invalid characters for property names
|
||||
const validNamePattern = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
|
||||
return validNamePattern.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets properties from an object schema
|
||||
*/
|
||||
export function getSchemaProperties(schema: JSONSchema): Property[] {
|
||||
if (!isObjectSchema(schema) || !schema.properties) return [];
|
||||
|
||||
const required = schema.required || [];
|
||||
|
||||
return Object.entries(schema.properties).map(([name, propSchema]) => ({
|
||||
name,
|
||||
schema: propSchema,
|
||||
required: required.includes(name),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the items schema from an array schema
|
||||
*/
|
||||
export function getArrayItemsSchema(schema: JSONSchema): JSONSchema | null {
|
||||
if (isBooleanSchema(schema)) return null;
|
||||
if (schema.type !== 'array') return null;
|
||||
|
||||
return schema.items || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a schema has children
|
||||
*/
|
||||
export function hasChildren(schema: JSONSchema): boolean {
|
||||
if (!isObjectSchema(schema)) return false;
|
||||
|
||||
if (schema.type === 'object' && schema.properties) {
|
||||
return Object.keys(schema.properties).length > 0;
|
||||
}
|
||||
|
||||
if (schema.type === 'array' && schema.items && isObjectSchema(schema.items)) {
|
||||
return schema.items.type === 'object' && !!schema.items.properties;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
46
web/src/components/jsonjoy-builder/lib/utils.ts
Normal file
46
web/src/components/jsonjoy-builder/lib/utils.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import type { Translation } from '../i18n/translation-keys.ts';
|
||||
import type { SchemaType } from '../types/jsonSchema.ts';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// Helper functions for backward compatibility
|
||||
export const getTypeColor = (type: SchemaType): string => {
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return 'text-blue-500 bg-blue-50';
|
||||
case 'number':
|
||||
case 'integer':
|
||||
return 'text-purple-500 bg-purple-50';
|
||||
case 'boolean':
|
||||
return 'text-green-500 bg-green-50';
|
||||
case 'object':
|
||||
return 'text-orange-500 bg-orange-50';
|
||||
case 'array':
|
||||
return 'text-pink-500 bg-pink-50';
|
||||
case 'null':
|
||||
return 'text-gray-500 bg-gray-50';
|
||||
}
|
||||
};
|
||||
|
||||
// Get type display label
|
||||
export const getTypeLabel = (t: Translation, type: SchemaType): string => {
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return t.schemaTypeString;
|
||||
case 'number':
|
||||
case 'integer':
|
||||
return t.schemaTypeNumber;
|
||||
case 'boolean':
|
||||
return t.schemaTypeBoolean;
|
||||
case 'object':
|
||||
return t.schemaTypeObject;
|
||||
case 'array':
|
||||
return t.schemaTypeArray;
|
||||
case 'null':
|
||||
return t.schemaTypeNull;
|
||||
}
|
||||
};
|
||||
175
web/src/components/jsonjoy-builder/types/jsonSchema.ts
Normal file
175
web/src/components/jsonjoy-builder/types/jsonSchema.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Core definitions
|
||||
const simpleTypes = [
|
||||
'string',
|
||||
'number',
|
||||
'integer',
|
||||
'boolean',
|
||||
'object',
|
||||
'array',
|
||||
'null',
|
||||
] as const;
|
||||
|
||||
// Define base schema first - Zod is the source of truth
|
||||
/** @public */
|
||||
export const baseSchema = z.object({
|
||||
// Base schema properties
|
||||
$id: z.string().optional(),
|
||||
$schema: z.string().optional(),
|
||||
$ref: z.string().optional(),
|
||||
$anchor: z.string().optional(),
|
||||
$dynamicRef: z.string().optional(),
|
||||
$dynamicAnchor: z.string().optional(),
|
||||
$vocabulary: z.record(z.string(), z.boolean()).optional(),
|
||||
$comment: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
default: z.unknown().optional(),
|
||||
deprecated: z.boolean().optional(),
|
||||
readOnly: z.boolean().optional(),
|
||||
writeOnly: z.boolean().optional(),
|
||||
examples: z.array(z.unknown()).optional(),
|
||||
type: z.union([z.enum(simpleTypes), z.array(z.enum(simpleTypes))]).optional(),
|
||||
|
||||
// String validations
|
||||
minLength: z.number().int().min(0).optional(),
|
||||
maxLength: z.number().int().min(0).optional(),
|
||||
pattern: z.string().optional(),
|
||||
format: z.string().optional(),
|
||||
contentMediaType: z.string().optional(),
|
||||
contentEncoding: z.string().optional(),
|
||||
|
||||
// Number validations
|
||||
multipleOf: z.number().positive().optional(),
|
||||
minimum: z.number().optional(),
|
||||
maximum: z.number().optional(),
|
||||
exclusiveMinimum: z.number().optional(),
|
||||
exclusiveMaximum: z.number().optional(),
|
||||
|
||||
// Array validations
|
||||
minItems: z.number().int().min(0).optional(),
|
||||
maxItems: z.number().int().min(0).optional(),
|
||||
uniqueItems: z.boolean().optional(),
|
||||
minContains: z.number().int().min(0).optional(),
|
||||
maxContains: z.number().int().min(0).optional(),
|
||||
|
||||
// Object validations
|
||||
required: z.array(z.string()).optional(),
|
||||
minProperties: z.number().int().min(0).optional(),
|
||||
maxProperties: z.number().int().min(0).optional(),
|
||||
dependentRequired: z.record(z.string(), z.array(z.string())).optional(),
|
||||
|
||||
// Value validations
|
||||
const: z.unknown().optional(),
|
||||
enum: z.array(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
// Define recursive schema type
|
||||
/** @public */
|
||||
export type JSONSchema =
|
||||
| boolean
|
||||
| (z.infer<typeof baseSchema> & {
|
||||
// Recursive properties
|
||||
$defs?: Record<string, JSONSchema>;
|
||||
contentSchema?: JSONSchema;
|
||||
items?: JSONSchema;
|
||||
prefixItems?: JSONSchema[];
|
||||
contains?: JSONSchema;
|
||||
unevaluatedItems?: JSONSchema;
|
||||
properties?: Record<string, JSONSchema>;
|
||||
patternProperties?: Record<string, JSONSchema>;
|
||||
additionalProperties?: JSONSchema | boolean;
|
||||
propertyNames?: JSONSchema;
|
||||
dependentSchemas?: Record<string, JSONSchema>;
|
||||
unevaluatedProperties?: JSONSchema;
|
||||
allOf?: JSONSchema[];
|
||||
anyOf?: JSONSchema[];
|
||||
oneOf?: JSONSchema[];
|
||||
not?: JSONSchema;
|
||||
if?: JSONSchema;
|
||||
then?: JSONSchema;
|
||||
else?: JSONSchema;
|
||||
});
|
||||
|
||||
// Define Zod schema with recursive types
|
||||
export const jsonSchemaType: z.ZodType<JSONSchema> = z.lazy(() =>
|
||||
z.union([
|
||||
baseSchema.extend({
|
||||
$defs: z.record(z.string(), jsonSchemaType).optional(),
|
||||
contentSchema: jsonSchemaType.optional(),
|
||||
items: jsonSchemaType.optional(),
|
||||
prefixItems: z.array(jsonSchemaType).optional(),
|
||||
contains: jsonSchemaType.optional(),
|
||||
unevaluatedItems: jsonSchemaType.optional(),
|
||||
properties: z.record(z.string(), jsonSchemaType).optional(),
|
||||
patternProperties: z.record(z.string(), jsonSchemaType).optional(),
|
||||
additionalProperties: z.union([jsonSchemaType, z.boolean()]).optional(),
|
||||
propertyNames: jsonSchemaType.optional(),
|
||||
dependentSchemas: z.record(z.string(), jsonSchemaType).optional(),
|
||||
unevaluatedProperties: jsonSchemaType.optional(),
|
||||
allOf: z.array(jsonSchemaType).optional(),
|
||||
anyOf: z.array(jsonSchemaType).optional(),
|
||||
oneOf: z.array(jsonSchemaType).optional(),
|
||||
not: jsonSchemaType.optional(),
|
||||
if: jsonSchemaType.optional(),
|
||||
// biome-ignore lint/suspicious/noThenProperty: This is a required property name in JSON Schema
|
||||
then: jsonSchemaType.optional(),
|
||||
else: jsonSchemaType.optional(),
|
||||
}),
|
||||
z.boolean(),
|
||||
]),
|
||||
);
|
||||
|
||||
// Derive our types from the schema
|
||||
export type SchemaType = (typeof simpleTypes)[number];
|
||||
|
||||
export interface NewField {
|
||||
name: string;
|
||||
type: SchemaType;
|
||||
description: string;
|
||||
required: boolean;
|
||||
validation?: ObjectJSONSchema;
|
||||
}
|
||||
|
||||
export interface SchemaEditorState {
|
||||
schema: JSONSchema;
|
||||
fieldInfo: {
|
||||
type: SchemaType;
|
||||
properties: Array<{
|
||||
name: string;
|
||||
path: string[];
|
||||
schema: JSONSchema;
|
||||
required: boolean;
|
||||
}>;
|
||||
} | null;
|
||||
handleAddField: (newField: NewField, parentPath?: string[]) => void;
|
||||
handleEditField: (path: string[], updatedField: NewField) => void;
|
||||
handleDeleteField: (path: string[]) => void;
|
||||
handleSchemaEdit: (schema: JSONSchema) => void;
|
||||
}
|
||||
|
||||
export type ObjectJSONSchema = Exclude<JSONSchema, boolean>;
|
||||
|
||||
export function isBooleanSchema(schema: JSONSchema): schema is boolean {
|
||||
return typeof schema === 'boolean';
|
||||
}
|
||||
|
||||
export function isObjectSchema(schema: JSONSchema): schema is ObjectJSONSchema {
|
||||
return !isBooleanSchema(schema);
|
||||
}
|
||||
|
||||
export function asObjectSchema(schema: JSONSchema): ObjectJSONSchema {
|
||||
return isObjectSchema(schema) ? schema : { type: 'null' };
|
||||
}
|
||||
export function getSchemaDescription(schema: JSONSchema): string {
|
||||
return isObjectSchema(schema) ? schema.description || '' : '';
|
||||
}
|
||||
|
||||
export function withObjectSchema<T>(
|
||||
schema: JSONSchema,
|
||||
fn: (schema: ObjectJSONSchema) => T,
|
||||
defaultValue: T,
|
||||
): T {
|
||||
return isObjectSchema(schema) ? fn(schema) : defaultValue;
|
||||
}
|
||||
377
web/src/components/jsonjoy-builder/types/validation.ts
Normal file
377
web/src/components/jsonjoy-builder/types/validation.ts
Normal file
@ -0,0 +1,377 @@
|
||||
import z from 'zod';
|
||||
import type { Translation } from '../i18n/translation-keys.ts';
|
||||
import { baseSchema, type JSONSchema } from './jsonSchema';
|
||||
|
||||
function refineRangeConsistency(
|
||||
min: number | undefined,
|
||||
isMinExclusive: boolean,
|
||||
max: number | undefined,
|
||||
isMaxExclusive: boolean,
|
||||
): boolean {
|
||||
if (min !== undefined && max !== undefined && min > max) {
|
||||
return false;
|
||||
}
|
||||
if (isMinExclusive && isMaxExclusive && max - min < 2) {
|
||||
return false;
|
||||
}
|
||||
if ((isMinExclusive || isMaxExclusive) && max - min < 1) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const getJsonStringType = (t: Translation) =>
|
||||
z
|
||||
.object({
|
||||
minLength: z
|
||||
.number()
|
||||
.int({ message: t.typeValidationErrorIntValue })
|
||||
.min(0, { message: t.typeValidationErrorNegativeLength })
|
||||
.optional(),
|
||||
maxLength: z
|
||||
.number()
|
||||
.int({ message: t.typeValidationErrorIntValue })
|
||||
.min(0, { message: t.typeValidationErrorNegativeLength })
|
||||
.optional(),
|
||||
pattern: baseSchema.shape.pattern,
|
||||
format: baseSchema.shape.format,
|
||||
enum: baseSchema.shape.enum,
|
||||
contentMediaType: baseSchema.shape.contentMediaType, // TODO
|
||||
contentEncoding: baseSchema.shape.contentEncoding, // TODO
|
||||
})
|
||||
// If minLength and maxLength are both set, minLength must not be greater than maxLength.
|
||||
.refine(
|
||||
({ minLength, maxLength }) =>
|
||||
refineRangeConsistency(minLength, false, maxLength, false),
|
||||
{
|
||||
message: t.stringValidationErrorLengthRange,
|
||||
path: ['length'],
|
||||
},
|
||||
);
|
||||
|
||||
const getJsonNumberType = (t: Translation) =>
|
||||
z
|
||||
.object({
|
||||
multipleOf: z
|
||||
.number()
|
||||
.positive({ message: t.typeValidationErrorPositive })
|
||||
.optional(),
|
||||
minimum: baseSchema.shape.minimum,
|
||||
maximum: baseSchema.shape.maximum,
|
||||
exclusiveMinimum: baseSchema.shape.exclusiveMinimum,
|
||||
exclusiveMaximum: baseSchema.shape.exclusiveMaximum,
|
||||
enum: baseSchema.shape.enum,
|
||||
})
|
||||
// If both minimum (or exclusiveMinimum) and maximum (or exclusiveMaximum) are set, minimum must not be greater than maximum.
|
||||
.refine(
|
||||
({ minimum, exclusiveMinimum, maximum, exclusiveMaximum }) =>
|
||||
refineRangeConsistency(minimum, false, maximum, false) &&
|
||||
refineRangeConsistency(minimum, false, exclusiveMaximum, true) &&
|
||||
refineRangeConsistency(exclusiveMinimum, true, maximum, false) &&
|
||||
refineRangeConsistency(exclusiveMinimum, true, exclusiveMaximum, true),
|
||||
{
|
||||
message: t.numberValidationErrorMinMax,
|
||||
path: ['minMax'],
|
||||
},
|
||||
)
|
||||
// cannot set both exclusiveMinimum and minimum
|
||||
.refine(
|
||||
({ minimum, exclusiveMinimum }) =>
|
||||
exclusiveMinimum === undefined || minimum === undefined,
|
||||
{
|
||||
message: t.numberValidationErrorBothExclusiveAndInclusiveMin,
|
||||
path: ['redundantMinimum'],
|
||||
},
|
||||
)
|
||||
// cannot set both exclusiveMaximum and maximum
|
||||
.refine(
|
||||
({ maximum, exclusiveMaximum }) =>
|
||||
exclusiveMaximum === undefined || maximum === undefined,
|
||||
{
|
||||
message: t.numberValidationErrorBothExclusiveAndInclusiveMax,
|
||||
path: ['redundantMaximum'],
|
||||
},
|
||||
)
|
||||
// check that the enums are within min/max if they are set
|
||||
.refine(
|
||||
({
|
||||
enum: enumValues,
|
||||
minimum,
|
||||
maximum,
|
||||
exclusiveMinimum,
|
||||
exclusiveMaximum,
|
||||
}) => {
|
||||
if (!enumValues || enumValues.length === 0) return true;
|
||||
return enumValues.every((val) => {
|
||||
if (typeof val !== 'number') return false;
|
||||
if (minimum !== undefined && val < minimum) return false;
|
||||
if (maximum !== undefined && val > maximum) return false;
|
||||
if (exclusiveMinimum !== undefined && val <= exclusiveMinimum)
|
||||
return false;
|
||||
if (exclusiveMaximum !== undefined && val >= exclusiveMaximum)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
},
|
||||
{
|
||||
message: t.numberValidationErrorEnumOutOfRange,
|
||||
path: ['enum'],
|
||||
},
|
||||
);
|
||||
|
||||
const getJsonArrayType = (t: Translation) =>
|
||||
z
|
||||
.object({
|
||||
minItems: z
|
||||
.number()
|
||||
.int({ message: t.typeValidationErrorIntValue })
|
||||
.min(0, { message: t.typeValidationErrorNegativeLength })
|
||||
.optional(),
|
||||
maxItems: z
|
||||
.number()
|
||||
.int({ message: t.typeValidationErrorIntValue })
|
||||
.min(0, { message: t.typeValidationErrorNegativeLength })
|
||||
.optional(),
|
||||
uniqueItems: z.boolean().optional(),
|
||||
minContains: z
|
||||
.number()
|
||||
.int({ message: t.typeValidationErrorIntValue })
|
||||
.min(0, { message: t.typeValidationErrorNegativeLength })
|
||||
.optional(),
|
||||
maxContains: z
|
||||
.number()
|
||||
.int({ message: t.typeValidationErrorIntValue })
|
||||
.min(0, { message: t.typeValidationErrorNegativeLength })
|
||||
.optional(),
|
||||
})
|
||||
// If both minItems and maxItems are set, minItems must not be greater than maxItems.
|
||||
.refine(
|
||||
({ minItems, maxItems }) =>
|
||||
refineRangeConsistency(minItems, false, maxItems, false),
|
||||
{
|
||||
message: t.arrayValidationErrorMinMax,
|
||||
path: ['minmax'],
|
||||
},
|
||||
)
|
||||
// If both minContains and maxContains are set, minContains must not be greater than maxContains.
|
||||
.refine(
|
||||
({ minContains, maxContains }) =>
|
||||
refineRangeConsistency(minContains, false, maxContains, false),
|
||||
{
|
||||
message: t.arrayValidationErrorContainsMinMax,
|
||||
path: ['minmaxContains'],
|
||||
},
|
||||
);
|
||||
|
||||
const getJsonObjectType = (t: Translation) =>
|
||||
z
|
||||
.object({
|
||||
minProperties: z
|
||||
.number()
|
||||
.int({ message: t.typeValidationErrorIntValue })
|
||||
.min(0, { message: t.typeValidationErrorNegativeLength })
|
||||
.optional(),
|
||||
maxProperties: z
|
||||
.number()
|
||||
.int({ message: t.typeValidationErrorIntValue })
|
||||
.min(0, { message: t.typeValidationErrorNegativeLength })
|
||||
.optional(),
|
||||
})
|
||||
// If both minProperties and maxProperties are set, minProperties must not be greater than maxProperties.
|
||||
.refine(
|
||||
({ minProperties, maxProperties }) =>
|
||||
refineRangeConsistency(minProperties, false, maxProperties, false),
|
||||
{
|
||||
message: t.objectValidationErrorMinMax,
|
||||
path: ['minmax'],
|
||||
},
|
||||
);
|
||||
|
||||
export function getTypeValidation(type: string, t: Translation) {
|
||||
const jsonTypesValidation: Record<string, z.ZodTypeAny> = {
|
||||
string: getJsonStringType(t),
|
||||
number: getJsonNumberType(t),
|
||||
array: getJsonArrayType(t),
|
||||
object: getJsonObjectType(t),
|
||||
};
|
||||
|
||||
return jsonTypesValidation[type] || z.any();
|
||||
}
|
||||
|
||||
export interface TypeValidationResult {
|
||||
success: boolean;
|
||||
errors?: z.core.$ZodIssue[];
|
||||
}
|
||||
|
||||
export function validateSchemaByType(
|
||||
schema: unknown,
|
||||
type: string,
|
||||
t: Translation,
|
||||
): TypeValidationResult {
|
||||
const zodSchema = getTypeValidation(type, t);
|
||||
const result = zodSchema.safeParse(schema);
|
||||
if (result.success) {
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, errors: result.error.issues };
|
||||
}
|
||||
}
|
||||
|
||||
export interface ValidationTreeNode {
|
||||
name: string;
|
||||
validation: TypeValidationResult;
|
||||
children: Record<string, ValidationTreeNode>;
|
||||
cumulativeChildrenErrors: number; // Total errors in this node and all its descendants
|
||||
}
|
||||
|
||||
export function buildValidationTree(
|
||||
schema: JSONSchema,
|
||||
t: Translation,
|
||||
): ValidationTreeNode {
|
||||
// Helper to determine a concrete type string from a schema.type which may be string | string[] | undefined
|
||||
const deriveType = (sch: unknown): string | undefined => {
|
||||
if (!sch || typeof sch !== 'object') return undefined;
|
||||
const declared = (sch as Record<string, unknown>).type;
|
||||
if (typeof declared === 'string') return declared;
|
||||
if (
|
||||
Array.isArray(declared) &&
|
||||
declared.length > 0 &&
|
||||
typeof declared[0] === 'string'
|
||||
)
|
||||
return declared[0];
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// TODO confirm assumption below:
|
||||
// Handle boolean schemas: true => always valid, false => always invalid
|
||||
if (typeof schema === 'boolean') {
|
||||
const validation: TypeValidationResult =
|
||||
schema === true
|
||||
? { success: true }
|
||||
: {
|
||||
success: false,
|
||||
errors: [
|
||||
{
|
||||
code: 'custom',
|
||||
message: t.validatorErrorSchemaValidation,
|
||||
path: [],
|
||||
} as unknown as z.core.$ZodIssue,
|
||||
],
|
||||
};
|
||||
|
||||
const node: ValidationTreeNode = {
|
||||
name: String(schema),
|
||||
validation,
|
||||
children: {},
|
||||
cumulativeChildrenErrors: validation.success
|
||||
? 0
|
||||
: validation.errors?.length ?? 0,
|
||||
};
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
// schema is an object-shaped JSONSchema
|
||||
const sch = schema as Record<string, unknown>;
|
||||
const currentType = deriveType(sch);
|
||||
|
||||
const validation = validateSchemaByType(schema, currentType, t);
|
||||
|
||||
const children: Record<string, ValidationTreeNode> = {};
|
||||
|
||||
// Traverse object properties
|
||||
if (currentType === 'object') {
|
||||
const properties = sch.properties;
|
||||
if (properties && typeof properties === 'object') {
|
||||
for (const [propName, propSchema] of Object.entries(
|
||||
properties as Record<string, JSONSchema>,
|
||||
)) {
|
||||
children[propName] = buildValidationTree(propSchema, t);
|
||||
}
|
||||
}
|
||||
// handle dependentSchemas, patternProperties etc. if present (shallow support)
|
||||
if (sch.patternProperties && typeof sch.patternProperties === 'object') {
|
||||
for (const [patternName, patternSchema] of Object.entries(
|
||||
sch.patternProperties as Record<string, JSONSchema>,
|
||||
)) {
|
||||
children[`pattern:${patternName}`] = buildValidationTree(
|
||||
patternSchema,
|
||||
t,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Traverse array items / prefixItems
|
||||
if (currentType === 'array') {
|
||||
const items = sch.items;
|
||||
if (Array.isArray(items)) {
|
||||
items.forEach((it, idx) => {
|
||||
children[`items[${idx}]`] = buildValidationTree(it, t);
|
||||
});
|
||||
} else if (items) {
|
||||
children.items = buildValidationTree(items as JSONSchema, t);
|
||||
}
|
||||
|
||||
if (Array.isArray(sch.prefixItems)) {
|
||||
(sch.prefixItems as JSONSchema[]).forEach((it, idx) => {
|
||||
children[`prefixItems[${idx}]`] = buildValidationTree(it, t);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle combinators: allOf / anyOf / oneOf / not (shallow traversal)
|
||||
const combinators: Array<'allOf' | 'anyOf' | 'oneOf'> = [
|
||||
'allOf',
|
||||
'anyOf',
|
||||
'oneOf',
|
||||
];
|
||||
for (const comb of combinators) {
|
||||
const arr = sch[comb];
|
||||
if (Array.isArray(arr)) {
|
||||
arr.forEach((subSchema, idx) => {
|
||||
children[[comb, idx].join(':')] = buildValidationTree(
|
||||
subSchema as JSONSchema,
|
||||
t,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (sch.not) {
|
||||
children.not = buildValidationTree(sch.not as JSONSchema, t);
|
||||
}
|
||||
|
||||
// $defs / definitions / dependentSchemas (shallow)
|
||||
if (sch.$defs && typeof sch.$defs === 'object') {
|
||||
for (const [defName, defSchema] of Object.entries(
|
||||
sch.$defs as Record<string, JSONSchema>,
|
||||
)) {
|
||||
children[`$defs:${defName}`] = buildValidationTree(defSchema, t);
|
||||
}
|
||||
}
|
||||
|
||||
// definitions is the older name for $defs, so we support both
|
||||
const definitions = (sch as Record<string, unknown>).definitions;
|
||||
if (definitions && typeof definitions === 'object') {
|
||||
for (const [defName, defSchema] of Object.entries(
|
||||
definitions as Record<string, JSONSchema>,
|
||||
)) {
|
||||
children[`definitions:${defName}`] = buildValidationTree(defSchema, t);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute cumulative error counts (own + all descendants)
|
||||
const ownErrors = validation.success ? 0 : validation.errors?.length ?? 0;
|
||||
const childrenErrors = Object.values(children).reduce(
|
||||
(sum, child) => sum + child.cumulativeChildrenErrors,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
name: currentType,
|
||||
validation,
|
||||
children,
|
||||
cumulativeChildrenErrors: ownErrors + childrenErrors,
|
||||
};
|
||||
}
|
||||
228
web/src/components/jsonjoy-builder/utils/jsonValidator.ts
Normal file
228
web/src/components/jsonjoy-builder/utils/jsonValidator.ts
Normal file
@ -0,0 +1,228 @@
|
||||
import Ajv from 'ajv';
|
||||
import addFormats from 'ajv-formats';
|
||||
import type { JSONSchema } from '../types/jsonSchema.ts';
|
||||
|
||||
// Initialize Ajv with all supported formats and meta-schemas
|
||||
const ajv = new Ajv({
|
||||
allErrors: true,
|
||||
strict: false,
|
||||
validateSchema: false,
|
||||
validateFormats: false,
|
||||
});
|
||||
addFormats(ajv);
|
||||
|
||||
export interface ValidationError {
|
||||
path: string;
|
||||
message: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors?: ValidationError[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the line and column number for a specific path in a JSON string
|
||||
*/
|
||||
export function findLineNumberForPath(
|
||||
jsonStr: string,
|
||||
path: string,
|
||||
): { line: number; column: number } | undefined {
|
||||
try {
|
||||
// For root errors
|
||||
if (path === '/' || path === '') {
|
||||
return { line: 1, column: 1 };
|
||||
}
|
||||
|
||||
// Convert the path to an array of segments
|
||||
const pathSegments = path.split('/').filter(Boolean);
|
||||
|
||||
// For root validation errors
|
||||
if (pathSegments.length === 0) {
|
||||
return { line: 1, column: 1 };
|
||||
}
|
||||
|
||||
const lines = jsonStr.split('\n');
|
||||
|
||||
// Handle simple property lookup for top-level properties
|
||||
if (pathSegments.length === 1) {
|
||||
const propName = pathSegments[0];
|
||||
const propPattern = new RegExp(`([\\s]*)("${propName}")`);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const match = propPattern.exec(line);
|
||||
|
||||
if (match) {
|
||||
// The column value should be the position where the property name begins
|
||||
const columnPos = line.indexOf(`"${propName}"`) + 1;
|
||||
return { line: i + 1, column: columnPos };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle nested paths
|
||||
if (pathSegments.length > 1) {
|
||||
// For the specific test case of "/aa/a", we know exactly where it should be
|
||||
if (path === '/aa/a') {
|
||||
// Find the parent object first
|
||||
let parentFound = false;
|
||||
let lineWithNestedProp = -1;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// If we find the parent object ("aa"), we'll look for the child property next
|
||||
if (line.includes(`"${pathSegments[0]}"`)) {
|
||||
parentFound = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Once we've found the parent, look for the child property
|
||||
if (parentFound && line.includes(`"${pathSegments[1]}"`)) {
|
||||
lineWithNestedProp = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lineWithNestedProp !== -1) {
|
||||
// Return the correct line and column
|
||||
const line = lines[lineWithNestedProp];
|
||||
const column = line.indexOf(`"${pathSegments[1]}"`) + 1;
|
||||
return { line: lineWithNestedProp + 1, column: column };
|
||||
}
|
||||
}
|
||||
|
||||
// For all other nested paths, search for the last segment
|
||||
const lastSegment = pathSegments[pathSegments.length - 1];
|
||||
|
||||
// Try to find the property directly in the JSON
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.includes(`"${lastSegment}"`)) {
|
||||
// Find the position of the last segment's property name
|
||||
const column = line.indexOf(`"${lastSegment}"`) + 1;
|
||||
return { line: i + 1, column: column };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't find a match, return undefined
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.error('Error finding line number:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts line and column information from a JSON syntax error message
|
||||
*/
|
||||
export function extractErrorPosition(
|
||||
error: Error,
|
||||
jsonInput: string,
|
||||
): { line: number; column: number } {
|
||||
let line = 1;
|
||||
let column = 1;
|
||||
const errorMessage = error.message;
|
||||
|
||||
// Try to match 'at line X column Y' pattern
|
||||
const lineColMatch = errorMessage.match(/at line (\d+) column (\d+)/);
|
||||
if (lineColMatch?.[1] && lineColMatch?.[2]) {
|
||||
line = Number.parseInt(lineColMatch[1], 10);
|
||||
column = Number.parseInt(lineColMatch[2], 10);
|
||||
} else {
|
||||
// Fall back to position-based extraction
|
||||
const positionMatch = errorMessage.match(/position (\d+)/);
|
||||
if (positionMatch?.[1]) {
|
||||
const position = Number.parseInt(positionMatch[1], 10);
|
||||
const jsonUpToError = jsonInput.substring(0, position);
|
||||
const lines = jsonUpToError.split('\n');
|
||||
line = lines.length;
|
||||
column = lines[lines.length - 1].length + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { line, column };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a JSON string against a schema and returns validation results
|
||||
*/
|
||||
export function validateJson(
|
||||
jsonInput: string,
|
||||
schema: JSONSchema,
|
||||
): ValidationResult {
|
||||
if (!jsonInput.trim()) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [
|
||||
{
|
||||
path: '/',
|
||||
message: 'Empty JSON input',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the JSON input
|
||||
const jsonObject = JSON.parse(jsonInput);
|
||||
|
||||
// Use Ajv to validate the JSON against the schema
|
||||
const validate = ajv.compile(schema);
|
||||
const valid = validate(jsonObject);
|
||||
|
||||
if (!valid) {
|
||||
const errors =
|
||||
validate.errors?.map((error) => {
|
||||
const path = error.instancePath || '/';
|
||||
const position = findLineNumberForPath(jsonInput, path);
|
||||
return {
|
||||
path,
|
||||
message: error.message || 'Unknown error',
|
||||
line: position?.line,
|
||||
column: position?.column,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
errors: [],
|
||||
};
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [
|
||||
{
|
||||
path: '/',
|
||||
message: `Unknown error: ${error}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const { line, column } = extractErrorPosition(error, jsonInput);
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
errors: [
|
||||
{
|
||||
path: '/',
|
||||
message: error.message,
|
||||
line,
|
||||
column,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -16,4 +16,5 @@ export interface IModalProps<T> {
|
||||
visible?: boolean;
|
||||
loading?: boolean;
|
||||
onOk?(payload?: T): Promise<any> | void;
|
||||
initialValues?: T;
|
||||
}
|
||||
|
||||
@ -1720,6 +1720,9 @@ Important structured information may include: names, dates, locations, events, k
|
||||
imageParseMethodOptions: {
|
||||
ocr: 'OCR',
|
||||
},
|
||||
structuredOutput: {
|
||||
configuration: 'Configuration',
|
||||
},
|
||||
},
|
||||
llmTools: {
|
||||
bad_calculator: {
|
||||
|
||||
@ -1600,6 +1600,9 @@ Tokenizer 会根据所选方式将内容存储为对应的数据结构。`,
|
||||
cancel: '取消',
|
||||
filenameEmbeddingWeight: '文件名嵌入权重',
|
||||
switchPromptMessage: '提示词将发生变化,请确认是否放弃已有提示词?',
|
||||
structuredOutput: {
|
||||
configuration: '配置',
|
||||
},
|
||||
},
|
||||
footer: {
|
||||
profile: 'All rights reserved @ React',
|
||||
|
||||
@ -615,6 +615,7 @@ export const initialAgentValues = {
|
||||
type: 'string',
|
||||
value: '',
|
||||
},
|
||||
structured: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
import { LlmSettingSchema } from '@/components/llm-setting-items/next';
|
||||
import { MessageHistoryWindowSizeFormField } from '@/components/message-history-window-size-item';
|
||||
import { SelectWithSearch } from '@/components/originui/select-with-search';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@ -39,7 +40,9 @@ import { Output } from '../components/output';
|
||||
import { PromptEditor } from '../components/prompt-editor';
|
||||
import { QueryVariable } from '../components/query-variable';
|
||||
import { AgentTools, Agents } from './agent-tools';
|
||||
import { StructuredOutputDialog } from './structured-output-dialog';
|
||||
import { useBuildPromptExtraPromptOptions } from './use-build-prompt-options';
|
||||
import { useShowStructuredOutputDialog } from './use-show-structured-output-dialog';
|
||||
import { useValues } from './use-values';
|
||||
import { useWatchFormChange } from './use-watch-change';
|
||||
|
||||
@ -108,6 +111,14 @@ function AgentForm({ node }: INextOperatorForm) {
|
||||
name: 'exception_method',
|
||||
});
|
||||
|
||||
const {
|
||||
initialStructuredOutput,
|
||||
showStructuredOutputDialog,
|
||||
structuredOutputDialogVisible,
|
||||
hideStructuredOutputDialog,
|
||||
handleStructuredOutputDialogOk,
|
||||
} = useShowStructuredOutputDialog(node?.id);
|
||||
|
||||
useEffect(() => {
|
||||
if (exceptionMethod !== AgentExceptionMethod.Goto) {
|
||||
if (node?.id) {
|
||||
@ -122,146 +133,166 @@ function AgentForm({ node }: INextOperatorForm) {
|
||||
useWatchFormChange(node?.id, form);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<FormWrapper>
|
||||
{isSubAgent && <DescriptionField></DescriptionField>}
|
||||
<LargeModelFormField showSpeech2TextModel></LargeModelFormField>
|
||||
{findLlmByUuid(llmId)?.model_type === LlmModelType.Image2text && (
|
||||
<QueryVariable
|
||||
name="visual_files_var"
|
||||
label="Visual Input File"
|
||||
type={VariableType.File}
|
||||
></QueryVariable>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`sys_prompt`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>{t('flow.systemPrompt')}</FormLabel>
|
||||
<FormControl>
|
||||
<PromptEditor
|
||||
{...field}
|
||||
placeholder={t('flow.messagePlaceholder')}
|
||||
showToolbar={true}
|
||||
extraOptions={extraOptions}
|
||||
></PromptEditor>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
<>
|
||||
<Form {...form}>
|
||||
<FormWrapper>
|
||||
{isSubAgent && <DescriptionField></DescriptionField>}
|
||||
<LargeModelFormField showSpeech2TextModel></LargeModelFormField>
|
||||
{findLlmByUuid(llmId)?.model_type === LlmModelType.Image2text && (
|
||||
<QueryVariable
|
||||
name="visual_files_var"
|
||||
label="Visual Input File"
|
||||
type={VariableType.File}
|
||||
></QueryVariable>
|
||||
)}
|
||||
/>
|
||||
{isSubAgent || (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`prompts`}
|
||||
name={`sys_prompt`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>{t('flow.userPrompt')}</FormLabel>
|
||||
<FormLabel>{t('flow.systemPrompt')}</FormLabel>
|
||||
<FormControl>
|
||||
<section>
|
||||
<PromptEditor {...field} showToolbar={true}></PromptEditor>
|
||||
</section>
|
||||
<PromptEditor
|
||||
{...field}
|
||||
placeholder={t('flow.messagePlaceholder')}
|
||||
showToolbar={true}
|
||||
extraOptions={extraOptions}
|
||||
></PromptEditor>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Separator></Separator>
|
||||
<AgentTools></AgentTools>
|
||||
<Agents node={node}></Agents>
|
||||
<Collapse title={<div>{t('flow.advancedSettings')}</div>}>
|
||||
<section className="space-y-5">
|
||||
<MessageHistoryWindowSizeFormField></MessageHistoryWindowSizeFormField>
|
||||
{isSubAgent || (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`cite`}
|
||||
name={`prompts`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel tooltip={t('flow.citeTip')}>
|
||||
{t('flow.cite')}
|
||||
</FormLabel>
|
||||
<FormLabel>{t('flow.userPrompt')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
></Switch>
|
||||
<section>
|
||||
<PromptEditor
|
||||
{...field}
|
||||
showToolbar={true}
|
||||
></PromptEditor>
|
||||
</section>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`max_retries`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>{t('flow.maxRetries')}</FormLabel>
|
||||
<FormControl>
|
||||
<NumberInput {...field} max={8}></NumberInput>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`delay_after_error`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>{t('flow.delayEfterError')}</FormLabel>
|
||||
<FormControl>
|
||||
<NumberInput {...field} max={5} step={0.1}></NumberInput>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{hasSubAgentOrTool(edges, node?.id) && (
|
||||
)}
|
||||
<Separator></Separator>
|
||||
<AgentTools></AgentTools>
|
||||
<Agents node={node}></Agents>
|
||||
<Collapse title={<div>{t('flow.advancedSettings')}</div>}>
|
||||
<section className="space-y-5">
|
||||
<MessageHistoryWindowSizeFormField></MessageHistoryWindowSizeFormField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`max_rounds`}
|
||||
name={`cite`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>{t('flow.maxRounds')}</FormLabel>
|
||||
<FormLabel tooltip={t('flow.citeTip')}>
|
||||
{t('flow.cite')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<NumberInput {...field}></NumberInput>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
></Switch>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`exception_method`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>{t('flow.exceptionMethod')}</FormLabel>
|
||||
<FormControl>
|
||||
<SelectWithSearch
|
||||
{...field}
|
||||
options={ExceptionMethodOptions}
|
||||
allowClear
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{exceptionMethod === AgentExceptionMethod.Comment && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`exception_default_value`}
|
||||
name={`max_retries`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>{t('flow.ExceptionDefaultValue')}</FormLabel>
|
||||
<FormLabel>{t('flow.maxRetries')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<NumberInput {...field} max={8}></NumberInput>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`delay_after_error`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>{t('flow.delayEfterError')}</FormLabel>
|
||||
<FormControl>
|
||||
<NumberInput {...field} max={5} step={0.1}></NumberInput>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{hasSubAgentOrTool(edges, node?.id) && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`max_rounds`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>{t('flow.maxRounds')}</FormLabel>
|
||||
<FormControl>
|
||||
<NumberInput {...field}></NumberInput>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`exception_method`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>{t('flow.exceptionMethod')}</FormLabel>
|
||||
<FormControl>
|
||||
<SelectWithSearch
|
||||
{...field}
|
||||
options={ExceptionMethodOptions}
|
||||
allowClear
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{exceptionMethod === AgentExceptionMethod.Comment && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`exception_default_value`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>{t('flow.ExceptionDefaultValue')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</Collapse>
|
||||
<Output list={outputList}></Output>
|
||||
<section>
|
||||
<div className="flex justify-between items-center">
|
||||
structured_output
|
||||
<Button variant={'outline'} onClick={showStructuredOutputDialog}>
|
||||
{t('flow.structuredOutput.configuration')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</Collapse>
|
||||
<Output list={outputList}></Output>
|
||||
</FormWrapper>
|
||||
</Form>
|
||||
</FormWrapper>
|
||||
</Form>
|
||||
{structuredOutputDialogVisible && (
|
||||
<StructuredOutputDialog
|
||||
hideModal={hideStructuredOutputDialog}
|
||||
onOk={handleStructuredOutputDialogOk}
|
||||
initialValues={initialStructuredOutput}
|
||||
></StructuredOutputDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
import {
|
||||
JSONSchema,
|
||||
JsonSchemaVisualizer,
|
||||
SchemaVisualEditor,
|
||||
} from '@/components/jsonjoy-builder';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { IModalProps } from '@/interfaces/common';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function StructuredOutputDialog({
|
||||
hideModal,
|
||||
onOk,
|
||||
initialValues,
|
||||
}: IModalProps<any>) {
|
||||
const { t } = useTranslation();
|
||||
const [schema, setSchema] = useState<JSONSchema>(initialValues);
|
||||
|
||||
const handleOk = useCallback(() => {
|
||||
onOk?.(schema);
|
||||
}, [onOk, schema]);
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={hideModal} open>
|
||||
<DialogContent className="md:max-w-[1200px] h-[50vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle> {t('flow.structuredOutput.configuration')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<section className="flex">
|
||||
<div className="flex-1">
|
||||
<SchemaVisualEditor schema={schema} onChange={setSchema} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<JsonSchemaVisualizer schema={schema} onChange={setSchema} />
|
||||
</div>
|
||||
</section>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">{t('common.cancel')}</Button>
|
||||
</DialogClose>
|
||||
<Button type="button" onClick={handleOk}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
import { JSONSchema } from '@/components/jsonjoy-builder';
|
||||
import { useSetModalState } from '@/hooks/common-hooks';
|
||||
import { useCallback } from 'react';
|
||||
import useGraphStore from '../../store';
|
||||
|
||||
export function useShowStructuredOutputDialog(nodeId?: string) {
|
||||
const {
|
||||
visible: structuredOutputDialogVisible,
|
||||
showModal: showStructuredOutputDialog,
|
||||
hideModal: hideStructuredOutputDialog,
|
||||
} = useSetModalState();
|
||||
const { updateNodeForm, getNode } = useGraphStore((state) => state);
|
||||
|
||||
const initialStructuredOutput = getNode(nodeId)?.data.form.outputs.structured;
|
||||
|
||||
const handleStructuredOutputDialogOk = useCallback(
|
||||
(values: JSONSchema) => {
|
||||
// Sync data to canvas
|
||||
if (nodeId) {
|
||||
updateNodeForm(nodeId, values, ['outputs', 'structured']);
|
||||
}
|
||||
hideStructuredOutputDialog();
|
||||
},
|
||||
[hideStructuredOutputDialog, nodeId, updateNodeForm],
|
||||
);
|
||||
|
||||
return {
|
||||
initialStructuredOutput,
|
||||
structuredOutputDialogVisible,
|
||||
showStructuredOutputDialog,
|
||||
hideStructuredOutputDialog,
|
||||
handleStructuredOutputDialogOk,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user