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

@@ -2577,6 +2577,7 @@
"workflowSettings.helpTexts.workflowCallerPolicy": "Workflows that are allowed to call this workflow using the Execute Workflow node", "workflowSettings.helpTexts.workflowCallerPolicy": "Workflows that are allowed to call this workflow using the Execute Workflow node",
"workflowSettings.hours": "hours", "workflowSettings.hours": "hours",
"workflowSettings.minutes": "minutes", "workflowSettings.minutes": "minutes",
"workflowSettings.name": "Workflow name",
"workflowSettings.noWorkflow": "- No Workflow -", "workflowSettings.noWorkflow": "- No Workflow -",
"workflowSettings.save": "@:_reusableBaseText.save", "workflowSettings.save": "@:_reusableBaseText.save",
"workflowSettings.saveDataErrorExecution": "Save failed production executions", "workflowSettings.saveDataErrorExecution": "Save failed production executions",

View File

@@ -23,6 +23,7 @@ import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useHistoryHelper } from '@/composables/useHistoryHelper'; import { useHistoryHelper } from '@/composables/useHistoryHelper';
import { useWorkflowDiffRouting } from '@/composables/useWorkflowDiffRouting';
import { useStyles } from './composables/useStyles'; import { useStyles } from './composables/useStyles';
import { locale } from '@n8n/design-system'; import { locale } from '@n8n/design-system';
import axios from 'axios'; import axios from 'axios';
@@ -40,6 +41,9 @@ const { setAppZIndexes } = useStyles();
// Initialize undo/redo // Initialize undo/redo
useHistoryHelper(route); useHistoryHelper(route);
// Initialize workflow diff routing management
useWorkflowDiffRouting();
const loading = ref(true); const loading = ref(true);
const defaultLocale = computed(() => rootStore.defaultLocale); const defaultLocale = computed(() => rootStore.defaultLocale);
const isDemoMode = computed(() => route.name === VIEWS.DEMO); const isDemoMode = computed(() => route.name === VIEWS.DEMO);

View File

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

View File

