Skip to content

Commit d6cad1c

Browse files
authored
Merge pull request #196 from PRO-Robotech/feature/dev
form: refactor, additionalprops new handling, new yaml editor handling, inline yaml editor form item, concurency fix
2 parents d46f01f + 04ffc2d commit d6cad1c

File tree

9 files changed

+850
-47
lines changed

9 files changed

+850
-47
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@prorobotech/openapi-k8s-toolkit",
3-
"version": "0.0.1-alpha.137",
3+
"version": "0.0.1-alpha.138",
44
"description": "ProRobotech OpenAPI k8s tools",
55
"main": "dist/openapi-k8s-toolkit.cjs.js",
66
"module": "dist/openapi-k8s-toolkit.es.js",
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/* eslint-disable no-empty */
2+
/* eslint-disable @typescript-eslint/no-explicit-any */
3+
import React, { FC, useEffect, useMemo, useRef, useState } from 'react'
4+
import { Form } from 'antd'
5+
import Editor from '@monaco-editor/react'
6+
import type * as monaco from 'monaco-editor'
7+
import { TFormName, TPersistedControls } from 'localTypes/form'
8+
import * as yaml from 'yaml'
9+
import { useOnValuesChangeCallback } from '../../organisms/BlackholeForm/context'
10+
11+
export const FormInlineYamlEditor: FC<{
12+
path: TFormName
13+
persistedControls: TPersistedControls
14+
externalValue?: unknown
15+
}> = ({ path, persistedControls, externalValue }) => {
16+
const form = Form.useFormInstance()
17+
const onValuesChange = useOnValuesChangeCallback()
18+
19+
const [yamlText, setYamlText] = useState<string>('')
20+
const monacoRef = useRef<typeof monaco | null>(null)
21+
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null)
22+
const isLocalEditRef = useRef<boolean>(false)
23+
const clearLocalEditTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
24+
const isFocusedRef = useRef<boolean>(false)
25+
26+
// focus is tracked via Monaco events for reliability during IME/composition
27+
28+
const modelUri = useMemo(() => {
29+
const encoded = encodeURIComponent(JSON.stringify(path))
30+
return `inmemory://openapi-ui/unknown/${encoded}.yaml`
31+
}, [path])
32+
33+
// Watch specific field for external updates (e.g., main YAML editor)
34+
const watchedValue = Form.useWatch(path as any, form)
35+
useEffect(() => {
36+
if (isLocalEditRef.current) return
37+
if (isFocusedRef.current) return
38+
const value = watchedValue
39+
const isEmptyObj = value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0
40+
const next = value == null || isEmptyObj ? '' : yaml.stringify(value) ?? ''
41+
setYamlText(prev => (prev === next ? prev : next))
42+
}, [watchedValue])
43+
44+
// Force-sync from externalValue prop (provided by parent Form.Item with shouldUpdate)
45+
useEffect(() => {
46+
if (isLocalEditRef.current) return
47+
if (isFocusedRef.current) return
48+
const value = externalValue
49+
const isEmptyObj = value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0
50+
const next = value == null || isEmptyObj ? '' : yaml.stringify(value) ?? ''
51+
setYamlText(prev => (prev === next ? prev : next))
52+
}, [externalValue])
53+
54+
// Keep model tokenization and content in sync when not locally editing or focused
55+
useEffect(() => {
56+
if (isLocalEditRef.current) return
57+
if (isFocusedRef.current) return
58+
const ed = editorRef.current
59+
const m = monacoRef.current
60+
if (!ed || !m) return
61+
const uri = m.Uri.parse(modelUri)
62+
let model = ed.getModel() || m.editor.getModel(uri)
63+
if (!model) {
64+
model = m.editor.createModel(yamlText ?? '', 'yaml', uri)
65+
}
66+
if (model) {
67+
m.editor.setModelLanguage(model, 'yaml')
68+
const current = model.getValue()
69+
if ((yamlText ?? '') !== current) {
70+
model.setValue(yamlText ?? '')
71+
}
72+
}
73+
}, [yamlText, modelUri])
74+
75+
return (
76+
<div style={{ height: 140 }}>
77+
<Editor
78+
language="yaml"
79+
path={modelUri}
80+
keepCurrentModel
81+
width="100%"
82+
height="100%"
83+
defaultValue={yamlText ?? ''}
84+
onMount={(editor, m) => {
85+
editorRef.current = editor
86+
monacoRef.current = m
87+
// initialize focus state
88+
try {
89+
isFocusedRef.current = !!editor.hasTextFocus?.()
90+
} catch {
91+
isFocusedRef.current = false
92+
}
93+
editor.onDidFocusEditorText(() => {
94+
isFocusedRef.current = true
95+
})
96+
editor.onDidBlurEditorText(() => {
97+
isFocusedRef.current = false
98+
})
99+
const uri = m.Uri.parse(modelUri)
100+
let model = editor.getModel() || m.editor.getModel(uri)
101+
if (!model) {
102+
model = m.editor.createModel(yamlText ?? '', 'yaml', uri)
103+
}
104+
if (model) {
105+
const ensureYaml = () => {
106+
try {
107+
const mm = monacoRef.current
108+
const ee = editorRef.current
109+
if (!mm || !ee) return
110+
const u = mm.Uri.parse(modelUri)
111+
const mdl = ee.getModel() || mm.editor.getModel(u)
112+
if (!mdl) return
113+
const lang = (mdl as any).getLanguageId?.() || (mdl as any).getModeId?.()
114+
if (lang !== 'yaml') {
115+
mm.editor.setModelLanguage(mdl, 'yaml')
116+
}
117+
} catch {}
118+
}
119+
// Initial apply and a few retries to cover lazy language loading/layout timing
120+
ensureYaml()
121+
try {
122+
requestAnimationFrame(() => ensureYaml())
123+
} catch {}
124+
setTimeout(() => ensureYaml(), 50)
125+
setTimeout(() => ensureYaml(), 200)
126+
}
127+
}}
128+
onValidate={() => {
129+
// Re-apply model language to restore tokenization after value changes from outside
130+
const m = monacoRef.current
131+
const ed = editorRef.current
132+
if (!m || !ed) return
133+
const uri = m.Uri.parse(modelUri)
134+
const model = ed.getModel() || m.editor.getModel(uri)
135+
if (model) {
136+
m.editor.setModelLanguage(model, 'yaml')
137+
}
138+
}}
139+
onChange={value => {
140+
const nextText = value || ''
141+
// Debounce local flag longer to avoid flicker with fast inputs and async BFF roundtrip
142+
isLocalEditRef.current = true
143+
if (clearLocalEditTimeoutRef.current) clearTimeout(clearLocalEditTimeoutRef.current)
144+
setYamlText(nextText)
145+
try {
146+
const parsed = yaml.parse(nextText || '')
147+
// Normalize empty content/null/empty object to an empty object value
148+
let nextValue: any
149+
if (!nextText.trim()) {
150+
nextValue = {}
151+
} else if (parsed === null) {
152+
nextValue = {}
153+
} else if (typeof parsed === 'object' && !Array.isArray(parsed) && Object.keys(parsed).length === 0) {
154+
nextValue = {}
155+
} else {
156+
nextValue = parsed
157+
}
158+
form.setFieldValue(path as any, nextValue)
159+
// Mark persisted to prevent cleanup from removing empty object branch
160+
if (!persistedControls.persistedKeys.some(k => JSON.stringify(k) === JSON.stringify(path))) {
161+
persistedControls.onPersistMark(path, 'obj')
162+
}
163+
onValuesChange?.()
164+
} catch {
165+
// ignore parse errors while typing
166+
}
167+
clearLocalEditTimeoutRef.current = setTimeout(() => {
168+
isLocalEditRef.current = false
169+
}, 600)
170+
}}
171+
options={{
172+
minimap: { enabled: false },
173+
lineNumbers: 'off',
174+
glyphMargin: false,
175+
folding: false,
176+
lineDecorationsWidth: 0,
177+
lineNumbersMinChars: 0,
178+
renderLineHighlight: 'none',
179+
scrollbar: { vertical: 'hidden', horizontal: 'auto' },
180+
overviewRulerLanes: 0,
181+
wordWrap: 'on',
182+
}}
183+
/>
184+
</div>
185+
)
186+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './FormInlineYamlEditor'

