mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 10:31:15 +00:00
fix(editor): Source control workflow diff release (#17974)
Co-authored-by: r00gm <raul00gm@gmail.com>
This commit is contained in:
@@ -7,7 +7,6 @@ import {
|
||||
MODAL_CONFIRM,
|
||||
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||
PROJECT_MOVE_RESOURCE_MODAL,
|
||||
SOURCE_CONTROL_PUSH_MODAL_KEY,
|
||||
VIEWS,
|
||||
WORKFLOW_MENU_ACTIONS,
|
||||
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||
@@ -41,7 +40,6 @@ import { getResourcePermissions } from '@n8n/permissions';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { nodeViewEventBus } from '@/event-bus';
|
||||
import { hasPermission } from '@/utils/rbac/permissions';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import { computed, ref, useCssModule, useTemplateRef, watch } from 'vue';
|
||||
@@ -82,7 +80,6 @@ const props = defineProps<{
|
||||
const $style = useCssModule();
|
||||
|
||||
const rootStore = useRootStore();
|
||||
const canvasStore = useCanvasStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const tagsStore = useTagsStore();
|
||||
@@ -112,7 +109,6 @@ const tagsSaving = ref(false);
|
||||
const importFileRef = ref<HTMLInputElement | undefined>();
|
||||
|
||||
const tagsEventBus = createEventBus();
|
||||
const sourceControlModalEventBus = createEventBus();
|
||||
const changeOwnerEventBus = createEventBus();
|
||||
|
||||
const hasChanged = (prev: string[], curr: string[]) => {
|
||||
@@ -488,15 +484,15 @@ async function onWorkflowMenuSelect(value: string): Promise<void> {
|
||||
break;
|
||||
}
|
||||
case WORKFLOW_MENU_ACTIONS.PUSH: {
|
||||
canvasStore.startLoading();
|
||||
try {
|
||||
await onSaveButtonClick();
|
||||
|
||||
const status = await sourceControlStore.getAggregatedStatus();
|
||||
|
||||
uiStore.openModalWithData({
|
||||
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
|
||||
data: { eventBus: sourceControlModalEventBus, status },
|
||||
// Navigate to route with sourceControl param - modal will handle data loading and loading states
|
||||
void router.push({
|
||||
query: {
|
||||
...route.query,
|
||||
sourceControl: 'push',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
@@ -511,8 +507,6 @@ async function onWorkflowMenuSelect(value: string): Promise<void> {
|
||||
default:
|
||||
toast.showError(error, locale.baseText('error'));
|
||||
}
|
||||
} finally {
|
||||
canvasStore.stopLoading();
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
@@ -3,27 +3,32 @@ import { waitFor } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import merge from 'lodash/merge';
|
||||
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants';
|
||||
import { reactive } from 'vue';
|
||||
import { STORES } from '@n8n/stores';
|
||||
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
||||
import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useRBACStore } from '@/stores/rbac.store';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
|
||||
let pinia: ReturnType<typeof createTestingPinia>;
|
||||
let sourceControlStore: ReturnType<typeof useSourceControlStore>;
|
||||
let uiStore: ReturnType<typeof useUIStore>;
|
||||
let rbacStore: ReturnType<typeof useRBACStore>;
|
||||
let projectStore: ReturnType<typeof useProjectsStore>;
|
||||
|
||||
const showMessage = vi.fn();
|
||||
const showError = vi.fn();
|
||||
const showToast = vi.fn();
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({ showMessage, showError, showToast }),
|
||||
const mockRoute = reactive({
|
||||
query: {},
|
||||
});
|
||||
|
||||
const mockRouterPush = vi.fn();
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => mockRoute,
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
}),
|
||||
RouterLink: vi.fn(),
|
||||
}));
|
||||
|
||||
const renderComponent = createComponentRenderer(MainSidebarSourceControl);
|
||||
@@ -31,6 +36,13 @@ const renderComponent = createComponentRenderer(MainSidebarSourceControl);
|
||||
describe('MainSidebarSourceControl', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Reset route mock to default values
|
||||
mockRoute.query = {};
|
||||
|
||||
// Reset router push mock
|
||||
mockRouterPush.mockReset();
|
||||
|
||||
pinia = createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.SETTINGS]: {
|
||||
@@ -45,8 +57,6 @@ describe('MainSidebarSourceControl', () => {
|
||||
|
||||
sourceControlStore = useSourceControlStore();
|
||||
vi.spyOn(sourceControlStore, 'isEnterpriseSourceControlEnabled', 'get').mockReturnValue(true);
|
||||
|
||||
uiStore = useUIStore();
|
||||
});
|
||||
|
||||
it('should render nothing when not instance owner', async () => {
|
||||
@@ -173,26 +183,7 @@ describe('MainSidebarSourceControl', () => {
|
||||
expect(pushButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should show toast error if pull response http status code is not 409', async () => {
|
||||
vi.spyOn(sourceControlStore, 'pullWorkfolder').mockRejectedValueOnce({
|
||||
response: { status: 400 },
|
||||
});
|
||||
const { getAllByRole } = renderComponent({
|
||||
pinia,
|
||||
props: { isCollapsed: false },
|
||||
});
|
||||
|
||||
await userEvent.click(getAllByRole('button')[0]);
|
||||
await waitFor(() => expect(showError).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('should show confirm if pull response http status code is 409', async () => {
|
||||
const status = {};
|
||||
vi.spyOn(sourceControlStore, 'pullWorkfolder').mockRejectedValueOnce({
|
||||
response: { status: 409, data: { data: status } },
|
||||
});
|
||||
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
|
||||
|
||||
it('should navigate to pull route when pull button is clicked', async () => {
|
||||
const { getAllByRole } = renderComponent({
|
||||
pinia,
|
||||
props: { isCollapsed: false },
|
||||
@@ -200,20 +191,15 @@ describe('MainSidebarSourceControl', () => {
|
||||
|
||||
await userEvent.click(getAllByRole('button')[0]);
|
||||
await waitFor(() =>
|
||||
expect(openModalSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: SOURCE_CONTROL_PULL_MODAL_KEY,
|
||||
data: expect.objectContaining({
|
||||
status,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
expect(mockRouterPush).toHaveBeenCalledWith({
|
||||
query: {
|
||||
sourceControl: 'pull',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show toast when there are no changes', async () => {
|
||||
vi.spyOn(sourceControlStore, 'getAggregatedStatus').mockResolvedValueOnce([]);
|
||||
|
||||
it('should navigate to push route when push button is clicked', async () => {
|
||||
const { getAllByRole } = renderComponent({
|
||||
pinia,
|
||||
props: { isCollapsed: false },
|
||||
@@ -221,43 +207,11 @@ describe('MainSidebarSourceControl', () => {
|
||||
|
||||
await userEvent.click(getAllByRole('button')[1]);
|
||||
await waitFor(() =>
|
||||
expect(showMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'No changes to commit' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should open push modal when there are changes', async () => {
|
||||
const status = [
|
||||
{
|
||||
id: '014da93897f146d2b880-baa374b9d02d',
|
||||
name: 'vuelfow2',
|
||||
type: 'workflow' as const,
|
||||
status: 'created' as const,
|
||||
location: 'local' as const,
|
||||
conflict: false,
|
||||
file: '/014da93897f146d2b880-baa374b9d02d.json',
|
||||
updatedAt: '2025-01-09T13:12:24.580Z',
|
||||
},
|
||||
];
|
||||
vi.spyOn(sourceControlStore, 'getAggregatedStatus').mockResolvedValueOnce(status);
|
||||
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
|
||||
|
||||
const { getAllByRole } = renderComponent({
|
||||
pinia,
|
||||
props: { isCollapsed: false },
|
||||
});
|
||||
|
||||
await userEvent.click(getAllByRole('button')[1]);
|
||||
await waitFor(() =>
|
||||
expect(openModalSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
|
||||
data: expect.objectContaining({
|
||||
status,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
expect(mockRouterPush).toHaveBeenCalledWith({
|
||||
query: {
|
||||
sourceControl: 'push',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { hasPermission } from '@/utils/rbac/permissions';
|
||||
import { getResourcePermissions } from '@n8n/permissions';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useLoadingService } from '@/composables/useLoadingService';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants';
|
||||
import { sourceControlEventBus } from '@/event-bus/source-control';
|
||||
import { notifyUserAboutPullWorkFolderOutcome } from '@/utils/sourceControlUtils';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
defineProps<{
|
||||
isCollapsed: boolean;
|
||||
}>();
|
||||
|
||||
const responseStatuses = {
|
||||
CONFLICT: 409,
|
||||
};
|
||||
|
||||
const loadingService = useLoadingService();
|
||||
const uiStore = useUIStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const projectStore = useProjectsStore();
|
||||
const toast = useToast();
|
||||
const i18n = useI18n();
|
||||
|
||||
const eventBus = createEventBus();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const tooltipOpenDelay = ref(300);
|
||||
|
||||
const currentBranch = computed(() => {
|
||||
@@ -57,57 +44,23 @@ const sourceControlAvailable = computed(
|
||||
);
|
||||
|
||||
async function pushWorkfolder() {
|
||||
loadingService.startLoading();
|
||||
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.checkingForChanges'));
|
||||
try {
|
||||
const status = await sourceControlStore.getAggregatedStatus();
|
||||
|
||||
if (!status.length) {
|
||||
toast.showMessage({
|
||||
title: 'No changes to commit',
|
||||
message: 'Everything is up to date',
|
||||
type: 'info',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
uiStore.openModalWithData({
|
||||
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
|
||||
data: { eventBus, status },
|
||||
});
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('error'));
|
||||
} finally {
|
||||
loadingService.stopLoading();
|
||||
loadingService.setLoadingText(i18n.baseText('genericHelpers.loading'));
|
||||
}
|
||||
// Navigate to route with sourceControl param - modal will handle data loading and loading states
|
||||
void router.push({
|
||||
query: {
|
||||
...route.query,
|
||||
sourceControl: 'push',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function pullWorkfolder() {
|
||||
loadingService.startLoading();
|
||||
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.pull'));
|
||||
|
||||
try {
|
||||
const status = await sourceControlStore.pullWorkfolder(false);
|
||||
|
||||
await notifyUserAboutPullWorkFolderOutcome(status, toast);
|
||||
|
||||
sourceControlEventBus.emit('pull');
|
||||
} catch (error) {
|
||||
const errorResponse = error.response;
|
||||
|
||||
if (errorResponse?.status === responseStatuses.CONFLICT) {
|
||||
uiStore.openModalWithData({
|
||||
name: SOURCE_CONTROL_PULL_MODAL_KEY,
|
||||
data: { eventBus, status: errorResponse.data.data },
|
||||
});
|
||||
} else {
|
||||
toast.showError(error, 'Error');
|
||||
}
|
||||
} finally {
|
||||
loadingService.stopLoading();
|
||||
loadingService.setLoadingText(i18n.baseText('genericHelpers.loading'));
|
||||
}
|
||||
function pullWorkfolder() {
|
||||
// Navigate to route with sourceControl param - modal will handle the pull operation
|
||||
void router.push({
|
||||
query: {
|
||||
...route.query,
|
||||
sourceControl: 'pull',
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -166,9 +166,9 @@ const projectLocation = computed(() => {
|
||||
>
|
||||
<ProjectIcon :icon="badgeIcon" :border-less="true" size="mini" />
|
||||
<router-link v-if="projectLocation" :to="projectLocation">
|
||||
<span v-n8n-truncate:20="badgeText" />
|
||||
<span v-n8n-truncate:20="badgeText" :class="$style.nowrap" />
|
||||
</router-link>
|
||||
<span v-else v-n8n-truncate:20="badgeText" />
|
||||
<span v-else v-n8n-truncate:20="badgeText" :class="$style.nowrap" />
|
||||
</N8nBadge>
|
||||
<template #content>
|
||||
{{ badgeTooltip }}
|
||||
@@ -230,4 +230,8 @@ const projectLocation = computed(() => {
|
||||
border-left: var(--border-base);
|
||||
line-height: var(--font-line-height-regular);
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { waitFor } from '@testing-library/dom';
|
||||
import { reactive } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { defaultSettings } from '@/__tests__/defaults';
|
||||
|
||||
const eventBus = createEventBus();
|
||||
|
||||
@@ -21,6 +23,7 @@ const mockRoute = reactive({
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => mockRoute,
|
||||
useRouter: () => ({
|
||||
back: vi.fn(),
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
go: vi.fn(),
|
||||
@@ -31,6 +34,14 @@ vi.mock('vue-router', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/composables/useLoadingService', () => ({
|
||||
useLoadingService: () => ({
|
||||
startLoading: vi.fn(),
|
||||
stopLoading: vi.fn(),
|
||||
setLoadingText: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the toast composable to prevent Element Plus DOM errors
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
@@ -47,6 +58,7 @@ const DynamicScrollerStub = {
|
||||
minItemSize: Number,
|
||||
class: String,
|
||||
style: [String, Object],
|
||||
itemClass: String,
|
||||
},
|
||||
template:
|
||||
'<div><template v-for="(item, index) in items" :key="index"><slot v-bind="{ item, index, active: false }"></slot></template></div>',
|
||||
@@ -80,13 +92,14 @@ const renderModal = createComponentRenderer(SourceControlPullModalEe, {
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
EnvFeatureFlag: {
|
||||
template: '<div><slot></slot></div>',
|
||||
},
|
||||
N8nIconButton: {
|
||||
template: '<button><slot></slot></button>',
|
||||
props: ['icon', 'type', 'class'],
|
||||
},
|
||||
'router-link': {
|
||||
template: '<a><slot /></a>',
|
||||
props: ['to'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -116,18 +129,29 @@ const sampleFiles = [
|
||||
|
||||
describe('SourceControlPullModal', () => {
|
||||
let sourceControlStore: ReturnType<typeof mockedStore<typeof useSourceControlStore>>;
|
||||
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
|
||||
let pinia: ReturnType<typeof createTestingPinia>;
|
||||
|
||||
beforeEach(() => {
|
||||
createTestingPinia();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup store with default mock to prevent automatic data loading
|
||||
pinia = createTestingPinia();
|
||||
sourceControlStore = mockedStore(useSourceControlStore);
|
||||
sourceControlStore.getAggregatedStatus = vi.fn().mockResolvedValue([]);
|
||||
sourceControlStore.pullWorkfolder = vi.fn().mockResolvedValue([]);
|
||||
|
||||
settingsStore = mockedStore(useSettingsStore);
|
||||
settingsStore.settings.enterprise = defaultSettings.enterprise;
|
||||
});
|
||||
|
||||
it('mounts', () => {
|
||||
const { getByText } = renderModal({
|
||||
pinia,
|
||||
props: {
|
||||
data: {
|
||||
eventBus,
|
||||
status: [],
|
||||
status: [], // Provide initial status to prevent auto-loading
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -136,6 +160,7 @@ describe('SourceControlPullModal', () => {
|
||||
|
||||
it('should renders the changes', () => {
|
||||
const { getAllByTestId } = renderModal({
|
||||
pinia,
|
||||
props: {
|
||||
data: {
|
||||
eventBus,
|
||||
@@ -150,7 +175,9 @@ describe('SourceControlPullModal', () => {
|
||||
});
|
||||
|
||||
it('should force pull', async () => {
|
||||
// Use the existing store instance from beforeEach
|
||||
const { getByTestId } = renderModal({
|
||||
pinia,
|
||||
props: {
|
||||
data: {
|
||||
eventBus,
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
import { useLoadingService } from '@/composables/useLoadingService';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { SOURCE_CONTROL_PULL_MODAL_KEY, VIEWS, WORKFLOW_DIFF_MODAL_KEY } from '@/constants';
|
||||
import { SOURCE_CONTROL_PULL_MODAL_KEY, VIEWS } from '@/constants';
|
||||
import { sourceControlEventBus } from '@/event-bus/source-control';
|
||||
import EnvFeatureFlag from '@/features/env-feature-flag/EnvFeatureFlag.vue';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import type { ProjectListItem } from '@/types/projects.types';
|
||||
import {
|
||||
getPullPriorityByStatus,
|
||||
@@ -19,11 +18,10 @@ import { type SourceControlledFile, SOURCE_CONTROL_FILE_TYPE } from '@n8n/api-ty
|
||||
import { N8nBadge, N8nButton, N8nHeading, N8nInfoTip, N8nLink, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { EventBus } from '@n8n/utils/event-bus';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import dateformat from 'dateformat';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import { computed, onBeforeMount, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { computed, onBeforeMount, onMounted, ref } from 'vue';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
import Modal from './Modal.vue';
|
||||
@@ -32,17 +30,58 @@ type SourceControlledFileType = SourceControlledFile['type'];
|
||||
type SourceControlledFileWithProject = SourceControlledFile & { project?: ProjectListItem };
|
||||
|
||||
const props = defineProps<{
|
||||
data: { eventBus: EventBus; status: SourceControlledFile[] };
|
||||
data: { eventBus: EventBus; status?: SourceControlledFile[] };
|
||||
}>();
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
const loadingService = useLoadingService();
|
||||
const uiStore = useUIStore();
|
||||
const toast = useToast();
|
||||
const i18n = useI18n();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const isWorkflowDiffsEnabled = computed(() => settingsStore.settings.enterprise.workflowDiffs);
|
||||
|
||||
// Reactive status state - starts with props data or empty, then loads fresh data
|
||||
const status = ref<SourceControlledFile[]>(props.data.status || []);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const responseStatuses = {
|
||||
CONFLICT: 409,
|
||||
};
|
||||
|
||||
// Load fresh source control status when modal opens
|
||||
async function loadSourceControlStatus() {
|
||||
if (isLoading.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
loadingService.startLoading();
|
||||
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.checkingForChanges'));
|
||||
|
||||
try {
|
||||
const freshStatus = await sourceControlStore.pullWorkfolder(false);
|
||||
await notifyUserAboutPullWorkFolderOutcome(freshStatus, toast);
|
||||
sourceControlEventBus.emit('pull');
|
||||
close();
|
||||
} catch (error) {
|
||||
// only show the modal when there are conflicts
|
||||
const errorResponse = error.response;
|
||||
|
||||
if (errorResponse?.status === responseStatuses.CONFLICT) {
|
||||
status.value = errorResponse.data.data || [];
|
||||
} else {
|
||||
toast.showError(error, 'Error');
|
||||
close();
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
loadingService.stopLoading();
|
||||
loadingService.setLoadingText(i18n.baseText('genericHelpers.loading'));
|
||||
}
|
||||
}
|
||||
onBeforeMount(() => {
|
||||
void projectsStore.getAvailableProjects();
|
||||
});
|
||||
@@ -52,18 +91,10 @@ const activeTab = ref<
|
||||
typeof SOURCE_CONTROL_FILE_TYPE.workflow | typeof SOURCE_CONTROL_FILE_TYPE.credential
|
||||
>(SOURCE_CONTROL_FILE_TYPE.workflow);
|
||||
|
||||
// Group files by type with project information
|
||||
const filesWithProjects = computed(() =>
|
||||
props.data.status.map((file) => {
|
||||
const project = projectsStore.availableProjects.find(({ id }) => id === file.owner?.projectId);
|
||||
return { ...file, project };
|
||||
}),
|
||||
);
|
||||
|
||||
const groupedFilesByType = computed(() => {
|
||||
const grouped: Partial<Record<SourceControlledFileType, SourceControlledFileWithProject[]>> = {};
|
||||
|
||||
filesWithProjects.value.forEach((file) => {
|
||||
status.value.forEach((file) => {
|
||||
if (!grouped[file.type]) {
|
||||
grouped[file.type] = [];
|
||||
}
|
||||
@@ -156,17 +187,19 @@ const otherFiles = computed(() => {
|
||||
});
|
||||
|
||||
function close() {
|
||||
uiStore.closeModal(SOURCE_CONTROL_PULL_MODAL_KEY);
|
||||
// Navigate back in history to maintain proper browser navigation
|
||||
// The global route watcher will handle closing the modal
|
||||
router.back();
|
||||
}
|
||||
|
||||
async function pullWorkfolder() {
|
||||
loadingService.startLoading(i18n.baseText('settings.sourceControl.loading.pull'));
|
||||
loadingService.startLoading(i18n.baseText('settings.sourceControl.loading.checkingForChanges'));
|
||||
close();
|
||||
|
||||
try {
|
||||
const status = await sourceControlStore.pullWorkfolder(true);
|
||||
const pullStatus = await sourceControlStore.pullWorkfolder(true);
|
||||
|
||||
await notifyUserAboutPullWorkFolderOutcome(status, toast);
|
||||
await notifyUserAboutPullWorkFolderOutcome(pullStatus, toast);
|
||||
|
||||
sourceControlEventBus.emit('pull');
|
||||
} catch (error) {
|
||||
@@ -190,16 +223,19 @@ function renderUpdatedAt(file: SourceControlledFile) {
|
||||
});
|
||||
}
|
||||
|
||||
const workflowDiffEventBus = createEventBus();
|
||||
|
||||
function openDiffModal(id: string) {
|
||||
telemetry.track('User clicks compare workflows', {
|
||||
workflow_id: id,
|
||||
context: 'source_control_pull',
|
||||
});
|
||||
uiStore.openModalWithData({
|
||||
name: WORKFLOW_DIFF_MODAL_KEY,
|
||||
data: { eventBus: workflowDiffEventBus, workflowId: id, direction: 'pull' },
|
||||
|
||||
// Only update route - modal will be opened by route watcher
|
||||
void router.push({
|
||||
query: {
|
||||
...route.query,
|
||||
diff: id,
|
||||
direction: 'pull',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -209,15 +245,25 @@ const modalHeight = computed(() =>
|
||||
? 'min(80vh, 850px)'
|
||||
: 'auto',
|
||||
);
|
||||
|
||||
// Load data when modal opens
|
||||
onMounted(() => {
|
||||
// Only load fresh data if we don't have any initial data
|
||||
if (!props.data.status || props.data.status.length === 0) {
|
||||
void loadSourceControlStatus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
v-if="!isLoading"
|
||||
width="812px"
|
||||
:event-bus="data.eventBus"
|
||||
:name="SOURCE_CONTROL_PULL_MODAL_KEY"
|
||||
:height="modalHeight"
|
||||
:custom-class="$style.sourceControlPull"
|
||||
:before-close="close"
|
||||
>
|
||||
<template #header>
|
||||
<N8nHeading tag="h1" size="xlarge">
|
||||
@@ -227,7 +273,6 @@ const modalHeight = computed(() =>
|
||||
<div :class="[$style.filtersRow]" class="mt-l">
|
||||
<N8nText tag="div" class="mb-xs">
|
||||
{{ i18n.baseText('settings.sourceControl.modals.pull.description') }}
|
||||
<br />
|
||||
<N8nLink :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')">
|
||||
{{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }}
|
||||
</N8nLink>
|
||||
@@ -235,16 +280,7 @@ const modalHeight = computed(() =>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div v-if="!tabs.some((tab) => tab.total > 0)">
|
||||
<N8nText tag="div" class="mb-xs">
|
||||
{{ i18n.baseText('settings.sourceControl.modals.pull.description') }}
|
||||
<br />
|
||||
<N8nLink :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')">
|
||||
{{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }}
|
||||
</N8nLink>
|
||||
</N8nText>
|
||||
</div>
|
||||
<div v-else style="display: flex; height: 100%">
|
||||
<div style="display: flex; height: 100%">
|
||||
<div :class="$style.tabs">
|
||||
<template v-for="tab in tabs" :key="tab.value">
|
||||
<button
|
||||
@@ -318,14 +354,14 @@ const modalHeight = computed(() =>
|
||||
<N8nBadge :theme="getStatusTheme(file.status)" style="height: 25px">
|
||||
{{ getStatusText(file.status) }}
|
||||
</N8nBadge>
|
||||
<EnvFeatureFlag name="SOURCE_CONTROL_WORKFLOW_DIFF">
|
||||
<template v-if="isWorkflowDiffsEnabled">
|
||||
<N8nIconButton
|
||||
v-if="file.type === SOURCE_CONTROL_FILE_TYPE.workflow"
|
||||
icon="file-diff"
|
||||
type="secondary"
|
||||
@click="openDiffModal(file.id)"
|
||||
/>
|
||||
</EnvFeatureFlag>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</DynamicScrollerItem>
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import type { ProjectListItem } from '@/types/projects.types';
|
||||
import { reactive } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { defaultSettings } from '@/__tests__/defaults';
|
||||
|
||||
const eventBus = createEventBus();
|
||||
|
||||
@@ -24,8 +26,16 @@ const mockRoute = reactive({
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => mockRoute,
|
||||
RouterLink: vi.fn(),
|
||||
useRouter: vi.fn(),
|
||||
useRouter: () => ({
|
||||
back: vi.fn(),
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
go: vi.fn(),
|
||||
}),
|
||||
RouterLink: {
|
||||
template: '<a><slot></slot></a>',
|
||||
props: ['to', 'target'],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/composables/useTelemetry', () => {
|
||||
@@ -39,6 +49,14 @@ vi.mock('@/composables/useTelemetry', () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/composables/useLoadingService', () => ({
|
||||
useLoadingService: () => ({
|
||||
startLoading: vi.fn(),
|
||||
stopLoading: vi.fn(),
|
||||
setLoadingText: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
showMessage: vi.fn(),
|
||||
@@ -49,14 +67,6 @@ vi.mock('@/composables/useToast', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/composables/useLoadingService', () => ({
|
||||
useLoadingService: () => ({
|
||||
startLoading: vi.fn(),
|
||||
stopLoading: vi.fn(),
|
||||
setLoading: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
let telemetry: ReturnType<typeof useTelemetry>;
|
||||
|
||||
const DynamicScrollerStub = {
|
||||
@@ -80,7 +90,7 @@ const DynamicScrollerItemStub = {
|
||||
sizeDependencies: Array,
|
||||
dataIndex: Number,
|
||||
},
|
||||
template: '<div><slot></slot></div>',
|
||||
template: '<slot></slot>',
|
||||
};
|
||||
|
||||
const projects = [
|
||||
@@ -111,29 +121,45 @@ const renderModal = createComponentRenderer(SourceControlPushModal, {
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
'router-link': {
|
||||
template: '<a><slot /></a>',
|
||||
props: ['to'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('SourceControlPushModal', () => {
|
||||
let sourceControlStore: ReturnType<typeof mockedStore<typeof useSourceControlStore>>;
|
||||
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
|
||||
let pinia: ReturnType<typeof createTestingPinia>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset route mock to default values
|
||||
mockRoute.name = '';
|
||||
mockRoute.params = {};
|
||||
mockRoute.fullPath = '';
|
||||
|
||||
telemetry = useTelemetry();
|
||||
createTestingPinia();
|
||||
|
||||
// Reset route mock to default values
|
||||
mockRoute.name = 'default';
|
||||
mockRoute.params = {};
|
||||
mockRoute.fullPath = '/';
|
||||
|
||||
// Setup store with default mock to prevent automatic data loading
|
||||
pinia = createTestingPinia();
|
||||
sourceControlStore = mockedStore(useSourceControlStore);
|
||||
sourceControlStore.getAggregatedStatus = vi.fn().mockResolvedValue([]);
|
||||
sourceControlStore.pushWorkfolder = vi.fn().mockResolvedValue([]);
|
||||
|
||||
settingsStore = mockedStore(useSettingsStore);
|
||||
settingsStore.settings.enterprise = defaultSettings.enterprise;
|
||||
});
|
||||
|
||||
it('mounts', () => {
|
||||
const { getByText } = renderModal({
|
||||
pinia: createTestingPinia(),
|
||||
pinia,
|
||||
props: {
|
||||
data: {
|
||||
eventBus,
|
||||
status: [],
|
||||
status: [], // Provide initial status to prevent auto-loading
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -273,9 +299,8 @@ describe('SourceControlPushModal', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const sourceControlStore = mockedStore(useSourceControlStore);
|
||||
|
||||
const { getByTestId, getByRole } = renderModal({
|
||||
pinia,
|
||||
props: {
|
||||
data: {
|
||||
eventBus,
|
||||
@@ -311,7 +336,7 @@ describe('SourceControlPushModal', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should auto select currentWorkflow', async () => {
|
||||
it('should allow selecting currentWorkflow and enable commit', async () => {
|
||||
const status: SourceControlledFile[] = [
|
||||
{
|
||||
id: 'gTbbBkkYTnNyX1jD',
|
||||
@@ -339,6 +364,7 @@ describe('SourceControlPushModal', () => {
|
||||
mockRoute.params = { name: 'gTbbBkkYTnNyX1jD' };
|
||||
|
||||
const { getByTestId, getAllByTestId } = renderModal({
|
||||
pinia,
|
||||
props: {
|
||||
data: {
|
||||
eventBus,
|
||||
@@ -347,16 +373,15 @@ describe('SourceControlPushModal', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const submitButton = getByTestId('source-control-push-modal-submit');
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
const files = getAllByTestId('source-control-push-modal-file-checkbox');
|
||||
expect(files).toHaveLength(2);
|
||||
|
||||
await waitFor(() => expect(within(files[0]).getByRole('checkbox')).toBeChecked());
|
||||
// The current workflow should be auto-selected now that we fixed the regression
|
||||
expect(within(files[0]).getByRole('checkbox')).toBeChecked();
|
||||
expect(within(files[1]).getByRole('checkbox')).not.toBeChecked();
|
||||
|
||||
await userEvent.type(getByTestId('source-control-push-modal-commit'), 'message');
|
||||
const submitButton = getByTestId('source-control-push-modal-submit');
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -664,9 +689,10 @@ describe('SourceControlPushModal', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const sourceControlStore = mockedStore(useSourceControlStore);
|
||||
// Use the existing store instance from beforeEach
|
||||
|
||||
const { getByTestId } = renderModal({
|
||||
pinia,
|
||||
props: {
|
||||
data: {
|
||||
eventBus,
|
||||
@@ -706,9 +732,10 @@ describe('SourceControlPushModal', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const sourceControlStore = mockedStore(useSourceControlStore);
|
||||
// Use the existing store instance from beforeEach
|
||||
|
||||
const { getByTestId } = renderModal({
|
||||
pinia,
|
||||
props: {
|
||||
data: {
|
||||
eventBus,
|
||||
@@ -740,12 +767,13 @@ describe('SourceControlPushModal', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const sourceControlStore = mockedStore(useSourceControlStore);
|
||||
// Use the existing store instance from beforeEach
|
||||
|
||||
mockRoute.name = 'SOME_OTHER_VIEW';
|
||||
mockRoute.params = { name: 'differentId' };
|
||||
|
||||
const { getByTestId, getAllByTestId } = renderModal({
|
||||
pinia,
|
||||
props: {
|
||||
data: {
|
||||
eventBus,
|
||||
|
||||
@@ -3,12 +3,11 @@ import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
|
||||
import { useLoadingService } from '@/composables/useLoadingService';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS, WORKFLOW_DIFF_MODAL_KEY } from '@/constants';
|
||||
import EnvFeatureFlag from '@/features/env-feature-flag/EnvFeatureFlag.vue';
|
||||
import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants';
|
||||
import type { WorkflowResource } from '@/Interface';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types';
|
||||
import { ResourceType } from '@/utils/projects.utils';
|
||||
@@ -37,29 +36,68 @@ import {
|
||||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { EventBus } from '@n8n/utils/event-bus';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { refDebounced, useStorage } from '@vueuse/core';
|
||||
import dateformat from 'dateformat';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import { computed, onBeforeMount, onMounted, reactive, ref, toRaw, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { computed, onBeforeMount, onMounted, reactive, ref, toRaw, watch, watchEffect } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
import Modal from './Modal.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
data: { eventBus: EventBus; status: SourceControlledFile[] };
|
||||
data: { eventBus: EventBus; status?: SourceControlledFile[] };
|
||||
}>();
|
||||
|
||||
const loadingService = useLoadingService();
|
||||
const uiStore = useUIStore();
|
||||
const toast = useToast();
|
||||
const i18n = useI18n();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const telemetry = useTelemetry();
|
||||
const usersStore = useUsersStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const isWorkflowDiffsEnabled = computed(() => settingsStore.settings.enterprise.workflowDiffs);
|
||||
|
||||
// Reactive status state - starts with props data or empty, then loads fresh data
|
||||
const status = ref<SourceControlledFile[]>(props.data.status ?? []);
|
||||
const isLoading = ref(false);
|
||||
|
||||
// Load fresh source control status when modal opens
|
||||
async function loadSourceControlStatus() {
|
||||
if (isLoading.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
loadingService.startLoading();
|
||||
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.checkingForChanges'));
|
||||
|
||||
try {
|
||||
const freshStatus = await sourceControlStore.getAggregatedStatus();
|
||||
|
||||
if (!freshStatus.length) {
|
||||
toast.showMessage({
|
||||
title: 'No changes to commit',
|
||||
message: 'Everything is up to date',
|
||||
type: 'info',
|
||||
});
|
||||
// Close modal since there's nothing to show
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
status.value = freshStatus;
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('error'));
|
||||
close();
|
||||
} finally {
|
||||
loadingService.stopLoading();
|
||||
loadingService.setLoadingText(i18n.baseText('genericHelpers.loading'));
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const projectAdminCalloutDismissed = useStorage(
|
||||
'SOURCE_CONTROL_PROJECT_ADMIN_CALLOUT_DISMISSED',
|
||||
@@ -182,15 +220,13 @@ const workflowId = computed(
|
||||
([VIEWS.WORKFLOW].includes(route.name as VIEWS) && route.params.name?.toString()) || undefined,
|
||||
);
|
||||
|
||||
const changes = computed(() => classifyFilesByType(props.data.status, workflowId.value));
|
||||
const changes = computed(() => classifyFilesByType(status.value, workflowId.value));
|
||||
|
||||
const selectedWorkflows = reactive<Set<string>>(new Set());
|
||||
|
||||
const maybeSelectCurrentWorkflow = (workflow?: SourceControlledFileWithProject) =>
|
||||
workflow && selectedWorkflows.add(workflow.id);
|
||||
|
||||
onMounted(() => maybeSelectCurrentWorkflow(changes.value.currentWorkflow));
|
||||
|
||||
const currentProject = computed(() => {
|
||||
if (!route.params.projectId) {
|
||||
return null;
|
||||
@@ -361,7 +397,9 @@ function onToggleSelectAll() {
|
||||
}
|
||||
|
||||
function close() {
|
||||
uiStore.closeModal(SOURCE_CONTROL_PUSH_MODAL_KEY);
|
||||
// Navigate back in history to maintain proper browser navigation
|
||||
// The useWorkflowDiffRouting composable will handle closing the modal
|
||||
router.back();
|
||||
}
|
||||
|
||||
function renderUpdatedAt(file: SourceControlledFile) {
|
||||
@@ -591,27 +629,47 @@ function castProject(project: ProjectListItem): WorkflowResource {
|
||||
return resource;
|
||||
}
|
||||
|
||||
const workflowDiffEventBus = createEventBus();
|
||||
|
||||
function openDiffModal(id: string) {
|
||||
telemetry.track('User clicks compare workflows', {
|
||||
workflow_id: id,
|
||||
context: 'source_control_push',
|
||||
});
|
||||
uiStore.openModalWithData({
|
||||
name: WORKFLOW_DIFF_MODAL_KEY,
|
||||
data: { eventBus: workflowDiffEventBus, workflowId: id, direction: 'push' },
|
||||
|
||||
// Only update route - modal will be opened by route watcher
|
||||
void router.push({
|
||||
query: {
|
||||
...route.query,
|
||||
diff: id,
|
||||
direction: 'push',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-select current workflow when it becomes available
|
||||
watchEffect(() => {
|
||||
if (changes.value.currentWorkflow && !selectedWorkflows.has(changes.value.currentWorkflow.id)) {
|
||||
maybeSelectCurrentWorkflow(changes.value.currentWorkflow);
|
||||
}
|
||||
});
|
||||
|
||||
// Load data when modal opens
|
||||
onMounted(async () => {
|
||||
// Only load fresh data if we don't have any initial data
|
||||
if (!props.data.status || props.data.status.length === 0) {
|
||||
await loadSourceControlStatus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
v-if="!isLoading"
|
||||
width="812px"
|
||||
:event-bus="data.eventBus"
|
||||
:name="SOURCE_CONTROL_PUSH_MODAL_KEY"
|
||||
:height="modalHeight"
|
||||
:custom-class="$style.sourceControlPush"
|
||||
:before-close="close"
|
||||
>
|
||||
<template #header>
|
||||
<N8nHeading tag="h1" size="xlarge">
|
||||
@@ -830,14 +888,14 @@ function openDiffModal(id: string) {
|
||||
<N8nBadge :theme="getStatusTheme(file.status)" style="height: 25px">
|
||||
{{ getStatusText(file.status) }}
|
||||
</N8nBadge>
|
||||
<EnvFeatureFlag name="SOURCE_CONTROL_WORKFLOW_DIFF">
|
||||
<template v-if="isWorkflowDiffsEnabled">
|
||||
<N8nIconButton
|
||||
v-if="file.type === SOURCE_CONTROL_FILE_TYPE.workflow"
|
||||
icon="file-diff"
|
||||
type="secondary"
|
||||
@click="openDiffModal(file.id)"
|
||||
/>
|
||||
</EnvFeatureFlag>
|
||||
</template>
|
||||
</span>
|
||||
</N8nCheckbox>
|
||||
</DynamicScrollerItem>
|
||||
|
||||
Reference in New Issue
Block a user