1- import React , { useEffect , useMemo , useRef , useState } from 'react' ;
1+ import React , { useEffect , useMemo , useState } from 'react' ;
22import { VsumDetails } from '../../types' ;
33import { apiService } from '../../services/api' ;
44
@@ -12,11 +12,8 @@ interface VsumTabsProps {
1212export const VsumTabs : React . FC < VsumTabsProps > = ( { openVsums, activeVsumId, onActivate, onClose } ) => {
1313 const [ detailsById , setDetailsById ] = useState < Record < number , VsumDetails | undefined > > ( { } ) ;
1414 const [ error , setError ] = useState < string > ( '' ) ;
15- const [ edits , setEdits ] = useState < Record < number , { name : string ; metaModelIds : number [ ] } > > ( { } ) ;
15+ const [ edits , setEdits ] = useState < Record < number , { metaModelIds : number [ ] } > > ( { } ) ;
1616 const [ saving , setSaving ] = useState ( false ) ;
17- const [ renamingId , setRenamingId ] = useState < number | null > ( null ) ;
18- const [ renameValue , setRenameValue ] = useState < string > ( '' ) ;
19- const renameCommitRef = useRef ( false ) ;
2017
2118 const areIdArraysEqual = ( a : number [ ] = [ ] , b : number [ ] = [ ] ) => {
2219 if ( a . length !== b . length ) return false ;
@@ -35,8 +32,7 @@ export const VsumTabs: React.FC<VsumTabsProps> = ({ openVsums, activeVsumId, onA
3532 const details = detailsById [ id ] ;
3633 if ( ! edit || ! details ) { map [ id ] = false ; return ; }
3734 const detailsIds = ( details . metaModels || [ ] ) . map ( m => m . id ) ;
38- const isDirty = edit . name . trim ( ) !== ( details . name || '' ) . trim ( ) || ! areIdArraysEqual ( edit . metaModelIds , detailsIds ) ;
39- map [ id ] = isDirty ;
35+ map [ id ] = ! areIdArraysEqual ( edit . metaModelIds , detailsIds ) ;
4036 } ) ;
4137 return map ;
4238 } , [ openVsums , edits , detailsById ] ) ;
@@ -47,7 +43,10 @@ export const VsumTabs: React.FC<VsumTabsProps> = ({ openVsums, activeVsumId, onA
4743 try {
4844 const res = await apiService . getVsumDetails ( id ) ;
4945 setDetailsById ( prev => ( { ...prev , [ id ] : res . data } ) ) ;
50- setEdits ( prev => ( { ...prev , [ id ] : { name : res . data . name , metaModelIds : ( res . data . metaModels || [ ] ) . map ( m => m . id ) } } ) ) ;
46+ setEdits ( prev => ( {
47+ ...prev ,
48+ [ id ] : { metaModelIds : ( res . data . metaModels || [ ] ) . map ( m => m . id ) }
49+ } ) ) ;
5150 } catch ( e ) {
5251 setError ( e instanceof Error ? e . message : 'Failed to load VSUM details' ) ;
5352 }
@@ -57,21 +56,38 @@ export const VsumTabs: React.FC<VsumTabsProps> = ({ openVsums, activeVsumId, onA
5756 }
5857 } , [ activeVsumId , detailsById ] ) ;
5958
60- // Derived states no longer needed: activeEdit, activeDetails
61-
62- const saveById = async ( id : number , override ?: { name ?: string ; metaModelIds ?: number [ ] } ) => {
59+ const saveById = async (
60+ id : number ,
61+ override ?: { metaModelIds ?: number [ ] }
62+ ) => {
6363 const edit = edits [ id ] ;
6464 const fallbackMetaIds = ( detailsById [ id ] ?. metaModels || [ ] ) . map ( m => m . id ) ;
65- const name = ( override ?. name ?? edit ?. name ?? '' ) . trim ( ) ;
6665 const metaModelIds = override ?. metaModelIds ?? edit ?. metaModelIds ?? fallbackMetaIds ;
67- if ( ! name ) { setError ( 'Name is required' ) ; return ; }
66+
67+ if ( ! metaModelIds || metaModelIds . length === 0 ) {
68+ setError ( 'At least one MetaModel is required' ) ;
69+ return ;
70+ }
71+
6872 setSaving ( true ) ;
6973 setError ( '' ) ;
7074 try {
71- await apiService . updateVsum ( id , { name, metaModelIds } ) ;
75+ await apiService . updateVsumSyncChanges ( id , {
76+ metaModelIds,
77+ metaModelRelationRequests : null , // not implemented yet
78+ } ) ;
79+
7280 const res = await apiService . getVsumDetails ( id ) ;
7381 setDetailsById ( prev => ( { ...prev , [ id ] : res . data } ) ) ;
74- setEdits ( prev => ( { ...prev , [ id ] : { name : res . data . name , metaModelIds : ( res . data . metaModels || [ ] ) . map ( m => m . id ) } } ) ) ;
82+
83+ // keep edit state in sync with server
84+ setEdits ( prev => ( {
85+ ...prev ,
86+ [ id ] : {
87+ metaModelIds : ( res . data . metaModels || [ ] ) . map ( m => m . id ) ,
88+ } ,
89+ } ) ) ;
90+
7591 window . dispatchEvent ( new CustomEvent ( 'vitruv.refreshVsums' ) ) ;
7692 } catch ( e ) {
7793 setError ( e instanceof Error ? e . message : 'Failed to save VSUM' ) ;
@@ -85,45 +101,23 @@ export const VsumTabs: React.FC<VsumTabsProps> = ({ openVsums, activeVsumId, onA
85101 await saveById ( activeVsumId ) ;
86102 } ;
87103
88- const ensureDetails = async ( id : number ) => {
89- if ( detailsById [ id ] ) return detailsById [ id ] ;
90- try {
91- const res = await apiService . getVsumDetails ( id ) ;
92- setDetailsById ( prev => ( { ...prev , [ id ] : res . data } ) ) ;
93- setEdits ( prev => ( { ...prev , [ id ] : { name : res . data . name , metaModelIds : ( res . data . metaModels || [ ] ) . map ( m => m . id ) } } ) ) ;
94- return res . data ;
95- } catch ( e ) {
96- setError ( e instanceof Error ? e . message : 'Failed to load VSUM details' ) ;
97- return undefined ;
98- }
99- } ;
100-
101- const beginRename = async ( id : number ) => {
102- await ensureDetails ( id ) ;
103- const currentName = edits [ id ] ?. name || detailsById [ id ] ?. name || `VSUM #${ id } ` ;
104- setRenamingId ( id ) ;
105- setRenameValue ( currentName ) ;
106- } ;
107-
108- const commitRenameById = async ( id : number ) => {
109- const current = edits [ id ] || { name : renameValue , metaModelIds : ( detailsById [ id ] ?. metaModels || [ ] ) . map ( m => m . id ) } ;
110- const nextName = renameValue . trim ( ) ;
111- if ( ! nextName ) { setError ( 'Name is required' ) ; return ; }
112- setEdits ( prev => ( { ...prev , [ id ] : { ...current , name : nextName } } ) ) ;
113- setRenamingId ( null ) ;
114- await saveById ( id , { name : nextName } ) ;
115- } ;
116-
104+ // handle external "add meta model" event
117105 useEffect ( ( ) => {
118106 const onAdd = ( e : Event ) => {
119107 const ce = e as CustomEvent < { id : number } > ; // meta model id
120108 if ( ! activeVsumId ) return ;
121109 const mmId = ce . detail ?. id ;
122110 if ( typeof mmId !== 'number' ) return ;
123- const current = edits [ activeVsumId ] || { name : detailsById [ activeVsumId ! ] ?. name || '' , metaModelIds : [ ] } ;
111+
112+ const current = edits [ activeVsumId ] || { metaModelIds : ( detailsById [ activeVsumId ! ] ?. metaModels || [ ] ) . map ( m => m . id ) } ;
124113 if ( current . metaModelIds . includes ( mmId ) ) return ;
125- setEdits ( prev => ( { ...prev , [ activeVsumId ! ] : { ...current , metaModelIds : [ ...current . metaModelIds , mmId ] } } ) ) ;
114+
115+ setEdits ( prev => ( {
116+ ...prev ,
117+ [ activeVsumId ! ] : { metaModelIds : [ ...current . metaModelIds , mmId ] }
118+ } ) ) ;
126119 } ;
120+
127121 window . addEventListener ( 'vitruv.addMetaModelToActiveVsum' , onAdd as EventListener ) ;
128122 return ( ) => window . removeEventListener ( 'vitruv.addMetaModelToActiveVsum' , onAdd as EventListener ) ;
129123 } , [ activeVsumId , edits , detailsById ] ) ;
@@ -133,134 +127,115 @@ export const VsumTabs: React.FC<VsumTabsProps> = ({ openVsums, activeVsumId, onA
133127 const anyDirty = activeVsumId ? ! ! dirtyById [ activeVsumId ] : false ;
134128
135129 return (
136- < div style = { { background : '#ffffff' , borderBottom : '1px solid #e5e7eb' , position : 'sticky' , top : 0 , zIndex : 20 , boxShadow : 'inset 0 -1px 0 #e5e7eb' , cursor : saving ? 'progress' : 'default' } } >
137- < div style = { { display : 'flex' , alignItems : 'center' , gap : 10 , padding : '8px 10px' } } >
138- < div style = { { display : 'flex' , alignItems : 'center' , gap : 6 , overflowX : 'auto' , flex : 1 } } >
139- { openVsums . map ( id => {
140- const isActive = id === activeVsumId ;
141- const name = detailsById [ id ] ?. name || `VSUM #${ id } ` ;
142- const isDirty = ! ! dirtyById [ id ] ;
143- return (
144- < div
145- key = { id }
146- style = { {
147- display : 'flex' , alignItems : 'center' , gap : 8 ,
148- padding : '6px 12px' ,
149- border : isActive ? '1px solid #bfdbfe' : '1px solid #e5e7eb' ,
150- borderBottom : isActive ? '2px solid #3b82f6' : '1px solid #e5e7eb' ,
151- borderRadius : 8 ,
152- background : isActive ? '#f0f7ff' : '#ffffff' ,
153- cursor : 'pointer' ,
154- boxShadow : isActive ? '0 1px 2px rgba(0,0,0,0.04)' : 'none'
155- } }
156- onClick = { ( ) => onActivate ( id ) }
157- onContextMenu = { ( e ) => { e . preventDefault ( ) ; beginRename ( id ) ; } }
158- aria-current = { isActive ? 'page' : undefined }
159- title = { name }
160- >
161- { isDirty && < span aria-label = "Unsaved changes" title = "Unsaved changes" style = { { width : 6 , height : 6 , borderRadius : 6 , background : '#f59e0b' , display : 'inline-block' } } /> }
162- { renamingId === id ? (
163- < input
164- value = { renameValue }
165- onChange = { ( e ) => setRenameValue ( e . target . value ) }
166- onKeyDown = { ( e ) => {
167- if ( e . key === 'Enter' ) {
168- e . preventDefault ( ) ;
169- renameCommitRef . current = true ;
170- void ( async ( ) => {
171- try {
172- await commitRenameById ( id ) ;
173- } finally {
174- // allow blur after commit
175- setTimeout ( ( ) => { renameCommitRef . current = false ; } , 0 ) ;
176- }
177- } ) ( ) ;
178- }
179- if ( e . key === 'Escape' ) { setRenamingId ( null ) ; }
180- } }
181- onBlur = { ( ) => { if ( ! renameCommitRef . current ) { setRenamingId ( null ) ; } } }
182- autoFocus
183- disabled = { saving }
184- style = { {
185- padding : '1px 4px' ,
186- border : 'none' ,
187- borderBottom : '1px solid #94a3b8' ,
188- background : 'transparent' ,
189- fontSize : 12 ,
190- minWidth : 80 ,
191- color : '#111827' ,
192- outline : 'none'
193- } }
194- />
195- ) : (
196- < span style = { { fontWeight : 700 , color : '#1f2937' , fontSize : 12 , whiteSpace : 'nowrap' } } > { name } </ span >
197- ) }
198- { renamingId === id && (
199- < button
200- onMouseDown = { ( ) => { renameCommitRef . current = true ; } }
201- onClick = { ( e ) => {
202- e . stopPropagation ( ) ;
203- void ( async ( ) => {
204- try {
205- await commitRenameById ( id ) ;
206- } finally {
207- setTimeout ( ( ) => { renameCommitRef . current = false ; } , 0 ) ;
208- }
209- } ) ( ) ;
210- } }
211- disabled = { saving || ! renameValue . trim ( ) || renameValue . trim ( ) === ( edits [ id ] ?. name || detailsById [ id ] ?. name || '' ) }
212- style = { { border : '1px solid transparent' , background : 'transparent' , color : saving ? '#93c5fd' : '#3b82f6' , cursor : saving ? 'not-allowed' : 'pointer' , borderRadius : 4 , lineHeight : 1 , width : 16 , height : 16 , display : 'inline-flex' , alignItems : 'center' , justifyContent : 'center' } }
213- aria-label = { `Save name for ${ name } ` }
214- title = "Save"
215- >
216- ✓
217- </ button >
218- ) }
219- < button
220- onMouseDown = { ( ) => { renameCommitRef . current = true ; } }
221- onClick = { ( e ) => {
222- e . stopPropagation ( ) ;
223- setRenamingId ( null ) ;
224- onClose ( id ) ;
225- setTimeout ( ( ) => { renameCommitRef . current = false ; } , 0 ) ;
226- } }
227- style = { { border : '1px solid transparent' , background : 'transparent' , color : '#64748b' , cursor : 'pointer' , borderRadius : 4 , lineHeight : 1 , width : 16 , height : 16 , display : 'inline-flex' , alignItems : 'center' , justifyContent : 'center' } }
228- aria-label = { `Close ${ name } ` }
229- title = "Close"
230- >
231- ×
232- </ button >
233- </ div >
234- ) ;
235- } ) }
236- </ div >
237- { activeVsumId && (
238- < div style = { { display : 'flex' , alignItems : 'center' , gap : 8 } } >
239- { error && (
240- < div role = "alert" style = { { padding : '4px 8px' , border : '1px solid #fecaca' , color : '#991b1b' , background : '#fef2f2' , borderRadius : 6 , fontSize : 11 } } > { error } </ div >
241- ) }
242- { anyDirty && (
243- < button
244- onClick = { onSave }
245- disabled = { saving }
246- style = { {
247- padding : '6px 10px' ,
248- border : '1px solid #3b82f6' ,
249- borderRadius : 8 ,
250- background : saving ? '#bfdbfe' : '#3b82f6' ,
251- color : '#ffffff' ,
252- fontWeight : 700 ,
253- cursor : saving ? 'not-allowed' : 'pointer'
254- } }
255- >
256- { saving ? 'Saving…' : 'Save changes' }
257- </ button >
258- ) }
130+ < div
131+ style = { {
132+ background : '#ffffff' ,
133+ borderBottom : '1px solid #e5e7eb' ,
134+ position : 'sticky' ,
135+ top : 0 ,
136+ zIndex : 20 ,
137+ boxShadow : 'inset 0 -1px 0 #e5e7eb' ,
138+ cursor : saving ? 'progress' : 'default'
139+ } }
140+ >
141+ < div style = { { display : 'flex' , alignItems : 'center' , gap : 10 , padding : '8px 10px' } } >
142+ < div style = { { display : 'flex' , alignItems : 'center' , gap : 6 , overflowX : 'auto' , flex : 1 } } >
143+ { openVsums . map ( id => {
144+ const isActive = id === activeVsumId ;
145+ const name = detailsById [ id ] ?. name || `VSUM #${ id } ` ;
146+ const isDirty = ! ! dirtyById [ id ] ;
147+ return (
148+ < div
149+ key = { id }
150+ style = { {
151+ display : 'flex' ,
152+ alignItems : 'center' ,
153+ gap : 8 ,
154+ padding : '6px 12px' ,
155+ border : isActive ? '1px solid #bfdbfe' : '1px solid #e5e7eb' ,
156+ borderBottom : isActive ? '2px solid #3b82f6' : '1px solid #e5e7eb' ,
157+ borderRadius : 8 ,
158+ background : isActive ? '#f0f7ff' : '#ffffff' ,
159+ cursor : 'pointer' ,
160+ boxShadow : isActive ? '0 1px 2px rgba(0,0,0,0.04)' : 'none'
161+ } }
162+ onClick = { ( ) => onActivate ( id ) }
163+ aria-current = { isActive ? 'page' : undefined }
164+ title = { name }
165+ >
166+ { isDirty && (
167+ < span
168+ aria-label = "Unsaved changes"
169+ title = "Unsaved changes"
170+ style = { { width : 6 , height : 6 , borderRadius : 6 , background : '#f59e0b' , display : 'inline-block' } }
171+ />
172+ ) }
173+ < span style = { { fontWeight : 700 , color : '#1f2937' , fontSize : 12 , whiteSpace : 'nowrap' } } > { name } </ span >
174+ < button
175+ onClick = { ( e ) => {
176+ e . stopPropagation ( ) ;
177+ onClose ( id ) ;
178+ } }
179+ style = { {
180+ border : '1px solid transparent' ,
181+ background : 'transparent' ,
182+ color : '#64748b' ,
183+ cursor : 'pointer' ,
184+ borderRadius : 4 ,
185+ lineHeight : 1 ,
186+ width : 16 ,
187+ height : 16 ,
188+ display : 'inline-flex' ,
189+ alignItems : 'center' ,
190+ justifyContent : 'center'
191+ } }
192+ aria-label = { `Close ${ name } ` }
193+ title = "Close"
194+ >
195+ ×
196+ </ button >
197+ </ div >
198+ ) ;
199+ } ) }
259200 </ div >
260- ) }
201+
202+ { activeVsumId && (
203+ < div style = { { display : 'flex' , alignItems : 'center' , gap : 8 } } >
204+ { error && (
205+ < div
206+ role = "alert"
207+ style = { {
208+ padding : '4px 8px' ,
209+ border : '1px solid #fecaca' ,
210+ color : '#991b1b' ,
211+ background : '#fef2f2' ,
212+ borderRadius : 6 ,
213+ fontSize : 11
214+ } }
215+ >
216+ { error }
217+ </ div >
218+ ) }
219+ { anyDirty && (
220+ < button
221+ onClick = { onSave }
222+ disabled = { saving }
223+ style = { {
224+ padding : '6px 10px' ,
225+ border : '1px solid #3b82f6' ,
226+ borderRadius : 8 ,
227+ background : saving ? '#bfdbfe' : '#3b82f6' ,
228+ color : '#ffffff' ,
229+ fontWeight : 700 ,
230+ cursor : saving ? 'not-allowed' : 'pointer'
231+ } }
232+ >
233+ { saving ? 'Saving…' : 'Save changes' }
234+ </ button >
235+ ) }
236+ </ div >
237+ ) }
238+ </ div >
261239 </ div >
262- </ div >
263240 ) ;
264- } ;
265-
266-
241+ } ;
0 commit comments