mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
fix(editor): Optimize workflow selector search performance by implementing pagination (#19252)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -74,6 +74,7 @@
|
||||
"generic.refresh": "Refresh",
|
||||
"generic.retry": "Retry",
|
||||
"generic.error": "Something went wrong",
|
||||
"generic.error.subworkflowCreationFailed": "Error creating sub-workflow",
|
||||
"generic.settings": "Settings",
|
||||
"generic.service": "the service",
|
||||
"generic.star": "Star",
|
||||
|
||||
@@ -13,6 +13,8 @@ import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { createMockEnterpriseSettings } from '@/__tests__/mocks';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import type { INodeParameterResourceLocator } from 'n8n-workflow';
|
||||
import type { IWorkflowDb, WorkflowListResource } from '@/Interface';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
function getNdvStateMock(): Partial<ReturnType<typeof useNDVStore>> {
|
||||
return {
|
||||
@@ -379,19 +381,19 @@ describe('ParameterInput.vue', () => {
|
||||
value: workflowId,
|
||||
};
|
||||
|
||||
workflowsStore.allWorkflows = [
|
||||
{
|
||||
workflowsStore.fetchWorkflowsPage.mockResolvedValue([
|
||||
mock<WorkflowListResource>({
|
||||
id: workflowId,
|
||||
name: 'Test',
|
||||
active: false,
|
||||
isArchived: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
nodes: [],
|
||||
connections: {},
|
||||
// nodes: [],
|
||||
// connections: {},
|
||||
versionId: faker.string.uuid(),
|
||||
},
|
||||
];
|
||||
}),
|
||||
]);
|
||||
|
||||
const { emitted, container, getByTestId, queryByTestId } = renderComponent({
|
||||
props: {
|
||||
@@ -432,19 +434,17 @@ describe('ParameterInput.vue', () => {
|
||||
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 workflowBase = {
|
||||
id: workflowId,
|
||||
name: 'Test',
|
||||
active: false,
|
||||
isArchived: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
versionId: faker.string.uuid(),
|
||||
};
|
||||
workflowsStore.allWorkflows = [mock<IWorkflowDb>(workflowBase)];
|
||||
workflowsStore.fetchWorkflowsPage.mockResolvedValue([mock<WorkflowListResource>(workflowBase)]);
|
||||
|
||||
const { emitted, container, getByTestId } = renderComponent({
|
||||
props: {
|
||||
|
||||
@@ -128,8 +128,9 @@ describe('ResourceLocator', () => {
|
||||
await waitFor(() => {
|
||||
expect(nodeTypesStore.getResourceLocatorResults).toHaveBeenCalled();
|
||||
});
|
||||
// Expect the items to be rendered
|
||||
expect(getAllByTestId('rlc-item')).toHaveLength(TEST_ITEMS.length);
|
||||
// Expect the items to be rendered, including the cached value from
|
||||
// TEST_MODEL_VALUE
|
||||
expect(getAllByTestId('rlc-item')).toHaveLength(TEST_ITEMS.length + 1);
|
||||
// We should be getting one item for each result
|
||||
TEST_ITEMS.forEach((item) => {
|
||||
expect(getByText(item.name)).toBeInTheDocument();
|
||||
@@ -286,7 +287,9 @@ describe('ResourceLocator', () => {
|
||||
expect(nodeTypesStore.getResourceLocatorResults).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(getAllByTestId('rlc-item')).toHaveLength(TEST_ITEMS.length);
|
||||
// Expect the items to be rendered, including the cached value from
|
||||
// TEST_MODEL_VALUE
|
||||
expect(getAllByTestId('rlc-item')).toHaveLength(TEST_ITEMS.length + 1);
|
||||
TEST_ITEMS.forEach((item) => {
|
||||
expect(getByText(item.name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -912,7 +912,7 @@ function removeOverride() {
|
||||
>
|
||||
<ResourceLocatorDropdown
|
||||
ref="dropdownRef"
|
||||
:model-value="modelValue ? modelValue.value : ''"
|
||||
:model-value="modelValue"
|
||||
:show="resourceDropdownVisible"
|
||||
:filterable="isSearchable"
|
||||
:filter-required="requiresSearchFilter"
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { fireEvent, screen } from '@testing-library/vue';
|
||||
import { vi } from 'vitest';
|
||||
import ResourceLocatorDropdown from './ResourceLocatorDropdown.vue';
|
||||
import type { INodeParameterResourceLocator } from 'n8n-workflow';
|
||||
import type { IResourceLocatorResultExpanded } from '@/Interface';
|
||||
|
||||
const mockResources: IResourceLocatorResultExpanded[] = [
|
||||
{
|
||||
name: 'Workflow 1',
|
||||
value: 'workflow-1',
|
||||
url: '/workflow/workflow-1',
|
||||
},
|
||||
{
|
||||
name: 'Workflow 2',
|
||||
value: 'workflow-2',
|
||||
url: '/workflow/workflow-2',
|
||||
},
|
||||
];
|
||||
|
||||
const mockModelValue: INodeParameterResourceLocator = {
|
||||
__rl: true,
|
||||
value: 'workflow-1',
|
||||
mode: 'list',
|
||||
cachedResultName: 'Workflow 1',
|
||||
cachedResultUrl: '/workflow/workflow-1',
|
||||
};
|
||||
|
||||
const renderComponent = createComponentRenderer(ResourceLocatorDropdown, {
|
||||
props: {
|
||||
show: true,
|
||||
resources: mockResources,
|
||||
modelValue: mockModelValue,
|
||||
},
|
||||
});
|
||||
|
||||
describe('ResourceLocatorDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('cached result display', () => {
|
||||
it('should show cached result when selected item is not in resources list', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const cachedModelValue: INodeParameterResourceLocator = {
|
||||
__rl: true,
|
||||
value: 'workflow-cached',
|
||||
mode: 'list',
|
||||
cachedResultName: 'Cached Workflow',
|
||||
cachedResultUrl: '/workflow/workflow-cached',
|
||||
};
|
||||
|
||||
renderComponent({
|
||||
props: {
|
||||
show: true,
|
||||
resources: mockResources, // doesn't contain workflow-cached
|
||||
modelValue: cachedModelValue,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText('Cached Workflow')).toBeInTheDocument();
|
||||
|
||||
// Find the cached workflow item and hover to show the link icon
|
||||
const cachedItem = screen.getByText('Cached Workflow').closest('[data-test-id="rlc-item"]');
|
||||
expect(cachedItem).toBeInTheDocument();
|
||||
|
||||
if (cachedItem) {
|
||||
await fireEvent.mouseEnter(cachedItem);
|
||||
|
||||
// Fast-forward time by 250ms to trigger the hover timeout
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
// Verify the external link icon is present after hover
|
||||
const linkIcon = cachedItem.querySelector('svg[data-icon="external-link"]');
|
||||
expect(linkIcon).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('should prioritize actual resource over cached when both exist', () => {
|
||||
const modelValue: INodeParameterResourceLocator = {
|
||||
__rl: true,
|
||||
value: 'workflow-1',
|
||||
mode: 'list',
|
||||
cachedResultName: 'Cached Name',
|
||||
cachedResultUrl: '/cached-url',
|
||||
};
|
||||
|
||||
renderComponent({
|
||||
props: {
|
||||
show: true,
|
||||
resources: mockResources,
|
||||
modelValue,
|
||||
},
|
||||
});
|
||||
|
||||
// Should show the actual resource name, not cached
|
||||
expect(screen.getByText('Workflow 1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Cached Name')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('model value handling', () => {
|
||||
it('should compare values correctly for selection highlighting', () => {
|
||||
const modelValue: INodeParameterResourceLocator = {
|
||||
__rl: true,
|
||||
value: 'workflow-2',
|
||||
mode: 'list',
|
||||
};
|
||||
|
||||
renderComponent({
|
||||
props: {
|
||||
show: true,
|
||||
resources: mockResources,
|
||||
modelValue,
|
||||
},
|
||||
});
|
||||
|
||||
// Find the item containing "Workflow 2" and check that it's selected
|
||||
const selectedItem = screen.getByText('Workflow 2').closest('[data-test-id="rlc-item"]');
|
||||
expect(selectedItem).toHaveClass('selected');
|
||||
|
||||
// Find the item containing "Workflow 1" and check that it's not selected
|
||||
const unselectedItem = screen.getByText('Workflow 1').closest('[data-test-id="rlc-item"]');
|
||||
expect(unselectedItem).not.toHaveClass('selected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filtering behavior', () => {
|
||||
it('should emit filter event when filter input changes', async () => {
|
||||
const wrapper = renderComponent({
|
||||
props: {
|
||||
show: true,
|
||||
resources: mockResources,
|
||||
filterable: true,
|
||||
},
|
||||
});
|
||||
|
||||
const filterInput = screen.getByTestId('rlc-search');
|
||||
await fireEvent.update(filterInput, 'test search');
|
||||
|
||||
expect(wrapper.emitted().filter).toEqual([['test search']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resource selection', () => {
|
||||
it('should emit update:modelValue with correct value when resource is clicked', async () => {
|
||||
const wrapper = renderComponent({
|
||||
props: {
|
||||
show: true,
|
||||
resources: mockResources,
|
||||
},
|
||||
});
|
||||
|
||||
const secondItem = screen.getByText('Workflow 2').closest('[data-test-id="rlc-item"]');
|
||||
if (secondItem) {
|
||||
await fireEvent.click(secondItem);
|
||||
}
|
||||
|
||||
expect(wrapper.emitted()['update:modelValue']).toEqual([['workflow-2']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
it('should trigger onResultsEnd handler when scrolling to bottom with pagination', async () => {
|
||||
// Generate multiple pages of mock data (enough to require scrolling)
|
||||
const manyResources: IResourceLocatorResultExpanded[] = Array.from(
|
||||
{ length: 50 },
|
||||
(_, i) => ({
|
||||
name: `Workflow ${i + 1}`,
|
||||
value: `workflow-${i + 1}`,
|
||||
url: `/workflow/workflow-${i + 1}`,
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapper = renderComponent({
|
||||
props: {
|
||||
show: true,
|
||||
resources: manyResources,
|
||||
hasMore: true, // Indicates there are more items to load
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
|
||||
const resultsContainer = screen
|
||||
.getByTestId('resource-locator-dropdown')
|
||||
.querySelector('[class*="container"]') as HTMLDivElement;
|
||||
expect(resultsContainer).toBeInTheDocument();
|
||||
|
||||
// Mock scroll to bottom - simulate scrolling to the end
|
||||
Object.defineProperty(resultsContainer, 'scrollTop', {
|
||||
writable: true,
|
||||
value: resultsContainer.scrollHeight - resultsContainer.offsetHeight,
|
||||
});
|
||||
|
||||
// Trigger scroll event
|
||||
await fireEvent.scroll(resultsContainer);
|
||||
|
||||
// Verify loadMore event was emitted
|
||||
expect(wrapper.emitted().loadMore).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not trigger loadMore when already loading', async () => {
|
||||
const wrapper = renderComponent({
|
||||
props: {
|
||||
show: true,
|
||||
resources: [],
|
||||
hasMore: true,
|
||||
loading: true, // Currently loading
|
||||
},
|
||||
});
|
||||
|
||||
const resultsContainer = screen
|
||||
.getByTestId('resource-locator-dropdown')
|
||||
.querySelector('[class*="container"]') as HTMLDivElement;
|
||||
|
||||
// Mock scroll to bottom
|
||||
Object.defineProperty(resultsContainer, 'scrollTop', {
|
||||
writable: true,
|
||||
value: resultsContainer.scrollHeight - resultsContainer.offsetHeight,
|
||||
});
|
||||
|
||||
await fireEvent.scroll(resultsContainer);
|
||||
|
||||
// Should not emit loadMore when loading
|
||||
expect(wrapper.emitted().loadMore).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not trigger loadMore when no more items available', async () => {
|
||||
const wrapper = renderComponent({
|
||||
props: {
|
||||
show: true,
|
||||
resources: [],
|
||||
hasMore: false, // No more items to load
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
|
||||
const resultsContainer = screen
|
||||
.getByTestId('resource-locator-dropdown')
|
||||
.querySelector('[class*="container"]') as HTMLDivElement;
|
||||
|
||||
// Mock scroll to bottom
|
||||
Object.defineProperty(resultsContainer, 'scrollTop', {
|
||||
writable: true,
|
||||
value: resultsContainer.scrollHeight - resultsContainer.offsetHeight,
|
||||
});
|
||||
|
||||
await fireEvent.scroll(resultsContainer);
|
||||
|
||||
// Should not emit loadMore when hasMore is false
|
||||
expect(wrapper.emitted().loadMore).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,14 +4,14 @@ import type { IResourceLocatorResultExpanded } from '@/Interface';
|
||||
import { N8nLoading } from '@n8n/design-system';
|
||||
import type { EventBus } from '@n8n/utils/event-bus';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import type { INodeParameterResourceLocator, NodeParameterValue } from 'n8n-workflow';
|
||||
import type { INodeParameterResourceLocator } from 'n8n-workflow';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, useCssModule, watch } from 'vue';
|
||||
|
||||
const SEARCH_BAR_HEIGHT_PX = 40;
|
||||
const SCROLL_MARGIN_PX = 10;
|
||||
|
||||
type Props = {
|
||||
modelValue?: NodeParameterValue;
|
||||
modelValue?: INodeParameterResourceLocator;
|
||||
resources?: IResourceLocatorResultExpanded[];
|
||||
show?: boolean;
|
||||
filterable?: boolean;
|
||||
@@ -21,10 +21,8 @@ type Props = {
|
||||
errorView?: boolean;
|
||||
filterRequired?: boolean;
|
||||
width?: number;
|
||||
allowNewResources?: { label?: string };
|
||||
eventBus?: EventBus;
|
||||
allowNewResources?: {
|
||||
label?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -60,14 +58,14 @@ const itemsRef = ref<HTMLDivElement[]>([]);
|
||||
|
||||
const sortedResources = computed<IResourceLocatorResultExpanded[]>(() => {
|
||||
const seen = new Set();
|
||||
const { selected, notSelected } = props.resources.reduce(
|
||||
const result = props.resources.reduce(
|
||||
(acc, item: IResourceLocatorResultExpanded) => {
|
||||
if (seen.has(item.value)) {
|
||||
return acc;
|
||||
}
|
||||
seen.add(item.value);
|
||||
|
||||
if (props.modelValue && item.value === props.modelValue) {
|
||||
if (props.modelValue && item.value === props.modelValue.value) {
|
||||
acc.selected = item;
|
||||
} else if (!item.isArchived) {
|
||||
// Archived items are not shown in the list unless selected
|
||||
@@ -82,11 +80,22 @@ const sortedResources = computed<IResourceLocatorResultExpanded[]>(() => {
|
||||
},
|
||||
);
|
||||
|
||||
if (selected) {
|
||||
return [selected, ...notSelected];
|
||||
// Resources are paginated, so the currently selected one may not actually be
|
||||
// in the list.
|
||||
// If that's the case we'll render the cached value.
|
||||
if (result.selected === null && props.modelValue?.cachedResultName && props.modelValue.value) {
|
||||
result.selected = {
|
||||
name: props.modelValue.cachedResultName,
|
||||
value: props.modelValue.value,
|
||||
url: props.modelValue.cachedResultUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return notSelected;
|
||||
if (result.selected) {
|
||||
return [result.selected, ...result.notSelected];
|
||||
}
|
||||
|
||||
return result.notSelected;
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -105,12 +114,6 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
() => {
|
||||
setTimeout(() => onResultsEnd(), 0); // in case of filtering
|
||||
},
|
||||
);
|
||||
onMounted(() => {
|
||||
props.eventBus.on('keyDown', onKeyDown);
|
||||
});
|
||||
@@ -221,22 +224,26 @@ defineExpose({ isWithinDropdown });
|
||||
<template>
|
||||
<n8n-popover
|
||||
placement="bottom"
|
||||
:width="width"
|
||||
:width="props.width"
|
||||
:popper-class="$style.popover"
|
||||
:visible="show"
|
||||
:visible="props.show"
|
||||
:teleported="false"
|
||||
data-test-id="resource-locator-dropdown"
|
||||
>
|
||||
<div v-if="errorView" :class="$style.messageContainer">
|
||||
<div v-if="props.errorView" :class="$style.messageContainer">
|
||||
<slot name="error"></slot>
|
||||
</div>
|
||||
<div v-if="filterable && !errorView" :class="$style.searchInput" @keydown="onKeyDown">
|
||||
<div
|
||||
v-if="props.filterable && !props.errorView"
|
||||
:class="$style.searchInput"
|
||||
@keydown="onKeyDown"
|
||||
>
|
||||
<N8nInput
|
||||
ref="searchRef"
|
||||
:model-value="filter"
|
||||
:model-value="props.filter"
|
||||
:clearable="true"
|
||||
:placeholder="
|
||||
allowNewResources.label
|
||||
props.allowNewResources.label
|
||||
? i18n.baseText('resourceLocator.placeholder.searchOrCreate')
|
||||
: i18n.baseText('resourceLocator.placeholder.search')
|
||||
"
|
||||
@@ -248,23 +255,31 @@ defineExpose({ isWithinDropdown });
|
||||
</template>
|
||||
</N8nInput>
|
||||
</div>
|
||||
<div v-if="filterRequired && !filter && !errorView && !loading" :class="$style.searchRequired">
|
||||
<div
|
||||
v-if="props.filterRequired && !props.filter && !props.errorView && !props.loading"
|
||||
:class="$style.searchRequired"
|
||||
>
|
||||
{{ i18n.baseText('resourceLocator.mode.list.searchRequired') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!errorView && !allowNewResources.label && sortedResources.length === 0 && !loading"
|
||||
v-else-if="
|
||||
!props.errorView &&
|
||||
!props.allowNewResources.label &&
|
||||
sortedResources.length === 0 &&
|
||||
!props.loading
|
||||
"
|
||||
:class="$style.messageContainer"
|
||||
>
|
||||
{{ i18n.baseText('resourceLocator.mode.list.noResults') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!errorView"
|
||||
v-else-if="!props.errorView"
|
||||
ref="resultsContainerRef"
|
||||
:class="$style.container"
|
||||
@scroll="onResultsEnd"
|
||||
>
|
||||
<div
|
||||
v-if="allowNewResources.label"
|
||||
v-if="props.allowNewResources.label"
|
||||
key="addResourceKey"
|
||||
ref="itemsRef"
|
||||
data-test-id="rlc-item-add-resource"
|
||||
@@ -277,7 +292,7 @@ defineExpose({ isWithinDropdown });
|
||||
@click="() => emit('addResourceClick')"
|
||||
>
|
||||
<div :class="$style.resourceNameContainer">
|
||||
<span :class="$style.addResourceText">{{ allowNewResources.label }}</span>
|
||||
<span :class="$style.addResourceText">{{ props.allowNewResources.label }}</span>
|
||||
<n8n-icon :class="$style.addResourceIcon" icon="plus" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,7 +302,7 @@ defineExpose({ isWithinDropdown });
|
||||
ref="itemsRef"
|
||||
:class="{
|
||||
[$style.resourceItem]: true,
|
||||
[$style.selected]: result.value === modelValue,
|
||||
[$style.selected]: result.value === props.modelValue?.value,
|
||||
[$style.hovering]: hoverIndex === i + 1,
|
||||
}"
|
||||
data-test-id="rlc-item"
|
||||
@@ -312,7 +327,7 @@ defineExpose({ isWithinDropdown });
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading && !errorView">
|
||||
<div v-if="props.loading && !props.errorView">
|
||||
<div v-for="i in 3" :key="i" :class="$style.loadingItem">
|
||||
<N8nLoading :class="$style.loader" variant="p" :rows="1" />
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import WorkflowSelectorParameterInput, {
|
||||
type Props,
|
||||
@@ -13,17 +19,25 @@ const { onDocumentVisible } = vi.hoisted(() => ({
|
||||
|
||||
const flushPromises = async () => await new Promise(setImmediate);
|
||||
|
||||
const mockToast = {
|
||||
showError: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@/composables/useDocumentVisibility', () => ({
|
||||
useDocumentVisibility: () => ({ onDocumentVisible }),
|
||||
}));
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: vi.fn(() => mockToast),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', () => {
|
||||
const push = vi.fn();
|
||||
return {
|
||||
useRouter: () => ({
|
||||
push,
|
||||
resolve: vi.fn().mockReturnValue({
|
||||
href: '/projects/1/folders/1',
|
||||
href: '/projects/1/workflows/1',
|
||||
}),
|
||||
}),
|
||||
useRoute: () => ({}),
|
||||
@@ -43,6 +57,17 @@ const workflowsStore = mockedStore(useWorkflowsStore);
|
||||
describe('WorkflowSelectorParameterInput', () => {
|
||||
beforeEach(() => {
|
||||
createAppModals();
|
||||
// Mock store methods to prevent unhandled errors
|
||||
workflowsStore.fetchWorkflowsPage.mockResolvedValue([]);
|
||||
workflowsStore.totalWorkflowCount = 0;
|
||||
workflowsStore.getWorkflowById.mockReturnValue(null as any);
|
||||
workflowsStore.fetchWorkflow.mockResolvedValue({} as any);
|
||||
workflowsStore.createNewWorkflow.mockResolvedValue({
|
||||
id: 'new-workflow-id',
|
||||
name: 'New Workflow',
|
||||
} as any);
|
||||
workflowsStore.allWorkflows = [];
|
||||
mockToast.showError.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -65,13 +90,20 @@ describe('WorkflowSelectorParameterInput', () => {
|
||||
default: '',
|
||||
},
|
||||
};
|
||||
const { emitted } = renderComponent({ props });
|
||||
const wrapper = renderComponent({ props });
|
||||
await flushPromises();
|
||||
expect(emitted()['update:modelValue']?.[0]).toEqual([props.modelValue]);
|
||||
|
||||
// The component adds cachedResultUrl to the model value
|
||||
const expectedModelValue = {
|
||||
...props.modelValue,
|
||||
cachedResultUrl: '/projects/1/workflows/1',
|
||||
};
|
||||
|
||||
expect(wrapper.emitted()['update:modelValue']?.[0]).toEqual([expectedModelValue]);
|
||||
expect(workflowsStore.fetchWorkflow).toHaveBeenCalledWith(props.modelValue.value);
|
||||
});
|
||||
|
||||
it('should update cached workflow when page is visible', async () => {
|
||||
it('should update cached workflow when document becomes visible', async () => {
|
||||
const props: Props = {
|
||||
modelValue: {
|
||||
__rl: true,
|
||||
@@ -90,7 +122,12 @@ describe('WorkflowSelectorParameterInput', () => {
|
||||
await flushPromises();
|
||||
|
||||
// on mount
|
||||
expect(emitted()['update:modelValue']?.[0]).toEqual([props.modelValue]);
|
||||
const expectedModelValue = {
|
||||
...props.modelValue,
|
||||
cachedResultUrl: '/projects/1/workflows/1',
|
||||
};
|
||||
|
||||
expect(emitted()['update:modelValue']?.[0]).toEqual([expectedModelValue]);
|
||||
expect(workflowsStore.fetchWorkflow).toHaveBeenCalledWith(props.modelValue.value);
|
||||
workflowsStore.fetchWorkflow.mockReset();
|
||||
|
||||
@@ -98,7 +135,7 @@ describe('WorkflowSelectorParameterInput', () => {
|
||||
const onDocumentVisibleCallback = onDocumentVisible.mock.lastCall?.[0];
|
||||
await onDocumentVisibleCallback();
|
||||
|
||||
expect(emitted()['update:modelValue']?.[1]).toEqual([props.modelValue]);
|
||||
expect(emitted()['update:modelValue']?.[1]).toEqual([expectedModelValue]);
|
||||
expect(workflowsStore.fetchWorkflow).toHaveBeenCalledWith(props.modelValue.value);
|
||||
});
|
||||
|
||||
@@ -126,4 +163,103 @@ describe('WorkflowSelectorParameterInput', () => {
|
||||
expect(getByTestId('parameter-issues')).toBeInTheDocument();
|
||||
expect(getByTestId('rlc-open-resource-link')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('workflow caching behavior', () => {
|
||||
it('should include cached URL in model value updates', async () => {
|
||||
const props: Props = {
|
||||
modelValue: {
|
||||
__rl: true,
|
||||
value: 'test-workflow',
|
||||
mode: 'list',
|
||||
},
|
||||
path: '',
|
||||
parameter: {
|
||||
displayName: 'display-name',
|
||||
type: 'workflowSelector',
|
||||
name: 'name',
|
||||
default: '',
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = renderComponent({ props });
|
||||
await flushPromises();
|
||||
|
||||
const updateEvents = wrapper.emitted()['update:modelValue'] as any[];
|
||||
const lastUpdate = updateEvents[updateEvents.length - 1][0];
|
||||
|
||||
expect(lastUpdate).toMatchObject({
|
||||
__rl: true,
|
||||
value: 'test-workflow',
|
||||
mode: 'list',
|
||||
cachedResultUrl: '/projects/1/workflows/1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle workflow name caching from store', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'existing-workflow',
|
||||
name: 'Existing Workflow',
|
||||
};
|
||||
|
||||
workflowsStore.getWorkflowById.mockReturnValue(mockWorkflow as any);
|
||||
|
||||
const props: Props = {
|
||||
modelValue: {
|
||||
__rl: true,
|
||||
value: 'existing-workflow',
|
||||
mode: 'list',
|
||||
},
|
||||
path: '',
|
||||
parameter: {
|
||||
displayName: 'display-name',
|
||||
type: 'workflowSelector',
|
||||
name: 'name',
|
||||
default: '',
|
||||
},
|
||||
};
|
||||
|
||||
renderComponent({ props });
|
||||
await flushPromises();
|
||||
|
||||
// Verify workflow was fetched via fetchWorkflow, not getWorkflowById directly
|
||||
expect(workflowsStore.fetchWorkflow).toHaveBeenCalledWith('existing-workflow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should show toast when workflow creation fails', async () => {
|
||||
// Make createNewWorkflow fail
|
||||
const error = new Error('Failed to create workflow');
|
||||
workflowsStore.createNewWorkflow.mockRejectedValue(error);
|
||||
|
||||
const props: Props = {
|
||||
modelValue: {
|
||||
__rl: true,
|
||||
value: '',
|
||||
mode: 'list',
|
||||
},
|
||||
path: '',
|
||||
parameter: {
|
||||
displayName: 'display-name',
|
||||
type: 'workflowSelector',
|
||||
name: 'name',
|
||||
default: '',
|
||||
},
|
||||
};
|
||||
|
||||
const { getByTestId } = renderComponent({ props });
|
||||
await flushPromises();
|
||||
|
||||
// Get the ResourceLocatorDropdown component to trigger the add resource click
|
||||
const addResourceButton = getByTestId('rlc-item-add-resource');
|
||||
expect(addResourceButton).toBeInTheDocument();
|
||||
|
||||
// Click the add resource button
|
||||
addResourceButton.click();
|
||||
await flushPromises();
|
||||
|
||||
// Verify the toast error was shown
|
||||
expect(mockToast.showError).toHaveBeenCalledWith(error, 'Error creating sub-workflow');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import { VIEWS } from '@/constants';
|
||||
import { SAMPLE_SUBWORKFLOW_TRIGGER_ID, SAMPLE_SUBWORKFLOW_WORKFLOW } from '@/constants.workflows';
|
||||
import type { WorkflowDataCreate } from '@n8n/rest-api-client/api/workflows';
|
||||
import { useDocumentVisibility } from '@/composables/useDocumentVisibility';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
|
||||
export interface Props {
|
||||
modelValue: INodeParameterResourceLocator;
|
||||
@@ -69,6 +70,7 @@ const i18n = useI18n();
|
||||
const container = ref<HTMLDivElement>();
|
||||
const dropdown = ref<ComponentInstance<typeof ResourceLocatorDropdown>>();
|
||||
const telemetry = useTelemetry();
|
||||
const toast = useToast();
|
||||
|
||||
const width = ref(0);
|
||||
const inputRef = ref<HTMLInputElement | undefined>();
|
||||
@@ -88,15 +90,15 @@ const { onDocumentVisible } = useDocumentVisibility();
|
||||
const {
|
||||
hasMoreWorkflowsToLoad,
|
||||
isLoadingResources,
|
||||
filteredResources,
|
||||
searchFilter,
|
||||
onSearchFilter,
|
||||
getWorkflowName,
|
||||
applyDefaultExecuteWorkflowNodeName,
|
||||
populateNextWorkflowsPage,
|
||||
setWorkflowsResources,
|
||||
reloadWorkflows,
|
||||
workflowDbToResourceMapper,
|
||||
getWorkflowUrl,
|
||||
workflowsResources,
|
||||
} = useWorkflowResourcesLocator(router);
|
||||
|
||||
const currentProjectName = computed(() => {
|
||||
@@ -161,6 +163,7 @@ function onInputChange(workflowId: NodeParameterValue): void {
|
||||
__rl: true,
|
||||
value: workflowId,
|
||||
mode: selectedMode.value,
|
||||
cachedResultUrl: getWorkflowUrl(workflowId),
|
||||
};
|
||||
if (isListMode.value) {
|
||||
const resource = workflowsStore.getWorkflowById(workflowId);
|
||||
@@ -262,34 +265,44 @@ onClickOutside(dropdown, () => {
|
||||
});
|
||||
|
||||
const onAddResourceClicked = async () => {
|
||||
const projectId = projectStore.currentProjectId;
|
||||
const sampleWorkflow = props.sampleWorkflow;
|
||||
const workflowName = sampleWorkflow.name ?? 'My Sub-Workflow';
|
||||
const sampleSubWorkflows = workflowsStore.allWorkflows.filter(
|
||||
(w) => w.name && new RegExp(workflowName).test(w.name),
|
||||
);
|
||||
try {
|
||||
const projectId = projectStore.currentProjectId;
|
||||
const sampleWorkflow = props.sampleWorkflow;
|
||||
const workflowName = sampleWorkflow.name ?? 'My Sub-Workflow';
|
||||
const sampleSubWorkflows = workflowsStore.allWorkflows.filter(
|
||||
(w) => w.name && new RegExp(workflowName).test(w.name),
|
||||
);
|
||||
|
||||
const workflow: WorkflowDataCreate = {
|
||||
...sampleWorkflow,
|
||||
name: `${workflowName} ${sampleSubWorkflows.length + 1}`,
|
||||
};
|
||||
if (projectId) {
|
||||
workflow.projectId = projectId;
|
||||
const workflow: WorkflowDataCreate = {
|
||||
...sampleWorkflow,
|
||||
name: `${workflowName} ${sampleSubWorkflows.length + 1}`,
|
||||
};
|
||||
if (projectId) {
|
||||
workflow.projectId = projectId;
|
||||
}
|
||||
telemetry.track('User clicked create new sub-workflow button', {});
|
||||
|
||||
const newWorkflow = await workflowsStore.createNewWorkflow(workflow);
|
||||
const { href } = router.resolve({
|
||||
name: VIEWS.WORKFLOW,
|
||||
params: { name: newWorkflow.id, nodeId: SAMPLE_SUBWORKFLOW_TRIGGER_ID },
|
||||
});
|
||||
workflowsResources.value.push(workflowDbToResourceMapper(newWorkflow));
|
||||
emit('update:modelValue', {
|
||||
__rl: true,
|
||||
value: newWorkflow.id,
|
||||
mode: selectedMode.value,
|
||||
cachedResultName: newWorkflow.name,
|
||||
cachedResultUrl: getWorkflowUrl(newWorkflow.id),
|
||||
});
|
||||
hideDropdown();
|
||||
|
||||
window.open(href, '_blank');
|
||||
|
||||
emit('workflowCreated', newWorkflow.id);
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('generic.error.subworkflowCreationFailed'));
|
||||
}
|
||||
telemetry.track('User clicked create new sub-workflow button', {});
|
||||
|
||||
const newWorkflow = await workflowsStore.createNewWorkflow(workflow);
|
||||
const { href } = router.resolve({
|
||||
name: VIEWS.WORKFLOW,
|
||||
params: { name: newWorkflow.id, nodeId: SAMPLE_SUBWORKFLOW_TRIGGER_ID },
|
||||
});
|
||||
await reloadWorkflows();
|
||||
onInputChange(newWorkflow.id);
|
||||
hideDropdown();
|
||||
|
||||
window.open(href, '_blank');
|
||||
|
||||
emit('workflowCreated', newWorkflow.id);
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
@@ -303,7 +316,7 @@ const onAddResourceClicked = async () => {
|
||||
:show="isDropdownVisible"
|
||||
:filterable="true"
|
||||
:filter-required="false"
|
||||
:resources="filteredResources"
|
||||
:resources="workflowsResources"
|
||||
:loading="isLoadingResources"
|
||||
:filter="searchFilter"
|
||||
:has-more="hasMoreWorkflowsToLoad"
|
||||
@@ -313,7 +326,7 @@ const onAddResourceClicked = async () => {
|
||||
}"
|
||||
:width="width"
|
||||
:event-bus="eventBus"
|
||||
:model-value="modelValue.value"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="onListItemSelected"
|
||||
@filter="onSearchFilter"
|
||||
@load-more="populateNextWorkflowsPage"
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useWorkflowResourcesLocator } from './useWorkflowResourcesLocator';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
@@ -18,7 +22,9 @@ describe('useWorkflowResourcesLocator', () => {
|
||||
let ndvStoreMock: MockedStore<typeof useNDVStore>;
|
||||
|
||||
const renameNodeMock = vi.fn();
|
||||
const routerMock = { resolve: vi.fn() } as unknown as Router;
|
||||
const routerMock = {
|
||||
resolve: vi.fn().mockReturnValue({ href: '/workflow/test' }),
|
||||
} as unknown as Router;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -108,4 +114,218 @@ describe('useWorkflowResourcesLocator', () => {
|
||||
expect(renameNodeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination functionality', () => {
|
||||
it('should initialize with correct default state', () => {
|
||||
const { hasMoreWorkflowsToLoad, workflowsResources } =
|
||||
useWorkflowResourcesLocator(routerMock);
|
||||
|
||||
expect(workflowsResources.value).toEqual([]);
|
||||
expect(hasMoreWorkflowsToLoad.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should populate next workflows page correctly', async () => {
|
||||
const mockWorkflows = [
|
||||
{ id: '1', name: 'Workflow 1' },
|
||||
{ id: '2', name: 'Workflow 2' },
|
||||
] as any;
|
||||
|
||||
workflowsStoreMock.fetchWorkflowsPage.mockResolvedValue(mockWorkflows);
|
||||
workflowsStoreMock.totalWorkflowCount = 100;
|
||||
|
||||
const { populateNextWorkflowsPage, workflowsResources, hasMoreWorkflowsToLoad } =
|
||||
useWorkflowResourcesLocator(routerMock);
|
||||
|
||||
await populateNextWorkflowsPage();
|
||||
|
||||
expect(workflowsStoreMock.fetchWorkflowsPage).toHaveBeenCalledWith(
|
||||
undefined, // projectId
|
||||
1, // page
|
||||
40, // pageSize
|
||||
'updatedAt:desc', // sort
|
||||
undefined, // filter
|
||||
);
|
||||
|
||||
expect(workflowsResources.value).toEqual([
|
||||
{ name: 'Workflow 1', value: '1', url: expect.any(String) as string, isArchived: false },
|
||||
{ name: 'Workflow 2', value: '2', url: expect.any(String) as string, isArchived: false },
|
||||
]);
|
||||
|
||||
expect(hasMoreWorkflowsToLoad.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle search filtering with pagination reset', async () => {
|
||||
const mockFilteredWorkflows = [{ id: '3', name: 'Filtered Workflow' }] as any;
|
||||
|
||||
workflowsStoreMock.fetchWorkflowsPage.mockResolvedValue(mockFilteredWorkflows);
|
||||
|
||||
const { onSearchFilter, workflowsResources } = useWorkflowResourcesLocator(routerMock);
|
||||
|
||||
// Pre-populate some workflows
|
||||
workflowsResources.value = [
|
||||
{ name: 'Old Workflow', value: 'old', url: '/old', isArchived: false },
|
||||
];
|
||||
|
||||
await onSearchFilter('test search');
|
||||
|
||||
expect(workflowsStoreMock.fetchWorkflowsPage).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
1,
|
||||
40,
|
||||
'updatedAt:desc',
|
||||
{ name: 'test search' },
|
||||
);
|
||||
|
||||
// Should reset workflows array and populate with filtered results
|
||||
expect(workflowsResources.value).toEqual([
|
||||
{
|
||||
name: 'Filtered Workflow',
|
||||
value: '3',
|
||||
url: expect.any(String) as string,
|
||||
isArchived: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should calculate hasMore correctly based on total count', async () => {
|
||||
workflowsStoreMock.fetchWorkflowsPage.mockResolvedValue([
|
||||
{ id: '1', name: 'Workflow 1' },
|
||||
] as any);
|
||||
workflowsStoreMock.totalWorkflowCount = 1; // Only 1 total, so no more after first load
|
||||
|
||||
const { populateNextWorkflowsPage, hasMoreWorkflowsToLoad } =
|
||||
useWorkflowResourcesLocator(routerMock);
|
||||
|
||||
await populateNextWorkflowsPage();
|
||||
|
||||
expect(hasMoreWorkflowsToLoad.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle multiple page loads correctly', async () => {
|
||||
const firstPageWorkflows = [
|
||||
{ id: '1', name: 'Workflow 1' },
|
||||
{ id: '2', name: 'Workflow 2' },
|
||||
] as any;
|
||||
const secondPageWorkflows = [
|
||||
{ id: '3', name: 'Workflow 3' },
|
||||
{ id: '4', name: 'Workflow 4' },
|
||||
] as any;
|
||||
|
||||
workflowsStoreMock.fetchWorkflowsPage
|
||||
.mockResolvedValueOnce(firstPageWorkflows)
|
||||
.mockResolvedValueOnce(secondPageWorkflows);
|
||||
workflowsStoreMock.totalWorkflowCount = 100;
|
||||
|
||||
const { populateNextWorkflowsPage, workflowsResources } =
|
||||
useWorkflowResourcesLocator(routerMock);
|
||||
|
||||
// Load first page
|
||||
await populateNextWorkflowsPage();
|
||||
expect(workflowsResources.value).toHaveLength(2);
|
||||
expect(workflowsStoreMock.fetchWorkflowsPage).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
1,
|
||||
40,
|
||||
'updatedAt:desc',
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Load second page
|
||||
await populateNextWorkflowsPage();
|
||||
expect(workflowsResources.value).toHaveLength(4);
|
||||
expect(workflowsStoreMock.fetchWorkflowsPage).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
2,
|
||||
40,
|
||||
'updatedAt:desc',
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Verify workflows from both pages are present
|
||||
expect(workflowsResources.value.map((w) => w.name)).toEqual([
|
||||
'Workflow 1',
|
||||
'Workflow 2',
|
||||
'Workflow 3',
|
||||
'Workflow 4',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflowDbToResourceMapper', () => {
|
||||
it('should map WorkflowListResource correctly', () => {
|
||||
routerMock.resolve = vi.fn().mockReturnValue({ href: '/workflow/test-id' });
|
||||
|
||||
const { workflowDbToResourceMapper } = useWorkflowResourcesLocator(routerMock);
|
||||
|
||||
const workflow = {
|
||||
id: 'test-id',
|
||||
name: 'Test Workflow',
|
||||
} as any;
|
||||
|
||||
const result = workflowDbToResourceMapper(workflow);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'Test Workflow',
|
||||
value: 'test-id',
|
||||
url: '/workflow/test-id',
|
||||
isArchived: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should map IWorkflowDb with archived status correctly', () => {
|
||||
routerMock.resolve = vi.fn().mockReturnValue({ href: '/workflow/test-id' });
|
||||
|
||||
const { workflowDbToResourceMapper } = useWorkflowResourcesLocator(routerMock);
|
||||
|
||||
const workflow: IWorkflowDb = {
|
||||
id: 'test-id',
|
||||
name: 'Archived Workflow',
|
||||
isArchived: true,
|
||||
} as IWorkflowDb;
|
||||
|
||||
const result = workflowDbToResourceMapper(workflow);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'Archived Workflow',
|
||||
value: 'test-id',
|
||||
url: '/workflow/test-id',
|
||||
isArchived: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('utility functions', () => {
|
||||
it('should generate correct workflow URL', () => {
|
||||
routerMock.resolve = vi.fn().mockReturnValue({ href: '/workflow/test-workflow-id' });
|
||||
|
||||
const { getWorkflowUrl } = useWorkflowResourcesLocator(routerMock);
|
||||
const url = getWorkflowUrl('test-workflow-id');
|
||||
|
||||
expect(routerMock.resolve as any).toHaveBeenCalledWith({
|
||||
name: 'NodeViewExisting',
|
||||
params: { name: 'test-workflow-id' },
|
||||
});
|
||||
expect(url).toBe('/workflow/test-workflow-id');
|
||||
});
|
||||
|
||||
it('should get workflow name from store', () => {
|
||||
const mockWorkflow = { id: 'test-id', name: 'Test Name' } as IWorkflowDb;
|
||||
workflowsStoreMock.getWorkflowById.mockReturnValue(mockWorkflow);
|
||||
|
||||
const { getWorkflowName } = useWorkflowResourcesLocator(routerMock);
|
||||
const name = getWorkflowName('test-id');
|
||||
|
||||
expect(name).toBe('Test Name');
|
||||
expect(workflowsStoreMock.getWorkflowById).toHaveBeenCalledWith('test-id');
|
||||
});
|
||||
|
||||
it('should return workflow ID when workflow not found in store', () => {
|
||||
workflowsStoreMock.getWorkflowById.mockReturnValue(null as any);
|
||||
|
||||
const { getWorkflowName } = useWorkflowResourcesLocator(routerMock);
|
||||
const name = getWorkflowName('missing-id');
|
||||
|
||||
expect(name).toBe('missing-id');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import type { Router } from 'vue-router';
|
||||
import { VIEWS } from '@/constants';
|
||||
|
||||
import type { IWorkflowDb } from '@/Interface';
|
||||
import type { IWorkflowDb, WorkflowListResource } from '@/Interface';
|
||||
import type { NodeParameterValue } from 'n8n-workflow';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||||
@@ -14,71 +13,30 @@ export function useWorkflowResourcesLocator(router: Router) {
|
||||
const ndvStore = useNDVStore();
|
||||
const { renameNode } = useCanvasOperations();
|
||||
|
||||
const workflowsResources = ref<Array<{ name: string; value: string; url: string }>>([]);
|
||||
const workflowsResources = ref<
|
||||
Array<{ name: string; value: string; url: string; isArchived: boolean }>
|
||||
>([]);
|
||||
const isLoadingResources = ref(true);
|
||||
const searchFilter = ref('');
|
||||
const currentPage = ref(0);
|
||||
const PAGE_SIZE = 40;
|
||||
const totalCount = ref(0);
|
||||
|
||||
const sortedWorkflows = computed(() =>
|
||||
sortBy(workflowsStore.allWorkflows, (workflow) =>
|
||||
new Date(workflow.updatedAt).valueOf(),
|
||||
).reverse(),
|
||||
);
|
||||
const hasMoreWorkflowsToLoad = computed(() => totalCount.value > workflowsResources.value.length);
|
||||
|
||||
const hasMoreWorkflowsToLoad = computed(
|
||||
() => workflowsStore.allWorkflows.length > workflowsResources.value.length,
|
||||
);
|
||||
function constructName(workflow: IWorkflowDb | WorkflowListResource) {
|
||||
// Add the project name if it's not a personal project
|
||||
if (workflow.homeProject && workflow.homeProject.type !== 'personal') {
|
||||
return `${workflow.homeProject.name} — ${workflow.name}`;
|
||||
}
|
||||
|
||||
const filteredResources = computed(() => {
|
||||
return workflowsStore.allWorkflows
|
||||
.filter((resource) => resource.name.toLowerCase().includes(searchFilter.value.toLowerCase()))
|
||||
.map(workflowDbToResourceMapper);
|
||||
});
|
||||
|
||||
async function populateNextWorkflowsPage() {
|
||||
await workflowsStore.fetchAllWorkflows();
|
||||
const nextPage = sortedWorkflows.value.slice(
|
||||
workflowsResources.value.length,
|
||||
workflowsResources.value.length + PAGE_SIZE,
|
||||
);
|
||||
|
||||
workflowsResources.value.push(...nextPage.map(workflowDbToResourceMapper));
|
||||
}
|
||||
|
||||
async function setWorkflowsResources() {
|
||||
isLoadingResources.value = true;
|
||||
await populateNextWorkflowsPage();
|
||||
isLoadingResources.value = false;
|
||||
}
|
||||
|
||||
async function reloadWorkflows() {
|
||||
isLoadingResources.value = true;
|
||||
await workflowsStore.fetchAllWorkflows();
|
||||
isLoadingResources.value = false;
|
||||
}
|
||||
|
||||
function workflowDbToResourceMapper(workflow: IWorkflowDb) {
|
||||
return {
|
||||
name: getWorkflowName(workflow.id),
|
||||
value: workflow.id,
|
||||
url: getWorkflowUrl(workflow.id),
|
||||
isArchived: workflow.isArchived,
|
||||
};
|
||||
}
|
||||
|
||||
function getWorkflowUrl(workflowId: string) {
|
||||
const { href } = router.resolve({ name: VIEWS.WORKFLOW, params: { name: workflowId } });
|
||||
return href;
|
||||
return workflow.name;
|
||||
}
|
||||
|
||||
function getWorkflowName(id: string): string {
|
||||
const workflow = workflowsStore.getWorkflowById(id);
|
||||
if (workflow) {
|
||||
// Add the project name if it's not a personal project
|
||||
if (workflow.homeProject && workflow.homeProject.type !== 'personal') {
|
||||
return `${workflow.homeProject.name} — ${workflow.name}`;
|
||||
}
|
||||
return workflow.name;
|
||||
return constructName(workflow);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -91,8 +49,51 @@ export function useWorkflowResourcesLocator(router: Router) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function onSearchFilter(filter: string) {
|
||||
function getWorkflowUrl(workflowId: string) {
|
||||
const { href } = router.resolve({ name: VIEWS.WORKFLOW, params: { name: workflowId } });
|
||||
return href;
|
||||
}
|
||||
|
||||
function workflowDbToResourceMapper(workflow: WorkflowListResource | IWorkflowDb) {
|
||||
return {
|
||||
name: constructName(workflow),
|
||||
value: workflow.id,
|
||||
url: getWorkflowUrl(workflow.id),
|
||||
isArchived: 'isArchived' in workflow ? workflow.isArchived : false,
|
||||
};
|
||||
}
|
||||
|
||||
async function populateNextWorkflowsPage(reset = false) {
|
||||
if (reset) {
|
||||
currentPage.value = 0;
|
||||
}
|
||||
|
||||
currentPage.value++;
|
||||
const workflows = await workflowsStore.fetchWorkflowsPage(
|
||||
undefined,
|
||||
currentPage.value,
|
||||
PAGE_SIZE,
|
||||
'updatedAt:desc',
|
||||
searchFilter.value ? { name: searchFilter.value } : undefined,
|
||||
);
|
||||
totalCount.value = workflowsStore.totalWorkflowCount;
|
||||
|
||||
if (reset) {
|
||||
workflowsResources.value = workflows.map(workflowDbToResourceMapper);
|
||||
} else {
|
||||
workflowsResources.value.push(...workflows.map(workflowDbToResourceMapper));
|
||||
}
|
||||
}
|
||||
|
||||
async function setWorkflowsResources() {
|
||||
isLoadingResources.value = true;
|
||||
await populateNextWorkflowsPage();
|
||||
isLoadingResources.value = false;
|
||||
}
|
||||
|
||||
async function onSearchFilter(filter: string) {
|
||||
searchFilter.value = filter;
|
||||
await populateNextWorkflowsPage(true);
|
||||
}
|
||||
|
||||
function applyDefaultExecuteWorkflowNodeName(workflowId: NodeParameterValue) {
|
||||
@@ -115,14 +116,13 @@ export function useWorkflowResourcesLocator(router: Router) {
|
||||
workflowsResources,
|
||||
isLoadingResources,
|
||||
hasMoreWorkflowsToLoad,
|
||||
filteredResources,
|
||||
searchFilter,
|
||||
reloadWorkflows,
|
||||
getWorkflowUrl,
|
||||
onSearchFilter,
|
||||
getWorkflowName,
|
||||
applyDefaultExecuteWorkflowNodeName,
|
||||
populateNextWorkflowsPage,
|
||||
setWorkflowsResources,
|
||||
workflowDbToResourceMapper,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user