fix(editor): Add "time saved per execution" workflow setting (#13369)

This commit is contained in:
Csaba Tuncsik
2025-03-21 20:21:37 +01:00
committed by GitHub
parent 198f17dbcf
commit 6992c36ebb
3 changed files with 231 additions and 115 deletions

View File

@@ -1,55 +1,61 @@
import { createPinia, setActivePinia } from 'pinia'; import { nextTick, reactive } from 'vue';
import WorkflowSettingsVue from '@/components/WorkflowSettings.vue'; import { createTestingPinia } from '@pinia/testing';
import { setupServer } from '@/__tests__/server';
import type { MockInstance } from 'vitest'; import type { MockInstance } from 'vitest';
import { afterAll, beforeAll } from 'vitest'; import { within, waitFor } from '@testing-library/vue';
import { within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import type { FrontendSettings } from '@n8n/api-types';
import { createComponentRenderer } from '@/__tests__/render';
import { getDropdownItems, mockedStore, type MockedStore } from '@/__tests__/utils';
import { EnterpriseEditionFeature } from '@/constants';
import WorkflowSettingsVue from '@/components/WorkflowSettings.vue';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { createComponentRenderer } from '@/__tests__/render'; vi.mock('vue-router', async () => ({
import { cleanupAppModals, createAppModals, getDropdownItems } from '@/__tests__/utils'; useRouter: vi.fn(),
import { EnterpriseEditionFeature, WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants'; useRoute: () =>
reactive({
params: {
name: '1',
},
}),
RouterLink: {
template: '<a><slot /></a>',
},
}));
import { nextTick } from 'vue'; let workflowsStore: MockedStore<typeof useWorkflowsStore>;
import type { IWorkflowDb } from '@/Interface'; let settingsStore: MockedStore<typeof useSettingsStore>;
import * as permissions from '@/permissions'; let sourceControlStore: MockedStore<typeof useSourceControlStore>;
import type { PermissionsRecord } from '@/permissions'; let pinia: ReturnType<typeof createTestingPinia>;
let pinia: ReturnType<typeof createPinia>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let settingsStore: ReturnType<typeof useSettingsStore>;
let uiStore: ReturnType<typeof useUIStore>;
let fetchAllWorkflowsSpy: MockInstance<(typeof workflowsStore)['fetchAllWorkflows']>; let fetchAllWorkflowsSpy: MockInstance<(typeof workflowsStore)['fetchAllWorkflows']>;
const createComponent = createComponentRenderer(WorkflowSettingsVue); const createComponent = createComponentRenderer(WorkflowSettingsVue, {
global: {
stubs: {
Modal: {
template:
'<div role="dialog"><slot name="header" /><slot name="content" /><slot name="footer" /></div>',
},
},
},
});
describe('WorkflowSettingsVue', () => { describe('WorkflowSettingsVue', () => {
let server: ReturnType<typeof setupServer>;
beforeAll(() => {
server = setupServer();
});
beforeEach(async () => { beforeEach(async () => {
pinia = createPinia(); pinia = createTestingPinia();
setActivePinia(pinia); workflowsStore = mockedStore(useWorkflowsStore);
settingsStore = mockedStore(useSettingsStore);
sourceControlStore = mockedStore(useSourceControlStore);
createAppModals(); settingsStore.settings = {
enterprise: {},
workflowsStore = useWorkflowsStore(); } as FrontendSettings;
settingsStore = useSettingsStore(); workflowsStore.workflowName = 'Test Workflow';
uiStore = useUIStore(); workflowsStore.workflowId = '1';
fetchAllWorkflowsSpy = workflowsStore.fetchAllWorkflows.mockResolvedValue([
await settingsStore.getSettings();
vi.spyOn(workflowsStore, 'workflowName', 'get').mockReturnValue('Test Workflow');
vi.spyOn(workflowsStore, 'workflowId', 'get').mockReturnValue('1');
fetchAllWorkflowsSpy = vi.spyOn(workflowsStore, 'fetchAllWorkflows').mockResolvedValue([
{ {
id: '1', id: '1',
name: 'Test Workflow', name: 'Test Workflow',
@@ -61,7 +67,7 @@ describe('WorkflowSettingsVue', () => {
versionId: '123', versionId: '123',
}, },
]); ]);
vi.spyOn(workflowsStore, 'getWorkflowById').mockReturnValue({ workflowsStore.getWorkflowById.mockImplementation(() => ({
id: '1', id: '1',
name: 'Test Workflow', name: 'Test Workflow',
active: true, active: true,
@@ -70,24 +76,12 @@ describe('WorkflowSettingsVue', () => {
createdAt: 1, createdAt: 1,
updatedAt: 1, updatedAt: 1,
versionId: '123', versionId: '123',
} as IWorkflowDb); scopes: ['workflow:update'],
vi.spyOn(permissions, 'getResourcePermissions').mockReturnValue({ }));
workflow: {
update: true,
},
} as PermissionsRecord);
uiStore.modalsById[WORKFLOW_SETTINGS_MODAL_KEY] = {
open: true,
};
}); });
afterEach(() => { afterEach(() => {
cleanupAppModals(); vi.clearAllMocks();
});
afterAll(() => {
server.shutdown();
}); });
it('should render correctly', async () => { it('should render correctly', async () => {
@@ -220,4 +214,79 @@ describe('WorkflowSettingsVue', () => {
expect(dropdownItems[0]).toHaveTextContent(optionText); expect(dropdownItems[0]).toHaveTextContent(optionText);
}, },
); );
it('should save time saved per execution correctly', async () => {
const { getByTestId, getByRole } = createComponent({ pinia });
await nextTick();
const timeSavedPerExecutionInput = getByTestId('workflow-settings-time-saved-per-execution');
expect(timeSavedPerExecutionInput).toBeVisible();
await userEvent.type(timeSavedPerExecutionInput as Element, '10');
expect(timeSavedPerExecutionInput).toHaveValue(10);
await userEvent.click(getByRole('button', { name: 'Save' }));
expect(workflowsStore.updateWorkflow).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ settings: expect.objectContaining({ timeSavedPerExecution: 10 }) }),
);
});
it('should remove time saved per execution setting', async () => {
workflowsStore.workflowSettings.timeSavedPerExecution = 10;
const { getByTestId, getByRole } = createComponent({ pinia });
await nextTick();
const timeSavedPerExecutionInput = getByTestId('workflow-settings-time-saved-per-execution');
expect(timeSavedPerExecutionInput).toBeVisible();
await waitFor(() => expect(timeSavedPerExecutionInput).toHaveValue(10));
await userEvent.clear(timeSavedPerExecutionInput as Element);
expect(timeSavedPerExecutionInput).not.toHaveValue();
await userEvent.click(getByRole('button', { name: 'Save' }));
expect(workflowsStore.updateWorkflow).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
settings: expect.not.objectContaining({ timeSavedPerExecution: 10 }),
}),
);
});
it('should disable save time saved per execution if env is read-only', async () => {
sourceControlStore.preferences.branchReadOnly = true;
const { getByTestId } = createComponent({ pinia });
await nextTick();
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.getWorkflowById.mockImplementation(() => ({
id: '1',
name: 'Test Workflow',
active: true,
nodes: [],
connections: {},
createdAt: 1,
updatedAt: 1,
versionId: '123',
scopes: ['workflow:read'],
}));
const { getByTestId } = createComponent({ pinia });
await nextTick();
const timeSavedPerExecutionInput = getByTestId('workflow-settings-time-saved-per-execution');
expect(timeSavedPerExecutionInput).toBeVisible();
expect(timeSavedPerExecutionInput).toBeDisabled();
});
}); });

View File

@@ -378,6 +378,11 @@ const toggleTimeout = () => {
workflowSettings.value.executionTimeout = workflowSettings.value.executionTimeout === -1 ? 0 : -1; workflowSettings.value.executionTimeout = workflowSettings.value.executionTimeout === -1 ? 0 : -1;
}; };
const updateTimeSavedPerExecution = (value: string) => {
const numValue = parseInt(value, 10);
workflowSettings.value.timeSavedPerExecution = isNaN(numValue) ? undefined : numValue;
};
onMounted(async () => { onMounted(async () => {
executionTimeout.value = rootStore.executionTimeout; executionTimeout.value = rootStore.executionTimeout;
maxExecutionTimeout.value = rootStore.maxExecutionTimeout; maxExecutionTimeout.value = rootStore.maxExecutionTimeout;
@@ -484,7 +489,7 @@ onMounted(async () => {
{{ i18n.baseText('workflowSettings.executionOrder') + ':' }} {{ i18n.baseText('workflowSettings.executionOrder') + ':' }}
</el-col> </el-col>
<el-col :span="14" class="ignore-key-press-canvas"> <el-col :span="14" class="ignore-key-press-canvas">
<n8n-select <N8nSelect
v-model="workflowSettings.executionOrder" v-model="workflowSettings.executionOrder"
placeholder="Select Execution Order" placeholder="Select Execution Order"
size="medium" size="medium"
@@ -493,29 +498,29 @@ onMounted(async () => {
:limit-popper-width="true" :limit-popper-width="true"
data-test-id="workflow-settings-execution-order" data-test-id="workflow-settings-execution-order"
> >
<n8n-option <N8nOption
v-for="option in executionOrderOptions" v-for="option in executionOrderOptions"
:key="option.key" :key="option.key"
:label="option.value" :label="option.value"
:value="option.key" :value="option.key"
> >
</n8n-option> </N8nOption>
</n8n-select> </N8nSelect>
</el-col> </el-col>
</el-row> </el-row>
<el-row data-test-id="error-workflow"> <el-row data-test-id="error-workflow">
<el-col :span="10" class="setting-name"> <el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.errorWorkflow') + ':' }} {{ i18n.baseText('workflowSettings.errorWorkflow') + ':' }}
<n8n-tooltip placement="top"> <N8nTooltip placement="top">
<template #content> <template #content>
<div v-n8n-html="helpTexts.errorWorkflow"></div> <div v-n8n-html="helpTexts.errorWorkflow"></div>
</template> </template>
<font-awesome-icon icon="question-circle" /> <font-awesome-icon icon="question-circle" />
</n8n-tooltip> </N8nTooltip>
</el-col> </el-col>
<el-col :span="14" class="ignore-key-press-canvas"> <el-col :span="14" class="ignore-key-press-canvas">
<n8n-select <N8nSelect
v-model="workflowSettings.errorWorkflow" v-model="workflowSettings.errorWorkflow"
placeholder="Select Workflow" placeholder="Select Workflow"
filterable filterable
@@ -523,58 +528,58 @@ onMounted(async () => {
:limit-popper-width="true" :limit-popper-width="true"
data-test-id="workflow-settings-error-workflow" data-test-id="workflow-settings-error-workflow"
> >
<n8n-option <N8nOption
v-for="item in workflows" v-for="item in workflows"
:key="item.id" :key="item.id"
:label="item.name" :label="item.name"
:value="item.id" :value="item.id"
> >
</n8n-option> </N8nOption>
</n8n-select> </N8nSelect>
</el-col> </el-col>
</el-row> </el-row>
<div v-if="isSharingEnabled" data-test-id="workflow-caller-policy"> <div v-if="isSharingEnabled" data-test-id="workflow-caller-policy">
<el-row> <el-row>
<el-col :span="10" class="setting-name"> <el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.callerPolicy') + ':' }} {{ i18n.baseText('workflowSettings.callerPolicy') + ':' }}
<n8n-tooltip placement="top"> <N8nTooltip placement="top">
<template #content> <template #content>
<div v-text="helpTexts.workflowCallerPolicy"></div> <div v-text="helpTexts.workflowCallerPolicy"></div>
</template> </template>
<font-awesome-icon icon="question-circle" /> <font-awesome-icon icon="question-circle" />
</n8n-tooltip> </N8nTooltip>
</el-col> </el-col>
<el-col :span="14" class="ignore-key-press-canvas"> <el-col :span="14" class="ignore-key-press-canvas">
<n8n-select <N8nSelect
v-model="workflowSettings.callerPolicy" v-model="workflowSettings.callerPolicy"
:disabled="readOnlyEnv || !workflowPermissions.update" :disabled="readOnlyEnv || !workflowPermissions.update"
:placeholder="i18n.baseText('workflowSettings.selectOption')" :placeholder="i18n.baseText('workflowSettings.selectOption')"
filterable filterable
:limit-popper-width="true" :limit-popper-width="true"
> >
<n8n-option <N8nOption
v-for="option of workflowCallerPolicyOptions" v-for="option of workflowCallerPolicyOptions"
:key="option.key" :key="option.key"
:label="option.value" :label="option.value"
:value="option.key" :value="option.key"
> >
</n8n-option> </N8nOption>
</n8n-select> </N8nSelect>
</el-col> </el-col>
</el-row> </el-row>
<el-row v-if="workflowSettings.callerPolicy === 'workflowsFromAList'"> <el-row v-if="workflowSettings.callerPolicy === 'workflowsFromAList'">
<el-col :span="10" class="setting-name"> <el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.callerIds') + ':' }} {{ i18n.baseText('workflowSettings.callerIds') + ':' }}
<n8n-tooltip placement="top"> <N8nTooltip placement="top">
<template #content> <template #content>
<div v-text="helpTexts.workflowCallerIds"></div> <div v-text="helpTexts.workflowCallerIds"></div>
</template> </template>
<font-awesome-icon icon="question-circle" /> <font-awesome-icon icon="question-circle" />
</n8n-tooltip> </N8nTooltip>
</el-col> </el-col>
<el-col :span="14"> <el-col :span="14">
<n8n-input <N8nInput
v-model="workflowSettings.callerIds" v-model="workflowSettings.callerIds"
:disabled="readOnlyEnv || !workflowPermissions.update" :disabled="readOnlyEnv || !workflowPermissions.update"
:placeholder="i18n.baseText('workflowSettings.callerIds.placeholder')" :placeholder="i18n.baseText('workflowSettings.callerIds.placeholder')"
@@ -588,15 +593,15 @@ onMounted(async () => {
<el-row> <el-row>
<el-col :span="10" class="setting-name"> <el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.timezone') + ':' }} {{ i18n.baseText('workflowSettings.timezone') + ':' }}
<n8n-tooltip placement="top"> <N8nTooltip placement="top">
<template #content> <template #content>
<div v-text="helpTexts.timezone"></div> <div v-text="helpTexts.timezone"></div>
</template> </template>
<font-awesome-icon icon="question-circle" /> <font-awesome-icon icon="question-circle" />
</n8n-tooltip> </N8nTooltip>
</el-col> </el-col>
<el-col :span="14" class="ignore-key-press-canvas"> <el-col :span="14" class="ignore-key-press-canvas">
<n8n-select <N8nSelect
v-model="workflowSettings.timezone" v-model="workflowSettings.timezone"
placeholder="Select Timezone" placeholder="Select Timezone"
filterable filterable
@@ -604,28 +609,28 @@ onMounted(async () => {
:limit-popper-width="true" :limit-popper-width="true"
data-test-id="workflow-settings-timezone" data-test-id="workflow-settings-timezone"
> >
<n8n-option <N8nOption
v-for="timezone of timezones" v-for="timezone of timezones"
:key="timezone.key" :key="timezone.key"
:label="timezone.value" :label="timezone.value"
:value="timezone.key" :value="timezone.key"
> >
</n8n-option> </N8nOption>
</n8n-select> </N8nSelect>
</el-col> </el-col>
</el-row> </el-row>
<el-row> <el-row>
<el-col :span="10" class="setting-name"> <el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.saveDataErrorExecution') + ':' }} {{ i18n.baseText('workflowSettings.saveDataErrorExecution') + ':' }}
<n8n-tooltip placement="top"> <N8nTooltip placement="top">
<template #content> <template #content>
<div v-text="helpTexts.saveDataErrorExecution"></div> <div v-text="helpTexts.saveDataErrorExecution"></div>
</template> </template>
<font-awesome-icon icon="question-circle" /> <font-awesome-icon icon="question-circle" />
</n8n-tooltip> </N8nTooltip>
</el-col> </el-col>
<el-col :span="14" class="ignore-key-press-canvas"> <el-col :span="14" class="ignore-key-press-canvas">
<n8n-select <N8nSelect
v-model="workflowSettings.saveDataErrorExecution" v-model="workflowSettings.saveDataErrorExecution"
:placeholder="i18n.baseText('workflowSettings.selectOption')" :placeholder="i18n.baseText('workflowSettings.selectOption')"
filterable filterable
@@ -633,28 +638,28 @@ onMounted(async () => {
:limit-popper-width="true" :limit-popper-width="true"
data-test-id="workflow-settings-save-failed-executions" data-test-id="workflow-settings-save-failed-executions"
> >
<n8n-option <N8nOption
v-for="option of saveDataErrorExecutionOptions" v-for="option of saveDataErrorExecutionOptions"
:key="option.key" :key="option.key"
:label="option.value" :label="option.value"
:value="option.key" :value="option.key"
> >
</n8n-option> </N8nOption>
</n8n-select> </N8nSelect>
</el-col> </el-col>
</el-row> </el-row>
<el-row> <el-row>
<el-col :span="10" class="setting-name"> <el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.saveDataSuccessExecution') + ':' }} {{ i18n.baseText('workflowSettings.saveDataSuccessExecution') + ':' }}
<n8n-tooltip placement="top"> <N8nTooltip placement="top">
<template #content> <template #content>
<div v-text="helpTexts.saveDataSuccessExecution"></div> <div v-text="helpTexts.saveDataSuccessExecution"></div>
</template> </template>
<font-awesome-icon icon="question-circle" /> <font-awesome-icon icon="question-circle" />
</n8n-tooltip> </N8nTooltip>
</el-col> </el-col>
<el-col :span="14" class="ignore-key-press-canvas"> <el-col :span="14" class="ignore-key-press-canvas">
<n8n-select <N8nSelect
v-model="workflowSettings.saveDataSuccessExecution" v-model="workflowSettings.saveDataSuccessExecution"
:placeholder="i18n.baseText('workflowSettings.selectOption')" :placeholder="i18n.baseText('workflowSettings.selectOption')"
filterable filterable
@@ -662,28 +667,28 @@ onMounted(async () => {
:limit-popper-width="true" :limit-popper-width="true"
data-test-id="workflow-settings-save-success-executions" data-test-id="workflow-settings-save-success-executions"
> >
<n8n-option <N8nOption
v-for="option of saveDataSuccessExecutionOptions" v-for="option of saveDataSuccessExecutionOptions"
:key="option.key" :key="option.key"
:label="option.value" :label="option.value"
:value="option.key" :value="option.key"
> >
</n8n-option> </N8nOption>
</n8n-select> </N8nSelect>
</el-col> </el-col>
</el-row> </el-row>
<el-row> <el-row>
<el-col :span="10" class="setting-name"> <el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.saveManualExecutions') + ':' }} {{ i18n.baseText('workflowSettings.saveManualExecutions') + ':' }}
<n8n-tooltip placement="top"> <N8nTooltip placement="top">
<template #content> <template #content>
<div v-text="helpTexts.saveManualExecutions"></div> <div v-text="helpTexts.saveManualExecutions"></div>
</template> </template>
<font-awesome-icon icon="question-circle" /> <font-awesome-icon icon="question-circle" />
</n8n-tooltip> </N8nTooltip>
</el-col> </el-col>
<el-col :span="14" class="ignore-key-press-canvas"> <el-col :span="14" class="ignore-key-press-canvas">
<n8n-select <N8nSelect
v-model="workflowSettings.saveManualExecutions" v-model="workflowSettings.saveManualExecutions"
:placeholder="i18n.baseText('workflowSettings.selectOption')" :placeholder="i18n.baseText('workflowSettings.selectOption')"
filterable filterable
@@ -691,28 +696,28 @@ onMounted(async () => {
:limit-popper-width="true" :limit-popper-width="true"
data-test-id="workflow-settings-save-manual-executions" data-test-id="workflow-settings-save-manual-executions"
> >
<n8n-option <N8nOption
v-for="option of saveManualOptions" v-for="option of saveManualOptions"
:key="option.key" :key="option.key"
:label="option.value" :label="option.value"
:value="option.key" :value="option.key"
> >
</n8n-option> </N8nOption>
</n8n-select> </N8nSelect>
</el-col> </el-col>
</el-row> </el-row>
<el-row> <el-row>
<el-col :span="10" class="setting-name"> <el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.saveExecutionProgress') + ':' }} {{ i18n.baseText('workflowSettings.saveExecutionProgress') + ':' }}
<n8n-tooltip placement="top"> <N8nTooltip placement="top">
<template #content> <template #content>
<div v-text="helpTexts.saveExecutionProgress"></div> <div v-text="helpTexts.saveExecutionProgress"></div>
</template> </template>
<font-awesome-icon icon="question-circle" /> <font-awesome-icon icon="question-circle" />
</n8n-tooltip> </N8nTooltip>
</el-col> </el-col>
<el-col :span="14" class="ignore-key-press-canvas"> <el-col :span="14" class="ignore-key-press-canvas">
<n8n-select <N8nSelect
v-model="workflowSettings.saveExecutionProgress" v-model="workflowSettings.saveExecutionProgress"
:placeholder="i18n.baseText('workflowSettings.selectOption')" :placeholder="i18n.baseText('workflowSettings.selectOption')"
filterable filterable
@@ -720,25 +725,25 @@ onMounted(async () => {
:limit-popper-width="true" :limit-popper-width="true"
data-test-id="workflow-settings-save-execution-progress" data-test-id="workflow-settings-save-execution-progress"
> >
<n8n-option <N8nOption
v-for="option of saveExecutionProgressOptions" v-for="option of saveExecutionProgressOptions"
:key="option.key" :key="option.key"
:label="option.value" :label="option.value"
:value="option.key" :value="option.key"
> >
</n8n-option> </N8nOption>
</n8n-select> </N8nSelect>
</el-col> </el-col>
</el-row> </el-row>
<el-row> <el-row>
<el-col :span="10" class="setting-name"> <el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.timeoutWorkflow') + ':' }} {{ i18n.baseText('workflowSettings.timeoutWorkflow') + ':' }}
<n8n-tooltip placement="top"> <N8nTooltip placement="top">
<template #content> <template #content>
<div v-text="helpTexts.executionTimeoutToggle"></div> <div v-text="helpTexts.executionTimeoutToggle"></div>
</template> </template>
<font-awesome-icon icon="question-circle" /> <font-awesome-icon icon="question-circle" />
</n8n-tooltip> </N8nTooltip>
</el-col> </el-col>
<el-col :span="14"> <el-col :span="14">
<div> <div>
@@ -760,25 +765,25 @@ onMounted(async () => {
<el-row> <el-row>
<el-col :span="10" class="setting-name"> <el-col :span="10" class="setting-name">
{{ i18n.baseText('workflowSettings.timeoutAfter') + ':' }} {{ i18n.baseText('workflowSettings.timeoutAfter') + ':' }}
<n8n-tooltip placement="top"> <N8nTooltip placement="top">
<template #content> <template #content>
<div v-text="helpTexts.executionTimeout"></div> <div v-text="helpTexts.executionTimeout"></div>
</template> </template>
<font-awesome-icon icon="question-circle" /> <font-awesome-icon icon="question-circle" />
</n8n-tooltip> </N8nTooltip>
</el-col> </el-col>
<el-col :span="4"> <el-col :span="4">
<n8n-input <N8nInput
:disabled="readOnlyEnv || !workflowPermissions.update" :disabled="readOnlyEnv || !workflowPermissions.update"
:model-value="timeoutHMS.hours" :model-value="timeoutHMS.hours"
:min="0" :min="0"
@update:model-value="(value: string) => setTheTimeout('hours', value)" @update:model-value="(value: string) => setTheTimeout('hours', value)"
> >
<template #append>{{ i18n.baseText('workflowSettings.hours') }}</template> <template #append>{{ i18n.baseText('workflowSettings.hours') }}</template>
</n8n-input> </N8nInput>
</el-col> </el-col>
<el-col :span="4" class="timeout-input"> <el-col :span="4" class="timeout-input">
<n8n-input <N8nInput
:disabled="readOnlyEnv || !workflowPermissions.update" :disabled="readOnlyEnv || !workflowPermissions.update"
:model-value="timeoutHMS.minutes" :model-value="timeoutHMS.minutes"
:min="0" :min="0"
@@ -786,10 +791,10 @@ onMounted(async () => {
@update:model-value="(value: string) => setTheTimeout('minutes', value)" @update:model-value="(value: string) => setTheTimeout('minutes', value)"
> >
<template #append>{{ i18n.baseText('workflowSettings.minutes') }}</template> <template #append>{{ i18n.baseText('workflowSettings.minutes') }}</template>
</n8n-input> </N8nInput>
</el-col> </el-col>
<el-col :span="4" class="timeout-input"> <el-col :span="4" class="timeout-input">
<n8n-input <N8nInput
:disabled="readOnlyEnv || !workflowPermissions.update" :disabled="readOnlyEnv || !workflowPermissions.update"
:model-value="timeoutHMS.seconds" :model-value="timeoutHMS.seconds"
:min="0" :min="0"
@@ -797,15 +802,41 @@ onMounted(async () => {
@update:model-value="(value: string) => setTheTimeout('seconds', value)" @update:model-value="(value: string) => setTheTimeout('seconds', value)"
> >
<template #append>{{ i18n.baseText('workflowSettings.seconds') }}</template> <template #append>{{ i18n.baseText('workflowSettings.seconds') }}</template>
</n8n-input> </N8nInput>
</el-col> </el-col>
</el-row> </el-row>
</div> </div>
<el-row>
<el-col :span="10" class="setting-name">
<label for="timeSavedPerExecution">
{{ i18n.baseText('workflowSettings.timeSavedPerExecution') + ':' }}
<N8nTooltip placement="top">
<template #content>
{{ i18n.baseText('workflowSettings.timeSavedPerExecution.tooltip') }}
</template>
<font-awesome-icon icon="question-circle" />
</N8nTooltip>
</label>
</el-col>
<el-col :span="14">
<div class="time-saved">
<N8nInput
id="timeSavedPerExecution"
v-model="workflowSettings.timeSavedPerExecution"
:disabled="readOnlyEnv || !workflowPermissions.update"
data-test-id="workflow-settings-time-saved-per-execution"
type="number"
@update:model-value="updateTimeSavedPerExecution"
/>
<span>{{ i18n.baseText('workflowSettings.timeSavedPerExecution.hint') }}</span>
</div>
</el-col>
</el-row>
</div> </div>
</template> </template>
<template #footer> <template #footer>
<div class="action-buttons" data-test-id="workflow-settings-save-button"> <div class="action-buttons" data-test-id="workflow-settings-save-button">
<n8n-button <N8nButton
:disabled="readOnlyEnv || !workflowPermissions.update" :disabled="readOnlyEnv || !workflowPermissions.update"
:label="i18n.baseText('workflowSettings.save')" :label="i18n.baseText('workflowSettings.save')"
size="large" size="large"
@@ -844,4 +875,17 @@ onMounted(async () => {
.timeout-input { .timeout-input {
margin-left: 5px; margin-left: 5px;
} }
.time-saved {
display: flex;
align-items: center;
:deep(.el-input) {
width: 64px;
}
span {
margin-left: var(--spacing-2xs);
}
}
</style> </style>

View File

@@ -2375,6 +2375,9 @@
"workflowSettings.timeoutAfter": "Timeout After", "workflowSettings.timeoutAfter": "Timeout After",
"workflowSettings.timeoutWorkflow": "Timeout Workflow", "workflowSettings.timeoutWorkflow": "Timeout Workflow",
"workflowSettings.timezone": "Timezone", "workflowSettings.timezone": "Timezone",
"workflowSettings.timeSavedPerExecution": "Estimated time saved",
"workflowSettings.timeSavedPerExecution.hint": "Minutes per production execution",
"workflowSettings.timeSavedPerExecution.tooltip": "Total time savings are summarised in the Overview page.",
"workflowHistory.title": "Version History", "workflowHistory.title": "Version History",
"workflowHistory.content.title": "Version", "workflowHistory.content.title": "Version",
"workflowHistory.content.editedBy": "Edited by", "workflowHistory.content.editedBy": "Edited by",