diff --git a/cypress/e2e/41-editors.cy.ts b/cypress/e2e/41-editors.cy.ts index c99b982e4c..d84aa5843f 100644 --- a/cypress/e2e/41-editors.cy.ts +++ b/cypress/e2e/41-editors.cy.ts @@ -110,6 +110,40 @@ describe('Editors', () => { ndv.actions.close(); workflowPage.getters.isWorkflowSaved().should('not.be.true'); }); + + it('should allow switching between SQL editors in connected nodes', () => { + workflowPage.actions.addInitialNodeToCanvas('Postgres', { + action: 'Execute a SQL query', + keepNdvOpen: true, + }); + ndv.getters + .sqlEditorContainer() + .click() + .find('.cm-content') + .paste('SELECT * FROM `firstTable`'); + ndv.actions.close(); + + workflowPage.actions.addNodeToCanvas('Postgres', true, true, 'Execute a SQL query'); + ndv.getters + .sqlEditorContainer() + .click() + .find('.cm-content') + .paste('SELECT * FROM `secondTable`'); + ndv.actions.close(); + + workflowPage.actions.openNode('Postgres'); + ndv.actions.clickFloatingNode('Postgres1'); + ndv.getters + .sqlEditorContainer() + .find('.cm-content') + .should('have.text', 'SELECT * FROM `secondTable`'); + + ndv.actions.clickFloatingNode('Postgres'); + ndv.getters + .sqlEditorContainer() + .find('.cm-content') + .should('have.text', 'SELECT * FROM `firstTable`'); + }); }); describe('HTML Editor', () => { @@ -173,5 +207,38 @@ describe('Editors', () => { ndv.actions.close(); workflowPage.getters.isWorkflowSaved().should('not.be.true'); }); + + it('should allow switching between HTML editors in connected nodes', () => { + workflowPage.actions.addInitialNodeToCanvas('HTML', { + action: 'Generate HTML template', + keepNdvOpen: true, + }); + ndv.getters + .htmlEditorContainer() + .click() + .find('.cm-content') + .type('{selectall}') + .paste('
First
'); + ndv.actions.close(); + + workflowPage.actions.addNodeToCanvas('HTML', true, true, 'Generate HTML template'); + ndv.getters + .htmlEditorContainer() + .click() + .find('.cm-content') + .type('{selectall}') + .paste('
Second
'); + ndv.actions.close(); + + workflowPage.actions.openNode('HTML'); + ndv.actions.clickFloatingNode('HTML1'); + ndv.getters + .htmlEditorContainer() + .find('.cm-content') + .should('have.text', '
Second
'); + + ndv.actions.clickFloatingNode('HTML'); + ndv.getters.htmlEditorContainer().find('.cm-content').should('have.text', '
First
'); + }); }); }); diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index 3c208241d7..65ff46087e 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -387,7 +387,6 @@ describe('NDV', () => { ndv.getters.codeEditorFullscreen().type('{selectall}').type('{backspace}').type('foo()'); ndv.getters.codeEditorFullscreen().should('contain.text', 'foo()'); - cy.wait(200); // allow change to emit before closing modal ndv.getters.codeEditorDialog().find('.el-dialog__close').click(); ndv.getters.parameterInput('jsCode').get('.cm-content').should('contain.text', 'foo()'); ndv.actions.close(); @@ -400,9 +399,8 @@ describe('NDV', () => { .codeEditorFullscreen() .type('{selectall}') .type('{backspace}') - .type('SELECT * FROM workflows'); + .paste('SELECT * FROM workflows'); ndv.getters.codeEditorFullscreen().should('contain.text', 'SELECT * FROM workflows'); - cy.wait(200); ndv.getters.codeEditorDialog().find('.el-dialog__close').click(); ndv.getters .parameterInput('query') @@ -418,10 +416,8 @@ describe('NDV', () => { .codeEditorFullscreen() .type('{selectall}') .type('{backspace}') - .type('
Hello World'); + .type('
Hello World
'); ndv.getters.codeEditorFullscreen().should('contain.text', '
Hello World
'); - cy.wait(200); - ndv.getters.codeEditorDialog().find('.el-dialog__close').click(); ndv.getters .parameterInput('html') diff --git a/packages/frontend/editor-ui/src/components/CssEditor/CssEditor.vue b/packages/frontend/editor-ui/src/components/CssEditor/CssEditor.vue index a6ef3ffbb3..d163ba2680 100644 --- a/packages/frontend/editor-ui/src/components/CssEditor/CssEditor.vue +++ b/packages/frontend/editor-ui/src/components/CssEditor/CssEditor.vue @@ -10,18 +10,18 @@ import { keymap, lineNumbers, } from '@codemirror/view'; -import { computed, onMounted, ref, toRaw, watch } from 'vue'; +import { computed, ref, toRaw } from 'vue'; import { useExpressionEditor } from '@/composables/useExpressionEditor'; import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions'; -import { editorKeymap } from '@/plugins/codemirror/keymap'; -import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang'; -import { codeEditorTheme } from '../CodeNodeEditor/theme'; import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop'; import { expressionCloseBrackets, expressionCloseBracketsConfig, } from '@/plugins/codemirror/expressionCloseBrackets'; +import { editorKeymap } from '@/plugins/codemirror/keymap'; +import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang'; +import { codeEditorTheme } from '../CodeNodeEditor/theme'; type Props = { modelValue: string; @@ -69,23 +69,13 @@ const extensions = computed(() => [ mappingDropCursor(), ]); -const { - editor: editorRef, - segments, - readEditorValue, - isDirty, -} = useExpressionEditor({ +const { editor: editorRef, readEditorValue } = useExpressionEditor({ editorRef: cssEditor, editorValue, extensions, -}); - -watch(segments.display, () => { - emit('update:model-value', readEditorValue()); -}); - -onMounted(() => { - if (isDirty.value) emit('update:model-value', readEditorValue()); + onChange: () => { + emit('update:model-value', readEditorValue()); + }, }); async function onDrop(value: string, event: MouseEvent) { diff --git a/packages/frontend/editor-ui/src/components/HtmlEditor/HtmlEditor.vue b/packages/frontend/editor-ui/src/components/HtmlEditor/HtmlEditor.vue index e868ff7ef2..9830e3b810 100644 --- a/packages/frontend/editor-ui/src/components/HtmlEditor/HtmlEditor.vue +++ b/packages/frontend/editor-ui/src/components/HtmlEditor/HtmlEditor.vue @@ -20,22 +20,22 @@ import jsParser from 'prettier/plugins/babel'; import * as estree from 'prettier/plugins/estree'; import htmlParser from 'prettier/plugins/html'; import cssParser from 'prettier/plugins/postcss'; -import { computed, onBeforeUnmount, onMounted, ref, toRaw, toValue, watch } from 'vue'; +import { computed, onBeforeUnmount, onMounted, ref, toRaw, toValue } from 'vue'; import { useExpressionEditor } from '@/composables/useExpressionEditor'; import { htmlEditorEventBus } from '@/event-bus'; import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions'; +import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop'; +import { + expressionCloseBrackets, + expressionCloseBracketsConfig, +} from '@/plugins/codemirror/expressionCloseBrackets'; import { editorKeymap } from '@/plugins/codemirror/keymap'; import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang'; import { autoCloseTags, htmlLanguage } from 'codemirror-lang-html-n8n'; import { codeEditorTheme } from '../CodeNodeEditor/theme'; import type { Range, Section } from './types'; import { nonTakenRanges } from './utils'; -import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop'; -import { - expressionCloseBrackets, - expressionCloseBracketsConfig, -} from '@/plugins/codemirror/expressionCloseBrackets'; type Props = { modelValue: string; @@ -55,7 +55,6 @@ const emit = defineEmits<{ }>(); const htmlEditor = ref(); -const editorValue = ref(props.modelValue); const extensions = computed(() => [ bracketMatching(), n8nAutocompletion(), @@ -82,15 +81,13 @@ const extensions = computed(() => [ highlightActiveLine(), mappingDropCursor(), ]); -const { - editor: editorRef, - segments, - readEditorValue, - isDirty, -} = useExpressionEditor({ +const { editor: editorRef, readEditorValue } = useExpressionEditor({ editorRef: htmlEditor, - editorValue, + editorValue: () => props.modelValue, extensions, + onChange: () => { + emit('update:model-value', readEditorValue()); + }, }); const sections = computed(() => { @@ -225,16 +222,11 @@ async function formatHtml() { }); } -watch(segments.display, () => { - emit('update:model-value', readEditorValue()); -}); - onMounted(() => { htmlEditorEventBus.on('format-html', formatHtml); }); onBeforeUnmount(() => { - if (isDirty.value) emit('update:model-value', readEditorValue()); htmlEditorEventBus.off('format-html', formatHtml); }); diff --git a/packages/frontend/editor-ui/src/components/SqlEditor/SqlEditor.vue b/packages/frontend/editor-ui/src/components/SqlEditor/SqlEditor.vue index 398f66c866..29e8d5aa4e 100644 --- a/packages/frontend/editor-ui/src/components/SqlEditor/SqlEditor.vue +++ b/packages/frontend/editor-ui/src/components/SqlEditor/SqlEditor.vue @@ -112,27 +112,21 @@ const extensions = computed(() => { } return baseExtensions; }); -const editorValue = ref(props.modelValue); const { editor, segments: { all: segments }, readEditorValue, hasFocus: editorHasFocus, - isDirty, } = useExpressionEditor({ editorRef: sqlEditor, - editorValue, + editorValue: () => props.modelValue, extensions, skipSegments: ['Statement', 'CompositeIdentifier', 'Parens', 'Brackets'], isReadOnly: props.isReadOnly, -}); - -watch( - () => props.modelValue, - (newValue) => { - editorValue.value = newValue; + onChange: () => { + emit('update:model-value', readEditorValue()); }, -); +}); watch(editorHasFocus, (focus) => { if (focus) { @@ -140,10 +134,6 @@ watch(editorHasFocus, (focus) => { } }); -watch(segments, () => { - emit('update:model-value', readEditorValue()); -}); - onMounted(() => { codeNodeEditorEventBus.on('highlightLine', highlightLine); @@ -153,7 +143,6 @@ onMounted(() => { }); onBeforeUnmount(() => { - if (isDirty.value) emit('update:model-value', readEditorValue()); codeNodeEditorEventBus.off('highlightLine', highlightLine); }); diff --git a/packages/frontend/editor-ui/src/composables/useExpressionEditor.ts b/packages/frontend/editor-ui/src/composables/useExpressionEditor.ts index ed845e3501..2c519617e6 100644 --- a/packages/frontend/editor-ui/src/composables/useExpressionEditor.ts +++ b/packages/frontend/editor-ui/src/composables/useExpressionEditor.ts @@ -38,6 +38,7 @@ import { useRouter } from 'vue-router'; import { useI18n } from '../composables/useI18n'; import { useWorkflowsStore } from '../stores/workflows.store'; import { useAutocompleteTelemetry } from './useAutocompleteTelemetry'; +import { ignoreUpdateAnnotation } from '../utils/forceParse'; export const useExpressionEditor = ({ editorRef, @@ -47,6 +48,7 @@ export const useExpressionEditor = ({ skipSegments = [], autocompleteTelemetry, isReadOnly = false, + onChange = () => {}, }: { editorRef: MaybeRefOrGetter; editorValue?: MaybeRefOrGetter; @@ -55,6 +57,7 @@ export const useExpressionEditor = ({ skipSegments?: MaybeRefOrGetter; autocompleteTelemetry?: MaybeRefOrGetter<{ enabled: true; parameterPath: string }>; isReadOnly?: MaybeRefOrGetter; + onChange?: (viewUpdate: ViewUpdate) => void; }) => { const ndvStore = useNDVStore(); const workflowsStore = useWorkflowsStore(); @@ -70,7 +73,9 @@ export const useExpressionEditor = ({ const telemetryExtensions = ref(new Compartment()); const autocompleteStatus = ref<'pending' | 'active' | null>(null); const dragging = ref(false); - const isDirty = ref(false); + const hasChanges = ref(false); + + const emitChanges = debounce(onChange, 300); const updateSegments = (): void => { const state = editor.value?.state; @@ -157,13 +162,18 @@ export const useExpressionEditor = ({ const debouncedUpdateSegments = debounce(updateSegments, 200); function onEditorUpdate(viewUpdate: ViewUpdate) { - isDirty.value = true; autocompleteStatus.value = completionStatus(viewUpdate.view.state); updateSelection(viewUpdate); - if (!viewUpdate.docChanged) return; + const shouldIgnoreUpdate = viewUpdate.transactions.some((tr) => + tr.annotation(ignoreUpdateAnnotation), + ); - debouncedUpdateSegments(); + if (viewUpdate.docChanged && !shouldIgnoreUpdate) { + hasChanges.value = true; + emitChanges(viewUpdate); + debouncedUpdateSegments(); + } } function blur() { @@ -265,6 +275,8 @@ export const useExpressionEditor = ({ onBeforeUnmount(() => { document.removeEventListener('click', blurOnClickOutside); + debouncedUpdateSegments.flush(); + emitChanges.flush(); editor.value?.destroy(); }); @@ -465,6 +477,6 @@ export const useExpressionEditor = ({ select, selectAll, focus, - isDirty, + hasChanges, }; };