Feat: Configure structured data output for agent forms #10866 (#10867)

### 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:
balibabu
2025-10-29 12:19:24 +08:00
committed by GitHub
parent 415de50419
commit 4e69100ca7
51 changed files with 7343 additions and 317 deletions

386
web/package-lock.json generated
View File

@ -48,6 +48,8 @@
"@uiw/react-markdown-preview": "^5.1.3", "@uiw/react-markdown-preview": "^5.1.3",
"@xyflow/react": "^12.3.6", "@xyflow/react": "^12.3.6",
"ahooks": "^3.7.10", "ahooks": "^3.7.10",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"antd": "^5.12.7", "antd": "^5.12.7",
"axios": "^1.12.0", "axios": "^1.12.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
@ -154,108 +156,6 @@
"node": ">=18.20.4" "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": { "node_modules/@aashutoshrathi/word-wrap": {
"version": "1.2.6", "version": "1.2.6",
"resolved": "https://registry.npmmirror.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", "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": "^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": { "node_modules/@eslint/eslintrc/node_modules/argparse": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
@ -2711,6 +2628,13 @@
"js-yaml": "bin/js-yaml.js" "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": { "node_modules/@eslint/eslintrc/node_modules/type-fest": {
"version": "0.20.2", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz", "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz",
@ -12907,34 +12831,6 @@
} }
}, },
"node_modules/ajv": { "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", "version": "8.17.1",
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz", "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
@ -12950,18 +12846,21 @@
"url": "https://github.com/sponsors/epoberezkin" "url": "https://github.com/sponsors/epoberezkin"
} }
}, },
"node_modules/ajv-formats/node_modules/json-schema-traverse": { "node_modules/ajv-formats": {
"version": "1.0.0", "version": "3.0.1",
"resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT" "license": "MIT",
}, "dependencies": {
"node_modules/ajv-keywords": { "ajv": "^8.0.0"
"version": "3.5.2", },
"resolved": "https://registry.npmmirror.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
"peerDependencies": { "peerDependencies": {
"ajv": "^6.9.1" "ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
} }
}, },
"node_modules/align-text": { "node_modules/align-text": {
@ -18043,21 +17942,22 @@
"@types/json-schema": "*" "@types/json-schema": "*"
} }
}, },
"node_modules/eslint-webpack-plugin/node_modules/ajv": { "node_modules/eslint-webpack-plugin/node_modules/ajv-formats": {
"version": "8.17.1", "version": "2.1.1",
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz", "resolved": "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "ajv": "^8.0.0"
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
}, },
"funding": { "peerDependencies": {
"type": "github", "ajv": "^8.0.0"
"url": "https://github.com/sponsors/epoberezkin" },
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
} }
}, },
"node_modules/eslint-webpack-plugin/node_modules/ajv-keywords": { "node_modules/eslint-webpack-plugin/node_modules/ajv-keywords": {
@ -18099,13 +17999,6 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0" "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": { "node_modules/eslint-webpack-plugin/node_modules/schema-utils": {
"version": "4.3.2", "version": "4.3.2",
"resolved": "https://registry.npmmirror.com/schema-utils/-/schema-utils-4.3.2.tgz", "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" "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": { "node_modules/eslint/node_modules/ansi-styles": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
@ -18267,6 +18177,13 @@
"js-yaml": "bin/js-yaml.js" "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": { "node_modules/eslint/node_modules/supports-color": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz",
@ -24096,9 +24013,10 @@
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="
}, },
"node_modules/json-schema-traverse": { "node_modules/json-schema-traverse": {
"version": "0.4.1", "version": "1.0.0",
"resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
}, },
"node_modules/json-stable-stringify-without-jsonify": { "node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1", "version": "1.0.1",
@ -29366,6 +29284,33 @@
"node": ">=14" "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": { "node_modules/react-dev-utils/node_modules/ansi-styles": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
@ -29503,6 +29448,13 @@
"integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
"dev": true "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": { "node_modules/react-dev-utils/node_modules/loader-utils": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmmirror.com/loader-utils/-/loader-utils-3.2.1.tgz", "resolved": "https://registry.npmmirror.com/loader-utils/-/loader-utils-3.2.1.tgz",
@ -31946,6 +31898,37 @@
"node": ">= 10.13.0" "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": { "node_modules/screenfull": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmmirror.com/screenfull/-/screenfull-5.2.0.tgz", "resolved": "https://registry.npmmirror.com/screenfull/-/screenfull-5.2.0.tgz",
@ -34108,24 +34091,6 @@
"node": ">=10.0.0" "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": { "node_modules/tailwind-merge": {
"version": "2.5.4", "version": "2.5.4",
"resolved": "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-2.5.4.tgz", "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": { "node_modules/terser-webpack-plugin/node_modules/ajv-formats": {
"version": "8.17.1", "version": "2.1.1",
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz", "resolved": "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "ajv": "^8.0.0"
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
}, },
"funding": { "peerDependencies": {
"type": "github", "ajv": "^8.0.0"
"url": "https://github.com/sponsors/epoberezkin" },
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
} }
}, },
"node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": {
@ -34422,12 +34388,6 @@
"node": ">= 10.13.0" "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": { "node_modules/terser-webpack-plugin/node_modules/schema-utils": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmmirror.com/schema-utils/-/schema-utils-4.3.0.tgz", "resolved": "https://registry.npmmirror.com/schema-utils/-/schema-utils-4.3.0.tgz",
@ -35592,6 +35552,7 @@
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
@ -35600,6 +35561,7 @@
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@ -36550,21 +36512,22 @@
} }
} }
}, },
"node_modules/webpack-dev-middleware/node_modules/ajv": { "node_modules/webpack-dev-middleware/node_modules/ajv-formats": {
"version": "8.17.1", "version": "2.1.1",
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz", "resolved": "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "ajv": "^8.0.0"
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
}, },
"funding": { "peerDependencies": {
"type": "github", "ajv": "^8.0.0"
"url": "https://github.com/sponsors/epoberezkin" },
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
} }
}, },
"node_modules/webpack-dev-middleware/node_modules/ajv-keywords": { "node_modules/webpack-dev-middleware/node_modules/ajv-keywords": {
@ -36580,13 +36543,6 @@
"ajv": "^8.8.2" "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": { "node_modules/webpack-dev-middleware/node_modules/schema-utils": {
"version": "4.3.2", "version": "4.3.2",
"resolved": "https://registry.npmmirror.com/schema-utils/-/schema-utils-4.3.2.tgz", "resolved": "https://registry.npmmirror.com/schema-utils/-/schema-utils-4.3.2.tgz",

View File

@ -61,6 +61,8 @@
"@uiw/react-markdown-preview": "^5.1.3", "@uiw/react-markdown-preview": "^5.1.3",
"@xyflow/react": "^12.3.6", "@xyflow/react": "^12.3.6",
"ahooks": "^3.7.10", "ahooks": "^3.7.10",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"antd": "^5.12.7", "antd": "^5.12.7",
"axios": "^1.12.0", "axios": "^1.12.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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>
);
}

View 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 };

View 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 };

