mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
fix(editor): Add execution concurrency info and paywall (#11847)
This commit is contained in:
parent
96e6be7fe7
commit
57d3269e40
|
@ -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'
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
|
@ -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<typeof createTestingPinia>;
|
||||
let settingsStore: MockedStore<typeof useSettingsStore>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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) {
|
|||
<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
|
||||
|
@ -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"
|
||||
|
|
|
@ -25,6 +25,7 @@ const props = withDefaults(
|
|||
selected?: boolean;
|
||||
workflowName?: string;
|
||||
workflowPermissions: PermissionsRecord['workflow'];
|
||||
concurrencyCap: number;
|
||||
}>(),
|
||||
{
|
||||
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) {
|
|||
<FontAwesomeIcon icon="spinner" spin />
|
||||
</span>
|
||||
<i18n-t
|
||||
v-if="!isWaitTillIndefinite"
|
||||
v-if="!isWaitTillIndefinite && !isQueued"
|
||||
data-test-id="execution-status"
|
||||
tag="span"
|
||||
:keypath="statusTextTranslationPath"
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import type { MockedStore } from '@/__tests__/utils';
|
||||
import WorkflowExecutionsSidebar from '@/components/executions/workflow/WorkflowExecutionsSidebar.vue';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { mockedStore, SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
||||
import { STORES } from '@/constants';
|
||||
import { merge } from 'lodash-es';
|
||||
import { expect, it } from 'vitest';
|
||||
|
||||
vi.mock('vue-router', () => {
|
||||
const location = {};
|
||||
return {
|
||||
useRouter: vi.fn(),
|
||||
useRoute: () => ({
|
||||
location,
|
||||
}),
|
||||
RouterLink: {
|
||||
template: '<a><slot /></a>',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
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<typeof useSettingsStore>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
|
@ -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<string[]>([]);
|
||||
const autoScrollDeps = ref<AutoScrollDeps>({
|
||||
|
@ -49,6 +52,10 @@ const executionListRef = ref<HTMLElement | null>(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 {
|
|||
<n8n-heading tag="h2" size="medium" color="text-dark">
|
||||
{{ i18n.baseText('generic.executions') }}
|
||||
</n8n-heading>
|
||||
|
||||
<ConcurrentExecutionsHeader
|
||||
v-if="settingsStore.isConcurrencyEnabled"
|
||||
:running-executions-count="runningExecutionsCount"
|
||||
:concurrency-cap="settingsStore.concurrency"
|
||||
/>
|
||||
</div>
|
||||
<div :class="$style.controls">
|
||||
<el-checkbox
|
||||
|
|
|
@ -697,6 +697,9 @@
|
|||
"executionsLandingPage.emptyState.accordion.footer.tooltipLink": "Save your workflow",
|
||||
"executionsLandingPage.emptyState.accordion.footer.tooltipText": "in order to access workflow settings",
|
||||
"executionsLandingPage.noResults": "No executions found",
|
||||
"executionsList.activeExecutions.none": "No active executions",
|
||||
"executionsList.activeExecutions.header": "{running}/{cap} active executions",
|
||||
"executionsList.activeExecutions.tooltip": "Current active executions: {running} out of {cap} allowed by your plan. Upgrade to increase the limit.",
|
||||
"executionsList.allWorkflows": "All Workflows",
|
||||
"executionsList.anyStatus": "Any Status",
|
||||
"executionsList.autoRefresh": "Auto refresh",
|
||||
|
@ -764,6 +767,8 @@
|
|||
"executionsList.workflowExecutions": "Executions",
|
||||
"executionsList.view": "View",
|
||||
"executionsList.stop": "Stop",
|
||||
"executionsList.statusTooltipText.waitingForWebhook": "The workflow is waiting indefinitely for an incoming webhook call.",
|
||||
"executionsList.statusTooltipText.waitingForConcurrencyCapacity": "This execution will start once concurrency capacity is available. This instance is limited to {concurrencyCap} concurrent production executions.",
|
||||
"executionsList.statusTooltipText.theWorkflowIsWaitingIndefinitely": "The workflow is waiting indefinitely for an incoming webhook call.",
|
||||
"executionsList.debug.button.copyToEditor": "Copy to editor",
|
||||
"executionsList.debug.button.debugInEditor": "Debug in editor",
|
||||
|
|
|
@ -15,10 +15,12 @@ import { useRootStore } from '@/stores/root.store';
|
|||
import { makeRestApiRequest, unflattenExecutionData } from '@/utils/apiUtils';
|
||||
import { executionFilterToQueryFilter, getDefaultExecutionFilters } from '@/utils/executionUtils';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
|
||||
export const useExecutionsStore = defineStore('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<Record<string, ExecutionSummaryWithScopes>>({});
|
||||
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;
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue