Skip to content

Commit

Permalink
fix(editor): Add execution concurrency info and paywall (#11847)
Browse files Browse the repository at this point in the history
  • Loading branch information
cstuncsik authored Nov 28, 2024
1 parent 96e6be7 commit 57d3269
Show file tree
Hide file tree
Showing 11 changed files with 315 additions and 24 deletions.
2 changes: 2 additions & 0 deletions packages/editor-ui/src/Interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1474,6 +1474,7 @@ export interface ExternalSecretsProvider {
export type CloudUpdateLinkSourceType =
| 'advanced-permissions'
| 'canvas-nav'
| 'concurrency'
| 'custom-data-filter'
| 'workflow_sharing'
| 'credential_sharing'
Expand All @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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: '<a><slot /></a>',
},
};
});

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();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<script lang="ts" setup>
import { computed, defineProps } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const props = defineProps<{
runningExecutionsCount: number;
concurrencyCap: number;
}>();
const i18n = useI18n();
const pageRedirectionHelper = usePageRedirectionHelper();
const tooltipText = computed(() =>
i18n.baseText('executionsList.activeExecutions.tooltip', {
interpolate: {
running: props.runningExecutionsCount,
cap: props.concurrencyCap,
},
}),
);
const headerText = computed(() => {
if (props.runningExecutionsCount === 0) {
return i18n.baseText('executionsList.activeExecutions.none');
}
return i18n.baseText('executionsList.activeExecutions.header', {
interpolate: {
running: props.runningExecutionsCount,
cap: props.concurrencyCap,
},
});
});
const goToUpgrade = () => {
void pageRedirectionHelper.goToUpgrade('concurrency', 'upgrade-concurrency');
};
</script>

<template>
<div data-test-id="concurrent-executions-header">
<n8n-tooltip>
<template #content>
<div :class="$style.tooltip">
{{ tooltipText }}
<n8n-link bold size="small" :class="$style.upgrade" @click="goToUpgrade">
{{ i18n.baseText('generic.upgradeNow') }}
</n8n-link>
</div>
</template>
<font-awesome-icon icon="info-circle" class="mr-2xs" />
</n8n-tooltip>
<n8n-text>{{ headerText }}</n8n-text>
</div>
</template>

<style module scoped>
.tooltip {
display: flex;
flex-direction: column;
}
.upgrade {
margin-top: var(--spacing-xs);
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -18,7 +25,7 @@ vi.mock('vue-router', () => ({
RouterLink: vi.fn(),
}));

let pinia: ReturnType<typeof createTestingPinia>;
let settingsStore: MockedStore<typeof useSettingsStore>;

const generateUndefinedNullOrString = () => {
switch (randomInt(4)) {
Expand Down Expand Up @@ -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,
},
Expand All @@ -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 () => {
Expand All @@ -105,7 +112,6 @@ describe('GlobalExecutionsList', () => {
total: 0,
estimated: false,
},
pinia,
});
await waitAllPromises();

Expand All @@ -128,7 +134,6 @@ describe('GlobalExecutionsList', () => {
filters: {},
estimated: false,
},
pinia,
});
await waitAllPromises();

Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down Expand Up @@ -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,
() => {
Expand Down Expand Up @@ -320,6 +325,12 @@ async function onAutoRefreshToggle(value: boolean) {
<div :class="$style.execList">
<div :class="$style.execListHeader">
<div :class="$style.execListHeaderControls">
<ConcurrentExecutionsHeader
v-if="settingsStore.isConcurrencyEnabled"
class="mr-xl"
:running-executions-count="runningExecutionsCount"
:concurrency-cap="settingsStore.concurrency"
/>
<N8nLoading v-if="!isMounted" :class="$style.filterLoader" variant="custom" />
<ElCheckbox
v-else
Expand Down Expand Up @@ -388,6 +399,7 @@ async function onAutoRefreshToggle(value: boolean) {
:workflow-name="getExecutionWorkflowName(execution)"
:workflow-permissions="getExecutionWorkflowPermissions(execution)"
:selected="selectedItems[execution.id] || allExistingSelected"
:concurrency-cap="settingsStore.concurrency"
data-test-id="global-execution-list-item"
@stop="stopExecution"
@delete="deleteExecution"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const props = withDefaults(
selected?: boolean;
workflowName?: string;
workflowPermissions: PermissionsRecord['workflow'];
concurrencyCap: number;
}>(),
{
selected: false,
Expand All @@ -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;
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -178,7 +189,7 @@ async function handleActionItemClick(commandData: Command) {
<FontAwesomeIcon icon="spinner" spin />
</span>
<i18n-t
v-if="!isWaitTillIndefinite"
v-if="!isWaitTillIndefinite && !isQueued"
data-test-id="execution-status"
tag="span"
:keypath="statusTextTranslationPath"
Expand Down
Loading

0 comments on commit 57d3269

Please sign in to comment.