@@ -30,13 +30,23 @@ const SECTION_COLORS = {
3030 mitm : 'var(--color-warning)' , pii : 'var(--color-error)' , other : 'var(--color-text-muted)' ,
3131}
3232
33- function flattenConfig ( obj , prefix = '' ) {
33+ // flattenConfig turns a parsed YAML config into a flat { 'a.b.c': value }
34+ // map keyed by the same dotted paths the field registry uses. leafPaths is
35+ // the set of registered schema leaf paths: recursion STOPS at any of them so
36+ // a map-typed field (e.g. pii_detection.entity_actions, a {GROUP: action}
37+ // object) is stored whole at its own path. Without this guard a map's value
38+ // was scattered into `pii_detection.entity_actions.SSN` etc. — paths that
39+ // match no registered field — so the editor rendered neither the field nor
40+ // its values, hiding per-entity policy like SSN→block from the operator.
41+ function flattenConfig ( obj , leafPaths , prefix = '' ) {
3442 const result = { }
3543 if ( ! obj || typeof obj !== 'object' ) return result
3644 for ( const [ key , val ] of Object . entries ( obj ) ) {
3745 const path = prefix ? `${ prefix } .${ key } ` : key
38- if ( val !== null && typeof val === 'object' && ! Array . isArray ( val ) ) {
39- Object . assign ( result , flattenConfig ( val , path ) )
46+ if ( leafPaths && leafPaths . has ( path ) ) {
47+ result [ path ] = val
48+ } else if ( val !== null && typeof val === 'object' && ! Array . isArray ( val ) ) {
49+ Object . assign ( result , flattenConfig ( val , leafPaths , path ) )
4050 } else {
4151 result [ path ] = val
4252 }
@@ -80,6 +90,16 @@ export default function ModelEditor() {
8090 const { addToast } = useOutletContext ( )
8191 const { sections, fields, loading : metaLoading , error : metaError } = useConfigMetadata ( )
8292
93+ // Registered schema leaf paths. flattenConfig stops recursing at these so
94+ // map-typed fields (e.g. pii_detection.entity_actions) bind as a whole
95+ // object to their registered editor instead of vanishing into sub-paths.
96+ const leafPaths = useMemo ( ( ) => new Set ( fields . map ( f => f . path ) ) , [ fields ] )
97+
98+ // The parsed (not-yet-flattened) config loaded from the server. Flattening
99+ // is deferred to a separate effect keyed on leafPaths so the schema metadata
100+ // can arrive after the config without a fetch race re-clobbering values.
101+ const [ loadedConfig , setLoadedConfig ] = useState ( null )
102+
83103 const isCreateMode = ! name
84104 const [ selectedTemplate , setSelectedTemplate ] = useState ( null )
85105
@@ -121,34 +141,39 @@ export default function ModelEditor() {
121141 }
122142 } , [ isCreateMode , searchParams , handleSelectTemplate ] )
123143
124- // Load raw YAML config (edit mode only)
144+ // Load raw YAML config (edit mode only). This only fetches + parses; the
145+ // flatten-into-form-values step is the separate effect below so it can
146+ // re-run when the schema metadata (leafPaths) resolves without re-fetching.
125147 useEffect ( ( ) => {
126148 if ( ! name ) return
127149 modelsApi . getEditConfig ( name )
128150 . then ( data => {
129151 const raw = data ?. config || ''
130152 setYamlText ( raw )
131153 setSavedYamlText ( raw )
132-
133- // Parse YAML to get only the fields actually present in the file
134154 try {
135- const parsed = YAML . parse ( raw )
136- const flat = flattenConfig ( parsed || { } )
137- const active = new Set ( Object . keys ( flat ) )
138- setValues ( flat )
139- setInitialValues ( structuredClone ( flat ) )
140- setActiveFieldPaths ( active )
155+ setLoadedConfig ( YAML . parse ( raw ) || { } )
141156 } catch {
142- // If YAML parsing fails, start with empty state
143- setValues ( { } )
144- setInitialValues ( { } )
145- setActiveFieldPaths ( new Set ( ) )
157+ setLoadedConfig ( { } )
146158 }
147159 } )
148160 . catch ( err => addToast ( `Failed to load config: ${ err . message } ` , 'error' ) )
149161 . finally ( ( ) => setConfigLoading ( false ) )
150162 } , [ name , addToast ] )
151163
164+ // Flatten the loaded config into form values. Keyed on leafPaths so a late
165+ // schema-metadata resolution re-flattens (keeping map fields whole) WITHOUT
166+ // re-fetching — avoiding a two-fetch race that could clobber values. Only
167+ // fires on (re)load: loadedConfig changes per model, leafPaths is stable
168+ // once metadata is in, so this never stomps in-progress edits.
169+ useEffect ( ( ) => {
170+ if ( loadedConfig === null ) return
171+ const flat = flattenConfig ( loadedConfig , leafPaths )
172+ setValues ( flat )
173+ setInitialValues ( structuredClone ( flat ) )
174+ setActiveFieldPaths ( new Set ( Object . keys ( flat ) ) )
175+ } , [ loadedConfig , leafPaths ] )
176+
152177 // Build field lookup
153178 const fieldsByPath = useMemo ( ( ) => {
154179 const map = { }
@@ -323,7 +348,7 @@ export default function ModelEditor() {
323348 try {
324349 const parsed = YAML . parse ( yamlText )
325350 parsedName = parsed ?. name ?? null
326- const flat = flattenConfig ( parsed || { } )
351+ const flat = flattenConfig ( parsed || { } , leafPaths )
327352 setValues ( flat )
328353 setInitialValues ( structuredClone ( flat ) )
329354 setActiveFieldPaths ( new Set ( Object . keys ( flat ) ) )
0 commit comments