From 4adfced9373ca2b4da57ec9a28cbae63c6e615f8 Mon Sep 17 00:00:00 2001 From: Stephen Wright Date: Mon, 1 Dec 2025 10:52:35 +0000 Subject: [PATCH] feat: Allow configuring workflow for time saved capture by node (#22386) --- .../frontend/@n8n/i18n/src/locales/en.json | 10 + .../WorkflowProductionChecklist.vue | 18 +- .../src/app/components/WorkflowSettings.vue | 286 +++++++++++++++++- .../src/app/constants/experiments.ts | 5 + .../editor-ui/src/app/constants/nodeTypes.ts | 2 + .../nodes/TimeSaved/TimeSaved.node.ts | 1 + packages/nodes-base/package.json | 1 - 7 files changed, 320 insertions(+), 3 deletions(-) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index b73b416ed0b..ecf6d85f080 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -3134,6 +3134,16 @@ "workflowSettings.timeSavedPerExecution": "Estimated time saved", "workflowSettings.timeSavedPerExecution.hint": "Minutes per production execution", "workflowSettings.timeSavedPerExecution.tooltip": "Total time savings are summarised in the Overview page.", + "workflowSettings.timeSavedPerExecution.minutesSaved": "Minutes saved", + "workflowSettings.timeSavedPerExecution.tab.fixed": "Fixed", + "workflowSettings.timeSavedPerExecution.tab.conditional": "Conditional", + "workflowSettings.timeSavedPerExecution.noNodesDetected": "No time saved nodes detected", + "workflowSettings.timeSavedPerExecution.noNodesDetected.hint": "Add one or more time saved nodes to calculate time saved dynamically", + "workflowSettings.timeSavedPerExecution.nodesDetected": "Active - {count} time saved nodes currently setup", + "workflowSettings.timeSavedPerExecution.nodesDetected.hint": "Time saved is calculated dynamically based on each execution", + "workflowSettings.timeSavedPerExecution.nodesDetected.addMore": "Add more time saved nodes", + "workflowSettings.timeSavedPerExecution.fixedTabWarning": "There are one or more {link} calculating time saved on this workflows dynamically. While your workflow is configured for fix time saved values, these nodes will be ignored.", + "workflowSettings.timeSavedPerExecution.fixedTabWarning.link": "time saved nodes", "workflowSettings.availableInMCP": "Available in MCP", "workflowSettings.availableInMCP.tooltip": "Make this workflow visible to AI Agents through n8n MCP", "workflowSettings.toggleMCP.error.title": "Error updating MCP settings", diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.vue b/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.vue index 090f2820f38..2438f4c901a 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowProductionChecklist.vue @@ -16,6 +16,8 @@ import { EVALUATIONS_DOCS_URL, ERROR_WORKFLOW_DOCS_URL, TIME_SAVED_DOCS_URL, + TIME_SAVED_NODE_EXPERIMENT, + TIME_SAVED_NODE_TYPE, } from '@/app/constants'; import { useMessage } from '@/app/composables/useMessage'; import { useTelemetry } from '@/app/composables/useTelemetry'; @@ -26,6 +28,8 @@ import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp'; import { N8nSuggestedActions } from '@n8n/design-system'; import { useSettingsStore } from '@/app/stores/settings.store'; import { useUsersStore } from '@/features/settings/users/users.store'; +import { usePostHog } from '@/app/stores/posthog.store'; + const props = defineProps<{ workflow: IWorkflowDb; }>(); @@ -42,6 +46,7 @@ const sourceControlStore = useSourceControlStore(); const settingsStore = useSettingsStore(); const { isEligibleForMcpAccess } = useMcp(); const usersStore = useUsersStore(); +const posthogStore = usePostHog(); const isPopoverOpen = ref(false); const cachedSettings = ref(null); @@ -62,8 +67,19 @@ const hasErrorWorkflow = computed(() => { return !!props.workflow.settings?.errorWorkflow; }); +const hasSavedTimeNodes = computed(() => { + if (!posthogStore.isFeatureEnabled(TIME_SAVED_NODE_EXPERIMENT.name)) { + return false; + } + + if (!props.workflow?.nodes) return false; + return props.workflow.nodes.some( + (node) => node.type === TIME_SAVED_NODE_TYPE && node.disabled !== true, + ); +}); + const hasTimeSaved = computed(() => { - return props.workflow.settings?.timeSavedPerExecution !== undefined; + return props.workflow.settings?.timeSavedPerExecution !== undefined || hasSavedTimeNodes.value; }); const isActivationModalOpen = computed(() => { diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue index e9ea0e969b7..4d558fe9dca 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue @@ -9,7 +9,11 @@ import { EnterpriseEditionFeature, PLACEHOLDER_EMPTY_WORKFLOW_ID, WORKFLOW_SETTINGS_MODAL_KEY, + TIME_SAVED_NODE_EXPERIMENT, + NODE_CREATOR_OPEN_SOURCES, + TIME_SAVED_NODE_TYPE, } from '@/app/constants'; +import { N8nButton, N8nIcon, N8nInput, N8nOption, N8nSelect, N8nTooltip } from '@n8n/design-system'; import type { WorkflowSettings } from 'n8n-workflow'; import { deepCopy } from 'n8n-workflow'; import { useSettingsStore } from '@/app/stores/settings.store'; @@ -26,9 +30,12 @@ import { useTelemetry } from '@/app/composables/useTelemetry'; import { useDebounce } from '@/app/composables/useDebounce'; import { injectWorkflowState } from '@/app/composables/useWorkflowState'; import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp'; +import { useGlobalLinkActions } from '@/app/composables/useGlobalLinkActions'; +import { usePostHog } from '@/app/stores/posthog.store'; +import { useNodeCreatorStore } from '@/features/shared/nodeCreator/nodeCreator.store'; import { ElCol, ElRow, ElSwitch } from 'element-plus'; -import { N8nButton, N8nIcon, N8nInput, N8nOption, N8nSelect, N8nTooltip } from '@n8n/design-system'; + const route = useRoute(); const i18n = useI18n(); const externalHooks = useExternalHooks(); @@ -36,6 +43,7 @@ const toast = useToast(); const modalBus = createEventBus(); const telemetry = useTelemetry(); const { isEligibleForMcpAccess, trackMcpAccessEnabledForWorkflow, mcpTriggerMap } = useMcp(); +const { registerCustomAction, unregisterCustomAction } = useGlobalLinkActions(); const rootStore = useRootStore(); const settingsStore = useSettingsStore(); @@ -43,6 +51,8 @@ const sourceControlStore = useSourceControlStore(); const workflowsStore = useWorkflowsStore(); const workflowState = injectWorkflowState(); const workflowsEEStore = useWorkflowsEEStore(); +const posthogStore = usePostHog(); +const nodeCreatorStore = useNodeCreatorStore(); const isLoading = ref(true); const workflowCallerPolicyOptions = ref>([]); @@ -121,6 +131,40 @@ const isEligibleForMcp = computed(() => { return isEligibleForMcpAccess(workflow.value); }); +const isTimeSavedNodeExperimentEnabled = computed(() => { + return posthogStore.isFeatureEnabled(TIME_SAVED_NODE_EXPERIMENT.name); +}); + +const savedTimeNodes = computed(() => { + if (!isTimeSavedNodeExperimentEnabled.value) { + return []; + } + + if (!workflow?.value?.nodes) return []; + return workflow.value.nodes.filter( + (node) => node.type === TIME_SAVED_NODE_TYPE && node.disabled !== true, + ); +}); + +const hasSavedTimeNodes = computed(() => { + if (!isTimeSavedNodeExperimentEnabled.value) { + return false; + } + + return savedTimeNodes.value.length > 0; +}); + +const timeSavedModeOptions = computed(() => [ + { + label: 'Fixed', + value: 'fixed' as const, + }, + { + label: 'Dynamic (node based)', + value: 'dynamic' as const, + }, +]); + const onCallerIdsInput = (str: string) => { workflowSettings.value.callerIds = /^[a-zA-Z0-9,\s]+$/.test(str) ? str @@ -477,6 +521,9 @@ onMounted(async () => { const workflowSettingsData = deepCopy(workflowsStore.workflowSettings); + if (workflowSettingsData.timeSavedMode === undefined) { + workflowSettingsData.timeSavedMode = 'fixed'; + } if (workflowSettingsData.timezone === undefined) { workflowSettingsData.timezone = 'DEFAULT'; } @@ -519,10 +566,24 @@ onMounted(async () => { telemetry.track('User opened workflow settings', { workflow_id: workflowsStore.workflowId, }); + + // Register custom action for opening SavedTime node creator + registerCustomAction({ + key: 'openSavedTimeNodeCreator', + action: () => { + // Close the workflow settings modal + closeDialog(); + // Open node creator for regular nodes + nodeCreatorStore.openNodeCreatorForRegularNodes( + NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_ACTION, + ); + }, + }); }); onBeforeUnmount(() => { debouncedLoadWorkflows.cancel?.(); + unregisterCustomAction('openSavedTimeNodeCreator'); }); @@ -911,6 +972,42 @@ onBeforeUnmount(() => { +
+ + {{ i18n.baseText('workflowSettings.timeSavedPerExecution.hint') }} +
+
+ + + +
+
+ + + +
{
+ + +
+
+ +
+
+
+
+ + + + +
+
+
+ +
+
+ {{ + i18n.baseText('workflowSettings.timeSavedPerExecution.nodesDetected', { + interpolate: { count: savedTimeNodes.length }, + }) + }} +
+
+ {{ + i18n.baseText('workflowSettings.timeSavedPerExecution.nodesDetected.hint') + }} +
+
+
+ + {{ + i18n.baseText('workflowSettings.timeSavedPerExecution.nodesDetected.addMore') + }} + +
+
+
+
+ + + +
+
+
+ {{ i18n.baseText('workflowSettings.timeSavedPerExecution.noNodesDetected') }} +
+
+ {{ i18n.baseText('workflowSettings.timeSavedPerExecution.noNodesDetected.hint') }} +
+
+
+
+