feat(editor): Show error on Execute Workflow Node if connected workflow is archived (no-changelog) (#15056)

This commit is contained in:
Jaakko Husso
2025-05-08 15:02:49 +03:00
committed by GitHub
parent ce7ab2f456
commit d870c685b5
9 changed files with 219 additions and 31 deletions

View File

@@ -1,4 +1,4 @@
import { renderComponent } from '@/__tests__/render';
import { createComponentRenderer } from '@/__tests__/render';
import ParameterInput from '@/components/ParameterInput.vue';
import type { useNDVStore } from '@/stores/ndv.store';
import type { CompletionResult } from '@codemirror/autocomplete';
@@ -7,8 +7,12 @@ import { faker } from '@faker-js/faker';
import { waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import type { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
import { useSettingsStore } from '@/stores/settings.store';
import { cleanupAppModals, createAppModals, mockedStore } from '@/__tests__/utils';
import { createEventBus } from '@n8n/utils/event-bus';
import { createMockEnterpriseSettings } from '@/__tests__/mocks';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { INodeParameterResourceLocator } from 'n8n-workflow';
let mockNdvState: Partial<ReturnType<typeof useNDVStore>>;
let mockNodeTypesState: Partial<ReturnType<typeof useNodeTypesStore>>;
@@ -58,12 +62,22 @@ vi.mock('vue-router', () => {
return {
useRouter: () => ({
push,
resolve: vi.fn().mockReturnValue({
href: '/projects/1/folders/1',
}),
}),
useRoute: () => ({}),
RouterLink: vi.fn(),
};
});
const renderComponent = createComponentRenderer(ParameterInput, {
pinia: createTestingPinia(),
});
const settingsStore = mockedStore(useSettingsStore);
const workflowsStore = mockedStore(useWorkflowsStore);
describe('ParameterInput.vue', () => {
beforeEach(() => {
mockNdvState = {
@@ -84,15 +98,16 @@ describe('ParameterInput.vue', () => {
getNodeType: vi.fn().mockReturnValue(null),
};
createAppModals();
settingsStore.settings.enterprise = createMockEnterpriseSettings();
});
afterEach(() => {
cleanupAppModals();
vi.clearAllMocks();
});
test('should render an options parameter (select)', async () => {
const { container, baseElement, emitted } = renderComponent(ParameterInput, {
pinia: createTestingPinia(),
const { container, baseElement, emitted } = renderComponent({
props: {
path: 'operation',
parameter: {
@@ -147,8 +162,7 @@ describe('ParameterInput.vue', () => {
test('should render an options parameter even if it has invalid fields (like displayName)', async () => {
// Test case based on the Schedule node
// type=options parameters shouldn't have a displayName field, but some do
const { container, baseElement, emitted } = renderComponent(ParameterInput, {
pinia: createTestingPinia(),
const { container, baseElement, emitted } = renderComponent({
props: {
path: 'operation',
parameter: {
@@ -191,8 +205,7 @@ describe('ParameterInput.vue', () => {
});
test('should render a string parameter', async () => {
const { container, emitted } = renderComponent(ParameterInput, {
pinia: createTestingPinia(),
const { container, emitted } = renderComponent({
props: {
path: 'tag',
parameter: {
@@ -212,8 +225,7 @@ describe('ParameterInput.vue', () => {
});
test('should correctly handle paste events', async () => {
const { container, emitted } = renderComponent(ParameterInput, {
pinia: createTestingPinia(),
const { container, emitted } = renderComponent({
props: {
path: 'tag',
parameter: {
@@ -254,8 +266,7 @@ describe('ParameterInput.vue', () => {
{ name: 'Description', value: 'description' },
]);
const { emitted, container } = renderComponent(ParameterInput, {
pinia: createTestingPinia(),
const { emitted, container } = renderComponent({
props: {
path: 'columns',
parameter: {
@@ -291,8 +302,7 @@ describe('ParameterInput.vue', () => {
],
});
const { emitted, container, getByTestId } = renderComponent(ParameterInput, {
pinia: createTestingPinia(),
const { emitted, container, getByTestId } = renderComponent({
props: {
path: 'columns',
parameter: {
@@ -318,10 +328,114 @@ describe('ParameterInput.vue', () => {
expect(emitted('update')).toBeUndefined();
});
test('should render workflow selector without issues when selected workflow is not archived', async () => {
const workflowId = faker.string.uuid();
const modelValue = {
mode: 'id',
value: workflowId,
};
workflowsStore.allWorkflows = [
{
id: workflowId,
name: 'Test',
active: false,
isArchived: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
nodes: [],
connections: {},
versionId: faker.string.uuid(),
},
];
const { emitted, container, getByTestId, queryByTestId } = renderComponent({
props: {
path: 'columns',
parameter: {
displayName: 'Workflow',
name: 'workflowId',
type: 'workflowSelector',
default: '',
},
modelValue,
},
});
await waitFor(() => expect(getByTestId('resource-locator-workflowId')).toBeInTheDocument());
expect(container.querySelector('.has-issues')).not.toBeInTheDocument();
const inputs = container.querySelectorAll('input');
const mode = inputs[0];
expect(mode).toBeInTheDocument();
expect(mode).toHaveValue('By ID');
const value = inputs[1];
expect(value).toBeInTheDocument();
expect(value).toHaveValue(workflowId);
expect(queryByTestId('parameter-issues')).not.toBeInTheDocument();
expect(emitted('update')).toBeUndefined();
});
test('should show error when workflow selector has archived workflow selected', async () => {
const workflowId = faker.string.uuid();
const modelValue: INodeParameterResourceLocator = {
__rl: true,
mode: 'id',
value: workflowId,
};
workflowsStore.allWorkflows = [
{
id: workflowId,
name: 'Test',
active: false,
isArchived: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
nodes: [],
connections: {},
versionId: faker.string.uuid(),
},
];
const { emitted, container, getByTestId } = renderComponent({
props: {
path: 'columns',
parameter: {
displayName: 'Workflow',
name: 'workflowId',
type: 'workflowSelector',
default: '',
},
modelValue,
},
});
await waitFor(() => expect(getByTestId('resource-locator-workflowId')).toBeInTheDocument());
expect(container.querySelector('.has-issues')).toBeInTheDocument();
const inputs = container.querySelectorAll('input');
const mode = inputs[0];
expect(mode).toBeInTheDocument();
expect(mode).toHaveValue('By ID');
const value = inputs[1];
expect(value).toBeInTheDocument();
expect(value).toHaveValue(workflowId);
expect(getByTestId('parameter-issues')).toBeInTheDocument();
expect(emitted('update')).toBeUndefined();
});
test('should reset bool on eventBus:removeExpression', async () => {
const eventBus = createEventBus();
const { emitted } = renderComponent(ParameterInput, {
pinia: createTestingPinia(),
const { emitted } = renderComponent({
props: {
path: 'aSwitch',
parameter: {
@@ -341,8 +455,7 @@ describe('ParameterInput.vue', () => {
test('should reset bool with undefined evaluation on eventBus:removeExpression', async () => {
const eventBus = createEventBus();
const { emitted } = renderComponent(ParameterInput, {
pinia: createTestingPinia(),
const { emitted } = renderComponent({
props: {
path: 'aSwitch',
parameter: {
@@ -362,8 +475,7 @@ describe('ParameterInput.vue', () => {
test('should reset number on eventBus:removeExpression', async () => {
const eventBus = createEventBus();
const { emitted } = renderComponent(ParameterInput, {
pinia: createTestingPinia(),
const { emitted } = renderComponent({
props: {
path: 'aNum',
parameter: {
@@ -383,8 +495,7 @@ describe('ParameterInput.vue', () => {
test('should reset string on eventBus:removeExpression', async () => {
const eventBus = createEventBus();
const { emitted } = renderComponent(ParameterInput, {
pinia: createTestingPinia(),
const { emitted } = renderComponent({
props: {
path: 'aStr',
parameter: {

View File

@@ -397,6 +397,21 @@ const getIssues = computed<string[]>(() => {
issues.parameters[props.parameter.name] = [
`There was a problem loading the parameter options from server: "${remoteParameterOptionsLoadingIssues.value}"`,
];
} else if (props.parameter.type === 'workflowSelector') {
const selected = modelValueResourceLocator.value?.value;
if (selected) {
const isSelectedArchived = workflowsStore.allWorkflows.some(
(resource) => resource.id === selected && resource.isArchived,
);
if (isSelectedArchived) {
if (issues.parameters === undefined) {
issues.parameters = {};
}
const issue = i18n.baseText('parameterInput.selectedWorkflowIsArchived');
issues.parameters[props.parameter.name] = [issue];
}
}
}
if (issues?.parameters?.[props.parameter.name] !== undefined) {

View File

@@ -69,7 +69,8 @@ const sortedResources = computed<IResourceLocatorResultExpanded[]>(() => {
if (props.modelValue && item.value === props.modelValue) {
acc.selected = item;
} else {
} else if (!item.isArchived) {
// Archived items are not shown in the list unless selected
acc.notSelected.push(item);
}
@@ -282,6 +283,11 @@ defineExpose({ isWithinDropdown });
>
<div :class="$style.resourceNameContainer">
<span>{{ result.name }}</span>
<span v-if="result.isArchived">
<N8nBadge class="ml-3xs" theme="tertiary" bold data-test-id="workflow-archived-tag">
{{ i18n.baseText('workflows.item.archived') }}
</N8nBadge>
</span>
</div>
<div :class="$style.urlLink">
<font-awesome-icon

View File

@@ -87,6 +87,10 @@ $--mode-selector-width: 92px;
right: var(--input-override-width);
}
.backgroundWithIssuesAndShowResourceLink {
right: 47px;
}
&.multipleModes {
.inputContainer {
display: flex;

View File

@@ -17,12 +17,21 @@ vi.mock('@/composables/useDocumentVisibility', () => ({
useDocumentVisibility: () => ({ onDocumentVisible }),
}));
vi.mock('vue-router', () => {
const push = vi.fn();
return {
useRouter: () => ({
push,
resolve: vi.fn().mockReturnValue({
href: '/projects/1/folders/1',
}),
}),
useRoute: () => ({}),
RouterLink: vi.fn(),
};
});
const renderComponent = createComponentRenderer(WorkflowSelectorParameterInput, {
global: {
stubs: {
ResourceLocatorDropdown: true,
},
},
pinia: createTestingPinia({}),
});
@@ -38,6 +47,7 @@ describe('WorkflowSelectorParameterInput', () => {
afterEach(() => {
cleanupAppModals();
vi.clearAllMocks();
});
it('should update cached workflow when page is visible', async () => {
@@ -91,4 +101,29 @@ describe('WorkflowSelectorParameterInput', () => {
expect(emitted()['update:modelValue']?.[1]).toEqual([props.modelValue]);
expect(workflowsStore.fetchWorkflow).toHaveBeenCalledWith(props.modelValue.value);
});
it('should show parameter issues selector with resource link', async () => {
const props: Props = {
modelValue: {
__rl: true,
value: 'workflow-id',
mode: 'list',
},
path: '',
parameter: {
displayName: 'display-name',
type: 'workflowSelector',
name: 'name',
default: '',
},
parameterIssues: ['Some issue'],
};
const { getByTestId } = renderComponent({ props });
await flushPromises();
expect(workflowsStore.fetchWorkflow).toHaveBeenCalledWith(props.modelValue.value);
expect(getByTestId('parameter-issues')).toBeInTheDocument();
expect(getByTestId('rlc-open-resource-link')).toBeInTheDocument();
});
});

View File

@@ -142,6 +142,10 @@ const placeholder = computed(() => {
return i18n.baseText('resourceLocator.id.placeholder');
});
const showOpenResourceLink = computed(() => {
return !props.isValueExpression && props.modelValue.value;
});
function setWidth() {
const containerRef = container.value as HTMLElement | undefined;
if (containerRef) {
@@ -294,7 +298,7 @@ const onAddResourceClicked = async () => {
}"
:width="width"
:event-bus="eventBus"
:value="modelValue"
:model-value="modelValue.value"
@update:model-value="onListItemSelected"
@filter="onSearchFilter"
@load-more="populateNextWorkflowsPage"
@@ -313,7 +317,13 @@ const onAddResourceClicked = async () => {
[$style.multipleModes]: true,
}"
>
<div :class="$style.background"></div>
<div
:class="{
[$style.background]: true,
[$style.backgroundWithIssuesAndShowResourceLink]:
showOpenResourceLink && parameterIssues?.length,
}"
/>
<div :class="$style.modeSelector">
<n8n-select
:model-value="selectedMode"
@@ -402,7 +412,11 @@ const onAddResourceClicked = async () => {
:issues="parameterIssues"
:class="$style['parameter-issues']"
/>
<div v-if="!isValueExpression && modelValue.value" :class="$style.openResourceLink">
<div
v-if="showOpenResourceLink"
:class="$style.openResourceLink"
data-test-id="rlc-open-resource-link"
>
<n8n-link theme="text" @click.stop="openWorkflow()">
<font-awesome-icon icon="external-link-alt" :title="'Open resource link'" />
</n8n-link>

View File

@@ -56,6 +56,7 @@ export function useWorkflowResourcesLocator(router: Router) {
name: getWorkflowName(workflow.id),
value: workflow.id,
url: getWorkflowUrl(workflow.id),
isArchived: workflow.isArchived,
};
}