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:
Csaba Tuncsik
2025-08-12 14:22:35 +02:00
committed by GitHub
parent 19946c3f72
commit d06581ef3f
9 changed files with 621 additions and 250 deletions

View File

@@ -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);

View File

@@ -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"
} }

View File

@@ -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();
}); });
}); });

View File

@@ -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,53 +41,119 @@ 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'],
['asc', 'desc'],
),
);
const ITEM_TITLES: Record<Exclude<SourceControlledFileType, 'file'>, string> = { // Active data source based on tab
[SOURCE_CONTROL_FILE_TYPE.workflow]: 'Workflows', const activeDataSourceFiltered = computed(() => {
[SOURCE_CONTROL_FILE_TYPE.credential]: 'Credentials', if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) {
[SOURCE_CONTROL_FILE_TYPE.variables]: 'Variables', return sortedWorkflows.value;
[SOURCE_CONTROL_FILE_TYPE.tags]: 'Tags', }
[SOURCE_CONTROL_FILE_TYPE.folders]: 'Folders', if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.credential) {
} as const; return sortedCredentials.value;
}
return [];
});
const files = computed<ItemsList>(() => const filtersNoResultText = computed(() => {
[ if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) {
SOURCE_CONTROL_FILE_TYPE.workflow, return i18n.baseText('workflows.noResults');
SOURCE_CONTROL_FILE_TYPE.credential, }
SOURCE_CONTROL_FILE_TYPE.variables, return i18n.baseText('credentials.noResults');
SOURCE_CONTROL_FILE_TYPE.tags, });
SOURCE_CONTROL_FILE_TYPE.folders,
].reduce<ItemsList>((acc, fileType) => { // Tab data
if (!groupedFilesByType.value[fileType]) { const tabs = computed(() => {
return acc; 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);
} }
acc.push({ return others;
type: 'render-title', });
title: ITEM_TITLES[fileType],
id: fileType,
});
acc.push(...groupedFilesByType.value[fileType]);
return acc;
}, []),
);
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,16 +202,29 @@ 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 #content> <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"> <N8nText tag="div" class="mb-xs">
{{ i18n.baseText('settings.sourceControl.modals.pull.description') }} {{ i18n.baseText('settings.sourceControl.modals.pull.description') }}
<br /> <br />
@@ -136,69 +232,126 @@ function openDiffModal(id: string) {
{{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }} {{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }}
</N8nLink> </N8nLink>
</N8nText> </N8nText>
<div :class="$style.container">
<DynamicScroller
ref="scroller"
:items="files"
:min-item-size="47"
:class="$style.scroller"
style="max-height: 440px"
>
<template #default="{ item, index, active }">
<div
v-if="item.type === 'render-title'"
:class="$style.listHeader"
data-test-id="pull-modal-item-header"
>
<N8nText bold>{{ item.title }}</N8nText>
</div> </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 :class="$style.tabs">
<template v-for="tab in tabs" :key="tab.value">
<button
type="button"
:class="[$style.tab, { [$style.tabActive]: activeTab === tab.value }]"
data-test-id="source-control-pull-modal-tab"
@click="activeTab = tab.value"
>
<div>{{ tab.label }}</div>
<N8nText tag="div" color="text-light">
{{ tab.total }} {{ tab.total === 1 ? 'item' : 'items' }}
</N8nText>
</button>
</template>
</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 <DynamicScrollerItem
v-else :item="file"
:item="item"
:active="active" :active="active"
:size-dependencies="[item.name]" :size-dependencies="[file.name, file.id]"
:data-index="index" :data-index="index"
> >
<div :class="$style.listItem" data-test-id="pull-modal-item"> <div :class="[$style.listItem]" data-test-id="pull-modal-item">
<div :class="$style.itemName"> <div :class="[$style.itemContent]">
<N8nText tag="div" bold color="text-dark" :class="[$style.listItemName]">
<RouterLink <RouterLink
v-if="item.type === 'credential'" v-if="file.type === SOURCE_CONTROL_FILE_TYPE.credential"
target="_blank" target="_blank"
:to="{ name: VIEWS.CREDENTIALS, params: { credentialId: item.id } }" :to="{ name: VIEWS.CREDENTIALS, params: { credentialId: file.id } }"
> >
<N8nText>{{ item.name }}</N8nText> {{ file.name }}
</RouterLink> </RouterLink>
<RouterLink <RouterLink
v-else-if="item.type === 'workflow'" v-else-if="file.type === SOURCE_CONTROL_FILE_TYPE.workflow"
target="_blank" target="_blank"
:to="{ name: VIEWS.WORKFLOW, params: { name: item.id } }" :to="{ name: VIEWS.WORKFLOW, params: { name: file.id } }"
> >
<N8nText>{{ item.name }}</N8nText> {{ file.name }}
</RouterLink> </RouterLink>
<N8nText v-else>{{ item.name }}</N8nText> <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> </div>
<div :class="$style.itemActions"> <span :class="[$style.badges]">
<N8nBadge :theme="getStatusTheme(item.status)" :class="$style.listBadge"> <N8nBadge :theme="getStatusTheme(file.status)" style="height: 25px">
{{ getStatusText(item.status) }} {{ getStatusText(file.status) }}
</N8nBadge> </N8nBadge>
<EnvFeatureFlag name="SOURCE_CONTROL_WORKFLOW_DIFF"> <EnvFeatureFlag name="SOURCE_CONTROL_WORKFLOW_DIFF">
<N8nIconButton <N8nIconButton
v-if="item.type === SOURCE_CONTROL_FILE_TYPE.workflow" v-if="file.type === SOURCE_CONTROL_FILE_TYPE.workflow"
icon="file-diff" icon="file-diff"
type="secondary" type="secondary"
:class="$style.diffButton" @click="openDiffModal(file.id)"
@click="openDiffModal(item.id)"
/> />
</EnvFeatureFlag> </EnvFeatureFlag>
</div> </span>
</div> </div>
</DynamicScrollerItem> </DynamicScrollerItem>
</template> </template>
</DynamicScroller> </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;
display: block; overflow: hidden;
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>

View File

@@ -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: {

View File

@@ -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 {

View File

@@ -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();
});
});
});
}); });

View File

@@ -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 {

View File

@@ -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);