mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
fix(editor): Update SourceControlPullModal to look and feel the same as SourceControlPushModal (#18129)
Co-authored-by: r00gm <raul00gm@gmail.com>
This commit is contained in:
@@ -514,7 +514,7 @@
|
|||||||
--color-configurable-node-name: var(--color-text-dark);
|
--color-configurable-node-name: var(--color-text-dark);
|
||||||
--color-secondary-link: var(--p-color-secondary-270);
|
--color-secondary-link: var(--p-color-secondary-270);
|
||||||
--color-secondary-link-hover: var(--p-color-secondary-370);
|
--color-secondary-link-hover: var(--p-color-secondary-370);
|
||||||
//Params
|
// Params
|
||||||
--color-icon-base: var(--color-text-light);
|
--color-icon-base: var(--color-text-light);
|
||||||
--color-icon-hover: var(--p-color-primary-320);
|
--color-icon-hover: var(--p-color-primary-320);
|
||||||
|
|
||||||
|
|||||||
@@ -3407,7 +3407,10 @@
|
|||||||
"workflowDiff.local": "Local",
|
"workflowDiff.local": "Local",
|
||||||
"workflowDiff.remote": "Remote ({branchName})",
|
"workflowDiff.remote": "Remote ({branchName})",
|
||||||
"workflowDiff.noChanges": "No changes",
|
"workflowDiff.noChanges": "No changes",
|
||||||
"workflowDiff.deletedWorkflow": "Deleted workflow",
|
"workflowDiff.deletedWorkflow": "Missing workflow",
|
||||||
"workflowDiff.deletedWorkflow.database": "The workflow was deleted on the database",
|
"workflowDiff.deletedWorkflow.database": "The workflow doesn't exist in the database",
|
||||||
"workflowDiff.deletedWorkflow.remote": "The workflow was deleted on remote"
|
"workflowDiff.deletedWorkflow.remote": "The workflow doesn't exist on remote",
|
||||||
|
"workflowDiff.newWorkflow": "New workflow",
|
||||||
|
"workflowDiff.newWorkflow.database": "The workflow will be created in the database",
|
||||||
|
"workflowDiff.newWorkflow.remote": "The workflow will be created on remote"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ const sampleFiles = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('SourceControlPushModal', () => {
|
describe('SourceControlPullModal', () => {
|
||||||
let sourceControlStore: ReturnType<typeof mockedStore<typeof useSourceControlStore>>;
|
let sourceControlStore: ReturnType<typeof mockedStore<typeof useSourceControlStore>>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -144,8 +144,9 @@ describe('SourceControlPushModal', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getAllByTestId('pull-modal-item-header').length).toBe(2);
|
// The new structure renders items in a tabbed interface
|
||||||
expect(getAllByTestId('pull-modal-item').length).toBe(2);
|
// Both items should be rendered (one workflow, one credential)
|
||||||
|
expect(getAllByTestId('pull-modal-item').length).toBe(1); // Only workflow tab items are shown initially
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should force pull', async () => {
|
it('should force pull', async () => {
|
||||||
@@ -183,13 +184,13 @@ describe('SourceControlPushModal', () => {
|
|||||||
expect(diffButton).toBeInTheDocument();
|
expect(diffButton).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not render diff button for non-workflow items', () => {
|
it('should not render diff button for non-workflow items', async () => {
|
||||||
const credentialFile = {
|
const credentialFile = {
|
||||||
...sampleFiles[1], // credential file
|
...sampleFiles[1], // credential file
|
||||||
type: 'credential',
|
type: 'credential',
|
||||||
};
|
};
|
||||||
|
|
||||||
const { container } = renderModal({
|
const { container, getByText } = renderModal({
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -198,10 +199,13 @@ describe('SourceControlPushModal', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// For credential files, there should be no additional buttons in the item actions
|
// Click on credentials tab to show credential items
|
||||||
const itemActions = container.querySelector('[class*="itemActions"]');
|
await userEvent.click(getByText('Credentials'));
|
||||||
const buttons = itemActions?.querySelectorAll('button');
|
|
||||||
expect(buttons).toHaveLength(0);
|
// For credential files, there should be no diff buttons (only badges in the badges container)
|
||||||
|
const badges = container.querySelector('[class*="badges"]');
|
||||||
|
const buttons = badges?.querySelectorAll('button');
|
||||||
|
expect(buttons?.length || 0).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render item names with ellipsis for long text', () => {
|
it('should render item names with ellipsis for long text', () => {
|
||||||
@@ -219,13 +223,14 @@ describe('SourceControlPushModal', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if the itemName container exists and has the proper structure
|
// Check if the listItemName container exists
|
||||||
const nameContainer = container.querySelector('[class*="itemName"]');
|
const nameContainer = container.querySelector('[class*="listItemName"]');
|
||||||
expect(nameContainer).toBeInTheDocument();
|
expect(nameContainer).toBeInTheDocument();
|
||||||
|
|
||||||
// Check if the RouterLink stub is rendered (since the name is rendered inside it)
|
// Check if the RouterLink stub is rendered (since the name is rendered inside it)
|
||||||
const routerLink = nameContainer?.querySelector('a');
|
const routerLink = nameContainer?.querySelector('a');
|
||||||
expect(routerLink).toBeInTheDocument();
|
expect(routerLink).toBeInTheDocument();
|
||||||
|
expect(routerLink?.textContent).toContain(longNameFile.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render badges and actions in separate container', () => {
|
it('should render badges and actions in separate container', () => {
|
||||||
@@ -240,14 +245,10 @@ describe('SourceControlPushModal', () => {
|
|||||||
|
|
||||||
const listItems = getAllByTestId('pull-modal-item');
|
const listItems = getAllByTestId('pull-modal-item');
|
||||||
|
|
||||||
// Each list item should have the new structure with itemActions container
|
// Each list item should have the new structure with badges container
|
||||||
listItems.forEach((item) => {
|
listItems.forEach((item) => {
|
||||||
const actionsContainer = item.querySelector('[class*="itemActions"]');
|
const badgesContainer = item.querySelector('[class*="badges"]');
|
||||||
expect(actionsContainer).toBeInTheDocument();
|
expect(badgesContainer).toBeInTheDocument();
|
||||||
|
|
||||||
// Badge should be inside actions container
|
|
||||||
const badge = actionsContainer?.querySelector('[class*="listBadge"]');
|
|
||||||
expect(badge).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ 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, WORKFLOW_DIFF_MODAL_KEY } 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 EnvFeatureFlag from '@/features/env-feature-flag/EnvFeatureFlag.vue';
|
||||||
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import type { ProjectListItem } from '@/types/projects.types';
|
||||||
import {
|
import {
|
||||||
getPullPriorityByStatus,
|
getPullPriorityByStatus,
|
||||||
getStatusText,
|
getStatusText,
|
||||||
@@ -14,19 +16,20 @@ import {
|
|||||||
notifyUserAboutPullWorkFolderOutcome,
|
notifyUserAboutPullWorkFolderOutcome,
|
||||||
} from '@/utils/sourceControlUtils';
|
} from '@/utils/sourceControlUtils';
|
||||||
import { type SourceControlledFile, SOURCE_CONTROL_FILE_TYPE } from '@n8n/api-types';
|
import { type SourceControlledFile, SOURCE_CONTROL_FILE_TYPE } from '@n8n/api-types';
|
||||||
import { N8nBadge, N8nButton, 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 { createEventBus } from '@n8n/utils/event-bus';
|
||||||
import groupBy from 'lodash/groupBy';
|
import dateformat from 'dateformat';
|
||||||
import orderBy from 'lodash/orderBy';
|
import orderBy from 'lodash/orderBy';
|
||||||
import { computed } from 'vue';
|
import { computed, onBeforeMount, ref } from 'vue';
|
||||||
import { RouterLink } from 'vue-router';
|
import { RouterLink } 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';
|
||||||
|
|
||||||
type SourceControlledFileType = SourceControlledFile['type'];
|
type SourceControlledFileType = SourceControlledFile['type'];
|
||||||
|
type SourceControlledFileWithProject = SourceControlledFile & { project?: ProjectListItem };
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
data: { eventBus: EventBus; status: SourceControlledFile[] };
|
data: { eventBus: EventBus; status: SourceControlledFile[] };
|
||||||
@@ -38,54 +41,120 @@ 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 sortedFiles = computed(() =>
|
onBeforeMount(() => {
|
||||||
|
void projectsStore.getAvailableProjects();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tab state
|
||||||
|
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) => {
|
||||||
|
if (!grouped[file.type]) {
|
||||||
|
grouped[file.type] = [];
|
||||||
|
}
|
||||||
|
grouped[file.type]!.push(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtered workflows
|
||||||
|
const filteredWorkflows = computed(() => {
|
||||||
|
const workflows = groupedFilesByType.value[SOURCE_CONTROL_FILE_TYPE.workflow] || [];
|
||||||
|
return workflows;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedWorkflows = computed(() =>
|
||||||
orderBy(
|
orderBy(
|
||||||
props.data.status,
|
filteredWorkflows.value,
|
||||||
[({ status }) => getPullPriorityByStatus(status), ({ name }) => name.toLowerCase()],
|
[({ status }) => getPullPriorityByStatus(status), 'updatedAt'],
|
||||||
['desc', 'asc'],
|
['asc', 'desc'],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupedFilesByType = computed<
|
// Filtered credentials
|
||||||
Partial<Record<SourceControlledFileType, SourceControlledFile[]>>
|
const filteredCredentials = computed(() => {
|
||||||
>(() => groupBy(sortedFiles.value, 'type'));
|
const credentials = groupedFilesByType.value[SOURCE_CONTROL_FILE_TYPE.credential] || [];
|
||||||
|
return credentials;
|
||||||
|
});
|
||||||
|
|
||||||
type ItemsList = Array<
|
const sortedCredentials = computed(() =>
|
||||||
{ type: 'render-title'; title: string; id: SourceControlledFileType } | SourceControlledFile
|
orderBy(
|
||||||
>;
|
filteredCredentials.value,
|
||||||
|
[({ status }) => getPullPriorityByStatus(status), 'updatedAt'],
|
||||||
const ITEM_TITLES: Record<Exclude<SourceControlledFileType, 'file'>, string> = {
|
['asc', 'desc'],
|
||||||
[SOURCE_CONTROL_FILE_TYPE.workflow]: 'Workflows',
|
),
|
||||||
[SOURCE_CONTROL_FILE_TYPE.credential]: 'Credentials',
|
|
||||||
[SOURCE_CONTROL_FILE_TYPE.variables]: 'Variables',
|
|
||||||
[SOURCE_CONTROL_FILE_TYPE.tags]: 'Tags',
|
|
||||||
[SOURCE_CONTROL_FILE_TYPE.folders]: 'Folders',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const files = computed<ItemsList>(() =>
|
|
||||||
[
|
|
||||||
SOURCE_CONTROL_FILE_TYPE.workflow,
|
|
||||||
SOURCE_CONTROL_FILE_TYPE.credential,
|
|
||||||
SOURCE_CONTROL_FILE_TYPE.variables,
|
|
||||||
SOURCE_CONTROL_FILE_TYPE.tags,
|
|
||||||
SOURCE_CONTROL_FILE_TYPE.folders,
|
|
||||||
].reduce<ItemsList>((acc, fileType) => {
|
|
||||||
if (!groupedFilesByType.value[fileType]) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
acc.push({
|
|
||||||
type: 'render-title',
|
|
||||||
title: ITEM_TITLES[fileType],
|
|
||||||
id: fileType,
|
|
||||||
});
|
|
||||||
|
|
||||||
acc.push(...groupedFilesByType.value[fileType]);
|
|
||||||
return acc;
|
|
||||||
}, []),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Active data source based on tab
|
||||||
|
const activeDataSourceFiltered = computed(() => {
|
||||||
|
if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) {
|
||||||
|
return sortedWorkflows.value;
|
||||||
|
}
|
||||||
|
if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.credential) {
|
||||||
|
return sortedCredentials.value;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtersNoResultText = computed(() => {
|
||||||
|
if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) {
|
||||||
|
return i18n.baseText('workflows.noResults');
|
||||||
|
}
|
||||||
|
return i18n.baseText('credentials.noResults');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tab data
|
||||||
|
const tabs = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Workflows',
|
||||||
|
value: SOURCE_CONTROL_FILE_TYPE.workflow,
|
||||||
|
total: groupedFilesByType.value[SOURCE_CONTROL_FILE_TYPE.workflow]?.length || 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Credentials',
|
||||||
|
value: SOURCE_CONTROL_FILE_TYPE.credential,
|
||||||
|
total: groupedFilesByType.value[SOURCE_CONTROL_FILE_TYPE.credential]?.length || 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Other files (variables, tags, folders) that are always pulled
|
||||||
|
const otherFiles = computed(() => {
|
||||||
|
const others: SourceControlledFileWithProject[] = [];
|
||||||
|
|
||||||
|
const variables = groupedFilesByType.value[SOURCE_CONTROL_FILE_TYPE.variables];
|
||||||
|
if (variables) {
|
||||||
|
others.push.apply(others, variables);
|
||||||
|
}
|
||||||
|
const tags = groupedFilesByType.value[SOURCE_CONTROL_FILE_TYPE.tags];
|
||||||
|
if (tags) {
|
||||||
|
others.push.apply(others, tags);
|
||||||
|
}
|
||||||
|
const folders = groupedFilesByType.value[SOURCE_CONTROL_FILE_TYPE.folders];
|
||||||
|
if (folders) {
|
||||||
|
others.push.apply(others, folders);
|
||||||
|
}
|
||||||
|
|
||||||
|
return others;
|
||||||
|
});
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
uiStore.closeModal(SOURCE_CONTROL_PULL_MODAL_KEY);
|
uiStore.closeModal(SOURCE_CONTROL_PULL_MODAL_KEY);
|
||||||
}
|
}
|
||||||
@@ -107,6 +176,20 @@ async function pullWorkfolder() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderUpdatedAt(file: SourceControlledFile) {
|
||||||
|
const currentYear = new Date().getFullYear().toString();
|
||||||
|
|
||||||
|
return i18n.baseText('settings.sourceControl.lastUpdated', {
|
||||||
|
interpolate: {
|
||||||
|
date: dateformat(
|
||||||
|
file.updatedAt,
|
||||||
|
`d mmm${file.updatedAt?.startsWith(currentYear) ? '' : ', yyyy'}`,
|
||||||
|
),
|
||||||
|
time: dateformat(file.updatedAt, 'HH:MM'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const workflowDiffEventBus = createEventBus();
|
const workflowDiffEventBus = createEventBus();
|
||||||
|
|
||||||
function openDiffModal(id: string) {
|
function openDiffModal(id: string) {
|
||||||
@@ -119,86 +202,156 @@ function openDiffModal(id: string) {
|
|||||||
data: { eventBus: workflowDiffEventBus, workflowId: id, direction: 'pull' },
|
data: { eventBus: workflowDiffEventBus, workflowId: id, direction: 'pull' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modalHeight = computed(() =>
|
||||||
|
groupedFilesByType.value[SOURCE_CONTROL_FILE_TYPE.workflow]?.length ||
|
||||||
|
groupedFilesByType.value[SOURCE_CONTROL_FILE_TYPE.credential]?.length
|
||||||
|
? 'min(80vh, 850px)'
|
||||||
|
: 'auto',
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal
|
<Modal
|
||||||
width="500px"
|
width="812px"
|
||||||
:title="i18n.baseText('settings.sourceControl.modals.pull.title')"
|
|
||||||
:event-bus="data.eventBus"
|
:event-bus="data.eventBus"
|
||||||
:name="SOURCE_CONTROL_PULL_MODAL_KEY"
|
:name="SOURCE_CONTROL_PULL_MODAL_KEY"
|
||||||
|
:height="modalHeight"
|
||||||
|
:custom-class="$style.sourceControlPull"
|
||||||
>
|
>
|
||||||
|
<template #header>
|
||||||
|
<N8nHeading tag="h1" size="xlarge">
|
||||||
|
{{ i18n.baseText('settings.sourceControl.modals.pull.title') }}
|
||||||
|
</N8nHeading>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</N8nText>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<N8nText tag="div" class="mb-xs">
|
<div v-if="!tabs.some((tab) => tab.total > 0)">
|
||||||
{{ i18n.baseText('settings.sourceControl.modals.pull.description') }}
|
<N8nText tag="div" class="mb-xs">
|
||||||
<br />
|
{{ i18n.baseText('settings.sourceControl.modals.pull.description') }}
|
||||||
<N8nLink :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')">
|
<br />
|
||||||
{{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }}
|
<N8nLink :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')">
|
||||||
</N8nLink>
|
{{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }}
|
||||||
</N8nText>
|
</N8nLink>
|
||||||
<div :class="$style.container">
|
</N8nText>
|
||||||
<DynamicScroller
|
</div>
|
||||||
ref="scroller"
|
<div v-else style="display: flex; height: 100%">
|
||||||
:items="files"
|
<div :class="$style.tabs">
|
||||||
:min-item-size="47"
|
<template v-for="tab in tabs" :key="tab.value">
|
||||||
:class="$style.scroller"
|
<button
|
||||||
style="max-height: 440px"
|
type="button"
|
||||||
>
|
:class="[$style.tab, { [$style.tabActive]: activeTab === tab.value }]"
|
||||||
<template #default="{ item, index, active }">
|
data-test-id="source-control-pull-modal-tab"
|
||||||
<div
|
@click="activeTab = tab.value"
|
||||||
v-if="item.type === 'render-title'"
|
|
||||||
:class="$style.listHeader"
|
|
||||||
data-test-id="pull-modal-item-header"
|
|
||||||
>
|
>
|
||||||
<N8nText bold>{{ item.title }}</N8nText>
|
<div>{{ tab.label }}</div>
|
||||||
</div>
|
<N8nText tag="div" color="text-light">
|
||||||
<DynamicScrollerItem
|
{{ tab.total }} {{ tab.total === 1 ? 'item' : 'items' }}
|
||||||
v-else
|
</N8nText>
|
||||||
:item="item"
|
</button>
|
||||||
:active="active"
|
|
||||||
:size-dependencies="[item.name]"
|
|
||||||
:data-index="index"
|
|
||||||
>
|
|
||||||
<div :class="$style.listItem" data-test-id="pull-modal-item">
|
|
||||||
<div :class="$style.itemName">
|
|
||||||
<RouterLink
|
|
||||||
v-if="item.type === 'credential'"
|
|
||||||
target="_blank"
|
|
||||||
:to="{ name: VIEWS.CREDENTIALS, params: { credentialId: item.id } }"
|
|
||||||
>
|
|
||||||
<N8nText>{{ item.name }}</N8nText>
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
v-else-if="item.type === 'workflow'"
|
|
||||||
target="_blank"
|
|
||||||
:to="{ name: VIEWS.WORKFLOW, params: { name: item.id } }"
|
|
||||||
>
|
|
||||||
<N8nText>{{ item.name }}</N8nText>
|
|
||||||
</RouterLink>
|
|
||||||
<N8nText v-else>{{ item.name }}</N8nText>
|
|
||||||
</div>
|
|
||||||
<div :class="$style.itemActions">
|
|
||||||
<N8nBadge :theme="getStatusTheme(item.status)" :class="$style.listBadge">
|
|
||||||
{{ getStatusText(item.status) }}
|
|
||||||
</N8nBadge>
|
|
||||||
<EnvFeatureFlag name="SOURCE_CONTROL_WORKFLOW_DIFF">
|
|
||||||
<N8nIconButton
|
|
||||||
v-if="item.type === SOURCE_CONTROL_FILE_TYPE.workflow"
|
|
||||||
icon="file-diff"
|
|
||||||
type="secondary"
|
|
||||||
:class="$style.diffButton"
|
|
||||||
@click="openDiffModal(item.id)"
|
|
||||||
/>
|
|
||||||
</EnvFeatureFlag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DynamicScrollerItem>
|
|
||||||
</template>
|
</template>
|
||||||
</DynamicScroller>
|
</div>
|
||||||
|
<div style="flex: 1">
|
||||||
|
<div :class="[$style.table]">
|
||||||
|
<div :class="[$style.tableHeader]">
|
||||||
|
<div :class="$style.headerTitle">
|
||||||
|
<N8nText>Title</N8nText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1; overflow: hidden">
|
||||||
|
<N8nInfoTip v-if="!activeDataSourceFiltered.length" class="p-xs" :bold="false">
|
||||||
|
{{ filtersNoResultText }}
|
||||||
|
</N8nInfoTip>
|
||||||
|
<DynamicScroller
|
||||||
|
v-if="activeDataSourceFiltered.length"
|
||||||
|
:class="[$style.scroller]"
|
||||||
|
:items="activeDataSourceFiltered"
|
||||||
|
:min-item-size="57"
|
||||||
|
item-class="scrollerItem"
|
||||||
|
>
|
||||||
|
<template #default="{ item: file, active, index }">
|
||||||
|
<DynamicScrollerItem
|
||||||
|
:item="file"
|
||||||
|
:active="active"
|
||||||
|
:size-dependencies="[file.name, file.id]"
|
||||||
|
:data-index="index"
|
||||||
|
>
|
||||||
|
<div :class="[$style.listItem]" data-test-id="pull-modal-item">
|
||||||
|
<div :class="[$style.itemContent]">
|
||||||
|
<N8nText tag="div" bold color="text-dark" :class="[$style.listItemName]">
|
||||||
|
<RouterLink
|
||||||
|
v-if="file.type === SOURCE_CONTROL_FILE_TYPE.credential"
|
||||||
|
target="_blank"
|
||||||
|
:to="{ name: VIEWS.CREDENTIALS, params: { credentialId: file.id } }"
|
||||||
|
>
|
||||||
|
{{ file.name }}
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
v-else-if="file.type === SOURCE_CONTROL_FILE_TYPE.workflow"
|
||||||
|
target="_blank"
|
||||||
|
:to="{ name: VIEWS.WORKFLOW, params: { name: file.id } }"
|
||||||
|
>
|
||||||
|
{{ file.name }}
|
||||||
|
</RouterLink>
|
||||||
|
<span v-else>{{ file.name }}</span>
|
||||||
|
</N8nText>
|
||||||
|
<N8nText
|
||||||
|
v-if="file.updatedAt"
|
||||||
|
tag="p"
|
||||||
|
class="mt-0"
|
||||||
|
color="text-light"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ renderUpdatedAt(file) }}
|
||||||
|
</N8nText>
|
||||||
|
</div>
|
||||||
|
<span :class="[$style.badges]">
|
||||||
|
<N8nBadge :theme="getStatusTheme(file.status)" style="height: 25px">
|
||||||
|
{{ getStatusText(file.status) }}
|
||||||
|
</N8nBadge>
|
||||||
|
<EnvFeatureFlag name="SOURCE_CONTROL_WORKFLOW_DIFF">
|
||||||
|
<N8nIconButton
|
||||||
|
v-if="file.type === SOURCE_CONTROL_FILE_TYPE.workflow"
|
||||||
|
icon="file-diff"
|
||||||
|
type="secondary"
|
||||||
|
@click="openDiffModal(file.id)"
|
||||||
|
/>
|
||||||
|
</EnvFeatureFlag>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</DynamicScrollerItem>
|
||||||
|
</template>
|
||||||
|
</DynamicScroller>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
<div v-if="otherFiles.length" class="mb-xs">
|
||||||
|
<N8nText bold size="medium">Additional changes to be pulled:</N8nText>
|
||||||
|
<N8nText size="small">
|
||||||
|
<template v-if="groupedFilesByType[SOURCE_CONTROL_FILE_TYPE.variables]?.length">
|
||||||
|
Variables ({{ groupedFilesByType[SOURCE_CONTROL_FILE_TYPE.variables]?.length || 0 }}),
|
||||||
|
</template>
|
||||||
|
<template v-if="groupedFilesByType[SOURCE_CONTROL_FILE_TYPE.tags]?.length">
|
||||||
|
Tags ({{ groupedFilesByType[SOURCE_CONTROL_FILE_TYPE.tags]?.length || 0 }}),
|
||||||
|
</template>
|
||||||
|
<template v-if="groupedFilesByType[SOURCE_CONTROL_FILE_TYPE.folders]?.length">
|
||||||
|
Folders ({{ groupedFilesByType[SOURCE_CONTROL_FILE_TYPE.folders]?.length || 0 }})
|
||||||
|
</template>
|
||||||
|
</N8nText>
|
||||||
|
</div>
|
||||||
<div :class="$style.footer">
|
<div :class="$style.footer">
|
||||||
<N8nButton type="tertiary" class="mr-2xs" @click="close">
|
<N8nButton type="tertiary" class="mr-2xs" @click="close">
|
||||||
{{ i18n.baseText('settings.sourceControl.modals.pull.buttons.cancel') }}
|
{{ i18n.baseText('settings.sourceControl.modals.pull.buttons.cancel') }}
|
||||||
@@ -212,78 +365,90 @@ function openDiffModal(id: string) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
.container {
|
.sourceControlPull {
|
||||||
overflow-wrap: break-word;
|
&:global(.el-dialog) {
|
||||||
padding-right: 8px;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scroller {
|
:global(.el-dialog__header) {
|
||||||
margin-right: -8px;
|
padding-bottom: var(--spacing-xs);
|
||||||
}
|
|
||||||
|
|
||||||
.filesList {
|
|
||||||
list-style: inside;
|
|
||||||
margin-top: var(--spacing-3xs);
|
|
||||||
padding-left: var(--spacing-2xs);
|
|
||||||
|
|
||||||
li {
|
|
||||||
margin-top: var(--spacing-3xs);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.listHeader {
|
.filtersRow {
|
||||||
padding-top: 16px;
|
display: flex;
|
||||||
padding-bottom: 12px;
|
align-items: center;
|
||||||
height: 47px;
|
gap: 8px;
|
||||||
margin-right: 8px;
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerTitle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filtersApplied {
|
||||||
|
border-top: var(--border-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroller {
|
||||||
|
max-height: 100%;
|
||||||
|
scrollbar-color: var(--color-foreground-base) transparent;
|
||||||
|
outline: var(--border-base);
|
||||||
|
|
||||||
|
:global(.scrollerItem) {
|
||||||
|
&:last-child {
|
||||||
|
.listItem {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.listItem {
|
.listItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-bottom: 8px;
|
justify-content: space-between;
|
||||||
margin-right: 8px;
|
padding: 10px 16px;
|
||||||
|
margin: 0;
|
||||||
&::before {
|
border-bottom: var(--border-base);
|
||||||
display: block;
|
gap: 30px;
|
||||||
content: '';
|
|
||||||
width: 5px;
|
|
||||||
height: 5px;
|
|
||||||
background-color: var(--color-foreground-xdark);
|
|
||||||
border-radius: 100%;
|
|
||||||
margin: 7px 8px 6px 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemName {
|
.itemContent {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-right: 8px;
|
}
|
||||||
|
|
||||||
a,
|
.listItemName {
|
||||||
span {
|
line-clamp: 2;
|
||||||
white-space: nowrap;
|
-webkit-line-clamp: 2;
|
||||||
overflow: hidden;
|
text-overflow: ellipsis;
|
||||||
text-overflow: ellipsis;
|
overflow: hidden;
|
||||||
display: block;
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
word-wrap: break-word;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemActions {
|
.badges {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.listBadge {
|
|
||||||
align-self: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diffButton {
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,5 +456,55 @@ function openDiffModal(id: string) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: var(--border-base);
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
border-bottom-right-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHeader {
|
||||||
|
border-bottom: var(--border-base);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
width: 165px;
|
||||||
|
padding: var(--spacing-2xs);
|
||||||
|
border: var(--border-base);
|
||||||
|
border-right: 0;
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
border-bottom-left-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
color: var(--color-text-base);
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: var(--spacing-2xs);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-background-base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabActive {
|
||||||
|
background-color: var(--color-background-base);
|
||||||
|
color: var(--color-text-dark);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { within, waitFor } from '@testing-library/dom';
|
import { within, waitFor } from '@testing-library/dom';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
|
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
@@ -12,15 +11,19 @@ import { VIEWS } from '@/constants';
|
|||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
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';
|
||||||
|
|
||||||
const eventBus = createEventBus();
|
const eventBus = createEventBus();
|
||||||
|
|
||||||
|
// Create a reactive route mock to avoid Vue warnings
|
||||||
|
const mockRoute = reactive({
|
||||||
|
name: '',
|
||||||
|
params: {},
|
||||||
|
fullPath: '',
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
useRoute: vi.fn().mockReturnValue({
|
useRoute: () => mockRoute,
|
||||||
name: vi.fn(),
|
|
||||||
params: vi.fn(),
|
|
||||||
fullPath: vi.fn(),
|
|
||||||
}),
|
|
||||||
RouterLink: vi.fn(),
|
RouterLink: vi.fn(),
|
||||||
useRouter: vi.fn(),
|
useRouter: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -36,21 +39,48 @@ vi.mock('@/composables/useTelemetry', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
let route: ReturnType<typeof useRoute>;
|
vi.mock('@/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showMessage: vi.fn(),
|
||||||
|
showError: vi.fn(),
|
||||||
|
showSuccess: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 = {
|
||||||
props: {
|
props: {
|
||||||
items: Array,
|
items: Array,
|
||||||
|
minItemSize: Number,
|
||||||
|
class: String,
|
||||||
|
itemClass: String,
|
||||||
},
|
},
|
||||||
template: '<div><template v-for="item in items"><slot v-bind="{ item }"></slot></template></div>',
|
template:
|
||||||
|
'<div><template v-for="(item, index) in items" :key="index"><slot v-bind="{ item, index, active: false }"></slot></template></div>',
|
||||||
methods: {
|
methods: {
|
||||||
scrollToItem: vi.fn(),
|
scrollToItem: vi.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const DynamicScrollerItemStub = {
|
const DynamicScrollerItemStub = {
|
||||||
template: '<slot></slot>',
|
props: {
|
||||||
|
item: Object,
|
||||||
|
active: Boolean,
|
||||||
|
sizeDependencies: Array,
|
||||||
|
dataIndex: Number,
|
||||||
|
},
|
||||||
|
template: '<div><slot></slot></div>',
|
||||||
};
|
};
|
||||||
|
|
||||||
const projects = [
|
const projects = [
|
||||||
@@ -88,14 +118,16 @@ const renderModal = createComponentRenderer(SourceControlPushModal, {
|
|||||||
describe('SourceControlPushModal', () => {
|
describe('SourceControlPushModal', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
route = useRoute();
|
// Reset route mock to default values
|
||||||
|
mockRoute.name = '';
|
||||||
|
mockRoute.params = {};
|
||||||
|
mockRoute.fullPath = '';
|
||||||
|
|
||||||
telemetry = useTelemetry();
|
telemetry = useTelemetry();
|
||||||
createTestingPinia();
|
createTestingPinia();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('mounts', () => {
|
it('mounts', () => {
|
||||||
vi.spyOn(route, 'fullPath', 'get').mockReturnValue('');
|
|
||||||
|
|
||||||
const { getByText } = renderModal({
|
const { getByText } = renderModal({
|
||||||
pinia: createTestingPinia(),
|
pinia: createTestingPinia(),
|
||||||
props: {
|
props: {
|
||||||
@@ -303,8 +335,8 @@ describe('SourceControlPushModal', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
vi.spyOn(route, 'name', 'get').mockReturnValue(VIEWS.WORKFLOW);
|
mockRoute.name = VIEWS.WORKFLOW;
|
||||||
vi.spyOn(route, 'params', 'get').mockReturnValue({ name: 'gTbbBkkYTnNyX1jD' });
|
mockRoute.params = { name: 'gTbbBkkYTnNyX1jD' };
|
||||||
|
|
||||||
const { getByTestId, getAllByTestId } = renderModal({
|
const { getByTestId, getAllByTestId } = renderModal({
|
||||||
props: {
|
props: {
|
||||||
@@ -710,8 +742,8 @@ describe('SourceControlPushModal', () => {
|
|||||||
|
|
||||||
const sourceControlStore = mockedStore(useSourceControlStore);
|
const sourceControlStore = mockedStore(useSourceControlStore);
|
||||||
|
|
||||||
vi.spyOn(route, 'name', 'get').mockReturnValue('SOME_OTHER_VIEW');
|
mockRoute.name = 'SOME_OTHER_VIEW';
|
||||||
vi.spyOn(route, 'params', 'get').mockReturnValue({ name: 'differentId' });
|
mockRoute.params = { name: 'differentId' };
|
||||||
|
|
||||||
const { getByTestId, getAllByTestId } = renderModal({
|
const { getByTestId, getAllByTestId } = renderModal({
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -252,11 +252,18 @@ const filteredWorkflows = computed(() => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workflow.project && filters.value.project) {
|
// Project filter logic: if a project filter is set, only show items from that project
|
||||||
return workflow.project.id === filters.value.project.id;
|
if (filters.value.project) {
|
||||||
|
// Item must have a project and it must match the filter
|
||||||
|
return workflow.project?.id === filters.value.project.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return !(filters.value.status && filters.value.status !== workflow.status);
|
// Status filter (only applied when no project filter is active)
|
||||||
|
if (filters.value.status && filters.value.status !== workflow.status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -283,11 +290,18 @@ const filteredCredentials = computed(() => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (credential.project && filters.value.project) {
|
// Project filter logic: if a project filter is set, only show items from that project
|
||||||
return credential.project.id === filters.value.project.id;
|
if (filters.value.project) {
|
||||||
|
// Item must have a project and it must match the filter
|
||||||
|
return credential.project?.id === filters.value.project.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return !(filters.value.status && filters.value.status !== credential.status);
|
// Status filter (only applied when no project filter is active)
|
||||||
|
if (filters.value.status && filters.value.status !== credential.status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -559,8 +573,22 @@ function castType(type: string): ResourceType {
|
|||||||
return ResourceType.Credential;
|
return ResourceType.Credential;
|
||||||
}
|
}
|
||||||
|
|
||||||
function castProject(project: ProjectListItem) {
|
function castProject(project: ProjectListItem): WorkflowResource {
|
||||||
return { homeProject: project } as unknown as WorkflowResource;
|
// Create a properly typed object that satisfies WorkflowResource
|
||||||
|
// This is a workaround for the ProjectCardBadge component expecting WorkflowResource
|
||||||
|
const resource: WorkflowResource = {
|
||||||
|
homeProject: project,
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
active: false,
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
isArchived: false,
|
||||||
|
readOnly: false,
|
||||||
|
resourceType: 'workflow',
|
||||||
|
sharedWithProjects: [],
|
||||||
|
};
|
||||||
|
return resource;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workflowDiffEventBus = createEventBus();
|
const workflowDiffEventBus = createEventBus();
|
||||||
@@ -761,29 +789,8 @@ function openDiffModal(id: string) {
|
|||||||
@update:model-value="toggleSelected(file.id)"
|
@update:model-value="toggleSelected(file.id)"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<N8nText
|
<N8nText tag="div" bold color="text-dark" :class="[$style.listItemName]">
|
||||||
v-if="file.status === SOURCE_CONTROL_FILE_STATUS.deleted"
|
{{ file.name || file.id }}
|
||||||
color="text-light"
|
|
||||||
>
|
|
||||||
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.workflow">
|
|
||||||
Deleted Workflow:
|
|
||||||
</span>
|
|
||||||
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.credential">
|
|
||||||
Deleted Credential:
|
|
||||||
</span>
|
|
||||||
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.folders">
|
|
||||||
Deleted Folders:
|
|
||||||
</span>
|
|
||||||
<strong>{{ file.name || file.id }}</strong>
|
|
||||||
</N8nText>
|
|
||||||
<N8nText
|
|
||||||
v-else
|
|
||||||
tag="div"
|
|
||||||
bold
|
|
||||||
color="text-dark"
|
|
||||||
:class="[$style.listItemName]"
|
|
||||||
>
|
|
||||||
{{ file.name }}
|
|
||||||
</N8nText>
|
</N8nText>
|
||||||
<N8nText
|
<N8nText
|
||||||
v-if="file.updatedAt"
|
v-if="file.updatedAt"
|
||||||
@@ -820,13 +827,13 @@ function openDiffModal(id: string) {
|
|||||||
:show-badge-border="false"
|
:show-badge-border="false"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<N8nBadge :theme="getStatusTheme(file.status)">
|
<N8nBadge :theme="getStatusTheme(file.status)" style="height: 25px">
|
||||||
{{ getStatusText(file.status) }}
|
{{ getStatusText(file.status) }}
|
||||||
</N8nBadge>
|
</N8nBadge>
|
||||||
<EnvFeatureFlag name="SOURCE_CONTROL_WORKFLOW_DIFF">
|
<EnvFeatureFlag name="SOURCE_CONTROL_WORKFLOW_DIFF">
|
||||||
<N8nIconButton
|
<N8nIconButton
|
||||||
v-if="file.type === SOURCE_CONTROL_FILE_TYPE.workflow"
|
v-if="file.type === SOURCE_CONTROL_FILE_TYPE.workflow"
|
||||||
icon="git-branch"
|
icon="file-diff"
|
||||||
type="secondary"
|
type="secondary"
|
||||||
@click="openDiffModal(file.id)"
|
@click="openDiffModal(file.id)"
|
||||||
/>
|
/>
|
||||||
@@ -951,6 +958,7 @@ function openDiffModal(id: string) {
|
|||||||
.badges {
|
.badges {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
|
|||||||
@@ -8,11 +8,23 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
|||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { mockedStore, type MockedStore } from '@/__tests__/utils';
|
import { mockedStore, type MockedStore } from '@/__tests__/utils';
|
||||||
import { ref } from 'vue';
|
import { reactive, ref } from 'vue';
|
||||||
import { createTestWorkflow } from '@/__tests__/mocks';
|
import { createTestWorkflow } from '@/__tests__/mocks';
|
||||||
|
|
||||||
const eventBus = createEventBus();
|
const eventBus = createEventBus();
|
||||||
|
|
||||||
|
const mockRoute = reactive({
|
||||||
|
name: '',
|
||||||
|
params: {},
|
||||||
|
fullPath: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRoute: () => mockRoute,
|
||||||
|
RouterLink: vi.fn(),
|
||||||
|
useRouter: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@/features/workflow-diff/useViewportSync', () => ({
|
vi.mock('@/features/workflow-diff/useViewportSync', () => ({
|
||||||
useProvideViewportSync: () => ({
|
useProvideViewportSync: () => ({
|
||||||
selectedDetailId: vi.fn(),
|
selectedDetailId: vi.fn(),
|
||||||
@@ -390,4 +402,85 @@ describe('WorkflowDiffModal', () => {
|
|||||||
expect(getByText('No changes')).toBeInTheDocument();
|
expect(getByText('No changes')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('missing workflow scenarios', () => {
|
||||||
|
it('should handle missing source workflow without crashing', async () => {
|
||||||
|
sourceControlStore.getRemoteWorkflow.mockResolvedValue({
|
||||||
|
content: mockWorkflow,
|
||||||
|
type: 'workflow',
|
||||||
|
});
|
||||||
|
workflowsStore.fetchWorkflow.mockRejectedValue(new Error('Workflow not found'));
|
||||||
|
|
||||||
|
const { getByText } = renderModal({
|
||||||
|
pinia: createTestingPinia(),
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
eventBus,
|
||||||
|
workflowId: 'new-workflow-id',
|
||||||
|
direction: 'pull',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Component should render successfully even with missing workflow
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText('Changes')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing target workflow without crashing', async () => {
|
||||||
|
sourceControlStore.getRemoteWorkflow.mockRejectedValue(new Error('Workflow not found'));
|
||||||
|
workflowsStore.fetchWorkflow.mockResolvedValue(mockWorkflow);
|
||||||
|
|
||||||
|
const { getByText } = renderModal({
|
||||||
|
pinia: createTestingPinia(),
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
eventBus,
|
||||||
|
workflowId: 'missing-workflow-id',
|
||||||
|
direction: 'push',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Component should render successfully even with missing workflow
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText('Changes')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle push direction without crashing', async () => {
|
||||||
|
const { getByText } = renderModal({
|
||||||
|
pinia: createTestingPinia(),
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
eventBus,
|
||||||
|
workflowId: 'test-workflow-id',
|
||||||
|
direction: 'push',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText('Changes')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle pull direction without crashing', async () => {
|
||||||
|
const { getByText } = renderModal({
|
||||||
|
pinia: createTestingPinia(),
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
eventBus,
|
||||||
|
workflowId: 'test-workflow-id',
|
||||||
|
direction: 'pull',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText('Changes')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -266,6 +266,17 @@ const changesCount = computed(
|
|||||||
() => nodeChanges.value.length + connectionsDiff.value.size + settingsDiff.value.length,
|
() => nodeChanges.value.length + connectionsDiff.value.size + settingsDiff.value.length,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isSourceWorkflowNew = computed(() => {
|
||||||
|
const sourceExists = !!sourceWorkFlow.value.state.value?.workflow;
|
||||||
|
const targetExists = !!targetWorkFlow.value.state.value?.workflow;
|
||||||
|
|
||||||
|
// Source is "new" only when it doesn't exist but target does AND
|
||||||
|
// we're in a context where the target is being pushed/pulled to create the source
|
||||||
|
// Push: remote (source) doesn't exist, local (target) does -> creating new on remote
|
||||||
|
// Pull: local (source) doesn't exist, remote (target) does -> creating new on local
|
||||||
|
return !sourceExists && targetExists;
|
||||||
|
});
|
||||||
|
|
||||||
onNodeClick((nodeId) => {
|
onNodeClick((nodeId) => {
|
||||||
const node = nodesDiff.value.get(nodeId);
|
const node = nodesDiff.value.get(nodeId);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
@@ -543,13 +554,19 @@ const modifiers = [
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<div :class="$style.emptyWorkflow">
|
<div :class="$style.emptyWorkflow">
|
||||||
<N8nHeading size="large">{{
|
<N8nHeading size="large">{{
|
||||||
i18n.baseText('workflowDiff.deletedWorkflow')
|
isSourceWorkflowNew
|
||||||
|
? i18n.baseText('workflowDiff.newWorkflow')
|
||||||
|
: i18n.baseText('workflowDiff.deletedWorkflow')
|
||||||
}}</N8nHeading>
|
}}</N8nHeading>
|
||||||
<N8nText v-if="targetWorkFlow.state.value?.remote" color="text-base">{{
|
<N8nText v-if="sourceWorkFlow.state.value?.remote" color="text-base">{{
|
||||||
i18n.baseText('workflowDiff.deletedWorkflow.database')
|
isSourceWorkflowNew
|
||||||
|
? i18n.baseText('workflowDiff.newWorkflow.remote')
|
||||||
|
: i18n.baseText('workflowDiff.deletedWorkflow.remote')
|
||||||
}}</N8nText>
|
}}</N8nText>
|
||||||
<N8nText v-else color="text-base">{{
|
<N8nText v-else color="text-base">{{
|
||||||
i18n.baseText('workflowDiff.deletedWorkflow.remote')
|
isSourceWorkflowNew
|
||||||
|
? i18n.baseText('workflowDiff.newWorkflow.database')
|
||||||
|
: i18n.baseText('workflowDiff.deletedWorkflow.database')
|
||||||
}}</N8nText>
|
}}</N8nText>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -629,7 +646,6 @@ const modifiers = [
|
|||||||
}
|
}
|
||||||
:global(.el-dialog__header) {
|
:global(.el-dialog__header) {
|
||||||
padding: 11px 16px;
|
padding: 11px 16px;
|
||||||
border-bottom: 1px solid var(--color-foreground-base);
|
|
||||||
}
|
}
|
||||||
:global(.el-dialog__headerbtn) {
|
:global(.el-dialog__headerbtn) {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -896,6 +912,7 @@ const modifiers = [
|
|||||||
.workflowDiffPanel {
|
.workflowDiffPanel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
border-top: 1px solid var(--color-foreground-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyWorkflow {
|
.emptyWorkflow {
|
||||||
|
|||||||
@@ -174,15 +174,16 @@ export const useWorkflowDiff = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const nodesDiff = computed(() => {
|
const nodesDiff = computed(() => {
|
||||||
// Don't compute diff until both workflows are loaded to prevent initial flashing
|
// Handle case where one or both workflows don't exist
|
||||||
if (!source.value?.workflow?.value || !target.value?.workflow?.value) {
|
const sourceNodes = source.value?.workflow?.value?.nodes ?? [];
|
||||||
|
const targetNodes = target.value?.workflow?.value?.nodes ?? [];
|
||||||
|
|
||||||
|
// If neither workflow exists, return empty diff
|
||||||
|
if (sourceNodes.length === 0 && targetNodes.length === 0) {
|
||||||
return new Map<string, NodeDiff<INodeUi>>();
|
return new Map<string, NodeDiff<INodeUi>>();
|
||||||
}
|
}
|
||||||
|
|
||||||
return compareWorkflowsNodes(
|
return compareWorkflowsNodes(sourceNodes, targetNodes);
|
||||||
source.value.workflow?.value?.nodes ?? [],
|
|
||||||
target.value.workflow?.value?.nodes ?? [],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type Connection = {
|
type Connection = {
|
||||||
@@ -222,14 +223,15 @@ export const useWorkflowDiff = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const connectionsDiff = computed(() => {
|
const connectionsDiff = computed(() => {
|
||||||
// Don't compute diff until both workflows are loaded to prevent initial flashing
|
// Handle case where one or both workflows don't exist
|
||||||
if (!source.value?.workflow?.value || !target.value?.workflow?.value) {
|
|
||||||
return new Map<string, { status: NodeDiffStatus; connection: Connection }>();
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceConnections = mapConnections(source.value?.connections ?? []);
|
const sourceConnections = mapConnections(source.value?.connections ?? []);
|
||||||
const targetConnections = mapConnections(target.value?.connections ?? []);
|
const targetConnections = mapConnections(target.value?.connections ?? []);
|
||||||
|
|
||||||
|
// If neither workflow has connections, return empty diff
|
||||||
|
if (sourceConnections.set.size === 0 && targetConnections.set.size === 0) {
|
||||||
|
return new Map<string, { status: NodeDiffStatus; connection: Connection }>();
|
||||||
|
}
|
||||||
|
|
||||||
const added = targetConnections.set.difference(sourceConnections.set);
|
const added = targetConnections.set.difference(sourceConnections.set);
|
||||||
const removed = sourceConnections.set.difference(targetConnections.set);
|
const removed = sourceConnections.set.difference(targetConnections.set);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user