View 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,
};

View 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 };

View 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 };

View 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,
};

View 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 };

View 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 };

View 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 };

View 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,
};
}

View 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}}`;
});
}

View 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.',
};

View 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.',
};

View 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.',
};

View 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: 'Значение должно быть положительным.',
};

View File

@ -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);

View 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;
}

View 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;
}
}

View 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';

View 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;
}

View 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;
}

View 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;
}
};

View 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;
}

View 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,
};
}

View 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,
},
],
};
}
}

View File

@ -16,4 +16,5 @@ export interface IModalProps<T> {
visible?: boolean; visible?: boolean;
loading?: boolean; loading?: boolean;
onOk?(payload?: T): Promise<any> | void; onOk?(payload?: T): Promise<any> | void;
initialValues?: T;
} }

View File

@ -1720,6 +1720,9 @@ Important structured information may include: names, dates, locations, events, k
imageParseMethodOptions: { imageParseMethodOptions: {
ocr: 'OCR', ocr: 'OCR',
}, },
structuredOutput: {
configuration: 'Configuration',
},
}, },
llmTools: { llmTools: {
bad_calculator: { bad_calculator: {

View File

@ -1600,6 +1600,9 @@ Tokenizer 会根据所选方式将内容存储为对应的数据结构。`,
cancel: '取消', cancel: '取消',
filenameEmbeddingWeight: '文件名嵌入权重', filenameEmbeddingWeight: '文件名嵌入权重',
switchPromptMessage: '提示词将发生变化,请确认是否放弃已有提示词?', switchPromptMessage: '提示词将发生变化,请确认是否放弃已有提示词?',
structuredOutput: {
configuration: '配置',
},
}, },
footer: { footer: {
profile: 'All rights reserved @ React', profile: 'All rights reserved @ React',

View File

@ -615,6 +615,7 @@ export const initialAgentValues = {
type: 'string', type: 'string',
value: '', value: '',
}, },
structured: {},
}, },
}; };

View File

@ -6,6 +6,7 @@ import {
import { LlmSettingSchema } from '@/components/llm-setting-items/next'; import { LlmSettingSchema } from '@/components/llm-setting-items/next';
import { MessageHistoryWindowSizeFormField } from '@/components/message-history-window-size-item'; import { MessageHistoryWindowSizeFormField } from '@/components/message-history-window-size-item';
import { SelectWithSearch } from '@/components/originui/select-with-search'; import { SelectWithSearch } from '@/components/originui/select-with-search';
import { Button } from '@/components/ui/button';
import { import {
Form, Form,
FormControl, FormControl,
@ -39,7 +40,9 @@ import { Output } from '../components/output';
import { PromptEditor } from '../components/prompt-editor'; import { PromptEditor } from '../components/prompt-editor';
import { QueryVariable } from '../components/query-variable'; import { QueryVariable } from '../components/query-variable';
import { AgentTools, Agents } from './agent-tools'; import { AgentTools, Agents } from './agent-tools';
import { StructuredOutputDialog } from './structured-output-dialog';
import { useBuildPromptExtraPromptOptions } from './use-build-prompt-options'; import { useBuildPromptExtraPromptOptions } from './use-build-prompt-options';
import { useShowStructuredOutputDialog } from './use-show-structured-output-dialog';
import { useValues } from './use-values'; import { useValues } from './use-values';
import { useWatchFormChange } from './use-watch-change'; import { useWatchFormChange } from './use-watch-change';
@ -108,6 +111,14 @@ function AgentForm({ node }: INextOperatorForm) {
name: 'exception_method', name: 'exception_method',
}); });
const {
initialStructuredOutput,
showStructuredOutputDialog,
structuredOutputDialogVisible,
hideStructuredOutputDialog,
handleStructuredOutputDialogOk,
} = useShowStructuredOutputDialog(node?.id);
useEffect(() => { useEffect(() => {
if (exceptionMethod !== AgentExceptionMethod.Goto) { if (exceptionMethod !== AgentExceptionMethod.Goto) {
if (node?.id) { if (node?.id) {
@ -122,146 +133,166 @@ function AgentForm({ node }: INextOperatorForm) {
useWatchFormChange(node?.id, form); useWatchFormChange(node?.id, form);
return ( return (
<Form {...form}> <>
<FormWrapper> <Form {...form}>
{isSubAgent && <DescriptionField></DescriptionField>} <FormWrapper>
<LargeModelFormField showSpeech2TextModel></LargeModelFormField> {isSubAgent && <DescriptionField></DescriptionField>}
{findLlmByUuid(llmId)?.model_type === LlmModelType.Image2text && ( <LargeModelFormField showSpeech2TextModel></LargeModelFormField>
<QueryVariable {findLlmByUuid(llmId)?.model_type === LlmModelType.Image2text && (
name="visual_files_var" <QueryVariable
label="Visual Input File" name="visual_files_var"
type={VariableType.File} label="Visual Input File"
></QueryVariable> 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>
)} )}
/>
{isSubAgent || (
<FormField <FormField
control={form.control} control={form.control}
name={`prompts`} name={`sys_prompt`}
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
<FormLabel>{t('flow.userPrompt')}</FormLabel> <FormLabel>{t('flow.systemPrompt')}</FormLabel>
<FormControl> <FormControl>
<section> <PromptEditor
<PromptEditor {...field} showToolbar={true}></PromptEditor> {...field}
</section> placeholder={t('flow.messagePlaceholder')}
showToolbar={true}
extraOptions={extraOptions}
></PromptEditor>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
/> />
)} {isSubAgent || (
<Separator></Separator>
<AgentTools></AgentTools>
<Agents node={node}></Agents>
<Collapse title={<div>{t('flow.advancedSettings')}</div>}>
<section className="space-y-5">
<MessageHistoryWindowSizeFormField></MessageHistoryWindowSizeFormField>
<FormField <FormField
control={form.control} control={form.control}
name={`cite`} name={`prompts`}
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
<FormLabel tooltip={t('flow.citeTip')}> <FormLabel>{t('flow.userPrompt')}</FormLabel>
{t('flow.cite')}
</FormLabel>
<FormControl> <FormControl>
<Switch <section>
checked={field.value} <PromptEditor
onCheckedChange={field.onChange} {...field}
></Switch> showToolbar={true}
></PromptEditor>
</section>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
/> />
<FormField )}
control={form.control} <Separator></Separator>
name={`max_retries`} <AgentTools></AgentTools>
render={({ field }) => ( <Agents node={node}></Agents>
<FormItem className="flex-1"> <Collapse title={<div>{t('flow.advancedSettings')}</div>}>
<FormLabel>{t('flow.maxRetries')}</FormLabel> <section className="space-y-5">
<FormControl> <MessageHistoryWindowSizeFormField></MessageHistoryWindowSizeFormField>
<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 <FormField
control={form.control} control={form.control}
name={`max_rounds`} name={`cite`}
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
<FormLabel>{t('flow.maxRounds')}</FormLabel> <FormLabel tooltip={t('flow.citeTip')}>
{t('flow.cite')}
</FormLabel>
<FormControl> <FormControl>
<NumberInput {...field}></NumberInput> <Switch
checked={field.value}
onCheckedChange={field.onChange}
></Switch>
</FormControl> </FormControl>
</FormItem> </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 <FormField
control={form.control} control={form.control}
name={`exception_default_value`} name={`max_retries`}
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
<FormLabel>{t('flow.ExceptionDefaultValue')}</FormLabel> <FormLabel>{t('flow.maxRetries')}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <NumberInput {...field} max={8}></NumberInput>
</FormControl> </FormControl>
</FormItem> </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> </section>
</Collapse> </FormWrapper>
<Output list={outputList}></Output> </Form>
</FormWrapper> {structuredOutputDialogVisible && (
</Form> <StructuredOutputDialog
hideModal={hideStructuredOutputDialog}
onOk={handleStructuredOutputDialogOk}
initialValues={initialStructuredOutput}
></StructuredOutputDialog>
)}
</>
); );
} }

View File

@ -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>
);
}

View File

@ -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,
};
}