diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 625e6e7a87..4d12d6b1df 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1474,6 +1474,7 @@ export interface ExternalSecretsProvider { export type CloudUpdateLinkSourceType = | 'advanced-permissions' | 'canvas-nav' + | 'concurrency' | 'custom-data-filter' | 'workflow_sharing' | 'credential_sharing' @@ -1496,6 +1497,7 @@ export type CloudUpdateLinkSourceType = export type UTMCampaign = | 'upgrade-custom-data-filter' | 'upgrade-canvas-nav' + | 'upgrade-concurrency' | 'upgrade-workflow-sharing' | 'upgrade-credentials-sharing' | 'upgrade-api' diff --git a/packages/editor-ui/src/components/executions/ConcurrentExecutionsHeader.test.ts b/packages/editor-ui/src/components/executions/ConcurrentExecutionsHeader.test.ts new file mode 100644 index 0000000000..7e7c45b8d4 --- /dev/null +++ b/packages/editor-ui/src/components/executions/ConcurrentExecutionsHeader.test.ts @@ -0,0 +1,73 @@ +import { createTestingPinia } from '@pinia/testing'; +import userEvent from '@testing-library/user-event'; +import { createComponentRenderer } from '@/__tests__/render'; +import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue'; + +vi.mock('vue-router', () => { + return { + useRouter: vi.fn(), + useRoute: vi.fn(), + RouterLink: { + template: '', + }, + }; +}); + +const renderComponent = createComponentRenderer(ConcurrentExecutionsHeader, { + pinia: createTestingPinia(), +}); + +describe('ConcurrentExecutionsHeader', () => { + it('should not throw error when rendered', async () => { + expect(() => + renderComponent({ + props: { + runningExecutionsCount: 0, + concurrencyCap: 0, + }, + }), + ).not.toThrow(); + }); + + test.each([ + [0, 5, 'No active executions'], + [2, 5, '2/5 active executions'], + ])( + 'shows the correct text when there are %i running executions of %i', + async (runningExecutionsCount, concurrencyCap, text) => { + const { getByText } = renderComponent({ + props: { + runningExecutionsCount, + concurrencyCap, + }, + }); + + expect(getByText(text)).toBeVisible(); + }, + ); + + it('should show tooltip on hover and call "goToUpgrade" on click', async () => { + const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + + const { container, getByText, getByRole, queryByRole } = renderComponent({ + props: { + runningExecutionsCount: 2, + concurrencyCap: 5, + }, + }); + + const tooltipTrigger = container.querySelector('svg') as SVGSVGElement; + + expect(tooltipTrigger).toBeVisible(); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + + await userEvent.hover(tooltipTrigger); + + expect(getByRole('tooltip')).toBeVisible(); + expect(getByText('Upgrade now')).toBeVisible(); + + await userEvent.click(getByText('Upgrade now')); + + expect(windowOpenSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/editor-ui/src/components/executions/ConcurrentExecutionsHeader.vue b/packages/editor-ui/src/components/executions/ConcurrentExecutionsHeader.vue new file mode 100644 index 0000000000..9ff4600546 --- /dev/null +++ b/packages/editor-ui/src/components/executions/ConcurrentExecutionsHeader.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.test.ts b/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.test.ts index f1aa96ebaa..cefaee033a 100644 --- a/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.test.ts +++ b/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.test.ts @@ -6,9 +6,16 @@ import { faker } from '@faker-js/faker'; import { STORES, VIEWS } from '@/constants'; import ExecutionsList from '@/components/executions/global/GlobalExecutionsList.vue'; import { randomInt, type ExecutionSummary } from 'n8n-workflow'; -import { retry, SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/__tests__/utils'; +import type { MockedStore } from '@/__tests__/utils'; +import { + mockedStore, + retry, + SETTINGS_STORE_DEFAULT_STATE, + waitAllPromises, +} from '@/__tests__/utils'; import { createComponentRenderer } from '@/__tests__/render'; import { waitFor } from '@testing-library/vue'; +import { useSettingsStore } from '@/stores/settings.store'; vi.mock('vue-router', () => ({ useRoute: vi.fn().mockReturnValue({ @@ -18,7 +25,7 @@ vi.mock('vue-router', () => ({ RouterLink: vi.fn(), })); -let pinia: ReturnType; +let settingsStore: MockedStore; const generateUndefinedNullOrString = () => { switch (randomInt(4)) { @@ -58,6 +65,20 @@ const generateExecutionsData = () => })); const renderComponent = createComponentRenderer(ExecutionsList, { + pinia: createTestingPinia({ + initialState: { + [STORES.EXECUTIONS]: { + executions: [], + }, + [STORES.SETTINGS]: { + settings: merge(SETTINGS_STORE_DEFAULT_STATE.settings, { + enterprise: { + advancedExecutionFilters: true, + }, + }), + }, + }, + }), props: { autoRefreshEnabled: false, }, @@ -80,21 +101,7 @@ describe('GlobalExecutionsList', () => { beforeEach(() => { executionsData = generateExecutionsData(); - - pinia = createTestingPinia({ - initialState: { - [STORES.EXECUTIONS]: { - executions: [], - }, - [STORES.SETTINGS]: { - settings: merge(SETTINGS_STORE_DEFAULT_STATE.settings, { - enterprise: { - advancedExecutionFilters: true, - }, - }), - }, - }, - }); + settingsStore = mockedStore(useSettingsStore); }); it('should render empty list', async () => { @@ -105,7 +112,6 @@ describe('GlobalExecutionsList', () => { total: 0, estimated: false, }, - pinia, }); await waitAllPromises(); @@ -128,7 +134,6 @@ describe('GlobalExecutionsList', () => { filters: {}, estimated: false, }, - pinia, }); await waitAllPromises(); @@ -194,11 +199,22 @@ describe('GlobalExecutionsList', () => { filters: {}, estimated: false, }, - pinia, }); await waitAllPromises(); expect(queryAllByText(/Retry of/).length).toBe(retryOf.length); expect(queryAllByText(/Success retry/).length).toBe(retrySuccessId.length); }); + + it('should render concurrent executions header if the feature is enabled', async () => { + settingsStore.concurrency = 5; + const { getByTestId } = renderComponent({ + props: { + executions: executionsData[0].results, + filters: {}, + }, + }); + + expect(getByTestId('concurrent-executions-header')).toBeVisible(); + }); }); diff --git a/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue b/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue index 8422699fee..38aeddda80 100644 --- a/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue +++ b/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue @@ -15,6 +15,7 @@ import type { PermissionsRecord } from '@/permissions'; import { getResourcePermissions } from '@/permissions'; import { useSettingsStore } from '@/stores/settings.store'; import ProjectHeader from '@/components/Projects/ProjectHeader.vue'; +import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue'; const props = withDefaults( defineProps<{ @@ -70,6 +71,10 @@ const isAnnotationEnabled = computed( () => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedExecutionFilters], ); +const runningExecutionsCount = computed(() => { + return props.executions.filter((execution) => execution.status === 'running').length; +}); + watch( () => props.executions, () => { @@ -320,6 +325,12 @@ async function onAutoRefreshToggle(value: boolean) {
+ (), { selected: false, @@ -42,6 +43,10 @@ const isRunning = computed(() => { return props.execution.status === 'running'; }); +const isQueued = computed(() => { + return props.execution.status === 'new'; +}); + const isWaitTillIndefinite = computed(() => { if (!props.execution.waitTill) { return false; @@ -80,6 +85,12 @@ const formattedStoppedAtDate = computed(() => { }); const statusTooltipText = computed(() => { + if (isQueued.value) { + return i18n.baseText('executionsList.statusTooltipText.waitingForConcurrencyCapacity', { + interpolate: { concurrencyCap: props.concurrencyCap }, + }); + } + if (props.execution.status === 'waiting' && isWaitTillIndefinite.value) { return i18n.baseText('executionsList.statusTooltipText.theWorkflowIsWaitingIndefinitely'); } @@ -178,7 +189,7 @@ async function handleActionItemClick(commandData: Command) { { + const location = {}; + return { + useRouter: vi.fn(), + useRoute: () => ({ + location, + }), + RouterLink: { + template: '', + }, + }; +}); + +const renderComponent = createComponentRenderer(WorkflowExecutionsSidebar, { + pinia: createTestingPinia({ + initialState: { + [STORES.EXECUTIONS]: { + executions: [], + }, + [STORES.SETTINGS]: { + settings: merge(SETTINGS_STORE_DEFAULT_STATE.settings, { + enterprise: { + advancedExecutionFilters: true, + }, + }), + }, + }, + }), +}); + +let settingsStore: MockedStore; + +describe('WorkflowExecutionsSidebar', () => { + beforeEach(() => { + settingsStore = mockedStore(useSettingsStore); + }); + + it('should not throw error when opened', async () => { + expect(() => + renderComponent({ + props: { + loading: false, + loadingMore: false, + executions: [], + }, + }), + ).not.toThrow(); + }); + + it('should render concurrent executions header if the feature is enabled', async () => { + settingsStore.concurrency = 5; + const { getByTestId } = renderComponent({ + props: { + loading: false, + loadingMore: false, + executions: [], + }, + }); + + expect(getByTestId('concurrent-executions-header')).toBeVisible(); + }); +}); diff --git a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsSidebar.vue b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsSidebar.vue index 6dc874d9b9..443a3d0f5d 100644 --- a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsSidebar.vue +++ b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsSidebar.vue @@ -13,6 +13,8 @@ import type { ExecutionFilterType, IWorkflowDb } from '@/Interface'; import { isComponentPublicInstance } from '@/utils/typeGuards'; import { getResourcePermissions } from '@/permissions'; import { useI18n } from '@/composables/useI18n'; +import { useSettingsStore } from '@/stores/settings.store'; +import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue'; type AutoScrollDeps = { activeExecutionSet: boolean; cardsMounted: boolean; scroll: boolean }; @@ -36,6 +38,7 @@ const router = useRouter(); const i18n = useI18n(); const executionsStore = useExecutionsStore(); +const settingsStore = useSettingsStore(); const mountedItems = ref([]); const autoScrollDeps = ref({ @@ -49,6 +52,10 @@ const executionListRef = ref(null); const workflowPermissions = computed(() => getResourcePermissions(props.workflow?.scopes).workflow); +const runningExecutionsCount = computed(() => { + return props.executions.filter((execution) => execution.status === 'running').length; +}); + watch( () => route, (to: RouteLocationNormalizedLoaded, from: RouteLocationNormalizedLoaded) => { @@ -174,6 +181,12 @@ function scrollToActiveCard(): void { {{ i18n.baseText('generic.executions') }} + +
{ const rootStore = useRootStore(); const projectsStore = useProjectsStore(); + const settingsStore = useSettingsStore(); const loading = ref(false); const itemsPerPage = ref(10); @@ -67,12 +69,29 @@ export const useExecutionsStore = defineStore('executions', () => { ); const currentExecutionsById = ref>({}); + const startedAtSortFn = (a: ExecutionSummary, b: ExecutionSummary) => + new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(); + + /** + * Prioritize `running` over `new` executions, then sort by start timestamp. + */ + const statusThenStartedAtSortFn = (a: ExecutionSummary, b: ExecutionSummary) => { + if (a.status && b.status) { + const statusPriority: { [key: string]: number } = { running: 1, new: 2 }; + const statusComparison = statusPriority[a.status] - statusPriority[b.status]; + + if (statusComparison !== 0) return statusComparison; + } + + return startedAtSortFn(a, b); + }; + + const sortFn = settingsStore.isConcurrencyEnabled ? statusThenStartedAtSortFn : startedAtSortFn; + const currentExecutions = computed(() => { const data = Object.values(currentExecutionsById.value); - data.sort((a, b) => { - return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(); - }); + data.sort(sortFn); return data; }); diff --git a/packages/editor-ui/src/stores/settings.store.ts b/packages/editor-ui/src/stores/settings.store.ts index 7d881b74d4..822c05f4dc 100644 --- a/packages/editor-ui/src/stores/settings.store.ts +++ b/packages/editor-ui/src/stores/settings.store.ts @@ -70,6 +70,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { const concurrency = computed(() => settings.value.concurrency); + const isConcurrencyEnabled = computed(() => concurrency.value !== -1); + const isPublicApiEnabled = computed(() => api.value.enabled); const isSwaggerUIEnabled = computed(() => api.value.swaggerUi.enabled); @@ -384,6 +386,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { security, nodeJsVersion, concurrency, + isConcurrencyEnabled, isPublicApiEnabled, isSwaggerUIEnabled, isPreviewMode,