src/components/molecules/BlackholeForm/molecules/YamlEditor/YamlEditor.tsx

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
/* eslint-disable no-nested-ternary */
3-
import React, { FC, useEffect, useState } from 'react'
3+
import React, { FC, useEffect, useRef, useState } from 'react'
44
import Editor from '@monaco-editor/react'
5+
import type * as monaco from 'monaco-editor'
56
import * as yaml from 'yaml'
67
import { Styled } from './styled'
78

@@ -14,19 +15,98 @@ type TYamlEditProps = {
1415
export const YamlEditor: FC<TYamlEditProps> = ({ theme, currentValues, onChange }) => {
1516
const [yamlData, setYamlData] = useState<string>('')
1617

18+
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null)
19+
const monacoRef = useRef<typeof monaco | null>(null)
20+
const isFocusedRef = useRef<boolean>(false)
21+
const pendingExternalYamlRef = useRef<string | null>(null)
22+
const isApplyingExternalUpdateRef = useRef<boolean>(false)
23+
1724
useEffect(() => {
18-
setYamlData(yaml.stringify(currentValues))
25+
const next = yaml.stringify(currentValues)
26+
if (isFocusedRef.current) {
27+
// Defer applying external updates to avoid cursor jumps while typing
28+
pendingExternalYamlRef.current = next ?? ''
29+
return
30+
}
31+
setYamlData(next ?? '')
1932
}, [currentValues])
2033

34+
useEffect(() => {
35+
// Keep one stable model and enforce yaml language
36+
const editor = editorRef.current
37+
const monaco = monacoRef.current
38+
if (editor && monaco) {
39+
if (isFocusedRef.current) return
40+
const uri = monaco.Uri.parse('inmemory://openapi-ui/form.yaml')
41+
let model = editor.getModel() || monaco.editor.getModel(uri)
42+
43+
if (!model) {
44+
model = monaco.editor.createModel(yamlData ?? '', 'yaml', uri)
45+
}
46+
47+
if (model) {
48+
monaco.editor.setModelLanguage(model, 'yaml')
49+
const current = model.getValue()
50+
51+
if ((yamlData ?? '') !== current) {
52+
// Mark that we are applying an external update so onChange is ignored once
53+
isApplyingExternalUpdateRef.current = true
54+
model.setValue(yamlData ?? '')
55+
}
56+
}
57+
}
58+
}, [yamlData])
59+
2160
return (
2261
<Styled.BorderRadiusContainer>
2362
<Editor
24-
defaultLanguage="yaml"
63+
language="yaml"
64+
path="inmemory://openapi-ui/form.yaml"
65+
keepCurrentModel
2566
width="100%"
2667
height="100%"
27-
value={yamlData}
68+
defaultValue={yamlData ?? ''}
69+
onMount={(editor: monaco.editor.IStandaloneCodeEditor, m: typeof monaco) => {
70+
editorRef.current = editor
71+
monacoRef.current = m
72+
// initialize focus state and listeners to control external updates while typing
73+
try {
74+
isFocusedRef.current = !!editor.hasTextFocus?.()
75+
} catch {
76+
isFocusedRef.current = false
77+
}
78+
editor.onDidFocusEditorText(() => {
79+
isFocusedRef.current = true
80+
})
81+
editor.onDidBlurEditorText(() => {
82+
isFocusedRef.current = false
83+
// Apply any deferred external update after blur
84+
if (pendingExternalYamlRef.current !== null) {
85+
setYamlData(pendingExternalYamlRef.current)
86+
pendingExternalYamlRef.current = null
87+
}
88+
})
89+
const uri = m.Uri.parse('inmemory://openapi-ui/form.yaml')
90+
let model = editor.getModel() || m.editor.getModel(uri)
91+
if (!model) {
92+
model = m.editor.createModel(yamlData ?? '', 'yaml', uri)
93+
}
94+
if (model) {
95+
m.editor.setModelLanguage(model, 'yaml')
96+
}
97+
}}
2898
onChange={value => {
29-
onChange(yaml.parse(value || ''))
99+
// Ignore changes that come from our own programmatic model.setValue
100+
if (isApplyingExternalUpdateRef.current) {
101+
isApplyingExternalUpdateRef.current = false
102+
setYamlData(value || '')
103+
return
104+
}
105+
try {
106+
onChange(yaml.parse(value || ''))
107+
} catch {
108+
// ignore parse errors while typing
109+
}
30110
setYamlData(value || '')
31111
}}
32112
theme={theme === 'dark' ? 'vs-dark' : theme === undefined ? 'vs-dark' : 'vs'}

src/components/molecules/BlackholeForm/molecules/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export * from './FormEnumStringInput'
99
export * from './FormNumberInput'
1010
export * from './FormObjectFromSwagger'
1111
export * from './FormArrayHeader'
12+
export * from './FormInlineYamlEditor'

0 commit comments

Comments
 (0)