From f5b3b54a5a59a8f4945a0e7a3057cb61acea8f13 Mon Sep 17 00:00:00 2001 From: hzhang Date: Mon, 5 May 2025 22:35:22 +0100 Subject: [PATCH] init --- .gitignore | 24 ++ eslint.config.js | 28 ++ index.html | 13 + package.json | 46 +++ postcss.config.js | 6 + src/App.css | 0 src/App.tsx | 13 + src/assets/react.svg | 1 + src/components/DefsEditor.tsx | 100 +++++ src/components/JSONSchemaEditor.tsx | 169 ++++++++ src/components/PropertyEditor.tsx | 598 ++++++++++++++++++++++++++++ src/defs/interfaces.tsx | 15 + src/defs/styles.tsx | 43 ++ src/defs/types.tsx | 45 +++ src/index.css | 4 + src/main.tsx | 10 + src/vite-env.d.ts | 1 + tailwind.config.js | 10 + tsconfig.app.json | 27 ++ tsconfig.json | 7 + tsconfig.node.json | 25 ++ vite.config.ts | 11 + 22 files changed, 1196 insertions(+) create mode 100644 .gitignore create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 src/App.css create mode 100644 src/App.tsx create mode 100644 src/assets/react.svg create mode 100644 src/components/DefsEditor.tsx create mode 100644 src/components/JSONSchemaEditor.tsx create mode 100644 src/components/PropertyEditor.tsx create mode 100644 src/defs/interfaces.tsx create mode 100644 src/defs/styles.tsx create mode 100644 src/defs/types.tsx create mode 100644 src/index.css create mode 100644 src/main.tsx create mode 100644 src/vite-env.d.ts create mode 100644 tailwind.config.js create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/index.html b/index.html new file mode 100644 index 0000000..e4b78ea --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..333e8a4 --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "json-schema-editor", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@monaco-editor/react": "^4.7.0", + "@reduxjs/toolkit": "^2.7.0", + "@tailwindcss/vite": "^4.1.5", + "antd": "^5.24.9", + "avj": "^0.0.0", + "immer": "^10.1.1", + "lodash": "^4.17.21", + "lucide-react": "^0.507.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-redux": "^9.2.0", + "zustand": "^5.0.4" + }, + "devDependencies": { + "@eslint/js": "^9.25.0", + "@tailwindcss/postcss": "^4.1.5", + "@types/lodash": "^4.17.16", + "@types/node": "^22.15.3", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@types/tailwindcss": "^3.1.0", + "@vitejs/plugin-react": "^4.4.1", + "autoprefixer": "^10.4.21", + "eslint": "^9.25.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^16.0.0", + "postcss": "^8.5.3", + "tailwindcss": "^4.1.5", + "typescript": "~5.8.3", + "typescript-eslint": "^8.30.1", + "vite": "^6.3.5" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..b830a0a --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..e69de29 diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..33a0467 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,13 @@ +import './App.css' +import JSONSchemaEditor from "./components/JSONSchemaEditor.tsx"; + +function App() { + + return ( + <> + + + ); +} + +export default App; diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/DefsEditor.tsx b/src/components/DefsEditor.tsx new file mode 100644 index 0000000..8eabe21 --- /dev/null +++ b/src/components/DefsEditor.tsx @@ -0,0 +1,100 @@ +import {useState} from "react"; +import {Check, Plus, Trash2, X} from "lucide-react"; +import type { JSONSchemaType } from "../defs/types"; +import PropertyEditor from "./PropertyEditor.tsx"; +import styles from "../defs/styles.tsx"; +import type {FieldVisibility} from "../defs/interfaces.tsx"; + +export const DefsEditor = ({ + defs, + onChange, + rootSchema, + visibility, + }: { + defs: Record, + onChange: (defs: Record) => void, + rootSchema: JSONSchemaType, + visibility: FieldVisibility, +}) => { + const [isAddingDef, setIsAddingDef] = useState(false); + const [newDefName, setNewDefName] = useState(''); + + const handleAddDef = () => { + if (newDefName && !defs[newDefName]) { + const newDefs = { ...defs }; + newDefs[newDefName] = { type: 'object', properties: {} }; + onChange(newDefs); + setNewDefName(''); + setIsAddingDef(false); + } + }; + + const handleDeleteDef = (defName: string) => { + const newDefs = { ...defs }; + delete newDefs[defName]; + onChange(Object.keys(newDefs).length ? newDefs : {}); + }; + + return ( +
+
+

Definitions ($defs)

+ {!isAddingDef ? ( + + ) : ( +
+ setNewDefName(e.target.value)} + className="border border-gray-300 rounded px-2 py-1 mr-2" + placeholder="Definition name" + /> + + +
+ )} +
+ + {defs && Object.entries(defs).map(([defName, defSchema]) => ( +
+
+

{defName}

+ +
+ { + const newDefs = { ...defs }; + newDefs[defName] = newDefSchema; + onChange(newDefs); + }} + rootSchema={rootSchema} + visibility={visibility} + /> +
+ ))} + + {(!defs || Object.keys(defs).length === 0) && ( +

No definitions yet. Add one to reference it elsewhere in your schema.

+ )} +
+ ); +}; + +export default DefsEditor; \ No newline at end of file diff --git a/src/components/JSONSchemaEditor.tsx b/src/components/JSONSchemaEditor.tsx new file mode 100644 index 0000000..1fa94cf --- /dev/null +++ b/src/components/JSONSchemaEditor.tsx @@ -0,0 +1,169 @@ +import { useState } from 'react'; +import {Copy, Eye, EyeOff} from 'lucide-react'; +import type {JSONSchemaType} from "../defs/types.tsx"; +import DefsEditor from "./DefsEditor.tsx"; +import styles from '../defs/styles.tsx'; +import PropertyEditor from "./PropertyEditor.tsx"; +import type {FieldVisibility} from "../defs/interfaces.tsx"; + + + + +type VisibilityField = keyof FieldVisibility; + +export default function JSONSchemaEditor() { + const [schema, setSchema] = useState({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.com/schema.json", + type: "object", + properties: { + firstName: { + type: "string", + description: "The person's first name" + }, + lastName: { + type: "string", + description: "The person's last name" + }, + age: { + type: "integer", + description: "Age in years", + minimum: 0 + } + }, + required: ["firstName", "lastName"] + }); + + const [activeTab, setActiveTab] = useState('editor'); + const [visibility, setVisibility] = useState({ + showTitle: true, + showDescription: true, + showPropertyLimit: true, + showRequired: true, + showAdditionalProperties: true, + showUniqueItems: true, + showArrayLimit: true, + showNumericLimit: true, + showMultipleOf: true, + showStringLength: true, + showStringFormat: true, + showStringPattern: true + }); + const handleSchemaChange = (newSchema: JSONSchemaType) => { + setSchema(newSchema); + }; + + const toggleVisibility = (field: VisibilityField) => { + setVisibility(prev => ({...prev, [field]: !prev[field]})); + }; + const renderToggleButton = (field: VisibilityField, label: string) => { + const isVisible = visibility[field]; + return ( + + ); + }; + + + const handleCopyJson = () => { + navigator.clipboard.writeText(JSON.stringify(schema, null, 2)); + }; + + return ( +
+
+

JSON Schema Editor

+

JSON Schema Editor

+
+ +
+
+
+
+ + +
+ +
+ +
+
+
+ {renderToggleButton('showTitle', 'Title')} + {renderToggleButton('showDescription', 'Description')} + {renderToggleButton('showPropertyLimit', 'Prop Limits (Obj)')} + {renderToggleButton('showRequired', 'Required (Obj)')} + {renderToggleButton('showAdditionalProperties', 'Add. Props (Obj)')} + {renderToggleButton('showUniqueItems', 'Unique Items (Arr)')} + {renderToggleButton('showArrayLimit', 'Array Limits (Arr)')} + {renderToggleButton('showNumericLimit', 'Num Limits (Num/Int)')} + {renderToggleButton('showMultipleOf', 'MultipleOf (Num/Int)')} + {renderToggleButton('showStringLength', 'Length (Str)')} + {renderToggleButton('showStringFormat', 'Format (Str)')} + {renderToggleButton('showStringPattern', 'Pattern (Str)')} + +
+ +
+ {activeTab === 'editor' ? ( + + ) : ( + { + setSchema({ + ...schema, + $defs: newDefs + }); + }} + rootSchema={schema} + visibility={visibility} + /> + )} +
+
+
+
+

JSON Preview

+
+ +
+            {JSON.stringify(schema, null, 2)}
+          
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/PropertyEditor.tsx b/src/components/PropertyEditor.tsx new file mode 100644 index 0000000..6cb8774 --- /dev/null +++ b/src/components/PropertyEditor.tsx @@ -0,0 +1,598 @@ +import { useState } from "react"; +import type { JSONSchemaType } from "../defs/types"; +import styles from "../defs/styles"; +import { ChevronDown, ChevronRight, Trash2, Plus, Edit, Check, X } from 'lucide-react'; +import type {FieldVisibility} from "../defs/interfaces.tsx"; + +const getDefaultSchemaForType = (type: string): JSONSchemaType => { + switch (type) { + case 'string': + return { type: 'string' }; + case 'number': + return { type: 'number' }; + case 'integer': + return { type: 'integer' }; + case 'boolean': + return { type: 'boolean' }; + case 'array': + return { type: 'array', items: { type: 'string' } }; + case 'object': + return { type: 'object', properties: {} }; + case 'null': + return { type: 'null' }; + default: + return {}; + } +}; + +const getTypeColor = (type: string) => { + switch (type) { + case 'string': return styles.stringType; + case 'number': + case 'integer': return styles.numberType; + case 'boolean': return styles.booleanType; + case 'object': return styles.objectType; + case 'array': return styles.arrayType; + case 'null': return styles.nullType; + default: return ''; + } +}; + +export const PropertyEditor = ({ + schema, + path, + onChange, + onDelete, + rootSchema, + onRename, + visibility, + }: { + schema: JSONSchemaType, + path: string, + onChange: (schema: JSONSchemaType) => void, + onDelete?: () => void, + rootSchema: JSONSchemaType, + onRename?: (oldName: string, newName: string) => void, + visibility: FieldVisibility, +}) => { + const propertyName = path.split('.').pop() || (path === 'root' ? 'Schema Root' : 'items'); + const [expanded, setExpanded] = useState(true); + const [isEditingName, setIsEditingName] = useState(false); + const [tempName, setTempName] = useState(propertyName); + + const handleTypeChange = (e: React.ChangeEvent) => { + const newType = e.target.value; + if (newType === '$ref') { + onChange({...schema, $ref: '#'}); + } else { + const newSchema = {...schema}; + if (newSchema.$ref) { + delete newSchema.$ref; + } + + onChange({ + ...newSchema, + ...getDefaultSchemaForType(newType) + }); + } + }; + + const handleFieldChange = (field: string, value: JSONSchemaType[keyof JSONSchemaType]) => { + onChange({...schema, [field]: value}); + }; + + const handleRefChange = (value: string) => { + onChange({...schema, $ref: value}); + }; + + const handleAddProperty = () => { + const properties = schema.properties + ? {...schema.properties} + : {}; + const newPropName = `newProperty${Object.keys(properties).length + 1}`; + properties[newPropName] = {type: 'string'}; + + onChange({ + ...schema, + properties + }); + }; + + const handleAddArrayItem = () => { + if (!schema.items || Array.isArray(schema.items)) { + onChange({ + ...schema, + items: {type: 'string'} + }); + } + }; + + const handleEditName = () => { + if(path === 'root' || path.endsWith('.items')) return; + setTempName(propertyName); + setIsEditingName(true); + }; + + const handleNameSave = () => { + const newName = tempName.trim(); + if(newName && newName !== propertyName){ + onRename?.(propertyName, newName); + } + setIsEditingName(false); + }; + + const handleNameCancel = () => { + setIsEditingName(false); + }; + + const handlePropertyRename = (oldPropName: string, newName: string) => { + if(!newName || !schema.properties || !schema.properties[oldPropName] || schema.properties[newName]) + { + setIsEditingName(false); + return; + } + const currentProperties = {...schema.properties}; + const propertySchema = currentProperties[oldPropName]; + delete currentProperties[oldPropName]; + currentProperties[newName] = propertySchema; + let currentRequired = schema.required ? [...schema.required] : undefined; + if(currentRequired?.includes(oldPropName)) + currentRequired = currentRequired.map(name => (name === oldPropName ? newName : name)); + onChange({ + ...schema, + properties: currentProperties, + required: currentRequired + }); + }; + + + const getRefOptions = () => { + const options: string[] = []; + + options.push('#'); + + if (rootSchema.$defs) { + Object.keys(rootSchema.$defs).forEach(defName => { + options.push(`#/$defs/${defName}`); + }); + } + + return options; + }; + + if (schema.$ref !== undefined) { + return ( +
+
+ {isEditingName ? ( +
+ setTempName(e.target.value)} + className="border border-gray-300 rounded px-2 py-1 mr-2" + /> + + +
+ ) : ( + <> +
+ {propertyName} + +
+
+ + {onDelete && ( + + )} +
+ + )} +
+
+
+ + +
+
+
+ ); + } + + return ( +
+
+ + + {isEditingName ? ( +
+ setTempName(e.target.value)} + className="border border-gray-300 rounded px-2 py-1 mr-2" + /> + + +
+ ) : ( + <> +
+ {propertyName} + {path !== 'root' && ( + + )} +
+
+ + {schema.type || 'any'} + + + {onDelete && ( + + )} +
+ + )} +
+ + {expanded && ( +
+ {visibility.showTitle && ( +
+ + handleFieldChange('title', e.target.value)} + className={styles.input} + placeholder="Schema title" + /> +
+ )} + + {visibility.showDescription && ( +
+ +