feat: refactor JSON schema visual editor with new context and store management

This commit is contained in:
twwu 2025-03-18 15:42:58 +08:00
parent 7a647cf18e
commit 4a93aba8ba
11 changed files with 526 additions and 509 deletions

View File

@ -1,49 +0,0 @@
import {
createContext,
useContext,
useRef,
} from 'react'
import { createJsonSchemaConfigStore } from './store'
import { useMitt } from '@/hooks/use-mitt'
type JsonSchemaConfigStore = ReturnType<typeof createJsonSchemaConfigStore>
type JsonSchemaConfigContextType = JsonSchemaConfigStore | null
type JsonSchemaConfigProviderProps = {
children: React.ReactNode
}
export const JsonSchemaConfigContext = createContext<JsonSchemaConfigContextType>(null)
export const JsonSchemaConfigContextProvider = ({ children }: JsonSchemaConfigProviderProps) => {
const storeRef = useRef<JsonSchemaConfigStore>()
if (!storeRef.current)
storeRef.current = createJsonSchemaConfigStore()
return (
<JsonSchemaConfigContext.Provider value={storeRef.current}>
{children}
</JsonSchemaConfigContext.Provider>
)
}
export const MittContext = createContext<ReturnType<typeof useMitt>>({
emit: () => {},
useSubscribe: () => {},
})
export const MittProvider = ({ children }: { children: React.ReactNode }) => {
const mitt = useMitt()
return (
<MittContext.Provider value={mitt}>
{children}
</MittContext.Provider>
)
}
export const useMittContext = () => {
return useContext(MittContext)
}

View File

@ -2,7 +2,6 @@ import React, { type FC } from 'react'
import Modal from '../../../../../base/modal' import Modal from '../../../../../base/modal'
import type { SchemaRoot } from '../../types' import type { SchemaRoot } from '../../types'
import JsonSchemaConfig from './json-schema-config' import JsonSchemaConfig from './json-schema-config'
import { JsonSchemaConfigContextProvider, MittProvider } from './context'
type JsonSchemaConfigModalProps = { type JsonSchemaConfigModalProps = {
isShow: boolean isShow: boolean
@ -23,15 +22,11 @@ const JsonSchemaConfigModal: FC<JsonSchemaConfigModalProps> = ({
onClose={onClose} onClose={onClose}
className='max-w-[960px] h-[800px] p-0' className='max-w-[960px] h-[800px] p-0'
> >
<MittProvider> <JsonSchemaConfig
<JsonSchemaConfigContextProvider> defaultSchema={defaultSchema}
<JsonSchemaConfig onSave={onSave}
defaultSchema={defaultSchema} onClose={onClose}
onSave={onSave} />
onClose={onClose}
/>
</JsonSchemaConfigContextProvider>
</MittProvider >
</Modal> </Modal>
) )
} }

View File

