fix(editor): Disable publish button when no changes (#23137)

Co-authored-by: Nikhil Kuriakose <nikhilkuria@gmail.com>
This commit is contained in:
Robert Squires
2025-12-15 09:41:52 +00:00
committed by GitHub
co-authored by Nikhil Kuriakose
parent 2f329b38f9
commit e391a2830b
4 changed files with 79 additions and 52 deletions
@@ -3056,7 +3056,7 @@
"workflowActivator.inactive": "Inactive",
"workflowActivator.showError.title": "Workflow could not be {newStateName}",
"workflowActivator.showMessage.activeChangedNodesIssuesExistTrue.message": "Please resolve outstanding issues before publishing",
"workflowActivator.showMessage.activeChangedNodesIssuesExistTrue.title": "{count} node has issues, fix before publishing: | {count} nodes have issues, fix before publishing:",
"workflowActivator.showMessage.activeChangedNodesIssuesExistTrue.title": "{count} node has issues, fix them before publishing. | {count} nodes have issues, fix them before publishing.",
"workflowActivator.showMessage.activeChangedWorkflowIdUndefined.message": "Please save it before activating",
"workflowActivator.showMessage.activeChangedWorkflowIdUndefined.title": "Problem activating workflow",
"workflowActivator.showMessage.displayActivationError.message.catchBlock": "Sorry there was a problem requesting the error",
@@ -84,10 +84,26 @@ const createMockActiveVersion = (versionId: string) => ({
description: null,
});
const triggerNode: INodeUi = {
id: 'trigger-1',
name: 'Webhook Trigger',
type: 'n8n-nodes-base.webhook',
typeVersion: 1,
position: [0, 0],
parameters: {},
disabled: false,
};
describe('WorkflowHeaderDraftPublishActions', () => {
let workflowsStore: MockedStore<typeof useWorkflowsStore>;
let uiStore: MockedStore<typeof useUIStore>;
const setupEnabledPublishButton = (overrides = {}) => {
workflowsStore.workflowTriggerNodes = [triggerNode];
workflowsStore.nodesIssuesExist = false;
Object.assign(workflowsStore, overrides);
};
beforeEach(() => {
workflowsStore = mockedStore(useWorkflowsStore);
uiStore = mockedStore(useUIStore);
@@ -192,6 +208,13 @@ describe('WorkflowHeaderDraftPublishActions', () => {
it('should open publish modal when clicked and workflow is saved', async () => {
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
uiStore.stateIsDirty = false;
setupEnabledPublishButton({
workflow: {
...workflowsStore.workflow,
versionId: 'version-1',
activeVersion: createMockActiveVersion('version-2'),
},
});
const { getByTestId } = renderComponent();
@@ -207,6 +230,7 @@ describe('WorkflowHeaderDraftPublishActions', () => {
it('should save workflow first when dirty then open publish modal', async () => {
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
uiStore.stateIsDirty = true;
setupEnabledPublishButton();
const { getByTestId } = renderComponent();
@@ -222,6 +246,7 @@ describe('WorkflowHeaderDraftPublishActions', () => {
it('should save workflow first when isNewWorkflow is true then open publish modal', async () => {
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
uiStore.stateIsDirty = false;
setupEnabledPublishButton();
const { getByTestId } = renderComponent({
props: {
@@ -243,6 +268,7 @@ describe('WorkflowHeaderDraftPublishActions', () => {
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
uiStore.stateIsDirty = true;
mockSaveCurrentWorkflow.mockResolvedValue(false);
setupEnabledPublishButton();
const { getByTestId } = renderComponent();
@@ -253,18 +279,8 @@ describe('WorkflowHeaderDraftPublishActions', () => {
});
});
describe('Publish indicator visibility', () => {
const triggerNode: INodeUi = {
id: 'trigger-1',
name: 'Webhook Trigger',
type: 'n8n-nodes-base.webhook',
typeVersion: 1,
position: [0, 0],
parameters: {},
disabled: false,
};
it('should not show publish indicator when there are no trigger nodes', () => {
describe('Publish button state', () => {
it('should show publish button disabled when there are no trigger nodes', () => {
workflowsStore.workflowTriggerNodes = [];
workflowsStore.workflow.versionId = 'version-1';
workflowsStore.workflow.activeVersion = createMockActiveVersion('version-2');
@@ -272,21 +288,21 @@ describe('WorkflowHeaderDraftPublishActions', () => {
const { queryByTestId } = renderComponent();
expect(queryByTestId('workflow-publish-indicator')).not.toBeInTheDocument();
expect(queryByTestId('workflow-open-publish-modal-button')).toBeDisabled();
});
it('should not show publish indicator when trigger node is disabled', () => {
it('should show publish button disabled when trigger node is disabled', () => {
workflowsStore.workflowTriggerNodes = [{ ...triggerNode, disabled: true }];
workflowsStore.workflow.versionId = 'version-1';
workflowsStore.workflow.activeVersion = createMockActiveVersion('version-2');
uiStore.stateIsDirty = true;
const { queryByTestId } = renderComponent();
const { getByTestId } = renderComponent();
expect(queryByTestId('workflow-publish-indicator')).not.toBeInTheDocument();
expect(getByTestId('workflow-open-publish-modal-button')).toBeDisabled();
});
it('should show publish indicator when there are unpublished changes (versionId mismatch)', () => {
it('should show publish button enabled when there are unpublished changes (versionId mismatch)', () => {
workflowsStore.workflowTriggerNodes = [triggerNode];
workflowsStore.workflow.versionId = 'version-1';
workflowsStore.workflow.activeVersion = createMockActiveVersion('version-2');
@@ -294,10 +310,10 @@ describe('WorkflowHeaderDraftPublishActions', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('workflow-publish-indicator')).toBeInTheDocument();
expect(getByTestId('workflow-open-publish-modal-button')).not.toBeDisabled();
});
it('should show publish indicator when state is dirty', () => {
it('should show publish button enabled when state is dirty', () => {
workflowsStore.workflowTriggerNodes = [triggerNode];
workflowsStore.workflow.versionId = 'version-1';
workflowsStore.workflow.activeVersion = createMockActiveVersion('version-1');
@@ -305,10 +321,10 @@ describe('WorkflowHeaderDraftPublishActions', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('workflow-publish-indicator')).toBeInTheDocument();
expect(getByTestId('workflow-open-publish-modal-button')).not.toBeDisabled();
});
it('should not show publish indicator when versions match and state is not dirty', () => {
it('should show publish button disabled when versions match and state is not dirty', () => {
workflowsStore.workflowTriggerNodes = [triggerNode];
workflowsStore.workflow.versionId = 'version-1';
workflowsStore.workflow.activeVersion = createMockActiveVersion('version-1');
@@ -316,10 +332,10 @@ describe('WorkflowHeaderDraftPublishActions', () => {
const { queryByTestId } = renderComponent();
expect(queryByTestId('workflow-publish-indicator')).not.toBeInTheDocument();
expect(queryByTestId('workflow-open-publish-modal-button')).toBeDisabled();
});
it('should show publish indicator when workflow has never been published (no active version)', () => {
it('should show publish button enabled when workflow has never been published (no active version)', () => {
workflowsStore.workflowTriggerNodes = [triggerNode];
workflowsStore.workflow.versionId = 'version-1';
workflowsStore.workflow.activeVersion = null;
@@ -327,7 +343,7 @@ describe('WorkflowHeaderDraftPublishActions', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('workflow-publish-indicator')).toBeInTheDocument();
expect(getByTestId('workflow-open-publish-modal-button')).not.toBeDisabled();
});
});
@@ -84,7 +84,7 @@ const isWorkflowSaved = computed(() => {
return !uiStore.stateIsDirty && !props.isNewWorkflow;
});
const showPublishIndicator = computed(() => {
const publishButtonEnabled = computed(() => {
if (!containsTrigger.value) {
return false;
}
@@ -100,6 +100,28 @@ const showPublishIndicator = computed(() => {
);
});
const publishTooltipText = computed(() => {
const wfHasAnyChanges =
workflowsStore.workflow.versionId !== workflowsStore.workflow.activeVersion?.versionId;
if (!containsTrigger.value) {
return i18n.baseText('workflows.publishModal.noTriggerMessage');
}
if (workflowsStore.nodesIssuesExist) {
return i18n.baseText('workflowActivator.showMessage.activeChangedNodesIssuesExistTrue.title', {
interpolate: { count: workflowsStore.nodesWithIssues.length },
adjustToNumber: workflowsStore.nodesWithIssues.length,
});
}
if (!wfHasAnyChanges && !uiStore.stateIsDirty) {
return i18n.baseText('workflows.publishModal.noChanges');
}
return '';
});
const activeVersion = computed(() => workflowsStore.workflow.activeVersion);
const activeVersionName = computed(() => {
@@ -144,20 +166,20 @@ defineExpose({
</N8nTooltip>
</div>
<div v-if="!isArchived && workflowPermissions.update" :class="$style.publishButtonWrapper">
<N8nButton
:loading="autoSaveForPublish"
:disabled="isWorkflowSaving"
type="secondary"
data-test-id="workflow-open-publish-modal-button"
@click="onPublishButtonClick"
>
{{ locale.baseText('workflows.publish') }}
</N8nButton>
<span
v-if="showPublishIndicator"
:class="$style.publishButtonIndicator"
data-test-id="workflow-publish-indicator"
></span>
<N8nTooltip :disabled="!publishTooltipText">
<template #content>
{{ publishTooltipText }}
</template>
<N8nButton
:loading="autoSaveForPublish"
:disabled="!publishButtonEnabled || isWorkflowSaving"
type="secondary"
data-test-id="workflow-open-publish-modal-button"
@click="onPublishButtonClick"
>
{{ locale.baseText('workflows.publish') }}
</N8nButton>
</N8nTooltip>
</div>
<SaveButton
type="primary"
@@ -209,15 +231,4 @@ defineExpose({
position: relative;
display: inline-block;
}
.publishButtonIndicator {
position: absolute;
top: -2px;
right: -2px;
width: 7px;
height: 7px;
background-color: var(--color--primary);
border-radius: 50%;
box-shadow: 0 0 0 2px var(--color--background--light-3);
}
</style>
@@ -15,7 +15,7 @@ test.describe('GHC-5776: AI sessions metadata should not cause console errors',
await n8n.start.fromBlankCanvas();
// This is just to trigger the watch on the workflow id, which will trigger the fetch of the sessions metadata
await n8n.canvas.getOpenPublishModalButton().click();
await n8n.canvas.getWorkflowSaveButton().click();
const consoleMessages = await n8n.page.consoleMessages();
const errorMessages = consoleMessages.filter((msg) => msg.type() === 'error');