feat(editor): Migrate Design System and Editor UI to Vue 3 (#6476)

* feat: remove vue-fragment (no-changelog)

* feat: partial design-system migration

* feat: migrate info-accordion and info-tip components

* feat: migrate several components to vue 3

* feat: migrated several components

* feat: migrate several components

* feat: migrate several components

* feat: migrate several components

* feat: re-exported all design system components

* fix: fix design for popper components

* fix: editor kind of working, lots of issues to fix

* fix: fix several vue 3 migration issues

* fix: replace @change with @update:modelValue in several places

* fix: fix translation linking

* fix: fix inline-edit input

* fix: fix ndv and dialog design

* fix: update parameter input event bindings

* fix: rename deprecated lifecycle methods

* fix: fix json view mapping

* build: update lock file

* fix(editor): revisit last conflict with master and fix issues

* fix(editor): revisit last conflict with master and fix issues

* fix: fix expression editor bug causing code mirror to no longer be reactive

* fix: fix resource locator bug

* fix: fix vue-agile integration

* fix: remove global import for vue-agile

* fix: replace element-plus buttons with n8n-buttons everywhere

* fix(editor): Fix various element-plus styles (#6571)

* fix(editor): Fix various element-plus styles

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* Remove debugging code

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* Address PR comments

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

---------

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* fix(editor): Fix loading in production mode [Vue 3] (#6578)

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* fix(editor): First round of e2e tests fixes with Vue 3 (#6579)

* fix(editor): Fix broken smoke and workflow list e2e tests
* ✔️ Fix failing canvas action tests. Updating some selectors used in credentials and workflow tests

* feat: add vue 3 eslint rules and fix issues

* fix: fix tags-dropdown

* fix: fix white-space issues caused by i18n-t

* fix: rename non-generic click events

* fix: fix search in resources list layout

* fix: fix datatable paginator

* fix: fix popper select caret and dropdown size

* fix: add width to action-dropdown

* fix: fix workflow settings icon not being hidden

* fix: refactor newly added code

* fix: fix merge issue

* fix: fix ndv credentials watcher

* fix: fix workflow saving and grabber notch

* fix: fix nodes list panel transition

* fix: fix node title visibility

* fix: fix data unpinning

* fix: fix value access

* fix: show  input panel only if trigger panel enabled or not trigger node

* fix: fix tags dropdown and executions status spcing

* fix(editor): Prevent execution list to load back when leaving the route (#6697)

fix(editor): prevent execution list to load back when leaving the route

* fix: fix drawer visibility

* fix: fix expression toggle padding

* fix: fix expressions editor styling

* chore: prepare for testing

* fix: fix styling for el-button without patching

* test: fix unit tests in design-system

* test: fix most unit tests

* fix: remove import cycle.

* fix: fix personalization modal tests

* fix further resource mapper test adjustments

* fix: fix multiple tests and n8n-route attr duplication

* fix: fix source control tets

* fix: fixed remaining unit tests

* fix: fix workflows and credentials e2e tests

* fix: fix localizeNodeNames

* fix: update ndv e2e tests

* fix: fix popper left placement arrow

* fix: fix 5-ndv e2e tests

* fix: fix 6-code-node e2e tests

* fix(editor): Drop click outside directive from NodeCreator (#6716)

* fix(editor): Drop click outside directive from NodeCreator

* fix(editor): make sure mouseup outside is unbound at least before the component is unmounted

* fix: fix 10-settings-log-streaming e2e tests

* fix: fix node redrawing

* fix: fix tooltip buttons styling

* fix: fix varous e2e suites

* fix: fix 15-scheduler-node e2e suite

* fix: fix route watcher

* fix: fixed param name update and credential edit

* feat: update event names

* refactor: Remove deprecated `$data` (#6576)

Co-authored-by: Alex Grozav <alex@grozav.com>

* fix: fix 17-sharing e2e suite

* fix: fix tags dropdown

* fix: fix tags manager

* fix(editor): move :deep selectors to a separate scoped style block

* fix: fix sticky component and inline text edit

* fix: update e2e tests

* fix: remove button override references

* fix(editor): Adjust spacing in templates for Vue 3 (#6744)

* fix(editor): Adjust spacing in templates

* fix: Undo unneeded change

* fix: Undo unneeded change

* fix(editor): Adjust NDV height for Vue 3 (#6742)

fix(editor): Adjust NDV height

* fix(editor): Restore collapsed sidebar items for Vue 3 (#6743)

fix(editor): Restore collapsed sidebar items

* fix: fix linting issues

* fix: fix design-system deps

* fix: post-merge fixes

* fix: update tests

* fix: increase timeout for executionslist tets

* chore: fix linting issue

* fix: fix 14-mapping e2e tests in ci

* fix: re-enable tests

* fix: fix workflow duplication e2e tests after tags update

* fix(editor): Change component prop to be typed

* fix: fix tags dropdown in duplicate wf modal

* fix: fix focus behaviour in tags selector

* fix: fix tag creation

* fix: fix log streaming e2e race condition

* fix(editor): Fix Vue 3 linting issues (#6748)

* fix(editor): Fix Vue 3 linting issues

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* fix MainSidebar linter issues

* revert pnpm lock

* update pnpm lock file

---------

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
Co-authored-by: Alex Grozav <alex@grozav.com>

* fix(editor): Some css fixes for vue3 branch (#6749)

*  Fixing filter button height

*  Update input modal button position

*  Updating tags styling

*  Fix event logging settings spacing

* 👕 Fixing lint errors

* fix: fix linting issues

* Revert to `// eslint-disable-next-line @typescript-eslint/no-misused-promises` disabling of mixins init

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* fix: fix css issue

* fix(editor): Lint fix

* fix(editor): Fix settings initialisation (#6750)

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* fix: fix initial settings loading

* fix: replace realClick with click force

* fix: fix randomly failing mapping e2e tests

* fix(editor): Fix menu item event handling

* fix: fix resource filters dropdown events (#6752)

* fix: fix resource filters dropdown events

* fix: remove teleported:false

* fix: fix event selection event naming (#6753)

* fix: removed console.log (#6754)

* fix: rever await nextTick changes

* fix: redo linting changes

* fix(editor): Redraw node connections if adding more than one node to canvas (#6755)

* fix(editor): Redraw node connections if adding more than one node to canvas

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* Update position before connection two nodes

* Lint fix

---------

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
Co-authored-by: Alex Grozav <alex@grozav.com>

* fix(editor): Fix `ResourceMapper` unit tests (#6758)

* ✔️ Fix matching columns test

* ✔️ Fix multiple matching columns test

* ✔️ Removing `skip` from the last test

* fix: Allow pasting a big workflow (#6760)

* fix: pasting a big workflow

* chore: update comment

* refactor: move try/catch to function

* refactor: move try/catch to function

* fix(editor): Fix modal layer width

* fix: fix position changes

* fix: undo it.only

* fix: make undo/redo multiple steps more verbose

* fix: Fix value survey styles (#6764)

* fix: fix value survey styles

* fix: lint

* Revert "fix: lint"

72869c431f1448861df021be041b61c62f1e3118

* fix: lint

* fix(editor): Fix collapsed sub menu

* fix: Fix drawer animation (#6767)

fix: drawer animation

* fix(editor): Fix source control buttons (#6769)

* fix(editor): Fix App loading & auth  (#6768)

* fix(editor): Fix App loading & auth

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* Await promises

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

* Fix eslint error

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

---------

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>

---------

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
Co-authored-by: Csaba Tuncsik <csaba@n8n.io>
Co-authored-by: OlegIvaniv <me@olegivaniv.com>
Co-authored-by: Milorad FIlipović <milorad@n8n.io>
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>
This commit is contained in:
Alex Grozav
2023-07-28 10:51:07 +03:00
committed by GitHub
parent d050b99fb2
commit dd6a4c956a
459 changed files with 8815 additions and 9913 deletions

View File

@@ -21,7 +21,7 @@ const categoryName = computed(() => {
<template>
<div
:class="$style.categoryWrapper"
v-on="$listeners"
v-bind="$attrs"
data-keyboard-nav="true"
data-test-id="node-creator-category-item"
>

View File

@@ -18,7 +18,7 @@
<p
:class="$style.communityNodeIcon"
v-html="
$locale.baseText('generic.communityNode.tooltip', {
i18n.baseText('generic.communityNode.tooltip', {
interpolate: {
packageName: nodeType.name.split('.')[0],
docURL: COMMUNITY_NODES_INSTALLATION_DOCS_URL,
@@ -38,7 +38,7 @@
</template>
<script setup lang="ts">
import { computed, ref, getCurrentInstance } from 'vue';
import { computed, ref } from 'vue';
import type { SimplifiedNodeType } from '@/Interface';
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL, DEFAULT_SUBCATEGORY } from '@/constants';
@@ -48,6 +48,7 @@ import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import NodeIcon from '@/components/NodeIcon.vue';
import { useActions } from '../composables/useActions';
import { useI18n, useTelemetry } from '@/composables';
export interface Props {
nodeType: SimplifiedNodeType;
@@ -59,16 +60,18 @@ const props = withDefaults(defineProps<Props>(), {
active: false,
});
const i18n = useI18n();
const telemetry = useTelemetry();
const { actions } = useNodeCreatorStore();
const { getNodeTypesWithManualTrigger } = useActions();
const instance = getCurrentInstance();
const dragging = ref(false);
const draggablePosition = ref({ x: -100, y: -100 });
const draggableDataTransfer = ref(null as Element | null);
const description = computed<string>(() => {
return instance?.proxy.$locale.headerText({
return i18n.headerText({
key: `headers.${shortNodeType.value}.description`,
fallback: props.nodeType.description,
}) as string;
@@ -84,9 +87,7 @@ const nodeActions = computed(() => {
return nodeActions;
});
const shortNodeType = computed<string>(
() => instance?.proxy.$locale.shortNodeType(props.nodeType.name) || '',
);
const shortNodeType = computed<string>(() => i18n.shortNodeType(props.nodeType.name) || '');
const draggableStyle = computed<{ top: string; left: string }>(() => ({
top: `${draggablePosition.value.y}px`,
@@ -99,7 +100,7 @@ const isCommunityNode = computed<boolean>(() => isCommunityPackageName(props.nod
const displayName = computed<any>(() => {
const displayName = props.nodeType.displayName.trimEnd();
return instance?.proxy.$locale.headerText({
return i18n.headerText({
key: `headers.${shortNodeType.value}.displayName`,
fallback: hasActions.value ? displayName.replace('Trigger', '') : displayName,
});
@@ -153,7 +154,7 @@ function onDragEnd(event: DragEvent): void {
function onCommunityNodeTooltipClick(event: MouseEvent) {
if ((event.target as Element).localName === 'a') {
instance?.proxy.$telemetry.track('user clicked cnr docs link', { source: 'nodes panel node' });
telemetry.track('user clicked cnr docs link', { source: 'nodes panel node' });
}
}
</script>

View File

@@ -1,9 +1,9 @@
<template>
<n8n-node-creator-node
:class="$style.subCategory"
:title="$locale.baseText(`nodeCreator.subcategoryNames.${subcategoryName}`)"
:title="i18n.baseText(`nodeCreator.subcategoryNames.${subcategoryName}`)"
:isTrigger="false"
:description="$locale.baseText(`nodeCreator.subcategoryDescriptions.${subcategoryName}`)"
:description="i18n.baseText(`nodeCreator.subcategoryDescriptions.${subcategoryName}`)"
:showActionArrow="true"
>
<template #icon>
@@ -16,11 +16,14 @@
import type { SubcategoryItemProps } from '@/Interface';
import { camelCase } from 'lodash-es';
import { computed } from 'vue';
import { useI18n } from '@/composables';
export interface Props {
item: SubcategoryItemProps;
}
const props = defineProps<Props>();
const i18n = useI18n();
const subcategoryName = computed(() => camelCase(props.item.subcategory || props.item.title));
</script>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, getCurrentInstance, onMounted, defineComponent } from 'vue';
import type { VNode, PropType } from 'vue';
import { computed, getCurrentInstance, onMounted, defineComponent, h } from 'vue';
import type { PropType } from 'vue';
import type {
INodeCreateElement,
ActionTypeDescription,
@@ -205,15 +205,15 @@ const OrderSwitcher = defineComponent({
type: String as PropType<NodeFilterType>,
},
},
render(h): VNode {
const triggers = this.$slots?.triggers?.[0];
const actions = this.$slots?.actions?.[0];
return h(
'div',
{},
this.rootView === REGULAR_NODE_CREATOR_VIEW ? [actions, triggers] : [triggers, actions],
);
setup(props, { slots }) {
return () =>
h(
'div',
{},
props.rootView === REGULAR_NODE_CREATOR_VIEW
? [slots.actions?.(), slots.triggers?.()]
: [slots.triggers?.(), slots.actions?.()],
);
},
});
@@ -236,32 +236,30 @@ onMounted(() => {
@selected="onSelected"
>
<!-- Empty state -->
<template #empty>
<template v-if="hasNoTriggerActions">
<n8n-callout
theme="info"
iconless
slim
data-test-id="actions-panel-no-triggers-callout"
>
<span
v-html="
$locale.baseText('nodeCreator.actionsCallout.noTriggerItems', {
interpolate: { nodeName: subcategory },
})
"
/>
</n8n-callout>
<ItemsRenderer @selected="onSelected" :elements="placeholderTriggerActions" />
</template>
<template v-else>
<p
:class="$style.resetSearch"
v-html="$locale.baseText('nodeCreator.actionsCategory.noMatchingTriggers')"
@click="resetSearch"
<template #empty v-if="hasNoTriggerActions">
<n8n-callout
v-if="hasNoTriggerActions"
theme="info"
iconless
slim
data-test-id="actions-panel-no-triggers-callout"
>
<span
v-html="
$locale.baseText('nodeCreator.actionsCallout.noTriggerItems', {
interpolate: { nodeName: subcategory },
})
"
/>
</template>
</n8n-callout>
<ItemsRenderer @selected="onSelected" :elements="placeholderTriggerActions" />
</template>
<template #empty v-else>
<p
:class="$style.resetSearch"
v-html="$locale.baseText('nodeCreator.actionsCategory.noMatchingTriggers')"
@click="resetSearch"
/>
</template>
</CategorizedItemsRenderer>
</template>
@@ -274,17 +272,15 @@ onMounted(() => {
:expanded="!isTriggerRootView || parsedTriggerActions.length === 0"
@selected="onSelected"
>
<template>
<n8n-callout
theme="info"
iconless
v-if="!userActivated && isTriggerRootView"
slim
data-test-id="actions-panel-activation-callout"
>
<span v-html="$locale.baseText('nodeCreator.actionsCallout.triggersStartWorkflow')" />
</n8n-callout>
</template>
<n8n-callout
theme="info"
iconless
v-if="!userActivated && isTriggerRootView"
slim
data-test-id="actions-panel-activation-callout"
>
<span v-html="$locale.baseText('nodeCreator.actionsCallout.triggersStartWorkflow')" />
</n8n-callout>
<!-- Empty state -->
<template #empty>
<n8n-info-tip theme="info" type="note" v-if="!search" :class="$style.actionsEmpty">
@@ -296,14 +292,13 @@ onMounted(() => {
"
/>
</n8n-info-tip>
<template v-else>
<p
:class="$style.resetSearch"
v-html="$locale.baseText('nodeCreator.actionsCategory.noMatchingActions')"
@click="resetSearch"
data-test-id="actions-panel-no-matching-actions"
/>
</template>
<p
v-else
:class="$style.resetSearch"
v-html="$locale.baseText('nodeCreator.actionsCategory.noMatchingActions')"
@click="resetSearch"
data-test-id="actions-panel-no-matching-actions"
/>
</template>
</CategorizedItemsRenderer>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { camelCase } from 'lodash-es';
import { getCurrentInstance, computed } from 'vue';
import { computed } from 'vue';
import type { INodeCreateElement, NodeFilterType } from '@/Interface';
import { TRIGGER_NODE_CREATOR_VIEW, HTTP_REQUEST_NODE_TYPE, WEBHOOK_NODE_TYPE } from '@/constants';
@@ -16,6 +16,7 @@ import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue';
import NoResults from '../Panel/NoResults.vue';
import { useI18n, useTelemetry } from '@/composables';
export interface Props {
rootView: 'trigger' | 'action';
@@ -25,7 +26,9 @@ const emit = defineEmits({
nodeTypeSelected: (nodeTypes: string[]) => true,
});
const instance = getCurrentInstance();
const i18n = useI18n();
const telemetry = useTelemetry();
const { mergedNodes, actions } = useNodeCreatorStore();
const { baseUrl } = useRootStore();
const { getNodeTypesWithManualTrigger } = useActions();
@@ -45,7 +48,7 @@ function selectNodeType(nodeTypes: string[]) {
function onSelected(item: INodeCreateElement) {
if (item.type === 'subcategory') {
const title = instance?.proxy.$locale.baseText(
const title = i18n.baseText(
`nodeCreator.subcategoryNames.${camelCase(item.properties.title)}` as BaseTextKey,
);
@@ -59,7 +62,7 @@ function onSelected(item: INodeCreateElement) {
itemsMapper: subcategoriesMapper,
});
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.onSubcategorySelected', {
telemetry.trackNodesPanel('nodeCreateList.onSubcategorySelected', {
subcategory: item.key,
});
}
@@ -96,10 +99,7 @@ function onSelected(item: INodeCreateElement) {
}
if (item.type === 'view') {
const view =
item.key === TRIGGER_NODE_CREATOR_VIEW
? TriggerView(instance?.proxy?.$locale)
: RegularView(instance?.proxy?.$locale);
const view = item.key === TRIGGER_NODE_CREATOR_VIEW ? TriggerView() : RegularView();
pushViewStack({
title: view.title,

View File

@@ -7,21 +7,20 @@
:class="$style.nodeCreator"
:style="nodeCreatorInlineStyle"
ref="nodeCreator"
v-click-outside="onClickOutside"
@dragover="onDragOver"
@drop="onDrop"
@mousedown="onMouseDown"
@mouseup="onMouseUp"
data-test-id="node-creator"
>
<NodesListPanel @nodeTypeSelected="$listeners.nodeTypeSelected" />
<NodesListPanel @nodeTypeSelected="onNodeTypeSelected" />
</div>
</slide-transition>
</div>
</template>
<script setup lang="ts">
import { watch, reactive, toRefs, computed } from 'vue';
import { watch, reactive, toRefs, computed, onBeforeUnmount } from 'vue';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
@@ -35,6 +34,7 @@ import { useUIStore } from '@/stores';
export interface Props {
active?: boolean;
onNodeTypeSelected?: (nodeType: string) => void;
}
const props = defineProps<Props>();
@@ -61,13 +61,7 @@ const viewStacksLength = computed(() => useViewStacks().viewStacks.length);
const nodeCreatorInlineStyle = computed(() => {
return { top: `${uiStore.bannersHeight + uiStore.headerHeight}px` };
});
function onClickOutside(event: Event) {
// We need to prevent cases where user would click inside the node creator
// and try to drag non-draggable element. In that case the click event would
// be fired and the node creator would be closed. So we stop that if we detect
// that the click event originated from inside the node creator. And fire click even on the
// original target.
function onMouseUpOutside() {
if (state.mousedownInsideEvent) {
const clickEvent = new MouseEvent('click', {
bubbles: true,
@@ -75,18 +69,21 @@ function onClickOutside(event: Event) {
});
state.mousedownInsideEvent.target?.dispatchEvent(clickEvent);
state.mousedownInsideEvent = null;
return;
}
if (event.type === 'click') {
emit('closeNodeCreator');
unBindOnMouseUpOutside();
}
}
function unBindOnMouseUpOutside() {
document.removeEventListener('mouseup', onMouseUpOutside);
document.removeEventListener('touchstart', onMouseUpOutside);
}
function onMouseUp() {
state.mousedownInsideEvent = null;
unBindOnMouseUpOutside();
}
function onMouseDown(event: MouseEvent) {
state.mousedownInsideEvent = event;
document.addEventListener('mouseup', onMouseUpOutside);
document.addEventListener('touchstart', onMouseUpOutside);
}
function onDragOver(event: DragEvent) {
event.preventDefault();
@@ -147,6 +144,10 @@ watch(
{ immediate: true },
);
const { nodeCreator } = toRefs(state);
onBeforeUnmount(() => {
unBindOnMouseUpOutside();
});
</script>
<style module lang="scss">

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { getCurrentInstance, computed, onMounted, onUnmounted, watch } from 'vue';
import { computed, onMounted, onUnmounted, watch } from 'vue';
import type { INodeCreateElement } from '@/Interface';
import { TRIGGER_NODE_CREATOR_VIEW } from '@/constants';
@@ -11,8 +11,9 @@ import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
import SearchBar from './SearchBar.vue';
import ActionsRenderer from '../Modes/ActionsMode.vue';
import NodesRenderer from '../Modes/NodesMode.vue';
import { useI18n } from '@/composables';
const instance = getCurrentInstance();
const i18n = useI18n();
const { mergedNodes } = useNodeCreatorStore();
const { pushViewStack, popViewStack, updateCurrentViewStack } = useViewStacks();
@@ -25,10 +26,10 @@ const viewStacks = computed(() => useViewStacks().viewStacks);
const isActionsMode = computed(() => useViewStacks().activeViewStackMode === 'actions');
const searchPlaceholder = computed(() =>
isActionsMode.value
? instance?.proxy?.$locale.baseText('nodeCreator.actionsCategory.searchActions', {
? i18n.baseText('nodeCreator.actionsCategory.searchActions', {
interpolate: { node: activeViewStack.value.title as string },
})
: instance?.proxy?.$locale.baseText('nodeCreator.searchBar.searchNodes'),
: i18n.baseText('nodeCreator.searchBar.searchNodes'),
);
const nodeCreatorView = computed(() => useNodeCreatorStore().selectedView);
@@ -58,10 +59,7 @@ onUnmounted(() => {
watch(
() => nodeCreatorView.value,
(selectedView) => {
const view =
selectedView === TRIGGER_NODE_CREATOR_VIEW
? TriggerView(instance?.proxy?.$locale)
: RegularView(instance?.proxy?.$locale);
const view = selectedView === TRIGGER_NODE_CREATOR_VIEW ? TriggerView() : RegularView();
pushViewStack({
title: view.title,
@@ -124,15 +122,15 @@ function onBackButton() {
? searchPlaceholder
: $locale.baseText('nodeCreator.searchBar.searchNodes')
"
@input="onSearch"
:value="activeViewStack.search"
:modelValue="activeViewStack.search"
@update:modelValue="onSearch"
/>
<div :class="$style.renderedItems">
<!-- Actions mode -->
<ActionsRenderer v-if="isActionsMode && activeViewStack.subcategory" v-on="$listeners" />
<ActionsRenderer v-if="isActionsMode && activeViewStack.subcategory" v-bind="$attrs" />
<!-- Nodes Mode -->
<NodesRenderer v-else :rootView="nodeCreatorView" v-on="$listeners" />
<NodesRenderer v-else :rootView="nodeCreatorView" v-bind="$attrs" />
</div>
</aside>
</transition>
@@ -149,14 +147,14 @@ function onBackButton() {
right: 0;
}
:global(.panel-slide-out-enter),
:global(.panel-slide-out-enter-from),
:global(.panel-slide-in-leave-to) {
transform: translateX(0);
z-index: -1;
}
:global(.panel-slide-out-leave-to),
:global(.panel-slide-in-enter) {
:global(.panel-slide-in-enter-from) {
transform: translateX(100%);
// Make sure the leaving panel stays on top
// for the slide-out panel effect

View File

@@ -1,21 +1,21 @@
<template>
<div :class="$style.searchContainer" data-test-id="search-bar">
<div :class="{ [$style.prefix]: true, [$style.active]: value.length > 0 }">
<div :class="{ [$style.prefix]: true, [$style.active]: modelValue.length > 0 }">
<font-awesome-icon icon="search" size="sm" />
</div>
<div :class="$style.text">
<input
:placeholder="placeholder"
:value="value"
@input="onInput"
:value="modelValue"
:class="$style.input"
ref="inputRef"
autofocus
data-test-id="node-creator-search-bar"
tabindex="0"
@input="onInput"
/>
</div>
<div :class="$style.suffix" v-if="value.length > 0" @click="clear">
<div :class="$style.suffix" v-if="modelValue.length > 0" @click="clear">
<button :class="[$style.clear, $style.clickable]">
<font-awesome-icon icon="times-circle" />
</button>
@@ -30,16 +30,16 @@ import { runExternalHook } from '@/utils';
export interface Props {
placeholder: string;
value: string;
modelValue: string;
}
withDefaults(defineProps<Props>(), {
placeholder: '',
value: '',
modelValue: '',
});
const emit = defineEmits<{
(event: 'input', value: string): void;
(event: 'update:modelValue', value: string): void;
}>();
const state = reactive({
@@ -52,11 +52,11 @@ function focus() {
function onInput(event: Event) {
const input = event.target as HTMLInputElement;
emit('input', input.value);
emit('update:modelValue', input.value);
}
function clear() {
emit('input', '');
emit('update:modelValue', '');
}
onMounted(() => {

View File

@@ -117,8 +117,8 @@ registerKeyHook(`CategoryLeft_${props.category}`, {
<!-- Pass through listeners & empty slot to ItemsRenderer -->
<ItemsRenderer
v-if="expanded"
v-bind="$attrs"
:elements="elements"
v-on="$listeners"
:isTrigger="isTriggerCategory"
>
<template #default> </template>

View File

@@ -125,7 +125,7 @@ watch(
:data-keyboard-nav-id="item.uuid"
@click="wrappedEmit('selected', item)"
>
<template v-if="renderedItems.includes(item)">
<div v-if="renderedItems.includes(item)">
<label-item v-if="item.type === 'label'" :item="item" />
<subcategory-item v-if="item.type === 'subcategory'" :item="item.properties" />
@@ -148,7 +148,7 @@ watch(
:view="item.properties"
:class="$style.viewItem"
/>
</template>
</div>
<n8n-loading :loading="true" :rows="1" variant="p" :class="$style.itemSkeleton" v-else />
</div>

View File

@@ -1,31 +1,34 @@
import { render, screen } from '@testing-library/vue';
import { screen } from '@testing-library/vue';
import CategoryItem from '../ItemTypes/CategoryItem.vue';
import { createComponentRenderer } from '@/__tests__/render';
const renderComponent = createComponentRenderer(CategoryItem);
describe('CategoryItem', () => {
it('should allow expand and collapse', async () => {
const { container, updateProps } = render(CategoryItem, { props: { name: 'Category Test' } });
const { container, rerender } = renderComponent({ props: { name: 'Category Test' } });
expect(container.querySelector('[data-icon="chevron-down"]')).toBeInTheDocument();
await updateProps({ expanded: false });
await rerender({ expanded: false });
expect(container.querySelector('[data-icon="chevron-down"]')).not.toBeInTheDocument();
expect(container.querySelector('[data-icon="chevron-up"]')).toBeInTheDocument();
});
it('should show count', async () => {
const { updateProps } = render(CategoryItem, { props: { name: 'Category Test', count: 10 } });
const { rerender } = renderComponent({ props: { name: 'Category Test', count: 10 } });
expect(screen.getByText('Category Test (10)')).toBeInTheDocument();
await updateProps({ count: 0 });
await rerender({ count: 0 });
expect(screen.getByText('Category Test')).toBeInTheDocument();
});
it('should show trigger icon', async () => {
const { updateProps, container } = render(CategoryItem, {
const { rerender, container } = renderComponent({
props: { name: 'Category Test', isTrigger: true },
});
expect(container.querySelector('[data-icon="bolt"]')).toBeInTheDocument();
await updateProps({ isTrigger: false });
await rerender({ isTrigger: false });
expect(container.querySelector('[data-icon="bolt"]')).not.toBeInTheDocument();
});
});

View File

@@ -1,7 +1,6 @@
import Vue from 'vue';
import { PiniaVuePlugin } from 'pinia';
import { nextTick } from 'vue';
import { createTestingPinia } from '@pinia/testing';
import { render, fireEvent } from '@testing-library/vue';
import { fireEvent } from '@testing-library/vue';
import {
mockSubcategoryCreateElement,
mockLabelCreateElement,
@@ -10,6 +9,9 @@ import {
import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
import { mockActionCreateElement } from './utils';
import { mockViewCreateElement } from './utils';
import { createComponentRenderer } from '@/__tests__/render';
const renderComponent = createComponentRenderer(ItemsRenderer);
describe('ItemsRenderer', () => {
it('should render items', async () => {
@@ -24,18 +26,16 @@ describe('ItemsRenderer', () => {
mockNodeCreateElement('subcategory', { displayName: 'Node 3', name: 'node3' }),
mockSubcategoryCreateElement({ title: 'Subcategory 2' }),
];
const { container } = render(
ItemsRenderer,
{
pinia: createTestingPinia(),
props: { elements: items },
const { container } = renderComponent({
pinia: createTestingPinia(),
props: { elements: items },
global: {
stubs: ['n8n-loading'],
},
(vue) => {
vue.use(PiniaVuePlugin);
},
);
//
await Vue.nextTick();
});
await nextTick();
const nodeItems = container.querySelectorAll('.iteratorItem .nodeItem');
const labels = container.querySelectorAll('.iteratorItem .label');
@@ -53,18 +53,12 @@ describe('ItemsRenderer', () => {
mockActionCreateElement(),
mockViewCreateElement(),
];
const { container, emitted } = render(
ItemsRenderer,
{
pinia: createTestingPinia(),
props: { elements: items },
},
(vue) => {
vue.use(PiniaVuePlugin);
},
);
const { container, emitted } = renderComponent({
pinia: createTestingPinia(),
props: { elements: items },
});
//
await Vue.nextTick();
await nextTick();
const itemTypes = {
node: container.querySelector('.iteratorItem .nodeItem'),

View File

@@ -1,30 +1,14 @@
import Vue, { defineComponent, watch } from 'vue';
import { defineComponent, nextTick, watch } from 'vue';
import type { PropType } from 'vue';
import { PiniaVuePlugin, createPinia } from 'pinia';
import { render, screen, fireEvent } from '@testing-library/vue';
import { createPinia } from 'pinia';
import { screen, fireEvent } from '@testing-library/vue';
import type { INodeTypeDescription } from 'n8n-workflow';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { mockSimplifiedNodeType } from './utils';
import NodesListPanel from '../Panel/NodesListPanel.vue';
import { REGULAR_NODE_CREATOR_VIEW } from '@/constants';
import type { NodeFilterType } from '@/Interface';
function TelemetryPlugin(vue: typeof Vue): void {
Object.defineProperty(vue, '$telemetry', {
get() {
return {
trackNodesPanel: () => {},
};
},
});
Object.defineProperty(vue.prototype, '$telemetry', {
get() {
return {
trackNodesPanel: () => {},
};
},
});
}
import { createComponentRenderer } from '@/__tests__/render';
function getWrapperComponent(setup: () => void) {
const wrapperComponent = defineComponent({
@@ -41,16 +25,11 @@ function getWrapperComponent(setup: () => void) {
template: '<NodesListPanel @nodeTypeSelected="e => $emit(\'nodeTypeSelected\', e)" />',
});
return render(
wrapperComponent,
{
pinia: createPinia(),
return createComponentRenderer(wrapperComponent, {
global: {
plugins: [createPinia()],
},
(vue) => {
vue.use(PiniaVuePlugin);
vue.use(TelemetryPlugin);
},
);
})();
}
describe('NodesListPanel', () => {
@@ -78,11 +57,11 @@ describe('NodesListPanel', () => {
return {};
});
await Vue.nextTick();
await nextTick();
expect(screen.getByText('Select a trigger')).toBeInTheDocument();
expect(screen.queryByTestId('node-creator-search-bar')).toBeInTheDocument();
screen.getByText('On app event').click();
await Vue.nextTick();
await nextTick();
expect(screen.queryByTestId('node-creator-search-bar')).not.toBeInTheDocument();
mockedTriggerNodes.forEach((n) => {
expect(screen.queryByText(n.name)).toBeInTheDocument();
@@ -95,7 +74,7 @@ describe('NodesListPanel', () => {
expect(container.querySelector('.backButton')).toBeInTheDocument();
await fireEvent.click(container.querySelector('.backButton')!);
await Vue.nextTick();
await nextTick();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(6);
});
@@ -145,27 +124,22 @@ describe('NodesListPanel', () => {
template: '<NodesListPanel @nodeTypeSelected="e => $emit(\'nodeTypeSelected\', e)" />',
});
render(
wrapperComponent,
{
pinia: createPinia(),
props: {
nodeTypes: mockedNodes,
selectedView: REGULAR_NODE_CREATOR_VIEW,
},
},
(vue) => {
vue.use(PiniaVuePlugin);
vue.use(TelemetryPlugin);
},
);
const renderComponent = createComponentRenderer(wrapperComponent);
await Vue.nextTick();
renderComponent({
pinia: createPinia(),
props: {
nodeTypes: mockedNodes,
selectedView: REGULAR_NODE_CREATOR_VIEW,
},
});
await nextTick();
expect(screen.getByText('What happens next?')).toBeInTheDocument();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(6);
screen.getByText('Action in an app').click();
await Vue.nextTick();
await nextTick();
mockedNodes.forEach((n) => {
expect(screen.queryByText(n.displayName)).toBeInTheDocument();
});
@@ -205,40 +179,31 @@ describe('NodesListPanel', () => {
template: '<NodesListPanel @nodeTypeSelected="e => $emit(\'nodeTypeSelected\', e)" />',
});
function renderComponent() {
return render(
wrapperComponent,
{
pinia: createPinia(),
props: {
nodeTypes: mockedNodes,
},
},
(vue) => {
vue.use(PiniaVuePlugin);
vue.use(TelemetryPlugin);
},
);
}
const renderComponent = createComponentRenderer(wrapperComponent, {
pinia: createPinia(),
props: {
nodeTypes: mockedNodes,
},
});
it('should be visible in the root view', async () => {
renderComponent();
await Vue.nextTick();
await nextTick();
expect(screen.queryByTestId('node-creator-search-bar')).toBeInTheDocument();
});
it('should not be visible if subcategory contains less than 9 items', async () => {
renderComponent();
await Vue.nextTick();
await nextTick();
screen.getByText('On app event').click();
await Vue.nextTick();
await nextTick();
expect(screen.queryByTestId('node-creator-search-bar')).not.toBeInTheDocument();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(8);
});
it('should be visible if subcategory contains 9 or more items', async () => {
const { updateProps } = renderComponent();
await Vue.nextTick();
const { rerender } = renderComponent();
await nextTick();
mockedNodes.push(
mockSimplifiedNodeType({
@@ -248,11 +213,11 @@ describe('NodesListPanel', () => {
}),
);
await updateProps({ nodeTypes: [...mockedNodes] });
await Vue.nextTick();
await rerender({ nodeTypes: [...mockedNodes] });
await nextTick();
screen.getByText('On app event').click();
await Vue.nextTick();
await nextTick();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(9);
expect(screen.queryByTestId('node-creator-search-bar')).toBeInTheDocument();
@@ -260,26 +225,26 @@ describe('NodesListPanel', () => {
it('should correctly handle search', async () => {
const { container } = renderComponent();
await Vue.nextTick();
await nextTick();
screen.getByText('On app event').click();
await Vue.nextTick();
await nextTick();
await fireEvent.input(screen.getByTestId('node-creator-search-bar'), {
target: { value: 'Ninth' },
});
await Vue.nextTick();
await nextTick();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(1);
await fireEvent.input(screen.getByTestId('node-creator-search-bar'), {
target: { value: 'Non sense' },
});
await Vue.nextTick();
await nextTick();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(0);
expect(screen.queryByText("We didn't make that... yet")).toBeInTheDocument();
await fireEvent.click(container.querySelector('.clear')!);
await Vue.nextTick();
await nextTick();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(9);
});
});

View File

@@ -1,8 +1,8 @@
import { render } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { defineComponent, computed } from 'vue';
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
import { PiniaVuePlugin, createPinia } from 'pinia';
import { createComponentRenderer } from '@/__tests__/render';
import { createPinia } from 'pinia';
const eventHookSpy = vi.fn();
describe('useKeyboardNavigation', () => {
@@ -38,18 +38,16 @@ describe('useKeyboardNavigation', () => {
},
});
const renderTestComponent = () => {
return render(TestComponent, { pinia: createPinia() }, (vue) => {
vue.use(PiniaVuePlugin);
});
};
const renderComponent = createComponentRenderer(TestComponent, {
pinia: createPinia(),
});
afterAll(() => {
eventHookSpy.mockClear();
});
test('ArrowDown moves to the next item, cycling after last item', async () => {
const { container } = renderTestComponent();
const { container } = renderComponent();
expect(container.querySelector('[data-keyboard-nav-id="item1"]')).toHaveClass('active');
await userEvent.keyboard('{arrowdown}');
@@ -60,7 +58,7 @@ describe('useKeyboardNavigation', () => {
});
test('ArrowUp moves to the previous item, cycling after firstitem', async () => {
const { container } = renderTestComponent();
const { container } = renderComponent();
expect(container.querySelector('[data-keyboard-nav-id="item1"]')).toHaveClass('active');
await userEvent.keyboard('{arrowup}');
@@ -72,7 +70,7 @@ describe('useKeyboardNavigation', () => {
});
test('Key hooks are executed', async () => {
renderTestComponent();
renderComponent();
await userEvent.keyboard('{arrowup}');
expect(eventHookSpy).toHaveBeenCalledWith('item3', 'ArrowUp');

View File

@@ -1,4 +1,4 @@
import { ref, set } from 'vue';
import { ref } from 'vue';
import { defineStore } from 'pinia';
export type KeyboardKey = (typeof WATCHED_KEYS)[number];
@@ -125,7 +125,7 @@ export const useKeyboardNavigation = defineStore('nodeCreatorKeyboardNavigation'
function registerKeyHook(name: string, hook: KeyHook) {
hook.keyboardKeys.forEach((keyboardKey) => {
if (WATCHED_KEYS.includes(keyboardKey)) {
set(keysHooks.value, name, hook);
keysHooks.value = { ...keysHooks.value, [name]: hook };
} else {
throw new Error(`Key ${keyboardKey} is not supported`);
}

View File

@@ -1,4 +1,4 @@
import { computed, ref, set } from 'vue';
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { v4 as uuid } from 'uuid';
import type { INodeCreateElement, NodeFilterType, SimplifiedNodeType } from '@/Interface';
@@ -167,7 +167,10 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
// For each key in the stack, update the matched stack
Object.keys(stack).forEach((key) => {
const typedKey = key as keyof ViewStack;
set(viewStacks.value[matchedIndex], key, stack[typedKey]);
viewStacks.value[matchedIndex] = {
...viewStacks.value[matchedIndex],
[key]: stack[typedKey],
};
});
}

View File

@@ -14,12 +14,15 @@ import {
EMAIL_IMAP_NODE_TYPE,
DEFAULT_SUBCATEGORY,
} from '@/constants';
import { useI18n } from '@/composables';
export function TriggerView() {
const i18n = useI18n();
export function TriggerView($locale: any) {
return {
value: TRIGGER_NODE_CREATOR_VIEW,
title: $locale.baseText('nodeCreator.triggerHelperPanel.selectATrigger'),
subtitle: $locale.baseText('nodeCreator.triggerHelperPanel.selectATriggerDescription'),
title: i18n.baseText('nodeCreator.triggerHelperPanel.selectATrigger'),
subtitle: i18n.baseText('nodeCreator.triggerHelperPanel.selectATriggerDescription'),
items: [
{
key: DEFAULT_SUBCATEGORY,
@@ -37,12 +40,8 @@ export function TriggerView($locale: any) {
properties: {
group: [],
name: SCHEDULE_TRIGGER_NODE_TYPE,
displayName: $locale.baseText(
'nodeCreator.triggerHelperPanel.scheduleTriggerDisplayName',
),
description: $locale.baseText(
'nodeCreator.triggerHelperPanel.scheduleTriggerDescription',
),
displayName: i18n.baseText('nodeCreator.triggerHelperPanel.scheduleTriggerDisplayName'),
description: i18n.baseText('nodeCreator.triggerHelperPanel.scheduleTriggerDescription'),
icon: 'fa:clock',
},
},
@@ -53,8 +52,8 @@ export function TriggerView($locale: any) {
properties: {
group: [],
name: WEBHOOK_NODE_TYPE,
displayName: $locale.baseText('nodeCreator.triggerHelperPanel.webhookTriggerDisplayName'),
description: $locale.baseText('nodeCreator.triggerHelperPanel.webhookTriggerDescription'),
displayName: i18n.baseText('nodeCreator.triggerHelperPanel.webhookTriggerDisplayName'),
description: i18n.baseText('nodeCreator.triggerHelperPanel.webhookTriggerDescription'),
iconData: {
type: 'file',
icon: 'webhook',
@@ -69,8 +68,8 @@ export function TriggerView($locale: any) {
properties: {
group: [],
name: MANUAL_TRIGGER_NODE_TYPE,
displayName: $locale.baseText('nodeCreator.triggerHelperPanel.manualTriggerDisplayName'),
description: $locale.baseText('nodeCreator.triggerHelperPanel.manualTriggerDescription'),
displayName: i18n.baseText('nodeCreator.triggerHelperPanel.manualTriggerDisplayName'),
description: i18n.baseText('nodeCreator.triggerHelperPanel.manualTriggerDescription'),
icon: 'fa:mouse-pointer',
},
},
@@ -81,12 +80,8 @@ export function TriggerView($locale: any) {
properties: {
group: [],
name: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
displayName: $locale.baseText(
'nodeCreator.triggerHelperPanel.workflowTriggerDisplayName',
),
description: $locale.baseText(
'nodeCreator.triggerHelperPanel.workflowTriggerDescription',
),
displayName: i18n.baseText('nodeCreator.triggerHelperPanel.workflowTriggerDisplayName'),
description: i18n.baseText('nodeCreator.triggerHelperPanel.workflowTriggerDescription'),
icon: 'fa:sign-out-alt',
},
},
@@ -103,10 +98,12 @@ export function TriggerView($locale: any) {
};
}
export function RegularView($locale: any) {
export function RegularView() {
const i18n = useI18n();
return {
value: REGULAR_NODE_CREATOR_VIEW,
title: $locale.baseText('nodeCreator.triggerHelperPanel.whatHappensNext'),
title: i18n.baseText('nodeCreator.triggerHelperPanel.whatHappensNext'),
items: [
{
key: DEFAULT_SUBCATEGORY,
@@ -156,11 +153,9 @@ export function RegularView($locale: any) {
key: TRIGGER_NODE_CREATOR_VIEW,
type: 'view',
properties: {
title: $locale.baseText('nodeCreator.triggerHelperPanel.addAnotherTrigger'),
title: i18n.baseText('nodeCreator.triggerHelperPanel.addAnotherTrigger'),
icon: 'bolt',
description: $locale.baseText(
'nodeCreator.triggerHelperPanel.addAnotherTriggerDescription',
),
description: i18n.baseText('nodeCreator.triggerHelperPanel.addAnotherTriggerDescription'),
},
},
],