@@ -3,27 +3,32 @@ import { waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import merge from 'lodash/merge'; 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 { STORES } from '@n8n/stores';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue'; import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store';
import { useRBACStore } from '@/stores/rbac.store'; import { useRBACStore } from '@/stores/rbac.store';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
let pinia: ReturnType<typeof createTestingPinia>; let pinia: ReturnType<typeof createTestingPinia>;
let sourceControlStore: ReturnType<typeof useSourceControlStore>; let sourceControlStore: ReturnType<typeof useSourceControlStore>;
let uiStore: ReturnType<typeof useUIStore>;
let rbacStore: ReturnType<typeof useRBACStore>; let rbacStore: ReturnType<typeof useRBACStore>;
let projectStore: ReturnType<typeof useProjectsStore>; let projectStore: ReturnType<typeof useProjectsStore>;
const showMessage = vi.fn(); const mockRoute = reactive({
const showError = vi.fn(); query: {},
const showToast = vi.fn(); });
vi.mock('@/composables/useToast', () => ({
useToast: () => ({ showMessage, showError, showToast }), const mockRouterPush = vi.fn();
vi.mock('vue-router', () => ({
useRoute: () => mockRoute,
useRouter: () => ({
push: mockRouterPush,
}),
RouterLink: vi.fn(),
})); }));
const renderComponent = createComponentRenderer(MainSidebarSourceControl); const renderComponent = createComponentRenderer(MainSidebarSourceControl);
@@ -31,6 +36,13 @@ const renderComponent = createComponentRenderer(MainSidebarSourceControl);
describe('MainSidebarSourceControl', () => { describe('MainSidebarSourceControl', () => {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
// Reset route mock to default values
mockRoute.query = {};
// Reset router push mock
mockRouterPush.mockReset();
pinia = createTestingPinia({ pinia = createTestingPinia({
initialState: { initialState: {
[STORES.SETTINGS]: { [STORES.SETTINGS]: {
@@ -45,8 +57,6 @@ describe('MainSidebarSourceControl', () => {
sourceControlStore = useSourceControlStore(); sourceControlStore = useSourceControlStore();
vi.spyOn(sourceControlStore, 'isEnterpriseSourceControlEnabled', 'get').mockReturnValue(true); vi.spyOn(sourceControlStore, 'isEnterpriseSourceControlEnabled', 'get').mockReturnValue(true);
uiStore = useUIStore();
}); });
it('should render nothing when not instance owner', async () => { it('should render nothing when not instance owner', async () => {
@@ -173,26 +183,7 @@ describe('MainSidebarSourceControl', () => {
expect(pushButton).toBeDisabled(); expect(pushButton).toBeDisabled();
}); });
it('should show toast error if pull response http status code is not 409', async () => { it('should navigate to pull route when pull button is clicked', 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');
const { getAllByRole } = renderComponent({ const { getAllByRole } = renderComponent({
pinia, pinia,
props: { isCollapsed: false }, props: { isCollapsed: false },
@@ -200,49 +191,15 @@ describe('MainSidebarSourceControl', () => {
await userEvent.click(getAllByRole('button')[0]); await userEvent.click(getAllByRole('button')[0]);
await waitFor(() => await waitFor(() =>
expect(openModalSpy).toHaveBeenCalledWith( expect(mockRouterPush).toHaveBeenCalledWith({
expect.objectContaining({ query: {
name: SOURCE_CONTROL_PULL_MODAL_KEY, sourceControl: 'pull',
data: expect.objectContaining({
status,
}),
}),
),
);
});
it('should show toast when there are no changes', async () => {
vi.spyOn(sourceControlStore, 'getAggregatedStatus').mockResolvedValueOnce([]);
const { getAllByRole } = renderComponent({
pinia,
props: { isCollapsed: false },
});
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'); });
it('should navigate to push route when push button is clicked', async () => {
const { getAllByRole } = renderComponent({ const { getAllByRole } = renderComponent({
pinia, pinia,
props: { isCollapsed: false }, props: { isCollapsed: false },
@@ -250,14 +207,11 @@ describe('MainSidebarSourceControl', () => {
await userEvent.click(getAllByRole('button')[1]); await userEvent.click(getAllByRole('button')[1]);
await waitFor(() => await waitFor(() =>
expect(openModalSpy).toHaveBeenCalledWith( expect(mockRouterPush).toHaveBeenCalledWith({
expect.objectContaining({ query: {
name: SOURCE_CONTROL_PUSH_MODAL_KEY, sourceControl: 'push',
data: expect.objectContaining({ },
status,
}), }),
}),
),
); );
}); });
}); });

View File

@@ -1,34 +1,21 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { createEventBus } from '@n8n/utils/event-bus';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { hasPermission } from '@/utils/rbac/permissions'; import { hasPermission } from '@/utils/rbac/permissions';
import { getResourcePermissions } from '@n8n/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 { 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 { useProjectsStore } from '@/stores/projects.store';
import { useRoute, useRouter } from 'vue-router';
defineProps<{ defineProps<{
isCollapsed: boolean; isCollapsed: boolean;
}>(); }>();
const responseStatuses = {
CONFLICT: 409,
};
const loadingService = useLoadingService();
const uiStore = useUIStore();
const sourceControlStore = useSourceControlStore(); const sourceControlStore = useSourceControlStore();
const projectStore = useProjectsStore(); const projectStore = useProjectsStore();
const toast = useToast();
const i18n = useI18n(); const i18n = useI18n();
const route = useRoute();
const eventBus = createEventBus(); const router = useRouter();
const tooltipOpenDelay = ref(300); const tooltipOpenDelay = ref(300);
const currentBranch = computed(() => { const currentBranch = computed(() => {
@@ -57,57 +44,23 @@ const sourceControlAvailable = computed(
); );
async function pushWorkfolder() { async function pushWorkfolder() {
loadingService.startLoading(); // Navigate to route with sourceControl param - modal will handle data loading and loading states
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.checkingForChanges')); void router.push({
try { query: {
const status = await sourceControlStore.getAggregatedStatus(); ...route.query,
sourceControl: 'push',
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'));
}
} }
async function pullWorkfolder() { function pullWorkfolder() {
loadingService.startLoading(); // Navigate to route with sourceControl param - modal will handle the pull operation
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.pull')); void router.push({
query: {
try { ...route.query,
const status = await sourceControlStore.pullWorkfolder(false); sourceControl: 'pull',
},
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'));
}
} }
</script> </script>

View File

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

View File

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

View File

@@ -2,12 +2,11 @@
import { useLoadingService } from '@/composables/useLoadingService'; import { useLoadingService } from '@/composables/useLoadingService';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast'; 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 { sourceControlEventBus } from '@/event-bus/source-control';
import EnvFeatureFlag from '@/features/env-feature-flag/EnvFeatureFlag.vue';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store';
import type { ProjectListItem } from '@/types/projects.types'; import type { ProjectListItem } from '@/types/projects.types';
import { import {
getPullPriorityByStatus, 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 { N8nBadge, N8nButton, N8nHeading, N8nInfoTip, N8nLink, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import type { EventBus } from '@n8n/utils/event-bus'; import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import dateformat from 'dateformat'; import dateformat from 'dateformat';
import orderBy from 'lodash/orderBy'; import orderBy from 'lodash/orderBy';
import { computed, onBeforeMount, ref } from 'vue'; import { computed, onBeforeMount, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router'; import { RouterLink, useRoute, useRouter } from 'vue-router';
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'; import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'; import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import Modal from './Modal.vue'; import Modal from './Modal.vue';
@@ -32,17 +30,58 @@ type SourceControlledFileType = SourceControlledFile['type'];
type SourceControlledFileWithProject = SourceControlledFile & { project?: ProjectListItem }; type SourceControlledFileWithProject = SourceControlledFile & { project?: ProjectListItem };
const props = defineProps<{ const props = defineProps<{
data: { eventBus: EventBus; status: SourceControlledFile[] }; data: { eventBus: EventBus; status?: SourceControlledFile[] };
}>(); }>();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const loadingService = useLoadingService(); const loadingService = useLoadingService();
const uiStore = useUIStore();
const toast = useToast(); const toast = useToast();
const i18n = useI18n(); const i18n = useI18n();
const sourceControlStore = useSourceControlStore(); const sourceControlStore = useSourceControlStore();
const projectsStore = useProjectsStore(); 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(() => { onBeforeMount(() => {
void projectsStore.getAvailableProjects(); void projectsStore.getAvailableProjects();
}); });
@@ -52,18 +91,10 @@ const activeTab = ref<
typeof SOURCE_CONTROL_FILE_TYPE.workflow | typeof SOURCE_CONTROL_FILE_TYPE.credential typeof SOURCE_CONTROL_FILE_TYPE.workflow | typeof SOURCE_CONTROL_FILE_TYPE.credential
>(SOURCE_CONTROL_FILE_TYPE.workflow); >(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 groupedFilesByType = computed(() => {
const grouped: Partial<Record<SourceControlledFileType, SourceControlledFileWithProject[]>> = {}; const grouped: Partial<Record<SourceControlledFileType, SourceControlledFileWithProject[]>> = {};
filesWithProjects.value.forEach((file) => { status.value.forEach((file) => {
if (!grouped[file.type]) { if (!grouped[file.type]) {
grouped[file.type] = []; grouped[file.type] = [];
} }
@@ -156,17 +187,19 @@ const otherFiles = computed(() => {
}); });
function close() { 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() { async function pullWorkfolder() {
loadingService.startLoading(i18n.baseText('settings.sourceControl.loading.pull')); loadingService.startLoading(i18n.baseText('settings.sourceControl.loading.checkingForChanges'));
close(); close();
try { try {
const status = await sourceControlStore.pullWorkfolder(true); const pullStatus = await sourceControlStore.pullWorkfolder(true);
await notifyUserAboutPullWorkFolderOutcome(status, toast); await notifyUserAboutPullWorkFolderOutcome(pullStatus, toast);
sourceControlEventBus.emit('pull'); sourceControlEventBus.emit('pull');
} catch (error) { } catch (error) {
@@ -190,16 +223,19 @@ function renderUpdatedAt(file: SourceControlledFile) {
}); });
} }
const workflowDiffEventBus = createEventBus();
function openDiffModal(id: string) { function openDiffModal(id: string) {
telemetry.track('User clicks compare workflows', { telemetry.track('User clicks compare workflows', {
workflow_id: id, workflow_id: id,
context: 'source_control_pull', context: 'source_control_pull',
}); });
uiStore.openModalWithData({
name: WORKFLOW_DIFF_MODAL_KEY, // Only update route - modal will be opened by route watcher
data: { eventBus: workflowDiffEventBus, workflowId: id, direction: 'pull' }, void router.push({
query: {
...route.query,
diff: id,
direction: 'pull',
},
}); });
} }
@@ -209,15 +245,25 @@ const modalHeight = computed(() =>
? 'min(80vh, 850px)' ? 'min(80vh, 850px)'
: 'auto', : '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> </script>
<template> <template>
<Modal <Modal
v-if="!isLoading"
width="812px" width="812px"
:event-bus="data.eventBus" :event-bus="data.eventBus"
:name="SOURCE_CONTROL_PULL_MODAL_KEY" :name="SOURCE_CONTROL_PULL_MODAL_KEY"
:height="modalHeight" :height="modalHeight"
:custom-class="$style.sourceControlPull" :custom-class="$style.sourceControlPull"
:before-close="close"
> >
<template #header> <template #header>
<N8nHeading tag="h1" size="xlarge"> <N8nHeading tag="h1" size="xlarge">
@@ -227,7 +273,6 @@ const modalHeight = computed(() =>
<div :class="[$style.filtersRow]" class="mt-l"> <div :class="[$style.filtersRow]" class="mt-l">
<N8nText tag="div" class="mb-xs"> <N8nText tag="div" class="mb-xs">
{{ i18n.baseText('settings.sourceControl.modals.pull.description') }} {{ i18n.baseText('settings.sourceControl.modals.pull.description') }}
<br />
<N8nLink :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')"> <N8nLink :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')">
{{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }} {{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }}
</N8nLink> </N8nLink>
@@ -235,16 +280,7 @@ const modalHeight = computed(() =>
</div> </div>
</template> </template>
<template #content> <template #content>
<div v-if="!tabs.some((tab) => tab.total > 0)"> <div style="display: flex; height: 100%">
<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 :class="$style.tabs"> <div :class="$style.tabs">
<template v-for="tab in tabs" :key="tab.value"> <template v-for="tab in tabs" :key="tab.value">
<button <button
@@ -318,14 +354,14 @@ const modalHeight = computed(() =>
<N8nBadge :theme="getStatusTheme(file.status)" style="height: 25px"> <N8nBadge :theme="getStatusTheme(file.status)" style="height: 25px">
{{ getStatusText(file.status) }} {{ getStatusText(file.status) }}
</N8nBadge> </N8nBadge>
<EnvFeatureFlag name="SOURCE_CONTROL_WORKFLOW_DIFF"> <template v-if="isWorkflowDiffsEnabled">
<N8nIconButton <N8nIconButton
v-if="file.type === SOURCE_CONTROL_FILE_TYPE.workflow" v-if="file.type === SOURCE_CONTROL_FILE_TYPE.workflow"
icon="file-diff" icon="file-diff"
type="secondary" type="secondary"
@click="openDiffModal(file.id)" @click="openDiffModal(file.id)"
/> />
</EnvFeatureFlag> </template>
</span> </span>
</div> </div>
</DynamicScrollerItem> </DynamicScrollerItem>

View File

@@ -12,6 +12,8 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import type { ProjectListItem } from '@/types/projects.types'; import type { ProjectListItem } from '@/types/projects.types';
import { reactive } from 'vue'; import { reactive } from 'vue';
import { useSettingsStore } from '@/stores/settings.store';
import { defaultSettings } from '@/__tests__/defaults';
const eventBus = createEventBus(); const eventBus = createEventBus();
@@ -24,8 +26,16 @@ const mockRoute = reactive({
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
useRoute: () => mockRoute, useRoute: () => mockRoute,
RouterLink: vi.fn(), useRouter: () => ({
useRouter: vi.fn(), 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', () => { 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', () => ({ vi.mock('@/composables/useToast', () => ({
useToast: () => ({ useToast: () => ({
showMessage: vi.fn(), 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>; let telemetry: ReturnType<typeof useTelemetry>;
const DynamicScrollerStub = { const DynamicScrollerStub = {
@@ -80,7 +90,7 @@ const DynamicScrollerItemStub = {
sizeDependencies: Array, sizeDependencies: Array,
dataIndex: Number, dataIndex: Number,
}, },
template: '<div><slot></slot></div>', template: '<slot></slot>',
}; };
const projects = [ const projects = [
@@ -111,29 +121,45 @@ const renderModal = createComponentRenderer(SourceControlPushModal, {
</div> </div>
`, `,
}, },
'router-link': {
template: '<a><slot /></a>',
props: ['to'],
},
}, },
}, },
}); });
describe('SourceControlPushModal', () => { describe('SourceControlPushModal', () => {
let sourceControlStore: ReturnType<typeof mockedStore<typeof useSourceControlStore>>;
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
let pinia: ReturnType<typeof createTestingPinia>;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// Reset route mock to default values
mockRoute.name = '';
mockRoute.params = {};
mockRoute.fullPath = '';
telemetry = useTelemetry(); 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', () => { it('mounts', () => {
const { getByText } = renderModal({ const { getByText } = renderModal({
pinia: createTestingPinia(), pinia,
props: { props: {
data: { data: {
eventBus, eventBus,
status: [], status: [], // Provide initial status to prevent auto-loading
}, },
}, },
}); });
@@ -273,9 +299,8 @@ describe('SourceControlPushModal', () => {
}, },
]; ];
const sourceControlStore = mockedStore(useSourceControlStore);
const { getByTestId, getByRole } = renderModal({ const { getByTestId, getByRole } = renderModal({
pinia,
props: { props: {
data: { data: {
eventBus, 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[] = [ const status: SourceControlledFile[] = [
{ {
id: 'gTbbBkkYTnNyX1jD', id: 'gTbbBkkYTnNyX1jD',
@@ -339,6 +364,7 @@ describe('SourceControlPushModal', () => {
mockRoute.params = { name: 'gTbbBkkYTnNyX1jD' }; mockRoute.params = { name: 'gTbbBkkYTnNyX1jD' };
const { getByTestId, getAllByTestId } = renderModal({ const { getByTestId, getAllByTestId } = renderModal({
pinia,
props: { props: {
data: { data: {
eventBus, 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'); const files = getAllByTestId('source-control-push-modal-file-checkbox');
expect(files).toHaveLength(2); 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(); expect(within(files[1]).getByRole('checkbox')).not.toBeChecked();
await userEvent.type(getByTestId('source-control-push-modal-commit'), 'message'); await userEvent.type(getByTestId('source-control-push-modal-commit'), 'message');
const submitButton = getByTestId('source-control-push-modal-submit');
expect(submitButton).not.toBeDisabled(); expect(submitButton).not.toBeDisabled();
}); });
@@ -664,9 +689,10 @@ describe('SourceControlPushModal', () => {
}, },
]; ];
const sourceControlStore = mockedStore(useSourceControlStore); // Use the existing store instance from beforeEach
const { getByTestId } = renderModal({ const { getByTestId } = renderModal({
pinia,
props: { props: {
data: { data: {
eventBus, eventBus,
@@ -706,9 +732,10 @@ describe('SourceControlPushModal', () => {
}, },
]; ];
const sourceControlStore = mockedStore(useSourceControlStore); // Use the existing store instance from beforeEach
const { getByTestId } = renderModal({ const { getByTestId } = renderModal({
pinia,
props: { props: {
data: { data: {
eventBus, eventBus,
@@ -740,12 +767,13 @@ describe('SourceControlPushModal', () => {
}, },
]; ];
const sourceControlStore = mockedStore(useSourceControlStore); // Use the existing store instance from beforeEach
mockRoute.name = 'SOME_OTHER_VIEW'; mockRoute.name = 'SOME_OTHER_VIEW';
mockRoute.params = { name: 'differentId' }; mockRoute.params = { name: 'differentId' };
const { getByTestId, getAllByTestId } = renderModal({ const { getByTestId, getAllByTestId } = renderModal({
pinia,
props: { props: {
data: { data: {
eventBus, eventBus,

View File

@@ -3,12 +3,11 @@ import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
import { useLoadingService } from '@/composables/useLoadingService'; import { useLoadingService } from '@/composables/useLoadingService';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS, WORKFLOW_DIFF_MODAL_KEY } from '@/constants'; import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants';
import EnvFeatureFlag from '@/features/env-feature-flag/EnvFeatureFlag.vue';
import type { WorkflowResource } from '@/Interface'; import type { WorkflowResource } from '@/Interface';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types'; import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types';
import { ResourceType } from '@/utils/projects.utils'; import { ResourceType } from '@/utils/projects.utils';
@@ -37,29 +36,68 @@ import {
} from '@n8n/design-system'; } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import type { EventBus } from '@n8n/utils/event-bus'; import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import { refDebounced, useStorage } from '@vueuse/core'; import { refDebounced, useStorage } from '@vueuse/core';
import dateformat from 'dateformat'; import dateformat from 'dateformat';
import orderBy from 'lodash/orderBy'; import orderBy from 'lodash/orderBy';
import { computed, onBeforeMount, onMounted, reactive, ref, toRaw, watch } from 'vue'; import { computed, onBeforeMount, onMounted, reactive, ref, toRaw, watch, watchEffect } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'; import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'; import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import Modal from './Modal.vue'; import Modal from './Modal.vue';
const props = defineProps<{ const props = defineProps<{
data: { eventBus: EventBus; status: SourceControlledFile[] }; data: { eventBus: EventBus; status?: SourceControlledFile[] };
}>(); }>();
const loadingService = useLoadingService(); const loadingService = useLoadingService();
const uiStore = useUIStore();
const toast = useToast(); const toast = useToast();
const i18n = useI18n(); const i18n = useI18n();
const sourceControlStore = useSourceControlStore(); const sourceControlStore = useSourceControlStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const route = useRoute(); const route = useRoute();
const router = useRouter();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const usersStore = useUsersStore(); 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( const projectAdminCalloutDismissed = useStorage(
'SOURCE_CONTROL_PROJECT_ADMIN_CALLOUT_DISMISSED', '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, ([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 selectedWorkflows = reactive<Set<string>>(new Set());
const maybeSelectCurrentWorkflow = (workflow?: SourceControlledFileWithProject) => const maybeSelectCurrentWorkflow = (workflow?: SourceControlledFileWithProject) =>
workflow && selectedWorkflows.add(workflow.id); workflow && selectedWorkflows.add(workflow.id);
onMounted(() => maybeSelectCurrentWorkflow(changes.value.currentWorkflow));
const currentProject = computed(() => { const currentProject = computed(() => {
if (!route.params.projectId) { if (!route.params.projectId) {
return null; return null;
@@ -361,7 +397,9 @@ function onToggleSelectAll() {
} }
function close() { 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) { function renderUpdatedAt(file: SourceControlledFile) {
@@ -591,27 +629,47 @@ function castProject(project: ProjectListItem): WorkflowResource {
return resource; return resource;
} }
const workflowDiffEventBus = createEventBus();
function openDiffModal(id: string) { function openDiffModal(id: string) {
telemetry.track('User clicks compare workflows', { telemetry.track('User clicks compare workflows', {
workflow_id: id, workflow_id: id,
context: 'source_control_push', context: 'source_control_push',
}); });
uiStore.openModalWithData({
name: WORKFLOW_DIFF_MODAL_KEY, // Only update route - modal will be opened by route watcher
data: { eventBus: workflowDiffEventBus, workflowId: id, direction: 'push' }, 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> </script>
<template> <template>
<Modal <Modal
v-if="!isLoading"
width="812px" width="812px"
:event-bus="data.eventBus" :event-bus="data.eventBus"
:name="SOURCE_CONTROL_PUSH_MODAL_KEY" :name="SOURCE_CONTROL_PUSH_MODAL_KEY"
:height="modalHeight" :height="modalHeight"
:custom-class="$style.sourceControlPush" :custom-class="$style.sourceControlPush"
:before-close="close"
> >
<template #header> <template #header>
<N8nHeading tag="h1" size="xlarge"> <N8nHeading tag="h1" size="xlarge">
@@ -830,14 +888,14 @@ function openDiffModal(id: string) {
<N8nBadge :theme="getStatusTheme(file.status)" style="height: 25px"> <N8nBadge :theme="getStatusTheme(file.status)" style="height: 25px">
{{ getStatusText(file.status) }} {{ getStatusText(file.status) }}
</N8nBadge> </N8nBadge>
<EnvFeatureFlag name="SOURCE_CONTROL_WORKFLOW_DIFF"> <template v-if="isWorkflowDiffsEnabled">
<N8nIconButton <N8nIconButton
v-if="file.type === SOURCE_CONTROL_FILE_TYPE.workflow" v-if="file.type === SOURCE_CONTROL_FILE_TYPE.workflow"
icon="file-diff" icon="file-diff"
type="secondary" type="secondary"
@click="openDiffModal(file.id)" @click="openDiffModal(file.id)"
/> />
</EnvFeatureFlag> </template>
</span> </span>
</N8nCheckbox> </N8nCheckbox>
</DynamicScrollerItem> </DynamicScrollerItem>

View File

@@ -0,0 +1,394 @@
import { nextTick, ref } from 'vue';
import { useWorkflowDiffRouting } from '@/composables/useWorkflowDiffRouting';
import {
WORKFLOW_DIFF_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,
} from '@/constants';
// Mock vue-router
const mockRoute = ref({
query: {},
});
vi.mock('vue-router', () => ({
useRoute: () => mockRoute.value,
}));
// Mock UI Store
const mockUiStore = {
modalsById: {} as Record<string, { open?: boolean; data?: unknown }>,
closeModal: vi.fn(),
openModalWithData: vi.fn(),
};
vi.mock('@/stores/ui.store', () => ({
useUIStore: () => mockUiStore,
}));
// Mock event bus
const mockEventBus = {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
};
vi.mock('@n8n/utils/event-bus', () => ({
createEventBus: () => mockEventBus,
}));
describe('useWorkflowDiffRouting', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUiStore.modalsById = {};
mockRoute.value = { query: {} };
});
describe('initialization', () => {
it('should return workflowDiffEventBus', () => {
const { workflowDiffEventBus } = useWorkflowDiffRouting();
expect(workflowDiffEventBus).toBe(mockEventBus);
});
it('should call handleRouteChange immediately on initialization', () => {
const spy = vi.spyOn(mockUiStore, 'closeModal');
// Set up modals as open so they can be closed
mockUiStore.modalsById[SOURCE_CONTROL_PUSH_MODAL_KEY] = { open: true };
mockUiStore.modalsById[SOURCE_CONTROL_PULL_MODAL_KEY] = { open: true };
useWorkflowDiffRouting();
// Should close both source control modals when no query params
expect(spy).toHaveBeenCalledWith(SOURCE_CONTROL_PUSH_MODAL_KEY);
expect(spy).toHaveBeenCalledWith(SOURCE_CONTROL_PULL_MODAL_KEY);
});
});
describe('query parameter parsing', () => {
it('should parse valid diff query parameter', async () => {
mockRoute.value.query = {
diff: 'workflow-123',
direction: 'push',
};
useWorkflowDiffRouting();
await nextTick();
expect(mockUiStore.openModalWithData).toHaveBeenCalledWith({
name: WORKFLOW_DIFF_MODAL_KEY,
data: {
eventBus: mockEventBus,
workflowId: 'workflow-123',
direction: 'push',
},
});
});
it('should ignore invalid diff query parameter types', async () => {
mockRoute.value.query = {
diff: ['array-value'],
direction: 'push',
};
useWorkflowDiffRouting();
await nextTick();
// Should still open source control modal based on direction, not diff modal
expect(mockUiStore.openModalWithData).toHaveBeenCalledWith({
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
data: { eventBus: mockEventBus },
});
// Should not open diff modal
expect(mockUiStore.openModalWithData).not.toHaveBeenCalledWith(
expect.objectContaining({
name: WORKFLOW_DIFF_MODAL_KEY,
}),
);
});
it('should parse valid direction query parameter', async () => {
mockRoute.value.query = {
direction: 'pull',
};
useWorkflowDiffRouting();
await nextTick();
expect(mockUiStore.openModalWithData).toHaveBeenCalledWith({
name: SOURCE_CONTROL_PULL_MODAL_KEY,
data: { eventBus: mockEventBus },
});
});
it('should ignore invalid direction values', async () => {
// Set up modals as open so they can be closed
mockUiStore.modalsById[SOURCE_CONTROL_PUSH_MODAL_KEY] = { open: true };
mockUiStore.modalsById[SOURCE_CONTROL_PULL_MODAL_KEY] = { open: true };
mockRoute.value.query = {
direction: 'invalid',
};
useWorkflowDiffRouting();
await nextTick();
expect(mockUiStore.closeModal).toHaveBeenCalledWith(SOURCE_CONTROL_PUSH_MODAL_KEY);
expect(mockUiStore.closeModal).toHaveBeenCalledWith(SOURCE_CONTROL_PULL_MODAL_KEY);
});
it('should parse valid sourceControl query parameter', async () => {
mockRoute.value.query = {
sourceControl: 'push',
};
useWorkflowDiffRouting();
await nextTick();
expect(mockUiStore.openModalWithData).toHaveBeenCalledWith({
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
data: { eventBus: mockEventBus },
});
});
});
describe('diff modal handling', () => {
it('should open diff modal when diff and direction are present', async () => {
mockRoute.value.query = {
diff: 'workflow-456',
direction: 'pull',
};
useWorkflowDiffRouting();
await nextTick();
expect(mockUiStore.openModalWithData).toHaveBeenCalledWith({
name: WORKFLOW_DIFF_MODAL_KEY,
data: {
eventBus: mockEventBus,
workflowId: 'workflow-456',
direction: 'pull',
},
});
});
it('should not open diff modal when diff is present but direction is missing', async () => {
mockRoute.value.query = {
diff: 'workflow-456',
};
useWorkflowDiffRouting();
await nextTick();
expect(mockUiStore.openModalWithData).not.toHaveBeenCalledWith(
expect.objectContaining({
name: WORKFLOW_DIFF_MODAL_KEY,
}),
);
});
it('should close diff modal when diff param is removed', async () => {
mockUiStore.modalsById[WORKFLOW_DIFF_MODAL_KEY] = { open: true };
mockRoute.value.query = {};
useWorkflowDiffRouting();
await nextTick();
expect(mockUiStore.closeModal).toHaveBeenCalledWith(WORKFLOW_DIFF_MODAL_KEY);
});
it('should not open diff modal if already open', async () => {
mockUiStore.modalsById[WORKFLOW_DIFF_MODAL_KEY] = { open: true };
mockRoute.value.query = {
diff: 'workflow-456',
direction: 'pull',
};
useWorkflowDiffRouting();
await nextTick();
expect(mockUiStore.openModalWithData).not.toHaveBeenCalled();
});
});
describe('source control modal handling', () => {
it('should open push modal when sourceControl=push', async () => {
mockRoute.value.query = {
sourceControl: 'push',
};
useWorkflowDiffRouting();
await nextTick();
expect(mockUiStore.openModalWithData).toHaveBeenCalledWith({
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
data: { eventBus: mockEventBus },
});
});
it('should open pull modal when sourceControl=pull', async () => {
mockRoute.value.query = {
sourceControl: 'pull',
};
useWorkflowDiffRouting();
await nextTick();
expect(mockUiStore.openModalWithData).toHaveBeenCalledWith({
name: SOURCE_CONTROL_PULL_MODAL_KEY,
data: { eventBus: mockEventBus },
});
});
it('should not open source control modal when viewing diff', async () => {
mockRoute.value.query = {
sourceControl: 'push',
diff: 'workflow-123',
direction: 'push',
};
useWorkflowDiffRouting();
await nextTick();
expect(mockUiStore.openModalWithData).toHaveBeenCalledWith({
name: WORKFLOW_DIFF_MODAL_KEY,
data: {
eventBus: mockEventBus,
workflowId: 'workflow-123',
direction: 'push',
},
});
expect(mockUiStore.openModalWithData).not.toHaveBeenCalledWith(
expect.objectContaining({
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
}),
);
});
it('should reopen parent modal when returning from diff (direction without diff or sourceControl)', async () => {
mockRoute.value.query = {
direction: 'push',
};
useWorkflowDiffRouting();
await nextTick();
expect(mockUiStore.openModalWithData).toHaveBeenCalledWith({
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
data: { eventBus: mockEventBus },
});
});
it('should preserve data when returning from diff modal', async () => {
const existingData = { eventBus: mockEventBus, someData: 'test' };
mockUiStore.modalsById[SOURCE_CONTROL_PUSH_MODAL_KEY] = {
data: existingData,
};
mockRoute.value.query = {
direction: 'push',
};
useWorkflowDiffRouting();
await nextTick();
expect(mockUiStore.openModalWithData).toHaveBeenCalledWith({
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
data: existingData,
});
});
it('should not open source control modal if already open', async () => {
mockUiStore.modalsById[SOURCE_CONTROL_PUSH_MODAL_KEY] = { open: true };
mockRoute.value.query = {
sourceControl: 'push',
};
useWorkflowDiffRouting();
await nextTick();
expect(mockUiStore.openModalWithData).not.toHaveBeenCalled();
});
it('should close both source control modals when no relevant params', async () => {
mockUiStore.modalsById[SOURCE_CONTROL_PUSH_MODAL_KEY] = { open: true };
mockUiStore.modalsById[SOURCE_CONTROL_PULL_MODAL_KEY] = { open: true };
mockRoute.value.query = {};
useWorkflowDiffRouting();
await nextTick();
expect(mockUiStore.closeModal).toHaveBeenCalledWith(SOURCE_CONTROL_PUSH_MODAL_KEY);
expect(mockUiStore.closeModal).toHaveBeenCalledWith(SOURCE_CONTROL_PULL_MODAL_KEY);
});
});
describe('route watching', () => {
it('should react to query parameter changes', async () => {
useWorkflowDiffRouting();
vi.clearAllMocks();
// Change route query
mockRoute.value.query = {
diff: 'workflow-789',
direction: 'pull',
};
await nextTick();
expect(mockUiStore.openModalWithData).toHaveBeenCalledWith({
name: WORKFLOW_DIFF_MODAL_KEY,
data: {
eventBus: mockEventBus,
workflowId: 'workflow-789',
direction: 'pull',
},
});
});
it('should handle complex route transitions', async () => {
useWorkflowDiffRouting();
// Start with source control modal
mockRoute.value.query = { sourceControl: 'push' };
await nextTick();
vi.clearAllMocks();
// Transition to diff view
mockRoute.value.query = {
diff: 'workflow-123',
direction: 'push',
};
await nextTick();
expect(mockUiStore.openModalWithData).toHaveBeenCalledWith({
name: WORKFLOW_DIFF_MODAL_KEY,
data: {
eventBus: mockEventBus,
workflowId: 'workflow-123',
direction: 'push',
},
});
vi.clearAllMocks();
// Set up diff modal as open so it can be closed
mockUiStore.modalsById[WORKFLOW_DIFF_MODAL_KEY] = { open: true };
// Return to parent modal
mockRoute.value.query = { direction: 'push' };
await nextTick();
expect(mockUiStore.closeModal).toHaveBeenCalledWith(WORKFLOW_DIFF_MODAL_KEY);
expect(mockUiStore.openModalWithData).toHaveBeenCalledWith({
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
data: { eventBus: mockEventBus },
});
});
});
});

View File

@@ -0,0 +1,138 @@
import { watch } from 'vue';
import { useRoute } from 'vue-router';
import { createEventBus } from '@n8n/utils/event-bus';
import { useUIStore } from '@/stores/ui.store';
import {
WORKFLOW_DIFF_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,
} from '@/constants';
/**
* Composable that handles source control modal state based on URL query parameters
* This enables browser back/forward navigation and direct URL access for:
* - Push/Pull modals
* - Workflow diff modals
*/
export function useWorkflowDiffRouting() {
const route = useRoute();
const uiStore = useUIStore();
// Create event buses for modal communication
const workflowDiffEventBus = createEventBus();
type Direction = 'push' | 'pull';
const closeModal = (modalKey: string) => {
if (uiStore.modalsById[modalKey]?.open) {
uiStore.closeModal(modalKey);
}
};
const reopenSourceControlModal = (direction: Direction, preserveData = false) => {
const modalKey =
direction === 'push' ? SOURCE_CONTROL_PUSH_MODAL_KEY : SOURCE_CONTROL_PULL_MODAL_KEY;
// If preserving data, try to reuse existing data from recently closed modal
// This helps when returning from diff modal via query manipulation (no browser history)
const modalData =
preserveData && uiStore.modalsById[modalKey]?.data?.eventBus
? uiStore.modalsById[modalKey].data
: { eventBus: createEventBus() };
uiStore.openModalWithData({
name: modalKey,
data: modalData,
});
};
const handleDiffModal = (
diffWorkflowId: string | undefined,
direction: Direction | undefined,
) => {
const shouldOpen = diffWorkflowId && direction;
const isOpen = uiStore.modalsById[WORKFLOW_DIFF_MODAL_KEY]?.open;
if (shouldOpen && !isOpen) {
uiStore.openModalWithData({
name: WORKFLOW_DIFF_MODAL_KEY,
data: {
eventBus: workflowDiffEventBus,
workflowId: diffWorkflowId,
direction,
},
});
} else if (!shouldOpen && isOpen) {
uiStore.closeModal(WORKFLOW_DIFF_MODAL_KEY);
}
};
const handleSourceControlModals = (
sourceControl: Direction | undefined,
diffWorkflowId: string | undefined,
direction: Direction | undefined,
) => {
// Open source control modal when sourceControl param present (but not viewing diff)
if (sourceControl && !diffWorkflowId) {
const modalKey =
sourceControl === 'push' ? SOURCE_CONTROL_PUSH_MODAL_KEY : SOURCE_CONTROL_PULL_MODAL_KEY;
const isOpen = uiStore.modalsById[modalKey]?.open;
if (!isOpen) {
reopenSourceControlModal(sourceControl);
}
return;
}
// Open parent modal when returning from diff (direction without diff or sourceControl)
if (direction && !diffWorkflowId && !sourceControl) {
const modalKey =
direction === 'push' ? SOURCE_CONTROL_PUSH_MODAL_KEY : SOURCE_CONTROL_PULL_MODAL_KEY;
const isOpen = uiStore.modalsById[modalKey]?.open;
if (!isOpen) {
// Always preserve data when returning from diff modal
// This handles both router.back() and query manipulation scenarios
reopenSourceControlModal(direction, true);
}
return;
}
// Close both modals when no relevant params
if (!sourceControl && !diffWorkflowId) {
closeModal(SOURCE_CONTROL_PUSH_MODAL_KEY);
closeModal(SOURCE_CONTROL_PULL_MODAL_KEY);
}
};
const handleRouteChange = () => {
const diffWorkflowId = typeof route.query.diff === 'string' ? route.query.diff : undefined;
const direction =
typeof route.query.direction === 'string' &&
(route.query.direction === 'push' || route.query.direction === 'pull')
? route.query.direction
: undefined;
const sourceControl =
typeof route.query.sourceControl === 'string' &&
(route.query.sourceControl === 'push' || route.query.sourceControl === 'pull')
? route.query.sourceControl
: undefined;
handleDiffModal(diffWorkflowId, direction);
handleSourceControlModals(sourceControl, diffWorkflowId, direction);
};
// Watch route changes and handle modal state
watch(
() => [route.query.diff, route.query.direction, route.query.sourceControl],
handleRouteChange,
{ immediate: true },
);
// Initial setup
handleRouteChange();
return {
workflowDiffEventBus,
};
}

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import NodeDiff from '@/features/workflow-diff/NodeDiff.vue'; import NodeDiff from '@/features/workflow-diff/NodeDiff.vue';
import { createTestingPinia } from '@pinia/testing';
// Mock the v-code-diff library // Mock the v-code-diff library
vi.mock('v-code-diff', () => ({ vi.mock('v-code-diff', () => ({
@@ -42,6 +43,10 @@ const renderComponent = createComponentRenderer(NodeDiff, {
}); });
describe('NodeDiff', () => { describe('NodeDiff', () => {
beforeEach(() => {
createTestingPinia();
});
it('should render with required props', () => { it('should render with required props', () => {
const { container } = renderComponent({ const { container } = renderComponent({
props: { props: {
@@ -154,18 +159,6 @@ describe('NodeDiff', () => {
expect(container.querySelector('.code-diff-mock')).toBeInTheDocument(); expect(container.querySelector('.code-diff-mock')).toBeInTheDocument();
}); });
it('should have correct CSS class', () => {
const { container } = renderComponent({
props: {
oldString: 'test',
newString: 'test2',
},
});
const wrapper = container.querySelector('.code-diff');
expect(wrapper).toBeInTheDocument();
});
it('should handle complex JSON differences', () => { it('should handle complex JSON differences', () => {
const oldComplex = JSON.stringify({ const oldComplex = JSON.stringify({
id: 'node1', id: 'node1',

View File

@@ -1,5 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { useUIStore } from '@/stores/ui.store';
import { CodeDiff } from 'v-code-diff'; import { CodeDiff } from 'v-code-diff';
const uiStore = useUIStore();
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
oldString: string; oldString: string;
@@ -10,136 +14,51 @@ const props = withDefaults(
hideHeader?: boolean; hideHeader?: boolean;
forceInlineComparison?: boolean; forceInlineComparison?: boolean;
diffStyle?: 'word' | 'char'; diffStyle?: 'word' | 'char';
theme?: 'light' | 'dark';
}>(), }>(),
{ {
outputFormat: 'line-by-line', outputFormat: 'line-by-line',
language: 'json', language: 'json',
hideHeader: true, hideHeader: true,
diffStyle: 'word', diffStyle: 'word',
theme: undefined,
}, },
); );
</script> </script>
<template> <template>
<CodeDiff v-bind="props" class="code-diff" /> <CodeDiff v-bind="props" :class="$style.codeDiff" :theme="props.theme || uiStore.appliedTheme" />
</template> </template>
<style lang="scss"> <style lang="scss" module>
/* Diff colors are now centralized in @n8n/design-system tokens */ /* Diff colors are now centralized in @n8n/design-system tokens */
.code-diff { .codeDiff {
&:global(.code-diff-view) {
height: 100%; height: 100%;
margin: 0; margin: 0;
border: none; border: none;
border-radius: 0; border-radius: 0;
// Dark theme support for v-code-diff &:global([theme='dark']) {
[data-theme='dark'] & { --fgColor-default: var(--color-text-dark);
// Main container and wrapper (primary background) --bgColor-default: var(--color-background-light);
background: var(--color-background-light) !important; // Dark background in dark theme --color-fg-subtle: var(--color-text-light); // Muted text
color: var(--color-text-dark) !important; // In dark theme: light text
// Target all possible wrapper elements // deletions
> div, --color-diff-blob-deletion-num-bg: var(--diff-del-light);
.v-code-diff, --color-diff-blob-deletion-num-text: var(--color-text-xlight);
.v-code-diff-wrapper, --color-danger-emphasis: var(--diff-del);
.code-diff-wrapper,
.diff-wrapper { // insertions
background: var(--color-background-light) !important; --color-diff-blob-addition-num-text: var(--color-text-xlight);
--color-diff-blob-addition-num-bg: var(--diff-new-light);
--color-success-emphasis: var(--diff-new);
--color-diff-blob-hunk-num-bg: var(--color-background-medium);
:global(.blob-code-hunk) {
background-color: var(--color-diff-blob-hunk-num-bg);
} }
// Code diff view wrapper
.code-diff-view {
background: var(--color-background-light) !important; // Dark background
color: var(--color-text-dark) !important;
}
// Main table wrapper
.diff-table {
background: var(--color-background-light) !important; // Dark background
color: var(--color-text-dark) !important;
}
// Line numbers (slightly emphasized background)
.blob-num {
background: var(--color-background-darker) !important; // In dark theme: even lighter gray
color: var(--color-text-light) !important; // Muted text
border-color: var(--color-foreground-base) !important;
}
// Context lines (unchanged code - dark background for better contrast)
.blob-num-context {
background: var(--color-background-light) !important; // Dark background in dark theme
color: var(--color-text-light) !important; // Muted text
}
.blob-code-context {
background: var(--color-background-light) !important; // Dark background in dark theme
color: var(--color-text-dark) !important; // Primary text
}
// Added lines (insertions)
.blob-num-addition {
background: var(--diff-new-light) !important;
color: var(--color-text-xlight) !important;
border-color: var(--diff-new) !important;
}
.blob-code-addition {
background: var(--diff-new-faint) !important;
color: var(--color-text-xlight) !important;
}
// Deleted lines
.blob-num-deletion {
background: var(--diff-del-light) !important;
color: var(--color-text-xlight) !important;
border-color: var(--diff-del) !important;
}
.blob-code-deletion {
background: var(--diff-del-faint) !important;
color: var(--color-text-xlight) !important;
}
// Hunk headers
.blob-num-hunk,
.blob-code-hunk {
background: var(--color-background-medium) !important;
color: var(--color-text-base) !important;
}
// Code markers and inner content
.blob-code-inner {
color: var(--color-text-dark) !important;
}
// Syntax highlighting overrides for dark theme
.hljs-attr {
color: #79c0ff !important;
}
.hljs-string {
color: #a5d6ff !important;
}
.hljs-number {
color: #79c0ff !important;
}
.hljs-punctuation {
color: var(--color-text-light) !important;
}
.hljs-keyword,
.hljs-literal {
color: #ff7b72 !important;
}
// Character-level diff highlighting
.x {
background: rgba(255, 255, 255, 0.1) !important;
color: inherit !important;
} }
} }
} }

View File

@@ -20,7 +20,8 @@ import type { EventBus } from '@n8n/utils/event-bus';
import { useAsyncState } from '@vueuse/core'; import { useAsyncState } from '@vueuse/core';
import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus'; import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus';
import type { IWorkflowSettings } from 'n8n-workflow'; import type { IWorkflowSettings } from 'n8n-workflow';
import { computed, ref, useCssModule } from 'vue'; import { computed, onMounted, onUnmounted, ref, useCssModule } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import HighlightedEdge from './HighlightedEdge.vue'; import HighlightedEdge from './HighlightedEdge.vue';
import WorkflowDiffAside from './WorkflowDiffAside.vue'; import WorkflowDiffAside from './WorkflowDiffAside.vue';
@@ -35,6 +36,8 @@ const $style = useCssModule();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const sourceControlStore = useSourceControlStore(); const sourceControlStore = useSourceControlStore();
const i18n = useI18n(); const i18n = useI18n();
const router = useRouter();
const route = useRoute();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
@@ -118,6 +121,17 @@ const settingsDiff = computed(() => {
return acc; return acc;
}, []); }, []);
const sourceName = sourceWorkFlow.value.state.value?.workflow?.name;
const targetName = targetWorkFlow.value.state.value?.workflow?.name;
if (sourceName && targetName && sourceName !== targetName) {
settings.unshift({
name: 'name',
before: sourceName,
after: targetName,
});
}
const sourceTags = (sourceWorkFlow.value.state.value?.workflow?.tags ?? []).map((tag) => const sourceTags = (sourceWorkFlow.value.state.value?.workflow?.tags ?? []).map((tag) =>
typeof tag === 'string' ? tag : tag.name, typeof tag === 'string' ? tag : tag.name,
); );
@@ -260,8 +274,37 @@ const nodeDiffs = computed(() => {
function handleBeforeClose() { function handleBeforeClose() {
selectedDetailId.value = undefined; selectedDetailId.value = undefined;
// Check if we have history to go back to avoid empty navigation issues
if (window.history.length > 1) {
// Use router.back() to maintain proper navigation flow when possible
router.back();
} else {
// Fallback to query parameter manipulation when no navigation history
const newQuery = { ...route.query };
delete newQuery.diff;
delete newQuery.direction;
void router.replace({ query: newQuery });
}
} }
// Handle ESC key since Element Plus Dialog doesn't trigger before-close on ESC
function handleEscapeKey(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
handleBeforeClose();
}
}
onMounted(() => {
document.addEventListener('keydown', handleEscapeKey, true);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleEscapeKey, true);
});
const changesCount = computed( const changesCount = computed(
() => nodeChanges.value.length + connectionsDiff.value.size + settingsDiff.value.length, () => nodeChanges.value.length + connectionsDiff.value.size + settingsDiff.value.length,
); );
@@ -342,9 +385,10 @@ const modifiers = [
width="100%" width="100%"
max-width="100%" max-width="100%"
max-height="100%" max-height="100%"
:close-on-press-escape="false"
@before-close="handleBeforeClose" @before-close="handleBeforeClose"
> >
<template #header="{ closeDialog }"> <template #header>
<div :class="$style.header"> <div :class="$style.header">
<div :class="$style.headerLeft"> <div :class="$style.headerLeft">
<N8nIconButton <N8nIconButton
@@ -352,7 +396,7 @@ const modifiers = [
type="secondary" type="secondary"
:class="[$style.backButton, 'mr-xs']" :class="[$style.backButton, 'mr-xs']"
icon-size="large" icon-size="large"
@click="closeDialog" @click="handleBeforeClose"
></N8nIconButton> ></N8nIconButton>
<N8nHeading tag="h1" size="xlarge"> <N8nHeading tag="h1" size="xlarge">
{{ {{
@@ -362,7 +406,7 @@ const modifiers = [
</N8nHeading> </N8nHeading>
</div> </div>
<div> <div :class="$style.headerRight">
<ElDropdown <ElDropdown
trigger="click" trigger="click"
:popper-options="{ :popper-options="{
@@ -875,8 +919,8 @@ const modifiers = [
} }
.dropdownContent { .dropdownContent {
width: 320px; min-width: 320px;
padding: 2px 0 2px 12px; padding: 0 12px;
ul { ul {
list-style: none; list-style: none;
@@ -938,7 +982,8 @@ const modifiers = [
} }
} }
.headerLeft { .headerLeft,
.headerRight {
display: flex; display: flex;
align-items: center; align-items: center;
} }