fix(editor): Source control workflow diff release (#17974)

Co-authored-by: r00gm <raul00gm@gmail.com>
This commit is contained in:
Csaba Tuncsik
2025-08-15 15:31:28 +02:00
committed by GitHub
parent 041672eb6c
commit abf7b11e09
15 changed files with 927 additions and 379 deletions

View File

@@ -7,7 +7,6 @@ import {
MODAL_CONFIRM,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
PROJECT_MOVE_RESOURCE_MODAL,
SOURCE_CONTROL_PUSH_MODAL_KEY,
VIEWS,
WORKFLOW_MENU_ACTIONS,
WORKFLOW_SETTINGS_MODAL_KEY,
@@ -41,7 +40,6 @@ import { getResourcePermissions } from '@n8n/permissions';
import { createEventBus } from '@n8n/utils/event-bus';
import { nodeViewEventBus } from '@/event-bus';
import { hasPermission } from '@/utils/rbac/permissions';
import { useCanvasStore } from '@/stores/canvas.store';
import { useRoute, useRouter } from 'vue-router';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { computed, ref, useCssModule, useTemplateRef, watch } from 'vue';
@@ -82,7 +80,6 @@ const props = defineProps<{
const $style = useCssModule();
const rootStore = useRootStore();
const canvasStore = useCanvasStore();
const settingsStore = useSettingsStore();
const sourceControlStore = useSourceControlStore();
const tagsStore = useTagsStore();
@@ -112,7 +109,6 @@ const tagsSaving = ref(false);
const importFileRef = ref<HTMLInputElement | undefined>();
const tagsEventBus = createEventBus();
const sourceControlModalEventBus = createEventBus();
const changeOwnerEventBus = createEventBus();
const hasChanged = (prev: string[], curr: string[]) => {
@@ -488,15 +484,15 @@ async function onWorkflowMenuSelect(value: string): Promise<void> {
break;
}
case WORKFLOW_MENU_ACTIONS.PUSH: {
canvasStore.startLoading();
try {
await onSaveButtonClick();
const status = await sourceControlStore.getAggregatedStatus();
uiStore.openModalWithData({
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
data: { eventBus: sourceControlModalEventBus, status },
// Navigate to route with sourceControl param - modal will handle data loading and loading states
void router.push({
query: {
...route.query,
sourceControl: 'push',
},
});
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
@@ -511,8 +507,6 @@ async function onWorkflowMenuSelect(value: string): Promise<void> {
default:
toast.showError(error, locale.baseText('error'));
}
} finally {
canvasStore.stopLoading();
}
break;

View File

@@ -3,27 +3,32 @@ import { waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createTestingPinia } from '@pinia/testing';
import merge from 'lodash/merge';
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants';
import { reactive } from 'vue';
import { STORES } from '@n8n/stores';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store';
import { useRBACStore } from '@/stores/rbac.store';
import { createComponentRenderer } from '@/__tests__/render';
import { useProjectsStore } from '@/stores/projects.store';
let pinia: ReturnType<typeof createTestingPinia>;
let sourceControlStore: ReturnType<typeof useSourceControlStore>;
let uiStore: ReturnType<typeof useUIStore>;
let rbacStore: ReturnType<typeof useRBACStore>;
let projectStore: ReturnType<typeof useProjectsStore>;
const showMessage = vi.fn();
const showError = vi.fn();
const showToast = vi.fn();
vi.mock('@/composables/useToast', () => ({
useToast: () => ({ showMessage, showError, showToast }),
const mockRoute = reactive({
query: {},
});
const mockRouterPush = vi.fn();
vi.mock('vue-router', () => ({
useRoute: () => mockRoute,
useRouter: () => ({
push: mockRouterPush,
}),
RouterLink: vi.fn(),
}));
const renderComponent = createComponentRenderer(MainSidebarSourceControl);
@@ -31,6 +36,13 @@ const renderComponent = createComponentRenderer(MainSidebarSourceControl);
describe('MainSidebarSourceControl', () => {
beforeEach(() => {
vi.resetAllMocks();
// Reset route mock to default values
mockRoute.query = {};
// Reset router push mock
mockRouterPush.mockReset();
pinia = createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
@@ -45,8 +57,6 @@ describe('MainSidebarSourceControl', () => {
sourceControlStore = useSourceControlStore();
vi.spyOn(sourceControlStore, 'isEnterpriseSourceControlEnabled', 'get').mockReturnValue(true);
uiStore = useUIStore();
});
it('should render nothing when not instance owner', async () => {
@@ -173,26 +183,7 @@ describe('MainSidebarSourceControl', () => {
expect(pushButton).toBeDisabled();
});
it('should show toast error if pull response http status code is not 409', async () => {
vi.spyOn(sourceControlStore, 'pullWorkfolder').mockRejectedValueOnce({
response: { status: 400 },
});
const { getAllByRole } = renderComponent({
pinia,
props: { isCollapsed: false },
});
await userEvent.click(getAllByRole('button')[0]);
await waitFor(() => expect(showError).toHaveBeenCalled());
});
it('should show confirm if pull response http status code is 409', async () => {
const status = {};
vi.spyOn(sourceControlStore, 'pullWorkfolder').mockRejectedValueOnce({
response: { status: 409, data: { data: status } },
});
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
it('should navigate to pull route when pull button is clicked', async () => {
const { getAllByRole } = renderComponent({
pinia,
props: { isCollapsed: false },
@@ -200,20 +191,15 @@ describe('MainSidebarSourceControl', () => {
await userEvent.click(getAllByRole('button')[0]);
await waitFor(() =>
expect(openModalSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: SOURCE_CONTROL_PULL_MODAL_KEY,
data: expect.objectContaining({
status,
}),
}),
),
expect(mockRouterPush).toHaveBeenCalledWith({
query: {
sourceControl: 'pull',
},
}),
);
});
it('should show toast when there are no changes', async () => {
vi.spyOn(sourceControlStore, 'getAggregatedStatus').mockResolvedValueOnce([]);
it('should navigate to push route when push button is clicked', async () => {
const { getAllByRole } = renderComponent({
pinia,
props: { isCollapsed: false },
@@ -221,43 +207,11 @@ describe('MainSidebarSourceControl', () => {
await userEvent.click(getAllByRole('button')[1]);
await waitFor(() =>
expect(showMessage).toHaveBeenCalledWith(
expect.objectContaining({ title: 'No changes to commit' }),
),
);
});
it('should open push modal when there are changes', async () => {
const status = [
{
id: '014da93897f146d2b880-baa374b9d02d',
name: 'vuelfow2',
type: 'workflow' as const,
status: 'created' as const,
location: 'local' as const,
conflict: false,
file: '/014da93897f146d2b880-baa374b9d02d.json',
updatedAt: '2025-01-09T13:12:24.580Z',
},
];
vi.spyOn(sourceControlStore, 'getAggregatedStatus').mockResolvedValueOnce(status);
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
const { getAllByRole } = renderComponent({
pinia,
props: { isCollapsed: false },
});
await userEvent.click(getAllByRole('button')[1]);
await waitFor(() =>
expect(openModalSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
data: expect.objectContaining({
status,
}),
}),
),
expect(mockRouterPush).toHaveBeenCalledWith({
query: {
sourceControl: 'push',
},
}),
);
});
});

View File

@@ -1,34 +1,21 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { createEventBus } from '@n8n/utils/event-bus';
import { useI18n } from '@n8n/i18n';
import { hasPermission } from '@/utils/rbac/permissions';
import { getResourcePermissions } from '@n8n/permissions';
import { useToast } from '@/composables/useToast';
import { useLoadingService } from '@/composables/useLoadingService';
import { useUIStore } from '@/stores/ui.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants';
import { sourceControlEventBus } from '@/event-bus/source-control';
import { notifyUserAboutPullWorkFolderOutcome } from '@/utils/sourceControlUtils';
import { useProjectsStore } from '@/stores/projects.store';
import { useRoute, useRouter } from 'vue-router';
defineProps<{
isCollapsed: boolean;
}>();
const responseStatuses = {
CONFLICT: 409,
};
const loadingService = useLoadingService();
const uiStore = useUIStore();
const sourceControlStore = useSourceControlStore();
const projectStore = useProjectsStore();
const toast = useToast();
const i18n = useI18n();
const eventBus = createEventBus();
const route = useRoute();
const router = useRouter();
const tooltipOpenDelay = ref(300);
const currentBranch = computed(() => {
@@ -57,57 +44,23 @@ const sourceControlAvailable = computed(
);
async function pushWorkfolder() {
loadingService.startLoading();
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.checkingForChanges'));
try {
const status = await sourceControlStore.getAggregatedStatus();
if (!status.length) {
toast.showMessage({
title: 'No changes to commit',
message: 'Everything is up to date',
type: 'info',
});
return;
}
uiStore.openModalWithData({
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
data: { eventBus, status },
});
} catch (error) {
toast.showError(error, i18n.baseText('error'));
} finally {
loadingService.stopLoading();
loadingService.setLoadingText(i18n.baseText('genericHelpers.loading'));
}
// Navigate to route with sourceControl param - modal will handle data loading and loading states
void router.push({
query: {
...route.query,
sourceControl: 'push',
},
});
}
async function pullWorkfolder() {
loadingService.startLoading();
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.pull'));
try {
const status = await sourceControlStore.pullWorkfolder(false);
await notifyUserAboutPullWorkFolderOutcome(status, toast);
sourceControlEventBus.emit('pull');
} catch (error) {
const errorResponse = error.response;
if (errorResponse?.status === responseStatuses.CONFLICT) {
uiStore.openModalWithData({
name: SOURCE_CONTROL_PULL_MODAL_KEY,
data: { eventBus, status: errorResponse.data.data },
});
} else {
toast.showError(error, 'Error');
}
} finally {
loadingService.stopLoading();
loadingService.setLoadingText(i18n.baseText('genericHelpers.loading'));
}
function pullWorkfolder() {
// Navigate to route with sourceControl param - modal will handle the pull operation
void router.push({
query: {
...route.query,
sourceControl: 'pull',
},
});
}
</script>

View File

@@ -166,9 +166,9 @@ const projectLocation = computed(() => {
>
<ProjectIcon :icon="badgeIcon" :border-less="true" size="mini" />
<router-link v-if="projectLocation" :to="projectLocation">
<span v-n8n-truncate:20="badgeText" />
<span v-n8n-truncate:20="badgeText" :class="$style.nowrap" />
</router-link>
<span v-else v-n8n-truncate:20="badgeText" />
<span v-else v-n8n-truncate:20="badgeText" :class="$style.nowrap" />
</N8nBadge>
<template #content>
{{ badgeTooltip }}
@@ -230,4 +230,8 @@ const projectLocation = computed(() => {
border-left: var(--border-base);
line-height: var(--font-line-height-regular);
}
.nowrap {
white-space: nowrap !important;
}
</style>

View File

@@ -7,6 +7,8 @@ import { useSourceControlStore } from '@/stores/sourceControl.store';
import { mockedStore } from '@/__tests__/utils';
import { waitFor } from '@testing-library/dom';
import { reactive } from 'vue';
import { useSettingsStore } from '@/stores/settings.store';
import { defaultSettings } from '@/__tests__/defaults';
const eventBus = createEventBus();
@@ -21,6 +23,7 @@ const mockRoute = reactive({
vi.mock('vue-router', () => ({
useRoute: () => mockRoute,
useRouter: () => ({
back: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
go: vi.fn(),
@@ -31,6 +34,14 @@ vi.mock('vue-router', () => ({
},
}));
vi.mock('@/composables/useLoadingService', () => ({
useLoadingService: () => ({
startLoading: vi.fn(),
stopLoading: vi.fn(),
setLoadingText: vi.fn(),
}),
}));
// Mock the toast composable to prevent Element Plus DOM errors
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
@@ -47,6 +58,7 @@ const DynamicScrollerStub = {
minItemSize: Number,
class: String,
style: [String, Object],
itemClass: String,
},
template:
'<div><template v-for="(item, index) in items" :key="index"><slot v-bind="{ item, index, active: false }"></slot></template></div>',
@@ -80,13 +92,14 @@ const renderModal = createComponentRenderer(SourceControlPullModalEe, {
</div>
`,
},
EnvFeatureFlag: {
template: '<div><slot></slot></div>',
},
N8nIconButton: {
template: '<button><slot></slot></button>',
props: ['icon', 'type', 'class'],
},
'router-link': {
template: '<a><slot /></a>',
props: ['to'],
},
},
},
});
@@ -116,18 +129,29 @@ const sampleFiles = [
describe('SourceControlPullModal', () => {
let sourceControlStore: ReturnType<typeof mockedStore<typeof useSourceControlStore>>;
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
let pinia: ReturnType<typeof createTestingPinia>;
beforeEach(() => {
createTestingPinia();
vi.clearAllMocks();
// Setup store with default mock to prevent automatic data loading
pinia = createTestingPinia();
sourceControlStore = mockedStore(useSourceControlStore);
sourceControlStore.getAggregatedStatus = vi.fn().mockResolvedValue([]);
sourceControlStore.pullWorkfolder = vi.fn().mockResolvedValue([]);
settingsStore = mockedStore(useSettingsStore);
settingsStore.settings.enterprise = defaultSettings.enterprise;
});
it('mounts', () => {
const { getByText } = renderModal({
pinia,
props: {
data: {
eventBus,
status: [],
status: [], // Provide initial status to prevent auto-loading
},
},
});
@@ -136,6 +160,7 @@ describe('SourceControlPullModal', () => {
it('should renders the changes', () => {
const { getAllByTestId } = renderModal({
pinia,
props: {
data: {
eventBus,
@@ -150,7 +175,9 @@ describe('SourceControlPullModal', () => {
});
it('should force pull', async () => {
// Use the existing store instance from beforeEach
const { getByTestId } = renderModal({
pinia,
props: {
data: {
eventBus,

View File

@@ -2,12 +2,11 @@
import { useLoadingService } from '@/composables/useLoadingService';
import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast';
import { SOURCE_CONTROL_PULL_MODAL_KEY, VIEWS, WORKFLOW_DIFF_MODAL_KEY } from '@/constants';
import { SOURCE_CONTROL_PULL_MODAL_KEY, VIEWS } from '@/constants';
import { sourceControlEventBus } from '@/event-bus/source-control';
import EnvFeatureFlag from '@/features/env-feature-flag/EnvFeatureFlag.vue';
import { useProjectsStore } from '@/stores/projects.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store';
import type { ProjectListItem } from '@/types/projects.types';
import {
getPullPriorityByStatus,
@@ -19,11 +18,10 @@ import { type SourceControlledFile, SOURCE_CONTROL_FILE_TYPE } from '@n8n/api-ty
import { N8nBadge, N8nButton, N8nHeading, N8nInfoTip, N8nLink, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import dateformat from 'dateformat';
import orderBy from 'lodash/orderBy';
import { computed, onBeforeMount, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { computed, onBeforeMount, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import Modal from './Modal.vue';
@@ -32,17 +30,58 @@ type SourceControlledFileType = SourceControlledFile['type'];
type SourceControlledFileWithProject = SourceControlledFile & { project?: ProjectListItem };
const props = defineProps<{
data: { eventBus: EventBus; status: SourceControlledFile[] };
data: { eventBus: EventBus; status?: SourceControlledFile[] };
}>();
const telemetry = useTelemetry();
const loadingService = useLoadingService();
const uiStore = useUIStore();
const toast = useToast();
const i18n = useI18n();
const sourceControlStore = useSourceControlStore();
const projectsStore = useProjectsStore();
const route = useRoute();
const router = useRouter();
const settingsStore = useSettingsStore();
const isWorkflowDiffsEnabled = computed(() => settingsStore.settings.enterprise.workflowDiffs);
// Reactive status state - starts with props data or empty, then loads fresh data
const status = ref<SourceControlledFile[]>(props.data.status || []);
const isLoading = ref(false);
const responseStatuses = {
CONFLICT: 409,
};
// Load fresh source control status when modal opens
async function loadSourceControlStatus() {
if (isLoading.value) return;
isLoading.value = true;
loadingService.startLoading();
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.checkingForChanges'));
try {
const freshStatus = await sourceControlStore.pullWorkfolder(false);
await notifyUserAboutPullWorkFolderOutcome(freshStatus, toast);
sourceControlEventBus.emit('pull');
close();
} catch (error) {
// only show the modal when there are conflicts
const errorResponse = error.response;
if (errorResponse?.status === responseStatuses.CONFLICT) {
status.value = errorResponse.data.data || [];
} else {
toast.showError(error, 'Error');
close();
}
} finally {
isLoading.value = false;
loadingService.stopLoading();
loadingService.setLoadingText(i18n.baseText('genericHelpers.loading'));
}
}
onBeforeMount(() => {
void projectsStore.getAvailableProjects();
});
@@ -52,18 +91,10 @@ const activeTab = ref<
typeof SOURCE_CONTROL_FILE_TYPE.workflow | typeof SOURCE_CONTROL_FILE_TYPE.credential
>(SOURCE_CONTROL_FILE_TYPE.workflow);
// Group files by type with project information
const filesWithProjects = computed(() =>
props.data.status.map((file) => {
const project = projectsStore.availableProjects.find(({ id }) => id === file.owner?.projectId);
return { ...file, project };
}),
);
const groupedFilesByType = computed(() => {
const grouped: Partial<Record<SourceControlledFileType, SourceControlledFileWithProject[]>> = {};
filesWithProjects.value.forEach((file) => {
status.value.forEach((file) => {
if (!grouped[file.type]) {
grouped[file.type] = [];
}
@@ -156,17 +187,19 @@ const otherFiles = computed(() => {
});
function close() {
uiStore.closeModal(SOURCE_CONTROL_PULL_MODAL_KEY);
// Navigate back in history to maintain proper browser navigation
// The global route watcher will handle closing the modal
router.back();
}
async function pullWorkfolder() {
loadingService.startLoading(i18n.baseText('settings.sourceControl.loading.pull'));
loadingService.startLoading(i18n.baseText('settings.sourceControl.loading.checkingForChanges'));
close();
try {
const status = await sourceControlStore.pullWorkfolder(true);
const pullStatus = await sourceControlStore.pullWorkfolder(true);
await notifyUserAboutPullWorkFolderOutcome(status, toast);
await notifyUserAboutPullWorkFolderOutcome(pullStatus, toast);
sourceControlEventBus.emit('pull');
} catch (error) {
@@ -190,16 +223,19 @@ function renderUpdatedAt(file: SourceControlledFile) {
});
}
const workflowDiffEventBus = createEventBus();
function openDiffModal(id: string) {
telemetry.track('User clicks compare workflows', {
workflow_id: id,
context: 'source_control_pull',
});
uiStore.openModalWithData({
name: WORKFLOW_DIFF_MODAL_KEY,
data: { eventBus: workflowDiffEventBus, workflowId: id, direction: 'pull' },
// Only update route - modal will be opened by route watcher
void router.push({
query: {
...route.query,
diff: id,
direction: 'pull',
},
});
}
@@ -209,15 +245,25 @@ const modalHeight = computed(() =>
? 'min(80vh, 850px)'
: 'auto',
);
// Load data when modal opens
onMounted(() => {
// Only load fresh data if we don't have any initial data
if (!props.data.status || props.data.status.length === 0) {
void loadSourceControlStatus();
}
});
</script>
<template>
<Modal
v-if="!isLoading"
width="812px"
:event-bus="data.eventBus"
:name="SOURCE_CONTROL_PULL_MODAL_KEY"
:height="modalHeight"
:custom-class="$style.sourceControlPull"
:before-close="close"
>
<template #header>
<N8nHeading tag="h1" size="xlarge">
@@ -227,7 +273,6 @@ const modalHeight = computed(() =>
<div :class="[$style.filtersRow]" class="mt-l">
<N8nText tag="div" class="mb-xs">
{{ i18n.baseText('settings.sourceControl.modals.pull.description') }}
<br />
<N8nLink :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')">
{{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }}
</N8nLink>
@@ -235,16 +280,7 @@ const modalHeight = computed(() =>
</div>
</template>
<template #content>
<div v-if="!tabs.some((tab) => tab.total > 0)">
<N8nText tag="div" class="mb-xs">
{{ i18n.baseText('settings.sourceControl.modals.pull.description') }}
<br />
<N8nLink :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')">
{{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }}
</N8nLink>
</N8nText>
</div>
<div v-else style="display: flex; height: 100%">
<div style="display: flex; height: 100%">
<div :class="$style.tabs">
<template v-for="tab in tabs" :key="tab.value">
<button
@@ -318,14 +354,14 @@ const modalHeight = computed(() =>
<N8nBadge :theme="getStatusTheme(file.status)" style="height: 25px">
{{ getStatusText(file.status) }}
</N8nBadge>
<EnvFeatureFlag name="SOURCE_CONTROL_WORKFLOW_DIFF">
<template v-if="isWorkflowDiffsEnabled">
<N8nIconButton
v-if="file.type === SOURCE_CONTROL_FILE_TYPE.workflow"
icon="file-diff"
type="secondary"
@click="openDiffModal(file.id)"
/>
</EnvFeatureFlag>
</template>
</span>
</div>
</DynamicScrollerItem>

View File

@@ -12,6 +12,8 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { useProjectsStore } from '@/stores/projects.store';
import type { ProjectListItem } from '@/types/projects.types';
import { reactive } from 'vue';
import { useSettingsStore } from '@/stores/settings.store';
import { defaultSettings } from '@/__tests__/defaults';
const eventBus = createEventBus();
@@ -24,8 +26,16 @@ const mockRoute = reactive({
vi.mock('vue-router', () => ({
useRoute: () => mockRoute,
RouterLink: vi.fn(),
useRouter: vi.fn(),
useRouter: () => ({
back: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
go: vi.fn(),
}),
RouterLink: {
template: '<a><slot></slot></a>',
props: ['to', 'target'],
},
}));
vi.mock('@/composables/useTelemetry', () => {
@@ -39,6 +49,14 @@ vi.mock('@/composables/useTelemetry', () => {
};
});
vi.mock('@/composables/useLoadingService', () => ({
useLoadingService: () => ({
startLoading: vi.fn(),
stopLoading: vi.fn(),
setLoadingText: vi.fn(),
}),
}));
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
showMessage: vi.fn(),
@@ -49,14 +67,6 @@ vi.mock('@/composables/useToast', () => ({
}),
}));
vi.mock('@/composables/useLoadingService', () => ({
useLoadingService: () => ({
startLoading: vi.fn(),
stopLoading: vi.fn(),
setLoading: vi.fn(),
}),
}));
let telemetry: ReturnType<typeof useTelemetry>;
const DynamicScrollerStub = {
@@ -80,7 +90,7 @@ const DynamicScrollerItemStub = {
sizeDependencies: Array,
dataIndex: Number,
},
template: '<div><slot></slot></div>',
template: '<slot></slot>',
};
const projects = [
@@ -111,29 +121,45 @@ const renderModal = createComponentRenderer(SourceControlPushModal, {
</div>
`,
},
'router-link': {
template: '<a><slot /></a>',
props: ['to'],
},
},
},
});
describe('SourceControlPushModal', () => {
let sourceControlStore: ReturnType<typeof mockedStore<typeof useSourceControlStore>>;
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
let pinia: ReturnType<typeof createTestingPinia>;
beforeEach(() => {
vi.clearAllMocks();
// Reset route mock to default values
mockRoute.name = '';
mockRoute.params = {};
mockRoute.fullPath = '';
telemetry = useTelemetry();
createTestingPinia();
// Reset route mock to default values
mockRoute.name = 'default';
mockRoute.params = {};
mockRoute.fullPath = '/';
// Setup store with default mock to prevent automatic data loading
pinia = createTestingPinia();
sourceControlStore = mockedStore(useSourceControlStore);
sourceControlStore.getAggregatedStatus = vi.fn().mockResolvedValue([]);
sourceControlStore.pushWorkfolder = vi.fn().mockResolvedValue([]);
settingsStore = mockedStore(useSettingsStore);
settingsStore.settings.enterprise = defaultSettings.enterprise;
});
it('mounts', () => {
const { getByText } = renderModal({
pinia: createTestingPinia(),
pinia,
props: {
data: {
eventBus,
status: [],
status: [], // Provide initial status to prevent auto-loading
},
},
});
@@ -273,9 +299,8 @@ describe('SourceControlPushModal', () => {
},
];
const sourceControlStore = mockedStore(useSourceControlStore);
const { getByTestId, getByRole } = renderModal({
pinia,
props: {
data: {
eventBus,
@@ -311,7 +336,7 @@ describe('SourceControlPushModal', () => {
);
});
it('should auto select currentWorkflow', async () => {
it('should allow selecting currentWorkflow and enable commit', async () => {
const status: SourceControlledFile[] = [
{
id: 'gTbbBkkYTnNyX1jD',
@@ -339,6 +364,7 @@ describe('SourceControlPushModal', () => {
mockRoute.params = { name: 'gTbbBkkYTnNyX1jD' };
const { getByTestId, getAllByTestId } = renderModal({
pinia,
props: {
data: {
eventBus,
@@ -347,16 +373,15 @@ describe('SourceControlPushModal', () => {
},
});
const submitButton = getByTestId('source-control-push-modal-submit');
expect(submitButton).toBeDisabled();
const files = getAllByTestId('source-control-push-modal-file-checkbox');
expect(files).toHaveLength(2);
await waitFor(() => expect(within(files[0]).getByRole('checkbox')).toBeChecked());
// The current workflow should be auto-selected now that we fixed the regression
expect(within(files[0]).getByRole('checkbox')).toBeChecked();
expect(within(files[1]).getByRole('checkbox')).not.toBeChecked();
await userEvent.type(getByTestId('source-control-push-modal-commit'), 'message');
const submitButton = getByTestId('source-control-push-modal-submit');
expect(submitButton).not.toBeDisabled();
});
@@ -664,9 +689,10 @@ describe('SourceControlPushModal', () => {
},
];
const sourceControlStore = mockedStore(useSourceControlStore);
// Use the existing store instance from beforeEach
const { getByTestId } = renderModal({
pinia,
props: {
data: {
eventBus,
@@ -706,9 +732,10 @@ describe('SourceControlPushModal', () => {
},
];
const sourceControlStore = mockedStore(useSourceControlStore);
// Use the existing store instance from beforeEach
const { getByTestId } = renderModal({
pinia,
props: {
data: {
eventBus,
@@ -740,12 +767,13 @@ describe('SourceControlPushModal', () => {
},
];
const sourceControlStore = mockedStore(useSourceControlStore);
// Use the existing store instance from beforeEach
mockRoute.name = 'SOME_OTHER_VIEW';
mockRoute.params = { name: 'differentId' };
const { getByTestId, getAllByTestId } = renderModal({
pinia,
props: {
data: {
eventBus,

View File

@@ -3,12 +3,11 @@ import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
import { useLoadingService } from '@/composables/useLoadingService';
import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast';
import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS, WORKFLOW_DIFF_MODAL_KEY } from '@/constants';
import EnvFeatureFlag from '@/features/env-feature-flag/EnvFeatureFlag.vue';
import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants';
import type { WorkflowResource } from '@/Interface';
import { useProjectsStore } from '@/stores/projects.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types';
import { ResourceType } from '@/utils/projects.utils';
@@ -37,29 +36,68 @@ import {
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import { refDebounced, useStorage } from '@vueuse/core';
import dateformat from 'dateformat';
import orderBy from 'lodash/orderBy';
import { computed, onBeforeMount, onMounted, reactive, ref, toRaw, watch } from 'vue';
import { useRoute } from 'vue-router';
import { computed, onBeforeMount, onMounted, reactive, ref, toRaw, watch, watchEffect } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import Modal from './Modal.vue';
const props = defineProps<{
data: { eventBus: EventBus; status: SourceControlledFile[] };
data: { eventBus: EventBus; status?: SourceControlledFile[] };
}>();
const loadingService = useLoadingService();
const uiStore = useUIStore();
const toast = useToast();
const i18n = useI18n();
const sourceControlStore = useSourceControlStore();
const projectsStore = useProjectsStore();
const route = useRoute();
const router = useRouter();
const telemetry = useTelemetry();
const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
const isWorkflowDiffsEnabled = computed(() => settingsStore.settings.enterprise.workflowDiffs);
// Reactive status state - starts with props data or empty, then loads fresh data
const status = ref<SourceControlledFile[]>(props.data.status ?? []);
const isLoading = ref(false);
// Load fresh source control status when modal opens
async function loadSourceControlStatus() {
if (isLoading.value) return;
isLoading.value = true;
loadingService.startLoading();
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.checkingForChanges'));
try {
const freshStatus = await sourceControlStore.getAggregatedStatus();
if (!freshStatus.length) {
toast.showMessage({
title: 'No changes to commit',
message: 'Everything is up to date',
type: 'info',
});
// Close modal since there's nothing to show
close();
return;
}
status.value = freshStatus;
} catch (error) {
toast.showError(error, i18n.baseText('error'));
close();
} finally {
loadingService.stopLoading();
loadingService.setLoadingText(i18n.baseText('genericHelpers.loading'));
isLoading.value = false;
}
}
const projectAdminCalloutDismissed = useStorage(
'SOURCE_CONTROL_PROJECT_ADMIN_CALLOUT_DISMISSED',
@@ -182,15 +220,13 @@ const workflowId = computed(
([VIEWS.WORKFLOW].includes(route.name as VIEWS) && route.params.name?.toString()) || undefined,
);
const changes = computed(() => classifyFilesByType(props.data.status, workflowId.value));
const changes = computed(() => classifyFilesByType(status.value, workflowId.value));
const selectedWorkflows = reactive<Set<string>>(new Set());
const maybeSelectCurrentWorkflow = (workflow?: SourceControlledFileWithProject) =>
workflow && selectedWorkflows.add(workflow.id);
onMounted(() => maybeSelectCurrentWorkflow(changes.value.currentWorkflow));
const currentProject = computed(() => {
if (!route.params.projectId) {
return null;
@@ -361,7 +397,9 @@ function onToggleSelectAll() {
}
function close() {
uiStore.closeModal(SOURCE_CONTROL_PUSH_MODAL_KEY);
// Navigate back in history to maintain proper browser navigation
// The useWorkflowDiffRouting composable will handle closing the modal
router.back();
}
function renderUpdatedAt(file: SourceControlledFile) {
@@ -591,27 +629,47 @@ function castProject(project: ProjectListItem): WorkflowResource {
return resource;
}
const workflowDiffEventBus = createEventBus();
function openDiffModal(id: string) {
telemetry.track('User clicks compare workflows', {
workflow_id: id,
context: 'source_control_push',
});
uiStore.openModalWithData({
name: WORKFLOW_DIFF_MODAL_KEY,
data: { eventBus: workflowDiffEventBus, workflowId: id, direction: 'push' },
// Only update route - modal will be opened by route watcher
void router.push({
query: {
...route.query,
diff: id,
direction: 'push',
},
});
}
// Auto-select current workflow when it becomes available
watchEffect(() => {
if (changes.value.currentWorkflow && !selectedWorkflows.has(changes.value.currentWorkflow.id)) {
maybeSelectCurrentWorkflow(changes.value.currentWorkflow);
}
});
// Load data when modal opens
onMounted(async () => {
// Only load fresh data if we don't have any initial data
if (!props.data.status || props.data.status.length === 0) {
await loadSourceControlStatus();
}
});
</script>
<template>
<Modal
v-if="!isLoading"
width="812px"
:event-bus="data.eventBus"
:name="SOURCE_CONTROL_PUSH_MODAL_KEY"
:height="modalHeight"
:custom-class="$style.sourceControlPush"
:before-close="close"
>
<template #header>
<N8nHeading tag="h1" size="xlarge">
@@ -830,14 +888,14 @@ function openDiffModal(id: string) {
<N8nBadge :theme="getStatusTheme(file.status)" style="height: 25px">
{{ getStatusText(file.status) }}
</N8nBadge>
<EnvFeatureFlag name="SOURCE_CONTROL_WORKFLOW_DIFF">
<template v-if="isWorkflowDiffsEnabled">
<N8nIconButton
v-if="file.type === SOURCE_CONTROL_FILE_TYPE.workflow"
icon="file-diff"
type="secondary"
@click="openDiffModal(file.id)"
/>
</EnvFeatureFlag>
</template>
</span>
</N8nCheckbox>
</DynamicScrollerItem>