Skip to content

Commit f232b9e

Browse files
authored
fix(DesignerV2): Optimized useHandoffActionsForAgent hook, standalone improvement (#8802)
* Optimized hook * Removed any typecast
1 parent 035beab commit f232b9e

File tree

4 files changed

+349
-91
lines changed

4 files changed

+349
-91
lines changed

apps/Standalone/src/designer/app/AzureLogicAppsDesigner/DesignerCommandBarV2.tsx

Lines changed: 147 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
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';
2021
import type { AppDispatch, CustomCodeFileNameMapping, RootState, Workflow } from '@microsoft/logic-apps-designer-v2';
2122
import {
2223
store as DesignerStore,
@@ -45,6 +46,7 @@ import {
4546
resetDesignerView,
4647
downloadDocumentAsFile,
4748
useNodesAndDynamicDataInitialized,
49+
useChangeCount,
4850
} from '@microsoft/logic-apps-designer-v2';
4951
import { useEffect, useMemo, useState } from 'react';
5052
import { 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

7579
const 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+
};

libs/designer-v2/src/lib/core/state/workflow/workflowSelectors.ts

Lines changed: 41 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {} from '@tanstack/react-query';
1414
import { collapseFlowTree } from './helper';
1515
import { useEdges } from '@xyflow/react';
1616
import type { OperationMetadataState } from '../operation/operationMetadataSlice';
17+
import { getOperationsState } from '../selectors/actionMetadataSelector';
1718

1819
export const getWorkflowState = (state: RootState): WorkflowState => state.workflow;
1920

@@ -326,7 +327,7 @@ export const useNewAdditiveSubgraphId = (baseId: string) =>
326327
let caseId = baseId;
327328
let caseCount = 1;
328329
const idList = Object.keys(state.nodesMetadata);
329-
// eslint-disable-next-line no-loop-func
330+
330331
while (idList.some((id) => id === caseId)) {
331332
caseCount++;
332333
caseId = `${baseId}_${caseCount}`;
@@ -754,41 +755,45 @@ export const useHandoffEdges = (): WorkflowEdge[] => {
754755

755756
export const useHandoffActionsForAgent = (agentId: string): any[] => {
756757
return useSelector(
757-
createSelector(getWorkflowAndOperationState, (state: { workflow: WorkflowState; operations: OperationMetadataState }) => {
758-
// Check the action is an agent action
759-
if (!equals(state.workflow.operations[agentId]?.type, commonConstants.NODE.TYPE.AGENT)) {
760-
return [];
761-
}
762-
const toolNodeIds = Object.keys(state.workflow.nodesMetadata[agentId]?.handoffs ?? {});
763-
const output: any[] = [];
764-
for (const toolId of toolNodeIds) {
765-
// If the tool contains a handoff action, add it to the output
766-
const toolActionIds = getNodesWithGraphId(toolId, state.workflow.nodesMetadata);
767-
const isSingleAction = Object.keys(toolActionIds).length === 1;
768-
for (const actionId of Object.keys(toolActionIds)) {
769-
const action = state.workflow.operations[actionId];
770-
if (equals(action.type, commonConstants.NODE.TYPE.HANDOFF)) {
771-
const toolDescription =
772-
state.operations?.inputParameters?.[toolId]?.parameterGroups?.default?.parameters?.find((param) =>
773-
equals(param.parameterName, 'description')
774-
)?.value?.[0]?.value ?? '';
775-
const targetId =
776-
state.operations?.inputParameters?.[actionId]?.parameterGroups?.default?.parameters?.find((param) =>
777-
equals(param.parameterName, 'name')
778-
)?.value?.[0]?.value ?? '';
779-
780-
const actionData = {
781-
id: actionId,
782-
toolId,
783-
toolDescription,
784-
targetId,
785-
isSingleAction,
786-
};
787-
output.push(actionData);
758+
useMemo(
759+
() =>
760+
createSelector([getWorkflowState, getOperationsState], (workflowState: WorkflowState, operationsState: OperationMetadataState) => {
761+
// Check the action is an agent action
762+
if (!equals(workflowState.operations[agentId]?.type, commonConstants.NODE.TYPE.AGENT)) {
763+
return [];
788764
}
789-
}
790-
}
791-
return output;
792-
})
765+
const toolNodeIds = Object.keys(workflowState.nodesMetadata[agentId]?.handoffs ?? {});
766+
const output: any[] = [];
767+
for (const toolId of toolNodeIds) {
768+
// If the tool contains a handoff action, add it to the output
769+
const toolActionIds = getNodesWithGraphId(toolId, workflowState.nodesMetadata);
770+
const isSingleAction = Object.keys(toolActionIds).length === 1;
771+
for (const actionId of Object.keys(toolActionIds)) {
772+
const action = workflowState.operations[actionId];
773+
if (equals(action.type, commonConstants.NODE.TYPE.HANDOFF)) {
774+
const toolDescription =
775+
operationsState?.inputParameters?.[toolId]?.parameterGroups?.default?.parameters?.find((param) =>
776+
equals(param.parameterName, 'description')
777+
)?.value?.[0]?.value ?? '';
778+
const targetId =
779+
operationsState?.inputParameters?.[actionId]?.parameterGroups?.default?.parameters?.find((param) =>
780+
equals(param.parameterName, 'name')
781+
)?.value?.[0]?.value ?? '';
782+
783+
const actionData = {
784+
id: actionId,
785+
toolId,
786+
toolDescription,
787+
targetId,
788+
isSingleAction,
789+
};
790+
output.push(actionData);
791+
}
792+
}
793+
}
794+
return output;
795+
}),
796+
[agentId]
797+
)
793798
);
794799
};

0 commit comments

Comments
 (0)