mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 10:31:15 +00:00
refactor(editor): Move editor-ui and design-system to frontend dir (no-changelog) (#13564)
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { EditorState, type Extension } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
|
||||
import type { Plaintext, Resolved, Segment } from '@/types/expressions';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { forceParse } from '@/utils/forceParse';
|
||||
|
||||
interface ExpressionOutputProps {
|
||||
segments: Segment[];
|
||||
extensions?: Extension[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ExpressionOutputProps>(), { extensions: () => [] });
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const editor = ref<EditorView | null>(null);
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
|
||||
const resolvedExpression = computed(() => {
|
||||
if (props.segments.length === 0) {
|
||||
return i18n.baseText('parameterInput.emptyString');
|
||||
}
|
||||
|
||||
return props.segments.reduce(
|
||||
(acc, segment) => {
|
||||
// skip duplicate segments
|
||||
if (acc.cursor >= segment.to) return acc;
|
||||
|
||||
acc.resolved += segment.kind === 'resolvable' ? String(segment.resolved) : segment.plaintext;
|
||||
acc.cursor = segment.to;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ resolved: '', cursor: 0 },
|
||||
).resolved;
|
||||
});
|
||||
|
||||
const plaintextSegments = computed<Plaintext[]>(() => {
|
||||
return props.segments.filter((s): s is Plaintext => s.kind === 'plaintext');
|
||||
});
|
||||
|
||||
const resolvedSegments = computed<Resolved[]>(() => {
|
||||
if (props.segments.length === 0) {
|
||||
const emptyExpression = resolvedExpression.value;
|
||||
const emptySegment: Resolved = {
|
||||
from: 0,
|
||||
to: emptyExpression.length,
|
||||
kind: 'resolvable',
|
||||
error: null,
|
||||
resolvable: '',
|
||||
resolved: emptyExpression,
|
||||
state: 'pending',
|
||||
};
|
||||
return [emptySegment];
|
||||
}
|
||||
|
||||
let cursor = 0;
|
||||
|
||||
return props.segments
|
||||
.map((segment) => {
|
||||
segment.from = cursor;
|
||||
cursor +=
|
||||
segment.kind === 'plaintext'
|
||||
? segment.plaintext.length
|
||||
: segment.resolved
|
||||
? (segment.resolved as string | number | boolean).toString().length
|
||||
: 0;
|
||||
segment.to = cursor;
|
||||
return segment;
|
||||
})
|
||||
.filter((segment): segment is Resolved => segment.kind === 'resolvable');
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.segments,
|
||||
() => {
|
||||
if (!editor.value) return;
|
||||
|
||||
editor.value.dispatch({
|
||||
changes: { from: 0, to: editor.value.state.doc.length, insert: resolvedExpression.value },
|
||||
});
|
||||
|
||||
highlighter.addColor(editor.value as EditorView, resolvedSegments.value);
|
||||
highlighter.removeColor(editor.value as EditorView, plaintextSegments.value);
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
editor.value = new EditorView({
|
||||
parent: root.value as HTMLElement,
|
||||
state: EditorState.create({
|
||||
doc: resolvedExpression.value,
|
||||
extensions: [
|
||||
EditorState.readOnly.of(true),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.domEventHandlers({ scroll: (_, view) => forceParse(view) }),
|
||||
...props.extensions,
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
highlighter.addColor(editor.value as EditorView, resolvedSegments.value);
|
||||
highlighter.removeColor(editor.value as EditorView, plaintextSegments.value);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor.value?.destroy();
|
||||
});
|
||||
|
||||
defineExpose({ getValue: () => '=' + resolvedExpression.value });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" data-test-id="expression-output"></div>
|
||||
</template>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { history } from '@codemirror/commands';
|
||||
import { type EditorState, Prec, type SelectionRange } from '@codemirror/state';
|
||||
import { dropCursor, EditorView, keymap } from '@codemirror/view';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||
import { editorKeymap } from '@/plugins/codemirror/keymap';
|
||||
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
|
||||
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
|
||||
import type { Segment } from '@/types/expressions';
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
import { inputTheme } from './theme';
|
||||
import { onKeyStroke } from '@vueuse/core';
|
||||
import { expressionCloseBrackets } from '@/plugins/codemirror/expressionCloseBrackets';
|
||||
|
||||
type Props = {
|
||||
modelValue: string;
|
||||
path: string;
|
||||
rows?: number;
|
||||
isReadOnly?: boolean;
|
||||
additionalData?: IDataObject;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rows: 5,
|
||||
isReadOnly: false,
|
||||
additionalData: () => ({}),
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:model-value': [value: { value: string; segments: Segment[] }];
|
||||
'update:selection': [value: { state: EditorState; selection: SelectionRange }];
|
||||
focus: [];
|
||||
}>();
|
||||
|
||||
const root = ref<HTMLElement>();
|
||||
const extensions = computed(() => [
|
||||
Prec.highest(keymap.of(editorKeymap)),
|
||||
n8nLang(),
|
||||
n8nAutocompletion(),
|
||||
inputTheme({ isReadOnly: props.isReadOnly, rows: props.rows }),
|
||||
history(),
|
||||
mappingDropCursor(),
|
||||
dropCursor(),
|
||||
expressionCloseBrackets(),
|
||||
EditorView.lineWrapping,
|
||||
infoBoxTooltips(),
|
||||
]);
|
||||
const editorValue = computed(() => props.modelValue);
|
||||
|
||||
// Exit expression editor when pressing Backspace in empty field
|
||||
onKeyStroke(
|
||||
'Backspace',
|
||||
() => {
|
||||
if (props.modelValue === '') emit('update:model-value', { value: '', segments: [] });
|
||||
},
|
||||
{ target: root },
|
||||
);
|
||||
|
||||
const {
|
||||
editor: editorRef,
|
||||
segments,
|
||||
selection,
|
||||
readEditorValue,
|
||||
setCursorPosition,
|
||||
hasFocus,
|
||||
focus,
|
||||
} = useExpressionEditor({
|
||||
editorRef: root,
|
||||
editorValue,
|
||||
extensions,
|
||||
isReadOnly: computed(() => props.isReadOnly),
|
||||
autocompleteTelemetry: { enabled: true, parameterPath: props.path },
|
||||
additionalData: props.additionalData,
|
||||
});
|
||||
|
||||
watch(segments.display, (newSegments) => {
|
||||
emit('update:model-value', {
|
||||
value: '=' + readEditorValue(),
|
||||
segments: newSegments,
|
||||
});
|
||||
});
|
||||
|
||||
watch(selection, (newSelection: SelectionRange) => {
|
||||
if (editorRef.value) {
|
||||
emit('update:selection', {
|
||||
state: editorRef.value.state,
|
||||
selection: newSelection,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
watch(hasFocus, (focused) => {
|
||||
if (focused) emit('focus');
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
editor: editorRef,
|
||||
setCursorPosition,
|
||||
focus: () => {
|
||||
if (!hasFocus.value) {
|
||||
setCursorPosition('lastExpression');
|
||||
focus();
|
||||
}
|
||||
},
|
||||
selectAll: () => {
|
||||
editorRef.value?.dispatch({
|
||||
selection: selection.value.extend(0, editorRef.value?.state.doc.length),
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" title="" data-test-id="inline-expression-editor-input"></div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.cm-editor) {
|
||||
padding-left: 0;
|
||||
}
|
||||
:deep(.cm-content) {
|
||||
padding-left: var(--spacing-2xs);
|
||||
|
||||
&[aria-readonly='true'] {
|
||||
background-color: var(--disabled-fill, var(--color-background-light));
|
||||
border-color: var(--disabled-border, var(--border-color-base));
|
||||
color: var(--disabled-color, var(--color-text-base));
|
||||
cursor: not-allowed;
|
||||
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
import { renderComponent } from '@/__tests__/render';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import InlineExpressionEditorOutput from './InlineExpressionEditorOutput.vue';
|
||||
|
||||
describe('InlineExpressionEditorOutput.vue', () => {
|
||||
test('should render duplicate segments correctly', async () => {
|
||||
const { getByTestId } = renderComponent(InlineExpressionEditorOutput, {
|
||||
pinia: createTestingPinia(),
|
||||
props: {
|
||||
visible: true,
|
||||
segments: [
|
||||
{
|
||||
from: 0,
|
||||
to: 5,
|
||||
plaintext: '[1,2]',
|
||||
kind: 'plaintext',
|
||||
},
|
||||
{
|
||||
from: 0,
|
||||
to: 1,
|
||||
plaintext: '[',
|
||||
kind: 'plaintext',
|
||||
},
|
||||
{
|
||||
from: 1,
|
||||
to: 2,
|
||||
plaintext: '1',
|
||||
kind: 'plaintext',
|
||||
},
|
||||
{
|
||||
from: 2,
|
||||
to: 3,
|
||||
plaintext: ',',
|
||||
kind: 'plaintext',
|
||||
},
|
||||
{
|
||||
from: 3,
|
||||
to: 4,
|
||||
plaintext: '2',
|
||||
kind: 'plaintext',
|
||||
},
|
||||
{
|
||||
from: 4,
|
||||
to: 5,
|
||||
plaintext: ']',
|
||||
kind: 'plaintext',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(getByTestId('inline-expression-editor-output')).toHaveTextContent('[1,2]');
|
||||
});
|
||||
|
||||
test('should render segments with resolved expressions', () => {
|
||||
const { getByTestId } = renderComponent(InlineExpressionEditorOutput, {
|
||||
pinia: createTestingPinia(),
|
||||
props: {
|
||||
visible: true,
|
||||
segments: [
|
||||
{
|
||||
kind: 'plaintext',
|
||||
from: 0,
|
||||
to: 6,
|
||||
plaintext: 'before>',
|
||||
},
|
||||
{
|
||||
kind: 'plaintext',
|
||||
from: 6,
|
||||
to: 7,
|
||||
plaintext: ' ',
|
||||
},
|
||||
{
|
||||
kind: 'resolvable',
|
||||
from: 7,
|
||||
to: 17,
|
||||
resolvable: '{{ $now }}',
|
||||
resolved: '[Object: "2024-04-18T09:03:26.651-04:00"]',
|
||||
state: 'valid',
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
kind: 'plaintext',
|
||||
from: 17,
|
||||
to: 18,
|
||||
plaintext: ' ',
|
||||
},
|
||||
{
|
||||
kind: 'plaintext',
|
||||
from: 18,
|
||||
to: 24,
|
||||
plaintext: '<after',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(getByTestId('inline-expression-editor-output')).toHaveTextContent(
|
||||
'before> [Object: "2024-04-18T09:03:26.651-04:00"] <after',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import type { EditorState, SelectionRange } from '@codemirror/state';
|
||||
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import type { Segment } from '@/types/expressions';
|
||||
import { onBeforeUnmount } from 'vue';
|
||||
import ExpressionOutput from './ExpressionOutput.vue';
|
||||
import OutputItemSelect from './OutputItemSelect.vue';
|
||||
import InlineExpressionTip from './InlineExpressionTip.vue';
|
||||
import { outputTheme } from './theme';
|
||||
|
||||
interface InlineExpressionEditorOutputProps {
|
||||
segments: Segment[];
|
||||
unresolvedExpression?: string;
|
||||
editorState?: EditorState;
|
||||
selection?: SelectionRange;
|
||||
visible?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<InlineExpressionEditorOutputProps>(), {
|
||||
visible: false,
|
||||
editorState: undefined,
|
||||
selection: undefined,
|
||||
isReadOnly: false,
|
||||
unresolvedExpression: undefined,
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const theme = outputTheme();
|
||||
const ndvStore = useNDVStore();
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
ndvStore.expressionOutputItemIndex = 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" :class="$style.dropdown" title="">
|
||||
<div :class="$style.header">
|
||||
<n8n-text bold size="small" compact>
|
||||
{{ i18n.baseText('parameterInput.result') }}
|
||||
</n8n-text>
|
||||
|
||||
<OutputItemSelect />
|
||||
</div>
|
||||
<n8n-text :class="$style.body">
|
||||
<ExpressionOutput
|
||||
data-test-id="inline-expression-editor-output"
|
||||
:segments="segments"
|
||||
:extensions="theme"
|
||||
>
|
||||
</ExpressionOutput>
|
||||
</n8n-text>
|
||||
<div :class="$style.footer" v-if="!isReadOnly">
|
||||
<InlineExpressionTip
|
||||
:editor-state="editorState"
|
||||
:selection="selection"
|
||||
:unresolved-expression="unresolvedExpression"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.dropdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
z-index: 2; // cover tooltips
|
||||
background: var(--color-code-background);
|
||||
border: var(--border-base);
|
||||
border-top: none;
|
||||
width: 100%;
|
||||
box-shadow: 0 2px 6px 0 rgba(#441c17, 0.1);
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
|
||||
:global(.cm-editor) {
|
||||
background-color: var(--color-code-background);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: var(--spacing-3xs);
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: var(--border-base);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2xs);
|
||||
color: var(--color-text-dark);
|
||||
font-weight: var(--font-weight-bold);
|
||||
padding: 0 var(--spacing-2xs);
|
||||
padding-top: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding-top: 0;
|
||||
padding-left: var(--spacing-2xs);
|
||||
color: var(--color-text-dark);
|
||||
|
||||
&:first-child {
|
||||
padding-top: var(--spacing-2xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
import { renderComponent } from '@/__tests__/render';
|
||||
import InlineExpressionTip from '@/components/InlineExpressionEditor/InlineExpressionTip.vue';
|
||||
import { FIELDS_SECTION } from '@/plugins/codemirror/completions/constants';
|
||||
import type { useNDVStore } from '@/stores/ndv.store';
|
||||
import type { CompletionResult } from '@codemirror/autocomplete';
|
||||
import { EditorSelection, EditorState } from '@codemirror/state';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
|
||||
let mockNdvState: Partial<ReturnType<typeof useNDVStore>>;
|
||||
let mockCompletionResult: Partial<CompletionResult>;
|
||||
|
||||
vi.mock('@/stores/ndv.store', () => {
|
||||
return {
|
||||
useNDVStore: vi.fn(() => mockNdvState),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/plugins/codemirror/completions/datatype.completions', () => {
|
||||
return {
|
||||
datatypeCompletions: vi.fn(() => mockCompletionResult),
|
||||
};
|
||||
});
|
||||
|
||||
describe('InlineExpressionTip.vue', () => {
|
||||
beforeEach(() => {
|
||||
mockNdvState = {
|
||||
hasInputData: true,
|
||||
isInputPanelEmpty: true,
|
||||
isOutputPanelEmpty: true,
|
||||
setHighlightDraggables: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
test('should show the default tip', async () => {
|
||||
const { container } = renderComponent(InlineExpressionTip, {
|
||||
pinia: createTestingPinia(),
|
||||
});
|
||||
expect(container).toHaveTextContent('Tip: Anything inside {{ }} is JavaScript. Learn more');
|
||||
});
|
||||
|
||||
describe('When the NDV input is not empty and a mappable input is focused', () => {
|
||||
test('should show the drag-n-drop tip', async () => {
|
||||
mockNdvState = {
|
||||
hasInputData: true,
|
||||
isInputPanelEmpty: false,
|
||||
isOutputPanelEmpty: false,
|
||||
focusedMappableInput: 'Some Input',
|
||||
setHighlightDraggables: vi.fn(),
|
||||
};
|
||||
const { container, unmount } = renderComponent(InlineExpressionTip, {
|
||||
pinia: createTestingPinia(),
|
||||
});
|
||||
expect(mockNdvState.setHighlightDraggables).toHaveBeenCalledWith(true);
|
||||
expect(container).toHaveTextContent('Tip: Drag aninput fieldfrom the left to use it here.');
|
||||
|
||||
unmount();
|
||||
expect(mockNdvState.setHighlightDraggables).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When the node has no input data', () => {
|
||||
test('should show the execute previous nodes tip', async () => {
|
||||
mockNdvState = {
|
||||
hasInputData: false,
|
||||
isInputParentOfActiveNode: true,
|
||||
isInputPanelEmpty: false,
|
||||
isOutputPanelEmpty: false,
|
||||
focusedMappableInput: 'Some Input',
|
||||
setHighlightDraggables: vi.fn(),
|
||||
};
|
||||
const { container } = renderComponent(InlineExpressionTip, {
|
||||
pinia: createTestingPinia(),
|
||||
});
|
||||
expect(container).toHaveTextContent('Tip: Execute previous nodes to use input data');
|
||||
});
|
||||
});
|
||||
|
||||
describe('When the expression can be autocompleted with a dot', () => {
|
||||
test('should show the correct tip for objects', async () => {
|
||||
mockNdvState = {
|
||||
hasInputData: true,
|
||||
isInputPanelEmpty: false,
|
||||
isOutputPanelEmpty: false,
|
||||
focusedMappableInput: 'Some Input',
|
||||
setHighlightDraggables: vi.fn(),
|
||||
};
|
||||
mockCompletionResult = { options: [{ label: 'foo', section: FIELDS_SECTION }] };
|
||||
const selection = EditorSelection.cursor(8);
|
||||
const expression = '{{ $json }}';
|
||||
const { rerender, container } = renderComponent(InlineExpressionTip, {
|
||||
pinia: createTestingPinia(),
|
||||
});
|
||||
|
||||
await rerender({
|
||||
editorState: EditorState.create({
|
||||
doc: expression,
|
||||
selection: EditorSelection.create([selection]),
|
||||
}),
|
||||
selection,
|
||||
unresolvedExpression: expression,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(container).toHaveTextContent(
|
||||
'Tip: Type . for data transformation options, or to access fields. Learn more',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('should show the correct tip for primitives', async () => {
|
||||
mockNdvState = {
|
||||
hasInputData: true,
|
||||
isInputPanelEmpty: false,
|
||||
isOutputPanelEmpty: false,
|
||||
focusedMappableInput: 'Some Input',
|
||||
setHighlightDraggables: vi.fn(),
|
||||
};
|
||||
mockCompletionResult = { options: [{ label: 'foo' }] };
|
||||
const selection = EditorSelection.cursor(12);
|
||||
const expression = '{{ $json.foo }}';
|
||||
const { rerender, container } = renderComponent(InlineExpressionTip, {
|
||||
pinia: createTestingPinia(),
|
||||
});
|
||||
|
||||
await rerender({
|
||||
editorState: EditorState.create({
|
||||
doc: expression,
|
||||
selection: EditorSelection.create([selection]),
|
||||
}),
|
||||
selection,
|
||||
unresolvedExpression: expression,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(container).toHaveTextContent(
|
||||
'Tip: Type . for data transformation options. Learn more',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { FIELDS_SECTION } from '@/plugins/codemirror/completions/constants';
|
||||
import { datatypeCompletions } from '@/plugins/codemirror/completions/datatype.completions';
|
||||
import { isCompletionSection } from '@/plugins/codemirror/completions/utils';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { type Completion, CompletionContext } from '@codemirror/autocomplete';
|
||||
import { EditorSelection, EditorState, type SelectionRange } from '@codemirror/state';
|
||||
import { watchDebounced } from '@vueuse/core';
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
type TipId = 'executePrevious' | 'drag' | 'default' | 'dotObject' | 'dotPrimitive';
|
||||
|
||||
type Props = {
|
||||
editorState?: EditorState;
|
||||
unresolvedExpression?: string;
|
||||
selection?: SelectionRange;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
editorState: undefined,
|
||||
unresolvedExpression: '',
|
||||
selection: () => EditorSelection.cursor(0),
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const ndvStore = useNDVStore();
|
||||
|
||||
const canAddDotToExpression = ref(false);
|
||||
const resolvedExpressionHasFields = ref(false);
|
||||
|
||||
const canDragToFocusedInput = computed(
|
||||
() => !ndvStore.isInputPanelEmpty && ndvStore.focusedMappableInput,
|
||||
);
|
||||
|
||||
const emptyExpression = computed(() => props.unresolvedExpression.trim().length === 0);
|
||||
|
||||
const tip = computed<TipId>(() => {
|
||||
if (
|
||||
!ndvStore.hasInputData &&
|
||||
ndvStore.isInputParentOfActiveNode &&
|
||||
ndvStore.focusedMappableInput
|
||||
) {
|
||||
return 'executePrevious';
|
||||
}
|
||||
|
||||
if (canAddDotToExpression.value) {
|
||||
return resolvedExpressionHasFields.value ? 'dotObject' : 'dotPrimitive';
|
||||
}
|
||||
|
||||
if (canDragToFocusedInput.value && emptyExpression.value) return 'drag';
|
||||
|
||||
return 'default';
|
||||
});
|
||||
|
||||
function getCompletionsWithDot(): readonly Completion[] {
|
||||
if (!props.editorState || !props.selection || !props.unresolvedExpression) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cursorAfterDot = props.selection.from + 1;
|
||||
const docWithDot =
|
||||
props.editorState.sliceDoc(0, props.selection.from) +
|
||||
'.' +
|
||||
props.editorState.sliceDoc(props.selection.to);
|
||||
const selectionWithDot = EditorSelection.create([EditorSelection.cursor(cursorAfterDot)]);
|
||||
|
||||
if (cursorAfterDot >= docWithDot.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stateWithDot = EditorState.create({
|
||||
doc: docWithDot,
|
||||
selection: selectionWithDot,
|
||||
});
|
||||
|
||||
const context = new CompletionContext(stateWithDot, cursorAfterDot, true);
|
||||
const completionResult = datatypeCompletions(context);
|
||||
return completionResult?.options ?? [];
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
ndvStore.setHighlightDraggables(false);
|
||||
});
|
||||
|
||||
watch(
|
||||
tip,
|
||||
(newTip) => {
|
||||
ndvStore.setHighlightDraggables(!ndvStore.isMappingOnboarded && newTip === 'drag');
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watchDebounced(
|
||||
[() => props.selection, () => props.unresolvedExpression],
|
||||
() => {
|
||||
const completions = getCompletionsWithDot();
|
||||
canAddDotToExpression.value = completions.length > 0;
|
||||
resolvedExpressionHasFields.value = completions.some(
|
||||
({ section }) => isCompletionSection(section) && section.name === FIELDS_SECTION.name,
|
||||
);
|
||||
},
|
||||
{ debounce: 200 },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[$style.tip, { [$style.drag]: tip === 'drag' }]">
|
||||
<n8n-text size="small" :class="$style.tipText"
|
||||
>{{ i18n.baseText('parameterInput.tip') }}:
|
||||
</n8n-text>
|
||||
|
||||
<div v-if="tip === 'drag'" :class="$style.content">
|
||||
<n8n-text size="small" :class="$style.text">
|
||||
{{ i18n.baseText('parameterInput.dragTipBeforePill') }}
|
||||
</n8n-text>
|
||||
<div :class="[$style.pill, { [$style.highlight]: !ndvStore.isMappingOnboarded }]">
|
||||
{{ i18n.baseText('parameterInput.inputField') }}
|
||||
</div>
|
||||
<n8n-text size="small" :class="$style.text">
|
||||
{{ i18n.baseText('parameterInput.dragTipAfterPill') }}
|
||||
</n8n-text>
|
||||
</div>
|
||||
|
||||
<div v-else-if="tip === 'executePrevious'" :class="$style.content">
|
||||
<span> {{ i18n.baseText('expressionTip.noExecutionData') }} </span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="tip === 'dotPrimitive'" :class="$style.content">
|
||||
<span v-n8n-html="i18n.baseText('expressionTip.typeDotPrimitive')" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="tip === 'dotObject'" :class="$style.content">
|
||||
<span v-n8n-html="i18n.baseText('expressionTip.typeDotObject')" />
|
||||
</div>
|
||||
|
||||
<div v-else :class="$style.content">
|
||||
<span v-n8n-html="i18n.baseText('expressionTip.javascript')" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4xs);
|
||||
line-height: var(--font-line-height-regular);
|
||||
color: var(--color-text-base);
|
||||
font-size: var(--font-size-2xs);
|
||||
padding: var(--spacing-2xs);
|
||||
|
||||
code {
|
||||
font-size: var(--font-size-3xs);
|
||||
background: var(--color-background-base);
|
||||
padding: var(--spacing-5xs);
|
||||
border-radius: var(--border-radius-base);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tipText {
|
||||
display: inline;
|
||||
color: var(--color-text-dark);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.drag .tipText {
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-dark);
|
||||
|
||||
border: var(--border-base);
|
||||
border-color: var(--color-foreground-light);
|
||||
background-color: var(--color-background-xlight);
|
||||
padding: var(--spacing-5xs) var(--spacing-3xs);
|
||||
margin: 0 var(--spacing-4xs);
|
||||
border-radius: var(--border-radius-base);
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: var(--color-primary);
|
||||
background-color: var(--color-primary-tint-3);
|
||||
border-color: var(--color-primary-tint-1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const i18n = useI18n();
|
||||
const ndvStore = useNDVStore();
|
||||
|
||||
const hoveringItem = computed(() => ndvStore.getHoveringItem);
|
||||
const hoveringItemIndex = computed(() => hoveringItem.value?.itemIndex);
|
||||
const isHoveringItem = computed(() => Boolean(hoveringItem.value));
|
||||
const itemsLength = computed(() => ndvStore.ndvInputDataWithPinnedData.length);
|
||||
const itemIndex = computed(
|
||||
() => hoveringItemIndex.value ?? ndvStore.expressionOutputItemIndex ?? 0,
|
||||
);
|
||||
const max = computed(() => Math.max(itemsLength.value - 1, 0));
|
||||
const isItemIndexEditable = computed(() => !isHoveringItem.value && itemsLength.value > 0);
|
||||
const hideTableHoverHint = computed(() => ndvStore.isTableHoverOnboarded);
|
||||
const canSelectPrevItem = computed(() => isItemIndexEditable.value && itemIndex.value !== 0);
|
||||
const canSelectNextItem = computed(
|
||||
() => isItemIndexEditable.value && itemIndex.value < itemsLength.value - 1,
|
||||
);
|
||||
|
||||
const inputCharWidth = computed(() => itemIndex.value.toString().length);
|
||||
|
||||
function updateItemIndex(index: number) {
|
||||
ndvStore.expressionOutputItemIndex = index;
|
||||
}
|
||||
|
||||
function nextItem() {
|
||||
ndvStore.expressionOutputItemIndex = ndvStore.expressionOutputItemIndex + 1;
|
||||
}
|
||||
|
||||
function prevItem() {
|
||||
ndvStore.expressionOutputItemIndex = ndvStore.expressionOutputItemIndex - 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.item">
|
||||
<n8n-text size="small" color="text-base" compact>
|
||||
{{ i18n.baseText('parameterInput.item') }}
|
||||
</n8n-text>
|
||||
|
||||
<div :class="$style.controls">
|
||||
<N8nInputNumber
|
||||
data-test-id="inline-expression-editor-item-input"
|
||||
size="mini"
|
||||
:controls="false"
|
||||
:class="[$style.input, { [$style.hovering]: isHoveringItem }]"
|
||||
:min="0"
|
||||
:max="max"
|
||||
:model-value="itemIndex"
|
||||
:style="{ '--input-width': `calc(${inputCharWidth}ch + var(--spacing-s))` }"
|
||||
@update:model-value="updateItemIndex"
|
||||
></N8nInputNumber>
|
||||
<N8nIconButton
|
||||
data-test-id="inline-expression-editor-item-prev"
|
||||
icon="chevron-left"
|
||||
type="tertiary"
|
||||
text
|
||||
size="mini"
|
||||
:disabled="!canSelectPrevItem"
|
||||
@click="prevItem"
|
||||
></N8nIconButton>
|
||||
|
||||
<N8nTooltip placement="right" :disabled="hideTableHoverHint">
|
||||
<template #content>
|
||||
<div>{{ i18n.baseText('parameterInput.hoverTableItemTip') }}</div>
|
||||
</template>
|
||||
<N8nIconButton
|
||||
data-test-id="inline-expression-editor-item-next"
|
||||
icon="chevron-right"
|
||||
type="tertiary"
|
||||
text
|
||||
size="mini"
|
||||
:disabled="!canSelectNextItem"
|
||||
@click="nextItem"
|
||||
></N8nIconButton>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4xs);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.controls .input {
|
||||
--input-height: 22px;
|
||||
--input-border-top-left-radius: var(--border-radius-base);
|
||||
--input-border-bottom-left-radius: var(--border-radius-base);
|
||||
--input-border-top-right-radius: var(--border-radius-base);
|
||||
--input-border-bottom-right-radius: var(--border-radius-base);
|
||||
line-height: calc(var(--input-height) - var(--spacing-4xs));
|
||||
|
||||
&.hovering {
|
||||
--input-font-color: var(--color-secondary);
|
||||
}
|
||||
|
||||
:global(.el-input__inner) {
|
||||
height: var(--input-height);
|
||||
min-height: var(--input-height);
|
||||
line-height: var(--input-height);
|
||||
text-align: center;
|
||||
padding: 0 var(--spacing-4xs);
|
||||
max-width: var(--input-width);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
|
||||
|
||||
const commonThemeProps = (isReadOnly = false) => ({
|
||||
'&.cm-focused': {
|
||||
outline: '0 !important',
|
||||
},
|
||||
'.cm-content': {
|
||||
fontFamily: 'var(--font-family-monospace)',
|
||||
color: 'var(--input-font-color, var(--color-text-dark))',
|
||||
caretColor: isReadOnly ? 'transparent' : 'var(--color-code-caret)',
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0',
|
||||
},
|
||||
});
|
||||
|
||||
export const inputTheme = ({ rows, isReadOnly } = { rows: 5, isReadOnly: false }) => {
|
||||
const maxHeight = Math.max(rows * 22 + 8);
|
||||
const theme = EditorView.theme({
|
||||
...commonThemeProps(isReadOnly),
|
||||
'&': {
|
||||
maxHeight: `${maxHeight}px`,
|
||||
minHeight: '30px',
|
||||
width: '100%',
|
||||
fontSize: 'var(--font-size-2xs)',
|
||||
padding: '0 0 0 var(--spacing-2xs)',
|
||||
borderWidth: 'var(--border-width-base)',
|
||||
borderStyle: 'var(--input-border-style, var(--border-style-base))',
|
||||
borderColor: 'var(--input-border-color, var(--border-color-base))',
|
||||
borderRightColor:
|
||||
'var(--input-border-right-color,var(--input-border-color, var(--border-color-base)))',
|
||||
borderBottomColor:
|
||||
'var(--input-border-bottom-color,var(--input-border-color, var(--border-color-base)))',
|
||||
borderRadius: 'var(--input-border-radius, var(--border-radius-base))',
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius:
|
||||
'var(--input-border-top-right-radius, var(--input-border-radius, var(--border-radius-base)))',
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius:
|
||||
'var(--input-border-bottom-right-radius, var(--input-border-radius, var(--border-radius-base)))',
|
||||
backgroundColor: 'white',
|
||||
},
|
||||
'.cm-cursor, .cm-dropCursor': {
|
||||
borderLeftColor: 'var(--color-code-caret)',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
lineHeight: '1.68',
|
||||
},
|
||||
'.cm-lineWrapping': {
|
||||
wordBreak: 'break-all',
|
||||
},
|
||||
});
|
||||
|
||||
return [theme, highlighter.resolvableStyle];
|
||||
};
|
||||
|
||||
export const outputTheme = () => {
|
||||
const theme = EditorView.theme({
|
||||
...commonThemeProps(true),
|
||||
'&': {
|
||||
maxHeight: '95px',
|
||||
width: '100%',
|
||||
fontSize: 'var(--font-size-2xs)',
|
||||
padding: '0',
|
||||
borderTopLeftRadius: '0',
|
||||
borderBottomLeftRadius: '0',
|
||||
backgroundColor: 'white',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
lineHeight: '1.6',
|
||||
},
|
||||
'.cm-valid-resolvable': {
|
||||
padding: '0 2px',
|
||||
borderRadius: '2px',
|
||||
},
|
||||
'.cm-invalid-resolvable': {
|
||||
padding: '0 2px',
|
||||
borderRadius: '2px',
|
||||
},
|
||||
});
|
||||
|
||||
return [theme, highlighter.resolvableStyle];
|
||||
};
|
||||
Reference in New Issue
Block a user