11import {
2+ Badge ,
23 Button ,
34 Card ,
45 Divider ,
@@ -11,12 +12,12 @@ import {
1112 MenuTrigger ,
1213 mergeClasses ,
1314 Spinner ,
14- Text ,
1515 tokens ,
1616 Toolbar ,
1717 ToolbarButton ,
18+ Tooltip ,
1819} from '@fluentui/react-components' ;
19- import { isNullOrEmpty , ChatbotService } from '@microsoft/logic-apps-shared' ;
20+ import { isNullOrEmpty , ChatbotService , useThrottledEffect } from '@microsoft/logic-apps-shared' ;
2021import type { AppDispatch , CustomCodeFileNameMapping , RootState , Workflow } from '@microsoft/logic-apps-designer-v2' ;
2122import {
2223 store as DesignerStore ,
@@ -45,6 +46,7 @@ import {
4546 resetDesignerView ,
4647 downloadDocumentAsFile ,
4748 useNodesAndDynamicDataInitialized ,
49+ useChangeCount ,
4850} from '@microsoft/logic-apps-designer-v2' ;
4951import { useEffect , useMemo , useState } from 'react' ;
5052import { useMutation } from '@tanstack/react-query' ;
@@ -70,6 +72,8 @@ import {
7072 DocumentOnePageAddRegular ,
7173 DocumentOnePageColumnsFilled ,
7274 DocumentOnePageColumnsRegular ,
75+ ArrowSyncFilled ,
76+ CheckmarkCircleRegular ,
7377} from '@fluentui/react-icons' ;
7478
7579const UndoIcon = bundleIcon ( ArrowUndoFilled , ArrowUndoRegular ) ;
@@ -148,55 +152,91 @@ export const DesignerCommandBar = ({
148152 const styles = useStyles ( ) ;
149153 const [ lastSavedTime , setLastSavedTime ] = useState < Date > ( ) ;
150154 const [ autoSaving , setAutoSaving ] = useState < boolean > ( false ) ;
155+ const [ autosaveError , setAutosaveError ] = useState < string > ( ) ;
156+
157+ const designerIsDirty = useIsDesignerDirty ( ) ;
158+ const isInitialized = useNodesAndDynamicDataInitialized ( ) ;
151159
152160 const dispatch = useDispatch < AppDispatch > ( ) ;
153161 const isCopilotReady = useNodesInitialized ( ) ;
154162 const { isLoading : isSaving , mutate : saveWorkflowMutate } = useMutation ( async ( autoSave ?: boolean ) => {
155- setAutoSaving ( autoSave ?? false ) ;
156- const designerState = DesignerStore . getState ( ) ;
157- const serializedWorkflow = await serializeBJSWorkflow ( designerState , {
158- skipValidation : false ,
159- ignoreNonCriticalErrors : true ,
160- } ) ;
161-
162- const validationErrorsList = Object . entries ( designerState . operations . inputParameters ) . reduce ( ( acc : any , [ id , nodeInputs ] ) => {
163- const hasValidationErrors = Object . values ( nodeInputs . parameterGroups ) . some ( ( parameterGroup ) => {
164- return parameterGroup . parameters . some ( ( parameter ) => {
165- const validationErrors = validateParameter ( parameter , parameter . value ) ;
166- if ( validationErrors . length > 0 ) {
167- dispatch (
168- updateParameterValidation ( {
169- nodeId : id ,
170- groupId : parameterGroup . id ,
171- parameterId : parameter . id ,
172- validationErrors,
173- } )
174- ) ;
175- }
176- return validationErrors . length ;
177- } ) ;
163+ try {
164+ setAutoSaving ( autoSave ?? false ) ;
165+ const designerState = DesignerStore . getState ( ) ;
166+ const serializedWorkflow = await serializeBJSWorkflow ( designerState , {
167+ skipValidation : false ,
168+ ignoreNonCriticalErrors : true ,
178169 } ) ;
179- if ( hasValidationErrors ) {
180- acc [ id ] = hasValidationErrors ;
181- }
182- return acc ;
183- } , { } ) ;
184170
185- const hasParametersErrors = ! isNullOrEmpty ( validationErrorsList ) ;
171+ const validationErrorsList = Object . entries ( designerState . operations . inputParameters ) . reduce ( ( acc : any , [ id , nodeInputs ] ) => {
172+ const hasValidationErrors = Object . values ( nodeInputs . parameterGroups ) . some ( ( parameterGroup ) => {
173+ return parameterGroup . parameters . some ( ( parameter ) => {
174+ const validationErrors = validateParameter ( parameter , parameter . value ) ;
175+ if ( validationErrors . length > 0 ) {
176+ dispatch (
177+ updateParameterValidation ( {
178+ nodeId : id ,
179+ groupId : parameterGroup . id ,
180+ parameterId : parameter . id ,
181+ validationErrors,
182+ } )
183+ ) ;
184+ }
185+ return validationErrors . length ;
186+ } ) ;
187+ } ) ;
188+ if ( hasValidationErrors ) {
189+ acc [ id ] = hasValidationErrors ;
190+ }
191+ return acc ;
192+ } , { } ) ;
193+
194+ const hasParametersErrors = ! isNullOrEmpty ( validationErrorsList ) ;
186195
187- const customCodeFilesWithData = getCustomCodeFilesWithData ( designerState . customCode ) ;
196+ const customCodeFilesWithData = getCustomCodeFilesWithData ( designerState . customCode ) ;
188197
189- if ( ! hasParametersErrors || autoSave ) {
190- await saveWorkflow ( serializedWorkflow , customCodeFilesWithData , ( ) => dispatch ( resetDesignerDirtyState ( undefined ) ) , autoSave ) ;
191- if ( Object . keys ( serializedWorkflow ?. definition ?. triggers ?? { } ) . length > 0 ) {
192- updateCallbackUrl ( designerState , dispatch ) ;
198+ if ( ! hasParametersErrors || autoSave ) {
199+ await saveWorkflow ( serializedWorkflow , customCodeFilesWithData , ( ) => dispatch ( resetDesignerDirtyState ( undefined ) ) , autoSave ) ;
200+ if ( Object . keys ( serializedWorkflow ?. definition ?. triggers ?? { } ) . length > 0 ) {
201+ updateCallbackUrl ( designerState , dispatch ) ;
202+ }
203+ if ( autoSave ) {
204+ setLastSavedTime ( new Date ( ) ) ;
205+ }
193206 }
207+ } catch ( error : any ) {
208+ console . error ( 'Error saving workflow:' , error ) ;
194209 if ( autoSave ) {
195- setLastSavedTime ( new Date ( ) ) ;
210+ setAutosaveError ( error ?. message ?? 'Unknown error during auto-save' ) ;
196211 }
212+ } finally {
213+ setAutoSaving ( false ) ;
197214 }
198215 } ) ;
199216
217+ // When any change is made, set needsSaved to true
218+ const changeCount = useChangeCount ( ) ;
219+ const [ needsSaved , setNeedsSaved ] = useState ( false ) ;
220+ useEffect ( ( ) => {
221+ if ( changeCount === 0 ) {
222+ return ;
223+ }
224+ setNeedsSaved ( true ) ;
225+ } , [ changeCount ] ) ;
226+
227+ // Auto-save every 5 seconds if needed
228+ useThrottledEffect (
229+ ( ) => {
230+ if ( ! needsSaved || ! isDraftMode || isSaving || isSaving || isMonitoringView ) {
231+ return ;
232+ }
233+ setNeedsSaved ( false ) ;
234+ saveWorkflowMutate ( true ) ;
235+ } ,
236+ [ isDraftMode , isSaving , isMonitoringView , isSaving , needsSaved , saveWorkflowMutate , isCodeView ] ,
237+ 5000
238+ ) ;
239+
200240 const { isLoading : isSavingUnitTest , mutate : saveUnitTestMutate } = useMutation ( async ( ) => {
201241 const designerState = DesignerStore . getState ( ) ;
202242 const definition = await serializeUnitTestDefinition ( designerState ) ;
@@ -230,9 +270,6 @@ export const DesignerCommandBar = ({
230270 downloadDocumentAsFile ( queryResponse ) ;
231271 } ) ;
232272
233- const designerIsDirty = useIsDesignerDirty ( ) ;
234- const isInitialized = useNodesAndDynamicDataInitialized ( ) ;
235-
236273 const allInputErrors = useSelector ( ( state : RootState ) => {
237274 return ( Object . entries ( state . operations . inputParameters ) ?? [ ] ) . filter ( ( [ _id , nodeInputs ] ) =>
238275 Object . values ( nodeInputs . parameterGroups ) . some ( ( parameterGroup ) =>
@@ -333,8 +370,43 @@ export const DesignerCommandBar = ({
333370 ) ;
334371
335372 const DraftSaveNotification = ( ) => {
336- if ( isDraftMode && lastSavedTime ) {
337- return < Text style = { { fontStyle : 'italic' } } > { `Draft auto-saved at: ${ lastSavedTime ?. toLocaleTimeString ( ) } ` } </ Text > ;
373+ const [ , setTick ] = useState ( 0 ) ;
374+
375+ useEffect ( ( ) => {
376+ if ( isDraftMode && lastSavedTime ) {
377+ const interval = setInterval ( ( ) => {
378+ setTick ( ( prev ) => prev + 1 ) ;
379+ } , 2000 ) ; // Update every 2 seconds
380+
381+ return ( ) => clearInterval ( interval ) ;
382+ }
383+ } , [ lastSavedTime , isDraftMode ] ) ;
384+
385+ if ( isDraftMode && ( isDesignerView || isCodeView ) ) {
386+ const style = { fontStyle : 'italic' , fontSize : '12px' } ;
387+ const iconStyle = { fontSize : '16px' } ;
388+
389+ if ( isSaving || needsSaved ) {
390+ return (
391+ < Badge appearance = "ghost" color = "informative" size = "small" style = { style } icon = { < ArrowSyncFilled style = { iconStyle } /> } >
392+ { 'Saving...' }
393+ </ Badge >
394+ ) ;
395+ }
396+
397+ return lastSavedTime ? (
398+ < Tooltip content = { `Draft autosaved at: ${ lastSavedTime . toLocaleTimeString ( ) } ` } relationship = "label" withArrow >
399+ < Badge appearance = "ghost" color = "informative" size = "small" style = { style } icon = { < CheckmarkCircleRegular style = { iconStyle } /> } >
400+ { getRelativeTimeString ( lastSavedTime ) }
401+ </ Badge >
402+ </ Tooltip >
403+ ) : autosaveError ? (
404+ < Tooltip content = { autosaveError } relationship = "label" withArrow >
405+ < Badge appearance = "ghost" color = "danger" size = "small" style = { style } icon = { < ErrorCircleFilled style = { iconStyle } /> } >
406+ { 'Error autosaving draft' }
407+ </ Badge >
408+ </ Tooltip >
409+ ) : null ;
338410 }
339411 return null ;
340412 } ;
@@ -415,15 +487,6 @@ export const DesignerCommandBar = ({
415487 </ Menu >
416488 ) ;
417489
418- useEffect ( ( ) => {
419- const timeoutId = setTimeout ( ( ) => {
420- if ( isDraftMode && ! isSaving && isDesignerView && ! isMonitoringView ) {
421- saveWorkflowMutate ( true ) ;
422- }
423- } , 30000 ) ; // Auto-save every 30 seconds
424- return ( ) => clearTimeout ( timeoutId ) ;
425- } , [ saveIsDisabled , isDraftMode , isSaving , isDesignerView , saveWorkflowMutate , isMonitoringView ] ) ;
426-
427490 return (
428491 < >
429492 < Toolbar
@@ -458,3 +521,36 @@ export const DesignerCommandBar = ({
458521 </ >
459522 ) ;
460523} ;
524+
525+ const getRelativeTimeString = ( savedTime : Date ) => {
526+ const now = new Date ( ) ;
527+ const diffMs = now . getTime ( ) - savedTime . getTime ( ) ;
528+ const diffSeconds = Math . floor ( diffMs / 1000 ) ;
529+ const diffMinutes = Math . floor ( diffSeconds / 60 ) ;
530+ const diffHours = Math . floor ( diffMinutes / 60 ) ;
531+
532+ if ( diffHours > 0 ) {
533+ const unit = diffHours === 1 ? Common_TimeFormat . hour : Common_TimeFormat . hours ;
534+ return Common_TimeFormat . autoSavedHours . replace ( '{0}' , diffHours . toString ( ) ) . replace ( '{1}' , unit ) ;
535+ }
536+ if ( diffMinutes > 0 ) {
537+ return Common_TimeFormat . autoSavedMinutes . replace ( '{0}' , Common_TimeFormat . aFew ) . replace ( '{1}' , Common_TimeFormat . minutes ) ;
538+ }
539+ return Common_TimeFormat . autoSavedSeconds . replace ( '{0}' , Common_TimeFormat . aFew ) . replace ( '{1}' , Common_TimeFormat . seconds ) ;
540+ } ;
541+
542+ const Common_TimeFormat = {
543+ /** 0 = number of seconds */
544+ autoSavedSeconds : 'Autosaved {0} {1} ago' ,
545+ /** 0 = number of minutes */
546+ autoSavedMinutes : 'Autosaved {0} {1} ago' ,
547+ /** 0 = number of hours */
548+ autoSavedHours : 'Autosaved {0} {1} ago' ,
549+ aFew : 'a few' ,
550+ second : 'second' ,
551+ seconds : 'seconds' ,
552+ minute : 'minute' ,
553+ minutes : 'minutes' ,
554+ hour : 'hour' ,
555+ hours : 'hours' ,
556+ } ;
0 commit comments