feat(editor): Add fullscreen view to code editor (#8084)

## Summary

<img width="1240" alt="image"
src="https://github.com/n8n-io/n8n/assets/8850410/2819f4ce-c343-431a-8a88-a1bc9c4b572a">
<img width="2649" alt="image"
src="https://github.com/n8n-io/n8n/assets/8850410/36862aaf-cc4c-4668-bdc8-cf5a6f00babe">

1. Add code node and open it
3. Click the fullscreen button in the bottom right
4. A fullscreen dialog should appear and allow editing the code
5. Changes made in the fullscreen dialog should be applied to the
original code editor when closed

It should work the same way for HTML/SQL/JSON editors

⚠️ Modal layout was updated so that modals/dialogs are centered, try to
test some modals

## Related tickets and issues
https://linear.app/n8n/issue/NODE-1009/add-fullscreen-view-to-code-node



## Review / Merge checklist
- [ ] PR title and summary are descriptive. **Remember, the title
automatically goes into the changelog. Use `(no-changelog)` otherwise.**
([conventions](https://github.com/n8n-io/n8n/blob/master/.github/pull_request_title_conventions.md))
- [ ] [Docs updated](https://github.com/n8n-io/n8n-docs) or follow-up
ticket created.
- [ ] Tests included.
> A bug is not considered fixed, unless a test is added to prevent it
from happening again.
   > A feature is not complete without tests.

---------

Co-authored-by: Giulio Andreini <andreini@netseven.it>
This commit is contained in:
Elias Meire 2024-01-04 17:23:24 +01:00 committed by GitHub
parent 8f22a265d6
commit 071e6d6b6e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 617 additions and 376 deletions

View file

@ -1,5 +1,5 @@
import { WorkflowPage, NDV, CredentialsModal } from '../pages';
import { getPopper, getVisiblePopper, getVisibleSelect } from '../utils';
import { getVisiblePopper, getVisibleSelect } from '../utils';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
@ -66,6 +66,8 @@ describe('Resource Locator', () => {
workflowPage.actions.addInitialNodeToCanvas('E2e Test', { action: 'Resource Locator' });
ndv.getters.resourceLocatorInput('rlc').click();
cy.getByTestId('rlc-item').should('exist');
getVisiblePopper()
.should('have.length', 1)
.findChildByTestId('rlc-item')
@ -73,9 +75,11 @@ describe('Resource Locator', () => {
ndv.actions.setInvalidExpression({ fieldName: 'fieldId' });
ndv.getters.container().click(); // remove focus from input, hide expression preview
ndv.getters.nodeParameters().click(); // remove focus from input, hide expression preview
ndv.getters.resourceLocatorInput('rlc').click();
cy.getByTestId('rlc-item').should('exist');
getVisiblePopper()
.should('have.length', 1)
.findChildByTestId('rlc-item')

View file

@ -302,7 +302,7 @@ describe('NDV', () => {
ndv.actions.setInvalidExpression({ fieldName: 'fieldId', delay: 200 });
ndv.getters.container().click(); // remove focus from input, hide expression preview
ndv.getters.nodeParameters().click(); // remove focus from input, hide expression preview
ndv.getters.parameterInput('remoteOptions').click();
@ -320,7 +320,7 @@ describe('NDV', () => {
ndv.actions.setInvalidExpression({ fieldName: 'otherField', delay: 50 });
ndv.getters.container().click(); // remove focus from input, hide expression preview
ndv.getters.nodeParameters().click(); // remove focus from input, hide expression preview
ndv.getters.parameterInput('remoteOptions').click();
getVisibleSelect().find('.el-select-dropdown__item').should('have.length', 3);
@ -360,6 +360,15 @@ describe('NDV', () => {
ndv.getters.nodeExecuteButton().should('be.visible');
});
it('should allow editing code in fullscreen in the Code node', () => {
workflowPage.actions.addInitialNodeToCanvas('Code', { keepNdvOpen: true });
ndv.actions.openCodeEditorFullscreen();
ndv.getters.codeEditorFullscreen().type('{selectall}').type('{backspace}').type('foo()');
ndv.getters.codeEditorDialog().find('.el-dialog__close').click();
ndv.getters.parameterInput('jsCode').get('.cm-content').should('contain.text', 'foo()');
});
it('should not retrieve remote options when a parameter value changes', () => {
cy.intercept('/rest/dynamic-node-parameters/options?**', cy.spy().as('fetchParameterOptions'));
workflowPage.actions.addInitialNodeToCanvas('E2e Test', { action: 'Remote Options' });
@ -370,106 +379,126 @@ describe('NDV', () => {
});
describe('floating nodes', () => {
function getFloatingNodeByPosition(position: 'inputMain' | 'outputMain' | 'outputSub'| 'inputSub') {
function getFloatingNodeByPosition(
position: 'inputMain' | 'outputMain' | 'outputSub' | 'inputSub',
) {
return cy.get(`[data-node-placement=${position}]`);
}
beforeEach(() => {
cy.createFixtureWorkflow('Floating_Nodes.json', `Floating Nodes`);
workflowPage.getters.canvasNodes().first().dblclick()
getFloatingNodeByPosition("inputMain").should('not.exist');
getFloatingNodeByPosition("outputMain").should('exist');
workflowPage.getters.canvasNodes().first().dblclick();
getFloatingNodeByPosition('inputMain').should('not.exist');
getFloatingNodeByPosition('outputMain').should('exist');
});
it('should traverse floating nodes with mouse', () => {
// Traverse 4 connected node forwards
Array.from(Array(4).keys()).forEach(i => {
getFloatingNodeByPosition("outputMain").click({ force: true});
Array.from(Array(4).keys()).forEach((i) => {
getFloatingNodeByPosition('outputMain').click({ force: true });
ndv.getters.nodeNameContainer().should('contain', `Node ${i + 1}`);
getFloatingNodeByPosition("inputMain").should('exist');
getFloatingNodeByPosition("outputMain").should('exist');
getFloatingNodeByPosition('inputMain').should('exist');
getFloatingNodeByPosition('outputMain').should('exist');
ndv.actions.close();
workflowPage.getters.selectedNodes().should('have.length', 1);
workflowPage.getters.selectedNodes().first().should('contain', `Node ${i + 1}`);
workflowPage.getters
.selectedNodes()
.first()
.should('contain', `Node ${i + 1}`);
workflowPage.getters.selectedNodes().first().dblclick();
})
});
getFloatingNodeByPosition("outputMain").click({ force: true});
getFloatingNodeByPosition('outputMain').click({ force: true });
ndv.getters.nodeNameContainer().should('contain', 'Chain');
getFloatingNodeByPosition("inputSub").should('exist');
getFloatingNodeByPosition("inputSub").click({ force: true});
getFloatingNodeByPosition('inputSub').should('exist');
getFloatingNodeByPosition('inputSub').click({ force: true });
ndv.getters.nodeNameContainer().should('contain', 'Model');
getFloatingNodeByPosition("inputSub").should('not.exist');
getFloatingNodeByPosition("inputMain").should('not.exist');
getFloatingNodeByPosition("outputMain").should('not.exist');
getFloatingNodeByPosition("outputSub").should('exist');
getFloatingNodeByPosition('inputSub').should('not.exist');
getFloatingNodeByPosition('inputMain').should('not.exist');
getFloatingNodeByPosition('outputMain').should('not.exist');
getFloatingNodeByPosition('outputSub').should('exist');
ndv.actions.close();
workflowPage.getters.selectedNodes().should('have.length', 1);
workflowPage.getters.selectedNodes().first().should('contain', 'Model');
workflowPage.getters.selectedNodes().first().dblclick();
getFloatingNodeByPosition("outputSub").click({ force: true});
getFloatingNodeByPosition('outputSub').click({ force: true });
ndv.getters.nodeNameContainer().should('contain', 'Chain');
// Traverse 4 connected node backwards
Array.from(Array(4).keys()).forEach(i => {
getFloatingNodeByPosition("inputMain").click({ force: true});
ndv.getters.nodeNameContainer().should('contain', `Node ${4 - (i)}`);
getFloatingNodeByPosition("outputMain").should('exist');
getFloatingNodeByPosition("inputMain").should('exist');
})
getFloatingNodeByPosition("inputMain").click({ force: true});
workflowPage.getters.selectedNodes().first().should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
getFloatingNodeByPosition("inputMain").should('not.exist');
getFloatingNodeByPosition("inputSub").should('not.exist');
getFloatingNodeByPosition("outputSub").should('not.exist');
Array.from(Array(4).keys()).forEach((i) => {
getFloatingNodeByPosition('inputMain').click({ force: true });
ndv.getters.nodeNameContainer().should('contain', `Node ${4 - i}`);
getFloatingNodeByPosition('outputMain').should('exist');
getFloatingNodeByPosition('inputMain').should('exist');
});
getFloatingNodeByPosition('inputMain').click({ force: true });
workflowPage.getters
.selectedNodes()
.first()
.should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
getFloatingNodeByPosition('inputMain').should('not.exist');
getFloatingNodeByPosition('inputSub').should('not.exist');
getFloatingNodeByPosition('outputSub').should('not.exist');
ndv.actions.close();
workflowPage.getters.selectedNodes().should('have.length', 1);
workflowPage.getters.selectedNodes().first().should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
workflowPage.getters
.selectedNodes()
.first()
.should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
});
it('should traverse floating nodes with mouse', () => {
// Traverse 4 connected node forwards
Array.from(Array(4).keys()).forEach(i => {
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowRight'])
Array.from(Array(4).keys()).forEach((i) => {
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowRight']);
ndv.getters.nodeNameContainer().should('contain', `Node ${i + 1}`);
getFloatingNodeByPosition("inputMain").should('exist');
getFloatingNodeByPosition("outputMain").should('exist');
getFloatingNodeByPosition('inputMain').should('exist');
getFloatingNodeByPosition('outputMain').should('exist');
ndv.actions.close();
workflowPage.getters.selectedNodes().should('have.length', 1);
workflowPage.getters.selectedNodes().first().should('contain', `Node ${i + 1}`);
workflowPage.getters
.selectedNodes()
.first()
.should('contain', `Node ${i + 1}`);
workflowPage.getters.selectedNodes().first().dblclick();
})
});
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowRight'])
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowRight']);
ndv.getters.nodeNameContainer().should('contain', 'Chain');
getFloatingNodeByPosition("inputSub").should('exist');
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowDown'])
getFloatingNodeByPosition('inputSub').should('exist');
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowDown']);
ndv.getters.nodeNameContainer().should('contain', 'Model');
getFloatingNodeByPosition("inputSub").should('not.exist');
getFloatingNodeByPosition("inputMain").should('not.exist');
getFloatingNodeByPosition("outputMain").should('not.exist');
getFloatingNodeByPosition("outputSub").should('exist');
getFloatingNodeByPosition('inputSub').should('not.exist');
getFloatingNodeByPosition('inputMain').should('not.exist');
getFloatingNodeByPosition('outputMain').should('not.exist');
getFloatingNodeByPosition('outputSub').should('exist');
ndv.actions.close();
workflowPage.getters.selectedNodes().should('have.length', 1);
workflowPage.getters.selectedNodes().first().should('contain', 'Model');
workflowPage.getters.selectedNodes().first().dblclick();
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowUp'])
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowUp']);
ndv.getters.nodeNameContainer().should('contain', 'Chain');
// Traverse 4 connected node backwards
Array.from(Array(4).keys()).forEach(i => {
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowLeft'])
ndv.getters.nodeNameContainer().should('contain', `Node ${4 - (i)}`);
getFloatingNodeByPosition("outputMain").should('exist');
getFloatingNodeByPosition("inputMain").should('exist');
})
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowLeft'])
workflowPage.getters.selectedNodes().first().should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
getFloatingNodeByPosition("inputMain").should('not.exist');
getFloatingNodeByPosition("inputSub").should('not.exist');
getFloatingNodeByPosition("outputSub").should('not.exist');
Array.from(Array(4).keys()).forEach((i) => {
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowLeft']);
ndv.getters.nodeNameContainer().should('contain', `Node ${4 - i}`);
getFloatingNodeByPosition('outputMain').should('exist');
getFloatingNodeByPosition('inputMain').should('exist');
});
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowLeft']);
workflowPage.getters
.selectedNodes()
.first()
.should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
getFloatingNodeByPosition('inputMain').should('not.exist');
getFloatingNodeByPosition('inputSub').should('not.exist');
getFloatingNodeByPosition('outputSub').should('not.exist');
ndv.actions.close();
workflowPage.getters.selectedNodes().should('have.length', 1);
workflowPage.getters.selectedNodes().first().should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
workflowPage.getters
.selectedNodes()
.first()
.should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
});
});
@ -501,23 +530,34 @@ describe('NDV', () => {
ndv.getters.outputDisplayMode().find('[class*=active]').should('contain', 'Table');
ndv.getters.outputTableRow(1).should('include.text', '<?xml version="1.0" encoding="UTF-8"?> <library>');
ndv.getters
.outputTableRow(1)
.should('include.text', '<?xml version="1.0" encoding="UTF-8"?> <library>');
cy.document().trigger('keyup', { key: '/' });
ndv.getters.searchInput().filter(':focus').type('<lib');
ndv.getters.outputTableRow(1).find('mark').should('have.text', '<lib')
ndv.getters.outputTableRow(1).find('mark').should('have.text', '<lib');
ndv.getters.outputDisplayMode().find('label').eq(1).should('include.text', 'JSON');
ndv.getters.outputDisplayMode().find('label').eq(1).click();
ndv.getters.outputDataContainer().find('.json-data').should('be.visible');
ndv.getters.outputDataContainer().should('have.text', '[{"body": "<?xml version="1.0" encoding="UTF-8"?> <library> <book> <title>Introduction to XML</title> <author>John Doe</author> <publication_year>2020</publication_year> <isbn>1234567890</isbn> </book> <book> <title>Data Science Basics</title> <author>Jane Smith</author> <publication_year>2019</publication_year> <isbn>0987654321</isbn> </book> <book> <title>Programming in Python</title> <author>Bob Johnson</author> <publication_year>2021</publication_year> <isbn>5432109876</isbn> </book> </library>"}]');
ndv.getters.outputDataContainer().find('mark').should('have.text', '<lib')
ndv.getters.outputDataContainer().find('.json-data').should('exist');
ndv.getters
.outputDataContainer()
.should(
'have.text',
'[{"body": "<?xml version="1.0" encoding="UTF-8"?> <library> <book> <title>Introduction to XML</title> <author>John Doe</author> <publication_year>2020</publication_year> <isbn>1234567890</isbn> </book> <book> <title>Data Science Basics</title> <author>Jane Smith</author> <publication_year>2019</publication_year> <isbn>0987654321</isbn> </book> <book> <title>Programming in Python</title> <author>Bob Johnson</author> <publication_year>2021</publication_year> <isbn>5432109876</isbn> </book> </library>"}]',
);
ndv.getters.outputDataContainer().find('mark').should('have.text', '<lib');
ndv.getters.outputDisplayMode().find('label').eq(2).should('include.text', 'Schema');
ndv.getters.outputDisplayMode().find('label').eq(2).click({force: true});
ndv.getters.outputDataContainer().findChildByTestId('run-data-schema-item').find('> span').should('include.text', '<?xml version="1.0" encoding="UTF-8"?>');
ndv.getters.outputDisplayMode().find('label').eq(2).click({ force: true });
ndv.getters
.outputDataContainer()
.findChildByTestId('run-data-schema-item')
.find('> span')
.should('include.text', '<?xml version="1.0" encoding="UTF-8"?>');
});
it('should properly show node execution indicator', () => {
@ -546,7 +586,10 @@ describe('NDV', () => {
});
it('Should handle mismatched option attributes', () => {
workflowPage.actions.addInitialNodeToCanvas('LDAP', { keepNdvOpen: true, action: 'Create a new entry' });
workflowPage.actions.addInitialNodeToCanvas('LDAP', {
keepNdvOpen: true,
action: 'Create a new entry',
});
// Add some attributes in Create operation
cy.getByTestId('parameter-item').contains('Add Attributes').click();
ndv.actions.changeNodeOperation('Update');
@ -556,7 +599,10 @@ describe('NDV', () => {
it('Should keep RLC values after operation change', () => {
const TEST_DOC_ID = '1111';
workflowPage.actions.addInitialNodeToCanvas('Google Sheets', { keepNdvOpen: true, action: 'Append row in sheet' });
workflowPage.actions.addInitialNodeToCanvas('Google Sheets', {
keepNdvOpen: true,
action: 'Append row in sheet',
});
ndv.actions.setRLCValue('documentId', TEST_DOC_ID);
ndv.actions.changeNodeOperation('Update Row');
ndv.getters.resourceLocatorInput('documentId').find('input').should('have.value', TEST_DOC_ID);

View file

@ -98,6 +98,9 @@ export class NDV extends BasePage {
pagination: () => cy.getByTestId('ndv-data-pagination'),
nodeVersion: () => cy.getByTestId('node-version'),
nodeSettingsTab: () => cy.getByTestId('tab-settings'),
codeEditorFullscreenButton: () => cy.getByTestId('code-editor-fullscreen-button'),
codeEditorDialog: () => cy.getByTestId('code-editor-fullscreen'),
codeEditorFullscreen: () => this.getters.codeEditorDialog().find('.cm-content'),
nodeRunSuccessIndicator: () => cy.getByTestId('node-run-info-success'),
nodeRunErrorIndicator: () => cy.getByTestId('node-run-info-danger'),
};
@ -251,9 +254,15 @@ export class NDV extends BasePage {
openSettings: () => {
this.getters.nodeSettingsTab().click();
},
openCodeEditorFullscreen: () => {
this.getters.codeEditorFullscreenButton().click({ force: true });
},
changeNodeOperation: (operation: string) => {
this.getters.parameterInput('operation').click();
cy.get('.el-select-dropdown__item').contains(new RegExp(`^${operation}$`)).click({ force: true });
cy.get('.el-select-dropdown__item')
.contains(new RegExp(`^${operation}$`))
.click({ force: true });
this.getters.parameterInput('operation').find('input').should('have.value', operation);
},
};

View file

@ -160,6 +160,7 @@ Cypress.Commands.add('draganddrop', (draggableSelector, droppableSelector) => {
cy.get(draggableSelector).trigger('mousedown');
}
// We don't chain these commands to make sure cy.get is re-trying correctly
cy.get(droppableSelector).realMouseMove(0, 0);
cy.get(droppableSelector).realMouseMove(pageX, pageY);
cy.get(droppableSelector).realHover();
cy.get(droppableSelector).realMouseUp();

View file

@ -18,7 +18,8 @@
height: 100%;
display: flex;
flex-direction: column;
align-items: inherit;
align-items: center;
justify-content: center;
overflow-x: hidden;
overflow-y: auto;
}
@ -60,7 +61,6 @@
@include mixins.e(header) {
padding: var.$dialog-padding-primary;
padding-bottom: 0px;
}
@include mixins.e(headerbtn) {
@ -95,6 +95,7 @@
@include mixins.e(body) {
padding: var.$dialog-padding-primary;
padding-top: 0;
color: var(--color-text-base);
}

View file

@ -17,7 +17,11 @@
name="code"
data-test-id="code-node-tab-code"
>
<div ref="codeNodeEditor" class="code-node-editor-input ph-no-capture code-editor-tabs" />
<div
ref="codeNodeEditor"
:class="['ph-no-capture', 'code-editor-tabs', $style.editorInput]"
/>
<slot name="suffix" />
</el-tab-pane>
<el-tab-pane
:label="$locale.baseText('codeNodeEditor.tabs.askAi')"
@ -35,7 +39,10 @@
</el-tab-pane>
</el-tabs>
<!-- If AskAi not enabled, there's no point in rendering tabs -->
<div v-else ref="codeNodeEditor" class="code-node-editor-input ph-no-capture" />
<div v-else :class="$style.fillHeight">
<div ref="codeNodeEditor" :class="['ph-no-capture', $style.fillHeight]" />
<slot name="suffix" />
</div>
</div>
</template>
@ -82,6 +89,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
fillParent: {
type: Boolean,
default: false,
},
mode: {
type: String as PropType<CodeExecutionMode>,
validator: (value: CodeExecutionMode): boolean => CODE_EXECUTION_MODES.includes(value),
@ -193,7 +204,12 @@ export default defineComponent({
...readOnlyEditorExtensions,
EditorState.readOnly.of(isReadOnly),
EditorView.editable.of(!isReadOnly),
codeNodeEditorTheme({ isReadOnly, customMinHeight: this.rows }),
codeNodeEditorTheme({
isReadOnly,
maxHeight: this.fillParent ? '100%' : '40vh',
minHeight: '20vh',
rows: this.rows,
}),
];
if (!isReadOnly) {
@ -384,15 +400,9 @@ export default defineComponent({
<style lang="scss" module>
.code-node-editor-container {
position: relative;
& > div {
height: 100%;
}
}
.ask-ai-button {
position: absolute;
top: var(--spacing-2xs);
right: var(--spacing-2xs);
.fillHeight {
height: 100%;
}
</style>

View file

@ -6,12 +6,14 @@ import {
highlightSpecialChars,
keymap,
lineNumbers,
type KeyBinding,
} from '@codemirror/view';
import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { acceptCompletion } from '@codemirror/autocomplete';
import { acceptCompletion, selectedCompletion } from '@codemirror/autocomplete';
import {
history,
indentWithTab,
indentLess,
indentMore,
insertNewlineAndIndent,
toggleComment,
redo,
@ -19,7 +21,7 @@ import {
undo,
} from '@codemirror/commands';
import { lintGutter } from '@codemirror/lint';
import type { Extension } from '@codemirror/state';
import { type Extension, Prec } from '@codemirror/state';
import { codeInputHandler } from '@/plugins/codemirror/inputHandlers/code.inputHandler';
@ -29,6 +31,42 @@ export const readOnlyEditorExtensions: readonly Extension[] = [
highlightSpecialChars(),
];
export const tabKeyMap: KeyBinding[] = [
{
any(editor, event) {
if (event.key === 'Tab' || (event.key === 'Escape' && selectedCompletion(editor.state))) {
event.stopPropagation();
}
return false;
},
},
{
key: 'Tab',
run: (editor) => {
if (selectedCompletion(editor.state)) {
return acceptCompletion(editor);
}
return indentMore(editor);
},
},
{ key: 'Shift-Tab', run: indentLess },
];
export const enterKeyMap: KeyBinding[] = [
{
key: 'Enter',
run: (editor) => {
if (selectedCompletion(editor.state)) {
return acceptCompletion(editor);
}
return insertNewlineAndIndent(editor);
},
},
];
export const writableEditorExtensions: readonly Extension[] = [
history(),
lintGutter(),
@ -39,14 +77,14 @@ export const writableEditorExtensions: readonly Extension[] = [
bracketMatching(),
highlightActiveLine(),
highlightActiveLineGutter(),
keymap.of([
{ key: 'Enter', run: insertNewlineAndIndent },
{ key: 'Tab', run: acceptCompletion },
{ key: 'Enter', run: acceptCompletion },
{ key: 'Mod-/', run: toggleComment },
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
{ key: 'Backspace', run: deleteCharBackward, shift: deleteCharBackward },
indentWithTab,
]),
Prec.highest(
keymap.of([
...tabKeyMap,
...enterKeyMap,
{ key: 'Mod-/', run: toggleComment },
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
{ key: 'Backspace', run: deleteCharBackward, shift: deleteCharBackward },
]),
),
];

View file

@ -29,14 +29,18 @@ const BASE_STYLING = {
interface ThemeSettings {
isReadOnly?: boolean;
customMaxHeight?: string;
customMinHeight?: number;
maxHeight?: string;
minHeight?: string;
rows?: number;
highlightColors?: 'default' | 'html';
}
export const codeNodeEditorTheme = ({
isReadOnly,
customMaxHeight,
customMinHeight,
minHeight,
maxHeight,
rows,
highlightColors,
}: ThemeSettings) => [
EditorView.theme({
'&': {
@ -85,11 +89,13 @@ export const codeNodeEditorTheme = ({
},
'.cm-scroller': {
overflow: 'auto',
maxHeight: customMaxHeight ?? '100%',
maxHeight: maxHeight ?? '100%',
...(isReadOnly
? {}
: { minHeight: customMinHeight ? `${Number(customMinHeight) * 1.3}em` : '10em' }),
: { minHeight: rows && rows !== -1 ? `${Number(rows + 1) * 1.3}em` : 'auto' }),
},
'.cm-gutter,.cm-content': {
minHeight: rows && rows !== -1 ? 'auto' : minHeight ?? 'calc(35vh - var(--spacing-2xl))',
},
'.cm-diagnosticAction': {
backgroundColor: BASE_STYLING.diagnosticButton.backgroundColor,
@ -106,47 +112,97 @@ export const codeNodeEditorTheme = ({
color: 'var(--color-text-base)',
},
}),
syntaxHighlighting(
HighlightStyle.define([
{
tag: tags.comment,
color: 'var(--color-code-tags-comment)',
},
{
tag: [tags.string, tags.special(tags.brace)],
color: 'var(--color-code-tags-string)',
},
{
tag: [tags.number, tags.self, tags.bool, tags.null],
color: 'var(--color-code-tags-primitive)',
},
{
tag: tags.keyword,
color: 'var(--color-code-tags-keyword)',
},
{
tag: tags.operator,
color: 'var(--color-code-tags-operator)',
},
{
tag: [
tags.variableName,
tags.propertyName,
tags.attributeName,
tags.regexp,
tags.className,
tags.typeName,
],
color: 'var(--color-code-tags-variable)',
},
{
tag: [
tags.definition(tags.typeName),
tags.definition(tags.propertyName),
tags.function(tags.variableName),
],
color: 'var(--color-code-tags-definition)',
},
]),
),
highlightColors === 'html'
? syntaxHighlighting(
HighlightStyle.define([
{ tag: tags.keyword, color: '#c678dd' },
{
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
color: '#e06c75',
},
{ tag: [tags.function(tags.variableName), tags.labelName], color: '#61afef' },
{
tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)],
color: '#d19a66',
},
{ tag: [tags.definition(tags.name), tags.separator], color: '#abb2bf' },
{
tag: [
tags.typeName,
tags.className,
tags.number,
tags.changed,
tags.annotation,
tags.modifier,
tags.self,
tags.namespace,
],
color: '#e06c75',
},
{
tag: [
tags.operator,
tags.operatorKeyword,
tags.url,
tags.escape,
tags.regexp,
tags.link,
tags.special(tags.string),
],
color: '#56b6c2',
},
{ tag: [tags.meta, tags.comment], color: '#7d8799' },
{ tag: tags.strong, fontWeight: 'bold' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strikethrough, textDecoration: 'line-through' },
{ tag: tags.link, color: '#7d8799', textDecoration: 'underline' },
{ tag: tags.heading, fontWeight: 'bold', color: '#e06c75' },
{ tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: '#d19a66' },
{ tag: [tags.processingInstruction, tags.string, tags.inserted], color: '#98c379' },
{ tag: tags.invalid, color: 'red', 'font-weight': 'bold' },
]),
)
: syntaxHighlighting(
HighlightStyle.define([
{
tag: tags.comment,
color: 'var(--color-code-tags-comment)',
},
{
tag: [tags.string, tags.special(tags.brace)],
color: 'var(--color-code-tags-string)',
},
{
tag: [tags.number, tags.self, tags.bool, tags.null],
color: 'var(--color-code-tags-primitive)',
},
{
tag: tags.keyword,
color: 'var(--color-code-tags-keyword)',
},
{
tag: tags.operator,
color: 'var(--color-code-tags-operator)',
},
{
tag: [
tags.variableName,
tags.propertyName,
tags.attributeName,
tags.regexp,
tags.className,
tags.typeName,
],
color: 'var(--color-code-tags-variable)',
},
{
tag: [
tags.definition(tags.typeName),
tags.definition(tags.propertyName),
tags.function(tags.variableName),
],
color: 'var(--color-code-tags-definition)',
},
]),
),
];

View file

@ -1,44 +1,48 @@
<template>
<div ref="htmlEditor"></div>
<div :class="$style.editor">
<div ref="htmlEditor"></div>
<slot name="suffix" />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { format } from 'prettier';
import htmlParser from 'prettier/plugins/html';
import cssParser from 'prettier/plugins/postcss';
import jsParser from 'prettier/plugins/babel';
import * as estree from 'prettier/plugins/estree';
import { htmlLanguage, autoCloseTags, html } from 'codemirror-lang-html-n8n';
import { autocompletion } from '@codemirror/autocomplete';
import { indentWithTab, insertNewlineAndIndent, history, redo, undo } from '@codemirror/commands';
import { history, redo, undo } from '@codemirror/commands';
import {
LanguageSupport,
bracketMatching,
ensureSyntaxTree,
foldGutter,
indentOnInput,
LanguageSupport,
} from '@codemirror/language';
import type { Extension } from '@codemirror/state';
import { EditorState } from '@codemirror/state';
import { EditorState, Prec } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
import {
dropCursor,
EditorView,
dropCursor,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
} from '@codemirror/view';
import { autoCloseTags, html, htmlLanguage } from 'codemirror-lang-html-n8n';
import { format } from 'prettier';
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 { defineComponent } from 'vue';
import { htmlEditorEventBus } from '@/event-bus';
import { expressionManager } from '@/mixins/expressionManager';
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { htmlEditorEventBus } from '@/event-bus';
import { expressionManager } from '@/mixins/expressionManager';
import { theme } from './theme';
import { nonTakenRanges } from './utils';
import { enterKeyMap, tabKeyMap } from '../CodeNodeEditor/baseExtensions';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import type { Range, Section } from './types';
import { nonTakenRanges } from './utils';
export default defineComponent({
name: 'HtmlEditor',
@ -52,6 +56,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
fillParent: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: 4,
@ -90,15 +98,21 @@ export default defineComponent({
this.disableExpressionCompletions ? html() : htmlWithCompletions(),
autoCloseTags,
expressionInputHandler(),
keymap.of([
indentWithTab,
{ key: 'Enter', run: insertNewlineAndIndent },
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
]),
Prec.highest(
keymap.of([
...tabKeyMap,
...enterKeyMap,
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
]),
),
indentOnInput(),
theme({
codeNodeEditorTheme({
isReadOnly: this.isReadOnly,
maxHeight: this.fillParent ? '100%' : '40vh',
minHeight: '20vh',
rows: this.rows,
highlightColors: 'html',
}),
lineNumbers(),
highlightActiveLineGutter(),
@ -288,4 +302,12 @@ export default defineComponent({
});
</script>
<style lang="scss" module></style>
<style lang="scss" module>
.editor {
height: 100%;
& > div {
height: 100%;
}
}
</style>

View file

@ -1,97 +0,0 @@
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { EditorView } from '@codemirror/view';
import { tags } from '@lezer/highlight';
export const theme = ({ isReadOnly }: { isReadOnly: boolean }) => [
EditorView.theme({
'&': {
'font-size': '0.8em',
border: 'var(--border-base)',
borderRadius: 'var(--border-radius-base)',
backgroundColor: 'var(--color-code-background)',
color: 'var(--color-code-foreground)',
},
'.cm-content': {
fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace !important",
caretColor: 'var(--color-code-caret)',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--color-code-caret)',
},
'&.cm-editor': {
...(isReadOnly ? { backgroundColor: 'var(--color-code-background-readonly)' } : {}),
borderColor: 'var(--border-color-base)',
},
'&.cm-editor.cm-focused': {
outline: '0',
},
'&.cm-focused .cm-selectionBackgroundm .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'var(--color-code-selection)',
},
'.cm-activeLine': {
backgroundColor: 'var(--color-code-lineHighlight)',
},
'.cm-activeLineGutter': {
backgroundColor: 'var(--color-code-lineHighlight)',
},
'.cm-gutters': {
backgroundColor: isReadOnly
? 'var(--color-code-background-readonly)'
: 'var(--color-code-gutterBackground)',
color: 'var(--color-code-gutterForeground)',
borderTopLeftRadius: 'var(--border-radius-base)',
borderBottomLeftRadius: 'var(--border-radius-base)',
borderRightColor: 'var(--border-color-base)',
},
'.cm-scroller': {
overflow: 'auto',
maxHeight: '350px',
},
}),
syntaxHighlighting(
HighlightStyle.define([
{ tag: tags.keyword, color: '#c678dd' },
{
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
color: '#e06c75',
},
{ tag: [tags.function(tags.variableName), tags.labelName], color: '#61afef' },
{ tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: '#d19a66' },
{ tag: [tags.definition(tags.name), tags.separator], color: '#abb2bf' },
{
tag: [
tags.typeName,
tags.className,
tags.number,
tags.changed,
tags.annotation,
tags.modifier,
tags.self,
tags.namespace,
],
color: '#e06c75',
},
{
tag: [
tags.operator,
tags.operatorKeyword,
tags.url,
tags.escape,
tags.regexp,
tags.link,
tags.special(tags.string),
],
color: '#56b6c2',
},
{ tag: [tags.meta, tags.comment], color: '#7d8799' },
{ tag: tags.strong, fontWeight: 'bold' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strikethrough, textDecoration: 'line-through' },
{ tag: tags.link, color: '#7d8799', textDecoration: 'underline' },
{ tag: tags.heading, fontWeight: 'bold', color: '#e06c75' },
{ tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: '#d19a66' },
{ tag: [tags.processingInstruction, tags.string, tags.inserted], color: '#98c379' },
{ tag: tags.invalid, color: 'red', 'font-weight': 'bold' },
]),
),
];

View file

@ -1,26 +1,30 @@
<template>
<div ref="jsEditor" class="ph-no-capture js-editor"></div>
<div :class="$style.editor">
<div ref="jsEditor" class="ph-no-capture js-editor"></div>
<slot name="suffix" />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { acceptCompletion, autocompletion } from '@codemirror/autocomplete';
import { indentWithTab, history, redo, toggleComment, undo } from '@codemirror/commands';
import { foldGutter, indentOnInput } from '@codemirror/language';
import { autocompletion } from '@codemirror/autocomplete';
import { history, redo, toggleComment, undo } from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
import { foldGutter, indentOnInput } from '@codemirror/language';
import { lintGutter } from '@codemirror/lint';
import type { Extension } from '@codemirror/state';
import { EditorState } from '@codemirror/state';
import { EditorState, Prec } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
import {
dropCursor,
EditorView,
dropCursor,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
} from '@codemirror/view';
import { defineComponent } from 'vue';
import { enterKeyMap, tabKeyMap } from '../CodeNodeEditor/baseExtensions';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
export default defineComponent({
@ -34,6 +38,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
fillParent: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: 4,
@ -57,18 +65,25 @@ export default defineComponent({
EditorView.lineWrapping,
EditorState.readOnly.of(isReadOnly),
EditorView.editable.of(!isReadOnly),
codeNodeEditorTheme({ isReadOnly, customMinHeight: this.rows }),
codeNodeEditorTheme({
isReadOnly,
maxHeight: this.fillParent ? '100%' : '40vh',
minHeight: '20vh',
rows: this.rows,
}),
];
if (!isReadOnly) {
extensions.push(
history(),
keymap.of([
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
{ key: 'Mod-/', run: toggleComment },
{ key: 'Tab', run: acceptCompletion },
indentWithTab,
]),
Prec.highest(
keymap.of([
...tabKeyMap,
...enterKeyMap,
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
{ key: 'Mod-/', run: toggleComment },
]),
),
lintGutter(),
autocompletion(),
indentOnInput(),
@ -93,3 +108,13 @@ export default defineComponent({
},
});
</script>
<style lang="scss" module>
.editor {
height: 100%;
& > div {
height: 100%;
}
}
</style>

View file

@ -1,26 +1,30 @@
<template>
<div ref="jsonEditor" class="ph-no-capture json-editor"></div>
<div :class="$style.editor">
<div ref="jsonEditor" class="ph-no-capture json-editor"></div>
<slot name="suffix" />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { autocompletion } from '@codemirror/autocomplete';
import { indentWithTab, history, redo, undo } from '@codemirror/commands';
import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { history, redo, undo } from '@codemirror/commands';
import { json, jsonParseLinter } from '@codemirror/lang-json';
import { lintGutter, linter as createLinter } from '@codemirror/lint';
import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { linter as createLinter, lintGutter } from '@codemirror/lint';
import type { Extension } from '@codemirror/state';
import { EditorState } from '@codemirror/state';
import { EditorState, Prec } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
import {
dropCursor,
EditorView,
dropCursor,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
} from '@codemirror/view';
import { defineComponent } from 'vue';
import { enterKeyMap, tabKeyMap } from '../CodeNodeEditor/baseExtensions';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
export default defineComponent({
@ -34,6 +38,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
fillParent: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: 4,
@ -57,16 +65,24 @@ export default defineComponent({
EditorView.lineWrapping,
EditorState.readOnly.of(isReadOnly),
EditorView.editable.of(!isReadOnly),
codeNodeEditorTheme({ isReadOnly, customMinHeight: this.rows }),
codeNodeEditorTheme({
isReadOnly,
maxHeight: this.fillParent ? '100%' : '40vh',
minHeight: '20vh',
rows: this.rows,
}),
];
if (!isReadOnly) {
extensions.push(
history(),
keymap.of([
indentWithTab,
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
]),
Prec.highest(
keymap.of([
...tabKeyMap,
...enterKeyMap,
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
]),
),
createLinter(jsonParseLinter()),
lintGutter(),
autocompletion(),
@ -93,3 +109,13 @@ export default defineComponent({
},
});
</script>
<style lang="scss" module>
.editor {
height: 100%;
& > div {
height: 100%;
}
}
</style>

View file

@ -1166,7 +1166,7 @@ export default defineComponent({
.node-parameters-wrapper {
overflow-y: auto;
padding: 0 var(--spacing-m) 200px var(--spacing-m);
padding: 0 var(--spacing-m) var(--spacing-l) var(--spacing-m);
flex-grow: 1;
}

View file

@ -15,7 +15,7 @@
:is-read-only="isReadOnly"
:redact-values="shouldRedactValue"
@closeDialog="closeExpressionEditDialog"
@update:modelValue="expressionUpdated"
@update:model-value="expressionUpdated"
></ExpressionEdit>
<div class="parameter-input ignore-key-press" :style="parameterInputWrapperStyle">
<ResourceLocator
@ -34,7 +34,7 @@
:node="node"
:path="path"
:event-bus="eventBus"
@update:modelValue="valueChanged"
@update:model-value="valueChanged"
@modalOpenerClick="openExpressionEditorModal"
@focus="setFocus"
@blur="onBlur"
@ -50,7 +50,7 @@
:path="path"
:additional-expression-data="additionalExpressionData"
:class="{ 'ph-no-capture': shouldRedactValue }"
@update:modelValue="expressionUpdated"
@update:model-value="expressionUpdated"
@modalOpenerClick="openExpressionEditorModal"
@focus="setFocus"
@blur="onBlur"
@ -62,23 +62,60 @@
"
>
<el-dialog
v-if="codeEditDialogVisible"
:model-value="true"
:model-value="codeEditDialogVisible"
append-to-body
:close-on-click-modal="false"
width="80%"
:title="`${i18n.baseText('codeEdit.edit')} ${$locale
.nodeText()
.inputLabelDisplayName(parameter, path)}`"
:before-close="closeCodeEditDialog"
data-test-id="code-editor-fullscreen"
>
<div class="ignore-key-press">
<div :key="codeEditDialogVisible" class="ignore-key-press code-edit-dialog">
<CodeNodeEditor
v-if="editorType === 'codeNodeEditor'"
:model-value="modelValue"
:default-value="parameter.default"
:language="editorLanguage"
:is-read-only="isReadOnly"
@update:modelValue="expressionUpdated"
fill-parent
@update:model-value="valueChangedDebounced"
/>
<HtmlEditor
v-else-if="editorType === 'htmlEditor'"
:model-value="modelValue"
:is-read-only="isReadOnly"
:rows="getArgument('rows')"
:disable-expression-coloring="!isHtmlNode(node)"
:disable-expression-completions="!isHtmlNode(node)"
fill-parent
@update:model-value="valueChangedDebounced"
/>
<SqlEditor
v-else-if="editorType === 'sqlEditor'"
:model-value="modelValue"
:dialect="getArgument('sqlDialect')"
:is-read-only="isReadOnly"
:rows="getArgument('rows')"
fill-parent
@update:model-value="valueChangedDebounced"
/>
<JsEditor
v-else-if="editorType === 'jsEditor'"
:model-value="modelValue"
:is-read-only="isReadOnly"
:rows="getArgument('rows')"
fill-parent
@update:model-value="valueChangedDebounced"
/>
<JsonEditor
v-else-if="parameter.type === 'json'"
:model-value="modelValue"
:is-read-only="isReadOnly"
:rows="getArgument('rows')"
fill-parent
@update:model-value="valueChangedDebounced"
/>
</div>
</el-dialog>
@ -90,11 +127,12 @@
:path="path"
:is-read-only="isReadOnly"
@closeDialog="closeTextEditDialog"
@update:modelValue="expressionUpdated"
@update:model-value="expressionUpdated"
></TextEdit>
<CodeNodeEditor
v-if="editorType === 'codeNodeEditor' && isCodeNode(node)"
:key="codeEditDialogVisible"
:mode="node.parameters.mode"
:model-value="modelValue"
:default-value="parameter.default"
@ -102,43 +140,102 @@
:is-read-only="isReadOnly"
:rows="getArgument('rows')"
:ai-button-enabled="settingsStore.isCloudDeployment"
@update:modelValue="valueChangedDebounced"
/>
@update:model-value="valueChangedDebounced"
>
<template #suffix>
<n8n-icon
data-test-id="code-editor-fullscreen-button"
icon="external-link-alt"
size="xsmall"
class="textarea-modal-opener"
:title="$locale.baseText('parameterInput.openEditWindow')"
@click="displayEditDialog()"
/>
</template>
</CodeNodeEditor>
<HtmlEditor
v-else-if="editorType === 'htmlEditor'"
:key="codeEditDialogVisible"
:model-value="modelValue"
:is-read-only="isReadOnly"
:rows="getArgument('rows')"
:disable-expression-coloring="!isHtmlNode(node)"
:disable-expression-completions="!isHtmlNode(node)"
@update:modelValue="valueChangedDebounced"
/>
@update:model-value="valueChangedDebounced"
>
<template #suffix>
<n8n-icon
data-test-id="code-editor-fullscreen-button"
icon="external-link-alt"
size="xsmall"
class="textarea-modal-opener"
:title="$locale.baseText('parameterInput.openEditWindow')"
@click="displayEditDialog()"
/>
</template>
</HtmlEditor>
<SqlEditor
v-else-if="editorType === 'sqlEditor'"
:key="codeEditDialogVisible"
:model-value="modelValue"
:dialect="getArgument('sqlDialect')"
:is-read-only="isReadOnly"
:rows="getArgument('rows')"
@update:modelValue="valueChangedDebounced"
/>
@update:model-value="valueChangedDebounced"
>
<template #suffix>
<n8n-icon
data-test-id="code-editor-fullscreen-button"
icon="external-link-alt"
size="xsmall"
class="textarea-modal-opener"
:title="$locale.baseText('parameterInput.openEditWindow')"
@click="displayEditDialog()"
/>
</template>
</SqlEditor>
<JsEditor
v-else-if="editorType === 'jsEditor'"
:key="codeEditDialogVisible"
:model-value="modelValue"
:is-read-only="isReadOnly"
:rows="getArgument('rows')"
@update:modelValue="valueChangedDebounced"
/>
@update:model-value="valueChangedDebounced"
>
<template #suffix>
<n8n-icon
data-test-id="code-editor-fullscreen-button"
icon="external-link-alt"
size="xsmall"
class="textarea-modal-opener"
:title="$locale.baseText('parameterInput.openEditWindow')"
@click="displayEditDialog()"
/>
</template>
</JsEditor>
<JsonEditor
v-else-if="parameter.type === 'json'"
:key="codeEditDialogVisible"
:model-value="modelValue"
:is-read-only="isReadOnly"
:rows="getArgument('rows')"
@update:modelValue="valueChangedDebounced"
/>
@update:model-value="valueChangedDebounced"
>
<template #suffix>
<n8n-icon
data-test-id="code-editor-fullscreen-button"
icon="external-link-alt"
size="xsmall"
class="textarea-modal-opener"
:title="$locale.baseText('parameterInput.openEditWindow')"
@click="displayEditDialog()"
/>
</template>
</JsonEditor>
<div v-else-if="editorType" class="readonly-code clickable" @click="displayEditDialog()">
<CodeNodeEditor
@ -161,7 +258,7 @@
:disabled="isReadOnly"
:title="displayTitle"
:placeholder="getPlaceholder()"
@update:modelValue="valueChanged($event) && onUpdateTextInput($event)"
@update:model-value="valueChanged($event) && onUpdateTextInput($event)"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
@ -194,7 +291,7 @@
:show-alpha="getArgument('showAlpha')"
@focus="setFocus"
@blur="onBlur"
@update:modelValue="valueChanged"
@update:model-value="valueChanged"
/>
<n8n-input
v-model="tempValue"
@ -202,7 +299,7 @@
type="text"
:disabled="isReadOnly"
:title="displayTitle"
@update:modelValue="valueChanged"
@update:model-value="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
@ -226,7 +323,7 @@
"
:picker-options="dateTimePickerOptions"
:class="{ 'ph-no-capture': shouldRedactValue }"
@update:modelValue="valueChanged"
@update:model-value="valueChanged"
@focus="setFocus"
@blur="onBlur"
@keydown.stop
@ -245,7 +342,7 @@
:class="{ 'ph-no-capture': shouldRedactValue }"
:title="displayTitle"
:placeholder="parameter.placeholder"
@update:modelValue="onUpdateTextInput"
@update:model-value="onUpdateTextInput"
@focus="setFocus"
@blur="onBlur"
@keydown.stop
@ -262,7 +359,7 @@
:is-read-only="isReadOnly"
:display-title="displayTitle"
@credentialSelected="credentialSelected"
@update:modelValue="valueChanged"
@update:model-value="valueChanged"
@setFocus="setFocus"
@onBlur="onBlur"
>
@ -283,7 +380,7 @@
:loading="remoteParameterOptionsLoading"
:disabled="isReadOnly || remoteParameterOptionsLoading"
:title="displayTitle"
@update:modelValue="valueChanged"
@update:model-value="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
@ -321,7 +418,7 @@
:disabled="isReadOnly || remoteParameterOptionsLoading"
:title="displayTitle"
:placeholder="i18n.baseText('parameterInput.select')"
@update:modelValue="valueChanged"
@update:model-value="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
@ -358,7 +455,7 @@
active-color="#13ce66"
:model-value="displayValue"
:disabled="isReadOnly"
@update:modelValue="valueChanged"
@update:model-value="valueChanged"
/>
</div>
@ -1056,7 +1153,7 @@ export default defineComponent({
return;
}
if (this.editorType) {
if (this.editorType || this.parameter.type === 'json') {
this.codeEditDialogVisible = true;
} else {
this.textEditDialogVisible = true;
@ -1418,4 +1515,12 @@ export default defineComponent({
.invalid {
border-color: var(--color-danger);
}
.code-edit-dialog {
height: 70vh;
.code-node-editor {
height: 100%;
}
}
</style>

View file

@ -1,7 +1,9 @@
<template>
<div v-on-click-outside="onBlur" :class="$style.sqlEditor">
<div ref="sqlEditor" data-test-id="sql-editor-container"></div>
<slot name="suffix" />
<InlineExpressionEditorOutput
v-if="!fillParent"
:segments="segments"
:is-read-only="isReadOnly"
:visible="isFocused"
@ -11,41 +13,42 @@
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { acceptCompletion, autocompletion, ifNotIn } from '@codemirror/autocomplete';
import { indentWithTab, history, redo, toggleComment, undo } from '@codemirror/commands';
import { bracketMatching, foldGutter, indentOnInput, LanguageSupport } from '@codemirror/language';
import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue';
import { EXPRESSIONS_DOCS_URL } from '@/constants';
import { codeNodeEditorEventBus } from '@/event-bus';
import { expressionManager } from '@/mixins/expressionManager';
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { autocompletion, ifNotIn } from '@codemirror/autocomplete';
import { history, redo, toggleComment, undo } from '@codemirror/commands';
import { LanguageSupport, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { type Extension, type Line, Prec } from '@codemirror/state';
import { EditorState } from '@codemirror/state';
import type { Line, Extension } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
import {
dropCursor,
EditorView,
dropCursor,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
} from '@codemirror/view';
import type { ViewUpdate } from '@codemirror/view';
import type { SQLDialect as SQLDialectType } from '@n8n/codemirror-lang-sql';
import {
MSSQL,
MySQL,
PostgreSQL,
StandardSQL,
MariaSQL,
SQLite,
Cassandra,
MSSQL,
MariaSQL,
MySQL,
PLSQL,
PostgreSQL,
SQLite,
StandardSQL,
keywordCompletionSource,
} from '@n8n/codemirror-lang-sql';
import type { SQLDialect as SQLDialectType } from '@n8n/codemirror-lang-sql';
import { defineComponent } from 'vue';
import { enterKeyMap, tabKeyMap } from '../CodeNodeEditor/baseExtensions';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { expressionManager } from '@/mixins/expressionManager';
import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue';
import { EXPRESSIONS_DOCS_URL } from '@/constants';
import { codeNodeEditorEventBus } from '@/event-bus';
const SQL_DIALECTS = {
StandardSQL,
@ -88,6 +91,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
fillParent: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: 4,
@ -129,8 +136,9 @@ export default defineComponent({
expressionInputHandler(),
codeNodeEditorTheme({
isReadOnly: this.isReadOnly,
customMaxHeight: '350px',
customMinHeight: this.rows,
maxHeight: this.fillParent ? '100%' : '40vh',
minHeight: '10vh',
rows: this.rows,
}),
lineNumbers(),
EditorView.lineWrapping,
@ -146,13 +154,15 @@ export default defineComponent({
if (!this.isReadOnly) {
extensions.push(
history(),
keymap.of([
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
{ key: 'Mod-/', run: toggleComment },
{ key: 'Tab', run: acceptCompletion },
indentWithTab,
]),
Prec.highest(
keymap.of([
...tabKeyMap,
...enterKeyMap,
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
{ key: 'Mod-/', run: toggleComment },
]),
),
autocompletion(),
indentOnInput(),
highlightActiveLine(),
@ -233,5 +243,10 @@ export default defineComponent({
<style module lang="scss">
.sqlEditor {
position: relative;
height: 100%;
& > div {
height: 100%;
}
}
</style>

View file

@ -192,15 +192,7 @@
border: var(--border-base);
box-shadow: 0px 6px 16px rgb(68 28 23 / 6%);
border-radius: 8px;
margin-top: 15vh;
@media (max-height: 1050px) {
margin: 4em auto !important;
}
@media (max-height: 930px) {
margin: 1em auto !important;
}
&.classic {
.el-dialog__header {
padding: 15px 20px;

View file

@ -7,7 +7,6 @@ const commonDescription: INodeProperties = {
typeOptions: {
editor: 'codeNodeEditor',
editorLanguage: 'javaScript',
rows: 5,
},
default: '',
description:

View file

@ -7,7 +7,6 @@ const commonDescription: INodeProperties = {
typeOptions: {
editor: 'codeNodeEditor',
editorLanguage: 'python',
rows: 5,
},
default: '',
description:

View file

@ -20,7 +20,6 @@ const properties: INodeProperties[] = [
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
rows: 5,
},
displayOptions: {
hide: {
@ -39,7 +38,6 @@ const properties: INodeProperties[] = [
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
rows: 5,
},
displayOptions: {
show: {

View file

@ -88,7 +88,6 @@ export class MicrosoftSql implements INodeType {
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
rows: 5,
sqlDialect: 'MSSQL',
},
displayOptions: {

View file

@ -79,7 +79,6 @@ const versionDescription: INodeTypeDescription = {
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
rows: 5,
sqlDialect: 'MySQL',
},
displayOptions: {

View file

@ -26,7 +26,6 @@ const properties: INodeProperties[] = [
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
rows: 5,
sqlDialect: 'MySQL',
},
hint: 'Consider using query parameters to prevent SQL injection attacks. Add them in the options below',

View file

@ -77,7 +77,6 @@ const versionDescription: INodeTypeDescription = {
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
rows: 5,
sqlDialect: 'PostgreSQL',
},
displayOptions: {

View file

@ -26,7 +26,6 @@ const properties: INodeProperties[] = [
"The SQL query to execute. You can use n8n expressions and $1, $2, $3, etc to refer to the 'Query Parameters' set in options below.",
typeOptions: {
editor: 'sqlEditor',
rows: 5,
sqlDialect: 'PostgreSQL',
},
hint: 'Consider using query parameters to prevent SQL injection attacks. Add them in the options below',

View file

@ -63,7 +63,6 @@ export class QuestDb implements INodeType {
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
rows: 5,
sqlDialect: 'PostgreSQL',
},
displayOptions: {

View file

@ -19,7 +19,7 @@ const properties: INodeProperties[] = [
typeOptions: {
rows: 5,
},
default: '{\n "my_field_1": "value",\n "my_field_2": 1\n}',
default: '{\n "my_field_1": "value",\n "my_field_2": 1\n}\n',
validateType: 'object',
ignoreValidationDuringExecution: true,
},

View file

@ -69,7 +69,6 @@ export class Snowflake implements INodeType {
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
rows: 5,
},
displayOptions: {
show: {

View file

@ -67,7 +67,6 @@ export class TimescaleDb implements INodeType {
noDataExpression: true,
typeOptions: {
editor: 'sqlEditor',
rows: 5,
sqlDialect: 'PostgreSQL',
},
displayOptions: {