Skip to content

Commit fdbe684

Browse files
authored
Merge pull request #202 from PRO-Robotech/feature/dev
fix expanded
2 parents 3fa5b72 + 60b90e5 commit fdbe684

File tree

3 files changed

+165
-24
lines changed

3 files changed

+165
-24
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.147",
3+
"version": "0.0.1-alpha.148",
44
"description": "ProRobotech OpenAPI k8s tools",
55
"main": "dist/openapi-k8s-toolkit.cjs.js",
66
"module": "dist/openapi-k8s-toolkit.es.js",

src/components/molecules/BlackholeForm/organisms/BlackholeForm/BlackholeForm.tsx

Lines changed: 162 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,34 @@ export const BlackholeForm: FC<TBlackholeFormCreateProps> = ({
117117

118118
const [isDebugModalOpen, setIsDebugModalOpen] = useState<boolean>(false)
119119

120-
const [expandedKeys, setExpandedKeys] = useState<TFormName[]>(expandedPaths || [])
120+
// Create a React state variable called `expandedKeys` to store the current expanded form paths.
121+
// `_setExpandedKeys` is the internal setter returned by `useState`, but we’ll wrap it below.
122+
const [expandedKeys, _setExpandedKeys] = useState<TFormName[]>(expandedPaths || [])
123+
// Create a mutable ref that always holds the latest value of `expandedKeys`.
124+
// Unlike React state, updating a ref does *not* trigger a re-render —
125+
// this lets us access the most recent expansion list even inside stale closures or async callbacks.
126+
const expandedKeysRef = useRef<TFormName[]>(expandedPaths || [])
127+
// Define our own wrapper function `setExpandedKeys`
128+
// so we can update *both* the state and the ref at the same time.
129+
// This ensures they always stay in sync.
130+
const setExpandedKeys = (
131+
next: TFormName[] | ((prev: TFormName[]) => TFormName[]), // can pass a new array OR an updater function
132+
) => {
133+
// Call the internal React setter `_setExpandedKeys`.
134+
// It can accept a callback that receives the previous value (`prev`).
135+
_setExpandedKeys(prev => {
136+
// Determine the new value:
137+
// - If `next` is a function, call it with the previous array to get the new one.
138+
// - If it’s already an array, use it directly.
139+
const value = typeof next === 'function' ? (next as (p: TFormName[]) => TFormName[])(prev) : next
140+
// Immediately update the ref so it always mirrors the latest value.
141+
// This is crucial because state updates are asynchronous,
142+
// but ref updates are synchronous and happen right away.
143+
expandedKeysRef.current = value
144+
// 7️⃣ Return the new value to React so it updates the state as usual.
145+
return value
146+
})
147+
}
121148
const [persistedKeys, setPersistedKeys] = useState<TFormName[]>(persistedPaths || [])
122149
const [resolvedHiddenPaths, setResolvedHiddenPaths] = useState<TFormName[]>([])
123150

@@ -493,7 +520,7 @@ export const BlackholeForm: FC<TBlackholeFormCreateProps> = ({
493520
* Builds the payload and triggers the debounced sync-to-YAML call.
494521
*/
495522
const onValuesChangeCallback = useCallback(
496-
(values?: any) => {
523+
(values?: any, changedValues?: any) => {
497524
// Get the most recent form values (or use the provided ones)
498525
const vRaw = values ?? form.getFieldsValue(true)
499526
const v = scrubLiteralWildcardKeys(vRaw)
@@ -535,38 +562,111 @@ export const BlackholeForm: FC<TBlackholeFormCreateProps> = ({
535562
group('values change')
536563
dbg('values snapshot keys', Object.keys(v || {}))
537564

565+
// 🔹 Helper function to extract a "root" or "head" portion of a path array.
566+
// Example: if a full path is ['spec', 'addressGroups', 0, 'name']
567+
// then headPath(p) → ['spec', 'addressGroups', 0]
568+
// This helps us identify which top-level object/array the change happened in.
569+
const headPath = (p: (string | number)[]) => {
570+
// You can control how deep you consider something to be the "root" of a change.
571+
// slice(0, 3) means we take the first three segments.
572+
// So a path like ['spec','containers',0,'env',0,'name'] becomes ['spec','containers',0].
573+
// If you want broader or narrower scoping, adjust this number.
574+
return p.slice(0, 3)
575+
}
576+
577+
// 🔹 Create a Set to hold the unique "root paths" of everything that changed in this render.
578+
// We’ll use these roots later to decide which arrays are safe to purge expansions for.
579+
const changedRoots = new Set<string>()
580+
581+
// 🔹 If Ant Design’s `onValuesChange` gave us a `changedValues` object (it does),
582+
// and it’s a normal object, collect all the individual field paths inside it.
583+
if (changedValues && typeof changedValues === 'object') {
584+
// `getAllPathsFromObj` returns arrays of keys/indexes for every nested field.
585+
// Example:
586+
// changedValues = { spec: { addressGroups: [ { name: "new" } ] } }
587+
// → getAllPathsFromObj(changedValues)
588+
// returns [ ['spec'], ['spec','addressGroups'], ['spec','addressGroups',0], ['spec','addressGroups',0,'name'] ]
589+
const changedPaths = getAllPathsFromObj(changedValues)
590+
591+
// 🔹 For each changed path, derive its "root" using headPath(),
592+
// then store it as a JSON string in our Set.
593+
// Using JSON.stringify lets us easily compare path arrays later.
594+
for (const p of changedPaths) {
595+
changedRoots.add(JSON.stringify(headPath(p)))
596+
}
597+
}
598+
538599
const newLengths = collectArrayLengths(v)
539600
const prevLengths = prevArrayLengthsRef.current
540601

541-
// If you delete arr el and then add it again. There is no purge
542-
// Adding purge:
602+
// We previously had no purge when you delete an array element and add it again.
603+
// This block adds a *safe* purge for array SHRIΝKs (when items are actually removed).
604+
543605
// --- handle SHRINK: indices removed ---
606+
// IMPORTANT: Only treat an array as "shrunk" if it appears in *both* snapshots.
607+
// If it's missing from `newLengths`, we don't know its real length (could be a transient omission),
608+
// so we must NOT assume length 0 and accidentally purge unrelated expansions.
544609
for (const [k, prevLen] of prevLengths.entries()) {
545-
const newLen = newLengths.get(k) ?? 0
610+
if (!newLengths.has(k)) {
611+
// Array is absent in the new snapshot → consider length "unknown", skip purging.
612+
// (Prevents false positives where another part of the form caused a temporary omission.)
613+
// eslint-disable-next-line no-continue
614+
continue
615+
}
616+
617+
// Safe: the array exists in both snapshots; compare lengths.
618+
const newLen = newLengths.get(k)!
546619
if (newLen < prevLen) {
620+
// We detected a real shrink: some trailing indices were removed.
547621
const arrayPath = JSON.parse(k) as (string | number)[]
622+
623+
// OPTIONAL SCOPE: If we captured change roots via `changedValues`,
624+
// only purge when this array is inside one of those roots.
625+
// This prevents edits under A from purging expansions under B.
626+
if (changedRoots.size) {
627+
const shouldPurge = [...changedRoots].some(rootJson =>
628+
isPrefix(arrayPath, JSON.parse(rootJson) as (string | number)[]),
629+
)
630+
if (!shouldPurge) {
631+
console.debug('[shrink] skipped unrelated array:', arrayPath)
632+
// eslint-disable-next-line no-continue
633+
continue
634+
}
635+
}
636+
637+
// Purge UI state for each removed index (from newLen up to prevLen - 1).
548638
for (let i = newLen; i < prevLen; i++) {
549-
// purge UI state + tombstones under removed index
550-
const removedPrefix = [...arrayPath, i]
639+
const removedPrefix = [...arrayPath, i] // e.g., ['spec','addressGroups', 2]
551640

552-
// drop expansions/persisted under this subtree
553-
setExpandedKeys(prev =>
554-
prev.filter(p => {
641+
// Drop EXPANSION state anywhere under the removed element's subtree.
642+
// (Prevents "phantom" expanded panels for items that no longer exist.)
643+
setExpandedKeys(prev => {
644+
const before = prev.length
645+
const next = prev.filter(p => {
555646
const full = Array.isArray(p) ? p : [p]
556647
return !isPrefix(full, removedPrefix)
557-
}),
558-
)
559-
setPersistedKeys(prev =>
560-
prev.filter(p => {
648+
})
649+
console.debug('[shrink] expanded pruned:', before - next.length, 'under', removedPrefix)
650+
return next
651+
})
652+
653+
// Drop PERSISTED markers under the same subtree.
654+
// (Prevents keeping persistence flags for fields that were deleted.)
655+
setPersistedKeys(prev => {
656+
const before = prev.length
657+
const next = prev.filter(p => {
561658
const full = Array.isArray(p) ? p : [p]
562659
return !isPrefix(full, removedPrefix)
563-
}),
564-
)
660+
})
661+
console.debug('[shrink] persisted pruned:', before - next.length, 'under', removedPrefix)
662+
return next
663+
})
565664

566-
// clear any blocks (tombstones) beneath removed index
567-
for (const k of [...blockedPathsRef.current]) {
568-
const path = JSON.parse(k) as (string | number)[]
569-
if (isPrefix(path, removedPrefix)) blockedPathsRef.current.delete(k)
665+
// Clear any "tombstone" blocks for paths under the removed element,
666+
// so that if a new element is added later, it won't be blocked from materializing.
667+
for (const bk of [...blockedPathsRef.current]) {
668+
const path = JSON.parse(bk) as (string | number)[]
669+
if (isPrefix(path, removedPrefix)) blockedPathsRef.current.delete(bk)
570670
}
571671
}
572672
}
@@ -710,6 +810,22 @@ export const BlackholeForm: FC<TBlackholeFormCreateProps> = ({
710810
if (dataPathSet.has(k)) blockedPathsRef.current.delete(k)
711811
})
712812

813+
// 🧩 When YAML → values sync finishes, the backend sends a brand-new `data` object,
814+
// and we immediately call `form.setFieldsValue(data)` to replace all form fields.
815+
//
816+
// ⚠️ That replacement often causes parts of the form UI (especially dynamic arrays
817+
// and nested objects) to unmount and remount — because React sees new keys,
818+
// new field paths, or different schema nodes.
819+
//
820+
// 👇 This line restores the user’s previous expansion state (`expandedKeysRef.current`)
821+
// right after we inject the new values, so any sections the user had expanded
822+
// stay visible instead of collapsing back to their default closed state.
823+
//
824+
// TL;DR — Without this line, every time YAML updates the form, all expanded panels
825+
// would snap shut; this re-applies the last known expansion model immediately
826+
// to preserve a stable, intuitive editing experience.
827+
setExpandedKeys(expandedKeysRef.current)
828+
713829
// --- Bring schema in sync: prune missing additional props, then materialize new ones ---
714830
setProperties(prevProps => {
715831
// Remove additionalProperties entries that are now absent or blocked
@@ -742,6 +858,27 @@ export const BlackholeForm: FC<TBlackholeFormCreateProps> = ({
742858
return merged
743859
})
744860
}
861+
862+
// 🧠 Why we need this:
863+
// Updating `setProperties(...)` can cause the form schema to re-render.
864+
// That re-render might temporarily unmount and recreate form sections
865+
// (especially when `additionalProperties` or dynamic arrays change).
866+
//
867+
// ⚠️ React applies the `setProperties` state update asynchronously.
868+
// If we immediately call `setExpandedKeys` *inside* this callback,
869+
// the UI may not yet reflect the new schema — our expansion restore
870+
// could run too early and be lost during the render that follows.
871+
//
872+
// ✅ Wrapping it in `queueMicrotask(...)` schedules the restore to run
873+
// right after React finishes applying the `setProperties` update,
874+
// but before the browser paints. That guarantees the expansion state
875+
// is re-applied *after* the new schema has stabilized.
876+
//
877+
// TL;DR — Wait one micro-tick after schema update, then re-apply expansions
878+
// so newly materialized fields appear in the correct expanded state
879+
// and nothing collapses due to the schema refresh.
880+
queueMicrotask(() => setExpandedKeys(expandedKeysRef.current))
881+
745882
return materialized
746883
})
747884
})
@@ -1027,7 +1164,11 @@ export const BlackholeForm: FC<TBlackholeFormCreateProps> = ({
10271164
<>
10281165
<Styled.Container $designNewLayout={designNewLayout} $designNewLayoutHeight={designNewLayoutHeight}>
10291166
<Styled.OverflowContainer ref={overflowRef}>
1030-
<Form form={form} initialValues={initialValues} onValuesChange={onValuesChangeCallback}>
1167+
<Form
1168+
form={form}
1169+
initialValues={initialValues}
1170+
onValuesChange={(_changedValues, all) => onValuesChangeCallback(all, _changedValues)}
1171+
>
10311172
<DesignNewLayoutProvider value={designNewLayout}>
10321173
<OnValuesChangeCallbackProvider value={onValuesChangeCallback}>
10331174
<IsTouchedPersistedProvider value={{}}>

0 commit comments

Comments
 (0)