mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(editor): Add the incoming changes banner and apply entry point on the destination (no-changelog) (#39150)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
dda9a56cb7
commit
02cd44e3aa
|
|
@ -8830,5 +8830,24 @@
|
|||
"promotions.modal.promoteSingle": "Promote 1 change",
|
||||
"promotions.banner.changesAvailable": "{count} changes available to promote.",
|
||||
"promotions.banner.singleChangeAvailable": "1 change available to promote.",
|
||||
"promotions.banner.viewChanges": "View changes"
|
||||
"promotions.banner.viewChanges": "View changes",
|
||||
"promotions.banner.incoming.changesAvailable": "{count} incoming changes.",
|
||||
"promotions.banner.incoming.singleChangeAvailable": "1 incoming change.",
|
||||
"promotions.banner.incoming.error": "Could not check for incoming changes.",
|
||||
"promotions.modal.incoming.title": "Incoming changes",
|
||||
"promotions.modal.incoming.empty": "Nothing to apply",
|
||||
"promotions.modal.incoming.empty.description": "This project matches the source.",
|
||||
"promotions.modal.incoming.selected": "{count} selected",
|
||||
"promotions.modal.incoming.applyAll": "Apply all changes",
|
||||
"promotions.modal.incoming.confirm.title": "Apply remote changes to this instance?",
|
||||
"promotions.modal.incoming.confirm.message": "Apply overwrites every team project on this instance to match the source, and deletes projects and content that are not in the package.",
|
||||
"promotions.modal.incoming.confirm.confirmButtonText": "Apply",
|
||||
"promotions.modal.incoming.applied.title": "Changes applied",
|
||||
"promotions.modal.incoming.applied.message": "{created} created, {updated} updated, {archived} archived, {deleted} deleted.",
|
||||
"promotions.modal.incoming.applied.notPublished": "{count} could not be published.",
|
||||
"promotions.modal.incoming.applyError": "Could not apply the changes",
|
||||
"promotions.modal.incoming.paused.title": "Apply paused",
|
||||
"promotions.modal.incoming.paused.blocked": "Some credentials or variables are not set up on this instance yet. Nothing was changed.",
|
||||
"promotions.modal.incoming.paused.source-changed": "The source changed since this preview. Refresh and review the changes again.",
|
||||
"promotions.applied.projectRemoved": "Apply removed this project from the instance."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
|||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
|
||||
import { promotionEventBus } from '@/features/integrations/promotions.ee/promotions.eventBus';
|
||||
import { useTagsStore } from '@/features/shared/tags/tags.store';
|
||||
import { useUsersStore } from '@n8n/stores/users.store';
|
||||
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
|
||||
|
|
@ -53,8 +54,9 @@ vi.mock('@n8n/composables/useTelemetry', () => ({
|
|||
}));
|
||||
|
||||
const mockShowError = vi.fn();
|
||||
const mockShowMessage = vi.fn();
|
||||
vi.mock('@n8n/composables/useToast', () => ({
|
||||
useToast: () => ({ showError: mockShowError }),
|
||||
useToast: () => ({ showError: mockShowError, showMessage: mockShowMessage }),
|
||||
}));
|
||||
|
||||
const router = createRouter({
|
||||
|
|
@ -466,6 +468,20 @@ describe('WorkflowsView', () => {
|
|||
await sourceControl.pullWorkfolder(true, 'none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('promotions', () => {
|
||||
it('should reload the list after a package was applied', async () => {
|
||||
renderComponent({ pinia });
|
||||
await waitAllPromises();
|
||||
const fetches = workflowsListStore.fetchWorkflowsPage.mock.calls.length;
|
||||
|
||||
promotionEventBus.emit('applied', { projectId: 'project-1' });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(workflowsListStore.fetchWorkflowsPage.mock.calls.length).toBeGreaterThan(fetches),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Folders', () => {
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ import { WORKFLOW_CARD_MCP_TOGGLE_EXPERIMENT } from '@/app/constants/experiments
|
|||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
|
||||
import { promotionEventBus } from '@/features/integrations/promotions.ee/promotions.eventBus';
|
||||
import { useTagsStore } from '@/features/shared/tags/tags.store';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useUsageStore } from '@/features/settings/usage/usage.store';
|
||||
|
|
@ -668,6 +669,11 @@ sourceControlStore.$onAction(({ name, after }) => {
|
|||
after(async () => await initialize());
|
||||
});
|
||||
|
||||
/** The project header leaves the page when the package removed this project. */
|
||||
async function onPromotionApplied() {
|
||||
await initialize();
|
||||
}
|
||||
|
||||
const refreshWorkflows = async () => {
|
||||
await Promise.all([
|
||||
fetchWorkflows(),
|
||||
|
|
@ -751,9 +757,11 @@ onMounted(async () => {
|
|||
workflowListEventBus.on('folder-transferred', onFolderTransferred);
|
||||
workflowListEventBus.on('workflow-moved', onWorkflowMoved);
|
||||
workflowListEventBus.on('workflow-transferred', onWorkflowTransferred);
|
||||
promotionEventBus.on('applied', onPromotionApplied);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
promotionEventBus.off('applied', onPromotionApplied);
|
||||
workflowListEventBus.off('resource-moved', fetchWorkflows);
|
||||
workflowListEventBus.off('workflow-duplicated', fetchWorkflows);
|
||||
workflowListEventBus.off('folder-deleted', onFolderDeleted);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import { useUIStore } from '@/app/stores/ui.store';
|
|||
import { useUsersStore } from '@n8n/stores/users.store';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import type { IUser } from '@n8n/rest-api-client';
|
||||
import { createServer } from 'miragejs';
|
||||
|
||||
const mockPush = vi.fn();
|
||||
vi.mock('vue-router', async () => {
|
||||
|
|
@ -116,47 +115,6 @@ describe('ProjectHeader', () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows live promotion changes only with export and push access', async () => {
|
||||
const server = createServer({ environment: 'test' });
|
||||
server.get('/rest/promotions/project-1/changes/promote', () => ({
|
||||
data: {
|
||||
commitSha: 'a'.repeat(40),
|
||||
changes: [
|
||||
{ id: 'workflow-1', name: 'Changed workflow', type: 'workflow', status: 'modified' },
|
||||
],
|
||||
},
|
||||
}));
|
||||
try {
|
||||
settingsStore.isModuleActive.mockReturnValue(true);
|
||||
settingsStore.settings = {
|
||||
...settingsStore.settings,
|
||||
envFeatureFlags: { N8N_ENV_FEAT_PROMOTIONS: 'true' },
|
||||
};
|
||||
projectsStore.currentProject = createTestProject({
|
||||
id: 'project-1',
|
||||
scopes: ['project:export'],
|
||||
});
|
||||
usersStore.currentUser = mock<IUser>({ globalScopes: ['gitConnection:push'] });
|
||||
const { findByTestId, queryByTestId } = renderComponent();
|
||||
|
||||
expect(await findByTestId('promotion-banner')).toHaveTextContent('1 change');
|
||||
await userEvent.click(await findByTestId('promotion-banner-link'));
|
||||
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
|
||||
name: 'promotionSelect',
|
||||
data: { projectId: 'project-1' },
|
||||
});
|
||||
|
||||
projectsStore.currentProject.scopes = [];
|
||||
await waitFor(() => expect(queryByTestId('promotion-banner')).not.toBeInTheDocument());
|
||||
projectsStore.currentProject.scopes = ['project:export'];
|
||||
expect(await findByTestId('promotion-banner')).toHaveTextContent('1 change');
|
||||
usersStore.currentUser = mock<IUser>({ globalScopes: [] });
|
||||
await waitFor(() => expect(queryByTestId('promotion-banner')).not.toBeInTheDocument());
|
||||
} finally {
|
||||
server.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('should not render title icon on overview page', async () => {
|
||||
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(true);
|
||||
const { container } = renderComponent();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useElementSize, useResizeObserver } from '@vueuse/core';
|
||||
import type { TabOptions, UserAction } from '@n8n/design-system';
|
||||
|
|
@ -12,7 +12,6 @@ import { getResourcePermissions } from '@n8n/permissions';
|
|||
import { EnterpriseEditionFeature, VIEWS } from '@/app/constants';
|
||||
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
|
||||
import ProjectCreateResource from './ProjectCreateResource.vue';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
|
||||
import { truncateTextToFitWidth } from '@/app/utils/formatters/textFormatter';
|
||||
|
|
@ -23,19 +22,9 @@ import { useUIStore } from '@/app/stores/ui.store';
|
|||
import { PROJECT_DATA_TABLES } from '@/features/core/dataTable/constants';
|
||||
import { useAgentPermissions } from '@/features/agents/composables/useAgentPermissions';
|
||||
import ReadyToRunButton from '@/features/workflows/readyToRun/components/ReadyToRunButton.vue';
|
||||
import { usePromotionsEnabled } from '@/features/shared/promotions/usePromotionsEnabled';
|
||||
import { PROMOTION_SELECT_MODAL_KEY } from '@/features/integrations/promotions.ee/promotions.constants';
|
||||
import { getPromotableChanges } from '@/features/integrations/promotions.ee/promotions.api';
|
||||
import PromotionBanners from '@/features/integrations/promotions.ee/components/PromotionBanners.vue';
|
||||
|
||||
import {
|
||||
N8nButton,
|
||||
N8nHeading,
|
||||
N8nIcon,
|
||||
N8nIconButton,
|
||||
N8nLink,
|
||||
N8nText,
|
||||
N8nTooltip,
|
||||
} from '@n8n/design-system';
|
||||
import { N8nButton, N8nHeading, N8nIconButton, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||
import { VARIABLE_MODAL_KEY } from '@/features/settings/environments.ee/environments.constants';
|
||||
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
||||
import { useCreateAgent } from '@/features/agents/composables/useCreateAgent';
|
||||
|
|
@ -53,40 +42,11 @@ const telemetry = useTelemetry();
|
|||
const { createAgent } = useCreateAgent();
|
||||
const usersStore = useUsersStore();
|
||||
const favoritesStore = useFavoritesStore();
|
||||
const { isEnabled: isPromotionsEnabled } = usePromotionsEnabled();
|
||||
const rootStore = useRootStore();
|
||||
|
||||
const currentProjectId = computed(() => projectsStore.currentProject?.id);
|
||||
|
||||
const isTeamProject = computed(() => projectsStore.currentProject?.type === ProjectTypes.Team);
|
||||
|
||||
const promotableChangeCount = ref(0);
|
||||
const showPromoteButton = computed(
|
||||
() =>
|
||||
isTeamProject.value &&
|
||||
isPromotionsEnabled.value &&
|
||||
!!projectPermissions.value.export &&
|
||||
!!getResourcePermissions(usersStore.currentUser?.globalScopes).gitConnection.push,
|
||||
);
|
||||
|
||||
async function fetchPromotableChangeCount() {
|
||||
// Capture the project this request is for, so a slow response for a project the
|
||||
// user already navigated away from cannot overwrite the current count.
|
||||
const requestedProjectId = currentProjectId.value;
|
||||
promotableChangeCount.value = 0;
|
||||
if (!showPromoteButton.value || !requestedProjectId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { changes } = await getPromotableChanges(rootStore.restApiContext, requestedProjectId);
|
||||
if (currentProjectId.value !== requestedProjectId) return;
|
||||
promotableChangeCount.value = changes.length;
|
||||
} catch {
|
||||
if (currentProjectId.value !== requestedProjectId) return;
|
||||
promotableChangeCount.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const isProjectFavorited = computed(() =>
|
||||
currentProjectId.value ? favoritesStore.isFavorite(currentProjectId.value, 'project') : false,
|
||||
);
|
||||
|
|
@ -176,21 +136,21 @@ const showFolders = computed(() => {
|
|||
);
|
||||
});
|
||||
|
||||
const customProjectTabs = computed((): Array<TabOptions<string>> => {
|
||||
// Determine the type of tab based on the current project page
|
||||
let tabType: 'shared' | 'overview' | 'project';
|
||||
const pageType = computed(() => {
|
||||
if (projectPages.isSharedSubPage) {
|
||||
tabType = 'shared';
|
||||
return 'shared';
|
||||
} else if (projectPages.isOverviewSubPage) {
|
||||
tabType = 'overview';
|
||||
return 'overview';
|
||||
} else {
|
||||
tabType = 'project';
|
||||
return 'project';
|
||||
}
|
||||
});
|
||||
|
||||
const customProjectTabs = computed((): Array<TabOptions<string>> => {
|
||||
// Only pick up tabs from active modules
|
||||
const activeModules = Object.keys(uiStore.moduleTabs[tabType]).filter(
|
||||
settingsStore.isModuleActive,
|
||||
);
|
||||
return activeModules.flatMap((module) => uiStore.moduleTabs[tabType][module]);
|
||||
const moduleTabs = uiStore.moduleTabs[pageType.value];
|
||||
const activeModules = Object.keys(moduleTabs).filter(settingsStore.isModuleActive);
|
||||
return activeModules.flatMap((module) => moduleTabs[module]);
|
||||
});
|
||||
|
||||
const ACTION_TYPES = {
|
||||
|
|
@ -430,16 +390,6 @@ const actions: Record<ActionTypes, (projectId: string, source: CreateSource) =>
|
|||
},
|
||||
} as const;
|
||||
|
||||
const pageType = computed(() => {
|
||||
if (projectPages.isSharedSubPage) {
|
||||
return 'shared';
|
||||
} else if (projectPages.isOverviewSubPage) {
|
||||
return 'overview';
|
||||
} else {
|
||||
return 'project';
|
||||
}
|
||||
});
|
||||
|
||||
const sectionDescription = computed(() => {
|
||||
if (projectPages.isSharedSubPage) {
|
||||
return i18n.baseText('projects.header.shared.subtitle');
|
||||
|
|
@ -503,25 +453,6 @@ const projectDescriptionTruncated = computed(() => {
|
|||
return truncateTextToFitWidth(projectDescription.value, availableTextWidth, fontSizeInPixels);
|
||||
});
|
||||
|
||||
const promotionBannerText = computed(() => {
|
||||
if (promotableChangeCount.value === 1) {
|
||||
return i18n.baseText('promotions.banner.singleChangeAvailable');
|
||||
}
|
||||
return i18n.baseText('promotions.banner.changesAvailable', {
|
||||
interpolate: { count: String(promotableChangeCount.value) },
|
||||
});
|
||||
});
|
||||
|
||||
watch([currentProjectId, showPromoteButton], fetchPromotableChangeCount, { immediate: true });
|
||||
|
||||
function onOpenPromotionModal() {
|
||||
if (!currentProjectId.value) return;
|
||||
uiStore.openModalWithData({
|
||||
name: PROMOTION_SELECT_MODAL_KEY,
|
||||
data: { projectId: currentProjectId.value },
|
||||
});
|
||||
}
|
||||
|
||||
const onSelect = (action: string, source: CreateSource) => {
|
||||
const executableAction = actions[action as ActionTypes];
|
||||
if (!homeProject.value) {
|
||||
|
|
@ -603,19 +534,7 @@ const onSelect = (action: string, source: CreateSource) => {
|
|||
:additional-tabs="customProjectTabs"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showPromoteButton && promotableChangeCount > 0"
|
||||
:class="$style.promotionBanner"
|
||||
data-test-id="promotion-banner"
|
||||
>
|
||||
<N8nIcon icon="upload" size="small" />
|
||||
<N8nText size="small">
|
||||
{{ promotionBannerText }}
|
||||
</N8nText>
|
||||
<N8nLink size="small" data-test-id="promotion-banner-link" @click="onOpenPromotionModal">
|
||||
{{ i18n.baseText('promotions.banner.viewChanges') }}
|
||||
</N8nLink>
|
||||
</div>
|
||||
<PromotionBanners />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -638,17 +557,6 @@ const onSelect = (action: string, source: CreateSource) => {
|
|||
padding: var(--spacing--2xs) 0 var(--spacing--xs);
|
||||
}
|
||||
|
||||
.promotionBanner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
padding: var(--spacing--xs) var(--spacing--sm);
|
||||
background-color: var(--background--hover);
|
||||
border: var(--border);
|
||||
border-radius: var(--radius--2xs);
|
||||
margin-bottom: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.projectDescriptionWrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { nextTick } from 'vue';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import { promotionEventBus } from '@/features/integrations/promotions.ee/promotions.eventBus';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { createProjectListItem, createTestProject } from '../__tests__/utils';
|
||||
|
|
@ -136,6 +139,29 @@ describe('ProjectsNavigation', () => {
|
|||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reload the projects after a package was applied', async () => {
|
||||
projectsStore.teamProjectsLimit = -1;
|
||||
renderComponent({ props: { collapsed: false } });
|
||||
// The listener registers once the users are fetched.
|
||||
await waitFor(() => expect(usersStore.fetchUsers).toHaveBeenCalled());
|
||||
await nextTick();
|
||||
|
||||
promotionEventBus.emit('applied', { projectId: 'project-1' });
|
||||
|
||||
await waitFor(() => expect(projectsStore.getMyProjects).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('should reload the projects after a package removed one', async () => {
|
||||
projectsStore.teamProjectsLimit = -1;
|
||||
renderComponent({ props: { collapsed: false } });
|
||||
await waitFor(() => expect(usersStore.fetchUsers).toHaveBeenCalled());
|
||||
await nextTick();
|
||||
|
||||
promotionEventBus.emit('projectRemoved', { projectId: 'project-1' });
|
||||
|
||||
await waitFor(() => expect(projectsStore.getMyProjects).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('should show "Projects" title and Personal project when the feature is enabled', async () => {
|
||||
projectsStore.teamProjectsLimit = -1;
|
||||
projectsStore.myProjects = [...personalProjects, ...teamProjects];
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { useGlobalEntityCreation } from '@/app/composables/useGlobalEntityCreation';
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import { sourceControlEventBus } from '@/features/integrations/sourceControl.ee/sourceControl.eventBus';
|
||||
import { promotionEventBus } from '@/features/integrations/promotions.ee/promotions.eventBus';
|
||||
import { useUsersStore } from '@n8n/stores/users.store';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { N8nIcon, N8nMenuItem, N8nText } from '@n8n/design-system';
|
||||
|
|
@ -186,18 +187,22 @@ const chat = computed<IMenuItem>(() => ({
|
|||
route: { to: { name: CHAT_VIEW } },
|
||||
}));
|
||||
|
||||
async function onSourceControlPull() {
|
||||
// Update myProjects for the sidebar display
|
||||
/** A pull or an applied package can create and delete projects behind the sidebar. */
|
||||
async function reloadMyProjects() {
|
||||
await projectsStore.getMyProjects();
|
||||
}
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await usersStore.fetchUsers({ filter: { isPending: false }, take: 2 });
|
||||
sourceControlEventBus.on('pull', onSourceControlPull);
|
||||
sourceControlEventBus.on('pull', reloadMyProjects);
|
||||
promotionEventBus.on('applied', reloadMyProjects);
|
||||
promotionEventBus.on('projectRemoved', reloadMyProjects);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
sourceControlEventBus.off('pull', onSourceControlPull);
|
||||
sourceControlEventBus.off('pull', reloadMyProjects);
|
||||
promotionEventBus.off('applied', reloadMyProjects);
|
||||
promotionEventBus.off('projectRemoved', reloadMyProjects);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
import { fireEvent, waitFor } from '@testing-library/vue';
|
||||
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
|
||||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import DataTableView from '@/features/core/dataTable/DataTableView.vue';
|
||||
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
|
||||
import { promotionEventBus } from '@/features/integrations/promotions.ee/promotions.eventBus';
|
||||
import { STORES } from '@n8n/stores';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
|
@ -190,6 +191,18 @@ describe('DataTableView', () => {
|
|||
|
||||
expect(mockToast.showError).toHaveBeenCalledWith(error, 'Error loading data tables');
|
||||
});
|
||||
|
||||
it('should reload the data tables after a package was applied', async () => {
|
||||
renderComponent({ pinia });
|
||||
await waitAllPromises();
|
||||
const fetches = dataTableStore.fetchDataTables.mock.calls.length;
|
||||
|
||||
promotionEventBus.emit('applied', { projectId: 'test-project' });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(dataTableStore.fetchDataTables.mock.calls.length).toBeGreaterThan(fetches),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty state', () => {
|
||||
|
|
|
|||
|
|
@ -18,9 +18,10 @@ import { useUIStore } from '@/app/stores/ui.store';
|
|||
import { useDataTableStore } from '@/features/core/dataTable/dataTable.store';
|
||||
import type { DataTableResource } from '@/features/core/dataTable/types';
|
||||
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
|
||||
import { promotionEventBus } from '@/features/integrations/promotions.ee/promotions.eventBus';
|
||||
import type { BaseFilters, SortingAndPaginationUpdates } from '@/Interface';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import ResourcesListLayout from '@/app/components/layouts/ResourcesListLayout.vue';
|
||||
|
|
@ -169,6 +170,11 @@ const onSearchUpdated = async (search: string) => {
|
|||
|
||||
onMounted(() => {
|
||||
documentTitle.set(i18n.baseText('dataTable.dataTables'));
|
||||
promotionEventBus.on('applied', fetchDataTables);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
promotionEventBus.off('applied', fetchDataTables);
|
||||
});
|
||||
|
||||
watch(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,291 @@
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import { createServer, Response } from 'miragejs';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import type { IUser } from '@n8n/rest-api-client';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { useUsersStore } from '@n8n/stores/users.store';
|
||||
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { createTestProject } from '@/features/collaboration/projects/__tests__/utils';
|
||||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
|
||||
import PromotionBanners from './PromotionBanners.vue';
|
||||
import { invalidateInstancePromotionConnection } from '../composables/useInstancePromotionConnection';
|
||||
import { promotionEventBus } from '../promotions.eventBus';
|
||||
import * as settingsApi from '../promotionsSettings.api';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/:projectId?', component: { template: '<div></div>' } },
|
||||
{ path: '/home/workflows', name: VIEWS.HOMEPAGE, component: { template: '<div></div>' } },
|
||||
],
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(PromotionBanners, {
|
||||
global: { plugins: [router] },
|
||||
});
|
||||
|
||||
const commitSha = 'a'.repeat(40);
|
||||
|
||||
const oneChange = (name: string, status: string) => ({
|
||||
data: { commitSha, changes: [{ id: 'workflow-1', name, type: 'workflow', status }] },
|
||||
});
|
||||
|
||||
const connections = (configs: Record<string, unknown>) => ({
|
||||
data: [{ id: 'connection-1', scope: 'instance', configs }],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
describe('PromotionBanners', () => {
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
||||
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
|
||||
let usersStore: ReturnType<typeof mockedStore<typeof useUsersStore>>;
|
||||
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await router.push('/project-1');
|
||||
await router.isReady();
|
||||
createTestingPinia();
|
||||
server = createServer({ environment: 'test' });
|
||||
projectsStore = mockedStore(useProjectsStore);
|
||||
settingsStore = mockedStore(useSettingsStore);
|
||||
usersStore = mockedStore(useUsersStore);
|
||||
uiStore = mockedStore(useUIStore);
|
||||
|
||||
settingsStore.isModuleActive.mockReturnValue(true);
|
||||
settingsStore.settings = {
|
||||
...settingsStore.settings,
|
||||
envFeatureFlags: { N8N_ENV_FEAT_PROMOTIONS: 'true' },
|
||||
};
|
||||
projectsStore.currentProject = createTestProject({
|
||||
id: 'project-1',
|
||||
scopes: ['project:export'],
|
||||
});
|
||||
// The store reads this from the route, which the mocked store cannot see.
|
||||
projectsStore.currentProjectId = 'project-1';
|
||||
// The connection is cached per page load; tests must not share it.
|
||||
invalidateInstancePromotionConnection();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.shutdown();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows outgoing changes only with export and push access', async () => {
|
||||
server.get('/api/v1/promotions/connections', () =>
|
||||
connections({ promote: { id: 'config-1' } }),
|
||||
);
|
||||
server.get('/rest/promotions/project-1/changes/promote', () =>
|
||||
oneChange('Changed workflow', 'modified'),
|
||||
);
|
||||
usersStore.currentUser = mock<IUser>({
|
||||
globalScopes: ['gitConnection:list', 'gitConnection:push'],
|
||||
});
|
||||
const { findByTestId, queryByTestId } = renderComponent();
|
||||
|
||||
expect(await findByTestId('promotion-banner')).toHaveTextContent('1 change');
|
||||
await userEvent.click(await findByTestId('promotion-banner-link'));
|
||||
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
|
||||
name: 'promotionSelect',
|
||||
data: { projectId: 'project-1', direction: 'promote' },
|
||||
});
|
||||
|
||||
// The store replaces the project object, so the change is reactive.
|
||||
projectsStore.currentProject = createTestProject({ id: 'project-1', scopes: [] });
|
||||
await waitFor(() => expect(queryByTestId('promotion-banner')).not.toBeInTheDocument());
|
||||
projectsStore.currentProject = createTestProject({
|
||||
id: 'project-1',
|
||||
scopes: ['project:export'],
|
||||
});
|
||||
expect(await findByTestId('promotion-banner')).toHaveTextContent('1 change');
|
||||
// Listing connections alone is not enough, the direction needs its own scope.
|
||||
usersStore.currentUser = mock<IUser>({ globalScopes: ['gitConnection:list'] });
|
||||
await waitFor(() => expect(queryByTestId('promotion-banner')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows nothing while the promotion feature is off', async () => {
|
||||
const connectionsRequest = vi.fn(() =>
|
||||
connections({
|
||||
promote: { id: 'config-1' },
|
||||
apply: { id: 'config-2', settings: { branchName: 'main' } },
|
||||
}),
|
||||
);
|
||||
server.get('/api/v1/promotions/connections', connectionsRequest);
|
||||
settingsStore.settings = { ...settingsStore.settings, envFeatureFlags: {} };
|
||||
usersStore.currentUser = mock<IUser>({
|
||||
globalScopes: ['gitConnection:list', 'gitConnection:push', 'gitConnection:pull'],
|
||||
});
|
||||
const { queryByTestId } = renderComponent();
|
||||
|
||||
await waitFor(() => expect(connectionsRequest).not.toHaveBeenCalled());
|
||||
expect(queryByTestId('promotion-banner')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('promotion-incoming-banner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows incoming changes only with pull access and an apply configuration', async () => {
|
||||
const promoteChanges = vi.fn(() => ({ data: { commitSha, changes: [] } }));
|
||||
server.get('/api/v1/promotions/connections', () =>
|
||||
connections({ apply: { id: 'config-1', settings: { branchName: 'main' } } }),
|
||||
);
|
||||
server.get('/rest/promotions/project-1/changes/promote', promoteChanges);
|
||||
server.get('/rest/promotions/project-1/changes/apply', () =>
|
||||
oneChange('Incoming workflow', 'new'),
|
||||
);
|
||||
usersStore.currentUser = mock<IUser>({
|
||||
globalScopes: ['gitConnection:list', 'gitConnection:pull'],
|
||||
});
|
||||
const { findByTestId, queryByTestId } = renderComponent();
|
||||
|
||||
expect(await findByTestId('promotion-incoming-banner')).toHaveTextContent('1 incoming change');
|
||||
expect(queryByTestId('promotion-banner')).not.toBeInTheDocument();
|
||||
// A destination without a promote configuration never asks for outgoing changes.
|
||||
expect(promoteChanges).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(await findByTestId('promotion-incoming-banner-link'));
|
||||
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
|
||||
name: 'promotionSelect',
|
||||
data: {
|
||||
projectId: 'project-1',
|
||||
direction: 'apply',
|
||||
apply: { connectionId: 'connection-1', configId: 'config-1', branchName: 'main' },
|
||||
},
|
||||
});
|
||||
|
||||
usersStore.currentUser = mock<IUser>({ globalScopes: ['gitConnection:list'] });
|
||||
await waitFor(() => expect(queryByTestId('promotion-incoming-banner')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('refetches both counts after a package was applied', async () => {
|
||||
const promoteChanges = vi.fn(() => oneChange('Outgoing workflow', 'modified'));
|
||||
const applyChanges = vi.fn(() => oneChange('Incoming workflow', 'new'));
|
||||
server.get('/api/v1/promotions/connections', () =>
|
||||
connections({
|
||||
promote: { id: 'config-1' },
|
||||
apply: { id: 'config-2', settings: { branchName: 'main' } },
|
||||
}),
|
||||
);
|
||||
server.get('/rest/promotions/project-1/changes/promote', promoteChanges);
|
||||
server.get('/rest/promotions/project-1/changes/apply', applyChanges);
|
||||
usersStore.currentUser = mock<IUser>({
|
||||
globalScopes: ['gitConnection:list', 'gitConnection:push', 'gitConnection:pull'],
|
||||
});
|
||||
const { findByTestId } = renderComponent();
|
||||
await findByTestId('promotion-banner');
|
||||
await findByTestId('promotion-incoming-banner');
|
||||
|
||||
const renamed = createTestProject({ id: 'project-1', name: 'Renamed' });
|
||||
promotionEventBus.emit('applied', { projectId: 'project-1', project: renamed });
|
||||
|
||||
await waitFor(() => expect(promoteChanges).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(applyChanges).toHaveBeenCalledTimes(2));
|
||||
// The header shows the project name, which the package may have changed.
|
||||
expect(projectsStore.setCurrentProject).toHaveBeenCalledWith(renamed);
|
||||
});
|
||||
|
||||
it('leaves the page when the applied package removed the project', async () => {
|
||||
server.get('/api/v1/promotions/connections', () =>
|
||||
connections({ apply: { id: 'config-1', settings: { branchName: 'main' } } }),
|
||||
);
|
||||
server.get('/rest/promotions/project-1/changes/apply', () =>
|
||||
oneChange('Incoming workflow', 'new'),
|
||||
);
|
||||
usersStore.currentUser = mock<IUser>({
|
||||
globalScopes: ['gitConnection:list', 'gitConnection:pull'],
|
||||
});
|
||||
const { findByTestId } = renderComponent();
|
||||
await findByTestId('promotion-incoming-banner');
|
||||
|
||||
promotionEventBus.emit('projectRemoved', { projectId: 'project-1' });
|
||||
|
||||
await waitFor(() => expect(router.currentRoute.value.name).toBe(VIEWS.HOMEPAGE));
|
||||
});
|
||||
|
||||
it('ignores results for a project the user already left', async () => {
|
||||
server.get('/api/v1/promotions/connections', () =>
|
||||
connections({ apply: { id: 'config-1', settings: { branchName: 'main' } } }),
|
||||
);
|
||||
server.get('/rest/promotions/:projectId/changes/apply', () =>
|
||||
oneChange('Incoming workflow', 'new'),
|
||||
);
|
||||
usersStore.currentUser = mock<IUser>({
|
||||
globalScopes: ['gitConnection:list', 'gitConnection:pull'],
|
||||
});
|
||||
const { findByTestId } = renderComponent();
|
||||
await findByTestId('promotion-incoming-banner');
|
||||
// The route moves at once, the store still holds the old project until it loaded.
|
||||
await router.push('/project-2');
|
||||
projectsStore.currentProjectId = 'project-2';
|
||||
|
||||
promotionEventBus.emit('applied', {
|
||||
projectId: 'project-1',
|
||||
project: createTestProject({ id: 'project-1', name: 'Renamed' }),
|
||||
});
|
||||
promotionEventBus.emit('projectRemoved', { projectId: 'project-1' });
|
||||
await waitAllPromises();
|
||||
|
||||
expect(projectsStore.setCurrentProject).not.toHaveBeenCalled();
|
||||
expect(router.currentRoute.value.params.projectId).toBe('project-2');
|
||||
});
|
||||
|
||||
it('retries a failed connection lookup on the next project', async () => {
|
||||
const lookup = vi
|
||||
.spyOn(settingsApi, 'fetchPromotionConnections')
|
||||
.mockRejectedValueOnce(new Error('offline'));
|
||||
const connectionsRequest = vi.fn(() =>
|
||||
connections({ apply: { id: 'config-1', settings: { branchName: 'main' } } }),
|
||||
);
|
||||
server.get('/api/v1/promotions/connections', connectionsRequest);
|
||||
server.get('/rest/promotions/project-2/changes/apply', () =>
|
||||
oneChange('Incoming workflow', 'new'),
|
||||
);
|
||||
usersStore.currentUser = mock<IUser>({
|
||||
globalScopes: ['gitConnection:list', 'gitConnection:pull'],
|
||||
});
|
||||
const { findByTestId, queryByTestId } = renderComponent();
|
||||
await waitFor(() => expect(lookup).toHaveBeenCalledTimes(1));
|
||||
// Let the rejection settle, a switch while the lookup is in flight joins the same request.
|
||||
await waitAllPromises();
|
||||
expect(connectionsRequest).not.toHaveBeenCalled();
|
||||
expect(queryByTestId('promotion-incoming-banner')).not.toBeInTheDocument();
|
||||
|
||||
projectsStore.currentProject = createTestProject({
|
||||
id: 'project-2',
|
||||
scopes: ['project:export'],
|
||||
});
|
||||
|
||||
expect(await findByTestId('promotion-incoming-banner')).toHaveTextContent('1 incoming change');
|
||||
expect(connectionsRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the incoming changes entry visible when the check fails', async () => {
|
||||
server.get('/api/v1/promotions/connections', () =>
|
||||
connections({ apply: { id: 'config-1', settings: { branchName: 'main' } } }),
|
||||
);
|
||||
server.get(
|
||||
'/rest/promotions/project-1/changes/apply',
|
||||
() => new Response(400, {}, { message: 'The apply direction is not cloned' }),
|
||||
);
|
||||
usersStore.currentUser = mock<IUser>({
|
||||
globalScopes: ['gitConnection:list', 'gitConnection:pull'],
|
||||
});
|
||||
const { findByTestId } = renderComponent();
|
||||
|
||||
expect(await findByTestId('promotion-incoming-banner')).toHaveTextContent(
|
||||
'Could not check for incoming changes',
|
||||
);
|
||||
// The modal shows the error and offers a retry.
|
||||
await userEvent.click(await findByTestId('promotion-incoming-banner-link'));
|
||||
expect(uiStore.openModalWithData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'promotionSelect' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { getResourcePermissions } from '@n8n/permissions';
|
||||
import { N8nIcon, N8nLink, N8nText } from '@n8n/design-system';
|
||||
import { useUsersStore } from '@n8n/stores/users.store';
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import { ProjectTypes } from '@/features/collaboration/projects/projects.types';
|
||||
import { usePromotionsEnabled } from '@/features/shared/promotions/usePromotionsEnabled';
|
||||
import { PROMOTION_SELECT_MODAL_KEY } from '../promotions.constants';
|
||||
import { promotionEventBus, type PromotionEventBusEvents } from '../promotions.eventBus';
|
||||
import { useInstancePromotionConnection } from '../composables/useInstancePromotionConnection';
|
||||
import { usePromotionChangeCount } from '../composables/usePromotionChangeCount';
|
||||
|
||||
const i18n = useI18n();
|
||||
const router = useRouter();
|
||||
const uiStore = useUIStore();
|
||||
const usersStore = useUsersStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const { isEnabled: isPromotionsEnabled } = usePromotionsEnabled();
|
||||
|
||||
const currentProjectId = computed(() => projectsStore.currentProject?.id);
|
||||
const isTeamProject = computed(() => projectsStore.currentProject?.type === ProjectTypes.Team);
|
||||
const projectPermissions = computed(
|
||||
() => getResourcePermissions(projectsStore.currentProject?.scopes).project,
|
||||
);
|
||||
const gitConnectionPermissions = computed(
|
||||
() => getResourcePermissions(usersStore.currentUser?.globalScopes).gitConnection,
|
||||
);
|
||||
|
||||
// Each banner needs the direction configured on the instance connection, so a
|
||||
// source never asks for incoming changes and a destination never asks for outgoing ones.
|
||||
const {
|
||||
connection,
|
||||
hasPromoteConfig,
|
||||
hasApplyConfig,
|
||||
load: loadConnection,
|
||||
} = useInstancePromotionConnection();
|
||||
|
||||
const canPreviewChanges = computed(
|
||||
() =>
|
||||
isTeamProject.value &&
|
||||
isPromotionsEnabled.value &&
|
||||
!!projectPermissions.value.export &&
|
||||
!!gitConnectionPermissions.value.list &&
|
||||
(!!gitConnectionPermissions.value.push || !!gitConnectionPermissions.value.pull),
|
||||
);
|
||||
const showPromoteBanner = computed(
|
||||
() => canPreviewChanges.value && hasPromoteConfig.value && !!gitConnectionPermissions.value.push,
|
||||
);
|
||||
|
||||
const showIncomingBanner = computed(
|
||||
() => canPreviewChanges.value && hasApplyConfig.value && !!gitConnectionPermissions.value.pull,
|
||||
);
|
||||
|
||||
watch(
|
||||
[canPreviewChanges, currentProjectId],
|
||||
async ([canPreview]) => {
|
||||
if (canPreview) await loadConnection();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const { count: promotableChangeCount, refetch: refetchPromotable } = usePromotionChangeCount(
|
||||
currentProjectId,
|
||||
'promote',
|
||||
showPromoteBanner,
|
||||
);
|
||||
const {
|
||||
count: incomingChangeCount,
|
||||
failed: incomingCheckFailed,
|
||||
refetch: refetchIncoming,
|
||||
} = usePromotionChangeCount(currentProjectId, 'apply', showIncomingBanner);
|
||||
|
||||
// The store's project id follows the route at once, the current project only once it loaded,
|
||||
// so a result for a project the user already left is dropped here.
|
||||
async function onPromotionApplied({ projectId, project }: PromotionEventBusEvents['applied']) {
|
||||
if (project && projectsStore.currentProjectId === projectId) {
|
||||
projectsStore.setCurrentProject(project);
|
||||
}
|
||||
await Promise.all([refetchPromotable(), refetchIncoming()]);
|
||||
}
|
||||
|
||||
async function onProjectRemoved({ projectId }: PromotionEventBusEvents['projectRemoved']) {
|
||||
if (projectsStore.currentProjectId !== projectId) return;
|
||||
await router.replace({ name: VIEWS.HOMEPAGE });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
promotionEventBus.on('applied', onPromotionApplied);
|
||||
promotionEventBus.on('projectRemoved', onProjectRemoved);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
promotionEventBus.off('applied', onPromotionApplied);
|
||||
promotionEventBus.off('projectRemoved', onProjectRemoved);
|
||||
});
|
||||
|
||||
const promotionBannerText = computed(() => {
|
||||
if (promotableChangeCount.value === 1) {
|
||||
return i18n.baseText('promotions.banner.singleChangeAvailable');
|
||||
}
|
||||
return i18n.baseText('promotions.banner.changesAvailable', {
|
||||
interpolate: { count: String(promotableChangeCount.value) },
|
||||
});
|
||||
});
|
||||
|
||||
const incomingBannerText = computed(() => {
|
||||
if (incomingCheckFailed.value) return i18n.baseText('promotions.banner.incoming.error');
|
||||
if (incomingChangeCount.value === 1) {
|
||||
return i18n.baseText('promotions.banner.incoming.singleChangeAvailable');
|
||||
}
|
||||
return i18n.baseText('promotions.banner.incoming.changesAvailable', {
|
||||
interpolate: { count: String(incomingChangeCount.value) },
|
||||
});
|
||||
});
|
||||
|
||||
function onOpenPromotionModal() {
|
||||
if (!currentProjectId.value) return;
|
||||
uiStore.openModalWithData({
|
||||
name: PROMOTION_SELECT_MODAL_KEY,
|
||||
data: { projectId: currentProjectId.value, direction: 'promote' },
|
||||
});
|
||||
}
|
||||
|
||||
function onOpenIncomingModal() {
|
||||
const applyConfig = connection.value?.configs.apply;
|
||||
if (!currentProjectId.value || !connection.value || !applyConfig) return;
|
||||
uiStore.openModalWithData({
|
||||
name: PROMOTION_SELECT_MODAL_KEY,
|
||||
data: {
|
||||
projectId: currentProjectId.value,
|
||||
direction: 'apply',
|
||||
apply: {
|
||||
connectionId: connection.value.id,
|
||||
configId: applyConfig.id,
|
||||
branchName: applyConfig.settings.branchName,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="showPromoteBanner && promotableChangeCount > 0"
|
||||
:class="$style.banner"
|
||||
data-test-id="promotion-banner"
|
||||
>
|
||||
<N8nIcon icon="upload" size="small" />
|
||||
<N8nText size="small">
|
||||
{{ promotionBannerText }}
|
||||
</N8nText>
|
||||
<N8nLink size="small" data-test-id="promotion-banner-link" @click="onOpenPromotionModal">
|
||||
{{ i18n.baseText('promotions.banner.viewChanges') }}
|
||||
</N8nLink>
|
||||
</div>
|
||||
<div
|
||||
v-if="showIncomingBanner && (incomingChangeCount > 0 || incomingCheckFailed)"
|
||||
:class="$style.banner"
|
||||
data-test-id="promotion-incoming-banner"
|
||||
>
|
||||
<N8nIcon :icon="incomingCheckFailed ? 'triangle-alert' : 'download'" size="small" />
|
||||
<N8nText size="small">
|
||||
{{ incomingBannerText }}
|
||||
</N8nText>
|
||||
<N8nLink
|
||||
size="small"
|
||||
data-test-id="promotion-incoming-banner-link"
|
||||
@click="onOpenIncomingModal"
|
||||
>
|
||||
{{ i18n.baseText('promotions.banner.viewChanges') }}
|
||||
</N8nLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
padding: var(--spacing--xs) var(--spacing--sm);
|
||||
background-color: var(--background--hover);
|
||||
border: var(--border);
|
||||
border-radius: var(--radius--2xs);
|
||||
margin-bottom: var(--spacing--xs);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,12 +1,33 @@
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { createTestProject } from '@/features/collaboration/projects/__tests__/utils';
|
||||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import PromotionSelectModal from './PromotionSelectModal.vue';
|
||||
import { PROMOTION_SELECT_MODAL_KEY } from '../promotions.constants';
|
||||
import { promotionEventBus } from '../promotions.eventBus';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { createServer, Response } from 'miragejs';
|
||||
import { createServer, Response, type Request } from 'miragejs';
|
||||
import { ResponseError } from '@n8n/rest-api-client';
|
||||
import { useUsersStore } from '@n8n/stores/users.store';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import { MODAL_CANCEL, MODAL_CONFIRM } from '@/app/constants/modals';
|
||||
|
||||
const { confirm, showMessage, showError } = vi.hoisted(() => ({
|
||||
confirm: vi.fn(),
|
||||
showMessage: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/useMessage', () => ({
|
||||
useMessage: () => ({ confirm }),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/composables/useToast', () => ({
|
||||
useToast: () => ({ showMessage, showError }),
|
||||
}));
|
||||
|
||||
const mockChanges = [
|
||||
{
|
||||
|
|
@ -81,7 +102,7 @@ describe('PromotionSelectModal', () => {
|
|||
pinia,
|
||||
props: {
|
||||
modalName: PROMOTION_SELECT_MODAL_KEY,
|
||||
data: { projectId: 'project-1' },
|
||||
data: { projectId: 'project-1', direction: 'promote' },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -98,7 +119,7 @@ describe('PromotionSelectModal', () => {
|
|||
pinia,
|
||||
props: {
|
||||
modalName: PROMOTION_SELECT_MODAL_KEY,
|
||||
data: { projectId: 'project-1' },
|
||||
data: { projectId: 'project-1', direction: 'promote' },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -111,7 +132,7 @@ describe('PromotionSelectModal', () => {
|
|||
pinia,
|
||||
props: {
|
||||
modalName: PROMOTION_SELECT_MODAL_KEY,
|
||||
data: { projectId: 'project-1' },
|
||||
data: { projectId: 'project-1', direction: 'promote' },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -128,7 +149,7 @@ describe('PromotionSelectModal', () => {
|
|||
pinia,
|
||||
props: {
|
||||
modalName: PROMOTION_SELECT_MODAL_KEY,
|
||||
data: { projectId: 'project-1' },
|
||||
data: { projectId: 'project-1', direction: 'promote' },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -146,7 +167,7 @@ describe('PromotionSelectModal', () => {
|
|||
pinia,
|
||||
props: {
|
||||
modalName: PROMOTION_SELECT_MODAL_KEY,
|
||||
data: { projectId: 'project-1' },
|
||||
data: { projectId: 'project-1', direction: 'promote' },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -160,7 +181,7 @@ describe('PromotionSelectModal', () => {
|
|||
pinia,
|
||||
props: {
|
||||
modalName: PROMOTION_SELECT_MODAL_KEY,
|
||||
data: { projectId: 'project-1' },
|
||||
data: { projectId: 'project-1', direction: 'promote' },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -192,7 +213,7 @@ describe('PromotionSelectModal', () => {
|
|||
pinia,
|
||||
props: {
|
||||
modalName: PROMOTION_SELECT_MODAL_KEY,
|
||||
data: { projectId: 'project-1' },
|
||||
data: { projectId: 'project-1', direction: 'promote' },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -214,7 +235,7 @@ describe('PromotionSelectModal', () => {
|
|||
pinia,
|
||||
props: {
|
||||
modalName: PROMOTION_SELECT_MODAL_KEY,
|
||||
data: { projectId: 'project-1' },
|
||||
data: { projectId: 'project-1', direction: 'promote' },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -256,7 +277,7 @@ describe('PromotionSelectModal', () => {
|
|||
pinia,
|
||||
props: {
|
||||
modalName: PROMOTION_SELECT_MODAL_KEY,
|
||||
data: { projectId: 'project-1' },
|
||||
data: { projectId: 'project-1', direction: 'promote' },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -265,4 +286,213 @@ describe('PromotionSelectModal', () => {
|
|||
expect(statuses).toEqual(['Will be archived', 'Will be deleted']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apply direction', () => {
|
||||
const applyProps = {
|
||||
modalName: PROMOTION_SELECT_MODAL_KEY,
|
||||
data: {
|
||||
projectId: 'project-1',
|
||||
direction: 'apply' as const,
|
||||
apply: { connectionId: 'connection-1', configId: 'config-1', branchName: 'main' },
|
||||
},
|
||||
};
|
||||
const applyChanges = vi.fn(() => ({ data: changesBody(mockChanges) }));
|
||||
const publishing = { published: 2, unpublished: 0, unchanged: 0, blocked: 0, failed: 0 };
|
||||
const applyPackage = vi.fn((_schema: unknown, request: Request) => ({
|
||||
receivedBody: JSON.parse(request.requestBody),
|
||||
connectionId: 'connection-1',
|
||||
configId: 'config-1',
|
||||
status: 'applied',
|
||||
counts: { workflows: { created: 1, updated: 1, archived: 0, deleted: 0, publishing } },
|
||||
warnings: [],
|
||||
git: { commitSha: 'a'.repeat(40), branchName: 'main' },
|
||||
}));
|
||||
|
||||
let applied: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
server.get('/rest/promotions/project-1/changes/apply', applyChanges);
|
||||
server.post('/api/v1/promotions/connections/connection-1/apply', applyPackage);
|
||||
confirm.mockResolvedValue(MODAL_CONFIRM);
|
||||
applied = vi.spyOn(promotionEventBus, 'emit');
|
||||
});
|
||||
|
||||
it('should offer to apply all changes instead of promoting', async () => {
|
||||
const { findByTestId, queryByTestId } = renderComponent({ pinia, props: applyProps });
|
||||
|
||||
expect(await findByTestId('promotion-apply-all')).toHaveTextContent('Apply all changes');
|
||||
expect(queryByTestId('promotion-submit')).not.toBeInTheDocument();
|
||||
expect(applyChanges).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not apply when the user cancels the confirmation', async () => {
|
||||
confirm.mockResolvedValue(MODAL_CANCEL);
|
||||
const { findByTestId, findByText } = renderComponent({ pinia, props: applyProps });
|
||||
await findByText('Payment Handler');
|
||||
|
||||
await userEvent.click(await findByTestId('promotion-apply-all'));
|
||||
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
expect(applyPackage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should apply the branch, report the counts and close the modal', async () => {
|
||||
const project = createTestProject({ id: 'project-1', name: 'Renamed' });
|
||||
mockedStore(useProjectsStore).fetchProject.mockResolvedValue(project);
|
||||
const { findByTestId, findByText } = renderComponent({ pinia, props: applyProps });
|
||||
await findByText('Payment Handler');
|
||||
|
||||
await userEvent.click(await findByTestId('promotion-apply-all'));
|
||||
|
||||
await waitFor(() => expect(applyPackage).toHaveBeenCalledTimes(1));
|
||||
// The reviewed commit travels with the request, so a moved branch is not applied blindly.
|
||||
expect(applyPackage.mock.results[0].value.receivedBody).toEqual({
|
||||
expectedSource: { configId: 'config-1', branchName: 'main', commitSha: 'a'.repeat(40) },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(showMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'success',
|
||||
message: '1 created, 1 updated, 0 archived, 0 deleted.',
|
||||
}),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(useUIStore().closeModal).toHaveBeenCalledWith(PROMOTION_SELECT_MODAL_KEY),
|
||||
);
|
||||
// A closed modal has no list to refresh; the open views learn about the apply instead.
|
||||
expect(applyChanges).toHaveBeenCalledTimes(1);
|
||||
expect(applied).toHaveBeenCalledWith('applied', { projectId: 'project-1', project });
|
||||
});
|
||||
|
||||
it('should warn when applied workflows could not be published', async () => {
|
||||
server.post('/api/v1/promotions/connections/connection-1/apply', () => ({
|
||||
connectionId: 'connection-1',
|
||||
configId: 'config-1',
|
||||
status: 'applied',
|
||||
counts: {
|
||||
workflows: {
|
||||
created: 2,
|
||||
updated: 0,
|
||||
archived: 0,
|
||||
deleted: 0,
|
||||
publishing: { ...publishing, published: 0, blocked: 1, failed: 1 },
|
||||
},
|
||||
},
|
||||
warnings: [],
|
||||
git: { commitSha: 'a'.repeat(40), branchName: 'main' },
|
||||
}));
|
||||
const { findByTestId, findByText } = renderComponent({ pinia, props: applyProps });
|
||||
await findByText('Payment Handler');
|
||||
|
||||
await userEvent.click(await findByTestId('promotion-apply-all'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(showMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'warning',
|
||||
title: 'Changes applied',
|
||||
message: '2 created, 0 updated, 0 archived, 0 deleted. 2 could not be published.',
|
||||
}),
|
||||
),
|
||||
);
|
||||
// The import went through, so the views still refresh and the modal closes.
|
||||
expect(applied).toHaveBeenCalledWith(
|
||||
'applied',
|
||||
expect.objectContaining({ projectId: 'project-1' }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(useUIStore().closeModal).toHaveBeenCalledWith(PROMOTION_SELECT_MODAL_KEY),
|
||||
);
|
||||
});
|
||||
|
||||
it('should announce a removed project instead of an applied one', async () => {
|
||||
mockedStore(useProjectsStore).fetchProject.mockRejectedValue(
|
||||
new ResponseError('Not found', { httpStatusCode: 404 }),
|
||||
);
|
||||
const { findByTestId, findByText } = renderComponent({ pinia, props: applyProps });
|
||||
await findByText('Payment Handler');
|
||||
|
||||
await userEvent.click(await findByTestId('promotion-apply-all'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(applied).toHaveBeenCalledWith('projectRemoved', { projectId: 'project-1' }),
|
||||
);
|
||||
expect(applied).not.toHaveBeenCalledWith('applied', expect.anything());
|
||||
expect(showMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Apply removed this project from the instance.',
|
||||
type: 'info',
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(useUIStore().closeModal).toHaveBeenCalledWith(PROMOTION_SELECT_MODAL_KEY),
|
||||
);
|
||||
});
|
||||
|
||||
it('should still announce the apply when the project lookup fails', async () => {
|
||||
mockedStore(useProjectsStore).fetchProject.mockRejectedValue(new Error('offline'));
|
||||
const { findByTestId, findByText } = renderComponent({ pinia, props: applyProps });
|
||||
await findByText('Payment Handler');
|
||||
|
||||
await userEvent.click(await findByTestId('promotion-apply-all'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(applied).toHaveBeenCalledWith('applied', { projectId: 'project-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
result: { status: 'blocked', preflight: {} },
|
||||
message:
|
||||
'Some credentials or variables are not set up on this instance yet. Nothing was changed.',
|
||||
},
|
||||
{
|
||||
result: { status: 'source-changed' },
|
||||
message: 'The source changed since this preview. Refresh and review the changes again.',
|
||||
},
|
||||
])(
|
||||
'should warn and keep the modal open when apply pauses with $result.status',
|
||||
async ({ result, message }) => {
|
||||
server.post('/api/v1/promotions/connections/connection-1/apply', () => ({
|
||||
connectionId: 'connection-1',
|
||||
configId: 'config-1',
|
||||
git: { commitSha: 'a'.repeat(40), branchName: 'main' },
|
||||
...result,
|
||||
}));
|
||||
const { findByTestId, findByText } = renderComponent({ pinia, props: applyProps });
|
||||
await findByText('Payment Handler');
|
||||
|
||||
await userEvent.click(await findByTestId('promotion-apply-all'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(showMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'warning', title: 'Apply paused', message }),
|
||||
),
|
||||
);
|
||||
expect(showMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
|
||||
await waitFor(() => expect(applyChanges).toHaveBeenCalledTimes(2));
|
||||
expect(useUIStore().closeModal).not.toHaveBeenCalled();
|
||||
expect(applied).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('should show the error and still refetch the changes when apply fails', async () => {
|
||||
server.post(
|
||||
'/api/v1/promotions/connections/connection-1/apply',
|
||||
() => new Response(409, {}, { message: 'Bindings unresolved' }),
|
||||
);
|
||||
const { findByTestId, findByText } = renderComponent({ pinia, props: applyProps });
|
||||
await findByText('Payment Handler');
|
||||
|
||||
await userEvent.click(await findByTestId('promotion-apply-all'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(showError).toHaveBeenCalledWith(expect.anything(), 'Could not apply the changes'),
|
||||
);
|
||||
await waitFor(() => expect(applyChanges).toHaveBeenCalledTimes(2));
|
||||
expect(showMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,31 +1,51 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useUsersStore } from '@n8n/stores/users.store';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { ResponseError } from '@n8n/rest-api-client';
|
||||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { useMessage } from '@/app/composables/useMessage';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { MODAL_CONFIRM } from '@/app/constants/modals';
|
||||
import Modal from '@/app/components/Modal.vue';
|
||||
import TimeAgo from '@/app/components/TimeAgo.vue';
|
||||
import { N8nButton, N8nCheckbox, N8nInput, N8nText } from '@n8n/design-system';
|
||||
import type { PromotableResourceStatus } from '@n8n/api-types';
|
||||
import type { PromotableResourceStatus, PromotionDirection } from '@n8n/api-types';
|
||||
import { usePromotionChanges } from '../composables/usePromotionChanges';
|
||||
import { promotionEventBus } from '../promotions.eventBus';
|
||||
import { applyPromotion } from '../promotionsSettings.api';
|
||||
|
||||
interface Props {
|
||||
modalName: string;
|
||||
data: {
|
||||
projectId: string;
|
||||
direction: PromotionDirection;
|
||||
/** The instance connection and its Apply config. Only the `apply` direction needs it. */
|
||||
apply?: { connectionId: string; configId: string; branchName: string };
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
const i18n = useI18n();
|
||||
const uiStore = useUIStore();
|
||||
const usersStore = useUsersStore();
|
||||
const rootStore = useRootStore();
|
||||
const message = useMessage();
|
||||
const toast = useToast();
|
||||
const modalBus = createEventBus();
|
||||
|
||||
const { direction } = props.data;
|
||||
const isIncoming = direction === 'apply';
|
||||
const isApplying = ref(false);
|
||||
|
||||
const {
|
||||
changes,
|
||||
commitSha,
|
||||
filteredChanges,
|
||||
isLoading,
|
||||
error,
|
||||
|
|
@ -37,7 +57,17 @@ const {
|
|||
fetchChanges,
|
||||
toggleSelected,
|
||||
toggleSelectAll,
|
||||
} = usePromotionChanges(props.data.projectId);
|
||||
} = usePromotionChanges(props.data.projectId, direction);
|
||||
|
||||
const title = i18n.baseText(
|
||||
isIncoming ? 'promotions.modal.incoming.title' : 'promotions.modal.title',
|
||||
);
|
||||
const emptyTitle = i18n.baseText(
|
||||
isIncoming ? 'promotions.modal.incoming.empty' : 'promotions.modal.empty',
|
||||
);
|
||||
const emptyDescription = i18n.baseText(
|
||||
isIncoming ? 'promotions.modal.incoming.empty.description' : 'promotions.modal.empty.description',
|
||||
);
|
||||
|
||||
// No visible rows despite a loaded, non-empty change set means the search excluded everything.
|
||||
const hasNoSearchResults = computed(
|
||||
|
|
@ -92,6 +122,92 @@ async function onRefresh() {
|
|||
await fetchChanges();
|
||||
}
|
||||
|
||||
// The open views refresh once the outcome for this project is known, so none of them
|
||||
// asks for a project the package removed.
|
||||
async function announceApplied() {
|
||||
const { projectId } = props.data;
|
||||
try {
|
||||
const project = await projectsStore.fetchProject(projectId);
|
||||
promotionEventBus.emit('applied', { projectId, project });
|
||||
} catch (error) {
|
||||
if (error instanceof ResponseError && error.httpStatusCode === 404) {
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('promotions.applied.projectRemoved'),
|
||||
type: 'info',
|
||||
});
|
||||
promotionEventBus.emit('projectRemoved', { projectId });
|
||||
return;
|
||||
}
|
||||
// The apply went through, so the views still refresh. Only the header keeps its name.
|
||||
promotionEventBus.emit('applied', { projectId });
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies the whole branch. The selection is kept for the selective apply that follows. */
|
||||
async function onApplyAll() {
|
||||
const { apply } = props.data;
|
||||
if (!apply) return;
|
||||
const confirmed = await message.confirm(
|
||||
i18n.baseText('promotions.modal.incoming.confirm.message'),
|
||||
i18n.baseText('promotions.modal.incoming.confirm.title'),
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: i18n.baseText('promotions.modal.incoming.confirm.confirmButtonText'),
|
||||
cancelButtonText: i18n.baseText('promotions.modal.close'),
|
||||
},
|
||||
);
|
||||
if (confirmed !== MODAL_CONFIRM) return;
|
||||
isApplying.value = true;
|
||||
try {
|
||||
// Pin the reviewed commit: a branch that moved since the preview is reported, not applied.
|
||||
const expectedSource = commitSha.value
|
||||
? { configId: apply.configId, branchName: apply.branchName, commitSha: commitSha.value }
|
||||
: undefined;
|
||||
const result = await applyPromotion(
|
||||
rootStore.publicApiContext,
|
||||
apply.connectionId,
|
||||
expectedSource && { expectedSource },
|
||||
);
|
||||
if (result.status === 'applied') {
|
||||
const { workflows } = result.counts;
|
||||
const notPublished = workflows.publishing.failed + workflows.publishing.blocked;
|
||||
const summary = i18n.baseText('promotions.modal.incoming.applied.message', {
|
||||
interpolate: {
|
||||
created: String(workflows.created),
|
||||
updated: String(workflows.updated),
|
||||
archived: String(workflows.archived),
|
||||
deleted: String(workflows.deleted),
|
||||
},
|
||||
});
|
||||
// A workflow can be imported and still fail to publish, so success alone would mislead.
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('promotions.modal.incoming.applied.title'),
|
||||
message: notPublished
|
||||
? `${summary} ${i18n.baseText('promotions.modal.incoming.applied.notPublished', {
|
||||
interpolate: { count: String(notPublished) },
|
||||
})}`
|
||||
: summary,
|
||||
type: notPublished ? 'warning' : 'success',
|
||||
});
|
||||
await announceApplied();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
// Apply pauses on unresolved bindings or a moved source. The binding screen comes with LIGO-1058.
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('promotions.modal.incoming.paused.title'),
|
||||
message: i18n.baseText(`promotions.modal.incoming.paused.${result.status}`),
|
||||
type: 'warning',
|
||||
});
|
||||
} catch (applyError) {
|
||||
toast.showError(applyError, i18n.baseText('promotions.modal.incoming.applyError'));
|
||||
} finally {
|
||||
isApplying.value = false;
|
||||
}
|
||||
// The modal stays open after a paused or failed apply, so the list must show what is left.
|
||||
await fetchChanges();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchChanges();
|
||||
});
|
||||
|
|
@ -100,7 +216,7 @@ onMounted(async () => {
|
|||
<template>
|
||||
<Modal
|
||||
:name="modalName"
|
||||
:title="i18n.baseText('promotions.modal.title')"
|
||||
:title="title"
|
||||
:event-bus="modalBus"
|
||||
width="640px"
|
||||
height="80vh"
|
||||
|
|
@ -162,10 +278,10 @@ onMounted(async () => {
|
|||
<template v-else-if="changes.length === 0">
|
||||
<div :class="$style.empty">
|
||||
<N8nText size="medium" bold>
|
||||
{{ i18n.baseText('promotions.modal.empty') }}
|
||||
{{ emptyTitle }}
|
||||
</N8nText>
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('promotions.modal.empty.description') }}
|
||||
{{ emptyDescription }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -248,7 +364,14 @@ onMounted(async () => {
|
|||
<template #footer>
|
||||
<div :class="$style.footer">
|
||||
<div :class="$style.footerLeft">
|
||||
<N8nText size="small" color="text-light">
|
||||
<N8nText v-if="isIncoming && selectedCount > 0" size="small" color="text-light">
|
||||
{{
|
||||
i18n.baseText('promotions.modal.incoming.selected', {
|
||||
interpolate: { count: String(selectedCount) },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
<N8nText v-else-if="!isIncoming" size="small" color="text-light">
|
||||
{{ i18n.baseText('promotions.modal.previewOnly') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
|
@ -256,7 +379,16 @@ onMounted(async () => {
|
|||
<N8nButton variant="subtle" @click="onClose">
|
||||
{{ i18n.baseText('promotions.modal.close') }}
|
||||
</N8nButton>
|
||||
<N8nButton disabled data-test-id="promotion-submit">
|
||||
<N8nButton
|
||||
v-if="isIncoming"
|
||||
:loading="isApplying"
|
||||
:disabled="isLoading || !!error"
|
||||
data-test-id="promotion-apply-all"
|
||||
@click="onApplyAll"
|
||||
>
|
||||
{{ i18n.baseText('promotions.modal.incoming.applyAll') }}
|
||||
</N8nButton>
|
||||
<N8nButton v-else disabled data-test-id="promotion-submit">
|
||||
{{ getPromoteButtonLabel() }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
invalidateInstancePromotionConnection,
|
||||
useInstancePromotionConnection,
|
||||
} from './useInstancePromotionConnection';
|
||||
import * as api from '../promotionsSettings.api';
|
||||
|
||||
vi.mock('@n8n/stores/useRootStore', () => ({
|
||||
useRootStore: () => ({ publicApiContext: {} }),
|
||||
}));
|
||||
|
||||
vi.mock('../promotionsSettings.api');
|
||||
|
||||
const connection = {
|
||||
id: 'connection-1',
|
||||
scope: 'instance',
|
||||
configs: { apply: { id: 'config-1' } },
|
||||
} as unknown as api.PromotionConnection;
|
||||
|
||||
describe('useInstancePromotionConnection', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.fetchPromotionConnections).mockReset();
|
||||
invalidateInstancePromotionConnection();
|
||||
});
|
||||
|
||||
it('should fetch the instance connection once for every caller', async () => {
|
||||
vi.mocked(api.fetchPromotionConnections).mockResolvedValue([connection]);
|
||||
const header = useInstancePromotionConnection();
|
||||
const modal = useInstancePromotionConnection();
|
||||
|
||||
await Promise.all([header.load(), modal.load()]);
|
||||
|
||||
expect(api.fetchPromotionConnections).toHaveBeenCalledTimes(1);
|
||||
expect(api.fetchPromotionConnections).toHaveBeenCalledWith({}, { scope: 'instance' });
|
||||
expect(modal.connection.value).toEqual(connection);
|
||||
expect(header.hasApplyConfig.value).toBe(true);
|
||||
expect(header.hasPromoteConfig.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should report no configured direction when the instance has no connection', async () => {
|
||||
vi.mocked(api.fetchPromotionConnections).mockResolvedValue([]);
|
||||
const {
|
||||
connection: current,
|
||||
hasApplyConfig,
|
||||
hasPromoteConfig,
|
||||
load,
|
||||
} = useInstancePromotionConnection();
|
||||
|
||||
await load();
|
||||
|
||||
expect(current.value).toBeNull();
|
||||
expect(hasApplyConfig.value).toBe(false);
|
||||
expect(hasPromoteConfig.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should treat a failed lookup as no connection and retry on the next load', async () => {
|
||||
vi.mocked(api.fetchPromotionConnections)
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce([connection]);
|
||||
const { connection: current, load } = useInstancePromotionConnection();
|
||||
|
||||
await load();
|
||||
expect(current.value).toBeNull();
|
||||
|
||||
await load();
|
||||
expect(current.value).toEqual(connection);
|
||||
expect(api.fetchPromotionConnections).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should fetch again only after the cache is invalidated', async () => {
|
||||
vi.mocked(api.fetchPromotionConnections).mockResolvedValue([connection]);
|
||||
const { load } = useInstancePromotionConnection();
|
||||
|
||||
await load();
|
||||
await load();
|
||||
expect(api.fetchPromotionConnections).toHaveBeenCalledTimes(1);
|
||||
|
||||
invalidateInstancePromotionConnection();
|
||||
await load();
|
||||
expect(api.fetchPromotionConnections).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { computed, ref } from 'vue';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import {
|
||||
fetchPromotionConnections,
|
||||
type PromotionConnectionSummary,
|
||||
} from '../promotionsSettings.api';
|
||||
|
||||
let instanceConnection: Promise<PromotionConnectionSummary | null> | undefined;
|
||||
|
||||
export function invalidateInstancePromotionConnection() {
|
||||
instanceConnection = undefined;
|
||||
}
|
||||
|
||||
/** The instance connection and which directions it has, so callers only ask for those. */
|
||||
export function useInstancePromotionConnection() {
|
||||
const rootStore = useRootStore();
|
||||
const connection = ref<PromotionConnectionSummary | null>(null);
|
||||
|
||||
async function load() {
|
||||
instanceConnection ??= fetchPromotionConnections(rootStore.publicApiContext, {
|
||||
scope: 'instance',
|
||||
})
|
||||
.then((connections) => connections[0] ?? null)
|
||||
.catch(() => {
|
||||
invalidateInstancePromotionConnection();
|
||||
return null;
|
||||
});
|
||||
connection.value = await instanceConnection;
|
||||
}
|
||||
|
||||
const hasPromoteConfig = computed(() => connection.value?.configs.promote !== undefined);
|
||||
const hasApplyConfig = computed(() => connection.value?.configs.apply !== undefined);
|
||||
|
||||
return { connection, hasPromoteConfig, hasApplyConfig, load };
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { nextTick, ref } from 'vue';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import type { PromotionChanges } from '@n8n/api-types';
|
||||
import { waitAllPromises } from '@/__tests__/utils';
|
||||
import { usePromotionChangeCount } from './usePromotionChangeCount';
|
||||
import * as api from '../promotions.api';
|
||||
|
||||
vi.mock('@n8n/stores/useRootStore', () => ({
|
||||
useRootStore: () => ({ restApiContext: {} }),
|
||||
}));
|
||||
|
||||
vi.mock('../promotions.api');
|
||||
|
||||
type Pending = {
|
||||
resolve: (changes: PromotionChanges) => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
|
||||
const changes = (count: number): PromotionChanges => ({
|
||||
commitSha: 'a'.repeat(40),
|
||||
changes: Array.from({ length: count }, (_, index) => ({
|
||||
id: `workflow-${index}`,
|
||||
name: `Workflow ${index}`,
|
||||
type: 'workflow',
|
||||
status: 'new',
|
||||
version: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
dependencyCount: 0,
|
||||
})),
|
||||
});
|
||||
|
||||
describe('usePromotionChangeCount', () => {
|
||||
// One entry per request, in call order, so a test can answer them out of order.
|
||||
let pending: Pending[];
|
||||
|
||||
beforeEach(() => {
|
||||
pending = [];
|
||||
vi.mocked(api.getPromotableChanges).mockImplementation(
|
||||
async () =>
|
||||
await new Promise<PromotionChanges>((resolve, reject) => {
|
||||
pending.push({ resolve, reject });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep the newest result when an older request answers last', async () => {
|
||||
const projectId = ref<string | undefined>('project-a');
|
||||
const { count, failed } = usePromotionChangeCount(projectId, 'apply', ref(true));
|
||||
|
||||
projectId.value = 'project-b';
|
||||
await nextTick();
|
||||
projectId.value = 'project-a';
|
||||
await nextTick();
|
||||
expect(api.getPromotableChanges).toHaveBeenCalledTimes(3);
|
||||
|
||||
pending[2].resolve(changes(2));
|
||||
await waitAllPromises();
|
||||
expect(count.value).toBe(2);
|
||||
|
||||
// The first request was for the same project, so a project id check alone would accept it.
|
||||
pending[0].resolve(changes(5));
|
||||
pending[1].reject(new Error('offline'));
|
||||
await waitAllPromises();
|
||||
expect(count.value).toBe(2);
|
||||
expect(failed.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { ref, watch, type Ref } from 'vue';
|
||||
import type { PromotionDirection } from '@n8n/api-types';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useLatestFetch } from '@/app/composables/useLatestFetch';
|
||||
import { getPromotableChanges } from '../promotions.api';
|
||||
|
||||
/** How many rows the change preview lists for a project in one direction, while `enabled` holds. */
|
||||
export function usePromotionChangeCount(
|
||||
projectId: Ref<string | undefined>,
|
||||
direction: PromotionDirection,
|
||||
enabled: Ref<boolean>,
|
||||
) {
|
||||
const rootStore = useRootStore();
|
||||
const { next } = useLatestFetch();
|
||||
const count = ref(0);
|
||||
/** True when the last check failed, so the banner can say so instead of hiding. */
|
||||
const failed = ref(false);
|
||||
|
||||
async function fetchCount() {
|
||||
// Only the newest request may write, so a slow answer for a project the user left
|
||||
// and came back to cannot overwrite the current state.
|
||||
const isLatest = next();
|
||||
const requestedProjectId = projectId.value;
|
||||
count.value = 0;
|
||||
failed.value = false;
|
||||
if (!enabled.value || !requestedProjectId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { changes } = await getPromotableChanges(
|
||||
rootStore.restApiContext,
|
||||
requestedProjectId,
|
||||
direction,
|
||||
);
|
||||
if (isLatest()) count.value = changes.length;
|
||||
} catch {
|
||||
if (isLatest()) failed.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
watch([projectId, enabled], fetchCount, { immediate: true });
|
||||
|
||||
return { count, failed, refetch: fetchCount };
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import type { Project } from '@/features/collaboration/projects/projects.types';
|
||||
|
||||
export interface PromotionEventBusEvents {
|
||||
applied: { projectId: string; project?: Project };
|
||||
projectRemoved: { projectId: string };
|
||||
}
|
||||
|
||||
export const promotionEventBus = createEventBus<PromotionEventBusEvents>();
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import type {
|
||||
ApplyPackageDto,
|
||||
ApplyPackageResultDto,
|
||||
CreatePromotionConnectionDto,
|
||||
CreatePromotionProviderDto,
|
||||
PromotionApplyConfigPublicDto,
|
||||
|
|
@ -223,3 +225,15 @@ export const promotePackage = async (
|
|||
endpoint: `${promotionsApiRoot}/connections/${connectionId}/promote`,
|
||||
data: payload,
|
||||
});
|
||||
|
||||
export const applyPromotion = async (
|
||||
context: PublicApiContext,
|
||||
connectionId: string,
|
||||
payload?: ApplyPackageDto,
|
||||
): Promise<ApplyPackageResultDto> =>
|
||||
await request({
|
||||
method: 'POST',
|
||||
baseURL: context.baseUrl,
|
||||
endpoint: `${promotionsApiRoot}/connections/${connectionId}/apply`,
|
||||
data: payload,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
|||
import PromoteInstanceSection from '../components/PromoteInstanceSection.vue';
|
||||
import PromotionConnectionForm from '../components/PromotionConnectionForm.vue';
|
||||
import PromotionProviderDialog from '../components/PromotionProviderDialog.vue';
|
||||
import { invalidateInstancePromotionConnection } from '../composables/useInstancePromotionConnection';
|
||||
import {
|
||||
fetchPromotionConnection,
|
||||
fetchPromotionConnections,
|
||||
|
|
@ -117,6 +118,12 @@ function onProviderDeleted() {
|
|||
pendingLoad = load();
|
||||
}
|
||||
|
||||
function onConnectionSaved(saved: PromotionConnection) {
|
||||
connection.value = saved;
|
||||
// The project header caches the instance connection per page load.
|
||||
invalidateInstancePromotionConnection();
|
||||
}
|
||||
|
||||
async function focusProvider(id: string | undefined) {
|
||||
await nextTick();
|
||||
const row = id ? list.value?.querySelector<HTMLElement>(`[data-provider-id="${id}"]`) : undefined;
|
||||
|
|
@ -207,7 +214,7 @@ async function onDialogOpenChange(open: boolean) {
|
|||
ref="connectionForm"
|
||||
:providers="providers"
|
||||
:connection="connection"
|
||||
@saved="connection = $event"
|
||||
@saved="onConnectionSaved"
|
||||
@add-provider="openCreateDialog(true)"
|
||||
/>
|
||||
</N8nSettingsSection>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user