fix(editor): Enhance changes dropdown in WorkflowDiffModal (#18033)

This commit is contained in:
Csaba Tuncsik
2025-08-07 16:50:42 +02:00
committed by GitHub
parent 25379fe522
commit bc76643976
3 changed files with 258 additions and 111 deletions

View File

@@ -3382,5 +3382,15 @@
"whatsNew.versionsBehind": "{count} version behind | {count} versions behind",
"whatsNew.update": "Update",
"whatsNew.updateAvailable": "You're currently on version {currentVersion}. Update to {latestVersion} to get {count} versions worth of new features, improvements, and fixes. See what changed",
"whatsNew.updateAvailable.changelogLink": "in the full changelog"
"whatsNew.updateAvailable.changelogLink": "in the full changelog",
"workflowDiff.changes": "Changes",
"workflowDiff.nodes": "Nodes",
"workflowDiff.connectors": "Connectors",
"workflowDiff.settings": "Settings",
"workflowDiff.local": "Local",
"workflowDiff.remote": "Remote ({branchName})",
"workflowDiff.noChanges": "No changes",
"workflowDiff.deletedWorkflow": "Deleted workflow",
"workflowDiff.deletedWorkflow.database": "The workflow was deleted on the database",
"workflowDiff.deletedWorkflow.remote": "The workflow was deleted on remote"
}

View File

@@ -302,4 +302,92 @@ describe('WorkflowDiffModal', () => {
expect(pullComponent.container.querySelector('.header')).toBeInTheDocument();
expect(pushComponent.container.querySelector('.header')).toBeInTheDocument();
});
it('should show empty state when no changes exist in tabs', async () => {
const { getByText } = renderModal({
pinia: createTestingPinia(),
props: {
data: {
eventBus,
workflowId: 'test-workflow-id',
direction: 'push',
},
},
});
// Open changes dropdown
const changesButton = getByText('Changes');
await userEvent.click(changesButton);
// Wait for dropdown to open and check tabs
await waitFor(() => {
expect(getByText('Nodes')).toBeInTheDocument();
});
// Click on Nodes tab to make it active
await userEvent.click(getByText('Nodes'));
// Should show "No changes" when there are no node changes
await waitFor(() => {
expect(getByText('No changes')).toBeInTheDocument();
});
});
it('should show empty state for connectors tab when no connector changes', async () => {
const { getByText } = renderModal({
pinia: createTestingPinia(),
props: {
data: {
eventBus,
workflowId: 'test-workflow-id',
direction: 'push',
},
},
});
// Open changes dropdown
const changesButton = getByText('Changes');
await userEvent.click(changesButton);
await waitFor(() => {
expect(getByText('Connectors')).toBeInTheDocument();
});
// Click on Connectors tab
await userEvent.click(getByText('Connectors'));
// Should show "No changes" when there are no connector changes
await waitFor(() => {
expect(getByText('No changes')).toBeInTheDocument();
});
});
it('should show empty state for settings tab when no settings changes', async () => {
const { getByText } = renderModal({
pinia: createTestingPinia(),
props: {
data: {
eventBus,
workflowId: 'test-workflow-id',
direction: 'push',
},
},
});
// Open changes dropdown
const changesButton = getByText('Changes');
await userEvent.click(changesButton);
await waitFor(() => {
expect(getByText('Settings')).toBeInTheDocument();
});
// Click on Settings tab
await userEvent.click(getByText('Settings'));
// Should show "No changes" when there are no settings changes
await waitFor(() => {
expect(getByText('No changes')).toBeInTheDocument();
});
});
});

View File

@@ -187,24 +187,24 @@ const activeTab = ref<'nodes' | 'connectors' | 'settings'>();
const tabs = computed(() => [
{
value: 'nodes' as const,
label: 'Nodes',
disabled: nodeChanges.value.length === 0,
label: i18n.baseText('workflowDiff.nodes'),
disabled: false,
data: {
count: nodeChanges.value.length,
},
},
{
value: 'connectors' as const,
label: 'Connectors',
disabled: connectionsDiff.value.size === 0,
label: i18n.baseText('workflowDiff.connectors'),
disabled: false,
data: {
count: connectionsDiff.value.size,
},
},
{
value: 'settings' as const,
label: 'Settings',
disabled: settingsDiff.value.length === 0,
label: i18n.baseText('workflowDiff.settings'),
disabled: false,
data: {
count: settingsDiff.value.length,
},
@@ -220,8 +220,7 @@ function setActiveTab(active: boolean) {
telemetry.track('User clicked workflow diff changes button', {
workflow_id: props.data.workflowId,
});
const value = tabs.value.find((tab) => !tab.disabled)?.value ?? 'nodes';
activeTab.value = value;
activeTab.value = 'nodes';
}
function trackTabChange(value: 'nodes' | 'connectors' | 'settings') {
@@ -367,7 +366,7 @@ const modifiers = [
<div v-if="changesCount" :class="$style.circleBadge">
{{ changesCount }}
</div>
Changes
{{ i18n.baseText('workflowDiff.changes') }}
</N8nButton>
<template #dropdown>
<ElDropdownMenu :hide-on-click="false">
@@ -388,80 +387,101 @@ const modifiers = [
</N8nRadioButtons>
<div>
<ul v-if="activeTab === 'nodes'">
<ElDropdownItem
v-for="change in nodeChanges"
:key="change.node.id"
:class="{
[$style.clickableChange]: true,
[$style.clickableChangeActive]: selectedDetailId === change.node.id,
}"
@click.prevent="setSelectedDetailId(change.node.id, activeTab)"
>
<DiffBadge :type="change.status" />
<NodeIcon :node-type="change.type" :size="16" class="ml-2xs mr-4xs" />
<span :class="$style.nodeName">{{ change.node.name }}</span>
</ElDropdownItem>
<template v-if="nodeChanges.length > 0">
<ElDropdownItem
v-for="change in nodeChanges"
:key="change.node.id"
:class="{
[$style.clickableChange]: true,
[$style.clickableChangeActive]: selectedDetailId === change.node.id,
}"
@click.prevent="setSelectedDetailId(change.node.id, activeTab)"
>
<DiffBadge :type="change.status" />
<NodeIcon :node-type="change.type" :size="16" class="ml-2xs mr-4xs" />
<span :class="$style.nodeName">{{ change.node.name }}</span>
</ElDropdownItem>
</template>
<li v-else :class="$style.emptyState">
<N8nText color="text-base" size="small">{{
i18n.baseText('workflowDiff.noChanges')
}}</N8nText>
</li>
</ul>
<ul v-if="activeTab === 'connectors'" :class="$style.changes">
<li v-for="change in connectionsDiff" :key="change[0]">
<div>
<DiffBadge :type="change[1].status" />
</div>
<div style="flex: 1; min-width: 0">
<ul :class="$style.changesNested">
<ElDropdownItem
:class="{
[$style.clickableChange]: true,
[$style.clickableChangeActive]:
selectedDetailId === change[1].connection.source?.id,
}"
@click.prevent="
setSelectedDetailId(change[1].connection.source?.id, activeTab)
"
>
<NodeIcon
:node-type="change[1].connection.sourceType"
:size="16"
class="ml-2xs mr-4xs"
/>
<span :class="$style.nodeName">{{
change[1].connection.source?.name
}}</span>
</ElDropdownItem>
<div :class="$style.separator"></div>
<ElDropdownItem
:class="{
[$style.clickableChange]: true,
[$style.clickableChangeActive]:
selectedDetailId === change[1].connection.target?.id,
}"
@click.prevent="
setSelectedDetailId(change[1].connection.target?.id, activeTab)
"
>
<NodeIcon
:node-type="change[1].connection.targetType"
:size="16"
class="ml-2xs mr-4xs"
/>
<span :class="$style.nodeName">{{
change[1].connection.target?.name
}}</span>
</ElDropdownItem>
</ul>
</div>
<template v-if="connectionsDiff.size > 0">
<li v-for="change in connectionsDiff" :key="change[0]">
<div>
<DiffBadge :type="change[1].status" />
</div>
<div style="flex: 1">
<ul :class="$style.changesNested">
<ElDropdownItem
:class="{
[$style.clickableChange]: true,
[$style.clickableChangeActive]:
selectedDetailId === change[1].connection.source?.id,
}"
@click.prevent="
setSelectedDetailId(change[1].connection.source?.id, activeTab)
"
>
<NodeIcon
:node-type="change[1].connection.sourceType"
:size="16"
class="ml-2xs mr-4xs"
/>
<span :class="$style.nodeName">{{
change[1].connection.source?.name
}}</span>
</ElDropdownItem>
<div :class="$style.separator"></div>
<ElDropdownItem
:class="{
[$style.clickableChange]: true,
[$style.clickableChangeActive]:
selectedDetailId === change[1].connection.target?.id,
}"
@click.prevent="
setSelectedDetailId(change[1].connection.target?.id, activeTab)
"
>
<NodeIcon
:node-type="change[1].connection.targetType"
:size="16"
class="ml-2xs mr-4xs"
/>
<span :class="$style.nodeName">{{
change[1].connection.target?.name
}}</span>
</ElDropdownItem>
</ul>
</div>
</li>
</template>
<li v-else :class="$style.emptyState">
<N8nText color="text-base" size="small">{{
i18n.baseText('workflowDiff.noChanges')
}}</N8nText>
</li>
</ul>
<ul v-if="activeTab === 'settings'">
<li v-for="setting in settingsDiff" :key="setting.name">
<N8nText color="text-dark" size="medium" tag="div" bold>{{
i18n.baseText(`workflowSettings.${setting.name}` as BaseTextKey)
<template v-if="settingsDiff.length > 0">
<li v-for="setting in settingsDiff" :key="setting.name">
<N8nText color="text-dark" size="medium" tag="div" bold>{{
i18n.baseText(`workflowSettings.${setting.name}` as BaseTextKey)
}}</N8nText>
<NodeDiff
:old-string="setting.before"
:new-string="setting.after"
:class="$style.noNumberDiff"
/>
</li>
</template>
<li v-else :class="$style.emptyState">
<N8nText color="text-base" size="small">{{
i18n.baseText('workflowDiff.noChanges')
}}</N8nText>
<NodeDiff
:old-string="setting.before"
:new-string="setting.after"
:class="$style.noNumberDiff"
/>
</li>
</ul>
</div>
@@ -494,8 +514,10 @@ const modifiers = [
<N8nIcon v-if="sourceWorkFlow.state.value.remote" icon="git-branch" />
{{
sourceWorkFlow.state.value.remote
? `Remote (${sourceControlStore.preferences.branchName})`
: 'Local'
? i18n.baseText('workflowDiff.remote', {
interpolate: { branchName: sourceControlStore.preferences.branchName },
})
: i18n.baseText('workflowDiff.local')
}}
</N8nText>
<template v-if="sourceWorkFlow.state.value.workflow">
@@ -520,14 +542,15 @@ const modifiers = [
</template>
<template v-else>
<div :class="$style.emptyWorkflow">
<template v-if="targetWorkFlow.state.value?.remote">
<N8nHeading size="large"> Deleted workflow </N8nHeading>
<N8nText color="text-base"> The workflow was deleted on the database </N8nText>
</template>
<template v-else>
<N8nHeading size="large"> Deleted workflow </N8nHeading>
<N8nText color="text-base"> The workflow was deleted on remote </N8nText>
</template>
<N8nHeading size="large">{{
i18n.baseText('workflowDiff.deletedWorkflow')
}}</N8nHeading>
<N8nText v-if="targetWorkFlow.state.value?.remote" color="text-base">{{
i18n.baseText('workflowDiff.deletedWorkflow.database')
}}</N8nText>
<N8nText v-else color="text-base">{{
i18n.baseText('workflowDiff.deletedWorkflow.remote')
}}</N8nText>
</div>
</template>
</template>
@@ -538,8 +561,10 @@ const modifiers = [
<N8nIcon v-if="targetWorkFlow.state.value.remote" icon="git-branch" />
{{
targetWorkFlow.state.value.remote
? `Remote (${sourceControlStore.preferences.branchName})`
: 'Local'
? i18n.baseText('workflowDiff.remote', {
interpolate: { branchName: sourceControlStore.preferences.branchName },
})
: i18n.baseText('workflowDiff.local')
}}
</N8nText>
<template v-if="targetWorkFlow.state.value.workflow">
@@ -564,14 +589,15 @@ const modifiers = [
</template>
<template v-else>
<div :class="$style.emptyWorkflow">
<template v-if="targetWorkFlow.state.value?.remote">
<N8nHeading size="large"> Deleted workflow </N8nHeading>
<N8nText color="text-base"> The workflow was deleted on remote </N8nText>
</template>
<template v-else>
<N8nHeading size="large"> Deleted workflow </N8nHeading>
<N8nText color="text-base"> The workflow was deleted on the data base </N8nText>
</template>
<N8nHeading size="large">{{
i18n.baseText('workflowDiff.deletedWorkflow')
}}</N8nHeading>
<N8nText v-if="targetWorkFlow.state.value?.remote" color="text-base">{{
i18n.baseText('workflowDiff.deletedWorkflow.remote')
}}</N8nText>
<N8nText v-else color="text-base">{{
i18n.baseText('workflowDiff.deletedWorkflow.database')
}}</N8nText>
</div>
</template>
</template>
@@ -651,8 +677,8 @@ const modifiers = [
gap: var(--spacing-2xs);
padding: 10px 0 var(--spacing-3xs) var(--spacing-2xs);
ul {
margin-top: -3px;
> div {
min-width: 0;
}
.clickableChange {
@@ -660,6 +686,12 @@ const modifiers = [
margin-left: -4px;
}
}
.changesNested {
margin-top: -3px;
width: 100%;
min-width: 0;
}
}
.clickableChange {
@@ -668,12 +700,18 @@ const modifiers = [
gap: var(--spacing-2xs);
border-radius: 4px;
padding: var(--spacing-xs) var(--spacing-2xs);
margin-right: var(--spacing-xs);
line-height: unset;
min-width: 0;
transition: background-color 0.2s ease;
&:hover {
background-color: var(--color-background-xlight);
}
}
.clickableChangeActive {
background-color: var(--color-background-medium);
background-color: var(--color-background-xlight);
}
.nodeName {
@@ -806,14 +844,6 @@ const modifiers = [
opacity: 0.5;
}
.noNumberDiff {
min-height: 41px;
margin-bottom: 10px;
:global(.blob-num) {
display: none;
}
}
.circleBadge {
display: inline-flex;
align-items: center;
@@ -829,13 +859,25 @@ const modifiers = [
}
.dropdownContent {
min-width: 300px;
padding: 2px 12px;
width: 320px;
padding: 2px 0 2px 12px;
ul {
list-style: none;
margin: 0;
padding: 0;
max-height: 400px;
overflow-x: hidden;
overflow-y: auto;
}
.noNumberDiff {
min-height: 41px;
margin: 0 12px 10px 0;
overflow: hidden;
:global(.blob-num) {
display: none;
}
}
}
@@ -883,4 +925,11 @@ const modifiers = [
display: flex;
align-items: center;
}
.emptyState {
display: flex;
justify-content: center;
align-items: center;
padding: var(--spacing-m) var(--spacing-xs);
}
</style>