@ -1,5 +1,5 @@
import React, { type FC, useCallback, useState } from 'react' import React, { type FC, useCallback, useState } from 'react'
import { ArrayType, type Field, type SchemaRoot, Type } from '../../types' import { type SchemaRoot, Type } from '../../types'
import { RiBracesLine, RiCloseLine, RiExternalLinkLine, RiTimelineView } from '@remixicon/react' import { RiBracesLine, RiCloseLine, RiExternalLinkLine, RiTimelineView } from '@remixicon/react'
import { SegmentedControl } from '../../../../../base/segmented-control' import { SegmentedControl } from '../../../../../base/segmented-control'
import JsonSchemaGenerator from './json-schema-generator' import JsonSchemaGenerator from './json-schema-generator'
@ -9,12 +9,8 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import VisualEditor from './visual-editor' import VisualEditor from './visual-editor'
import SchemaEditor from './schema-editor' import SchemaEditor from './schema-editor'
import { useJsonSchemaConfigStore } from './store'
import { useMittContext } from './context'
import type { EditData } from './visual-editor/edit-card'
import produce from 'immer'
import Toast from '@/app/components/base/toast'
import { jsonToSchema } from '../../utils' import { jsonToSchema } from '../../utils'
import { MittProvider, VisualEditorContextProvider } from './visual-editor/context'
type JsonSchemaConfigProps = { type JsonSchemaConfigProps = {
defaultSchema?: SchemaRoot defaultSchema?: SchemaRoot
@ -39,435 +35,17 @@ const DEFAULT_SCHEMA: SchemaRoot = {
additionalProperties: false, additionalProperties: false,
} }
type ChangeEventParams = {
path: string[],
parentPath: string[],
oldFields: EditData,
fields: EditData,
}
type AddEventParams = {
path: string[]
}
const findPropertyWithPath = (target: any, path: string[]) => {
let current = target
for (const key of path)
current = current[key]
return current
}
const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
defaultSchema, defaultSchema,
onSave, onSave,
onClose, onClose,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const backupSchema = useJsonSchemaConfigStore(state => state.backupSchema)
const setBackupSchema = useJsonSchemaConfigStore(state => state.setBackupSchema)
const setIsAddingNewField = useJsonSchemaConfigStore(state => state.setIsAddingNewField)
const setHoveringProperty = useJsonSchemaConfigStore(state => state.setHoveringProperty)
const { emit, useSubscribe } = useMittContext()
const [currentTab, setCurrentTab] = useState(SchemaView.VisualEditor) const [currentTab, setCurrentTab] = useState(SchemaView.VisualEditor)
const [jsonSchema, setJsonSchema] = useState(defaultSchema || DEFAULT_SCHEMA) const [jsonSchema, setJsonSchema] = useState(defaultSchema || DEFAULT_SCHEMA)
const [json, setJson] = useState(JSON.stringify(jsonSchema, null, 2)) const [json, setJson] = useState(JSON.stringify(jsonSchema, null, 2))
const [btnWidth, setBtnWidth] = useState(0) const [btnWidth, setBtnWidth] = useState(0)
useSubscribe('restoreSchema', () => {
if (backupSchema) {
setJsonSchema(backupSchema)
setBackupSchema(null)
}
})
useSubscribe('propertyNameChange', (params) => {
const { parentPath, oldFields, fields } = params as ChangeEventParams
const { name: oldName } = oldFields
const { name: newName } = fields
const newSchema = produce(jsonSchema, (draft) => {
if (oldName === newName) return
const schema = findPropertyWithPath(draft, parentPath) as Field
if (schema.type === Type.object) {
const properties = schema.properties || {}
if (properties[newName]) {
Toast.notify({
type: 'error',
message: 'Property name already exists',
})
emit('restorePropertyName')
return
}
const newProperties = Object.entries(properties).reduce((acc, [key, value]) => {
acc[key === oldName ? newName : key] = value
return acc
}, {} as Record<string, Field>)
const required = schema.required || []
const newRequired = produce(required, (draft) => {
const index = draft.indexOf(oldName)
if (index !== -1)
draft.splice(index, 1, newName)
})
schema.properties = newProperties
schema.required = newRequired
}
if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
const properties = schema.items.properties || {}
if (properties[newName]) {
Toast.notify({
type: 'error',
message: 'Property name already exists',
})
emit('restorePropertyName')
return
}
const newProperties = Object.entries(properties).reduce((acc, [key, value]) => {
acc[key === oldName ? newName : key] = value
return acc
}, {} as Record<string, Field>)
const required = schema.items.required || []
const newRequired = produce(required, (draft) => {
const index = draft.indexOf(oldName)
if (index !== -1)
draft.splice(index, 1, newName)
})
schema.items.properties = newProperties
schema.items.required = newRequired
}
})
setJsonSchema(newSchema)
})
useSubscribe('propertyTypeChange', (params) => {
const { path, oldFields, fields } = params as ChangeEventParams
const { type: oldType } = oldFields
const { type: newType } = fields
if (oldType === newType) return
const newSchema = produce(jsonSchema, (draft) => {
const schema = findPropertyWithPath(draft, path) as Field
if (schema.type === Type.object) {
delete schema.properties
delete schema.required
}
if (schema.type === Type.array)
delete schema.items
switch (newType) {
case Type.object:
schema.type = Type.object
schema.properties = {}
schema.required = []
break
case ArrayType.string:
schema.type = Type.array
schema.items = {
type: Type.string,
}
break
case ArrayType.number:
schema.type = Type.array
schema.items = {
type: Type.number,
}
break
case ArrayType.boolean:
schema.type = Type.array
schema.items = {
type: Type.boolean,
}
break
case ArrayType.object:
schema.type = Type.array
schema.items = {
type: Type.object,
properties: {},
required: [],
}
break
default:
schema.type = newType as Type
}
})
setJsonSchema(newSchema)
})
useSubscribe('propertyRequiredToggle', (params) => {
const { parentPath, fields } = params as ChangeEventParams
const { name } = fields
const newSchema = produce(jsonSchema, (draft) => {
const schema = findPropertyWithPath(draft, parentPath) as Field
if (schema.type === Type.object) {
const required = schema.required || []
const newRequired = required.includes(name)
? required.filter(item => item !== name)
: [...required, name]
schema.required = newRequired
}
if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
const required = schema.items.required || []
const newRequired = required.includes(name)
? required.filter(item => item !== name)
: [...required, name]
schema.items.required = newRequired
}
})
setJsonSchema(newSchema)
})
useSubscribe('propertyOptionsChange', (params) => {
const { path, fields } = params as ChangeEventParams
const newSchema = produce(jsonSchema, (draft) => {
const schema = findPropertyWithPath(draft, path) as Field
schema.description = fields.description
schema.enum = fields.enum
})
setJsonSchema(newSchema)
})
useSubscribe('propertyDelete', (params) => {
const { parentPath, fields } = params as ChangeEventParams
const { name } = fields
const newSchema = produce(jsonSchema, (draft) => {
const schema = findPropertyWithPath(draft, parentPath) as Field
if (schema.type === Type.object && schema.properties) {
delete schema.properties[name]
schema.required = schema.required?.filter(item => item !== name)
}
if (schema.type === Type.array && schema.items?.properties && schema.items?.type === Type.object) {
delete schema.items.properties[name]
schema.items.required = schema.items.required?.filter(item => item !== name)
}
})
setJsonSchema(newSchema)
})
useSubscribe('addField', (params) => {
setBackupSchema(jsonSchema)
const { path } = params as AddEventParams
setIsAddingNewField(true)
const newSchema = produce(jsonSchema, (draft) => {
const schema = findPropertyWithPath(draft, path) as Field
if (schema.type === Type.object) {
schema.properties = {
...(schema.properties || {}),
'': {
type: Type.string,
description: '',
enum: [],
},
}
setHoveringProperty([...path, 'properties', ''].join('.'))
}
if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
schema.items.properties = {
...(schema.items.properties || {}),
'': {
type: Type.string,
description: '',
enum: [],
},
}
setHoveringProperty([...path, 'items', 'properties', ''].join('.'))
}
})
setJsonSchema(newSchema)
})
useSubscribe('fieldChange', (params) => {
let samePropertyNameError = false
const { parentPath, oldFields, fields } = params as ChangeEventParams
const newSchema = produce(jsonSchema, (draft) => {
const parentSchema = findPropertyWithPath(draft, parentPath) as Field
const { name: oldName, type: oldType, required: oldRequired } = oldFields
const { name: newName, type: newType, required: newRequired } = fields
if (parentSchema.type === Type.object && parentSchema.properties) {
// name change
if (oldName !== newName) {
const properties = parentSchema.properties
if (properties[newName]) {
Toast.notify({
type: 'error',
message: 'Property name already exists',
})
samePropertyNameError = true
}
const newProperties = Object.entries(properties).reduce((acc, [key, value]) => {
acc[key === oldName ? newName : key] = value
return acc
}, {} as Record<string, Field>)
const requiredProperties = parentSchema.required || []
const newRequiredProperties = produce(requiredProperties, (draft) => {
const index = draft.indexOf(oldName)
if (index !== -1)
draft.splice(index, 1, newName)
})
parentSchema.properties = newProperties
parentSchema.required = newRequiredProperties
}
// required change
if (oldRequired !== newRequired) {
const required = parentSchema.required || []
const newRequired = required.includes(newName)
? required.filter(item => item !== newName)
: [...required, newName]
parentSchema.required = newRequired
}
const schema = parentSchema.properties[newName]
// type change
if (oldType !== newType) {
if (schema.type === Type.object) {
delete schema.properties
delete schema.required
}
if (schema.type === Type.array)
delete schema.items
switch (newType) {
case Type.object:
schema.type = Type.object
schema.properties = {}
schema.required = []
break
case ArrayType.string:
schema.type = Type.array
schema.items = {
type: Type.string,
}
break
case ArrayType.number:
schema.type = Type.array
schema.items = {
type: Type.number,
}
break
case ArrayType.boolean:
schema.type = Type.array
schema.items = {
type: Type.boolean,
}
break
case ArrayType.object:
schema.type = Type.array
schema.items = {
type: Type.object,
properties: {},
required: [],
}
break
default:
schema.type = newType as Type
}
}
// other options change
schema.description = fields.description
schema.enum = fields.enum
}
if (parentSchema.type === Type.array && parentSchema.items && parentSchema.items.type === Type.object && parentSchema.items.properties) {
// name change
if (oldName !== newName) {
const properties = parentSchema.items.properties || {}
if (properties[newName]) {
Toast.notify({
type: 'error',
message: 'Property name already exists',
})
samePropertyNameError = true
}
const newProperties = Object.entries(properties).reduce((acc, [key, value]) => {
acc[key === oldName ? newName : key] = value
return acc
}, {} as Record<string, Field>)
const required = parentSchema.items.required || []
const newRequired = produce(required, (draft) => {
const index = draft.indexOf(oldName)
if (index !== -1)
draft.splice(index, 1, newName)
})
parentSchema.items.properties = newProperties
parentSchema.items.required = newRequired
}
// required change
if (oldRequired !== newRequired) {
const required = parentSchema.items.required || []
const newRequired = required.includes(newName)
? required.filter(item => item !== newName)
: [...required, newName]
parentSchema.items.required = newRequired
}
const schema = parentSchema.items.properties[newName]
// type change
if (oldType !== newType) {
if (schema.type === Type.object) {
delete schema.properties
delete schema.required
}
if (schema.type === Type.array)
delete schema.items
switch (newType) {
case Type.object:
schema.type = Type.object
schema.properties = {}
schema.required = []
break
case ArrayType.string:
schema.type = Type.array
schema.items = {
type: Type.string,
}
break
case ArrayType.number:
schema.type = Type.array
schema.items = {
type: Type.number,
}
break
case ArrayType.boolean:
schema.type = Type.array
schema.items = {
type: Type.boolean,
}
break
case ArrayType.object:
schema.type = Type.array
schema.items = {
type: Type.object,
properties: {},
required: [],
}
break
default:
schema.type = newType as Type
}
}
// other options change
schema.description = fields.description
schema.enum = fields.enum
}
})
if (samePropertyNameError) return
setJsonSchema(newSchema)
emit('fieldChangeSuccess')
})
const updateBtnWidth = useCallback((width: number) => { const updateBtnWidth = useCallback((width: number) => {
setBtnWidth(width + 32) setBtnWidth(width + 32)
}, []) }, [])
@ -481,6 +59,10 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
setJsonSchema(jsonSchema) setJsonSchema(jsonSchema)
}, []) }, [])
const handleVisualEditorUpdate = useCallback((schema: SchemaRoot) => {
setJsonSchema(schema)
}, [])
const handleSchemaEditorUpdate = useCallback((schema: string) => { const handleSchemaEditorUpdate = useCallback((schema: string) => {
setJson(schema) setJson(schema)
}, []) }, [])
@ -535,7 +117,14 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
</div> </div>
<div className='px-6 grow overflow-hidden'> <div className='px-6 grow overflow-hidden'>
{currentTab === SchemaView.VisualEditor && ( {currentTab === SchemaView.VisualEditor && (
<VisualEditor schema={jsonSchema} /> <MittProvider>
<VisualEditorContextProvider>
<VisualEditor
schema={jsonSchema}
onChange={handleVisualEditorUpdate}
/>
</VisualEditorContextProvider>
</MittProvider>
)} )}
{currentTab === SchemaView.JsonSchema && ( {currentTab === SchemaView.JsonSchema && (
<SchemaEditor <SchemaEditor

View File

@ -1,13 +1,13 @@
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import { RiAddCircleFill } from '@remixicon/react' import { RiAddCircleFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useJsonSchemaConfigStore } from '../store' import { useVisualEditorStore } from './store'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useMittContext } from '../context' import { useMittContext } from './context'
const AddField = () => { const AddField = () => {
const { t } = useTranslation() const { t } = useTranslation()
const setIsAddingNewField = useJsonSchemaConfigStore(state => state.setIsAddingNewField) const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
const { emit } = useMittContext() const { emit } = useMittContext()
const handleAddField = useCallback(() => { const handleAddField = useCallback(() => {

View File

@ -0,0 +1,49 @@
import {
createContext,
useContext,
useRef,
} from 'react'
import { createVisualEditorStore } from './store'
import { useMitt } from '@/hooks/use-mitt'
type VisualEditorStore = ReturnType<typeof createVisualEditorStore>
type VisualEditorContextType = VisualEditorStore | null
type VisualEditorProviderProps = {
children: React.ReactNode
}
export const VisualEditorContext = createContext<VisualEditorContextType>(null)
export const VisualEditorContextProvider = ({ children }: VisualEditorProviderProps) => {
const storeRef = useRef<VisualEditorStore>()
if (!storeRef.current)
storeRef.current = createVisualEditorStore()
return (
<VisualEditorContext.Provider value={storeRef.current}>
{children}
</VisualEditorContext.Provider>
)
}
export const MittContext = createContext<ReturnType<typeof useMitt>>({
emit: () => {},
useSubscribe: () => {},
})
export const MittProvider = ({ children }: { children: React.ReactNode }) => {
const mitt = useMitt()
return (
<MittContext.Provider value={mitt}>
{children}
</MittContext.Provider>
)
}
export const useMittContext = () => {
return useContext(MittContext)
}

View File

@ -10,8 +10,8 @@ import AdvancedActions from './advanced-actions'
import AdvancedOptions, { type AdvancedOptionsType } from './advanced-options' import AdvancedOptions, { type AdvancedOptionsType } from './advanced-options'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import classNames from '@/utils/classnames' import classNames from '@/utils/classnames'
import { useJsonSchemaConfigStore } from '../../store' import { useVisualEditorStore } from '../store'
import { useMittContext } from '../../context' import { useMittContext } from '../context'
import produce from 'immer' import produce from 'immer'
import { useUnmount } from 'ahooks' import { useUnmount } from 'ahooks'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config' import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
@ -56,10 +56,10 @@ const EditCard: FC<EditCardProps> = ({
const { t } = useTranslation() const { t } = useTranslation()
const [currentFields, setCurrentFields] = useState(fields) const [currentFields, setCurrentFields] = useState(fields)
const [backupFields, setBackupFields] = useState<EditData | null>(null) const [backupFields, setBackupFields] = useState<EditData | null>(null)
const isAddingNewField = useJsonSchemaConfigStore(state => state.isAddingNewField) const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
const setIsAddingNewField = useJsonSchemaConfigStore(state => state.setIsAddingNewField) const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
const advancedEditing = useJsonSchemaConfigStore(state => state.advancedEditing) const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
const setAdvancedEditing = useJsonSchemaConfigStore(state => state.setAdvancedEditing) const setAdvancedEditing = useVisualEditorStore(state => state.setAdvancedEditing)
const { emit, useSubscribe } = useMittContext() const { emit, useSubscribe } = useMittContext()
const blurWithActions = useRef(false) const blurWithActions = useRef(false)

View File

@ -0,0 +1,423 @@
import produce from 'immer'
import type { VisualEditorProps } from '.'
import { useMittContext } from './context'
import { useVisualEditorStore } from './store'
import type { EditData } from './edit-card'
import { ArrayType, type Field, Type } from '../../../types'
import Toast from '@/app/components/base/toast'
import { findPropertyWithPath } from '../../../utils'
type ChangeEventParams = {
path: string[],
parentPath: string[],
oldFields: EditData,
fields: EditData,
}
type AddEventParams = {
path: string[]
}
export const useSchemaNodeOperations = (props: VisualEditorProps) => {
const { schema: jsonSchema, onChange } = props
const backupSchema = useVisualEditorStore(state => state.backupSchema)
const setBackupSchema = useVisualEditorStore(state => state.setBackupSchema)
const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
const setHoveringProperty = useVisualEditorStore(state => state.setHoveringProperty)
const { emit, useSubscribe } = useMittContext()
useSubscribe('restoreSchema', () => {
if (backupSchema) {
onChange(backupSchema)
setBackupSchema(null)
}
})
useSubscribe('propertyNameChange', (params) => {
const { parentPath, oldFields, fields } = params as ChangeEventParams
const { name: oldName } = oldFields
const { name: newName } = fields
const newSchema = produce(jsonSchema, (draft) => {
if (oldName === newName) return
const schema = findPropertyWithPath(draft, parentPath) as Field
if (schema.type === Type.object) {
const properties = schema.properties || {}
if (properties[newName]) {
Toast.notify({
type: 'error',
message: 'Property name already exists',
})
emit('restorePropertyName')
return
}
const newProperties = Object.entries(properties).reduce((acc, [key, value]) => {
acc[key === oldName ? newName : key] = value
return acc
}, {} as Record<string, Field>)
const required = schema.required || []
const newRequired = produce(required, (draft) => {
const index = draft.indexOf(oldName)
if (index !== -1)
draft.splice(index, 1, newName)
})
schema.properties = newProperties
schema.required = newRequired
}
if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
const properties = schema.items.properties || {}
if (properties[newName]) {
Toast.notify({
type: 'error',
message: 'Property name already exists',
})
emit('restorePropertyName')
return
}
const newProperties = Object.entries(properties).reduce((acc, [key, value]) => {
acc[key === oldName ? newName : key] = value
return acc
}, {} as Record<string, Field>)
const required = schema.items.required || []
const newRequired = produce(required, (draft) => {
const index = draft.indexOf(oldName)
if (index !== -1)
draft.splice(index, 1, newName)
})
schema.items.properties = newProperties
schema.items.required = newRequired
}
})
onChange(newSchema)
})
useSubscribe('propertyTypeChange', (params) => {
const { path, oldFields, fields } = params as ChangeEventParams
const { type: oldType } = oldFields
const { type: newType } = fields
if (oldType === newType) return
const newSchema = produce(jsonSchema, (draft) => {
const schema = findPropertyWithPath(draft, path) as Field
if (schema.type === Type.object) {
delete schema.properties
delete schema.required
}
if (schema.type === Type.array)
delete schema.items
switch (newType) {
case Type.object:
schema.type = Type.object
schema.properties = {}
schema.required = []
break
case ArrayType.string:
schema.type = Type.array
schema.items = {
type: Type.string,
}
break
case ArrayType.number:
schema.type = Type.array
schema.items = {
type: Type.number,
}
break
case ArrayType.boolean:
schema.type = Type.array
schema.items = {
type: Type.boolean,
}
break
case ArrayType.object:
schema.type = Type.array
schema.items = {
type: Type.object,
properties: {},
required: [],
}
break
default:
schema.type = newType as Type
}
})
onChange(newSchema)
})
useSubscribe('propertyRequiredToggle', (params) => {
const { parentPath, fields } = params as ChangeEventParams
const { name } = fields
const newSchema = produce(jsonSchema, (draft) => {
const schema = findPropertyWithPath(draft, parentPath) as Field
if (schema.type === Type.object) {
const required = schema.required || []
const newRequired = required.includes(name)
? required.filter(item => item !== name)
: [...required, name]
schema.required = newRequired
}
if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
const required = schema.items.required || []
const newRequired = required.includes(name)
? required.filter(item => item !== name)
: [...required, name]
schema.items.required = newRequired
}
})
onChange(newSchema)
})
useSubscribe('propertyOptionsChange', (params) => {
const { path, fields } = params as ChangeEventParams
const newSchema = produce(jsonSchema, (draft) => {
const schema = findPropertyWithPath(draft, path) as Field
schema.description = fields.description
schema.enum = fields.enum
})
onChange(newSchema)
})
useSubscribe('propertyDelete', (params) => {
const { parentPath, fields } = params as ChangeEventParams
const { name } = fields
const newSchema = produce(jsonSchema, (draft) => {
const schema = findPropertyWithPath(draft, parentPath) as Field
if (schema.type === Type.object && schema.properties) {
delete schema.properties[name]
schema.required = schema.required?.filter(item => item !== name)
}
if (schema.type === Type.array && schema.items?.properties && schema.items?.type === Type.object) {
delete schema.items.properties[name]
schema.items.required = schema.items.required?.filter(item => item !== name)
}
})
onChange(newSchema)
})
useSubscribe('addField', (params) => {
setBackupSchema(jsonSchema)
const { path } = params as AddEventParams
setIsAddingNewField(true)
const newSchema = produce(jsonSchema, (draft) => {
const schema = findPropertyWithPath(draft, path) as Field
if (schema.type === Type.object) {
schema.properties = {
...(schema.properties || {}),
'': {
type: Type.string,
description: '',
enum: [],
},
}
setHoveringProperty([...path, 'properties', ''].join('.'))
}
if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
schema.items.properties = {
...(schema.items.properties || {}),
'': {
type: Type.string,
description: '',
enum: [],
},
}
setHoveringProperty([...path, 'items', 'properties', ''].join('.'))
}
})
onChange(newSchema)
})
useSubscribe('fieldChange', (params) => {
let samePropertyNameError = false
const { parentPath, oldFields, fields } = params as ChangeEventParams
const newSchema = produce(jsonSchema, (draft) => {
const parentSchema = findPropertyWithPath(draft, parentPath) as Field
const { name: oldName, type: oldType, required: oldRequired } = oldFields
const { name: newName, type: newType, required: newRequired } = fields
if (parentSchema.type === Type.object && parentSchema.properties) {
// name change
if (oldName !== newName) {
const properties = parentSchema.properties
if (properties[newName]) {
Toast.notify({
type: 'error',
message: 'Property name already exists',
})
samePropertyNameError = true
}
const newProperties = Object.entries(properties).reduce((acc, [key, value]) => {
acc[key === oldName ? newName : key] = value
return acc
}, {} as Record<string, Field>)
const requiredProperties = parentSchema.required || []
const newRequiredProperties = produce(requiredProperties, (draft) => {
const index = draft.indexOf(oldName)
if (index !== -1)
draft.splice(index, 1, newName)
})
parentSchema.properties = newProperties
parentSchema.required = newRequiredProperties
}
// required change
if (oldRequired !== newRequired) {
const required = parentSchema.required || []
const newRequired = required.includes(newName)
? required.filter(item => item !== newName)
: [...required, newName]
parentSchema.required = newRequired
}
const schema = parentSchema.properties[newName]
// type change
if (oldType !== newType) {
if (schema.type === Type.object) {
delete schema.properties
delete schema.required
}
if (schema.type === Type.array)
delete schema.items
switch (newType) {
case Type.object:
schema.type = Type.object
schema.properties = {}
schema.required = []
break
case ArrayType.string:
schema.type = Type.array
schema.items = {
type: Type.string,
}
break
case ArrayType.number:
schema.type = Type.array
schema.items = {
type: Type.number,
}
break
case ArrayType.boolean:
schema.type = Type.array
schema.items = {
type: Type.boolean,
}
break
case ArrayType.object:
schema.type = Type.array
schema.items = {
type: Type.object,
properties: {},
required: [],
}
break
default:
schema.type = newType as Type
}
}
// other options change
schema.description = fields.description
schema.enum = fields.enum
}
if (parentSchema.type === Type.array && parentSchema.items && parentSchema.items.type === Type.object && parentSchema.items.properties) {
// name change
if (oldName !== newName) {
const properties = parentSchema.items.properties || {}
if (properties[newName]) {
Toast.notify({
type: 'error',
message: 'Property name already exists',
})
samePropertyNameError = true
}
const newProperties = Object.entries(properties).reduce((acc, [key, value]) => {
acc[key === oldName ? newName : key] = value
return acc
}, {} as Record<string, Field>)
const required = parentSchema.items.required || []
const newRequired = produce(required, (draft) => {
const index = draft.indexOf(oldName)
if (index !== -1)
draft.splice(index, 1, newName)
})
parentSchema.items.properties = newProperties
parentSchema.items.required = newRequired
}
// required change
if (oldRequired !== newRequired) {
const required = parentSchema.items.required || []
const newRequired = required.includes(newName)
? required.filter(item => item !== newName)
: [...required, newName]
parentSchema.items.required = newRequired
}
const schema = parentSchema.items.properties[newName]
// type change
if (oldType !== newType) {
if (schema.type === Type.object) {
delete schema.properties
delete schema.required
}
if (schema.type === Type.array)
delete schema.items
switch (newType) {
case Type.object:
schema.type = Type.object
schema.properties = {}
schema.required = []
break
case ArrayType.string:
schema.type = Type.array
schema.items = {
type: Type.string,
}
break
case ArrayType.number:
schema.type = Type.array
schema.items = {
type: Type.number,
}
break
case ArrayType.boolean:
schema.type = Type.array
schema.items = {
type: Type.boolean,
}
break
case ArrayType.object:
schema.type = Type.array
schema.items = {
type: Type.object,
properties: {},
required: [],
}
break
default:
schema.type = newType as Type
}
}
// other options change
schema.description = fields.description
schema.enum = fields.enum
}
})
if (samePropertyNameError) return
onChange(newSchema)
emit('fieldChangeSuccess')
})
}

View File

@ -1,14 +1,17 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { SchemaRoot } from '../../../types' import type { SchemaRoot } from '../../../types'
import SchemaNode from './schema-node' import SchemaNode from './schema-node'
import { useSchemaNodeOperations } from './hooks'
type VisualEditorProps = { export type VisualEditorProps = {
schema: SchemaRoot schema: SchemaRoot
onChange: (schema: SchemaRoot) => void
} }
const VisualEditor: FC<VisualEditorProps> = ({ const VisualEditor: FC<VisualEditorProps> = (props) => {
schema, const { schema } = props
}) => { useSchemaNodeOperations(props)
return ( return (
<div className='h-full rounded-xl p-1 pl-2 bg-background-section-burn overflow-auto'> <div className='h-full rounded-xl p-1 pl-2 bg-background-section-burn overflow-auto'>
<SchemaNode <SchemaNode

View File

@ -7,7 +7,7 @@ import { getFieldType, getHasChildren } from '../../../utils'
import Divider from '@/app/components/base/divider' import Divider from '@/app/components/base/divider'
import EditCard from './edit-card' import EditCard from './edit-card'
import Card from './card' import Card from './card'
import { useJsonSchemaConfigStore } from '../store' import { useVisualEditorStore } from './store'
import { useDebounceFn } from 'ahooks' import { useDebounceFn } from 'ahooks'
import AddField from './add-field' import AddField from './add-field'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config' import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
@ -56,10 +56,10 @@ const SchemaNode: FC<SchemaNodeProps> = ({
depth, depth,
}) => { }) => {
const [isExpanded, setIsExpanded] = useState(true) const [isExpanded, setIsExpanded] = useState(true)
const hoveringProperty = useJsonSchemaConfigStore(state => state.hoveringProperty) const hoveringProperty = useVisualEditorStore(state => state.hoveringProperty)
const setHoveringProperty = useJsonSchemaConfigStore(state => state.setHoveringProperty) const setHoveringProperty = useVisualEditorStore(state => state.setHoveringProperty)
const isAddingNewField = useJsonSchemaConfigStore(state => state.isAddingNewField) const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
const advancedEditing = useJsonSchemaConfigStore(state => state.advancedEditing) const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
const { run: setHoveringPropertyDebounced } = useDebounceFn((path: string) => { const { run: setHoveringPropertyDebounced } = useDebounceFn((path: string) => {
setHoveringProperty(path) setHoveringProperty(path)

View File

@ -1,9 +1,9 @@
import { useContext } from 'react' import { useContext } from 'react'
import { createStore, useStore } from 'zustand' import { createStore, useStore } from 'zustand'
import type { SchemaRoot } from '../../types' import type { SchemaRoot } from '../../../types'
import { JsonSchemaConfigContext } from './context' import { VisualEditorContext } from './context'
type JsonSchemaConfigStore = { type VisualEditorStore = {
hoveringProperty: string | '' hoveringProperty: string | ''
setHoveringProperty: (propertyPath: string) => void setHoveringProperty: (propertyPath: string) => void
isAddingNewField: boolean isAddingNewField: boolean
@ -14,7 +14,7 @@ type JsonSchemaConfigStore = {
setBackupSchema: (schema: SchemaRoot | null) => void setBackupSchema: (schema: SchemaRoot | null) => void
} }
export const createJsonSchemaConfigStore = () => createStore<JsonSchemaConfigStore>(set => ({ export const createVisualEditorStore = () => createStore<VisualEditorStore>(set => ({
hoveringProperty: '', hoveringProperty: '',
setHoveringProperty: (propertyPath: string) => set({ hoveringProperty: propertyPath }), setHoveringProperty: (propertyPath: string) => set({ hoveringProperty: propertyPath }),
isAddingNewField: false, isAddingNewField: false,
@ -25,10 +25,10 @@ export const createJsonSchemaConfigStore = () => createStore<JsonSchemaConfigSto
setBackupSchema: (schema: SchemaRoot | null) => set({ backupSchema: schema }), setBackupSchema: (schema: SchemaRoot | null) => set({ backupSchema: schema }),
})) }))
export const useJsonSchemaConfigStore = <T>(selector: (state: JsonSchemaConfigStore) => T): T => { export const useVisualEditorStore = <T>(selector: (state: VisualEditorStore) => T): T => {
const store = useContext(JsonSchemaConfigContext) const store = useContext(VisualEditorContext)
if (!store) if (!store)
throw new Error('Missing JsonSchemaConfigContext.Provider in the tree') throw new Error('Missing VisualEditorContext.Provider in the tree')
return useStore(store, selector) return useStore(store, selector)
} }

View File

@ -72,3 +72,10 @@ export const checkDepth = (json: any, currentDepth = 1) => {
} }
return maxDepth return maxDepth
} }
export const findPropertyWithPath = (target: any, path: string[]) => {
let current = target
for (const key of path)
current = current[key]
return current
}