mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
fix(editor): Source control workflow diff release (#17974)
Co-authored-by: r00gm <raul00gm@gmail.com>
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}),
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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({
|
function pullWorkfolder() {
|
||||||
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
|
// Navigate to route with sourceControl param - modal will handle the pull operation
|
||||||
data: { eventBus, status },
|
void router.push({
|
||||||
|
query: {
|
||||||
|
...route.query,
|
||||||
|
sourceControl: 'pull',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
toast.showError(error, i18n.baseText('error'));
|
|
||||||
} finally {
|
|
||||||
loadingService.stopLoading();
|
|
||||||
loadingService.setLoadingText(i18n.baseText('genericHelpers.loading'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +274,36 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user