mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat: Enable time saved node for testing (#22650)
This commit is contained in:
@@ -3180,7 +3180,7 @@
|
||||
"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": "Time saved for this workflow is tracked using a fixed value. Any Time Saved nodes in this workflow are ignored with this setting.",
|
||||
"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",
|
||||
|
||||
@@ -16,7 +16,6 @@ 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';
|
||||
@@ -28,7 +27,6 @@ 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;
|
||||
@@ -46,7 +44,6 @@ 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);
|
||||
@@ -68,10 +65,6 @@ const hasErrorWorkflow = computed(() => {
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { nextTick, reactive } from 'vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import type { MockInstance } from 'vitest';
|
||||
import { within, waitFor } from '@testing-library/vue';
|
||||
import { waitFor, within } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { FrontendSettings } from '@n8n/api-types';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
@@ -224,13 +224,15 @@ describe('WorkflowSettingsVue', () => {
|
||||
);
|
||||
|
||||
it('should save time saved per execution correctly', async () => {
|
||||
workflowsStore.workflowSettings.timeSavedMode = 'fixed';
|
||||
const { getByTestId, getByRole } = createComponent({ pinia });
|
||||
await nextTick();
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('workflow-settings-time-saved-per-execution')).toBeVisible();
|
||||
});
|
||||
|
||||
const timeSavedPerExecutionInput = getByTestId('workflow-settings-time-saved-per-execution');
|
||||
|
||||
expect(timeSavedPerExecutionInput).toBeVisible();
|
||||
|
||||
await userEvent.type(timeSavedPerExecutionInput as Element, '10');
|
||||
expect(timeSavedPerExecutionInput).toHaveValue(10);
|
||||
|
||||
@@ -242,14 +244,16 @@ describe('WorkflowSettingsVue', () => {
|
||||
});
|
||||
|
||||
it('should remove time saved per execution setting', async () => {
|
||||
workflowsStore.workflowSettings.timeSavedMode = 'fixed';
|
||||
workflowsStore.workflowSettings.timeSavedPerExecution = 10;
|
||||
|
||||
const { getByTestId, getByRole } = createComponent({ pinia });
|
||||
await nextTick();
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('workflow-settings-time-saved-per-execution')).toBeVisible();
|
||||
});
|
||||
|
||||
const timeSavedPerExecutionInput = getByTestId('workflow-settings-time-saved-per-execution');
|
||||
|
||||
expect(timeSavedPerExecutionInput).toBeVisible();
|
||||
await waitFor(() => expect(timeSavedPerExecutionInput).toHaveValue(10));
|
||||
|
||||
await userEvent.clear(timeSavedPerExecutionInput as Element);
|
||||
@@ -265,18 +269,22 @@ describe('WorkflowSettingsVue', () => {
|
||||
});
|
||||
|
||||
it('should disable save time saved per execution if env is read-only', async () => {
|
||||
workflowsStore.workflowSettings.timeSavedMode = 'fixed';
|
||||
sourceControlStore.preferences.branchReadOnly = true;
|
||||
|
||||
const { getByTestId } = createComponent({ pinia });
|
||||
await nextTick();
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('workflow-settings-time-saved-per-execution')).toBeVisible();
|
||||
});
|
||||
|
||||
const timeSavedPerExecutionInput = getByTestId('workflow-settings-time-saved-per-execution');
|
||||
|
||||
expect(timeSavedPerExecutionInput).toBeVisible();
|
||||
expect(timeSavedPerExecutionInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable save time saved per execution if user has no permission to update workflow', async () => {
|
||||
workflowsStore.workflowSettings.timeSavedMode = 'fixed';
|
||||
workflowsStore.getWorkflowById.mockImplementation(() => ({
|
||||
id: '1',
|
||||
name: 'Test Workflow',
|
||||
@@ -293,10 +301,12 @@ describe('WorkflowSettingsVue', () => {
|
||||
|
||||
const { getByTestId } = createComponent({ pinia });
|
||||
await nextTick();
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('workflow-settings-time-saved-per-execution')).toBeVisible();
|
||||
});
|
||||
|
||||
const timeSavedPerExecutionInput = getByTestId('workflow-settings-time-saved-per-execution');
|
||||
|
||||
expect(timeSavedPerExecutionInput).toBeVisible();
|
||||
expect(timeSavedPerExecutionInput).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ 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';
|
||||
@@ -31,7 +30,6 @@ 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';
|
||||
@@ -51,7 +49,6 @@ const sourceControlStore = useSourceControlStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const workflowState = injectWorkflowState();
|
||||
const workflowsEEStore = useWorkflowsEEStore();
|
||||
const posthogStore = usePostHog();
|
||||
const nodeCreatorStore = useNodeCreatorStore();
|
||||
|
||||
const isLoading = ref(true);
|
||||
@@ -131,15 +128,7 @@ 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,
|
||||
@@ -147,10 +136,6 @@ const savedTimeNodes = computed(() => {
|
||||
});
|
||||
|
||||
const hasSavedTimeNodes = computed(() => {
|
||||
if (!isTimeSavedNodeExperimentEnabled.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return savedTimeNodes.value.length > 0;
|
||||
});
|
||||
|
||||
@@ -160,7 +145,7 @@ const timeSavedModeOptions = computed(() => [
|
||||
value: 'fixed' as const,
|
||||
},
|
||||
{
|
||||
label: 'Dynamic (node based)',
|
||||
label: 'Dynamic (uses time saved nodes)',
|
||||
value: 'dynamic' as const,
|
||||
},
|
||||
]);
|
||||
@@ -503,6 +488,7 @@ onMounted(async () => {
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
workflowsStore.fetchWorkflow(workflowId.value),
|
||||
loadWorkflows(),
|
||||
loadSaveDataErrorExecutionOptions(),
|
||||
loadSaveDataSuccessExecutionOptions(),
|
||||
@@ -972,19 +958,7 @@ 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">
|
||||
<div class="ignore-key-press-canvas">
|
||||
<N8nSelect
|
||||
v-model="workflowSettings.timeSavedMode"
|
||||
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||
@@ -1003,12 +977,9 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<!-- Fixed mode warning section (only shown in fixed mode when nodes exist) -->
|
||||
<ElRow
|
||||
v-if="isTimeSavedNodeExperimentEnabled && workflowSettings.timeSavedMode === 'fixed'"
|
||||
>
|
||||
<ElRow v-if="workflowSettings.timeSavedMode === 'fixed'">
|
||||
<ElCol :span="14" :offset="10">
|
||||
<div :class="$style['time-saved']">
|
||||
<div :class="$style['time-saved-input']">
|
||||
<N8nInput
|
||||
id="timeSavedPerExecution"
|
||||
v-model="workflowSettings.timeSavedPerExecution"
|
||||
@@ -1022,13 +993,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<ElRow
|
||||
v-if="
|
||||
isTimeSavedNodeExperimentEnabled &&
|
||||
workflowSettings.timeSavedMode === 'fixed' &&
|
||||
hasSavedTimeNodes
|
||||
"
|
||||
>
|
||||
<ElRow v-if="workflowSettings.timeSavedMode === 'fixed' && hasSavedTimeNodes">
|
||||
<ElCol :span="14" :offset="10">
|
||||
<div :class="$style['time-saved-content']">
|
||||
<div :class="$style['time-saved-warning']">
|
||||
@@ -1047,13 +1012,7 @@ onBeforeUnmount(() => {
|
||||
</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
|
||||
"
|
||||
>
|
||||
<ElRow v-if="workflowSettings.timeSavedMode === 'dynamic' && hasSavedTimeNodes">
|
||||
<ElCol :span="14" :offset="10">
|
||||
<div :class="$style['time-saved-content']">
|
||||
<div :class="$style['time-saved-nodes-active']">
|
||||
@@ -1084,13 +1043,7 @@ onBeforeUnmount(() => {
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<!-- No nodes detected section (only shown in dynamic mode when no nodes) -->
|
||||
<ElRow
|
||||
v-if="
|
||||
isTimeSavedNodeExperimentEnabled &&
|
||||
workflowSettings.timeSavedMode === 'dynamic' &&
|
||||
!hasSavedTimeNodes
|
||||
"
|
||||
>
|
||||
<ElRow v-if="workflowSettings.timeSavedMode === 'dynamic' && !hasSavedTimeNodes">
|
||||
<ElCol :span="14" :offset="10">
|
||||
<div :class="$style['time-saved-content']">
|
||||
<div :class="$style['time-saved-no-nodes']">
|
||||
@@ -1175,6 +1128,19 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.time-saved-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
:global(.el-input) {
|
||||
width: var(--spacing--3xl);
|
||||
}
|
||||
|
||||
span {
|
||||
margin-left: var(--spacing--2xs);
|
||||
}
|
||||
}
|
||||
|
||||
.time-saved-dropdown {
|
||||
margin-bottom: var(--spacing--sm);
|
||||
}
|
||||
|
||||
@@ -89,10 +89,6 @@ 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',
|
||||
@@ -123,6 +119,5 @@ export const EXPERIMENTS_TO_TRACK = [
|
||||
UPGRADE_PLAN_CTA_EXPERIMENT.name,
|
||||
AI_BUILDER_TEMPLATE_EXAMPLES_EXPERIMENT.name,
|
||||
AI_BUILDER_MULTI_AGENT_EXPERIMENT.name,
|
||||
TIME_SAVED_NODE_EXPERIMENT.name,
|
||||
TEMPLATE_SETUP_EXPERIENCE.name,
|
||||
];
|
||||
|
||||
@@ -13,7 +13,8 @@ export class TimeSaved implements INodeType {
|
||||
icon: 'fa:timer',
|
||||
group: ['organization'],
|
||||
version: 1,
|
||||
description: 'Track dynamic time savings for this workflow execution',
|
||||
description:
|
||||
'Dynamically track time saved based on the workflow’s execution path and the number of items processed',
|
||||
defaults: {
|
||||
name: 'Time Saved',
|
||||
color: '#1E90FF',
|
||||
@@ -23,7 +24,7 @@ export class TimeSaved implements INodeType {
|
||||
properties: [
|
||||
{
|
||||
displayName:
|
||||
'Calculate time saved dynamically based on execution data. This allows you to track variable time savings (e.g., 5 minutes per item processed) instead of using a fixed value in workflow settings.',
|
||||
'For each run, time saved is the sum of all Time Saved nodes that execute. Use this when different execution paths or items save different amounts of time.',
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
@@ -69,8 +70,6 @@ export class TimeSaved implements INodeType {
|
||||
location: 'outputPane',
|
||||
},
|
||||
],
|
||||
// TODO: see if we can use posthog here
|
||||
hidden: true,
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
|
||||
@@ -797,6 +797,7 @@
|
||||
"dist/nodes/TravisCi/TravisCi.node.js",
|
||||
"dist/nodes/Trello/Trello.node.js",
|
||||
"dist/nodes/Trello/TrelloTrigger.node.js",
|
||||
"dist/nodes/TimeSaved/TimeSaved.node.js",
|
||||
"dist/nodes/Twake/Twake.node.js",
|
||||
"dist/nodes/Twilio/Twilio.node.js",
|
||||
"dist/nodes/Twilio/TwilioTrigger.node.js",
|
||||
|
||||
Reference in New Issue
Block a user