mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-29 15:57:00 +02:00
feat: Allow configuring workflow for time saved capture by node (#22386)
This commit is contained in:
parent
20f5bdc4e8
commit
4adfced937
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<WorkflowSettings | null>(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(() => {
|
||||
|
|
|
|||
|
|
@ -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<Array<{ key: string; value: string }>>([]);
|
||||
|
|
@ -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');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -911,6 +972,42 @@ onBeforeUnmount(() => {
|
|||
</label>
|
||||
</ElCol>
|
||||
<ElCol :span="14">
|
||||
<div v-if="!isTimeSavedNodeExperimentEnabled" :class="$style['time-saved']">
|
||||
<N8nInput
|
||||
id="timeSavedPerExecution"
|
||||
v-model="workflowSettings.timeSavedPerExecution"
|
||||
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||
data-test-id="workflow-settings-time-saved-per-execution"
|
||||
type="number"
|
||||
min="0"
|
||||
@update:model-value="updateTimeSavedPerExecution"
|
||||
/>
|
||||
<span>{{ i18n.baseText('workflowSettings.timeSavedPerExecution.hint') }}</span>
|
||||
</div>
|
||||
<div v-else class="ignore-key-press-canvas">
|
||||
<N8nSelect
|
||||
v-model="workflowSettings.timeSavedMode"
|
||||
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||
data-test-id="workflow-settings-time-saved-mode"
|
||||
size="medium"
|
||||
filterable
|
||||
:limit-popper-width="true"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="option in timeSavedModeOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</div>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<!-- Fixed mode warning section (only shown in fixed mode when nodes exist) -->
|
||||
<ElRow
|
||||
v-if="isTimeSavedNodeExperimentEnabled && workflowSettings.timeSavedMode === 'fixed'"
|
||||
>
|
||||
<ElCol :span="14" :offset="10">
|
||||
<div :class="$style['time-saved']">
|
||||
<N8nInput
|
||||
id="timeSavedPerExecution"
|
||||
|
|
@ -925,6 +1022,88 @@ onBeforeUnmount(() => {
|
|||
</div>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<ElRow
|
||||
v-if="
|
||||
isTimeSavedNodeExperimentEnabled &&
|
||||
workflowSettings.timeSavedMode === 'fixed' &&
|
||||
hasSavedTimeNodes
|
||||
"
|
||||
>
|
||||
<ElCol :span="14" :offset="10">
|
||||
<div :class="$style['time-saved-content']">
|
||||
<div :class="$style['time-saved-warning']">
|
||||
<span
|
||||
v-n8n-html="
|
||||
i18n.baseText('workflowSettings.timeSavedPerExecution.fixedTabWarning', {
|
||||
interpolate: {
|
||||
link: `<a href='#' class='${$style['time-saved-link']}' data-action='openSavedTimeNodeCreator'>${i18n.baseText('workflowSettings.timeSavedPerExecution.fixedTabWarning.link')}</a>`,
|
||||
},
|
||||
})
|
||||
"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<!-- Minutes saved section (only shown in fixed mode) -->
|
||||
<!-- Active nodes section (only shown in dynamic mode when nodes exist) -->
|
||||
<ElRow
|
||||
v-if="
|
||||
isTimeSavedNodeExperimentEnabled &&
|
||||
workflowSettings.timeSavedMode === 'dynamic' &&
|
||||
hasSavedTimeNodes
|
||||
"
|
||||
>
|
||||
<ElCol :span="14" :offset="10">
|
||||
<div :class="$style['time-saved-content']">
|
||||
<div :class="$style['time-saved-nodes-active']">
|
||||
<div :class="$style['nodes-active-wrapper']">
|
||||
<N8nIcon icon="clock" :class="$style['nodes-active-icon']" />
|
||||
<div :class="$style['nodes-active-content']">
|
||||
<div :class="$style['nodes-active-title']">
|
||||
{{
|
||||
i18n.baseText('workflowSettings.timeSavedPerExecution.nodesDetected', {
|
||||
interpolate: { count: savedTimeNodes.length },
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div :class="$style['nodes-active-hint']">
|
||||
{{
|
||||
i18n.baseText('workflowSettings.timeSavedPerExecution.nodesDetected.hint')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="#" :class="$style['add-more-link']" data-action="openSavedTimeNodeCreator">
|
||||
{{
|
||||
i18n.baseText('workflowSettings.timeSavedPerExecution.nodesDetected.addMore')
|
||||
}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<!-- No nodes detected section (only shown in dynamic mode when no nodes) -->
|
||||
<ElRow
|
||||
v-if="
|
||||
isTimeSavedNodeExperimentEnabled &&
|
||||
workflowSettings.timeSavedMode === 'dynamic' &&
|
||||
!hasSavedTimeNodes
|
||||
"
|
||||
>
|
||||
<ElCol :span="14" :offset="10">
|
||||
<div :class="$style['time-saved-content']">
|
||||
<div :class="$style['time-saved-no-nodes']">
|
||||
<div :class="$style['no-nodes-title']">
|
||||
{{ i18n.baseText('workflowSettings.timeSavedPerExecution.noNodesDetected') }}
|
||||
</div>
|
||||
<div :class="$style['no-nodes-hint']">
|
||||
{{ i18n.baseText('workflowSettings.timeSavedPerExecution.noNodesDetected.hint') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
|
|
@ -995,4 +1174,109 @@ onBeforeUnmount(() => {
|
|||
margin-left: var(--spacing--2xs);
|
||||
}
|
||||
}
|
||||
|
||||
.time-saved-dropdown {
|
||||
margin-bottom: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.time-saved-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.time-saved-content {
|
||||
padding: var(--spacing--sm);
|
||||
border: var(--border-width) var(--border-style) var(--color--foreground);
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--color--background--light-2);
|
||||
}
|
||||
|
||||
.time-saved-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
:global(.el-input) {
|
||||
width: var(--spacing--3xl);
|
||||
}
|
||||
|
||||
span {
|
||||
margin-left: var(--spacing--2xs);
|
||||
}
|
||||
}
|
||||
|
||||
.time-saved-warning {
|
||||
color: var(--color--text);
|
||||
line-height: var(--line-height--xl);
|
||||
}
|
||||
|
||||
.time-saved-link {
|
||||
color: var(--color--primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.time-saved-no-nodes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.no-nodes-title {
|
||||
font-weight: var(--font-weight--bold);
|
||||
color: var(--color--text);
|
||||
}
|
||||
|
||||
.no-nodes-hint {
|
||||
color: var(--color--text--tint-1);
|
||||
font-size: var(--font-size--sm);
|
||||
line-height: var(--line-height--xl);
|
||||
}
|
||||
|
||||
.time-saved-nodes-active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.nodes-active-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.nodes-active-icon {
|
||||
color: var(--color--primary);
|
||||
margin-top: var(--spacing--5xs);
|
||||
}
|
||||
|
||||
.nodes-active-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--5xs);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nodes-active-title {
|
||||
font-weight: var(--font-weight--bold);
|
||||
color: var(--color--text);
|
||||
}
|
||||
|
||||
.nodes-active-hint {
|
||||
color: var(--color--text--tint-1);
|
||||
font-size: var(--font-size--sm);
|
||||
}
|
||||
|
||||
.add-more-link {
|
||||
color: var(--color--primary);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size--sm);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ export const PERSONALIZED_TEMPLATES_V3 = {
|
|||
variant: 'variant',
|
||||
};
|
||||
|
||||
export const TIME_SAVED_NODE_EXPERIMENT = {
|
||||
name: '053_time_saved_node',
|
||||
};
|
||||
|
||||
export const TEMPLATE_SETUP_EXPERIENCE = {
|
||||
name: '055_template_setup_experience',
|
||||
control: 'control',
|
||||
|
|
@ -98,5 +102,6 @@ export const EXPERIMENTS_TO_TRACK = [
|
|||
TEMPLATE_RECO_V2.name,
|
||||
TEMPLATES_DATA_QUALITY_EXPERIMENT.name,
|
||||
READY_TO_RUN_V2_PART2_EXPERIMENT.name,
|
||||
TIME_SAVED_NODE_EXPERIMENT.name,
|
||||
TEMPLATE_SETUP_EXPERIENCE.name,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -100,8 +100,10 @@ export const FACEBOOK_LEAD_ADS_TRIGGER_NODE_TYPE = 'n8n-nodes-base.facebookLeadA
|
|||
export const RESPOND_TO_WEBHOOK_NODE_TYPE = 'n8n-nodes-base.respondToWebhook';
|
||||
export const DATA_TABLE_NODE_TYPE = 'n8n-nodes-base.dataTable';
|
||||
export const DATA_TABLE_TOOL_NODE_TYPE = 'n8n-nodes-base.dataTableTool';
|
||||
export const TIME_SAVED_NODE_TYPE = 'n8n-nodes-base.timeSaved';
|
||||
|
||||
export const CREDENTIAL_ONLY_NODE_PREFIX = 'n8n-creds-base';
|
||||
|
||||
export const CREDENTIAL_ONLY_HTTP_NODE_VERSION = 4.1;
|
||||
|
||||
export const EXECUTABLE_TRIGGER_NODE_TYPES = [
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export class TimeSaved implements INodeType {
|
|||
name: 'minutesSaved',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
noDataExpression: true,
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -799,7 +799,6 @@
|
|||
"dist/nodes/TheHiveProject/TheHiveProjectTrigger.node.js",
|
||||
"dist/nodes/TheHive/TheHive.node.js",
|
||||
"dist/nodes/TheHive/TheHiveTrigger.node.js",
|
||||
"dist/nodes/TimeSaved/TimeSaved.node.js",
|
||||
"dist/nodes/TimescaleDb/TimescaleDb.node.js",
|
||||
"dist/nodes/Todoist/Todoist.node.js",
|
||||
"dist/nodes/Toggl/TogglTrigger.node.js",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user