mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
refactor(editor): Move editor-ui and design-system to frontend dir (no-changelog) (#13564)
This commit is contained in:
108
packages/frontend/editor-ui/src/components/AboutModal.vue
Normal file
108
packages/frontend/editor-ui/src/components/AboutModal.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import Modal from './Modal.vue';
|
||||
import { ABOUT_MODAL_KEY } from '../constants';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useClipboard } from '@/composables/useClipboard';
|
||||
import { useDebugInfo } from '@/composables/useDebugInfo';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const modalBus = createEventBus();
|
||||
const toast = useToast();
|
||||
const i18n = useI18n();
|
||||
const debugInfo = useDebugInfo();
|
||||
const clipboard = useClipboard();
|
||||
const rootStore = useRootStore();
|
||||
|
||||
const closeDialog = () => {
|
||||
modalBus.emit('close');
|
||||
};
|
||||
|
||||
const copyDebugInfoToClipboard = async () => {
|
||||
toast.showToast({
|
||||
title: i18n.baseText('about.debug.toast.title'),
|
||||
message: i18n.baseText('about.debug.toast.message'),
|
||||
type: 'info',
|
||||
duration: 5000,
|
||||
});
|
||||
await clipboard.copy(debugInfo.generateDebugInfo());
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
max-width="540px"
|
||||
:title="i18n.baseText('about.aboutN8n')"
|
||||
:event-bus="modalBus"
|
||||
:name="ABOUT_MODAL_KEY"
|
||||
:center="true"
|
||||
>
|
||||
<template #content>
|
||||
<div :class="$style.container">
|
||||
<el-row>
|
||||
<el-col :span="8" class="info-name">
|
||||
<n8n-text>{{ i18n.baseText('about.n8nVersion') }}</n8n-text>
|
||||
</el-col>
|
||||
<el-col :span="16">
|
||||
<n8n-text>{{ rootStore.versionCli }}</n8n-text>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-col :span="8" class="info-name">
|
||||
<n8n-text>{{ i18n.baseText('about.sourceCode') }}</n8n-text>
|
||||
</el-col>
|
||||
<el-col :span="16">
|
||||
<n8n-link to="https://github.com/n8n-io/n8n">https://github.com/n8n-io/n8n</n8n-link>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-col :span="8" class="info-name">
|
||||
<n8n-text>{{ i18n.baseText('about.license') }}</n8n-text>
|
||||
</el-col>
|
||||
<el-col :span="16">
|
||||
<n8n-link to="https://github.com/n8n-io/n8n/blob/master/LICENSE.md">
|
||||
{{ i18n.baseText('about.n8nLicense') }}
|
||||
</n8n-link>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-col :span="8" class="info-name">
|
||||
<n8n-text>{{ i18n.baseText('about.instanceID') }}</n8n-text>
|
||||
</el-col>
|
||||
<el-col :span="16">
|
||||
<n8n-text>{{ rootStore.instanceId }}</n8n-text>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-col :span="8" class="info-name">
|
||||
<n8n-text>{{ i18n.baseText('about.debug.title') }}</n8n-text>
|
||||
</el-col>
|
||||
<el-col :span="16">
|
||||
<div :class="$style.debugInfo" @click="copyDebugInfoToClipboard">
|
||||
<n8n-link>{{ i18n.baseText('about.debug.message') }}</n8n-link>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="action-buttons">
|
||||
<n8n-button
|
||||
float="right"
|
||||
:label="i18n.baseText('about.close')"
|
||||
data-test-id="close-about-modal-button"
|
||||
@click="closeDialog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.container > * {
|
||||
margin-bottom: var(--spacing-s);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
141
packages/frontend/editor-ui/src/components/ActivationModal.vue
Normal file
141
packages/frontend/editor-ui/src/components/ActivationModal.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import {
|
||||
WORKFLOW_ACTIVE_MODAL_KEY,
|
||||
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||
LOCAL_STORAGE_ACTIVATION_FLAG,
|
||||
VIEWS,
|
||||
} from '../constants';
|
||||
import { getActivatableTriggerNodes, getTriggerNodeServiceName } from '@/utils/nodeTypesUtils';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useStorage } from '@/composables/useStorage';
|
||||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const checked = ref(false);
|
||||
|
||||
const executionsStore = useExecutionsStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const router = useRouter();
|
||||
const i18n = useI18n();
|
||||
|
||||
const triggerContent = computed(() => {
|
||||
const foundTriggers = getActivatableTriggerNodes(workflowsStore.workflowTriggerNodes);
|
||||
if (!foundTriggers.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (foundTriggers.length > 1) {
|
||||
return i18n.baseText('activationModal.yourTriggersWillNowFire');
|
||||
}
|
||||
|
||||
const trigger = foundTriggers[0];
|
||||
|
||||
const triggerNodeType = nodeTypesStore.getNodeType(trigger.type, trigger.typeVersion);
|
||||
if (triggerNodeType) {
|
||||
if (triggerNodeType.activationMessage) {
|
||||
return triggerNodeType.activationMessage;
|
||||
}
|
||||
|
||||
const serviceName = getTriggerNodeServiceName(triggerNodeType);
|
||||
if (trigger.webhookId) {
|
||||
return i18n.baseText('activationModal.yourWorkflowWillNowListenForEvents', {
|
||||
interpolate: {
|
||||
serviceName,
|
||||
},
|
||||
});
|
||||
} else if (triggerNodeType.polling) {
|
||||
return i18n.baseText('activationModal.yourWorkflowWillNowRegularlyCheck', {
|
||||
interpolate: {
|
||||
serviceName,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return i18n.baseText('activationModal.yourTriggerWillNowFire');
|
||||
});
|
||||
|
||||
const showExecutionsList = async () => {
|
||||
const activeExecution = executionsStore.activeExecution;
|
||||
const currentWorkflow = workflowsStore.workflowId;
|
||||
|
||||
if (activeExecution) {
|
||||
router
|
||||
.push({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: currentWorkflow, executionId: activeExecution.id },
|
||||
})
|
||||
.catch(() => {});
|
||||
} else {
|
||||
router.push({ name: VIEWS.EXECUTION_HOME, params: { name: currentWorkflow } }).catch(() => {});
|
||||
}
|
||||
uiStore.closeModal(WORKFLOW_ACTIVE_MODAL_KEY);
|
||||
};
|
||||
|
||||
const showSettings = async () => {
|
||||
uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (checkboxValue: boolean) => {
|
||||
checked.value = checkboxValue;
|
||||
useStorage(LOCAL_STORAGE_ACTIVATION_FLAG).value = checkboxValue.toString();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:name="WORKFLOW_ACTIVE_MODAL_KEY"
|
||||
:title="i18n.baseText('activationModal.workflowActivated')"
|
||||
width="460px"
|
||||
>
|
||||
<template #content>
|
||||
<div>
|
||||
<n8n-text>{{ triggerContent }}</n8n-text>
|
||||
</div>
|
||||
<div :class="$style.spaced">
|
||||
<n8n-text>
|
||||
<n8n-text :bold="true">
|
||||
{{ i18n.baseText('activationModal.theseExecutionsWillNotShowUp') }}
|
||||
</n8n-text>
|
||||
{{ i18n.baseText('activationModal.butYouCanSeeThem') }}
|
||||
<a @click="showExecutionsList">
|
||||
{{ i18n.baseText('activationModal.executionList') }}
|
||||
</a>
|
||||
{{ i18n.baseText('activationModal.ifYouChooseTo') }}
|
||||
<a @click="showSettings">{{ i18n.baseText('activationModal.saveExecutions') }}</a>
|
||||
</n8n-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer="{ close }">
|
||||
<div :class="$style.footer">
|
||||
<el-checkbox :model-value="checked" @update:model-value="handleCheckboxChange">{{
|
||||
i18n.baseText('generic.dontShowAgain')
|
||||
}}</el-checkbox>
|
||||
<n8n-button :label="i18n.baseText('activationModal.gotIt')" @click="close" />
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.spaced {
|
||||
margin-top: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: right;
|
||||
|
||||
> * {
|
||||
margin-left: var(--spacing-s);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
37
packages/frontend/editor-ui/src/components/AiStarsIcon.vue
Normal file
37
packages/frontend/editor-ui/src/components/AiStarsIcon.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: 'mini' | 'small' | 'medium' | 'large';
|
||||
}>(),
|
||||
{
|
||||
size: 'medium',
|
||||
},
|
||||
);
|
||||
|
||||
const sizes = {
|
||||
mini: 8,
|
||||
small: 10,
|
||||
medium: 12,
|
||||
large: 16,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="sizes[size]"
|
||||
:height="sizes[size]"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g id="NodeIcon">
|
||||
<path
|
||||
id="Union"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M15.7982 7.80784L13.92 7.20243C12.7186 6.80602 12.0148 5.83386 11.6844 4.61226L10.8579 0.586096C10.8363 0.506544 10.7837 0.400024 10.6219 0.400024C10.4857 0.400024 10.4075 0.506544 10.386 0.586096L9.55943 4.61361C9.22773 5.83521 8.52525 6.80737 7.32387 7.20378L5.44562 7.80919C5.18 7.89548 5.17595 8.27032 5.44023 8.36066L7.33196 9.01191C8.52929 9.40968 9.22773 10.3805 9.55943 11.5967L10.3873 15.5784C10.4089 15.6579 10.4534 15.8008 10.6233 15.8008C10.7991 15.8008 10.8362 15.6634 10.858 15.5831L10.8592 15.5784L11.6871 11.5967C12.0188 10.3791 12.7173 9.40833 13.9146 9.01191L15.8063 8.36066C16.0679 8.26897 16.0639 7.89413 15.7982 7.80784ZM5.04114 11.3108C3.92815 10.9434 3.81743 10.5296 3.63184 9.83597L3.62672 9.81687L3.15615 8.16649C3.12784 8.05997 2.85008 8.05997 2.82041 8.16649L2.50085 9.69147C2.31074 10.394 1.90623 10.9522 1.21588 11.18L0.11563 11.6574C-0.0367335 11.7072 -0.0394302 11.923 0.112933 11.9742L1.22127 12.3666C1.90893 12.5945 2.31074 13.1527 2.5022 13.8525L2.82176 15.3114C2.85142 15.4179 3.12784 15.4179 3.15615 15.3114L3.53099 13.8592C3.72111 13.1554 4.01235 12.5958 4.94675 12.3666L5.98768 11.9742C6.14004 11.9216 6.13869 11.7059 5.98498 11.656L5.04114 11.3108ZM5.33019 0.812949C5.36674 0.661849 5.58158 0.659434 5.61894 0.811355L5.61899 0.811582L6.02856 2.50239C6.08442 2.69624 6.23624 2.8465 6.43132 2.89951L7.47286 3.18013C7.61383 3.2197 7.61829 3.41714 7.48035 3.46394L7.48015 3.46401L6.38799 3.83076C6.21241 3.88968 6.07619 4.03027 6.02153 4.20719L5.61894 5.77311L5.61884 5.77349C5.58095 5.92613 5.36829 5.91987 5.33166 5.77336L4.94237 4.21215C4.88888 4.03513 4.75378 3.89336 4.57956 3.83328L3.48805 3.4555C3.34919 3.40591 3.36033 3.20859 3.50031 3.17175L3.50054 3.17169L4.53472 2.90337C4.73486 2.85153 4.89134 2.69755 4.94463 2.49805L5.33019 0.812949Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
const i18n = useI18n();
|
||||
|
||||
defineProps<{
|
||||
nodeName: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
{{ i18n.baseText('aiAssistant.codeUpdated.message.body1') }}
|
||||
<a data-action="openNodeDetail" :data-action-parameter-node="nodeName">{{ nodeName }}</a>
|
||||
{{ i18n.baseText('aiAssistant.codeUpdated.message.body2') }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useAnnotationTagsStore } from '@/stores/tags.store';
|
||||
import { ANNOTATION_TAGS_MANAGER_MODAL_KEY } from '@/constants';
|
||||
import type { EventBus } from '@n8n/utils/event-bus';
|
||||
|
||||
interface TagsDropdownWrapperProps {
|
||||
placeholder?: string;
|
||||
modelValue?: string[];
|
||||
createEnabled?: boolean;
|
||||
eventBus?: EventBus | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TagsDropdownWrapperProps>(), {
|
||||
placeholder: '',
|
||||
modelValue: () => [],
|
||||
createEnabled: false,
|
||||
eventBus: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [selected: string[]];
|
||||
esc: [];
|
||||
blur: [];
|
||||
}>();
|
||||
|
||||
const tagsStore = useAnnotationTagsStore();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const selectedTags = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
});
|
||||
|
||||
const allTags = computed(() => tagsStore.allTags);
|
||||
const isLoading = computed(() => tagsStore.isLoading);
|
||||
const tagsById = computed(() => tagsStore.tagsById);
|
||||
|
||||
async function createTag(name: string) {
|
||||
return await tagsStore.create(name);
|
||||
}
|
||||
|
||||
function handleManageTags() {
|
||||
uiStore.openModal(ANNOTATION_TAGS_MANAGER_MODAL_KEY);
|
||||
}
|
||||
|
||||
function handleEsc() {
|
||||
emit('esc');
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
emit('blur');
|
||||
}
|
||||
|
||||
// Fetch all tags when the component is created
|
||||
void tagsStore.fetchAll();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagsDropdown
|
||||
v-model="selectedTags"
|
||||
:placeholder="placeholder"
|
||||
:create-enabled="createEnabled"
|
||||
:event-bus="eventBus"
|
||||
:all-tags="allTags"
|
||||
:is-loading="isLoading"
|
||||
:tags-by-id="tagsById"
|
||||
:create-tag="createTag"
|
||||
@manage-tags="handleManageTags"
|
||||
@esc="handleEsc"
|
||||
@blur="handleBlur"
|
||||
/>
|
||||
</template>
|
||||
125
packages/frontend/editor-ui/src/components/ApiKeyCard.vue
Normal file
125
packages/frontend/editor-ui/src/components/ApiKeyCard.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { ApiKey } from '@n8n/api-types';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
const API_KEY_ITEM_ACTIONS = {
|
||||
EDIT: 'edit',
|
||||
DELETE: 'delete',
|
||||
};
|
||||
|
||||
const ACTION_LIST = [
|
||||
{
|
||||
label: 'Edit',
|
||||
value: API_KEY_ITEM_ACTIONS.EDIT,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
value: API_KEY_ITEM_ACTIONS.DELETE,
|
||||
},
|
||||
];
|
||||
|
||||
const i18n = useI18n();
|
||||
const cardActions = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const props = defineProps<{
|
||||
apiKey: ApiKey;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [id: string];
|
||||
delete: [id: string];
|
||||
}>();
|
||||
|
||||
async function onAction(action: string) {
|
||||
if (action === API_KEY_ITEM_ACTIONS.EDIT) {
|
||||
emit('edit', props.apiKey.id);
|
||||
} else if (action === API_KEY_ITEM_ACTIONS.DELETE) {
|
||||
emit('delete', props.apiKey.id);
|
||||
}
|
||||
}
|
||||
|
||||
const hasApiKeyExpired = (apiKey: ApiKey) => {
|
||||
if (!apiKey.expiresAt) return false;
|
||||
return apiKey.expiresAt <= Date.now() / 1000;
|
||||
};
|
||||
|
||||
const getExpirationTime = (apiKey: ApiKey): string => {
|
||||
if (!apiKey.expiresAt) return i18n.baseText('settings.api.neverExpires');
|
||||
|
||||
if (hasApiKeyExpired(apiKey)) return i18n.baseText('settings.api.expired');
|
||||
|
||||
const time = DateTime.fromSeconds(apiKey.expiresAt).toFormat('ccc, MMM d yyyy');
|
||||
|
||||
return i18n.baseText('settings.api.expirationTime', { interpolate: { time } });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n8n-card :class="$style.cardLink" data-test-id="api-key-card" @click="onAction('edit')">
|
||||
<template #header>
|
||||
<div>
|
||||
<n8n-heading tag="h2" bold :class="$style.cardHeading">
|
||||
{{ apiKey.label }}
|
||||
</n8n-heading>
|
||||
<div :class="[$style.cardDescription]">
|
||||
<n8n-text :color="!hasApiKeyExpired(apiKey) ? 'text-light' : 'warning'" size="small">
|
||||
<span>{{ getExpirationTime(apiKey) }}</span>
|
||||
</n8n-text>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="apiKey.apiKey.includes('*')" :class="$style.cardApiKey">
|
||||
<n8n-text color="text-light" size="small"> {{ apiKey.apiKey }}</n8n-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #append>
|
||||
<div ref="cardActions" :class="$style.cardActions">
|
||||
<n8n-action-toggle :actions="ACTION_LIST" theme="dark" @action="onAction" />
|
||||
</div>
|
||||
</template>
|
||||
</n8n-card>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.cardLink {
|
||||
transition: box-shadow 0.3s ease;
|
||||
cursor: pointer;
|
||||
padding: 0 0 0 var(--spacing-s);
|
||||
align-items: stretch;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(#441c17, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.cardHeading {
|
||||
font-size: var(--font-size-s);
|
||||
word-break: word-break;
|
||||
padding: var(--spacing-s) 0 0 var(--spacing-s);
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.cardDescription {
|
||||
min-height: 19px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0 var(--spacing-s) var(--spacing-s);
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 var(--spacing-s) 0 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.cardApiKey {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { API_KEY_CREATE_OR_EDIT_MODAL_KEY, STORES } from '@/constants';
|
||||
import { cleanupAppModals, createAppModals, mockedStore, retry } from '@/__tests__/utils';
|
||||
import ApiKeyEditModal from './ApiKeyCreateOrEditModal.vue';
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
|
||||
import { useApiKeysStore } from '@/stores/apiKeys.store';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { ApiKeyWithRawValue } from '@n8n/api-types';
|
||||
|
||||
const renderComponent = createComponentRenderer(ApiKeyEditModal, {
|
||||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.UI]: {
|
||||
modalsById: {
|
||||
[API_KEY_CREATE_OR_EDIT_MODAL_KEY]: { open: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const testApiKey: ApiKeyWithRawValue = {
|
||||
id: '123',
|
||||
label: 'new api key',
|
||||
apiKey: '123456***',
|
||||
createdAt: new Date().toString(),
|
||||
updatedAt: new Date().toString(),
|
||||
rawApiKey: '123456',
|
||||
expiresAt: 0,
|
||||
};
|
||||
|
||||
const apiKeysStore = mockedStore(useApiKeysStore);
|
||||
|
||||
describe('ApiKeyCreateOrEditModal', () => {
|
||||
beforeEach(() => {
|
||||
createAppModals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupAppModals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should allow creating API key with default expiration (30 days)', async () => {
|
||||
apiKeysStore.createApiKey.mockResolvedValue(testApiKey);
|
||||
|
||||
const { getByText, getByPlaceholderText } = renderComponent({
|
||||
props: {
|
||||
mode: 'new',
|
||||
},
|
||||
});
|
||||
|
||||
await retry(() => expect(getByText('Create API Key')).toBeInTheDocument());
|
||||
expect(getByText('Label')).toBeInTheDocument();
|
||||
|
||||
const inputLabel = getByPlaceholderText('e.g Internal Project');
|
||||
const saveButton = getByText('Save');
|
||||
|
||||
expect(inputLabel).toBeInTheDocument();
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
|
||||
await fireEvent.update(inputLabel, 'new label');
|
||||
|
||||
await fireEvent.click(saveButton);
|
||||
|
||||
expect(getByText('API Key Created')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('Done')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
getByText('Make sure to copy your API key now as you will not be able to see this again.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(getByText('You can find more details in')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('the API documentation')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('Click to copy')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('new api key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should allow creating API key with custom expiration', async () => {
|
||||
apiKeysStore.createApiKey.mockResolvedValue({
|
||||
id: '123',
|
||||
label: 'new api key',
|
||||
apiKey: '123456',
|
||||
createdAt: new Date().toString(),
|
||||
updatedAt: new Date().toString(),
|
||||
rawApiKey: '***456',
|
||||
expiresAt: 0,
|
||||
});
|
||||
|
||||
const { getByText, getByPlaceholderText, getByTestId } = renderComponent({
|
||||
props: {
|
||||
mode: 'new',
|
||||
},
|
||||
});
|
||||
|
||||
await retry(() => expect(getByText('Create API Key')).toBeInTheDocument());
|
||||
expect(getByText('Label')).toBeInTheDocument();
|
||||
|
||||
const inputLabel = getByPlaceholderText('e.g Internal Project');
|
||||
const saveButton = getByText('Save');
|
||||
const expirationSelect = getByTestId('expiration-select');
|
||||
|
||||
expect(inputLabel).toBeInTheDocument();
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
expect(expirationSelect).toBeInTheDocument();
|
||||
|
||||
await fireEvent.update(inputLabel, 'new label');
|
||||
|
||||
await fireEvent.click(expirationSelect);
|
||||
|
||||
const customOption = getByText('Custom');
|
||||
|
||||
expect(customOption).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(customOption);
|
||||
|
||||
const customExpirationInput = getByPlaceholderText('yyyy-mm-dd');
|
||||
|
||||
expect(customExpirationInput).toBeInTheDocument();
|
||||
|
||||
await fireEvent.input(customExpirationInput, '2029-12-31');
|
||||
|
||||
await fireEvent.click(saveButton);
|
||||
|
||||
expect(getByText('***456')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('API Key Created')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('Done')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
getByText('Make sure to copy your API key now as you will not be able to see this again.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(getByText('You can find more details in')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('the API documentation')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('Click to copy')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('new api key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should allow creating API key with no expiration', async () => {
|
||||
apiKeysStore.createApiKey.mockResolvedValue(testApiKey);
|
||||
|
||||
const { getByText, getByPlaceholderText, getByTestId } = renderComponent({
|
||||
props: {
|
||||
mode: 'new',
|
||||
},
|
||||
});
|
||||
|
||||
await retry(() => expect(getByText('Create API Key')).toBeInTheDocument());
|
||||
expect(getByText('Label')).toBeInTheDocument();
|
||||
|
||||
const inputLabel = getByPlaceholderText('e.g Internal Project');
|
||||
const saveButton = getByText('Save');
|
||||
const expirationSelect = getByTestId('expiration-select');
|
||||
|
||||
expect(inputLabel).toBeInTheDocument();
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
expect(expirationSelect).toBeInTheDocument();
|
||||
|
||||
await fireEvent.update(inputLabel, 'new label');
|
||||
|
||||
await fireEvent.click(expirationSelect);
|
||||
|
||||
const noExpirationOption = getByText('No Expiration');
|
||||
|
||||
expect(noExpirationOption).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(noExpirationOption);
|
||||
|
||||
await fireEvent.click(saveButton);
|
||||
|
||||
expect(getByText('API Key Created')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('Done')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
getByText('Make sure to copy your API key now as you will not be able to see this again.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(getByText('You can find more details in')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('the API documentation')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('Click to copy')).toBeInTheDocument();
|
||||
|
||||
expect(getByText('new api key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should allow editing API key label', async () => {
|
||||
apiKeysStore.apiKeys = [testApiKey];
|
||||
|
||||
apiKeysStore.updateApiKey.mockResolvedValue();
|
||||
|
||||
const { getByText, getByTestId } = renderComponent({
|
||||
props: {
|
||||
mode: 'edit',
|
||||
activeId: '123',
|
||||
},
|
||||
});
|
||||
|
||||
await retry(() => expect(getByText('Edit API Key')).toBeInTheDocument());
|
||||
|
||||
expect(getByText('Label')).toBeInTheDocument();
|
||||
|
||||
const formattedDate = DateTime.fromMillis(Date.parse(testApiKey.createdAt)).toFormat(
|
||||
'ccc, MMM d yyyy',
|
||||
);
|
||||
|
||||
expect(getByText(`API key was created on ${formattedDate}`)).toBeInTheDocument();
|
||||
|
||||
const labelInput = getByTestId('api-key-label');
|
||||
|
||||
expect((labelInput as unknown as HTMLInputElement).value).toBe('new api key');
|
||||
|
||||
await fireEvent.update(labelInput, 'updated api key');
|
||||
|
||||
const editButton = getByText('Edit');
|
||||
|
||||
expect(editButton).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
expect(apiKeysStore.updateApiKey).toHaveBeenCalledWith('123', { label: 'updated api key' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
<script lang="ts" setup>
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import { API_KEY_CREATE_OR_EDIT_MODAL_KEY, DOCS_DOMAIN } from '@/constants';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||
import { useApiKeysStore } from '@/stores/apiKeys.store';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import type { BaseTextKey } from '@/plugins/i18n';
|
||||
import { N8nText } from '@n8n/design-system';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { ApiKey, ApiKeyWithRawValue, CreateApiKeyRequestDto } from '@n8n/api-types';
|
||||
|
||||
const EXPIRATION_OPTIONS = {
|
||||
'7_DAYS': 7,
|
||||
'30_DAYS': 30,
|
||||
'60_DAYS': 60,
|
||||
'90_DAYS': 90,
|
||||
CUSTOM: 1,
|
||||
NO_EXPIRATION: 0,
|
||||
};
|
||||
|
||||
const i18n = useI18n();
|
||||
const { showError, showMessage } = useToast();
|
||||
|
||||
const uiStore = useUIStore();
|
||||
const rootStore = useRootStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { isSwaggerUIEnabled, publicApiPath, publicApiLatestVersion } = settingsStore;
|
||||
const { createApiKey, updateApiKey, apiKeysById } = useApiKeysStore();
|
||||
const { baseUrl } = useRootStore();
|
||||
const documentTitle = useDocumentTitle();
|
||||
|
||||
const label = ref('');
|
||||
const expirationDaysFromNow = ref(EXPIRATION_OPTIONS['30_DAYS']);
|
||||
const modalBus = createEventBus();
|
||||
const newApiKey = ref<ApiKeyWithRawValue | null>(null);
|
||||
const apiDocsURL = ref('');
|
||||
const loading = ref(false);
|
||||
const rawApiKey = ref('');
|
||||
const customExpirationDate = ref('');
|
||||
const showExpirationDateSelector = ref(false);
|
||||
const apiKeyCreationDate = ref('');
|
||||
|
||||
const calculateExpirationDate = (daysFromNow: number) => {
|
||||
const date = DateTime.now()
|
||||
.setZone(rootStore.timezone)
|
||||
.startOf('day')
|
||||
.plus({ days: daysFromNow });
|
||||
return date;
|
||||
};
|
||||
|
||||
const getExpirationOptionLabel = (value: number) => {
|
||||
if (EXPIRATION_OPTIONS.CUSTOM === value) {
|
||||
return i18n.baseText('settings.api.view.modal.form.expiration.custom');
|
||||
}
|
||||
|
||||
if (EXPIRATION_OPTIONS.NO_EXPIRATION === value) {
|
||||
return i18n.baseText('settings.api.view.modal.form.expiration.none');
|
||||
}
|
||||
|
||||
return i18n.baseText('settings.api.view.modal.form.expiration.days', {
|
||||
interpolate: {
|
||||
numberOfDays: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const expirationDate = ref(
|
||||
calculateExpirationDate(expirationDaysFromNow.value).toFormat('ccc, MMM d yyyy'),
|
||||
);
|
||||
|
||||
const inputRef = ref<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
mode?: 'new' | 'edit';
|
||||
activeId?: string;
|
||||
}>(),
|
||||
{
|
||||
mode: 'new',
|
||||
activeId: '',
|
||||
},
|
||||
);
|
||||
|
||||
const allFormFieldsAreSet = computed(() => {
|
||||
const isExpirationDateSet =
|
||||
expirationDaysFromNow.value === EXPIRATION_OPTIONS.NO_EXPIRATION ||
|
||||
(expirationDaysFromNow.value === EXPIRATION_OPTIONS.CUSTOM && customExpirationDate.value) ||
|
||||
expirationDate.value;
|
||||
|
||||
return label.value && (props.mode === 'edit' ? true : isExpirationDateSet);
|
||||
});
|
||||
|
||||
const isCustomDateInThePast = (date: Date) => Date.now() > date.getTime();
|
||||
|
||||
onMounted(() => {
|
||||
documentTitle.set(i18n.baseText('settings.api'));
|
||||
|
||||
setTimeout(() => {
|
||||
inputRef.value?.focus();
|
||||
});
|
||||
|
||||
if (props.mode === 'edit') {
|
||||
const apiKey = apiKeysById[props.activeId];
|
||||
label.value = apiKey.label ?? '';
|
||||
apiKeyCreationDate.value = getApiKeyCreationTime(apiKey);
|
||||
}
|
||||
|
||||
apiDocsURL.value = isSwaggerUIEnabled
|
||||
? `${baseUrl}${publicApiPath}/v${publicApiLatestVersion}/docs`
|
||||
: `https://${DOCS_DOMAIN}/api/api-reference/`;
|
||||
});
|
||||
|
||||
function onInput(value: string): void {
|
||||
label.value = value;
|
||||
}
|
||||
|
||||
const getApiKeyCreationTime = (apiKey: ApiKey): string => {
|
||||
const time = DateTime.fromMillis(Date.parse(apiKey.createdAt)).toFormat('ccc, MMM d yyyy');
|
||||
return i18n.baseText('settings.api.creationTime', { interpolate: { time } });
|
||||
};
|
||||
|
||||
async function onEdit() {
|
||||
try {
|
||||
loading.value = true;
|
||||
await updateApiKey(props.activeId, { label: label.value });
|
||||
showMessage({
|
||||
type: 'success',
|
||||
title: i18n.baseText('settings.api.update.toast'),
|
||||
});
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
showError(error, i18n.baseText('settings.api.edit.error'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
uiStore.closeModal(API_KEY_CREATE_OR_EDIT_MODAL_KEY);
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
if (!label.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
let expirationUnixTimestamp = null;
|
||||
|
||||
if (expirationDaysFromNow.value === EXPIRATION_OPTIONS.CUSTOM) {
|
||||
expirationUnixTimestamp = parseInt(customExpirationDate.value, 10);
|
||||
} else if (expirationDaysFromNow.value !== EXPIRATION_OPTIONS.NO_EXPIRATION) {
|
||||
expirationUnixTimestamp = calculateExpirationDate(expirationDaysFromNow.value).toUnixInteger();
|
||||
}
|
||||
|
||||
const payload: CreateApiKeyRequestDto = {
|
||||
label: label.value,
|
||||
expiresAt: expirationUnixTimestamp,
|
||||
};
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
newApiKey.value = await createApiKey(payload);
|
||||
rawApiKey.value = newApiKey.value.rawApiKey;
|
||||
|
||||
showMessage({
|
||||
type: 'success',
|
||||
title: i18n.baseText('settings.api.create.toast'),
|
||||
});
|
||||
} catch (error) {
|
||||
showError(error, i18n.baseText('settings.api.create.error'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
let path = 'edit';
|
||||
if (props.mode === 'new') {
|
||||
if (newApiKey.value) {
|
||||
path = 'created';
|
||||
} else {
|
||||
path = 'create';
|
||||
}
|
||||
}
|
||||
return i18n.baseText(`settings.api.view.modal.title.${path}` as BaseTextKey);
|
||||
});
|
||||
|
||||
const onSelect = (value: number) => {
|
||||
if (value === EXPIRATION_OPTIONS.CUSTOM) {
|
||||
showExpirationDateSelector.value = true;
|
||||
expirationDate.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (value !== EXPIRATION_OPTIONS.NO_EXPIRATION) {
|
||||
expirationDate.value = calculateExpirationDate(value).toFormat('ccc, MMM d yyyy');
|
||||
showExpirationDateSelector.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
expirationDate.value = '';
|
||||
showExpirationDateSelector.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:title="modalTitle"
|
||||
:event-bus="modalBus"
|
||||
:name="API_KEY_CREATE_OR_EDIT_MODAL_KEY"
|
||||
width="600px"
|
||||
:lock-scroll="false"
|
||||
:close-on-esc="true"
|
||||
:close-on-click-outside="true"
|
||||
:show-close="true"
|
||||
>
|
||||
<template #content>
|
||||
<div>
|
||||
<p v-if="newApiKey" class="mb-s">
|
||||
<n8n-info-tip :bold="false">
|
||||
<i18n-t keypath="settings.api.view.info" tag="span">
|
||||
<template #apiAction>
|
||||
<a
|
||||
href="https://docs.n8n.io/api"
|
||||
target="_blank"
|
||||
v-text="i18n.baseText('settings.api.view.info.api')"
|
||||
/>
|
||||
</template>
|
||||
<template #webhookAction>
|
||||
<a
|
||||
href="https://docs.n8n.io/integrations/core-nodes/n8n-nodes-base.webhook/"
|
||||
target="_blank"
|
||||
v-text="i18n.baseText('settings.api.view.info.webhook')"
|
||||
/>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</n8n-info-tip>
|
||||
</p>
|
||||
<n8n-card v-if="newApiKey" class="mb-4xs">
|
||||
<CopyInput
|
||||
:label="newApiKey.label"
|
||||
:value="newApiKey.rawApiKey"
|
||||
:redact-value="true"
|
||||
:copy-button-text="i18n.baseText('generic.clickToCopy')"
|
||||
:toast-title="i18n.baseText('settings.api.view.copy.toast')"
|
||||
:hint="i18n.baseText('settings.api.view.copy')"
|
||||
/>
|
||||
</n8n-card>
|
||||
|
||||
<div v-if="newApiKey" :class="$style.hint">
|
||||
<N8nText size="small">
|
||||
{{
|
||||
i18n.baseText(
|
||||
`settings.api.view.${settingsStore.isSwaggerUIEnabled ? 'tryapi' : 'more-details'}`,
|
||||
)
|
||||
}}
|
||||
</N8nText>
|
||||
{{ ' ' }}
|
||||
<n8n-link :to="apiDocsURL" :new-window="true" size="small">
|
||||
{{
|
||||
i18n.baseText(
|
||||
`settings.api.view.${isSwaggerUIEnabled ? 'apiPlayground' : 'external-docs'}`,
|
||||
)
|
||||
}}
|
||||
</n8n-link>
|
||||
</div>
|
||||
<div v-else :class="$style.form">
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('settings.api.view.modal.form.label')"
|
||||
color="text-dark"
|
||||
>
|
||||
<N8nInput
|
||||
ref="inputRef"
|
||||
required
|
||||
:model-value="label"
|
||||
size="large"
|
||||
type="text"
|
||||
:placeholder="i18n.baseText('settings.api.view.modal.form.label.placeholder')"
|
||||
:maxlength="50"
|
||||
data-test-id="api-key-label"
|
||||
@update:model-value="onInput"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
<div v-if="mode === 'new'" :class="$style.expirationSection">
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('settings.api.view.modal.form.expiration')"
|
||||
color="text-dark"
|
||||
>
|
||||
<N8nSelect
|
||||
v-model="expirationDaysFromNow"
|
||||
size="large"
|
||||
filterable
|
||||
data-test-id="expiration-select"
|
||||
@update:model-value="onSelect"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="key in Object.keys(EXPIRATION_OPTIONS)"
|
||||
:key="key"
|
||||
:value="EXPIRATION_OPTIONS[key as keyof typeof EXPIRATION_OPTIONS]"
|
||||
:label="
|
||||
getExpirationOptionLabel(
|
||||
EXPIRATION_OPTIONS[key as keyof typeof EXPIRATION_OPTIONS],
|
||||
)
|
||||
"
|
||||
>
|
||||
</N8nOption>
|
||||
</N8nSelect>
|
||||
</N8nInputLabel>
|
||||
<N8nText v-if="expirationDate" class="mb-xs">{{
|
||||
i18n.baseText('settings.api.view.modal.form.expirationText', {
|
||||
interpolate: { expirationDate },
|
||||
})
|
||||
}}</N8nText>
|
||||
<el-date-picker
|
||||
v-if="showExpirationDateSelector"
|
||||
v-model="customExpirationDate"
|
||||
type="date"
|
||||
:teleported="false"
|
||||
placeholder="yyyy-mm-dd"
|
||||
value-format="X"
|
||||
:disabled-date="isCustomDateInThePast"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div :class="$style.footer">
|
||||
<N8nButton
|
||||
v-if="mode === 'new' && !newApiKey"
|
||||
:loading="loading"
|
||||
:disabled="!allFormFieldsAreSet"
|
||||
:label="i18n.baseText('settings.api.view.modal.save.button')"
|
||||
@click="onSave"
|
||||
/>
|
||||
<N8nButton
|
||||
v-else-if="mode === 'new'"
|
||||
:label="i18n.baseText('settings.api.view.modal.done.button')"
|
||||
@click="closeModal"
|
||||
/>
|
||||
<N8nButton
|
||||
v-if="mode === 'edit'"
|
||||
:disabled="!allFormFieldsAreSet"
|
||||
:label="i18n.baseText('settings.api.view.modal.edit.button')"
|
||||
@click="onEdit"
|
||||
/>
|
||||
<N8nText v-if="mode === 'edit'" size="small" color="text-light">{{
|
||||
apiKeyCreationDate
|
||||
}}</N8nText>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
<style module lang="scss">
|
||||
.notice {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--color-text-light);
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.expirationSection {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts" setup>
|
||||
import { useAssistantStore } from '@/stores/assistant.store';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { computed } from 'vue';
|
||||
import SlideTransition from '@/components/transitions/SlideTransition.vue';
|
||||
import AskAssistantChat from '@n8n/design-system/components/AskAssistantChat/AskAssistantChat.vue';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
|
||||
const assistantStore = useAssistantStore();
|
||||
const usersStore = useUsersStore();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const user = computed(() => ({
|
||||
firstName: usersStore.currentUser?.firstName ?? '',
|
||||
lastName: usersStore.currentUser?.lastName ?? '',
|
||||
}));
|
||||
|
||||
const loadingMessage = computed(() => assistantStore.assistantThinkingMessage);
|
||||
|
||||
function onResize(data: { direction: string; x: number; width: number }) {
|
||||
assistantStore.updateWindowWidth(data.width);
|
||||
}
|
||||
|
||||
function onResizeDebounced(data: { direction: string; x: number; width: number }) {
|
||||
void useDebounce().callDebounced(onResize, { debounceTime: 10, trailing: true }, data);
|
||||
}
|
||||
|
||||
async function onUserMessage(content: string, quickReplyType?: string, isFeedback = false) {
|
||||
// If there is no current session running, initialize the support chat session
|
||||
if (!assistantStore.currentSessionId) {
|
||||
await assistantStore.initSupportChat(content);
|
||||
} else {
|
||||
await assistantStore.sendMessage({ text: content, quickReplyType });
|
||||
}
|
||||
const task = assistantStore.chatSessionTask;
|
||||
const solutionCount = assistantStore.chatMessages.filter(
|
||||
(msg) => msg.role === 'assistant' && !['text', 'event'].includes(msg.type),
|
||||
).length;
|
||||
if (isFeedback) {
|
||||
telemetry.track('User gave feedback', {
|
||||
task,
|
||||
chat_session_id: assistantStore.currentSessionId,
|
||||
is_quick_reply: !!quickReplyType,
|
||||
is_positive: quickReplyType === 'all-good',
|
||||
solution_count: solutionCount,
|
||||
response: content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function onCodeReplace(index: number) {
|
||||
await assistantStore.applyCodeDiff(index);
|
||||
telemetry.track('User clicked solution card action', {
|
||||
action: 'replace_code',
|
||||
});
|
||||
}
|
||||
|
||||
async function undoCodeDiff(index: number) {
|
||||
await assistantStore.undoCodeDiff(index);
|
||||
telemetry.track('User clicked solution card action', {
|
||||
action: 'undo_code_replace',
|
||||
});
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
assistantStore.closeChat();
|
||||
telemetry.track('User closed assistant', { source: 'top-toggle' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SlideTransition>
|
||||
<N8nResizeWrapper
|
||||
v-show="assistantStore.isAssistantOpen"
|
||||
:supported-directions="['left']"
|
||||
:width="assistantStore.chatWidth"
|
||||
data-test-id="ask-assistant-sidebar"
|
||||
@resize="onResizeDebounced"
|
||||
>
|
||||
<div
|
||||
:style="{ width: `${assistantStore.chatWidth}px` }"
|
||||
:class="$style.wrapper"
|
||||
data-test-id="ask-assistant-chat"
|
||||
tabindex="0"
|
||||
@keydown.stop
|
||||
>
|
||||
<AskAssistantChat
|
||||
:user="user"
|
||||
:messages="assistantStore.chatMessages"
|
||||
:streaming="assistantStore.streaming"
|
||||
:loading-message="loadingMessage"
|
||||
:session-id="assistantStore.currentSessionId"
|
||||
@close="onClose"
|
||||
@message="onUserMessage"
|
||||
@code-replace="onCodeReplace"
|
||||
@code-undo="undoCodeDiff"
|
||||
/>
|
||||
</div>
|
||||
</N8nResizeWrapper>
|
||||
</SlideTransition>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useStyles } from '@/composables/useStyles';
|
||||
import { useAssistantStore } from '@/stores/assistant.store';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import AssistantAvatar from '@n8n/design-system/components/AskAssistantAvatar/AssistantAvatar.vue';
|
||||
import AskAssistantButton from '@n8n/design-system/components/AskAssistantButton/AskAssistantButton.vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const assistantStore = useAssistantStore();
|
||||
const i18n = useI18n();
|
||||
const { APP_Z_INDEXES } = useStyles();
|
||||
const canvasStore = useCanvasStore();
|
||||
|
||||
const lastUnread = computed(() => {
|
||||
const msg = assistantStore.lastUnread;
|
||||
if (msg?.type === 'block') {
|
||||
return msg.title;
|
||||
}
|
||||
if (msg?.type === 'text') {
|
||||
return msg.content;
|
||||
}
|
||||
if (msg?.type === 'code-diff') {
|
||||
return msg.description;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const onClick = () => {
|
||||
assistantStore.openChat();
|
||||
assistantStore.trackUserOpenedAssistant({
|
||||
source: 'canvas',
|
||||
task: 'placeholder',
|
||||
has_existing_session: !assistantStore.isSessionEnded,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="assistantStore.canShowAssistantButtonsOnCanvas && !assistantStore.isAssistantOpen"
|
||||
:class="$style.container"
|
||||
data-test-id="ask-assistant-floating-button"
|
||||
:style="{ '--canvas-panel-height-offset': `${canvasStore.panelHeight}px` }"
|
||||
>
|
||||
<n8n-tooltip
|
||||
:z-index="APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON_TOOLTIP"
|
||||
placement="top"
|
||||
:visible="!!lastUnread"
|
||||
:popper-class="$style.tooltip"
|
||||
>
|
||||
<template #content>
|
||||
<div :class="$style.text">{{ lastUnread }}</div>
|
||||
<div :class="$style.assistant">
|
||||
<AssistantAvatar size="mini" />
|
||||
<span>{{ i18n.baseText('aiAssistant.name') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<AskAssistantButton :unread-count="assistantStore.unreadCount" @click="onClick" />
|
||||
</n8n-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
position: absolute;
|
||||
bottom: calc(var(--canvas-panel-height-offset, 0px) + var(--spacing-s));
|
||||
right: var(--spacing-s);
|
||||
z-index: var(--z-index-ask-assistant-floating-button);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
min-width: 150px;
|
||||
max-width: 265px !important;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.assistant {
|
||||
font-size: var(--font-size-3xs);
|
||||
line-height: var(--spacing-s);
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin-top: var(--spacing-xs);
|
||||
> span {
|
||||
margin-left: var(--spacing-4xs);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2; /* number of lines to show */
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts" setup>
|
||||
import { NEW_ASSISTANT_SESSION_MODAL } from '@/constants';
|
||||
import Modal from '../Modal.vue';
|
||||
import AssistantIcon from '@n8n/design-system/components/AskAssistantIcon/AssistantIcon.vue';
|
||||
import AssistantText from '@n8n/design-system/components/AskAssistantText/AssistantText.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import type { ChatRequest } from '@/types/assistant.types';
|
||||
import { useAssistantStore } from '@/stores/assistant.store';
|
||||
import type { ICredentialType } from 'n8n-workflow';
|
||||
|
||||
const i18n = useI18n();
|
||||
const uiStore = useUIStore();
|
||||
const assistantStore = useAssistantStore();
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
data: {
|
||||
context: { errorHelp: ChatRequest.ErrorContext } | { credHelp: { credType: ICredentialType } };
|
||||
};
|
||||
}>();
|
||||
|
||||
const close = () => {
|
||||
uiStore.closeModal(NEW_ASSISTANT_SESSION_MODAL);
|
||||
};
|
||||
|
||||
const startNewSession = async () => {
|
||||
if ('errorHelp' in props.data.context) {
|
||||
await assistantStore.initErrorHelper(props.data.context.errorHelp);
|
||||
assistantStore.trackUserOpenedAssistant({
|
||||
source: 'error',
|
||||
task: 'error',
|
||||
has_existing_session: true,
|
||||
});
|
||||
} else if ('credHelp' in props.data.context) {
|
||||
await assistantStore.initCredHelp(props.data.context.credHelp.credType);
|
||||
}
|
||||
close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
width="460px"
|
||||
height="250px"
|
||||
data-test-id="new-assistant-session-modal"
|
||||
:name="NEW_ASSISTANT_SESSION_MODAL"
|
||||
:center="true"
|
||||
:append-to-body="true"
|
||||
>
|
||||
<template #header>
|
||||
{{ i18n.baseText('aiAssistant.newSessionModal.title.part1') }}
|
||||
<span :class="$style.assistantIcon"><AssistantIcon size="medium" /></span>
|
||||
<AssistantText size="xlarge" :text="i18n.baseText('aiAssistant.assistant')" />
|
||||
{{ i18n.baseText('aiAssistant.newSessionModal.title.part2') }}
|
||||
</template>
|
||||
<template #content>
|
||||
<div :class="$style.container">
|
||||
<p>
|
||||
<n8n-text>{{ i18n.baseText('aiAssistant.newSessionModal.message') }}</n8n-text>
|
||||
</p>
|
||||
<p>
|
||||
<n8n-text>{{ i18n.baseText('aiAssistant.newSessionModal.question') }}</n8n-text>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div :class="$style.footer">
|
||||
<n8n-button :label="i18n.baseText('generic.cancel')" type="secondary" @click="close" />
|
||||
<n8n-button
|
||||
:label="i18n.baseText('aiAssistant.newSessionModal.confirm')"
|
||||
@click="startNewSession"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
p {
|
||||
line-height: normal;
|
||||
}
|
||||
p + p {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.assistantIcon {
|
||||
margin-right: var(--spacing-4xs);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import Assignment from './Assignment.vue';
|
||||
import { defaultSettings } from '@/__tests__/defaults';
|
||||
import { STORES } from '@/constants';
|
||||
import { merge } from 'lodash-es';
|
||||
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
|
||||
|
||||
const DEFAULT_SETUP = {
|
||||
pinia: createTestingPinia({
|
||||
initialState: { [STORES.SETTINGS]: { settings: merge({}, defaultSettings) } },
|
||||
}),
|
||||
props: {
|
||||
path: 'parameters.fields.0',
|
||||
modelValue: {
|
||||
name: '',
|
||||
type: 'string',
|
||||
value: '',
|
||||
},
|
||||
issues: [],
|
||||
},
|
||||
};
|
||||
|
||||
const renderComponent = createComponentRenderer(Assignment, DEFAULT_SETUP);
|
||||
|
||||
describe('Assignment.vue', () => {
|
||||
beforeEach(() => {
|
||||
createAppModals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupAppModals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('can edit name, type and value', async () => {
|
||||
const { getByTestId, baseElement, emitted } = renderComponent();
|
||||
|
||||
const nameField = getByTestId('assignment-name').querySelector('input') as HTMLInputElement;
|
||||
const valueField = getByTestId('assignment-value').querySelector('input') as HTMLInputElement;
|
||||
|
||||
expect(getByTestId('assignment')).toBeInTheDocument();
|
||||
expect(getByTestId('assignment-name')).toBeInTheDocument();
|
||||
expect(getByTestId('assignment-value')).toBeInTheDocument();
|
||||
expect(getByTestId('assignment-type-select')).toBeInTheDocument();
|
||||
|
||||
await userEvent.type(nameField, 'New name');
|
||||
await userEvent.type(valueField, 'New value');
|
||||
|
||||
await userEvent.click(baseElement.querySelectorAll('.option')[3]);
|
||||
|
||||
expect(emitted('update:model-value')[0]).toEqual([
|
||||
{ name: 'New name', type: 'array', value: 'New value' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('can remove itself', async () => {
|
||||
const { getByTestId, emitted } = renderComponent();
|
||||
|
||||
await userEvent.click(getByTestId('assignment-remove'));
|
||||
|
||||
expect(emitted('remove')).toEqual([[]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
import type { IUpdateInformation } from '@/Interface';
|
||||
import InputTriple from '@/components/InputTriple/InputTriple.vue';
|
||||
import ParameterInputFull from '@/components/ParameterInputFull.vue';
|
||||
import ParameterInputHint from '@/components/ParameterInputHint.vue';
|
||||
import ParameterIssues from '@/components/ParameterIssues.vue';
|
||||
import { useResolvedExpression } from '@/composables/useResolvedExpression';
|
||||
import useEnvironmentsStore from '@/stores/environments.ee.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import type { AssignmentValue, INodeProperties } from 'n8n-workflow';
|
||||
import { computed, ref } from 'vue';
|
||||
import TypeSelect from './TypeSelect.vue';
|
||||
import { N8nIconButton } from '@n8n/design-system';
|
||||
|
||||
interface Props {
|
||||
path: string;
|
||||
modelValue: AssignmentValue;
|
||||
issues: string[];
|
||||
hideType?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const assignment = ref<AssignmentValue>(props.modelValue);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:model-value': [value: AssignmentValue];
|
||||
remove: [];
|
||||
}>();
|
||||
|
||||
const ndvStore = useNDVStore();
|
||||
const environmentsStore = useEnvironmentsStore();
|
||||
|
||||
const assignmentTypeToNodeProperty = (
|
||||
type: string,
|
||||
): Partial<INodeProperties> & Pick<INodeProperties, 'type'> => {
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
return {
|
||||
type: 'options',
|
||||
default: false,
|
||||
options: [
|
||||
{ name: 'false', value: false },
|
||||
{ name: 'true', value: true },
|
||||
],
|
||||
};
|
||||
case 'array':
|
||||
case 'object':
|
||||
case 'any':
|
||||
return { type: 'string' };
|
||||
default:
|
||||
return { type } as INodeProperties;
|
||||
}
|
||||
};
|
||||
|
||||
const nameParameter = computed<INodeProperties>(() => ({
|
||||
name: 'name',
|
||||
displayName: 'Name',
|
||||
default: '',
|
||||
requiresDataPath: 'single',
|
||||
placeholder: 'name',
|
||||
type: 'string',
|
||||
}));
|
||||
|
||||
const valueParameter = computed<INodeProperties>(() => {
|
||||
return {
|
||||
name: 'value',
|
||||
displayName: 'Value',
|
||||
default: '',
|
||||
placeholder: 'value',
|
||||
...assignmentTypeToNodeProperty(assignment.value.type ?? 'string'),
|
||||
};
|
||||
});
|
||||
|
||||
const value = computed(() => assignment.value.value);
|
||||
|
||||
const resolvedAdditionalExpressionData = computed(() => {
|
||||
return { $vars: environmentsStore.variablesAsObject };
|
||||
});
|
||||
|
||||
const { resolvedExpressionString, isExpression } = useResolvedExpression({
|
||||
expression: value,
|
||||
additionalData: resolvedAdditionalExpressionData,
|
||||
});
|
||||
|
||||
const hint = computed(() => resolvedExpressionString.value);
|
||||
|
||||
const highlightHint = computed(() => Boolean(hint.value && ndvStore.getHoveringItem));
|
||||
|
||||
const onAssignmentNameChange = (update: IUpdateInformation): void => {
|
||||
assignment.value.name = update.value as string;
|
||||
};
|
||||
|
||||
const onAssignmentTypeChange = (update: string): void => {
|
||||
assignment.value.type = update;
|
||||
|
||||
if (update === 'boolean' && !isExpression.value) {
|
||||
assignment.value.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onAssignmentValueChange = (update: IUpdateInformation): void => {
|
||||
assignment.value.value = update.value as string;
|
||||
};
|
||||
|
||||
const onRemove = (): void => {
|
||||
emit('remove');
|
||||
};
|
||||
|
||||
const onBlur = (): void => {
|
||||
emit('update:model-value', assignment.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
[$style.wrapper]: true,
|
||||
[$style.hasIssues]: issues.length > 0,
|
||||
[$style.hasHint]: !!hint,
|
||||
}"
|
||||
data-test-id="assignment"
|
||||
>
|
||||
<N8nIconButton
|
||||
v-if="!isReadOnly"
|
||||
type="tertiary"
|
||||
text
|
||||
size="mini"
|
||||
icon="grip-vertical"
|
||||
:class="[$style.iconButton, $style.defaultTopPadding, 'drag-handle']"
|
||||
></N8nIconButton>
|
||||
<N8nIconButton
|
||||
v-if="!isReadOnly"
|
||||
type="tertiary"
|
||||
text
|
||||
size="mini"
|
||||
icon="trash"
|
||||
data-test-id="assignment-remove"
|
||||
:class="[$style.iconButton, $style.extraTopPadding]"
|
||||
@click="onRemove"
|
||||
></N8nIconButton>
|
||||
|
||||
<div :class="$style.inputs">
|
||||
<InputTriple middle-width="100px">
|
||||
<template #left>
|
||||
<ParameterInputFull
|
||||
:key="nameParameter.type"
|
||||
display-options
|
||||
hide-label
|
||||
hide-hint
|
||||
:is-read-only="isReadOnly"
|
||||
:parameter="nameParameter"
|
||||
:value="assignment.name"
|
||||
:path="`${path}.name`"
|
||||
data-test-id="assignment-name"
|
||||
@update="onAssignmentNameChange"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="!hideType" #middle>
|
||||
<TypeSelect
|
||||
:class="$style.select"
|
||||
:model-value="assignment.type ?? 'string'"
|
||||
:is-read-only="isReadOnly"
|
||||
@update:model-value="onAssignmentTypeChange"
|
||||
>
|
||||
</TypeSelect>
|
||||
</template>
|
||||
<template #right="{ breakpoint }">
|
||||
<div :class="$style.value">
|
||||
<ParameterInputFull
|
||||
:key="valueParameter.type"
|
||||
display-options
|
||||
hide-label
|
||||
hide-issues
|
||||
hide-hint
|
||||
is-assignment
|
||||
:is-read-only="isReadOnly"
|
||||
:options-position="breakpoint === 'default' ? 'top' : 'bottom'"
|
||||
:parameter="valueParameter"
|
||||
:value="assignment.value"
|
||||
:path="`${path}.value`"
|
||||
data-test-id="assignment-value"
|
||||
@update="onAssignmentValueChange"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<ParameterInputHint
|
||||
data-test-id="parameter-expression-preview-value"
|
||||
:class="$style.hint"
|
||||
:highlight="highlightHint"
|
||||
:hint="hint"
|
||||
single-line
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</InputTriple>
|
||||
</div>
|
||||
|
||||
<div :class="$style.status">
|
||||
<ParameterIssues v-if="issues.length > 0" :issues="issues" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-4xs);
|
||||
|
||||
&.hasIssues {
|
||||
--input-border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
&.hasHint {
|
||||
padding-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.iconButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
|
||||
> div {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.value {
|
||||
position: relative;
|
||||
|
||||
.hint {
|
||||
position: absolute;
|
||||
bottom: calc(var(--spacing-s) * -1);
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-in;
|
||||
color: var(--icon-base-color);
|
||||
}
|
||||
.extraTopPadding {
|
||||
top: calc(20px + var(--spacing-l));
|
||||
}
|
||||
|
||||
.defaultTopPadding {
|
||||
top: var(--spacing-l);
|
||||
}
|
||||
|
||||
.status {
|
||||
align-self: flex-start;
|
||||
padding-top: 28px;
|
||||
}
|
||||
|
||||
.statusIcon {
|
||||
padding-left: var(--spacing-4xs);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { fireEvent, within } from '@testing-library/vue';
|
||||
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
|
||||
import AssignmentCollection from './AssignmentCollection.vue';
|
||||
import { STORES } from '@/constants';
|
||||
import { cleanupAppModals, createAppModals, SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
||||
|
||||
const DEFAULT_SETUP = {
|
||||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.SETTINGS]: SETTINGS_STORE_DEFAULT_STATE,
|
||||
},
|
||||
stubActions: false,
|
||||
}),
|
||||
props: {
|
||||
path: 'parameters.fields',
|
||||
node: {
|
||||
parameters: {},
|
||||
id: 'f63efb2d-3cc5-4500-89f9-b39aab19baf5',
|
||||
name: 'Edit Fields',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.3,
|
||||
position: [1120, 380],
|
||||
credentials: {},
|
||||
disabled: false,
|
||||
},
|
||||
parameter: { name: 'fields', displayName: 'Fields To Set' },
|
||||
value: {},
|
||||
},
|
||||
};
|
||||
|
||||
const renderComponent = createComponentRenderer(AssignmentCollection, DEFAULT_SETUP);
|
||||
|
||||
const getInput = (e: HTMLElement): HTMLInputElement => {
|
||||
return e.querySelector('input') as HTMLInputElement;
|
||||
};
|
||||
|
||||
const getAssignmentType = (assignment: HTMLElement): string => {
|
||||
return getInput(within(assignment).getByTestId('assignment-type-select')).value;
|
||||
};
|
||||
|
||||
async function dropAssignment({
|
||||
key,
|
||||
value,
|
||||
dropArea,
|
||||
}: {
|
||||
key: string;
|
||||
value: unknown;
|
||||
dropArea: HTMLElement;
|
||||
}): Promise<void> {
|
||||
useNDVStore().draggableStartDragging({
|
||||
type: 'mapping',
|
||||
data: `{{ $json.${key} }}`,
|
||||
dimensions: null,
|
||||
});
|
||||
|
||||
vitest.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(value as never);
|
||||
|
||||
await userEvent.hover(dropArea);
|
||||
await fireEvent.mouseUp(dropArea);
|
||||
}
|
||||
|
||||
describe('AssignmentCollection.vue', () => {
|
||||
beforeEach(() => {
|
||||
createAppModals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanupAppModals();
|
||||
});
|
||||
|
||||
it('renders empty state properly', async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent();
|
||||
expect(getByTestId('assignment-collection-fields')).toBeInTheDocument();
|
||||
expect(getByTestId('assignment-collection-fields')).toHaveClass('empty');
|
||||
expect(getByTestId('assignment-collection-drop-area')).toHaveTextContent(
|
||||
'Drag input fields here',
|
||||
);
|
||||
expect(queryByTestId('assignment')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can add and remove assignments', async () => {
|
||||
const { getByTestId, findAllByTestId } = renderComponent();
|
||||
|
||||
await userEvent.click(getByTestId('assignment-collection-drop-area'));
|
||||
await userEvent.click(getByTestId('assignment-collection-drop-area'));
|
||||
|
||||
let assignments = await findAllByTestId('assignment');
|
||||
|
||||
expect(assignments.length).toEqual(2);
|
||||
|
||||
await userEvent.type(getInput(within(assignments[1]).getByTestId('assignment-name')), 'second');
|
||||
await userEvent.type(
|
||||
getInput(within(assignments[1]).getByTestId('assignment-value')),
|
||||
'secondValue',
|
||||
);
|
||||
await userEvent.click(within(assignments[0]).getByTestId('assignment-remove'));
|
||||
|
||||
assignments = await findAllByTestId('assignment');
|
||||
expect(assignments.length).toEqual(1);
|
||||
expect(getInput(within(assignments[0]).getByTestId('assignment-value'))).toHaveValue(
|
||||
'secondValue',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not break with saved assignments that have no ID (legacy)', async () => {
|
||||
const { findAllByTestId } = renderComponent({
|
||||
props: {
|
||||
value: {
|
||||
assignments: [
|
||||
{ name: 'key1', value: 'value1', type: 'string' },
|
||||
{ name: 'key2', value: 'value2', type: 'string' },
|
||||
{ name: 'key3', value: 'value3', type: 'string' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let assignments = await findAllByTestId('assignment');
|
||||
|
||||
expect(assignments.length).toEqual(3);
|
||||
|
||||
// Remove 2nd assignment
|
||||
await userEvent.click(within(assignments[1]).getByTestId('assignment-remove'));
|
||||
assignments = await findAllByTestId('assignment');
|
||||
expect(assignments.length).toEqual(2);
|
||||
expect(getInput(within(assignments[0]).getByTestId('assignment-value'))).toHaveValue('value1');
|
||||
expect(getInput(within(assignments[1]).getByTestId('assignment-value'))).toHaveValue('value3');
|
||||
});
|
||||
|
||||
it('can add assignments by drag and drop (and infer type)', async () => {
|
||||
const { getByTestId, findAllByTestId } = renderComponent();
|
||||
const dropArea = getByTestId('drop-area');
|
||||
|
||||
await dropAssignment({ key: 'boolKey', value: true, dropArea });
|
||||
await dropAssignment({ key: 'stringKey', value: 'stringValue', dropArea });
|
||||
await dropAssignment({ key: 'numberKey', value: 25, dropArea });
|
||||
await dropAssignment({ key: 'objectKey', value: {}, dropArea });
|
||||
await dropAssignment({ key: 'arrayKey', value: [], dropArea });
|
||||
|
||||
const assignments = await findAllByTestId('assignment');
|
||||
|
||||
expect(assignments.length).toBe(5);
|
||||
expect(getAssignmentType(assignments[0])).toEqual('Boolean');
|
||||
expect(getAssignmentType(assignments[1])).toEqual('String');
|
||||
expect(getAssignmentType(assignments[2])).toEqual('Number');
|
||||
expect(getAssignmentType(assignments[3])).toEqual('Object');
|
||||
expect(getAssignmentType(assignments[4])).toEqual('Array');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
<script setup lang="ts">
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import type {
|
||||
AssignmentCollectionValue,
|
||||
AssignmentValue,
|
||||
INode,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import DropArea from '../DropArea/DropArea.vue';
|
||||
import ParameterOptions from '../ParameterOptions.vue';
|
||||
import Assignment from './Assignment.vue';
|
||||
import { inputDataToAssignments, typeFromExpression } from './utils';
|
||||
import { propertyNameFromExpression } from '@/utils/mappingUtils';
|
||||
import Draggable from 'vuedraggable';
|
||||
|
||||
interface Props {
|
||||
parameter: INodeProperties;
|
||||
value: AssignmentCollectionValue;
|
||||
path: string;
|
||||
node: INode | null;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { isReadOnly: false });
|
||||
|
||||
const emit = defineEmits<{
|
||||
valueChanged: [value: { name: string; node: string; value: AssignmentCollectionValue }];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const state = reactive<{ paramValue: AssignmentCollectionValue }>({
|
||||
paramValue: {
|
||||
assignments:
|
||||
props.value.assignments?.map((assignment) => {
|
||||
if (!assignment.id) assignment.id = crypto.randomUUID();
|
||||
return assignment;
|
||||
}) ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
const ndvStore = useNDVStore();
|
||||
const { callDebounced } = useDebounce();
|
||||
|
||||
const issues = computed(() => {
|
||||
if (!ndvStore.activeNode) return {};
|
||||
return ndvStore.activeNode?.issues?.parameters ?? {};
|
||||
});
|
||||
|
||||
const empty = computed(() => state.paramValue.assignments.length === 0);
|
||||
const activeDragField = computed(() => propertyNameFromExpression(ndvStore.draggableData));
|
||||
const inputData = computed(() => ndvStore.ndvInputData?.[0]?.json);
|
||||
const actions = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: i18n.baseText('assignment.addAll'),
|
||||
value: 'addAll',
|
||||
disabled: !inputData.value,
|
||||
},
|
||||
{
|
||||
label: i18n.baseText('assignment.clearAll'),
|
||||
value: 'clearAll',
|
||||
disabled: state.paramValue.assignments.length === 0,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
watch(state.paramValue, (value) => {
|
||||
void callDebounced(
|
||||
() => {
|
||||
emit('valueChanged', { name: props.path, value, node: props.node?.name as string });
|
||||
},
|
||||
{ debounceTime: 1000 },
|
||||
);
|
||||
});
|
||||
|
||||
function addAssignment(): void {
|
||||
state.paramValue.assignments.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'string',
|
||||
});
|
||||
}
|
||||
|
||||
function dropAssignment(expression: string): void {
|
||||
state.paramValue.assignments.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: propertyNameFromExpression(expression),
|
||||
value: `=${expression}`,
|
||||
type: typeFromExpression(expression),
|
||||
});
|
||||
}
|
||||
|
||||
function onAssignmentUpdate(index: number, value: AssignmentValue): void {
|
||||
state.paramValue.assignments[index] = value;
|
||||
}
|
||||
|
||||
function onAssignmentRemove(index: number): void {
|
||||
state.paramValue.assignments.splice(index, 1);
|
||||
}
|
||||
|
||||
function getIssues(index: number): string[] {
|
||||
return issues.value[`${props.parameter.name}.${index}`] ?? [];
|
||||
}
|
||||
|
||||
function optionSelected(action: string) {
|
||||
if (action === 'clearAll') {
|
||||
state.paramValue.assignments = [];
|
||||
} else if (action === 'addAll' && inputData.value) {
|
||||
const newAssignments = inputDataToAssignments(inputData.value);
|
||||
state.paramValue.assignments = state.paramValue.assignments.concat(newAssignments);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="{ [$style.assignmentCollection]: true, [$style.empty]: empty }"
|
||||
:data-test-id="`assignment-collection-${parameter.name}`"
|
||||
>
|
||||
<n8n-input-label
|
||||
:label="parameter.displayName"
|
||||
:show-expression-selector="false"
|
||||
size="small"
|
||||
underline
|
||||
color="text-dark"
|
||||
>
|
||||
<template #options>
|
||||
<ParameterOptions
|
||||
:parameter="parameter"
|
||||
:value="value"
|
||||
:custom-actions="actions"
|
||||
:is-read-only="isReadOnly"
|
||||
:show-expression-selector="false"
|
||||
@update:model-value="optionSelected"
|
||||
/>
|
||||
</template>
|
||||
</n8n-input-label>
|
||||
<div :class="$style.content">
|
||||
<div :class="$style.assignments">
|
||||
<Draggable
|
||||
v-model="state.paramValue.assignments"
|
||||
item-key="id"
|
||||
handle=".drag-handle"
|
||||
:drag-class="$style.dragging"
|
||||
:ghost-class="$style.ghost"
|
||||
>
|
||||
<template #item="{ index, element: assignment }">
|
||||
<Assignment
|
||||
:model-value="assignment"
|
||||
:index="index"
|
||||
:path="`${path}.assignments.${index}`"
|
||||
:issues="getIssues(index)"
|
||||
:class="$style.assignment"
|
||||
:is-read-only="isReadOnly"
|
||||
@update:model-value="(value) => onAssignmentUpdate(index, value)"
|
||||
@remove="() => onAssignmentRemove(index)"
|
||||
>
|
||||
</Assignment>
|
||||
</template>
|
||||
</Draggable>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isReadOnly"
|
||||
:class="$style.dropAreaWrapper"
|
||||
data-test-id="assignment-collection-drop-area"
|
||||
@click="addAssignment"
|
||||
>
|
||||
<DropArea :sticky-offset="empty ? [-4, 32] : [92, 0]" @drop="dropAssignment">
|
||||
<template #default="{ active, droppable }">
|
||||
<div :class="{ [$style.active]: active, [$style.droppable]: droppable }">
|
||||
<div v-if="droppable" :class="$style.dropArea">
|
||||
<span>{{ i18n.baseText('assignment.dropField') }}</span>
|
||||
<span :class="$style.activeField">{{ activeDragField }}</span>
|
||||
</div>
|
||||
<div v-else :class="$style.dropArea">
|
||||
<span>{{ i18n.baseText('assignment.dragFields') }}</span>
|
||||
<span :class="$style.or">{{ i18n.baseText('assignment.or') }}</span>
|
||||
<span :class="$style.add">{{ i18n.baseText('assignment.add') }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DropArea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.assignmentCollection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
gap: var(--spacing-l);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.assignments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4xs);
|
||||
}
|
||||
|
||||
.assignment {
|
||||
padding-left: var(--spacing-l);
|
||||
}
|
||||
|
||||
.dropAreaWrapper {
|
||||
cursor: pointer;
|
||||
|
||||
&:not(.empty .dropAreaWrapper) {
|
||||
padding-left: var(--spacing-l);
|
||||
}
|
||||
|
||||
&:hover .add {
|
||||
color: var(--color-primary-shade-1);
|
||||
}
|
||||
}
|
||||
|
||||
.dropArea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-dark);
|
||||
gap: 1ch;
|
||||
min-height: 24px;
|
||||
|
||||
> span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.or {
|
||||
color: var(--color-text-light);
|
||||
font-size: var(--font-size-2xs);
|
||||
}
|
||||
|
||||
.add {
|
||||
color: var(--color-primary);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.activeField {
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-ndv-droppable-parameter);
|
||||
}
|
||||
|
||||
.active {
|
||||
.activeField {
|
||||
color: var(--color-success);
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
.dropArea {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3xs);
|
||||
min-height: 20vh;
|
||||
}
|
||||
|
||||
.droppable .dropArea {
|
||||
flex-direction: row;
|
||||
gap: 1ch;
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
.ghost,
|
||||
.dragging {
|
||||
border-radius: var(--border-radius-base);
|
||||
padding-right: var(--spacing-xs);
|
||||
padding-bottom: var(--spacing-xs);
|
||||
}
|
||||
.ghost {
|
||||
background-color: var(--color-background-base);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.dragging {
|
||||
background-color: var(--color-background-xlight);
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import TypeSelect from './TypeSelect.vue';
|
||||
|
||||
const DEFAULT_SETUP = {
|
||||
pinia: createTestingPinia(),
|
||||
props: {
|
||||
modelValue: 'boolean',
|
||||
},
|
||||
};
|
||||
|
||||
const renderComponent = createComponentRenderer(TypeSelect, DEFAULT_SETUP);
|
||||
|
||||
describe('TypeSelect.vue', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders default state correctly and emit events', async () => {
|
||||
const { getByTestId, baseElement, emitted } = renderComponent();
|
||||
expect(getByTestId('assignment-type-select')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(
|
||||
getByTestId('assignment-type-select').querySelector('.select-trigger') as HTMLElement,
|
||||
);
|
||||
|
||||
const options = baseElement.querySelectorAll('.option');
|
||||
expect(options.length).toEqual(5);
|
||||
|
||||
expect(options[0]).toHaveTextContent('String');
|
||||
expect(options[1]).toHaveTextContent('Number');
|
||||
expect(options[2]).toHaveTextContent('Boolean');
|
||||
expect(options[3]).toHaveTextContent('Array');
|
||||
expect(options[4]).toHaveTextContent('Object');
|
||||
|
||||
await userEvent.click(options[2]);
|
||||
|
||||
expect(emitted('update:model-value')).toEqual([['boolean']]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { BaseTextKey } from '@/plugins/i18n';
|
||||
import { ASSIGNMENT_TYPES } from './constants';
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
modelValue: string;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:model-value': [type: string];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const types = ASSIGNMENT_TYPES;
|
||||
|
||||
const icon = computed(() => types.find((type) => type.type === props.modelValue)?.icon ?? 'cube');
|
||||
|
||||
const onTypeChange = (type: string): void => {
|
||||
emit('update:model-value', type);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n8n-select
|
||||
data-test-id="assignment-type-select"
|
||||
size="small"
|
||||
:model-value="modelValue"
|
||||
:disabled="isReadOnly"
|
||||
@update:model-value="onTypeChange"
|
||||
>
|
||||
<template #prefix>
|
||||
<n8n-icon :class="$style.icon" :icon="icon" color="text-light" size="small" />
|
||||
</template>
|
||||
<n8n-option
|
||||
v-for="option in types"
|
||||
:key="option.type"
|
||||
:value="option.type"
|
||||
:label="i18n.baseText(`type.${option.type}` as BaseTextKey)"
|
||||
:class="$style.option"
|
||||
>
|
||||
<n8n-icon
|
||||
:icon="option.icon"
|
||||
:color="modelValue === option.type ? 'primary' : 'text-light'"
|
||||
size="small"
|
||||
/>
|
||||
<span>{{ i18n.baseText(`type.${option.type}` as BaseTextKey) }}</span>
|
||||
</n8n-option>
|
||||
</n8n-select>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.icon {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
gap: var(--spacing-2xs);
|
||||
align-items: center;
|
||||
font-size: var(--font-size-s);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
export const ASSIGNMENT_TYPES = [
|
||||
{ type: 'string', icon: 'font' },
|
||||
{ type: 'number', icon: 'hashtag' },
|
||||
{ type: 'boolean', icon: 'check-square' },
|
||||
{ type: 'array', icon: 'list' },
|
||||
{ type: 'object', icon: 'cube' },
|
||||
];
|
||||
@@ -0,0 +1,57 @@
|
||||
import { isObject } from 'lodash-es';
|
||||
import type { AssignmentValue, IDataObject } from 'n8n-workflow';
|
||||
import { resolveParameter } from '@/composables/useWorkflowHelpers';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export function inferAssignmentType(value: unknown): string {
|
||||
if (typeof value === 'boolean') return 'boolean';
|
||||
if (typeof value === 'number') return 'number';
|
||||
if (typeof value === 'string') return 'string';
|
||||
if (Array.isArray(value)) return 'array';
|
||||
if (isObject(value)) return 'object';
|
||||
return 'string';
|
||||
}
|
||||
|
||||
export function typeFromExpression(expression: string): string {
|
||||
try {
|
||||
const resolved = resolveParameter(`=${expression}`);
|
||||
return inferAssignmentType(resolved);
|
||||
} catch (error) {
|
||||
return 'string';
|
||||
}
|
||||
}
|
||||
|
||||
export function inputDataToAssignments(input: IDataObject): AssignmentValue[] {
|
||||
const assignments: AssignmentValue[] = [];
|
||||
|
||||
function processValue(value: IDataObject, path: Array<string | number> = []) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((element, index) => {
|
||||
processValue(element, [...path, index]);
|
||||
});
|
||||
} else if (isObject(value)) {
|
||||
for (const [key, objectValue] of Object.entries(value)) {
|
||||
processValue(objectValue as IDataObject, [...path, key]);
|
||||
}
|
||||
} else {
|
||||
const stringPath = path.reduce((fullPath: string, part) => {
|
||||
if (typeof part === 'number') {
|
||||
return `${fullPath}[${part}]`;
|
||||
}
|
||||
return `${fullPath}.${part}`;
|
||||
}, '$json');
|
||||
|
||||
const expression = `={{ ${stringPath} }}`;
|
||||
assignments.push({
|
||||
id: uuid(),
|
||||
name: stringPath.replace('$json.', ''),
|
||||
value: expression,
|
||||
type: inferAssignmentType(value),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
processValue(input);
|
||||
|
||||
return assignments;
|
||||
}
|
||||
51
packages/frontend/editor-ui/src/components/Badge.vue
Normal file
51
packages/frontend/editor-ui/src/components/Badge.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
export default {
|
||||
props: ['text', 'type'],
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-tag
|
||||
v-if="type === 'danger'"
|
||||
type="danger"
|
||||
size="small"
|
||||
:class="$style['danger']"
|
||||
:disable-transitions="true"
|
||||
>
|
||||
{{ text }}
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="type === 'warning'"
|
||||
size="small"
|
||||
:class="$style['warning']"
|
||||
:disable-transitions="true"
|
||||
>
|
||||
{{ text }}
|
||||
</el-tag>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
max-height: 18px;
|
||||
font-weight: 400;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.danger {
|
||||
composes: badge;
|
||||
color: $badge-danger-color;
|
||||
background-color: $badge-danger-background-color;
|
||||
border-color: $badge-danger-border-color;
|
||||
}
|
||||
|
||||
.warning {
|
||||
composes: badge;
|
||||
background-color: $badge-warning-background-color;
|
||||
color: $badge-warning-color;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
130
packages/frontend/editor-ui/src/components/Banner.vue
Normal file
130
packages/frontend/editor-ui/src/components/Banner.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
interface Props {
|
||||
theme: 'success' | 'danger';
|
||||
message: string;
|
||||
buttonLabel?: string;
|
||||
buttonLoadingLabel?: string;
|
||||
buttonTitle?: string;
|
||||
details?: string;
|
||||
buttonLoading?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
buttonLoading: false,
|
||||
buttonLabel: '',
|
||||
buttonLoadingLabel: '',
|
||||
buttonTitle: '',
|
||||
details: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
|
||||
const expanded = ref(false);
|
||||
|
||||
const expand = () => {
|
||||
expanded.value = true;
|
||||
};
|
||||
|
||||
const onClick = () => {
|
||||
expanded.value = false;
|
||||
emit('click');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-tag :type="theme" :disable-transitions="true" :class="$style.container">
|
||||
<font-awesome-icon
|
||||
:icon="theme === 'success' ? 'check-circle' : 'exclamation-triangle'"
|
||||
:class="theme === 'success' ? $style.icon : $style.dangerIcon"
|
||||
/>
|
||||
<div :class="$style.banner">
|
||||
<div :class="$style.content">
|
||||
<div>
|
||||
<span :class="theme === 'success' ? $style.message : $style.dangerMessage">
|
||||
{{ message }}
|
||||
</span>
|
||||
<n8n-link v-if="details && !expanded" :bold="true" size="small" @click="expand">
|
||||
<span :class="$style.moreDetails">More details</span>
|
||||
</n8n-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot v-if="$slots.button" name="button" />
|
||||
<n8n-button
|
||||
v-else-if="buttonLabel"
|
||||
:label="buttonLoading && buttonLoadingLabel ? buttonLoadingLabel : buttonLabel"
|
||||
:title="buttonTitle"
|
||||
:type="theme"
|
||||
:loading="buttonLoading"
|
||||
size="small"
|
||||
outline
|
||||
@click.stop="onClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="expanded" :class="$style.details">
|
||||
{{ details }}
|
||||
</div>
|
||||
</el-tag>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
.dangerIcon {
|
||||
composes: icon;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding-left: 40px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.message {
|
||||
white-space: normal;
|
||||
line-height: var(--font-line-height-regular);
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.dangerMessage {
|
||||
composes: message;
|
||||
color: var(--color-callout-danger-font);
|
||||
}
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-grow: 1;
|
||||
min-height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.details {
|
||||
composes: message;
|
||||
margin-top: var(--spacing-3xs);
|
||||
color: var(--color-text-base);
|
||||
font-size: var(--font-size-2xs);
|
||||
}
|
||||
|
||||
.moreDetails {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
</style>
|
||||
153
packages/frontend/editor-ui/src/components/BannersStack.test.ts
Normal file
153
packages/frontend/editor-ui/src/components/BannersStack.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { merge } from 'lodash-es';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
||||
import { ROLE, STORES } from '@/constants';
|
||||
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import BannerStack from '@/components/banners/BannerStack.vue';
|
||||
import type { RenderOptions } from '@/__tests__/render';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
|
||||
let uiStore: ReturnType<typeof useUIStore>;
|
||||
|
||||
const initialState = {
|
||||
[STORES.SETTINGS]: {
|
||||
settings: merge({}, SETTINGS_STORE_DEFAULT_STATE.settings),
|
||||
},
|
||||
[STORES.UI]: {
|
||||
bannerStack: ['TRIAL_OVER', 'V1', 'NON_PRODUCTION_LICENSE', 'EMAIL_CONFIRMATION'],
|
||||
},
|
||||
[STORES.USERS]: {
|
||||
currentUserId: 'aaa-bbb',
|
||||
usersById: {
|
||||
'aaa-bbb': {
|
||||
id: 'aaa-bbb',
|
||||
role: ROLE.Owner,
|
||||
},
|
||||
'bbb-bbb': {
|
||||
id: 'bbb-bbb',
|
||||
role: ROLE.Member,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const defaultRenderOptions: RenderOptions = {
|
||||
pinia: createTestingPinia({ initialState }),
|
||||
};
|
||||
|
||||
const renderComponent = createComponentRenderer(BannerStack, defaultRenderOptions);
|
||||
|
||||
describe('BannerStack', () => {
|
||||
beforeEach(() => {
|
||||
uiStore = useUIStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render banner with the highest priority', async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent();
|
||||
|
||||
const bannerStack = getByTestId('banner-stack');
|
||||
expect(bannerStack).toBeInTheDocument();
|
||||
// Only V1 banner should be visible
|
||||
expect(getByTestId('banners-V1')).toBeInTheDocument();
|
||||
expect(queryByTestId('banners-TRIAL_OVER')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should dismiss banner on click', async () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
const dismissBannerSpy = vi.spyOn(uiStore, 'dismissBanner').mockImplementation(async () => {});
|
||||
expect(getByTestId('banners-V1')).toBeInTheDocument();
|
||||
const closeTrialBannerButton = getByTestId('banner-V1-close');
|
||||
expect(closeTrialBannerButton).toBeInTheDocument();
|
||||
await userEvent.click(closeTrialBannerButton);
|
||||
expect(dismissBannerSpy).toHaveBeenCalledWith('V1');
|
||||
});
|
||||
|
||||
it('should permanently dismiss banner on click', async () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
const dismissBannerSpy = vi.spyOn(uiStore, 'dismissBanner').mockImplementation(async () => {});
|
||||
|
||||
const permanentlyDismissBannerLink = getByTestId('banner-confirm-v1');
|
||||
expect(permanentlyDismissBannerLink).toBeInTheDocument();
|
||||
await userEvent.click(permanentlyDismissBannerLink);
|
||||
expect(dismissBannerSpy).toHaveBeenCalledWith('V1', 'permanent');
|
||||
});
|
||||
|
||||
it('should not render permanent dismiss link if user is not owner', async () => {
|
||||
const { queryByTestId } = renderComponent({
|
||||
pinia: createTestingPinia({
|
||||
initialState: merge(initialState, {
|
||||
[STORES.USERS]: {
|
||||
currentUserId: 'bbb-bbb',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(queryByTestId('banner-confirm-v1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should send email confirmation request from the banner', async () => {
|
||||
const { getByTestId, getByText } = renderComponent({
|
||||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
...initialState,
|
||||
[STORES.UI]: {
|
||||
bannerStack: ['EMAIL_CONFIRMATION'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const confirmEmailSpy = vi.spyOn(useUsersStore(), 'sendConfirmationEmail');
|
||||
getByTestId('confirm-email-button').click();
|
||||
await waitFor(() => expect(confirmEmailSpy).toHaveBeenCalled());
|
||||
await waitFor(() => {
|
||||
expect(getByText('Confirmation email sent')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error message if email confirmation fails', async () => {
|
||||
const ERROR_MESSAGE = 'Something went wrong';
|
||||
const { getByTestId, getByText } = renderComponent({
|
||||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
...initialState,
|
||||
[STORES.UI]: {
|
||||
bannerStack: ['EMAIL_CONFIRMATION'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const confirmEmailSpy = vi
|
||||
.spyOn(useUsersStore(), 'sendConfirmationEmail')
|
||||
.mockImplementation(() => {
|
||||
throw new Error(ERROR_MESSAGE);
|
||||
});
|
||||
getByTestId('confirm-email-button').click();
|
||||
await waitFor(() => expect(confirmEmailSpy).toHaveBeenCalled());
|
||||
await waitFor(() => {
|
||||
expect(getByText(ERROR_MESSAGE)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render empty banner stack when there are no banners to display', async () => {
|
||||
const { queryByTestId } = renderComponent({
|
||||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
...initialState,
|
||||
[STORES.UI]: {
|
||||
bannerStack: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(queryByTestId('banner-stack')).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useBecomeTemplateCreatorStore } from './becomeTemplateCreatorStore';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const i18n = useI18n();
|
||||
const store = useBecomeTemplateCreatorStore();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const onClick = () => {
|
||||
telemetry.track('User clicked become creator CTA');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="store.showBecomeCreatorCta"
|
||||
:class="$style.container"
|
||||
data-test-id="become-template-creator-cta"
|
||||
>
|
||||
<div :class="$style.textAndCloseButton">
|
||||
<p :class="$style.text">
|
||||
{{ i18n.baseText('becomeCreator.text') }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
:class="$style.closeButton"
|
||||
data-test-id="close-become-template-creator-cta"
|
||||
@click="store.dismissCta()"
|
||||
>
|
||||
<n8n-icon icon="times" size="xsmall" :title="i18n.baseText('generic.close')" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<n8n-button
|
||||
:class="$style.becomeCreatorButton"
|
||||
:label="i18n.baseText('becomeCreator.buttonText')"
|
||||
size="xmini"
|
||||
type="secondary"
|
||||
element="a"
|
||||
href="https://creators.n8n.io/hub"
|
||||
target="_blank"
|
||||
@click="onClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--color-background-light);
|
||||
border: var(--border-base);
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.textAndCloseButton {
|
||||
display: flex;
|
||||
margin-top: var(--spacing-xs);
|
||||
margin-left: var(--spacing-s);
|
||||
margin-right: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-3xs);
|
||||
line-height: var(--font-line-height-compact);
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
flex: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--spacing-2xs);
|
||||
height: var(--spacing-2xs);
|
||||
border: none;
|
||||
color: var(--color-text-light);
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.becomeCreatorButton {
|
||||
margin: var(--spacing-s);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { STORES } from '@/constants';
|
||||
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||
import { useStorage } from '@/composables/useStorage';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { getBecomeCreatorCta } from '@/api/ctas';
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'N8N_BECOME_TEMPLATE_CREATOR_CTA_DISMISSED_AT';
|
||||
const RESHOW_DISMISSED_AFTER_DAYS = 30;
|
||||
const POLL_INTERVAL_IN_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
export const useBecomeTemplateCreatorStore = defineStore(STORES.BECOME_TEMPLATE_CREATOR, () => {
|
||||
const cloudPlanStore = useCloudPlanStore();
|
||||
const rootStore = useRootStore();
|
||||
|
||||
//#region State
|
||||
|
||||
const dismissedAt = useStorage(LOCAL_STORAGE_KEY);
|
||||
const ctaMeetsCriteria = ref(false);
|
||||
const monitorCtasTimer = ref<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
//#endregion State
|
||||
|
||||
//#region Computed
|
||||
|
||||
const isDismissed = computed(() => {
|
||||
return dismissedAt.value ? !hasEnoughTimePassedSinceDismissal(dismissedAt.value) : false;
|
||||
});
|
||||
|
||||
const showBecomeCreatorCta = computed(() => {
|
||||
return ctaMeetsCriteria.value && !cloudPlanStore.userIsTrialing && !isDismissed.value;
|
||||
});
|
||||
|
||||
//#endregion Computed
|
||||
|
||||
//#region Actions
|
||||
|
||||
const dismissCta = () => {
|
||||
dismissedAt.value = DateTime.now().toISO();
|
||||
};
|
||||
|
||||
const fetchBecomeCreatorCta = async () => {
|
||||
const becomeCreatorCta = await getBecomeCreatorCta(rootStore.restApiContext);
|
||||
|
||||
ctaMeetsCriteria.value = becomeCreatorCta;
|
||||
};
|
||||
|
||||
const fetchUserCtasIfNeeded = async () => {
|
||||
if (isDismissed.value || cloudPlanStore.userIsTrialing || ctaMeetsCriteria.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fetchBecomeCreatorCta();
|
||||
};
|
||||
|
||||
const startMonitoringCta = () => {
|
||||
if (monitorCtasTimer.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initial check after 1s so we don't bombard the API immediately during startup
|
||||
setTimeout(fetchUserCtasIfNeeded, 1000);
|
||||
|
||||
monitorCtasTimer.value = setInterval(fetchUserCtasIfNeeded, POLL_INTERVAL_IN_MS);
|
||||
};
|
||||
|
||||
const stopMonitoringCta = () => {
|
||||
if (!monitorCtasTimer.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(monitorCtasTimer.value);
|
||||
monitorCtasTimer.value = null;
|
||||
};
|
||||
|
||||
//#endregion Actions
|
||||
|
||||
return {
|
||||
showBecomeCreatorCta,
|
||||
dismissCta,
|
||||
startMonitoringCta,
|
||||
stopMonitoringCta,
|
||||
};
|
||||
});
|
||||
|
||||
function hasEnoughTimePassedSinceDismissal(dismissedAt: string) {
|
||||
const reshowAtTime = DateTime.fromISO(dismissedAt).plus({
|
||||
days: RESHOW_DISMISSED_AFTER_DAYS,
|
||||
});
|
||||
|
||||
return reshowAtTime <= DateTime.now();
|
||||
}
|
||||
122
packages/frontend/editor-ui/src/components/BinaryDataDisplay.vue
Normal file
122
packages/frontend/editor-ui/src/components/BinaryDataDisplay.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { IBinaryData, IRunData } from 'n8n-workflow';
|
||||
import BinaryDataDisplayEmbed from '@/components/BinaryDataDisplayEmbed.vue';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const props = defineProps<{
|
||||
displayData: IBinaryData;
|
||||
windowVisible: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const workflowRunData = computed<IRunData | null>(() => {
|
||||
const workflowExecution = workflowsStore.getWorkflowExecution;
|
||||
if (workflowExecution === null) {
|
||||
return null;
|
||||
}
|
||||
const executionData = workflowExecution.data;
|
||||
return executionData ? executionData.resultData.runData : null;
|
||||
});
|
||||
|
||||
const binaryData = computed<IBinaryData | null>(() => {
|
||||
if (
|
||||
typeof props.displayData.node !== 'string' ||
|
||||
typeof props.displayData.key !== 'string' ||
|
||||
typeof props.displayData.runIndex !== 'number' ||
|
||||
typeof props.displayData.index !== 'number' ||
|
||||
typeof props.displayData.outputIndex !== 'number'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const binaryDataLocal = nodeHelpers.getBinaryData(
|
||||
workflowRunData.value,
|
||||
props.displayData.node,
|
||||
props.displayData.runIndex,
|
||||
props.displayData.outputIndex,
|
||||
);
|
||||
|
||||
if (binaryDataLocal.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
props.displayData.index >= binaryDataLocal.length ||
|
||||
binaryDataLocal[props.displayData.index][props.displayData.key] === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const binaryDataItem: IBinaryData =
|
||||
binaryDataLocal[props.displayData.index][props.displayData.key];
|
||||
|
||||
return binaryDataItem;
|
||||
});
|
||||
|
||||
function closeWindow() {
|
||||
// Handle the close externally as the visible parameter is an external prop
|
||||
// and is so not allowed to be changed here.
|
||||
emit('close');
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="windowVisible" :class="['binary-data-window', binaryData?.fileType]">
|
||||
<n8n-button
|
||||
size="small"
|
||||
class="binary-data-window-back"
|
||||
:title="i18n.baseText('binaryDataDisplay.backToOverviewPage')"
|
||||
icon="arrow-left"
|
||||
:label="i18n.baseText('binaryDataDisplay.backToList')"
|
||||
@click.stop="closeWindow"
|
||||
/>
|
||||
|
||||
<div class="binary-data-window-wrapper">
|
||||
<div v-if="!binaryData">
|
||||
{{ i18n.baseText('binaryDataDisplay.noDataFoundToDisplay') }}
|
||||
</div>
|
||||
<BinaryDataDisplayEmbed v-else :binary-data="binaryData" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.binary-data-window {
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: calc(100% - 50px);
|
||||
background-color: var(--color-run-data-background);
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
|
||||
&.json {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.binary-data-window-wrapper {
|
||||
margin-top: 0.5em;
|
||||
padding: 0 1em;
|
||||
height: calc(100% - 50px);
|
||||
|
||||
.el-row,
|
||||
.el-col {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import type { IBinaryData } from 'n8n-workflow';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import VueJsonPretty from 'vue-json-pretty';
|
||||
import RunDataHtml from '@/components/RunDataHtml.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const props = defineProps<{
|
||||
binaryData: IBinaryData;
|
||||
}>();
|
||||
|
||||
const isLoading = ref(true);
|
||||
const embedSource = ref('');
|
||||
const error = ref(false);
|
||||
const data = ref('');
|
||||
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const embedClass = computed(() => {
|
||||
return [props.binaryData.fileType ?? 'other'];
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const { id, data: binaryData, fileName, fileType, mimeType } = props.binaryData;
|
||||
const isJSONData = fileType === 'json';
|
||||
const isHTMLData = fileType === 'html';
|
||||
|
||||
if (!id) {
|
||||
if (isJSONData || isHTMLData) {
|
||||
data.value = jsonParse(atob(binaryData));
|
||||
} else {
|
||||
embedSource.value = 'data:' + mimeType + ';base64,' + binaryData;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const binaryUrl = workflowsStore.getBinaryUrl(id, 'view', fileName ?? '', mimeType);
|
||||
if (isJSONData || isHTMLData) {
|
||||
const fetchedData = await fetch(binaryUrl, { credentials: 'include' });
|
||||
data.value = await (isJSONData ? fetchedData.json() : fetchedData.text());
|
||||
} else {
|
||||
embedSource.value = binaryUrl;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
isLoading.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>
|
||||
<div v-if="isLoading">Loading binary data...</div>
|
||||
<div v-else-if="error">Error loading binary data</div>
|
||||
<span v-else>
|
||||
<video v-if="binaryData.fileType === 'video'" controls autoplay>
|
||||
<source :src="embedSource" :type="binaryData.mimeType" />
|
||||
{{ i18n.baseText('binaryDataDisplay.yourBrowserDoesNotSupport') }}
|
||||
</video>
|
||||
<audio v-else-if="binaryData.fileType === 'audio'" controls autoplay>
|
||||
<source :src="embedSource" :type="binaryData.mimeType" />
|
||||
{{ i18n.baseText('binaryDataDisplay.yourBrowserDoesNotSupport') }}
|
||||
</audio>
|
||||
<img v-else-if="binaryData.fileType === 'image'" :src="embedSource" />
|
||||
<VueJsonPretty
|
||||
v-else-if="binaryData.fileType === 'json'"
|
||||
:data="data"
|
||||
:deep="3"
|
||||
:show-length="true"
|
||||
/>
|
||||
<RunDataHtml v-else-if="binaryData.fileType === 'html'" :input-html="data" />
|
||||
<embed v-else :src="embedSource" class="binary-data" :class="embedClass" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
img,
|
||||
video {
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
.binary-data {
|
||||
&.other,
|
||||
&.pdf {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||
import { BREAKPOINT_SM, BREAKPOINT_MD, BREAKPOINT_LG, BREAKPOINT_XL } from '@/constants';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { getBannerRowHeight } from '@/utils/htmlUtils';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
|
||||
/**
|
||||
* matching element.io https://element.eleme.io/#/en-US/component/layout#col-attributes
|
||||
* xs < 768
|
||||
* sm >= 768
|
||||
* md >= 992
|
||||
* lg >= 1200
|
||||
* xl >= 1920
|
||||
*/
|
||||
interface Props {
|
||||
valueXS?: number;
|
||||
valueXL?: number;
|
||||
valueLG?: number;
|
||||
valueMD?: number;
|
||||
valueSM?: number;
|
||||
valueDefault?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const { callDebounced } = useDebounce();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const width = ref(window.innerWidth);
|
||||
|
||||
const bp = computed(() => {
|
||||
if (width.value < BREAKPOINT_SM) {
|
||||
return 'XS';
|
||||
}
|
||||
if (width.value >= BREAKPOINT_XL) {
|
||||
return 'XL';
|
||||
}
|
||||
if (width.value >= BREAKPOINT_LG) {
|
||||
return 'LG';
|
||||
}
|
||||
if (width.value >= BREAKPOINT_MD) {
|
||||
return 'MD';
|
||||
}
|
||||
return 'SM';
|
||||
});
|
||||
|
||||
const value = computed(() => {
|
||||
if (props.valueXS && width.value < BREAKPOINT_SM) {
|
||||
return props.valueXS;
|
||||
}
|
||||
if (props.valueXL && width.value >= BREAKPOINT_XL) {
|
||||
return props.valueXL;
|
||||
}
|
||||
if (props.valueLG && width.value >= BREAKPOINT_LG) {
|
||||
return props.valueLG;
|
||||
}
|
||||
if (props.valueMD && width.value >= BREAKPOINT_MD) {
|
||||
return props.valueMD;
|
||||
}
|
||||
if (props.valueSM) {
|
||||
return props.valueSM;
|
||||
}
|
||||
return props.valueDefault;
|
||||
});
|
||||
|
||||
const onResize = () => {
|
||||
void callDebounced(onResizeEnd, { debounceTime: 50 });
|
||||
};
|
||||
|
||||
const onResizeEnd = async () => {
|
||||
width.value = window.innerWidth;
|
||||
await nextTick();
|
||||
|
||||
const bannerHeight = await getBannerRowHeight();
|
||||
uiStore.updateBannersHeight(bannerHeight);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>
|
||||
<slot :bp="bp" :value="value" />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,144 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import ButtonParameter, { type Props } from '@/components/ButtonParameter/ButtonParameter.vue';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
vi.mock('@/stores/ndv.store');
|
||||
vi.mock('@/stores/workflows.store');
|
||||
vi.mock('@/stores/posthog.store');
|
||||
vi.mock('@/stores/root.store');
|
||||
vi.mock('@/api/ai');
|
||||
vi.mock('@/composables/useI18n');
|
||||
vi.mock('@/composables/useToast');
|
||||
|
||||
describe('ButtonParameter', () => {
|
||||
const defaultProps: Props = {
|
||||
parameter: {
|
||||
name: 'testParam',
|
||||
displayName: 'Test Parameter',
|
||||
type: 'string',
|
||||
default: '',
|
||||
typeOptions: {
|
||||
buttonConfig: {
|
||||
label: 'Generate',
|
||||
action: {
|
||||
type: 'askAiCodeGeneration',
|
||||
target: 'targetParam',
|
||||
},
|
||||
hasInputField: true,
|
||||
},
|
||||
},
|
||||
} as INodeProperties,
|
||||
value: '',
|
||||
isReadOnly: false,
|
||||
path: 'testPath',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useNDVStore).mockReturnValue({
|
||||
ndvInputData: [{}],
|
||||
activeNode: { name: 'TestNode', parameters: {} },
|
||||
isDraggableDragging: false,
|
||||
} as any);
|
||||
|
||||
vi.mocked(useWorkflowsStore).mockReturnValue({
|
||||
getCurrentWorkflow: vi.fn().mockReturnValue({
|
||||
getParentNodesByDepth: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
getNodeByName: vi.fn().mockReturnValue({}),
|
||||
} as any);
|
||||
|
||||
vi.mocked(usePostHog).mockReturnValue({
|
||||
isAiEnabled: vi.fn().mockReturnValue(true),
|
||||
getVariant: vi.fn().mockReturnValue('gpt-3.5-turbo-16k'),
|
||||
} as any);
|
||||
|
||||
vi.mocked(useRootStore).mockReturnValue({
|
||||
versionCli: '1.0.0',
|
||||
pushRef: 'testPushRef',
|
||||
} as any);
|
||||
|
||||
vi.mocked(useI18n).mockReturnValue({
|
||||
baseText: vi.fn().mockReturnValue('Mocked Text'),
|
||||
nodeText: () => ({
|
||||
inputLabelDisplayName: vi.fn().mockReturnValue('Mocked Display Name'),
|
||||
inputLabelDescription: vi.fn().mockReturnValue('Mocked Description'),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
vi.mocked(useToast).mockReturnValue({
|
||||
showMessage: vi.fn(),
|
||||
} as any);
|
||||
});
|
||||
|
||||
const mountComponent = (props: Partial<Props> = {}) => {
|
||||
return mount(ButtonParameter, {
|
||||
props: { ...defaultProps, ...props },
|
||||
global: {
|
||||
plugins: [createTestingPinia()],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it('renders correctly', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.find('textarea').exists()).toBe(true);
|
||||
expect(wrapper.find('button').text()).toBe('Generate');
|
||||
});
|
||||
|
||||
it('emits valueChanged event on input', async () => {
|
||||
const wrapper = mountComponent();
|
||||
const input = wrapper.find('textarea');
|
||||
await input.setValue('Test prompt');
|
||||
expect(wrapper.emitted('valueChanged')).toBeTruthy();
|
||||
expect(wrapper.emitted('valueChanged')![0][0]).toEqual({
|
||||
name: 'testPath.testParam',
|
||||
value: 'Test prompt',
|
||||
});
|
||||
});
|
||||
|
||||
it('disables submit button when there is no execution data', async () => {
|
||||
vi.mocked(useNDVStore).mockReturnValue({
|
||||
ndvInputData: [],
|
||||
} as any);
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.find('button').attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
it('disables submit button when prompt is empty', async () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.find('button').attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
it('enables submit button when there is execution data and prompt', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await wrapper.find('textarea').setValue('Test prompt');
|
||||
expect(wrapper.find('button').attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('calls onSubmit when button is clicked', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await wrapper.find('textarea').setValue('Test prompt');
|
||||
|
||||
const submitButton = wrapper.find('button');
|
||||
expect(submitButton.attributes('disabled')).toBeUndefined();
|
||||
|
||||
await submitButton.trigger('click');
|
||||
|
||||
expect(useToast().showMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables input and button when in read only mode', async () => {
|
||||
const wrapper = mountComponent({ isReadOnly: true });
|
||||
expect(wrapper.find('textarea').attributes('disabled')).toBeDefined();
|
||||
expect(wrapper.find('button').attributes('disabled')).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
<script setup lang="ts">
|
||||
import { type INodeProperties, type NodePropertyAction } from 'n8n-workflow';
|
||||
import type { INodeUi, IUpdateInformation } from '@/Interface';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { N8nButton, N8nInput, N8nTooltip } from '@n8n/design-system/components';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import {
|
||||
getParentNodes,
|
||||
generateCodeForAiTransform,
|
||||
type TextareaRowData,
|
||||
getUpdatedTextareaValue,
|
||||
getTextareaCursorPosition,
|
||||
} from './utils';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
|
||||
import { propertyNameFromExpression } from '../../utils/mappingUtils';
|
||||
|
||||
const AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT = 'codeGeneratedForPrompt';
|
||||
|
||||
const emit = defineEmits<{
|
||||
valueChanged: [value: IUpdateInformation];
|
||||
}>();
|
||||
|
||||
export type Props = {
|
||||
parameter: INodeProperties;
|
||||
value: string;
|
||||
path: string;
|
||||
isReadOnly?: boolean;
|
||||
};
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const { activeNode } = useNDVStore();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const prompt = ref(props.value);
|
||||
const parentNodes = ref<INodeUi[]>([]);
|
||||
const textareaRowsData = ref<TextareaRowData | null>(null);
|
||||
|
||||
const hasExecutionData = computed(() => (useNDVStore().ndvInputData || []).length > 0);
|
||||
const hasInputField = computed(() => props.parameter.typeOptions?.buttonConfig?.hasInputField);
|
||||
const inputFieldMaxLength = computed(
|
||||
() => props.parameter.typeOptions?.buttonConfig?.inputFieldMaxLength,
|
||||
);
|
||||
const buttonLabel = computed(
|
||||
() => props.parameter.typeOptions?.buttonConfig?.label ?? props.parameter.displayName,
|
||||
);
|
||||
const isSubmitEnabled = computed(() => {
|
||||
if (!hasExecutionData.value || !prompt.value || props.isReadOnly) return false;
|
||||
|
||||
const maxlength = inputFieldMaxLength.value;
|
||||
if (maxlength && prompt.value.length > maxlength) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
const promptUpdated = computed(() => {
|
||||
const lastPrompt = activeNode?.parameters[AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT] as string;
|
||||
if (!lastPrompt) return false;
|
||||
return lastPrompt.trim() !== prompt.value.trim();
|
||||
});
|
||||
|
||||
function startLoading() {
|
||||
isLoading.value = true;
|
||||
}
|
||||
|
||||
function stopLoading() {
|
||||
setTimeout(() => {
|
||||
isLoading.value = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function getPath(parameter: string) {
|
||||
return (props.path ? `${props.path}.` : '') + parameter;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const { showMessage } = useToast();
|
||||
const action: string | NodePropertyAction | undefined =
|
||||
props.parameter.typeOptions?.buttonConfig?.action;
|
||||
|
||||
if (!action || !activeNode) return;
|
||||
|
||||
if (typeof action === 'string') {
|
||||
switch (action) {
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
emit('valueChanged', {
|
||||
name: getPath(props.parameter.name),
|
||||
value: prompt.value,
|
||||
});
|
||||
|
||||
const { type, target } = action;
|
||||
|
||||
startLoading();
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case 'askAiCodeGeneration':
|
||||
const updateInformation = await generateCodeForAiTransform(
|
||||
prompt.value,
|
||||
getPath(target as string),
|
||||
5,
|
||||
);
|
||||
if (!updateInformation) return;
|
||||
|
||||
//updade code parameter
|
||||
emit('valueChanged', updateInformation);
|
||||
|
||||
//update code generated for prompt parameter
|
||||
emit('valueChanged', {
|
||||
name: getPath(AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT),
|
||||
value: prompt.value,
|
||||
});
|
||||
|
||||
useTelemetry().trackAiTransform('generationFinished', {
|
||||
prompt: prompt.value,
|
||||
code: updateInformation.value,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage({
|
||||
type: 'success',
|
||||
title: i18n.baseText('codeNodeEditor.askAi.generationCompleted'),
|
||||
});
|
||||
|
||||
stopLoading();
|
||||
} catch (error) {
|
||||
useTelemetry().trackAiTransform('generationFinished', {
|
||||
prompt: prompt.value,
|
||||
code: '',
|
||||
hasError: true,
|
||||
});
|
||||
showMessage({
|
||||
type: 'error',
|
||||
title: i18n.baseText('codeNodeEditor.askAi.generationFailed'),
|
||||
message: error.message,
|
||||
});
|
||||
stopLoading();
|
||||
}
|
||||
}
|
||||
|
||||
function onPromptInput(inputValue: string) {
|
||||
prompt.value = inputValue;
|
||||
emit('valueChanged', {
|
||||
name: getPath(props.parameter.name),
|
||||
value: inputValue,
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
parentNodes.value = getParentNodes();
|
||||
});
|
||||
|
||||
function cleanTextareaRowsData() {
|
||||
textareaRowsData.value = null;
|
||||
}
|
||||
|
||||
async function onDrop(value: string, event: MouseEvent) {
|
||||
value = propertyNameFromExpression(value);
|
||||
|
||||
prompt.value = getUpdatedTextareaValue(event, textareaRowsData.value, value);
|
||||
|
||||
emit('valueChanged', {
|
||||
name: getPath(props.parameter.name),
|
||||
value: prompt.value,
|
||||
});
|
||||
}
|
||||
|
||||
async function updateCursorPositionOnMouseMove(event: MouseEvent, activeDrop: boolean) {
|
||||
if (!activeDrop) return;
|
||||
|
||||
const textarea = event.target as HTMLTextAreaElement;
|
||||
|
||||
const position = getTextareaCursorPosition(
|
||||
textarea,
|
||||
textareaRowsData.value,
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
);
|
||||
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(position, position);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n8n-input-label
|
||||
v-if="hasInputField"
|
||||
:label="i18n.nodeText().inputLabelDisplayName(parameter, path)"
|
||||
:tooltip-text="i18n.nodeText().inputLabelDescription(parameter, path)"
|
||||
:bold="false"
|
||||
size="small"
|
||||
color="text-dark"
|
||||
>
|
||||
</n8n-input-label>
|
||||
<div
|
||||
:class="[$style.inputContainer, { [$style.disabled]: isReadOnly }]"
|
||||
:hidden="!hasInputField"
|
||||
>
|
||||
<div :class="$style.meta">
|
||||
<span
|
||||
v-if="inputFieldMaxLength"
|
||||
v-show="prompt.length > 1"
|
||||
:class="$style.counter"
|
||||
v-text="`${prompt.length} / ${inputFieldMaxLength}`"
|
||||
/>
|
||||
<span
|
||||
v-if="promptUpdated"
|
||||
:class="$style['warning-text']"
|
||||
v-text="'Instructions changed'"
|
||||
/>
|
||||
</div>
|
||||
<DraggableTarget type="mapping" :disabled="isLoading" @drop="onDrop">
|
||||
<template #default="{ activeDrop, droppable }">
|
||||
<N8nInput
|
||||
v-model="prompt"
|
||||
:class="[
|
||||
$style.input,
|
||||
{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable },
|
||||
]"
|
||||
style="border: 1.5px solid var(--color-foreground-base)"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
:maxlength="inputFieldMaxLength"
|
||||
:placeholder="parameter.placeholder"
|
||||
:disabled="isReadOnly"
|
||||
@input="onPromptInput"
|
||||
@mousemove="updateCursorPositionOnMouseMove($event, activeDrop)"
|
||||
@mouseleave="cleanTextareaRowsData"
|
||||
/>
|
||||
</template>
|
||||
</DraggableTarget>
|
||||
</div>
|
||||
<div :class="$style.controls">
|
||||
<N8nTooltip :disabled="isSubmitEnabled">
|
||||
<div>
|
||||
<N8nButton
|
||||
:disabled="!isSubmitEnabled"
|
||||
size="small"
|
||||
:loading="isLoading"
|
||||
type="secondary"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<template #content>
|
||||
<span
|
||||
v-if="!hasExecutionData"
|
||||
v-text="i18n.baseText('codeNodeEditor.askAi.noInputData')"
|
||||
/>
|
||||
<span
|
||||
v-else-if="prompt.length === 0"
|
||||
v-text="i18n.baseText('codeNodeEditor.askAi.noPrompt')"
|
||||
/>
|
||||
</template>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.input * {
|
||||
border: 1.5px transparent !important;
|
||||
}
|
||||
|
||||
.input {
|
||||
border-radius: var(--border-radius-base);
|
||||
}
|
||||
|
||||
.input textarea {
|
||||
font-size: var(--font-size-2xs);
|
||||
padding-bottom: var(--spacing-2xl);
|
||||
font-family: var(--font-family);
|
||||
resize: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.intro {
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-text-dark);
|
||||
padding: var(--spacing-2xs) 0 0;
|
||||
}
|
||||
.inputContainer {
|
||||
position: relative;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
position: absolute;
|
||||
padding-bottom: var(--spacing-2xs);
|
||||
padding-top: var(--spacing-2xs);
|
||||
bottom: 2px;
|
||||
left: var(--spacing-xs);
|
||||
right: var(--spacing-xs);
|
||||
gap: var(--spacing-2xs);
|
||||
align-items: end;
|
||||
z-index: 1;
|
||||
background-color: var(--color-foreground-xlight);
|
||||
|
||||
* {
|
||||
font-size: var(--font-size-2xs);
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
.counter {
|
||||
color: var(--color-text-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.controls {
|
||||
padding: var(--spacing-2xs) 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.warning-text {
|
||||
color: var(--color-warning);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.droppable {
|
||||
border: 1.5px dashed var(--color-ndv-droppable-parameter) !important;
|
||||
}
|
||||
.activeDrop {
|
||||
border: 1.5px solid var(--color-success) !important;
|
||||
cursor: grabbing;
|
||||
}
|
||||
.disabled {
|
||||
.meta {
|
||||
background-color: var(--fill-disabled);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { generateCodeForAiTransform, reducePayloadSizeOrThrow } from './utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { generateCodeForPrompt } from '@/api/ai';
|
||||
import type { AskAiRequest } from '@/types/assistant.types';
|
||||
import type { Schema } from '@/Interface';
|
||||
|
||||
vi.mock('./utils', async () => {
|
||||
const actual = await vi.importActual('./utils');
|
||||
return {
|
||||
...actual,
|
||||
getSchemas: vi.fn(() => ({
|
||||
parentNodesSchemas: { test: 'parentSchema' },
|
||||
inputSchema: { test: 'inputSchema' },
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/stores/root.store', () => ({
|
||||
useRootStore: () => ({
|
||||
pushRef: 'mockRootPushRef',
|
||||
restApiContext: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/ndv.store', () => ({
|
||||
useNDVStore: () => ({
|
||||
pushRef: 'mockNdvPushRef',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/settings.store', () => ({
|
||||
useSettingsStore: vi.fn(() => ({ settings: {}, isAskAiEnabled: true })),
|
||||
}));
|
||||
|
||||
vi.mock('prettier', () => ({
|
||||
format: vi.fn(async (code) => await Promise.resolve(`formatted-${code}`)),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/ai', () => ({
|
||||
generateCodeForPrompt: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('generateCodeForAiTransform - Retry Tests', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
});
|
||||
|
||||
it('should retry and succeed on the second attempt', async () => {
|
||||
const mockGeneratedCode = 'const example = "retry success";';
|
||||
|
||||
vi.mocked(generateCodeForPrompt)
|
||||
.mockRejectedValueOnce(new Error('First attempt failed'))
|
||||
.mockResolvedValueOnce({ code: mockGeneratedCode });
|
||||
|
||||
const result = await generateCodeForAiTransform('test prompt', 'test/path', 2);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'test/path',
|
||||
value: 'formatted-const example = "retry success";',
|
||||
});
|
||||
expect(generateCodeForPrompt).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should exhaust retries and throw an error', async () => {
|
||||
vi.mocked(generateCodeForPrompt).mockRejectedValue(new Error('All attempts failed'));
|
||||
|
||||
await expect(generateCodeForAiTransform('test prompt', 'test/path', 3)).rejects.toThrow(
|
||||
'All attempts failed',
|
||||
);
|
||||
|
||||
expect(generateCodeForPrompt).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should succeed on the first attempt without retries', async () => {
|
||||
const mockGeneratedCode = 'const example = "no retries needed";';
|
||||
vi.mocked(generateCodeForPrompt).mockResolvedValue({ code: mockGeneratedCode });
|
||||
|
||||
const result = await generateCodeForAiTransform('test prompt', 'test/path');
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'test/path',
|
||||
value: 'formatted-const example = "no retries needed";',
|
||||
});
|
||||
expect(generateCodeForPrompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
const mockPayload = () =>
|
||||
({
|
||||
context: {
|
||||
schema: [
|
||||
{ nodeName: 'node1', data: 'some data' },
|
||||
{ nodeName: 'node2', data: 'other data' },
|
||||
],
|
||||
inputSchema: {
|
||||
schema: {
|
||||
value: [
|
||||
{ key: 'prop1', value: 'value1' },
|
||||
{ key: 'prop2', value: 'value2' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
question: 'What is node1 and prop1?',
|
||||
}) as unknown as AskAiRequest.RequestPayload;
|
||||
|
||||
describe('reducePayloadSizeOrThrow', () => {
|
||||
it('reduces schema size when tokens exceed the limit', () => {
|
||||
const payload = mockPayload();
|
||||
const error = new Error('Limit is 100 tokens, but 104 were provided');
|
||||
|
||||
reducePayloadSizeOrThrow(payload, error);
|
||||
|
||||
expect(payload.context.schema.length).toBe(1);
|
||||
expect(payload.context.schema[0]).toEqual({ nodeName: 'node1', data: 'some data' });
|
||||
});
|
||||
|
||||
it('removes unreferenced properties in input schema', () => {
|
||||
const payload = mockPayload();
|
||||
const error = new Error('Limit is 100 tokens, but 150 were provided');
|
||||
|
||||
reducePayloadSizeOrThrow(payload, error);
|
||||
|
||||
expect(payload.context.inputSchema.schema.value.length).toBe(1);
|
||||
expect((payload.context.inputSchema.schema.value as Schema[])[0].key).toBe('prop1');
|
||||
});
|
||||
|
||||
it('removes all parent nodes if needed', () => {
|
||||
const payload = mockPayload();
|
||||
const error = new Error('Limit is 100 tokens, but 150 were provided');
|
||||
|
||||
payload.question = '';
|
||||
|
||||
reducePayloadSizeOrThrow(payload, error);
|
||||
|
||||
expect(payload.context.schema.length).toBe(0);
|
||||
});
|
||||
|
||||
it('throws error if tokens still exceed after reductions', () => {
|
||||
const payload = mockPayload();
|
||||
const error = new Error('Limit is 100 tokens, but 200 were provided');
|
||||
|
||||
expect(() => reducePayloadSizeOrThrow(payload, error)).toThrowError(error);
|
||||
});
|
||||
|
||||
it('throws error if message format is invalid', () => {
|
||||
const payload = mockPayload();
|
||||
const error = new Error('Invalid token message format');
|
||||
|
||||
expect(() => reducePayloadSizeOrThrow(payload, error)).toThrowError(error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
import type { Schema } from '@/Interface';
|
||||
import { ApplicationError, type INodeExecutionData } from 'n8n-workflow';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useDataSchema } from '@/composables/useDataSchema';
|
||||
import { executionDataToJson } from '@/utils/nodeTypesUtils';
|
||||
import { generateCodeForPrompt } from '@/api/ai';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { type AskAiRequest } from '@/types/assistant.types';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { format } from 'prettier';
|
||||
import jsParser from 'prettier/plugins/babel';
|
||||
import * as estree from 'prettier/plugins/estree';
|
||||
|
||||
export type TextareaRowData = {
|
||||
rows: string[];
|
||||
linesToRowsMap: number[][];
|
||||
};
|
||||
|
||||
export function getParentNodes() {
|
||||
const activeNode = useNDVStore().activeNode;
|
||||
const { getCurrentWorkflow, getNodeByName } = useWorkflowsStore();
|
||||
const workflow = getCurrentWorkflow();
|
||||
|
||||
if (!activeNode || !workflow) return [];
|
||||
|
||||
return workflow
|
||||
.getParentNodesByDepth(activeNode?.name)
|
||||
.filter(({ name }, i, nodes) => {
|
||||
return name !== activeNode.name && nodes.findIndex((node) => node.name === name) === i;
|
||||
})
|
||||
.map((n) => getNodeByName(n.name))
|
||||
.filter((n) => n !== null);
|
||||
}
|
||||
|
||||
export function getSchemas() {
|
||||
const parentNodes = getParentNodes();
|
||||
const parentNodesNames = parentNodes.map((node) => node?.name);
|
||||
const { getSchemaForExecutionData, getInputDataWithPinned } = useDataSchema();
|
||||
const parentNodesSchemas: Array<{ nodeName: string; schema: Schema }> = parentNodes
|
||||
.map((node) => {
|
||||
const inputData: INodeExecutionData[] = getInputDataWithPinned(node);
|
||||
|
||||
return {
|
||||
nodeName: node?.name || '',
|
||||
schema: getSchemaForExecutionData(executionDataToJson(inputData), false),
|
||||
};
|
||||
})
|
||||
.filter((node) => node.schema?.value.length > 0);
|
||||
|
||||
const inputSchema = parentNodesSchemas.shift();
|
||||
|
||||
return {
|
||||
parentNodesNames,
|
||||
inputSchema,
|
||||
parentNodesSchemas,
|
||||
};
|
||||
}
|
||||
|
||||
//------ Reduce payload ------
|
||||
|
||||
const estimateNumberOfTokens = (item: unknown, averageTokenLength: number): number => {
|
||||
if (typeof item === 'object') {
|
||||
return Math.ceil(JSON.stringify(item).length / averageTokenLength);
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const calculateRemainingTokens = (error: Error) => {
|
||||
// Expected message format:
|
||||
//'This model's maximum context length is 8192 tokens. However, your messages resulted in 10514 tokens.'
|
||||
const tokens = error.message.match(/\d+/g);
|
||||
|
||||
if (!tokens || tokens.length < 2) throw error;
|
||||
|
||||
const maxTokens = parseInt(tokens[0], 10);
|
||||
const currentTokens = parseInt(tokens[1], 10);
|
||||
|
||||
return currentTokens - maxTokens;
|
||||
};
|
||||
|
||||
const trimParentNodesSchema = (
|
||||
payload: AskAiRequest.RequestPayload,
|
||||
remainingTokensToReduce: number,
|
||||
averageTokenLength: number,
|
||||
) => {
|
||||
//check if parent nodes schema takes more tokens than available
|
||||
let parentNodesTokenCount = estimateNumberOfTokens(payload.context.schema, averageTokenLength);
|
||||
|
||||
if (remainingTokensToReduce > parentNodesTokenCount) {
|
||||
remainingTokensToReduce -= parentNodesTokenCount;
|
||||
payload.context.schema = [];
|
||||
}
|
||||
|
||||
//remove parent nodes not referenced in the prompt
|
||||
if (payload.context.schema.length) {
|
||||
const nodes = [...payload.context.schema];
|
||||
|
||||
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex++) {
|
||||
if (payload.question.includes(nodes[nodeIndex].nodeName)) continue;
|
||||
|
||||
const nodeTokens = estimateNumberOfTokens(nodes[nodeIndex], averageTokenLength);
|
||||
remainingTokensToReduce -= nodeTokens;
|
||||
parentNodesTokenCount -= nodeTokens;
|
||||
payload.context.schema.splice(nodeIndex, 1);
|
||||
|
||||
if (remainingTokensToReduce <= 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
return [remainingTokensToReduce, parentNodesTokenCount];
|
||||
};
|
||||
|
||||
const trimInputSchemaProperties = (
|
||||
payload: AskAiRequest.RequestPayload,
|
||||
remainingTokensToReduce: number,
|
||||
averageTokenLength: number,
|
||||
parentNodesTokenCount: number,
|
||||
) => {
|
||||
if (remainingTokensToReduce <= 0) return remainingTokensToReduce;
|
||||
|
||||
//remove properties not referenced in the prompt from the input schema
|
||||
if (Array.isArray(payload.context.inputSchema.schema.value)) {
|
||||
const props = [...payload.context.inputSchema.schema.value];
|
||||
|
||||
for (let index = 0; index < props.length; index++) {
|
||||
const key = props[index].key;
|
||||
|
||||
if (key && payload.question.includes(key)) continue;
|
||||
|
||||
const propTokens = estimateNumberOfTokens(props[index], averageTokenLength);
|
||||
remainingTokensToReduce -= propTokens;
|
||||
payload.context.inputSchema.schema.value.splice(index, 1);
|
||||
|
||||
if (remainingTokensToReduce <= 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
//if tokensToReduce is still remaining, remove all parent nodes
|
||||
if (remainingTokensToReduce > 0) {
|
||||
payload.context.schema = [];
|
||||
remainingTokensToReduce -= parentNodesTokenCount;
|
||||
}
|
||||
|
||||
return remainingTokensToReduce;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts to reduce the size of the payload to fit within token limits or throws an error if unsuccessful,
|
||||
* payload would be modified in place
|
||||
*
|
||||
* @param {AskAiRequest.RequestPayload} payload - The request payload to be trimmed,
|
||||
* 'schema' and 'inputSchema.schema' will be modified.
|
||||
* @param {Error} error - The error to throw if the token reduction fails.
|
||||
* @param {number} [averageTokenLength=4] - The average token length used for estimation.
|
||||
* @throws {Error} - Throws the provided error if the payload cannot be reduced sufficiently.
|
||||
*/
|
||||
export function reducePayloadSizeOrThrow(
|
||||
payload: AskAiRequest.RequestPayload,
|
||||
error: Error,
|
||||
averageTokenLength = 4,
|
||||
) {
|
||||
let remainingTokensToReduce = calculateRemainingTokens(error);
|
||||
|
||||
const [remaining, parentNodesTokenCount] = trimParentNodesSchema(
|
||||
payload,
|
||||
remainingTokensToReduce,
|
||||
averageTokenLength,
|
||||
);
|
||||
|
||||
remainingTokensToReduce = remaining;
|
||||
|
||||
remainingTokensToReduce = trimInputSchemaProperties(
|
||||
payload,
|
||||
remainingTokensToReduce,
|
||||
averageTokenLength,
|
||||
parentNodesTokenCount,
|
||||
);
|
||||
|
||||
if (remainingTokensToReduce > 0) throw error;
|
||||
}
|
||||
|
||||
export async function generateCodeForAiTransform(prompt: string, path: string, retries = 1) {
|
||||
const schemas = getSchemas();
|
||||
|
||||
const payload: AskAiRequest.RequestPayload = {
|
||||
question: prompt,
|
||||
context: {
|
||||
schema: schemas.parentNodesSchemas,
|
||||
inputSchema: schemas.inputSchema!,
|
||||
ndvPushRef: useNDVStore().pushRef,
|
||||
pushRef: useRootStore().pushRef,
|
||||
},
|
||||
forNode: 'transform',
|
||||
};
|
||||
|
||||
let value;
|
||||
if (useSettingsStore().isAskAiEnabled) {
|
||||
const { restApiContext } = useRootStore();
|
||||
|
||||
let code = '';
|
||||
|
||||
while (retries > 0) {
|
||||
try {
|
||||
const { code: generatedCode } = await generateCodeForPrompt(restApiContext, payload);
|
||||
code = generatedCode;
|
||||
break;
|
||||
} catch (e) {
|
||||
if (e.message.includes('maximum context length')) {
|
||||
reducePayloadSizeOrThrow(payload, e);
|
||||
continue;
|
||||
}
|
||||
|
||||
retries--;
|
||||
if (!retries) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
value = code;
|
||||
} else {
|
||||
throw new ApplicationError('AI code generation is not enabled');
|
||||
}
|
||||
|
||||
if (value === undefined) return;
|
||||
|
||||
const formattedCode = await format(String(value), {
|
||||
parser: 'babel',
|
||||
plugins: [jsParser, estree],
|
||||
});
|
||||
|
||||
const updateInformation = {
|
||||
name: path,
|
||||
value: formattedCode,
|
||||
};
|
||||
|
||||
return updateInformation;
|
||||
}
|
||||
|
||||
//------ drag and drop ------
|
||||
|
||||
function splitText(textarea: HTMLTextAreaElement, textareaRowsData: TextareaRowData | null) {
|
||||
if (textareaRowsData) return textareaRowsData;
|
||||
const rows: string[] = [];
|
||||
const linesToRowsMap: number[][] = [];
|
||||
const style = window.getComputedStyle(textarea);
|
||||
|
||||
const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||
const border = parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth);
|
||||
const textareaWidth = textarea.clientWidth - padding - border;
|
||||
|
||||
const context = createTextContext(style);
|
||||
|
||||
const lines = textarea.value.split('\n');
|
||||
|
||||
lines.forEach((_) => {
|
||||
linesToRowsMap.push([]);
|
||||
});
|
||||
lines.forEach((line, index) => {
|
||||
if (line === '') {
|
||||
rows.push(line);
|
||||
linesToRowsMap[index].push(rows.length - 1);
|
||||
return;
|
||||
}
|
||||
let currentLine = '';
|
||||
const words = line.split(/(\s+)/);
|
||||
|
||||
words.forEach((word) => {
|
||||
const testLine = currentLine + word;
|
||||
const testWidth = context.measureText(testLine).width;
|
||||
|
||||
if (testWidth <= textareaWidth) {
|
||||
currentLine = testLine;
|
||||
} else {
|
||||
rows.push(currentLine.trimEnd());
|
||||
linesToRowsMap[index].push(rows.length - 1);
|
||||
currentLine = word;
|
||||
}
|
||||
});
|
||||
|
||||
if (currentLine) {
|
||||
rows.push(currentLine.trimEnd());
|
||||
linesToRowsMap[index].push(rows.length - 1);
|
||||
}
|
||||
});
|
||||
|
||||
return { rows, linesToRowsMap };
|
||||
}
|
||||
|
||||
function createTextContext(style: CSSStyleDeclaration): CanvasRenderingContext2D {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d')!;
|
||||
context.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
||||
return context;
|
||||
}
|
||||
|
||||
const getRowIndex = (textareaY: number, lineHeight: string) => {
|
||||
const rowHeight = parseInt(lineHeight, 10);
|
||||
const snapPosition = textareaY - rowHeight / 2 - 1;
|
||||
return Math.floor(snapPosition / rowHeight);
|
||||
};
|
||||
|
||||
const getColumnIndex = (rowText: string, textareaX: number, font: string) => {
|
||||
const span = document.createElement('span');
|
||||
span.style.font = font;
|
||||
span.style.visibility = 'hidden';
|
||||
span.style.position = 'absolute';
|
||||
span.style.whiteSpace = 'pre';
|
||||
document.body.appendChild(span);
|
||||
|
||||
let left = 0;
|
||||
let right = rowText.length;
|
||||
let col = 0;
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
span.textContent = rowText.substring(0, mid);
|
||||
const width = span.getBoundingClientRect().width;
|
||||
|
||||
if (width <= textareaX) {
|
||||
col = mid;
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
document.body.removeChild(span);
|
||||
|
||||
return rowText.length === col ? col : col - 1;
|
||||
};
|
||||
|
||||
export function getUpdatedTextareaValue(
|
||||
event: MouseEvent,
|
||||
textareaRowsData: TextareaRowData | null,
|
||||
value: string,
|
||||
) {
|
||||
const textarea = event.target as HTMLTextAreaElement;
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const textareaX = event.clientX - rect.left;
|
||||
const textareaY = event.clientY - rect.top;
|
||||
const { lineHeight, font } = window.getComputedStyle(textarea);
|
||||
|
||||
const rowIndex = getRowIndex(textareaY, lineHeight);
|
||||
|
||||
const rowsData = splitText(textarea, textareaRowsData);
|
||||
|
||||
let newText = value;
|
||||
|
||||
if (rowsData.rows[rowIndex] === undefined) {
|
||||
newText = `${textarea.value} ${value}`;
|
||||
}
|
||||
const { rows, linesToRowsMap } = rowsData;
|
||||
const rowText = rows[rowIndex];
|
||||
|
||||
if (rowText === '') {
|
||||
rows[rowIndex] = value;
|
||||
} else {
|
||||
const col = getColumnIndex(rowText, textareaX, font);
|
||||
rows[rowIndex] = [rows[rowIndex].slice(0, col).trim(), value, rows[rowIndex].slice(col).trim()]
|
||||
.join(' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
newText = linesToRowsMap
|
||||
.map((lineMap) => {
|
||||
return lineMap.map((index) => rows[index]).join(' ');
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return newText;
|
||||
}
|
||||
|
||||
export function getTextareaCursorPosition(
|
||||
textarea: HTMLTextAreaElement,
|
||||
textareaRowsData: TextareaRowData | null,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) {
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const textareaX = clientX - rect.left;
|
||||
const textareaY = clientY - rect.top;
|
||||
const { lineHeight, font } = window.getComputedStyle(textarea);
|
||||
|
||||
const rowIndex = getRowIndex(textareaY, lineHeight);
|
||||
const { rows } = splitText(textarea, textareaRowsData);
|
||||
|
||||
if (rowIndex < 0 || rowIndex >= rows.length) {
|
||||
return textarea.value.length;
|
||||
}
|
||||
|
||||
const rowText = rows[rowIndex];
|
||||
|
||||
const col = getColumnIndex(rowText, textareaX, font);
|
||||
|
||||
const position = rows.slice(0, rowIndex).reduce((acc, curr) => acc + curr.length + 1, 0) + col;
|
||||
|
||||
return position;
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
|
||||
import CanvasChat from './CanvasChat.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createTestWorkflowObject } from '@/__tests__/mocks';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { STORES } from '@/constants';
|
||||
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
|
||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import * as useChatMessaging from './composables/useChatMessaging';
|
||||
import * as useChatTrigger from './composables/useChatTrigger';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
|
||||
import type { IExecutionResponse, INodeUi } from '@/Interface';
|
||||
import type { ChatMessage } from '@n8n/chat/types';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
|
||||
vi.mock('@/composables/useToast', () => {
|
||||
const showMessage = vi.fn();
|
||||
const showError = vi.fn();
|
||||
return {
|
||||
useToast: () => {
|
||||
return {
|
||||
showMessage,
|
||||
showError,
|
||||
clearAllStickyNotifications: vi.fn(),
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/stores/pushConnection.store', () => ({
|
||||
usePushConnectionStore: vi.fn().mockReturnValue({
|
||||
isConnected: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test data
|
||||
const mockNodes: INodeUi[] = [
|
||||
{
|
||||
parameters: {
|
||||
options: {
|
||||
allowFileUploads: true,
|
||||
},
|
||||
},
|
||||
id: 'chat-trigger-id',
|
||||
name: 'When chat message received',
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
typeVersion: 1.1,
|
||||
position: [740, 860],
|
||||
webhookId: 'webhook-id',
|
||||
},
|
||||
{
|
||||
parameters: {},
|
||||
id: 'agent-id',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
typeVersion: 1.7,
|
||||
position: [960, 860],
|
||||
},
|
||||
];
|
||||
const mockNodeTypes: INodeTypeDescription[] = [
|
||||
{
|
||||
displayName: 'AI Agent',
|
||||
name: '@n8n/n8n-nodes-langchain.agent',
|
||||
properties: [],
|
||||
defaults: {
|
||||
name: 'AI Agent',
|
||||
},
|
||||
inputs: [NodeConnectionType.Main],
|
||||
outputs: [NodeConnectionType.Main],
|
||||
version: 0,
|
||||
group: [],
|
||||
description: '',
|
||||
codex: {
|
||||
subcategories: {
|
||||
AI: ['Agents'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockConnections = {
|
||||
'When chat message received': {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: 'AI Agent',
|
||||
type: NodeConnectionType.Main,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockWorkflowExecution = {
|
||||
data: {
|
||||
resultData: {
|
||||
runData: {
|
||||
'AI Agent': [
|
||||
{
|
||||
data: {
|
||||
main: [[{ json: { output: 'AI response message' } }]],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
lastNodeExecuted: 'AI Agent',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [],
|
||||
});
|
||||
|
||||
describe('CanvasChat', () => {
|
||||
const renderComponent = createComponentRenderer(CanvasChat, {
|
||||
global: {
|
||||
provide: {
|
||||
[ChatSymbol as symbol]: {},
|
||||
[ChatOptionsSymbol as symbol]: {},
|
||||
},
|
||||
plugins: [router],
|
||||
},
|
||||
});
|
||||
|
||||
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||
let canvasStore: ReturnType<typeof mockedStore<typeof useCanvasStore>>;
|
||||
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
|
||||
|
||||
beforeEach(() => {
|
||||
const pinia = createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.WORKFLOWS]: {
|
||||
workflow: {
|
||||
nodes: mockNodes,
|
||||
connections: mockConnections,
|
||||
},
|
||||
},
|
||||
[STORES.UI]: {
|
||||
chatPanelOpen: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setActivePinia(pinia);
|
||||
|
||||
workflowsStore = mockedStore(useWorkflowsStore);
|
||||
canvasStore = mockedStore(useCanvasStore);
|
||||
nodeTypeStore = mockedStore(useNodeTypesStore);
|
||||
|
||||
// Setup default mocks
|
||||
workflowsStore.getCurrentWorkflow.mockReturnValue(
|
||||
createTestWorkflowObject({
|
||||
nodes: mockNodes,
|
||||
connections: mockConnections,
|
||||
}),
|
||||
);
|
||||
workflowsStore.getNodeByName.mockImplementation((name) => {
|
||||
const matchedNode = mockNodes.find((node) => node.name === name) ?? null;
|
||||
|
||||
return matchedNode;
|
||||
});
|
||||
workflowsStore.isChatPanelOpen = true;
|
||||
workflowsStore.isLogsPanelOpen = true;
|
||||
workflowsStore.getWorkflowExecution = mockWorkflowExecution as unknown as IExecutionResponse;
|
||||
workflowsStore.getPastChatMessages = ['Previous message 1', 'Previous message 2'];
|
||||
|
||||
nodeTypeStore.getNodeType = vi.fn().mockImplementation((nodeTypeName) => {
|
||||
return mockNodeTypes.find((node) => node.name === nodeTypeName) ?? null;
|
||||
});
|
||||
|
||||
workflowsStore.runWorkflow.mockResolvedValue({ executionId: 'test-execution-issd' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render chat when panel is open', () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
expect(getByTestId('canvas-chat')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render chat when panel is closed', async () => {
|
||||
workflowsStore.isChatPanelOpen = false;
|
||||
const { queryByTestId } = renderComponent();
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('canvas-chat')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show correct input placeholder', async () => {
|
||||
const { findByTestId } = renderComponent();
|
||||
expect(await findByTestId('chat-input')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('message handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(chatEventBus, 'emit');
|
||||
workflowsStore.runWorkflow.mockResolvedValue({ executionId: 'test-execution-id' });
|
||||
});
|
||||
|
||||
it('should send message and show response', async () => {
|
||||
const { findByTestId, findByText } = renderComponent();
|
||||
|
||||
// Send message
|
||||
const input = await findByTestId('chat-input');
|
||||
await userEvent.type(input, 'Hello AI!');
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
// Verify message and response
|
||||
expect(await findByText('Hello AI!')).toBeInTheDocument();
|
||||
await waitFor(async () => {
|
||||
workflowsStore.getWorkflowExecution = {
|
||||
...(mockWorkflowExecution as unknown as IExecutionResponse),
|
||||
status: 'success',
|
||||
};
|
||||
expect(await findByText('AI response message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify workflow execution
|
||||
expect(workflowsStore.runWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runData: undefined,
|
||||
triggerToStartFrom: {
|
||||
name: 'When chat message received',
|
||||
data: {
|
||||
data: {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
json: {
|
||||
action: 'sendMessage',
|
||||
chatInput: 'Hello AI!',
|
||||
sessionId: expect.any(String),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
executionStatus: 'success',
|
||||
executionTime: 0,
|
||||
source: [null],
|
||||
startTime: expect.any(Number),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show loading state during message processing', async () => {
|
||||
const { findByTestId, queryByTestId } = renderComponent();
|
||||
|
||||
// Send message
|
||||
const input = await findByTestId('chat-input');
|
||||
await userEvent.type(input, 'Test message');
|
||||
|
||||
// Since runWorkflow resolve is mocked, the isWorkflowRunning will be false from the first run.
|
||||
// This means that the loading state never gets a chance to appear.
|
||||
// We're forcing isWorkflowRunning to be true for the first run.
|
||||
workflowsStore.isWorkflowRunning = true;
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
await waitFor(() => expect(queryByTestId('chat-message-typing')).toBeInTheDocument());
|
||||
|
||||
workflowsStore.isWorkflowRunning = false;
|
||||
workflowsStore.getWorkflowExecution = {
|
||||
...(mockWorkflowExecution as unknown as IExecutionResponse),
|
||||
status: 'success',
|
||||
};
|
||||
|
||||
await waitFor(() => expect(queryByTestId('chat-message-typing')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should handle workflow execution errors', async () => {
|
||||
workflowsStore.runWorkflow.mockRejectedValueOnce(new Error());
|
||||
|
||||
const { findByTestId } = renderComponent();
|
||||
|
||||
const input = await findByTestId('chat-input');
|
||||
await userEvent.type(input, 'Hello AI!');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
const toast = useToast();
|
||||
expect(toast.showError).toHaveBeenCalledWith(new Error(), 'Problem running workflow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('session management', () => {
|
||||
const mockMessages: ChatMessage[] = [
|
||||
{
|
||||
id: '1',
|
||||
text: 'Existing message',
|
||||
sender: 'user',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
|
||||
getChatMessages: vi.fn().mockReturnValue(mockMessages),
|
||||
sendMessage: vi.fn(),
|
||||
extractResponseMessage: vi.fn(),
|
||||
previousMessageIndex: ref(0),
|
||||
isLoading: computed(() => false),
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow copying session ID', async () => {
|
||||
const clipboardSpy = vi.fn();
|
||||
document.execCommand = clipboardSpy;
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
await userEvent.click(getByTestId('chat-session-id'));
|
||||
const toast = useToast();
|
||||
expect(clipboardSpy).toHaveBeenCalledWith('copy');
|
||||
expect(toast.showMessage).toHaveBeenCalledWith({
|
||||
message: '',
|
||||
title: 'Copied to clipboard',
|
||||
type: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
it('should refresh session with confirmation when messages exist', async () => {
|
||||
const { getByTestId, getByRole } = renderComponent();
|
||||
|
||||
const originalSessionId = getByTestId('chat-session-id').textContent;
|
||||
await userEvent.click(getByTestId('refresh-session-button'));
|
||||
|
||||
const confirmButton = getByRole('dialog').querySelector('button.btn--confirm');
|
||||
|
||||
if (!confirmButton) throw new Error('Confirm button not found');
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
expect(getByTestId('chat-session-id').textContent).not.toEqual(originalSessionId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resize functionality', () => {
|
||||
it('should handle panel resizing', async () => {
|
||||
const { container } = renderComponent();
|
||||
|
||||
const resizeWrapper = container.querySelector('.resizeWrapper');
|
||||
if (!resizeWrapper) throw new Error('Resize wrapper not found');
|
||||
|
||||
await userEvent.pointer([
|
||||
{ target: resizeWrapper, coords: { clientX: 0, clientY: 0 } },
|
||||
{ coords: { clientX: 0, clientY: 100 } },
|
||||
]);
|
||||
|
||||
expect(canvasStore.setPanelHeight).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should persist resize dimensions', () => {
|
||||
const mockStorage = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(window, 'localStorage', { value: mockStorage });
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(mockStorage.getItem).toHaveBeenCalledWith('N8N_CANVAS_CHAT_HEIGHT');
|
||||
expect(mockStorage.getItem).toHaveBeenCalledWith('N8N_CANVAS_CHAT_WIDTH');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
|
||||
getChatMessages: vi.fn().mockReturnValue([]),
|
||||
sendMessage: vi.fn(),
|
||||
extractResponseMessage: vi.fn(),
|
||||
previousMessageIndex: ref(0),
|
||||
isLoading: computed(() => false),
|
||||
});
|
||||
|
||||
workflowsStore.isChatPanelOpen = true;
|
||||
workflowsStore.allowFileUploads = true;
|
||||
});
|
||||
|
||||
it('should enable file uploads when allowed by chat trigger node', async () => {
|
||||
const allowFileUploads = ref(true);
|
||||
const original = useChatTrigger.useChatTrigger;
|
||||
vi.spyOn(useChatTrigger, 'useChatTrigger').mockImplementation((...args) => ({
|
||||
...original(...args),
|
||||
allowFileUploads: computed(() => allowFileUploads.value),
|
||||
}));
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
const chatPanel = getByTestId('canvas-chat');
|
||||
expect(chatPanel).toBeInTheDocument();
|
||||
|
||||
const fileInput = getByTestId('chat-attach-file-button');
|
||||
expect(fileInput).toBeInTheDocument();
|
||||
|
||||
allowFileUploads.value = false;
|
||||
await waitFor(() => {
|
||||
expect(fileInput).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('message history handling', () => {
|
||||
it('should properly navigate through message history with wrap-around', async () => {
|
||||
const messages = ['Message 1', 'Message 2', 'Message 3'];
|
||||
workflowsStore.getPastChatMessages = messages;
|
||||
|
||||
const { findByTestId } = renderComponent();
|
||||
const input = await findByTestId('chat-input');
|
||||
|
||||
// First up should show most recent message
|
||||
await userEvent.keyboard('{ArrowUp}');
|
||||
expect(input).toHaveValue('Message 3');
|
||||
|
||||
// Second up should show second most recent
|
||||
await userEvent.keyboard('{ArrowUp}');
|
||||
expect(input).toHaveValue('Message 2');
|
||||
|
||||
// Third up should show oldest message
|
||||
await userEvent.keyboard('{ArrowUp}');
|
||||
expect(input).toHaveValue('Message 1');
|
||||
|
||||
// Fourth up should wrap around to most recent
|
||||
await userEvent.keyboard('{ArrowUp}');
|
||||
expect(input).toHaveValue('Message 3');
|
||||
|
||||
// Down arrow should go in reverse
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
expect(input).toHaveValue('Message 1');
|
||||
});
|
||||
|
||||
it('should reset message history navigation on new input', async () => {
|
||||
workflowsStore.getPastChatMessages = ['Message 1', 'Message 2'];
|
||||
const { findByTestId } = renderComponent();
|
||||
const input = await findByTestId('chat-input');
|
||||
|
||||
// Navigate to oldest message
|
||||
await userEvent.keyboard('{ArrowUp}'); // Most recent
|
||||
await userEvent.keyboard('{ArrowUp}'); // Oldest
|
||||
expect(input).toHaveValue('Message 1');
|
||||
|
||||
await userEvent.type(input, 'New message');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
await userEvent.keyboard('{ArrowUp}');
|
||||
expect(input).toHaveValue('Message 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('message reuse and repost', () => {
|
||||
const sendMessageSpy = vi.fn();
|
||||
beforeEach(() => {
|
||||
const mockMessages: ChatMessage[] = [
|
||||
{
|
||||
id: '1',
|
||||
text: 'Original message',
|
||||
sender: 'user',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
text: 'AI response',
|
||||
sender: 'bot',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
|
||||
getChatMessages: vi.fn().mockReturnValue(mockMessages),
|
||||
sendMessage: sendMessageSpy,
|
||||
extractResponseMessage: vi.fn(),
|
||||
previousMessageIndex: ref(0),
|
||||
isLoading: computed(() => false),
|
||||
});
|
||||
workflowsStore.messages = mockMessages;
|
||||
});
|
||||
|
||||
it('should repost user message with new execution', async () => {
|
||||
const { findByTestId } = renderComponent();
|
||||
const repostButton = await findByTestId('repost-message-button');
|
||||
|
||||
await userEvent.click(repostButton);
|
||||
|
||||
expect(sendMessageSpy).toHaveBeenCalledWith('Original message');
|
||||
expect.objectContaining({
|
||||
runData: expect.objectContaining({
|
||||
'When chat message received': expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
main: expect.arrayContaining([
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
json: expect.objectContaining({
|
||||
chatInput: 'Original message',
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should show message options only for appropriate messages', async () => {
|
||||
const { findByText, container } = renderComponent();
|
||||
|
||||
await findByText('Original message');
|
||||
const userMessage = container.querySelector('.chat-message-from-user');
|
||||
expect(
|
||||
userMessage?.querySelector('[data-test-id="repost-message-button"]'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
userMessage?.querySelector('[data-test-id="reuse-message-button"]'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await findByText('AI response');
|
||||
const botMessage = container.querySelector('.chat-message-from-bot');
|
||||
expect(
|
||||
botMessage?.querySelector('[data-test-id="repost-message-button"]'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
botMessage?.querySelector('[data-test-id="reuse-message-button"]'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('panel state synchronization', () => {
|
||||
it('should update canvas height when chat or logs panel state changes', async () => {
|
||||
renderComponent();
|
||||
|
||||
// Toggle logs panel
|
||||
workflowsStore.isLogsPanelOpen = true;
|
||||
await waitFor(() => {
|
||||
expect(canvasStore.setPanelHeight).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Close chat panel
|
||||
workflowsStore.isChatPanelOpen = false;
|
||||
await waitFor(() => {
|
||||
expect(canvasStore.setPanelHeight).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve panel state across component remounts', async () => {
|
||||
const { unmount, rerender } = renderComponent();
|
||||
|
||||
// Set initial state
|
||||
workflowsStore.isChatPanelOpen = true;
|
||||
workflowsStore.isLogsPanelOpen = true;
|
||||
|
||||
// Unmount and remount
|
||||
unmount();
|
||||
await rerender({});
|
||||
|
||||
expect(workflowsStore.isChatPanelOpen).toBe(true);
|
||||
expect(workflowsStore.isLogsPanelOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard shortcuts', () => {
|
||||
it('should handle Enter key with modifier to start new line', async () => {
|
||||
const { findByTestId } = renderComponent();
|
||||
|
||||
const input = await findByTestId('chat-input');
|
||||
await userEvent.type(input, 'Line 1');
|
||||
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
|
||||
await userEvent.type(input, 'Line 2');
|
||||
|
||||
expect(input).toHaveValue('Line 1\nLine 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chat synchronization', () => {
|
||||
it('should load initial chat history when first opening panel', async () => {
|
||||
const getChatMessagesSpy = vi.fn().mockReturnValue(['Previous message']);
|
||||
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
|
||||
...vi.fn()(),
|
||||
getChatMessages: getChatMessagesSpy,
|
||||
});
|
||||
|
||||
workflowsStore.isChatPanelOpen = false;
|
||||
const { rerender } = renderComponent();
|
||||
|
||||
workflowsStore.isChatPanelOpen = true;
|
||||
await rerender({});
|
||||
|
||||
expect(getChatMessagesSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
<script setup lang="ts">
|
||||
import type { Ref } from 'vue';
|
||||
import { provide, watch, computed, ref, watchEffect } from 'vue';
|
||||
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
|
||||
import type { Router } from 'vue-router';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
// Components
|
||||
import ChatMessagesPanel from './components/ChatMessagesPanel.vue';
|
||||
import ChatLogsPanel from './components/ChatLogsPanel.vue';
|
||||
|
||||
// Composables
|
||||
import { useChatTrigger } from './composables/useChatTrigger';
|
||||
import { useChatMessaging } from './composables/useChatMessaging';
|
||||
import { useResize } from './composables/useResize';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||
|
||||
// Types
|
||||
import type { Chat, ChatMessage, ChatOptions } from '@n8n/chat/types';
|
||||
import type { RunWorkflowChatPayload } from './composables/useChatMessaging';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const canvasStore = useCanvasStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
const router = useRouter();
|
||||
|
||||
// Component state
|
||||
const messages = ref<ChatMessage[]>([]);
|
||||
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
|
||||
const isDisabled = ref(false);
|
||||
const container = ref<HTMLElement>();
|
||||
|
||||
// Computed properties
|
||||
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
||||
|
||||
const allConnections = computed(() => workflowsStore.allConnections);
|
||||
const isChatOpen = computed(() => {
|
||||
const result = workflowsStore.isChatPanelOpen;
|
||||
return result;
|
||||
});
|
||||
const canvasNodes = computed(() => workflowsStore.allNodes);
|
||||
const isLogsOpen = computed(() => workflowsStore.isLogsPanelOpen);
|
||||
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
|
||||
const resultData = computed(() => workflowsStore.getWorkflowRunData);
|
||||
// Expose internal state for testing
|
||||
defineExpose({
|
||||
messages,
|
||||
currentSessionId,
|
||||
isDisabled,
|
||||
workflow,
|
||||
});
|
||||
|
||||
const { runWorkflow } = useRunWorkflow({ router });
|
||||
|
||||
// Initialize features with injected dependencies
|
||||
const {
|
||||
chatTriggerNode,
|
||||
connectedNode,
|
||||
allowFileUploads,
|
||||
allowedFilesMimeTypes,
|
||||
setChatTriggerNode,
|
||||
setConnectedNode,
|
||||
} = useChatTrigger({
|
||||
workflow,
|
||||
canvasNodes,
|
||||
getNodeByName: workflowsStore.getNodeByName,
|
||||
getNodeType: nodeTypesStore.getNodeType,
|
||||
});
|
||||
|
||||
const { sendMessage, getChatMessages, isLoading } = useChatMessaging({
|
||||
chatTrigger: chatTriggerNode,
|
||||
connectedNode,
|
||||
messages,
|
||||
sessionId: currentSessionId,
|
||||
workflow,
|
||||
executionResultData: computed(() => workflowsStore.getWorkflowExecution?.data?.resultData),
|
||||
getWorkflowResultDataByNodeName: workflowsStore.getWorkflowResultDataByNodeName,
|
||||
onRunChatWorkflow,
|
||||
});
|
||||
|
||||
const {
|
||||
height,
|
||||
chatWidth,
|
||||
rootStyles,
|
||||
logsWidth,
|
||||
onResizeDebounced,
|
||||
onResizeChatDebounced,
|
||||
onWindowResize,
|
||||
} = useResize(container);
|
||||
|
||||
// Extracted pure functions for better testability
|
||||
function createChatConfig(params: {
|
||||
messages: Chat['messages'];
|
||||
sendMessage: Chat['sendMessage'];
|
||||
currentSessionId: Chat['currentSessionId'];
|
||||
isLoading: Ref<boolean>;
|
||||
isDisabled: Ref<boolean>;
|
||||
allowFileUploads: Ref<boolean>;
|
||||
locale: ReturnType<typeof useI18n>;
|
||||
}): { chatConfig: Chat; chatOptions: ChatOptions } {
|
||||
const chatConfig: Chat = {
|
||||
messages: params.messages,
|
||||
sendMessage: params.sendMessage,
|
||||
initialMessages: ref([]),
|
||||
currentSessionId: params.currentSessionId,
|
||||
waitingForResponse: params.isLoading,
|
||||
};
|
||||
|
||||
const chatOptions: ChatOptions = {
|
||||
i18n: {
|
||||
en: {
|
||||
title: '',
|
||||
footer: '',
|
||||
subtitle: '',
|
||||
inputPlaceholder: params.locale.baseText('chat.window.chat.placeholder'),
|
||||
getStarted: '',
|
||||
closeButtonTooltip: '',
|
||||
},
|
||||
},
|
||||
webhookUrl: '',
|
||||
mode: 'window',
|
||||
showWindowCloseButton: true,
|
||||
disabled: params.isDisabled,
|
||||
allowFileUploads: params.allowFileUploads,
|
||||
allowedFilesMimeTypes,
|
||||
};
|
||||
|
||||
return { chatConfig, chatOptions };
|
||||
}
|
||||
|
||||
function displayExecution(params: { router: Router; workflowId: string; executionId: string }) {
|
||||
const route = params.router.resolve({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: params.workflowId, executionId: params.executionId },
|
||||
});
|
||||
window.open(route.href, '_blank');
|
||||
}
|
||||
|
||||
function refreshSession(params: { messages: Ref<ChatMessage[]>; currentSessionId: Ref<string> }) {
|
||||
workflowsStore.setWorkflowExecutionData(null);
|
||||
nodeHelpers.updateNodesExecutionIssues();
|
||||
params.messages.value = [];
|
||||
params.currentSessionId.value = uuid().replace(/-/g, '');
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
const handleDisplayExecution = (executionId: string) => {
|
||||
displayExecution({
|
||||
router,
|
||||
workflowId: workflow.value.id,
|
||||
executionId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRefreshSession = () => {
|
||||
refreshSession({
|
||||
messages,
|
||||
currentSessionId,
|
||||
});
|
||||
};
|
||||
|
||||
const closePanel = () => {
|
||||
workflowsStore.setPanelOpen('chat', false);
|
||||
};
|
||||
|
||||
// This function creates a promise that resolves when the workflow execution completes
|
||||
// It's used to handle the loading state while waiting for the workflow to finish
|
||||
async function createExecutionPromise() {
|
||||
return await new Promise<void>((resolve) => {
|
||||
const resolveIfFinished = (isRunning: boolean) => {
|
||||
if (!isRunning) {
|
||||
unwatch();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for changes in the workflow execution status
|
||||
const unwatch = watch(() => workflowsStore.isWorkflowRunning, resolveIfFinished);
|
||||
resolveIfFinished(workflowsStore.isWorkflowRunning);
|
||||
});
|
||||
}
|
||||
|
||||
async function onRunChatWorkflow(payload: RunWorkflowChatPayload) {
|
||||
const runWorkflowOptions: Parameters<typeof runWorkflow>[0] = {
|
||||
triggerNode: payload.triggerNode,
|
||||
nodeData: payload.nodeData,
|
||||
source: payload.source,
|
||||
};
|
||||
|
||||
if (workflowsStore.chatPartialExecutionDestinationNode) {
|
||||
runWorkflowOptions.destinationNode = workflowsStore.chatPartialExecutionDestinationNode;
|
||||
workflowsStore.chatPartialExecutionDestinationNode = null;
|
||||
}
|
||||
|
||||
const response = await runWorkflow(runWorkflowOptions);
|
||||
|
||||
if (response) {
|
||||
await createExecutionPromise();
|
||||
workflowsStore.appendChatMessage(payload.message);
|
||||
return response;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize chat config
|
||||
const { chatConfig, chatOptions } = createChatConfig({
|
||||
messages,
|
||||
sendMessage,
|
||||
currentSessionId,
|
||||
isLoading,
|
||||
isDisabled,
|
||||
allowFileUploads,
|
||||
locale: useI18n(),
|
||||
});
|
||||
|
||||
// Provide chat context
|
||||
provide(ChatSymbol, chatConfig);
|
||||
provide(ChatOptionsSymbol, chatOptions);
|
||||
|
||||
// Watchers
|
||||
watch(
|
||||
() => isChatOpen.value,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
setChatTriggerNode();
|
||||
setConnectedNode();
|
||||
|
||||
if (messages.value.length === 0) {
|
||||
messages.value = getChatMessages();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
onWindowResize();
|
||||
chatEventBus.emit('focusInput');
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => allConnections.value,
|
||||
() => {
|
||||
if (canvasStore.isLoading) return;
|
||||
setTimeout(() => {
|
||||
if (!chatTriggerNode.value) {
|
||||
setChatTriggerNode();
|
||||
}
|
||||
setConnectedNode();
|
||||
}, 0);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watchEffect(() => {
|
||||
canvasStore.setPanelHeight(isChatOpen.value || isLogsOpen.value ? height.value : 0);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nResizeWrapper
|
||||
v-if="chatTriggerNode"
|
||||
:is-resizing-enabled="isChatOpen || isLogsOpen"
|
||||
:supported-directions="['top']"
|
||||
:class="[$style.resizeWrapper, !isChatOpen && !isLogsOpen && $style.empty]"
|
||||
:height="height"
|
||||
:style="rootStyles"
|
||||
@resize="onResizeDebounced"
|
||||
>
|
||||
<div ref="container" :class="[$style.container, 'ignore-key-press-canvas']" tabindex="0">
|
||||
<div v-if="isChatOpen || isLogsOpen" :class="$style.chatResizer">
|
||||
<n8n-resize-wrapper
|
||||
v-if="isChatOpen"
|
||||
:supported-directions="['right']"
|
||||
:width="chatWidth"
|
||||
:class="$style.chat"
|
||||
@resize="onResizeChatDebounced"
|
||||
>
|
||||
<div :class="$style.inner">
|
||||
<ChatMessagesPanel
|
||||
data-test-id="canvas-chat"
|
||||
:messages="messages"
|
||||
:session-id="currentSessionId"
|
||||
:past-chat-messages="previousChatMessages"
|
||||
:show-close-button="!connectedNode"
|
||||
@close="closePanel"
|
||||
@refresh-session="handleRefreshSession"
|
||||
@display-execution="handleDisplayExecution"
|
||||
@send-message="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</n8n-resize-wrapper>
|
||||
<div v-if="isLogsOpen && connectedNode" :class="$style.logs">
|
||||
<ChatLogsPanel
|
||||
:key="`${resultData?.length ?? messages?.length}`"
|
||||
:workflow="workflow"
|
||||
data-test-id="canvas-chat-logs"
|
||||
:node="connectedNode"
|
||||
:slim="logsWidth < 700"
|
||||
@close="closePanel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</N8nResizeWrapper>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.resizeWrapper {
|
||||
height: var(--panel-height);
|
||||
min-height: 4rem;
|
||||
max-height: 90vh;
|
||||
flex-basis: content;
|
||||
border-top: 1px solid var(--color-foreground-base);
|
||||
|
||||
&.empty {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
flex-basis: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chatResizer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid var(--color-foreground-base);
|
||||
width: 100%;
|
||||
background-color: var(--color-background-light);
|
||||
display: flex;
|
||||
padding: var(--spacing-2xs);
|
||||
gap: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.chat {
|
||||
width: var(--chat-width);
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--color-foreground-base);
|
||||
max-width: 100%;
|
||||
|
||||
&:only-child {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logs {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
background-color: var(--color-background-light);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import type { INode, Workflow } from 'n8n-workflow';
|
||||
import RunDataAi from '@/components/RunDataAi/RunDataAi.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
defineProps<{
|
||||
node: INode | null;
|
||||
slim?: boolean;
|
||||
workflow: Workflow;
|
||||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.logsWrapper" data-test-id="lm-chat-logs">
|
||||
<header :class="$style.logsHeader">
|
||||
<div class="meta">
|
||||
{{ locale.baseText('chat.window.logs') }}
|
||||
<span v-if="node">
|
||||
{{
|
||||
locale.baseText('chat.window.logsFromNode', { interpolate: { nodeName: node.name } })
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<n8n-icon-button
|
||||
:class="$style.close"
|
||||
outline
|
||||
icon="times"
|
||||
type="secondary"
|
||||
size="mini"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
</header>
|
||||
<div :class="$style.logs">
|
||||
<RunDataAi
|
||||
v-if="node"
|
||||
:class="$style.runData"
|
||||
:node="node"
|
||||
:workflow="workflow"
|
||||
:slim="slim"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.logsHeader {
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: var(--font-weight-bold);
|
||||
height: 2.6875rem;
|
||||
line-height: 18px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-foreground-base);
|
||||
padding: var(--spacing-xs);
|
||||
background-color: var(--color-foreground-xlight);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.close {
|
||||
border: none;
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: 100;
|
||||
}
|
||||
}
|
||||
.logsWrapper {
|
||||
--node-icon-color: var(--color-text-base);
|
||||
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logsTitle {
|
||||
margin: 0 var(--spacing-s) var(--spacing-s);
|
||||
}
|
||||
.logs {
|
||||
padding: var(--spacing-s) 0;
|
||||
flex-grow: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,357 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import MessagesList from '@n8n/chat/components/MessagesList.vue';
|
||||
import MessageOptionTooltip from './MessageOptionTooltip.vue';
|
||||
import MessageOptionAction from './MessageOptionAction.vue';
|
||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
|
||||
import ChatInput from '@n8n/chat/components/Input.vue';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { MODAL_CONFIRM } from '@/constants';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useClipboard } from '@/composables/useClipboard';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
|
||||
interface Props {
|
||||
pastChatMessages: string[];
|
||||
messages: ChatMessage[];
|
||||
sessionId: string;
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
displayExecution: [id: string];
|
||||
sendMessage: [message: string];
|
||||
refreshSession: [];
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const messageComposable = useMessage();
|
||||
const clipboard = useClipboard();
|
||||
const locale = useI18n();
|
||||
const toast = useToast();
|
||||
|
||||
const previousMessageIndex = ref(0);
|
||||
|
||||
const inputPlaceholder = computed(() => {
|
||||
if (props.messages.length > 0) {
|
||||
return locale.baseText('chat.window.chat.placeholder');
|
||||
}
|
||||
return locale.baseText('chat.window.chat.placeholderPristine');
|
||||
});
|
||||
/** Checks if message is a text message */
|
||||
function isTextMessage(message: ChatMessage): message is ChatMessageText {
|
||||
return message.type === 'text' || !message.type;
|
||||
}
|
||||
|
||||
/** Reposts the message */
|
||||
function repostMessage(message: ChatMessageText) {
|
||||
void sendMessage(message.text);
|
||||
}
|
||||
|
||||
/** Sets the message in input for reuse */
|
||||
function reuseMessage(message: ChatMessageText) {
|
||||
chatEventBus.emit('setInputValue', message.text);
|
||||
}
|
||||
|
||||
function sendMessage(message: string) {
|
||||
previousMessageIndex.value = 0;
|
||||
emit('sendMessage', message);
|
||||
}
|
||||
|
||||
async function onRefreshSession() {
|
||||
// If there are no messages, refresh the session without asking
|
||||
if (props.messages.length === 0) {
|
||||
emit('refreshSession');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmResult = await messageComposable.confirm(
|
||||
locale.baseText('chat.window.session.reset.warning'),
|
||||
{
|
||||
title: locale.baseText('chat.window.session.reset.title'),
|
||||
type: 'warning',
|
||||
confirmButtonText: locale.baseText('chat.window.session.reset.confirm'),
|
||||
showClose: true,
|
||||
},
|
||||
);
|
||||
if (confirmResult === MODAL_CONFIRM) {
|
||||
emit('refreshSession');
|
||||
}
|
||||
}
|
||||
|
||||
function onArrowKeyDown({ currentInputValue, key }: ArrowKeyDownPayload) {
|
||||
const pastMessages = props.pastChatMessages;
|
||||
const isCurrentInputEmptyOrMatch =
|
||||
currentInputValue.length === 0 || pastMessages.includes(currentInputValue);
|
||||
|
||||
if (isCurrentInputEmptyOrMatch && (key === 'ArrowUp' || key === 'ArrowDown')) {
|
||||
// Exit if no messages
|
||||
if (pastMessages.length === 0) return;
|
||||
|
||||
// Temporarily blur to avoid cursor position issues
|
||||
chatEventBus.emit('blurInput');
|
||||
|
||||
if (pastMessages.length === 1) {
|
||||
previousMessageIndex.value = 0;
|
||||
} else {
|
||||
if (key === 'ArrowUp') {
|
||||
if (currentInputValue.length === 0 && previousMessageIndex.value === 0) {
|
||||
// Start with most recent message
|
||||
previousMessageIndex.value = pastMessages.length - 1;
|
||||
} else {
|
||||
// Move backwards through history
|
||||
previousMessageIndex.value =
|
||||
previousMessageIndex.value === 0
|
||||
? pastMessages.length - 1
|
||||
: previousMessageIndex.value - 1;
|
||||
}
|
||||
} else if (key === 'ArrowDown') {
|
||||
// Move forwards through history
|
||||
previousMessageIndex.value =
|
||||
previousMessageIndex.value === pastMessages.length - 1
|
||||
? 0
|
||||
: previousMessageIndex.value + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Get message at current index
|
||||
const selectedMessage = pastMessages[previousMessageIndex.value];
|
||||
chatEventBus.emit('setInputValue', selectedMessage);
|
||||
|
||||
// Refocus and move cursor to end
|
||||
chatEventBus.emit('focusInput');
|
||||
}
|
||||
|
||||
// Reset history navigation when typing new content that doesn't match history
|
||||
if (!isCurrentInputEmptyOrMatch) {
|
||||
previousMessageIndex.value = 0;
|
||||
}
|
||||
}
|
||||
function copySessionId() {
|
||||
void clipboard.copy(props.sessionId);
|
||||
toast.showMessage({
|
||||
title: locale.baseText('generic.copiedToClipboard'),
|
||||
message: '',
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.chat" data-test-id="workflow-lm-chat-dialog">
|
||||
<header :class="$style.chatHeader">
|
||||
<span :class="$style.chatTitle">{{ locale.baseText('chat.window.title') }}</span>
|
||||
<div :class="$style.session">
|
||||
<span>{{ locale.baseText('chat.window.session.title') }}</span>
|
||||
<n8n-tooltip placement="left">
|
||||
<template #content>
|
||||
{{ sessionId }}
|
||||
</template>
|
||||
<span :class="$style.sessionId" data-test-id="chat-session-id" @click="copySessionId">{{
|
||||
sessionId
|
||||
}}</span>
|
||||
</n8n-tooltip>
|
||||
<n8n-icon-button
|
||||
:class="$style.headerButton"
|
||||
data-test-id="refresh-session-button"
|
||||
outline
|
||||
type="secondary"
|
||||
size="mini"
|
||||
icon="undo"
|
||||
:title="locale.baseText('chat.window.session.reset.confirm')"
|
||||
@click="onRefreshSession"
|
||||
/>
|
||||
<n8n-icon-button
|
||||
v-if="showCloseButton"
|
||||
:class="$style.headerButton"
|
||||
outline
|
||||
type="secondary"
|
||||
size="mini"
|
||||
icon="times"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<main :class="$style.chatBody">
|
||||
<MessagesList :messages="messages" :class="$style.messages">
|
||||
<template #beforeMessage="{ message }">
|
||||
<MessageOptionTooltip
|
||||
v-if="message.sender === 'bot' && !message.id.includes('preload')"
|
||||
placement="right"
|
||||
data-test-id="execution-id-tooltip"
|
||||
>
|
||||
{{ locale.baseText('chat.window.chat.chatMessageOptions.executionId') }}:
|
||||
<a href="#" @click="emit('displayExecution', message.id)">{{ message.id }}</a>
|
||||
</MessageOptionTooltip>
|
||||
|
||||
<MessageOptionAction
|
||||
v-if="isTextMessage(message) && message.sender === 'user'"
|
||||
data-test-id="repost-message-button"
|
||||
icon="redo"
|
||||
:label="locale.baseText('chat.window.chat.chatMessageOptions.repostMessage')"
|
||||
placement="left"
|
||||
@click.once="repostMessage(message)"
|
||||
/>
|
||||
|
||||
<MessageOptionAction
|
||||
v-if="isTextMessage(message) && message.sender === 'user'"
|
||||
data-test-id="reuse-message-button"
|
||||
icon="copy"
|
||||
:label="locale.baseText('chat.window.chat.chatMessageOptions.reuseMessage')"
|
||||
placement="left"
|
||||
@click="reuseMessage(message)"
|
||||
/>
|
||||
</template>
|
||||
</MessagesList>
|
||||
</main>
|
||||
|
||||
<div :class="$style.messagesInput">
|
||||
<ChatInput
|
||||
data-test-id="lm-chat-inputs"
|
||||
:placeholder="inputPlaceholder"
|
||||
@arrow-key-down="onArrowKeyDown"
|
||||
>
|
||||
<template v-if="pastChatMessages.length > 0" #leftPanel>
|
||||
<div :class="$style.messagesHistory">
|
||||
<n8n-button
|
||||
title="Navigate to previous message"
|
||||
icon="chevron-up"
|
||||
type="tertiary"
|
||||
text
|
||||
size="mini"
|
||||
@click="onArrowKeyDown({ currentInputValue: '', key: 'ArrowUp' })"
|
||||
/>
|
||||
<n8n-button
|
||||
title="Navigate to next message"
|
||||
icon="chevron-down"
|
||||
type="tertiary"
|
||||
text
|
||||
size="mini"
|
||||
@click="onArrowKeyDown({ currentInputValue: '', key: 'ArrowDown' })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ChatInput>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.chat {
|
||||
--chat--spacing: var(--spacing-xs);
|
||||
--chat--message--padding: var(--spacing-2xs);
|
||||
--chat--message--font-size: var(--font-size-xs);
|
||||
--chat--input--font-size: var(--font-size-s);
|
||||
--chat--input--placeholder--font-size: var(--font-size-xs);
|
||||
--chat--message--bot--background: transparent;
|
||||
--chat--message--user--background: var(--color-text-lighter);
|
||||
--chat--message--bot--color: var(--color-text-dark);
|
||||
--chat--message--user--color: var(--color-text-dark);
|
||||
--chat--message--bot--border: none;
|
||||
--chat--message--user--border: none;
|
||||
--chat--message--user--border: none;
|
||||
--chat--input--padding: var(--spacing-xs);
|
||||
--chat--color-typing: var(--color-text-light);
|
||||
--chat--textarea--max-height: calc(var(--panel-height) * 0.3);
|
||||
--chat--message--pre--background: var(--color-foreground-light);
|
||||
--chat--textarea--height: 2.5rem;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-background-light);
|
||||
}
|
||||
.chatHeader {
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-foreground-base);
|
||||
padding: var(--chat--spacing);
|
||||
background-color: var(--color-foreground-xlight);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.chatTitle {
|
||||
font-weight: 600;
|
||||
}
|
||||
.session {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2xs);
|
||||
color: var(--color-text-base);
|
||||
max-width: 70%;
|
||||
}
|
||||
.sessionId {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
.headerButton {
|
||||
max-height: 1.1rem;
|
||||
border: none;
|
||||
}
|
||||
.chatBody {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.messages {
|
||||
border-radius: var(--border-radius-base);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
padding-top: 1.5em;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.messagesInput {
|
||||
--input-border-color: var(--border-color-base);
|
||||
--chat--input--border: none;
|
||||
|
||||
--chat--input--border-radius: 0.5rem;
|
||||
--chat--input--send--button--background: transparent;
|
||||
--chat--input--send--button--color: var(--color-primary);
|
||||
--chat--input--file--button--background: transparent;
|
||||
--chat--input--file--button--color: var(--color-primary);
|
||||
--chat--input--border-active: var(--input-focus-border-color, var(--color-secondary));
|
||||
--chat--files-spacing: var(--spacing-2xs);
|
||||
--chat--input--background: transparent;
|
||||
--chat--input--file--button--color: var(--color-button-secondary-font);
|
||||
--chat--input--file--button--color-hover: var(--color-primary);
|
||||
|
||||
[data-theme='dark'] & {
|
||||
--chat--input--text-color: var(--input-font-color, var(--color-text-dark));
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--chat--input--text-color: var(--input-font-color, var(--color-text-dark));
|
||||
}
|
||||
|
||||
padding: var(--spacing-5xs);
|
||||
margin: 0 var(--chat--spacing) var(--chat--spacing);
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
background: var(--color-lm-chat-bot-background);
|
||||
border-radius: var(--chat--input--border-radius);
|
||||
transition: border-color 200ms ease-in-out;
|
||||
border: var(--input-border-color, var(--border-color-base))
|
||||
var(--input-border-style, var(--border-style-base))
|
||||
var(--input-border-width, var(--border-width-base));
|
||||
|
||||
&:focus-within {
|
||||
--input-border-color: #4538a3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
placement: {
|
||||
type: String as PropType<'left' | 'right' | 'top' | 'bottom'>,
|
||||
default: 'top',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<n8n-tooltip :placement="placement">
|
||||
<template #content>
|
||||
{{ label }}
|
||||
</template>
|
||||
<n8n-icon :class="$style.icon" :icon="icon" size="xsmall" @click="$attrs.onClick" />
|
||||
</n8n-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0 var(--spacing-4xs);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--color-foreground-dark);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
defineProps({
|
||||
placement: {
|
||||
type: String as PropType<'left' | 'right' | 'top' | 'bottom'>,
|
||||
default: 'top',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<n8n-tooltip :placement="placement">
|
||||
<template #content>
|
||||
<slot />
|
||||
</template>
|
||||
<span :class="$style.icon">
|
||||
<n8n-icon icon="info" size="xsmall" />
|
||||
</span>
|
||||
</n8n-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0 var(--spacing-4xs);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--color-foreground-dark);
|
||||
cursor: help;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
|
||||
import { NodeConnectionType, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
||||
import type {
|
||||
ITaskData,
|
||||
INodeExecutionData,
|
||||
IBinaryKeyData,
|
||||
IDataObject,
|
||||
IBinaryData,
|
||||
BinaryFileType,
|
||||
Workflow,
|
||||
IRunExecutionData,
|
||||
} from 'n8n-workflow';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { usePinnedData } from '@/composables/usePinnedData';
|
||||
import { get, isEmpty, last } from 'lodash-es';
|
||||
import { MANUAL_CHAT_TRIGGER_NODE_TYPE, MODAL_CONFIRM } from '@/constants';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { MemoryOutput } from '../types/chat';
|
||||
import type { IExecutionPushResponse, INodeUi } from '@/Interface';
|
||||
|
||||
export type RunWorkflowChatPayload = {
|
||||
triggerNode: string;
|
||||
nodeData: ITaskData;
|
||||
source: string;
|
||||
message: string;
|
||||
};
|
||||
export interface ChatMessagingDependencies {
|
||||
chatTrigger: Ref<INodeUi | null>;
|
||||
connectedNode: Ref<INodeUi | null>;
|
||||
messages: Ref<ChatMessage[]>;
|
||||
sessionId: Ref<string>;
|
||||
workflow: ComputedRef<Workflow>;
|
||||
executionResultData: ComputedRef<IRunExecutionData['resultData'] | undefined>;
|
||||
getWorkflowResultDataByNodeName: (nodeName: string) => ITaskData[] | null;
|
||||
onRunChatWorkflow: (
|
||||
payload: RunWorkflowChatPayload,
|
||||
) => Promise<IExecutionPushResponse | undefined>;
|
||||
}
|
||||
|
||||
export function useChatMessaging({
|
||||
chatTrigger,
|
||||
connectedNode,
|
||||
messages,
|
||||
sessionId,
|
||||
workflow,
|
||||
executionResultData,
|
||||
getWorkflowResultDataByNodeName,
|
||||
onRunChatWorkflow,
|
||||
}: ChatMessagingDependencies) {
|
||||
const locale = useI18n();
|
||||
const { showError } = useToast();
|
||||
const previousMessageIndex = ref(0);
|
||||
const isLoading = ref(false);
|
||||
|
||||
/** Converts a file to binary data */
|
||||
async function convertFileToBinaryData(file: File): Promise<IBinaryData> {
|
||||
const reader = new FileReader();
|
||||
return await new Promise((resolve, reject) => {
|
||||
reader.onload = () => {
|
||||
const binaryData: IBinaryData = {
|
||||
data: (reader.result as string).split('base64,')?.[1] ?? '',
|
||||
mimeType: file.type,
|
||||
fileName: file.name,
|
||||
fileSize: `${file.size} bytes`,
|
||||
fileExtension: file.name.split('.').pop() ?? '',
|
||||
fileType: file.type.split('/')[0] as BinaryFileType,
|
||||
};
|
||||
resolve(binaryData);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
reject(new Error('Failed to convert file to binary data'));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/** Gets keyed files for the workflow input */
|
||||
async function getKeyedFiles(files: File[]): Promise<IBinaryKeyData> {
|
||||
const binaryData: IBinaryKeyData = {};
|
||||
|
||||
await Promise.all(
|
||||
files.map(async (file, index) => {
|
||||
const data = await convertFileToBinaryData(file);
|
||||
const key = `data${index}`;
|
||||
|
||||
binaryData[key] = data;
|
||||
}),
|
||||
);
|
||||
|
||||
return binaryData;
|
||||
}
|
||||
|
||||
/** Extracts file metadata */
|
||||
function extractFileMeta(file: File): IDataObject {
|
||||
return {
|
||||
fileName: file.name,
|
||||
fileSize: `${file.size} bytes`,
|
||||
fileExtension: file.name.split('.').pop() ?? '',
|
||||
fileType: file.type.split('/')[0],
|
||||
mimeType: file.type,
|
||||
};
|
||||
}
|
||||
|
||||
/** Starts workflow execution with the message */
|
||||
async function startWorkflowWithMessage(message: string, files?: File[]): Promise<void> {
|
||||
const triggerNode = chatTrigger.value;
|
||||
|
||||
if (!triggerNode) {
|
||||
showError(new Error('Chat Trigger Node could not be found!'), 'Trigger Node not found');
|
||||
return;
|
||||
}
|
||||
|
||||
let inputKey = 'chatInput';
|
||||
if (triggerNode.type === MANUAL_CHAT_TRIGGER_NODE_TYPE && triggerNode.typeVersion < 1.1) {
|
||||
inputKey = 'input';
|
||||
}
|
||||
if (triggerNode.type === CHAT_TRIGGER_NODE_TYPE) {
|
||||
inputKey = 'chatInput';
|
||||
}
|
||||
|
||||
const inputPayload: INodeExecutionData = {
|
||||
json: {
|
||||
sessionId: sessionId.value,
|
||||
action: 'sendMessage',
|
||||
[inputKey]: message,
|
||||
},
|
||||
};
|
||||
|
||||
if (files && files.length > 0) {
|
||||
const filesMeta = files.map((file) => extractFileMeta(file));
|
||||
const binaryData = await getKeyedFiles(files);
|
||||
|
||||
inputPayload.json.files = filesMeta;
|
||||
inputPayload.binary = binaryData;
|
||||
}
|
||||
const nodeData: ITaskData = {
|
||||
startTime: new Date().getTime(),
|
||||
executionTime: 0,
|
||||
executionStatus: 'success',
|
||||
data: {
|
||||
main: [[inputPayload]],
|
||||
},
|
||||
source: [null],
|
||||
};
|
||||
isLoading.value = true;
|
||||
const response = await onRunChatWorkflow({
|
||||
triggerNode: triggerNode.name,
|
||||
nodeData,
|
||||
source: 'RunData.ManualChatMessage',
|
||||
message,
|
||||
});
|
||||
isLoading.value = false;
|
||||
if (!response?.executionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
processExecutionResultData(response.executionId);
|
||||
}
|
||||
|
||||
function processExecutionResultData(executionId: string) {
|
||||
const lastNodeExecuted = executionResultData.value?.lastNodeExecuted;
|
||||
|
||||
if (!lastNodeExecuted) return;
|
||||
|
||||
const nodeResponseDataArray = get(executionResultData.value.runData, lastNodeExecuted) ?? [];
|
||||
|
||||
const nodeResponseData = nodeResponseDataArray[nodeResponseDataArray.length - 1];
|
||||
|
||||
let responseMessage: string;
|
||||
|
||||
if (get(nodeResponseData, 'error')) {
|
||||
responseMessage = '[ERROR: ' + get(nodeResponseData, 'error.message') + ']';
|
||||
} else {
|
||||
const responseData = get(nodeResponseData, 'data.main[0][0].json');
|
||||
responseMessage = extractResponseMessage(responseData);
|
||||
}
|
||||
isLoading.value = false;
|
||||
messages.value.push({
|
||||
text: responseMessage,
|
||||
sender: 'bot',
|
||||
createdAt: new Date().toISOString(),
|
||||
id: executionId ?? uuid(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Extracts response message from workflow output */
|
||||
function extractResponseMessage(responseData?: IDataObject) {
|
||||
if (!responseData || isEmpty(responseData)) {
|
||||
return locale.baseText('chat.window.chat.response.empty');
|
||||
}
|
||||
|
||||
// Paths where the response message might be located
|
||||
const paths = ['output', 'text', 'response.text'];
|
||||
const matchedPath = paths.find((path) => get(responseData, path));
|
||||
|
||||
if (!matchedPath) return JSON.stringify(responseData, null, 2);
|
||||
|
||||
const matchedOutput = get(responseData, matchedPath);
|
||||
if (typeof matchedOutput === 'object') {
|
||||
return '```json\n' + JSON.stringify(matchedOutput, null, 2) + '\n```';
|
||||
}
|
||||
|
||||
return matchedOutput?.toString() ?? '';
|
||||
}
|
||||
|
||||
/** Sends a message to the chat */
|
||||
async function sendMessage(message: string, files?: File[]) {
|
||||
previousMessageIndex.value = 0;
|
||||
if (message.trim() === '' && (!files || files.length === 0)) {
|
||||
showError(
|
||||
new Error(locale.baseText('chat.window.chat.provideMessage')),
|
||||
locale.baseText('chat.window.chat.emptyChatMessage'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const pinnedChatData = usePinnedData(chatTrigger.value);
|
||||
if (pinnedChatData.hasData.value) {
|
||||
const confirmResult = await useMessage().confirm(
|
||||
locale.baseText('chat.window.chat.unpinAndExecute.description'),
|
||||
locale.baseText('chat.window.chat.unpinAndExecute.title'),
|
||||
{
|
||||
confirmButtonText: locale.baseText('chat.window.chat.unpinAndExecute.confirm'),
|
||||
cancelButtonText: locale.baseText('chat.window.chat.unpinAndExecute.cancel'),
|
||||
},
|
||||
);
|
||||
|
||||
if (!(confirmResult === MODAL_CONFIRM)) return;
|
||||
|
||||
pinnedChatData.unsetData('unpin-and-send-chat-message-modal');
|
||||
}
|
||||
|
||||
const newMessage: ChatMessage & { sessionId: string } = {
|
||||
text: message,
|
||||
sender: 'user',
|
||||
createdAt: new Date().toISOString(),
|
||||
sessionId: sessionId.value,
|
||||
id: uuid(),
|
||||
files,
|
||||
};
|
||||
messages.value.push(newMessage);
|
||||
|
||||
await startWorkflowWithMessage(newMessage.text, files);
|
||||
}
|
||||
|
||||
function getChatMessages(): ChatMessageText[] {
|
||||
if (!connectedNode.value) return [];
|
||||
|
||||
const connectedMemoryInputs =
|
||||
workflow.value.connectionsByDestinationNode?.[connectedNode.value.name]?.[
|
||||
NodeConnectionType.AiMemory
|
||||
];
|
||||
if (!connectedMemoryInputs) return [];
|
||||
|
||||
const memoryConnection = (connectedMemoryInputs ?? []).find((i) => (i ?? []).length > 0)?.[0];
|
||||
|
||||
if (!memoryConnection) return [];
|
||||
|
||||
const nodeResultData = getWorkflowResultDataByNodeName(memoryConnection.node);
|
||||
|
||||
const memoryOutputData = (nodeResultData ?? [])
|
||||
.map((data) => get(data, ['data', NodeConnectionType.AiMemory, 0, 0, 'json']) as MemoryOutput)
|
||||
.find((data) => data && data.action === 'saveContext');
|
||||
|
||||
return (memoryOutputData?.chatHistory ?? []).map((message, index) => {
|
||||
return {
|
||||
createdAt: new Date().toISOString(),
|
||||
text: message.kwargs.content,
|
||||
id: `preload__${index}`,
|
||||
sender: last(message.id) === 'HumanMessage' ? 'user' : 'bot',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
previousMessageIndex,
|
||||
isLoading: computed(() => isLoading.value),
|
||||
sendMessage,
|
||||
extractResponseMessage,
|
||||
getChatMessages,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { ComputedRef, MaybeRef } from 'vue';
|
||||
import { ref, computed, unref } from 'vue';
|
||||
import {
|
||||
CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE,
|
||||
NodeConnectionType,
|
||||
NodeHelpers,
|
||||
} from 'n8n-workflow';
|
||||
import type { INodeTypeDescription, Workflow, INode, INodeParameters } from 'n8n-workflow';
|
||||
import {
|
||||
AI_CATEGORY_AGENTS,
|
||||
AI_CATEGORY_CHAINS,
|
||||
AI_CODE_NODE_TYPE,
|
||||
AI_SUBCATEGORY,
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
||||
} from '@/constants';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
|
||||
export interface ChatTriggerDependencies {
|
||||
getNodeByName: (name: string) => INodeUi | null;
|
||||
getNodeType: (type: string, version: number) => INodeTypeDescription | null;
|
||||
canvasNodes: MaybeRef<INodeUi[]>;
|
||||
workflow: ComputedRef<Workflow>;
|
||||
}
|
||||
|
||||
export function useChatTrigger({
|
||||
getNodeByName,
|
||||
getNodeType,
|
||||
canvasNodes,
|
||||
workflow,
|
||||
}: ChatTriggerDependencies) {
|
||||
const chatTriggerName = ref<string | null>(null);
|
||||
const connectedNode = ref<INode | null>(null);
|
||||
|
||||
const chatTriggerNode = computed(() =>
|
||||
chatTriggerName.value ? getNodeByName(chatTriggerName.value) : null,
|
||||
);
|
||||
|
||||
const allowFileUploads = computed(() => {
|
||||
return (
|
||||
(chatTriggerNode.value?.parameters?.options as INodeParameters)?.allowFileUploads === true
|
||||
);
|
||||
});
|
||||
|
||||
const allowedFilesMimeTypes = computed(() => {
|
||||
return (
|
||||
(
|
||||
chatTriggerNode.value?.parameters?.options as INodeParameters
|
||||
)?.allowedFilesMimeTypes?.toString() ?? ''
|
||||
);
|
||||
});
|
||||
|
||||
/** Gets the chat trigger node from the workflow */
|
||||
function setChatTriggerNode() {
|
||||
const triggerNode = unref(canvasNodes).find((node) =>
|
||||
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(node.type),
|
||||
);
|
||||
|
||||
if (!triggerNode) {
|
||||
return;
|
||||
}
|
||||
chatTriggerName.value = triggerNode.name;
|
||||
}
|
||||
|
||||
/** Sets the connected node after finding the trigger */
|
||||
function setConnectedNode() {
|
||||
const triggerNode = chatTriggerNode.value;
|
||||
|
||||
if (!triggerNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chatChildren = workflow.value.getChildNodes(triggerNode.name);
|
||||
|
||||
const chatRootNode = chatChildren
|
||||
.reverse()
|
||||
.map((nodeName: string) => getNodeByName(nodeName))
|
||||
.filter((n): n is INodeUi => n !== null)
|
||||
// Reverse the nodes to match the last node logs first
|
||||
.reverse()
|
||||
.find((storeNode: INodeUi): boolean => {
|
||||
// Skip summarization nodes
|
||||
if (storeNode.type === CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE) return false;
|
||||
const nodeType = getNodeType(storeNode.type, storeNode.typeVersion);
|
||||
|
||||
if (!nodeType) return false;
|
||||
|
||||
// Check if node is an AI agent or chain based on its metadata
|
||||
const isAgent =
|
||||
nodeType.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_AGENTS);
|
||||
const isChain =
|
||||
nodeType.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_CHAINS);
|
||||
|
||||
// Handle custom AI Langchain Code nodes that could act as chains or agents
|
||||
let isCustomChainOrAgent = false;
|
||||
if (nodeType.name === AI_CODE_NODE_TYPE) {
|
||||
// Get node connection types for inputs and outputs
|
||||
const inputs = NodeHelpers.getNodeInputs(workflow.value, storeNode, nodeType);
|
||||
const inputTypes = NodeHelpers.getConnectionTypes(inputs);
|
||||
|
||||
const outputs = NodeHelpers.getNodeOutputs(workflow.value, storeNode, nodeType);
|
||||
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
|
||||
|
||||
// Validate if node has required AI connection types
|
||||
if (
|
||||
inputTypes.includes(NodeConnectionType.AiLanguageModel) &&
|
||||
inputTypes.includes(NodeConnectionType.Main) &&
|
||||
outputTypes.includes(NodeConnectionType.Main)
|
||||
) {
|
||||
isCustomChainOrAgent = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if node is not an AI component
|
||||
if (!isAgent && !isChain && !isCustomChainOrAgent) return false;
|
||||
|
||||
// Check if this node is connected to the trigger node
|
||||
const parentNodes = workflow.value.getParentNodes(storeNode.name);
|
||||
const isChatChild = parentNodes.some(
|
||||
(parentNodeName) => parentNodeName === triggerNode.name,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
const result = Boolean(isChatChild && (isAgent || isChain || isCustomChainOrAgent));
|
||||
return result;
|
||||
});
|
||||
connectedNode.value = chatRootNode ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
allowFileUploads,
|
||||
allowedFilesMimeTypes,
|
||||
chatTriggerNode,
|
||||
connectedNode: computed(() => connectedNode.value),
|
||||
setChatTriggerNode,
|
||||
setConnectedNode,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { Ref } from 'vue';
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watchEffect } from 'vue';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import type { IChatResizeStyles } from '../types/chat';
|
||||
import { useStorage } from '@/composables/useStorage';
|
||||
import { type ResizeData } from '@n8n/design-system';
|
||||
|
||||
const LOCAL_STORAGE_PANEL_HEIGHT = 'N8N_CANVAS_CHAT_HEIGHT';
|
||||
const LOCAL_STORAGE_PANEL_WIDTH = 'N8N_CANVAS_CHAT_WIDTH';
|
||||
|
||||
// Percentage of container width for chat panel constraints
|
||||
const MAX_WIDTH_PERCENTAGE = 0.8;
|
||||
const MIN_WIDTH_PERCENTAGE = 0.3;
|
||||
|
||||
// Percentage of window height for panel constraints
|
||||
const MIN_HEIGHT_PERCENTAGE = 0.3;
|
||||
const MAX_HEIGHT_PERCENTAGE = 0.75;
|
||||
|
||||
export function useResize(container: Ref<HTMLElement | undefined>) {
|
||||
const storage = {
|
||||
height: useStorage(LOCAL_STORAGE_PANEL_HEIGHT),
|
||||
width: useStorage(LOCAL_STORAGE_PANEL_WIDTH),
|
||||
};
|
||||
|
||||
const dimensions = {
|
||||
container: ref(0), // Container width
|
||||
minHeight: ref(0),
|
||||
maxHeight: ref(0),
|
||||
chat: ref(0), // Chat panel width
|
||||
logs: ref(0),
|
||||
height: ref(0),
|
||||
};
|
||||
|
||||
/** Computed styles for root element based on current dimensions */
|
||||
const rootStyles = computed<IChatResizeStyles>(() => ({
|
||||
'--panel-height': `${dimensions.height.value}px`,
|
||||
'--chat-width': `${dimensions.chat.value}px`,
|
||||
}));
|
||||
|
||||
const panelToContainerRatio = computed(() => {
|
||||
const chatRatio = dimensions.chat.value / dimensions.container.value;
|
||||
const containerRatio = dimensions.container.value / window.screen.width;
|
||||
return {
|
||||
chat: chatRatio.toFixed(2),
|
||||
logs: (1 - chatRatio).toFixed(2),
|
||||
container: containerRatio.toFixed(2),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Constrains height to min/max bounds and updates panel height
|
||||
*/
|
||||
function onResize(newHeight: number) {
|
||||
const { minHeight, maxHeight } = dimensions;
|
||||
dimensions.height.value = Math.min(Math.max(newHeight, minHeight.value), maxHeight.value);
|
||||
}
|
||||
|
||||
function onResizeDebounced(data: ResizeData) {
|
||||
void useDebounce().callDebounced(onResize, { debounceTime: 10, trailing: true }, data.height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrains chat width to min/max percentage of container width
|
||||
*/
|
||||
function onResizeChat(width: number) {
|
||||
const containerWidth = dimensions.container.value;
|
||||
const maxWidth = containerWidth * MAX_WIDTH_PERCENTAGE;
|
||||
const minWidth = containerWidth * MIN_WIDTH_PERCENTAGE;
|
||||
|
||||
dimensions.chat.value = Math.min(Math.max(width, minWidth), maxWidth);
|
||||
dimensions.logs.value = dimensions.container.value - dimensions.chat.value;
|
||||
}
|
||||
|
||||
function onResizeChatDebounced(data: ResizeData) {
|
||||
void useDebounce().callDebounced(
|
||||
onResizeChat,
|
||||
{ debounceTime: 10, trailing: true },
|
||||
data.width,
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Initializes dimensions from localStorage if available
|
||||
*/
|
||||
function restorePersistedDimensions() {
|
||||
const persistedHeight = parseInt(storage.height.value ?? '0', 10);
|
||||
const persistedWidth = parseInt(storage.width.value ?? '0', 10);
|
||||
|
||||
if (persistedHeight) onResize(persistedHeight);
|
||||
if (persistedWidth) onResizeChat(persistedWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates container width and height constraints on window resize
|
||||
*/
|
||||
function onWindowResize() {
|
||||
if (!container.value) return;
|
||||
|
||||
// Update container width and adjust chat panel if needed
|
||||
dimensions.container.value = container.value.getBoundingClientRect().width;
|
||||
onResizeChat(dimensions.chat.value);
|
||||
|
||||
// Update height constraints and adjust panel height if needed
|
||||
dimensions.minHeight.value = window.innerHeight * MIN_HEIGHT_PERCENTAGE;
|
||||
dimensions.maxHeight.value = window.innerHeight * MAX_HEIGHT_PERCENTAGE;
|
||||
onResize(dimensions.height.value);
|
||||
}
|
||||
|
||||
// Persist dimensions to localStorage when they change
|
||||
watchEffect(() => {
|
||||
const { chat, height } = dimensions;
|
||||
if (chat.value > 0) storage.width.value = chat.value.toString();
|
||||
if (height.value > 0) storage.height.value = height.value.toString();
|
||||
});
|
||||
|
||||
// Initialize dimensions when container is available
|
||||
watchEffect(() => {
|
||||
if (container.value) {
|
||||
onWindowResize();
|
||||
restorePersistedDimensions();
|
||||
}
|
||||
});
|
||||
|
||||
// Window resize handling
|
||||
onMounted(() => window.addEventListener('resize', onWindowResize));
|
||||
onBeforeUnmount(() => window.removeEventListener('resize', onWindowResize));
|
||||
|
||||
return {
|
||||
height: dimensions.height,
|
||||
chatWidth: dimensions.chat,
|
||||
logsWidth: dimensions.logs,
|
||||
rootStyles,
|
||||
onWindowResize,
|
||||
onResizeDebounced,
|
||||
onResizeChatDebounced,
|
||||
panelToContainerRatio,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface LangChainMessage {
|
||||
id: string[];
|
||||
kwargs: {
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MemoryOutput {
|
||||
action: string;
|
||||
chatHistory?: LangChainMessage[];
|
||||
}
|
||||
|
||||
export interface IChatMessageResponse {
|
||||
executionId?: string;
|
||||
success: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export interface IChatResizeStyles {
|
||||
'--panel-height': string;
|
||||
'--chat-width': string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import ChangePasswordModal from '@/components/ChangePasswordModal.vue';
|
||||
import type { createPinia } from 'pinia';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
|
||||
|
||||
const renderComponent = createComponentRenderer(ChangePasswordModal);
|
||||
|
||||
describe('ChangePasswordModal', () => {
|
||||
let pinia: ReturnType<typeof createPinia>;
|
||||
|
||||
beforeEach(() => {
|
||||
createAppModals();
|
||||
pinia = createTestingPinia({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupAppModals();
|
||||
});
|
||||
|
||||
it('should render correctly', () => {
|
||||
const wrapper = renderComponent({ pinia });
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { CHANGE_PASSWORD_MODAL_KEY } from '../constants';
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { createFormEventBus } from '@n8n/design-system/utils';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import type { IFormInputs, IFormInput } from '@/Interface';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const config = ref<IFormInputs | null>(null);
|
||||
const formBus = createFormEventBus();
|
||||
const modalBus = createEventBus();
|
||||
const password = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
const i18n = useI18n();
|
||||
const { showMessage, showError } = useToast();
|
||||
const usersStore = useUsersStore();
|
||||
|
||||
const passwordsMatch = (value: string | number | boolean | null | undefined) => {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value !== password.value) {
|
||||
return {
|
||||
messageKey: 'auth.changePassword.passwordsMustMatchError',
|
||||
};
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const onInput = (e: { name: string; value: string }) => {
|
||||
if (e.name === 'password') {
|
||||
password.value = e.value;
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: {
|
||||
currentPassword: string;
|
||||
password: string;
|
||||
mfaCode?: string;
|
||||
}) => {
|
||||
try {
|
||||
loading.value = true;
|
||||
await usersStore.updateCurrentUserPassword({
|
||||
currentPassword: values.currentPassword,
|
||||
newPassword: values.password,
|
||||
mfaCode: values.mfaCode,
|
||||
});
|
||||
|
||||
showMessage({
|
||||
type: 'success',
|
||||
title: i18n.baseText('auth.changePassword.passwordUpdated'),
|
||||
message: i18n.baseText('auth.changePassword.passwordUpdatedMessage'),
|
||||
});
|
||||
|
||||
modalBus.emit('close');
|
||||
} catch (error) {
|
||||
showError(error, i18n.baseText('auth.changePassword.error'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmitClick = () => {
|
||||
formBus.emit('submit');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const inputs: Record<string, IFormInput> = {
|
||||
currentPassword: {
|
||||
name: 'currentPassword',
|
||||
properties: {
|
||||
label: i18n.baseText('auth.changePassword.currentPassword'),
|
||||
type: 'password',
|
||||
required: true,
|
||||
autocomplete: 'current-password',
|
||||
capitalize: true,
|
||||
focusInitially: true,
|
||||
},
|
||||
},
|
||||
mfaCode: {
|
||||
name: 'mfaCode',
|
||||
properties: {
|
||||
label: i18n.baseText('auth.changePassword.mfaCode'),
|
||||
type: 'text',
|
||||
required: true,
|
||||
capitalize: true,
|
||||
},
|
||||
},
|
||||
newPassword: {
|
||||
name: 'password',
|
||||
properties: {
|
||||
label: i18n.baseText('auth.newPassword'),
|
||||
type: 'password',
|
||||
required: true,
|
||||
validationRules: [{ name: 'DEFAULT_PASSWORD_RULES' }],
|
||||
infoText: i18n.baseText('auth.defaultPasswordRequirements'),
|
||||
autocomplete: 'new-password',
|
||||
capitalize: true,
|
||||
},
|
||||
},
|
||||
newPasswordAgain: {
|
||||
name: 'password2',
|
||||
properties: {
|
||||
label: i18n.baseText('auth.changePassword.reenterNewPassword'),
|
||||
type: 'password',
|
||||
required: true,
|
||||
validators: {
|
||||
TWO_PASSWORDS_MATCH: {
|
||||
validate: passwordsMatch,
|
||||
},
|
||||
},
|
||||
validationRules: [{ name: 'TWO_PASSWORDS_MATCH' }],
|
||||
autocomplete: 'new-password',
|
||||
capitalize: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { currentUser } = usersStore;
|
||||
|
||||
const form: IFormInputs = currentUser?.mfaEnabled
|
||||
? [inputs.currentPassword, inputs.mfaCode, inputs.newPassword, inputs.newPasswordAgain]
|
||||
: [inputs.currentPassword, inputs.newPassword, inputs.newPasswordAgain];
|
||||
|
||||
config.value = form;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:name="CHANGE_PASSWORD_MODAL_KEY"
|
||||
:title="i18n.baseText('auth.changePassword')"
|
||||
:center="true"
|
||||
width="460px"
|
||||
:event-bus="modalBus"
|
||||
@enter="onSubmitClick"
|
||||
>
|
||||
<template #content>
|
||||
<n8n-form-inputs
|
||||
:inputs="config"
|
||||
:event-bus="formBus"
|
||||
:column-view="true"
|
||||
@update="onInput"
|
||||
@submit="onSubmit"
|
||||
/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<n8n-button
|
||||
:loading="loading"
|
||||
:label="i18n.baseText('auth.changePassword')"
|
||||
float="right"
|
||||
data-test-id="change-password-button"
|
||||
@click="onSubmitClick"
|
||||
/>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
import ChatEmbedModal from '@/components/ChatEmbedModal.vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { CHAT_EMBED_MODAL_KEY, STORES, WEBHOOK_NODE_TYPE } from '@/constants';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
|
||||
|
||||
const renderComponent = createComponentRenderer(ChatEmbedModal, {
|
||||
props: {
|
||||
teleported: false,
|
||||
appendToBody: false,
|
||||
},
|
||||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.UI]: {
|
||||
modalsById: {
|
||||
[CHAT_EMBED_MODAL_KEY]: { open: true },
|
||||
},
|
||||
},
|
||||
[STORES.WORKFLOWS]: {
|
||||
workflow: {
|
||||
nodes: [{ type: WEBHOOK_NODE_TYPE }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
describe('ChatEmbedModal', () => {
|
||||
beforeEach(() => {
|
||||
createAppModals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupAppModals();
|
||||
});
|
||||
it('should render correctly', async () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
await waitFor(() => expect(getByTestId('chatEmbed-modal')).toBeInTheDocument());
|
||||
|
||||
const modalContainer = getByTestId('chatEmbed-modal');
|
||||
const tabs = modalContainer.querySelectorAll('.n8n-tabs .tab');
|
||||
const activeTab = modalContainer.querySelector('.n8n-tabs .tab.activeTab');
|
||||
const editor = modalContainer.querySelector('.cm-editor');
|
||||
|
||||
expect(tabs).toHaveLength(4);
|
||||
expect(activeTab).toBeVisible();
|
||||
expect(activeTab).toHaveTextContent('CDN Embed');
|
||||
expect(editor).toBeVisible();
|
||||
});
|
||||
});
|
||||
203
packages/frontend/editor-ui/src/components/ChatEmbedModal.vue
Normal file
203
packages/frontend/editor-ui/src/components/ChatEmbedModal.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import type { EventBus } from '@n8n/utils/event-bus';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import Modal from './Modal.vue';
|
||||
import { CHAT_EMBED_MODAL_KEY, CHAT_TRIGGER_NODE_TYPE, WEBHOOK_NODE_TYPE } from '../constants';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import HtmlEditor from '@/components/HtmlEditor/HtmlEditor.vue';
|
||||
import JsEditor from '@/components/JsEditor/JsEditor.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modalBus?: EventBus;
|
||||
}>(),
|
||||
{
|
||||
modalBus: () => createEventBus(),
|
||||
},
|
||||
);
|
||||
|
||||
const i18n = useI18n();
|
||||
const rootStore = useRootStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
|
||||
type ChatEmbedModalTabValue = 'cdn' | 'vue' | 'react' | 'other';
|
||||
type ChatEmbedModalTab = {
|
||||
label: string;
|
||||
value: ChatEmbedModalTabValue;
|
||||
};
|
||||
const tabs = ref<ChatEmbedModalTab[]>([
|
||||
{
|
||||
label: 'CDN Embed',
|
||||
value: 'cdn',
|
||||
},
|
||||
{
|
||||
label: 'Vue Embed',
|
||||
value: 'vue',
|
||||
},
|
||||
{
|
||||
label: 'React Embed',
|
||||
value: 'react',
|
||||
},
|
||||
{
|
||||
label: 'Other',
|
||||
value: 'other',
|
||||
},
|
||||
]);
|
||||
const currentTab = ref<ChatEmbedModalTabValue>('cdn');
|
||||
|
||||
const webhookNode = computed(() => {
|
||||
for (const type of [CHAT_TRIGGER_NODE_TYPE, WEBHOOK_NODE_TYPE]) {
|
||||
const node = workflowsStore.workflow.nodes.find((node) => node.type === type);
|
||||
if (node) {
|
||||
// This has to be kept up-to-date with the mode in the Chat-Trigger node
|
||||
if (type === CHAT_TRIGGER_NODE_TYPE && !node.parameters.public) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
node,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const webhookUrl = computed(() => {
|
||||
const url = `${rootStore.webhookUrl}${
|
||||
webhookNode.value ? `/${webhookNode.value.node.webhookId}` : ''
|
||||
}`;
|
||||
|
||||
return webhookNode.value?.type === CHAT_TRIGGER_NODE_TYPE ? `${url}/chat` : url;
|
||||
});
|
||||
|
||||
function indentLines(code: string, indent: string = ' ') {
|
||||
return code
|
||||
.split('\n')
|
||||
.map((line) => `${indent}${line}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const importCode = 'import'; // To avoid vite from parsing the import statement
|
||||
const commonCode = computed(() => ({
|
||||
import: `${importCode} '@n8n/chat/style.css';
|
||||
${importCode} { createChat } from '@n8n/chat';`,
|
||||
createChat: `createChat({
|
||||
webhookUrl: '${webhookUrl.value}'
|
||||
});`,
|
||||
install: 'npm install @n8n/chat',
|
||||
}));
|
||||
|
||||
const cdnCode = computed(
|
||||
() => `<link href="https://cdn.jsdelivr.net/npm/@n8n/chat/dist/style.css" rel="stylesheet" />
|
||||
<script type="module">
|
||||
${importCode} { createChat } from 'https://cdn.jsdelivr.net/npm/@n8n/chat/dist/chat.bundle.es.js';
|
||||
|
||||
${commonCode.value.createChat}
|
||||
</${'script'}>`,
|
||||
);
|
||||
|
||||
const vueCode = computed(
|
||||
() => `<script lang="ts" setup>
|
||||
${importCode} { onMounted } from 'vue';
|
||||
${commonCode.value.import}
|
||||
|
||||
onMounted(() => {
|
||||
${indentLines(commonCode.value.createChat)}
|
||||
});
|
||||
</${'script'}>`,
|
||||
);
|
||||
|
||||
const reactCode = computed(
|
||||
() => `${importCode} { useEffect } from 'react';
|
||||
${commonCode.value.import}
|
||||
|
||||
export const App = () => {
|
||||
useEffect(() => {
|
||||
${indentLines(commonCode.value.createChat, ' ')}
|
||||
}, []);
|
||||
|
||||
return (<div></div>);
|
||||
};
|
||||
|
||||
</${'script'}>`,
|
||||
);
|
||||
|
||||
const otherCode = computed(
|
||||
() => `${commonCode.value.import}
|
||||
|
||||
${commonCode.value.createChat}`,
|
||||
);
|
||||
|
||||
function closeDialog() {
|
||||
props.modalBus.emit('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
max-width="960px"
|
||||
:title="i18n.baseText('chatEmbed.title')"
|
||||
:event-bus="modalBus"
|
||||
:name="CHAT_EMBED_MODAL_KEY"
|
||||
:center="true"
|
||||
>
|
||||
<template #content>
|
||||
<div :class="$style.container">
|
||||
<n8n-tabs v-model="currentTab" :options="tabs" />
|
||||
|
||||
<div v-if="currentTab !== 'cdn'">
|
||||
<div class="mb-s">
|
||||
<n8n-text>
|
||||
{{ i18n.baseText('chatEmbed.install') }}
|
||||
</n8n-text>
|
||||
</div>
|
||||
<HtmlEditor :model-value="commonCode.install" is-read-only />
|
||||
</div>
|
||||
|
||||
<div class="mb-s">
|
||||
<n8n-text>
|
||||
<i18n-t :keypath="`chatEmbed.paste.${currentTab}`">
|
||||
<template #code>
|
||||
<code>{{ i18n.baseText(`chatEmbed.paste.${currentTab}.file`) }}</code>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</n8n-text>
|
||||
</div>
|
||||
|
||||
<HtmlEditor v-if="currentTab === 'cdn'" :model-value="cdnCode" is-read-only />
|
||||
<HtmlEditor v-if="currentTab === 'vue'" :model-value="vueCode" is-read-only />
|
||||
<JsEditor v-if="currentTab === 'react'" :model-value="reactCode" is-read-only />
|
||||
<JsEditor v-if="currentTab === 'other'" :model-value="otherCode" is-read-only />
|
||||
|
||||
<n8n-text>
|
||||
{{ i18n.baseText('chatEmbed.packageInfo.description') }}
|
||||
<n8n-link :href="i18n.baseText('chatEmbed.url')" new-window bold>
|
||||
{{ i18n.baseText('chatEmbed.packageInfo.link') }}
|
||||
</n8n-link>
|
||||
</n8n-text>
|
||||
|
||||
<n8n-info-tip class="mt-s">
|
||||
{{ i18n.baseText('chatEmbed.chatTriggerNode') }}
|
||||
</n8n-info-tip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="action-buttons">
|
||||
<n8n-button float="right" :label="i18n.baseText('chatEmbed.close')" @click="closeDialog" />
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.container > * {
|
||||
margin-bottom: var(--spacing-s);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,390 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { snakeCase } from 'lodash-es';
|
||||
import { useSessionStorage } from '@vueuse/core';
|
||||
|
||||
import { N8nButton, N8nInput, N8nTooltip } from '@n8n/design-system/components';
|
||||
import { randomInt } from 'n8n-workflow';
|
||||
import type { CodeExecutionMode, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import type { BaseTextKey } from '@/plugins/i18n';
|
||||
import type { INodeUi, Schema } from '@/Interface';
|
||||
import { generateCodeForPrompt } from '@/api/ai';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useDataSchema } from '@/composables/useDataSchema';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { executionDataToJson } from '@/utils/nodeTypesUtils';
|
||||
import {
|
||||
ASK_AI_MAX_PROMPT_LENGTH,
|
||||
ASK_AI_MIN_PROMPT_LENGTH,
|
||||
ASK_AI_LOADING_DURATION_MS,
|
||||
} from '@/constants';
|
||||
import type { AskAiRequest } from '@/types/assistant.types';
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [code: string];
|
||||
replaceCode: [code: string];
|
||||
startedLoading: [];
|
||||
finishedLoading: [];
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
hasChanges: boolean;
|
||||
}>();
|
||||
|
||||
const { getSchemaForExecutionData, getInputDataWithPinned } = useDataSchema();
|
||||
const i18n = useI18n();
|
||||
|
||||
const loadingPhraseIndex = ref(0);
|
||||
const loaderProgress = ref(0);
|
||||
|
||||
const isLoading = ref(false);
|
||||
const prompt = ref('');
|
||||
const parentNodes = ref<INodeUi[]>([]);
|
||||
|
||||
const isSubmitEnabled = computed(() => {
|
||||
return (
|
||||
!isEachItemMode.value &&
|
||||
prompt.value.length >= ASK_AI_MIN_PROMPT_LENGTH &&
|
||||
hasExecutionData.value
|
||||
);
|
||||
});
|
||||
const hasExecutionData = computed(() => (useNDVStore().ndvInputData || []).length > 0);
|
||||
const loadingString = computed(() =>
|
||||
i18n.baseText(`codeNodeEditor.askAi.loadingPhrase${loadingPhraseIndex.value}` as BaseTextKey),
|
||||
);
|
||||
const isEachItemMode = computed(() => {
|
||||
const mode = useNDVStore().activeNode?.parameters.mode as CodeExecutionMode;
|
||||
|
||||
return mode === 'runOnceForEachItem';
|
||||
});
|
||||
|
||||
function getErrorMessageByStatusCode(statusCode: number) {
|
||||
const errorMessages: Record<number, string> = {
|
||||
400: i18n.baseText('codeNodeEditor.askAi.generationFailedUnknown'),
|
||||
413: i18n.baseText('codeNodeEditor.askAi.generationFailedTooLarge'),
|
||||
429: i18n.baseText('codeNodeEditor.askAi.generationFailedRate'),
|
||||
500: i18n.baseText('codeNodeEditor.askAi.generationFailedUnknown'),
|
||||
};
|
||||
|
||||
return errorMessages[statusCode] || i18n.baseText('codeNodeEditor.askAi.generationFailedUnknown');
|
||||
}
|
||||
|
||||
function getParentNodes() {
|
||||
const activeNode = useNDVStore().activeNode;
|
||||
const { getCurrentWorkflow, getNodeByName } = useWorkflowsStore();
|
||||
const workflow = getCurrentWorkflow();
|
||||
|
||||
if (!activeNode || !workflow) return [];
|
||||
|
||||
return workflow
|
||||
.getParentNodesByDepth(activeNode?.name)
|
||||
.filter(({ name }, i, nodes) => {
|
||||
return name !== activeNode.name && nodes.findIndex((node) => node.name === name) === i;
|
||||
})
|
||||
.map((n) => getNodeByName(n.name))
|
||||
.filter((n) => n !== null);
|
||||
}
|
||||
|
||||
function getSchemas() {
|
||||
const parentNodesNames = parentNodes.value.map((node) => node?.name);
|
||||
const parentNodesSchemas: Array<{ nodeName: string; schema: Schema }> = parentNodes.value
|
||||
.map((node) => {
|
||||
const inputData: INodeExecutionData[] = getInputDataWithPinned(node);
|
||||
|
||||
return {
|
||||
nodeName: node?.name || '',
|
||||
schema: getSchemaForExecutionData(executionDataToJson(inputData), true),
|
||||
};
|
||||
})
|
||||
.filter((node) => node.schema?.value.length > 0);
|
||||
|
||||
const inputSchema = parentNodesSchemas.shift();
|
||||
|
||||
return {
|
||||
parentNodesNames,
|
||||
inputSchema,
|
||||
parentNodesSchemas,
|
||||
};
|
||||
}
|
||||
|
||||
function startLoading() {
|
||||
emit('startedLoading');
|
||||
loaderProgress.value = 0;
|
||||
isLoading.value = true;
|
||||
|
||||
triggerLoadingChange();
|
||||
}
|
||||
|
||||
function stopLoading() {
|
||||
loaderProgress.value = 100;
|
||||
emit('finishedLoading');
|
||||
|
||||
setTimeout(() => {
|
||||
isLoading.value = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const { restApiContext } = useRootStore();
|
||||
const { activeNode } = useNDVStore();
|
||||
const { showMessage } = useToast();
|
||||
const { alert } = useMessage();
|
||||
if (!activeNode) return;
|
||||
const schemas = getSchemas();
|
||||
|
||||
if (props.hasChanges) {
|
||||
const confirmModal = await alert(i18n.baseText('codeNodeEditor.askAi.areYouSureToReplace'), {
|
||||
title: i18n.baseText('codeNodeEditor.askAi.replaceCurrentCode'),
|
||||
confirmButtonText: i18n.baseText('codeNodeEditor.askAi.generateCodeAndReplace'),
|
||||
showClose: true,
|
||||
showCancelButton: true,
|
||||
});
|
||||
|
||||
if (confirmModal === 'cancel') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
startLoading();
|
||||
|
||||
const rootStore = useRootStore();
|
||||
|
||||
const payload: AskAiRequest.RequestPayload = {
|
||||
question: prompt.value,
|
||||
context: {
|
||||
schema: schemas.parentNodesSchemas,
|
||||
inputSchema: schemas.inputSchema!,
|
||||
ndvPushRef: useNDVStore().pushRef,
|
||||
pushRef: rootStore.pushRef,
|
||||
},
|
||||
forNode: 'code',
|
||||
};
|
||||
|
||||
try {
|
||||
const { code } = await generateCodeForPrompt(restApiContext, payload);
|
||||
|
||||
stopLoading();
|
||||
emit('replaceCode', code);
|
||||
showMessage({
|
||||
type: 'success',
|
||||
title: i18n.baseText('codeNodeEditor.askAi.generationCompleted'),
|
||||
});
|
||||
useTelemetry().trackAskAI('askAi.generationFinished', {
|
||||
prompt: prompt.value,
|
||||
code,
|
||||
});
|
||||
} catch (error) {
|
||||
showMessage({
|
||||
type: 'error',
|
||||
title: i18n.baseText('codeNodeEditor.askAi.generationFailed'),
|
||||
message: getErrorMessageByStatusCode(error.httpStatusCode || error?.response.status),
|
||||
});
|
||||
stopLoading();
|
||||
useTelemetry().trackAskAI('askAi.generationFinished', {
|
||||
prompt: prompt.value,
|
||||
code: '',
|
||||
hasError: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
function triggerLoadingChange() {
|
||||
const loadingPhraseUpdateMs = 2000;
|
||||
const loadingPhrasesCount = 8;
|
||||
let start: number | null = null;
|
||||
let lastPhraseChange = 0;
|
||||
const step = (timestamp: number) => {
|
||||
if (!start) start = timestamp;
|
||||
|
||||
// Loading phrase change
|
||||
if (!lastPhraseChange || timestamp - lastPhraseChange >= loadingPhraseUpdateMs) {
|
||||
loadingPhraseIndex.value = randomInt(loadingPhrasesCount);
|
||||
lastPhraseChange = timestamp;
|
||||
}
|
||||
|
||||
// Loader progress change
|
||||
const elapsed = timestamp - start;
|
||||
loaderProgress.value = Math.min((elapsed / ASK_AI_LOADING_DURATION_MS) * 100, 100);
|
||||
|
||||
if (!isLoading.value) return;
|
||||
if (loaderProgress.value < 100 || lastPhraseChange + loadingPhraseUpdateMs > timestamp) {
|
||||
window.requestAnimationFrame(step);
|
||||
}
|
||||
};
|
||||
|
||||
window.requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function getSessionStoragePrompt() {
|
||||
const codeNodeName = (useNDVStore().activeNode?.name as string) ?? '';
|
||||
const hashedCode = snakeCase(codeNodeName);
|
||||
|
||||
return useSessionStorage(`ask_ai_prompt__${hashedCode}`, '');
|
||||
}
|
||||
|
||||
function onPromptInput(inputValue: string) {
|
||||
getSessionStoragePrompt().value = inputValue;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Restore prompt from session storage(with empty string fallback)
|
||||
prompt.value = getSessionStoragePrompt().value;
|
||||
parentNodes.value = getParentNodes();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<p :class="$style.intro" v-text="i18n.baseText('codeNodeEditor.askAi.intro')" />
|
||||
<div :class="$style.inputContainer">
|
||||
<div :class="$style.meta">
|
||||
<span
|
||||
v-show="prompt.length > 1"
|
||||
:class="$style.counter"
|
||||
data-test-id="ask-ai-prompt-counter"
|
||||
v-text="`${prompt.length} / ${ASK_AI_MAX_PROMPT_LENGTH}`"
|
||||
/>
|
||||
<a href="https://docs.n8n.io/code-examples/ai-code" target="_blank" :class="$style.help">
|
||||
<n8n-icon icon="question-circle" color="text-light" size="large" />{{
|
||||
i18n.baseText('codeNodeEditor.askAi.help')
|
||||
}}
|
||||
</a>
|
||||
</div>
|
||||
<N8nInput
|
||||
v-model="prompt"
|
||||
:class="$style.input"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
:maxlength="ASK_AI_MAX_PROMPT_LENGTH"
|
||||
:placeholder="i18n.baseText('codeNodeEditor.askAi.placeholder')"
|
||||
data-test-id="ask-ai-prompt-input"
|
||||
@input="onPromptInput"
|
||||
/>
|
||||
</div>
|
||||
<div :class="$style.controls">
|
||||
<div v-if="isLoading" :class="$style.loader">
|
||||
<transition name="text-fade-in-out" mode="out-in">
|
||||
<div :key="loadingPhraseIndex" v-text="loadingString" />
|
||||
</transition>
|
||||
<n8n-circle-loader :radius="8" :progress="loaderProgress" :stroke-width="3" />
|
||||
</div>
|
||||
<N8nTooltip v-else :disabled="isSubmitEnabled">
|
||||
<div>
|
||||
<N8nButton
|
||||
:disabled="!isSubmitEnabled"
|
||||
size="small"
|
||||
data-test-id="ask-ai-cta"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ i18n.baseText('codeNodeEditor.askAi.generateCode') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<template #content>
|
||||
<span
|
||||
v-if="!hasExecutionData"
|
||||
data-test-id="ask-ai-cta-tooltip-no-input-data"
|
||||
v-text="i18n.baseText('codeNodeEditor.askAi.noInputData')"
|
||||
/>
|
||||
<span
|
||||
v-else-if="prompt.length === 0"
|
||||
data-test-id="ask-ai-cta-tooltip-no-prompt"
|
||||
v-text="i18n.baseText('codeNodeEditor.askAi.noPrompt')"
|
||||
/>
|
||||
<span
|
||||
v-else-if="isEachItemMode"
|
||||
data-test-id="ask-ai-cta-tooltip-only-all-items-mode"
|
||||
v-text="i18n.baseText('codeNodeEditor.askAi.onlyAllItemsMode')"
|
||||
/>
|
||||
<span
|
||||
v-else-if="prompt.length < ASK_AI_MIN_PROMPT_LENGTH"
|
||||
data-test-id="ask-ai-cta-tooltip-prompt-too-short"
|
||||
v-text="
|
||||
i18n.baseText('codeNodeEditor.askAi.promptTooShort', {
|
||||
interpolate: { minLength: ASK_AI_MIN_PROMPT_LENGTH.toString() },
|
||||
})
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.text-fade-in-out-enter-active,
|
||||
.text-fade-in-out-leave-active {
|
||||
transition:
|
||||
opacity 0.5s ease-in-out,
|
||||
transform 0.5s ease-in-out;
|
||||
}
|
||||
.text-fade-in-out-enter,
|
||||
.text-fade-in-out-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
}
|
||||
.text-fade-in-out-enter-to,
|
||||
.text-fade-in-out-leave {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style module lang="scss">
|
||||
.input * {
|
||||
border: 0 !important;
|
||||
}
|
||||
.input textarea {
|
||||
font-size: var(--font-size-2xs);
|
||||
padding-bottom: var(--spacing-2xl);
|
||||
font-family: var(--font-family);
|
||||
resize: none;
|
||||
}
|
||||
.intro {
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-text-dark);
|
||||
padding: var(--spacing-2xs) var(--spacing-xs) 0;
|
||||
}
|
||||
.loader {
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-text-dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2xs);
|
||||
}
|
||||
.inputContainer {
|
||||
position: relative;
|
||||
}
|
||||
.help {
|
||||
text-decoration: underline;
|
||||
margin-left: auto;
|
||||
color: #909399;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
position: absolute;
|
||||
bottom: var(--spacing-2xs);
|
||||
left: var(--spacing-xs);
|
||||
right: var(--spacing-xs);
|
||||
z-index: 1;
|
||||
|
||||
* {
|
||||
font-size: var(--font-size-2xs);
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
.counter {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
.controls {
|
||||
padding: var(--spacing-2xs) var(--spacing-xs);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid var(--border-color-base);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,343 @@
|
||||
<script setup lang="ts">
|
||||
import type { ViewUpdate } from '@codemirror/view';
|
||||
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
|
||||
import { format } from 'prettier';
|
||||
import jsParser from 'prettier/plugins/babel';
|
||||
import * as estree from 'prettier/plugins/estree';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
|
||||
|
||||
import { CODE_NODE_TYPE } from '@/constants';
|
||||
import { codeNodeEditorEventBus } from '@/event-bus';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
|
||||
import { useCodeEditor } from '@/composables/useCodeEditor';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import AskAI from './AskAI/AskAI.vue';
|
||||
import { CODE_PLACEHOLDERS } from './constants';
|
||||
import { useLinter } from './linter';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { dropInCodeEditor } from '@/plugins/codemirror/dragAndDrop';
|
||||
|
||||
type Props = {
|
||||
mode: CodeExecutionMode;
|
||||
modelValue: string;
|
||||
aiButtonEnabled?: boolean;
|
||||
fillParent?: boolean;
|
||||
language?: CodeNodeEditorLanguage;
|
||||
isReadOnly?: boolean;
|
||||
rows?: number;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
aiButtonEnabled: false,
|
||||
fillParent: false,
|
||||
language: 'javaScript',
|
||||
isReadOnly: false,
|
||||
rows: 4,
|
||||
id: () => crypto.randomUUID(),
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
|
||||
const message = useMessage();
|
||||
const tabs = ref(['code', 'ask-ai']);
|
||||
const activeTab = ref('code');
|
||||
const isLoadingAIResponse = ref(false);
|
||||
const codeNodeEditorRef = ref<HTMLDivElement>();
|
||||
const codeNodeEditorContainerRef = ref<HTMLDivElement>();
|
||||
const hasManualChanges = ref(false);
|
||||
|
||||
const rootStore = useRootStore();
|
||||
const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const linter = useLinter(
|
||||
() => props.mode,
|
||||
() => props.language,
|
||||
);
|
||||
const extensions = computed(() => [linter.value]);
|
||||
const placeholder = computed(() => CODE_PLACEHOLDERS[props.language]?.[props.mode] ?? '');
|
||||
const dragAndDropEnabled = computed(() => {
|
||||
return !props.isReadOnly;
|
||||
});
|
||||
|
||||
const { highlightLine, readEditorValue, editor } = useCodeEditor({
|
||||
id: props.id,
|
||||
editorRef: codeNodeEditorRef,
|
||||
language: () => props.language,
|
||||
languageParams: () => ({ mode: props.mode }),
|
||||
editorValue: () => props.modelValue,
|
||||
placeholder,
|
||||
extensions,
|
||||
isReadOnly: () => props.isReadOnly,
|
||||
theme: {
|
||||
maxHeight: props.fillParent ? '100%' : '40vh',
|
||||
minHeight: '20vh',
|
||||
rows: props.rows,
|
||||
},
|
||||
onChange: onEditorUpdate,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.isReadOnly) codeNodeEditorEventBus.on('highlightLine', highlightLine);
|
||||
codeNodeEditorEventBus.on('codeDiffApplied', diffApplied);
|
||||
|
||||
if (!props.modelValue) {
|
||||
emit('update:modelValue', placeholder.value);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
codeNodeEditorEventBus.off('codeDiffApplied', diffApplied);
|
||||
if (!props.isReadOnly) codeNodeEditorEventBus.off('highlightLine', highlightLine);
|
||||
});
|
||||
|
||||
const askAiEnabled = computed(() => {
|
||||
return settingsStore.isAskAiEnabled && props.language === 'javaScript';
|
||||
});
|
||||
|
||||
watch([() => props.language, () => props.mode], (_, [prevLanguage, prevMode]) => {
|
||||
if (readEditorValue().trim() === CODE_PLACEHOLDERS[prevLanguage]?.[prevMode]) {
|
||||
emit('update:modelValue', placeholder.value);
|
||||
}
|
||||
});
|
||||
|
||||
async function onBeforeTabLeave(_activeName: string, oldActiveName: string) {
|
||||
// Confirm dialog if leaving ask-ai tab during loading
|
||||
if (oldActiveName === 'ask-ai' && isLoadingAIResponse.value) {
|
||||
const confirmModal = await message.alert(i18n.baseText('codeNodeEditor.askAi.sureLeaveTab'), {
|
||||
title: i18n.baseText('codeNodeEditor.askAi.areYouSure'),
|
||||
confirmButtonText: i18n.baseText('codeNodeEditor.askAi.switchTab'),
|
||||
showClose: true,
|
||||
showCancelButton: true,
|
||||
});
|
||||
|
||||
return confirmModal === 'confirm';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function onAiReplaceCode(code: string) {
|
||||
const formattedCode = await format(code, {
|
||||
parser: 'babel',
|
||||
plugins: [jsParser, estree],
|
||||
});
|
||||
|
||||
emit('update:modelValue', formattedCode);
|
||||
|
||||
activeTab.value = 'code';
|
||||
hasManualChanges.value = false;
|
||||
}
|
||||
|
||||
function onEditorUpdate(viewUpdate: ViewUpdate) {
|
||||
trackCompletion(viewUpdate);
|
||||
hasManualChanges.value = true;
|
||||
emit('update:modelValue', readEditorValue());
|
||||
}
|
||||
|
||||
function diffApplied() {
|
||||
codeNodeEditorContainerRef.value?.classList.add('flash-editor');
|
||||
codeNodeEditorContainerRef.value?.addEventListener('animationend', () => {
|
||||
codeNodeEditorContainerRef.value?.classList.remove('flash-editor');
|
||||
});
|
||||
}
|
||||
|
||||
function trackCompletion(viewUpdate: ViewUpdate) {
|
||||
const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete'));
|
||||
|
||||
if (!completionTx) return;
|
||||
|
||||
try {
|
||||
// @ts-expect-error - undocumented fields
|
||||
const { fromA, toB } = viewUpdate?.changedRanges[0];
|
||||
const full = viewUpdate.state.doc.slice(fromA, toB).toString();
|
||||
const lastDotIndex = full.lastIndexOf('.');
|
||||
|
||||
let context = null;
|
||||
let insertedText = null;
|
||||
|
||||
if (lastDotIndex === -1) {
|
||||
context = '';
|
||||
insertedText = full;
|
||||
} else {
|
||||
context = full.slice(0, lastDotIndex);
|
||||
insertedText = full.slice(lastDotIndex + 1);
|
||||
}
|
||||
|
||||
// TODO: Still has to get updated for Python and JSON
|
||||
telemetry.track('User autocompleted code', {
|
||||
instance_id: rootStore.instanceId,
|
||||
node_type: CODE_NODE_TYPE,
|
||||
field_name: props.mode === 'runOnceForAllItems' ? 'jsCodeAllItems' : 'jsCodeEachItem',
|
||||
field_type: 'code',
|
||||
context,
|
||||
inserted_text: insertedText,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function onAiLoadStart() {
|
||||
isLoadingAIResponse.value = true;
|
||||
}
|
||||
|
||||
function onAiLoadEnd() {
|
||||
isLoadingAIResponse.value = false;
|
||||
}
|
||||
|
||||
async function onDrop(value: string, event: MouseEvent) {
|
||||
if (!editor.value) return;
|
||||
|
||||
const valueToInsert =
|
||||
props.mode === 'runOnceForAllItems'
|
||||
? value.replace('$json', '$input.first().json').replace(/\$\((.*)\)\.item/, '$($1).first()')
|
||||
: value;
|
||||
|
||||
await dropInCodeEditor(toRaw(editor.value), event, valueToInsert);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="codeNodeEditorContainerRef"
|
||||
:class="['code-node-editor', $style['code-node-editor-container']]"
|
||||
>
|
||||
<el-tabs
|
||||
v-if="askAiEnabled"
|
||||
ref="tabs"
|
||||
v-model="activeTab"
|
||||
type="card"
|
||||
:before-leave="onBeforeTabLeave"
|
||||
:class="$style.tabs"
|
||||
>
|
||||
<el-tab-pane
|
||||
:label="i18n.baseText('codeNodeEditor.tabs.code')"
|
||||
name="code"
|
||||
data-test-id="code-node-tab-code"
|
||||
:class="$style.fillHeight"
|
||||
>
|
||||
<DraggableTarget
|
||||
type="mapping"
|
||||
:disabled="!dragAndDropEnabled"
|
||||
:class="$style.fillHeight"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<template #default="{ activeDrop, droppable }">
|
||||
<div
|
||||
ref="codeNodeEditorRef"
|
||||
:class="[
|
||||
'ph-no-capture',
|
||||
'code-editor-tabs',
|
||||
$style.editorInput,
|
||||
$style.fillHeight,
|
||||
{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</DraggableTarget>
|
||||
<slot name="suffix" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
:label="i18n.baseText('codeNodeEditor.tabs.askAi')"
|
||||
name="ask-ai"
|
||||
data-test-id="code-node-tab-ai"
|
||||
>
|
||||
<!-- Key the AskAI tab to make sure it re-mounts when changing tabs -->
|
||||
<AskAI
|
||||
:key="activeTab"
|
||||
:has-changes="hasManualChanges"
|
||||
@replace-code="onAiReplaceCode"
|
||||
@started-loading="onAiLoadStart"
|
||||
@finished-loading="onAiLoadEnd"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<!-- If AskAi not enabled, there's no point in rendering tabs -->
|
||||
<div v-else :class="$style.fillHeight">
|
||||
<DraggableTarget
|
||||
type="mapping"
|
||||
:disabled="!dragAndDropEnabled"
|
||||
:class="$style.fillHeight"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<template #default="{ activeDrop, droppable }">
|
||||
<div
|
||||
ref="codeNodeEditorRef"
|
||||
:class="[
|
||||
'ph-no-capture',
|
||||
$style.fillHeight,
|
||||
$style.editorInput,
|
||||
{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</DraggableTarget>
|
||||
<slot name="suffix" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.el-tabs) {
|
||||
.cm-editor {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes backgroundAnimation {
|
||||
0% {
|
||||
background-color: none;
|
||||
}
|
||||
30% {
|
||||
background-color: rgba(41, 163, 102, 0.1);
|
||||
}
|
||||
100% {
|
||||
background-color: none;
|
||||
}
|
||||
}
|
||||
|
||||
.flash-editor {
|
||||
:deep(.cm-editor),
|
||||
:deep(.cm-gutter) {
|
||||
animation: backgroundAnimation 1.5s ease-in-out;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" module>
|
||||
.tabs {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.code-node-editor-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fillHeight {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.editorInput.droppable {
|
||||
:global(.cm-editor) {
|
||||
border-color: var(--color-ndv-droppable-parameter);
|
||||
border-style: dashed;
|
||||
border-width: 1.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.editorInput.activeDrop {
|
||||
:global(.cm-editor) {
|
||||
border-color: var(--color-success);
|
||||
border-style: solid;
|
||||
cursor: grabbing;
|
||||
border-width: 1px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,301 @@
|
||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import { autocompletion } from '@codemirror/autocomplete';
|
||||
import { localCompletionSource } from '@codemirror/lang-javascript';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
import { toValue } from 'vue';
|
||||
|
||||
import { useBaseCompletions } from './completions/base.completions';
|
||||
import { jsSnippets } from './completions/js.snippets';
|
||||
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { CodeExecutionMode } from 'n8n-workflow';
|
||||
import { useExecutionCompletions } from './completions/execution.completions';
|
||||
import { useItemFieldCompletions } from './completions/itemField.completions';
|
||||
import { useItemIndexCompletions } from './completions/itemIndex.completions';
|
||||
import { useJsonFieldCompletions } from './completions/jsonField.completions';
|
||||
import { useLuxonCompletions } from './completions/luxon.completions';
|
||||
import { usePrevNodeCompletions } from './completions/prevNode.completions';
|
||||
import { useRequireCompletions } from './completions/require.completions';
|
||||
import { useVariablesCompletions } from './completions/variables.completions';
|
||||
import { useWorkflowCompletions } from './completions/workflow.completions';
|
||||
|
||||
export const useCompleter = (
|
||||
mode: MaybeRefOrGetter<CodeExecutionMode>,
|
||||
editor: MaybeRefOrGetter<EditorView | null>,
|
||||
) => {
|
||||
function autocompletionExtension(language: 'javaScript' | 'python'): Extension {
|
||||
// Base completions
|
||||
const { baseCompletions, itemCompletions, nodeSelectorCompletions } = useBaseCompletions(
|
||||
toValue(mode),
|
||||
language,
|
||||
);
|
||||
const { executionCompletions } = useExecutionCompletions();
|
||||
const { inputMethodCompletions, selectorMethodCompletions } = useItemFieldCompletions(language);
|
||||
const { inputCompletions, selectorCompletions } = useItemIndexCompletions(mode);
|
||||
const { inputJsonFieldCompletions, selectorJsonFieldCompletions } = useJsonFieldCompletions();
|
||||
const { dateTimeCompletions, nowCompletions, todayCompletions } = useLuxonCompletions();
|
||||
const { prevNodeCompletions } = usePrevNodeCompletions();
|
||||
const { requireCompletions } = useRequireCompletions();
|
||||
const { variablesCompletions } = useVariablesCompletions();
|
||||
const { workflowCompletions } = useWorkflowCompletions();
|
||||
|
||||
const completions = [];
|
||||
if (language === 'javaScript') {
|
||||
completions.push(jsSnippets, localCompletionSource);
|
||||
}
|
||||
|
||||
return autocompletion({
|
||||
icons: false,
|
||||
compareCompletions: (a: Completion, b: Completion) => {
|
||||
if (/\.json$|id$|id['"]\]$/.test(a.label)) return 0;
|
||||
|
||||
return a.label.localeCompare(b.label);
|
||||
},
|
||||
override: [
|
||||
...completions,
|
||||
|
||||
// core
|
||||
itemCompletions,
|
||||
baseCompletions,
|
||||
requireCompletions,
|
||||
nodeSelectorCompletions,
|
||||
prevNodeCompletions,
|
||||
workflowCompletions,
|
||||
variablesCompletions,
|
||||
executionCompletions,
|
||||
|
||||
// luxon
|
||||
todayCompletions,
|
||||
nowCompletions,
|
||||
dateTimeCompletions,
|
||||
|
||||
// item index
|
||||
inputCompletions,
|
||||
selectorCompletions,
|
||||
|
||||
// item field
|
||||
inputMethodCompletions,
|
||||
selectorMethodCompletions,
|
||||
|
||||
// item json field
|
||||
inputJsonFieldCompletions,
|
||||
selectorJsonFieldCompletions,
|
||||
|
||||
// multiline
|
||||
multilineCompletions,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete uses of variables to any of the supported completions.
|
||||
*/
|
||||
function multilineCompletions(context: CompletionContext): CompletionResult | null {
|
||||
const editorValue = toValue(editor);
|
||||
if (!editorValue) return null;
|
||||
|
||||
let variablesToValueMap: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
variablesToValueMap = variablesToValues();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Object.keys(variablesToValueMap).length === 0) return null;
|
||||
|
||||
/**
|
||||
* Complete uses of extended variables, i.e. variables having
|
||||
* one or more dotted segments already.
|
||||
*
|
||||
* const x = $input;
|
||||
* x.first(). -> .json
|
||||
* x.first().json. -> .field
|
||||
*/
|
||||
|
||||
const docLines = editorValue.state.doc.toString().split('\n');
|
||||
|
||||
const varNames = Object.keys(variablesToValueMap);
|
||||
|
||||
const uses = extendedUses(docLines, varNames);
|
||||
const { matcherItemFieldCompletions } = useItemFieldCompletions('javaScript');
|
||||
for (const use of uses.itemField) {
|
||||
const matcher = use.replace(/\.$/, '');
|
||||
const completions = matcherItemFieldCompletions(context, matcher, variablesToValueMap);
|
||||
|
||||
if (completions) return completions;
|
||||
}
|
||||
|
||||
for (const use of uses.jsonField) {
|
||||
const matcher = use.replace(/(\.|\[)$/, '');
|
||||
const completions = matcherItemFieldCompletions(context, matcher, variablesToValueMap);
|
||||
|
||||
if (completions) return completions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete uses of unextended variables, i.e. variables having
|
||||
* no dotted segment already.
|
||||
*
|
||||
* const x = $input;
|
||||
* x. -> .first()
|
||||
*
|
||||
* const x = $input.first();
|
||||
* x. -> .json
|
||||
*
|
||||
* const x = $input.first().json;
|
||||
* x. -> .field
|
||||
*/
|
||||
|
||||
const SELECTOR_REGEX = /^\$\((?<quotedNodeName>['"][\w\s]+['"])\)$/; // $('nodeName')
|
||||
|
||||
const INPUT_METHOD_REGEXES = Object.values({
|
||||
first: /\$input\.first\(\)$/,
|
||||
last: /\$input\.last\(\)$/,
|
||||
item: /\$input\.item$/,
|
||||
all: /\$input\.all\(\)\[(?<index>\w+)\]$/,
|
||||
});
|
||||
|
||||
const SELECTOR_METHOD_REGEXES = Object.values({
|
||||
first: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.first\(\)$/,
|
||||
last: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.last\(\)$/,
|
||||
item: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.item$/,
|
||||
all: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.all\(\)\[(?<index>\w+)\]$/,
|
||||
});
|
||||
|
||||
const INPUT_JSON_REGEXES = Object.values({
|
||||
first: /\$input\.first\(\)\.json$/,
|
||||
last: /\$input\.last\(\)\.json$/,
|
||||
item: /\$input\.item\.json$/,
|
||||
all: /\$input\.all\(\)\[(?<index>\w+)\]\.json$/,
|
||||
});
|
||||
|
||||
const SELECTOR_JSON_REGEXES = Object.values({
|
||||
first: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.first\(\)\.json$/,
|
||||
last: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.last\(\)\.json$/,
|
||||
item: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.item\.json$/,
|
||||
all: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.all\(\)\[(?<index>\w+)\]\.json$/,
|
||||
});
|
||||
|
||||
const { executionCompletions } = useExecutionCompletions();
|
||||
const { inputCompletions, selectorCompletions } = useItemIndexCompletions(mode);
|
||||
const { matcherJsonFieldCompletions } = useJsonFieldCompletions();
|
||||
const { dateTimeCompletions, nowCompletions, todayCompletions } = useLuxonCompletions();
|
||||
const { variablesCompletions } = useVariablesCompletions();
|
||||
const { workflowCompletions } = useWorkflowCompletions();
|
||||
|
||||
for (const [variable, value] of Object.entries(variablesToValueMap)) {
|
||||
const { prevNodeCompletions } = usePrevNodeCompletions(variable);
|
||||
|
||||
if (value === '$execution') return executionCompletions(context, variable);
|
||||
if (value === '$vars') return variablesCompletions(context, variable);
|
||||
|
||||
if (value === '$workflow') return workflowCompletions(context, variable);
|
||||
if (value === '$prevNode') return prevNodeCompletions(context);
|
||||
|
||||
// luxon
|
||||
|
||||
if (value === '$now') return nowCompletions(context, variable);
|
||||
if (value === '$today') return todayCompletions(context, variable);
|
||||
if (value === 'DateTime') return dateTimeCompletions(context, variable);
|
||||
|
||||
// item index
|
||||
|
||||
if (value === '$input') return inputCompletions(context, variable);
|
||||
if (SELECTOR_REGEX.test(value)) return selectorCompletions(context, variable);
|
||||
|
||||
// json field
|
||||
|
||||
const inputJsonMatched = INPUT_JSON_REGEXES.some((regex) => regex.test(value));
|
||||
const selectorJsonMatched = SELECTOR_JSON_REGEXES.some((regex) => regex.test(value));
|
||||
|
||||
if (inputJsonMatched || selectorJsonMatched) {
|
||||
return matcherJsonFieldCompletions(context, variable, variablesToValueMap);
|
||||
}
|
||||
|
||||
// item field
|
||||
|
||||
const inputMethodMatched = INPUT_METHOD_REGEXES.some((regex) => regex.test(value));
|
||||
const selectorMethodMatched = SELECTOR_METHOD_REGEXES.some((regex) => regex.test(value));
|
||||
|
||||
if (inputMethodMatched || selectorMethodMatched) {
|
||||
return matcherItemFieldCompletions(context, variable, variablesToValueMap);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// helpers
|
||||
// ----------------------------------
|
||||
|
||||
/**
|
||||
* Create a map of variables and the values they point to.
|
||||
*/
|
||||
function variablesToValues() {
|
||||
return variableDeclarationLines().reduce<Record<string, string>>((acc, line) => {
|
||||
const [left, right] = line.split('=');
|
||||
|
||||
const varName = left.replace(/(var|let|const)/, '').trim();
|
||||
const varValue = right.replace(/;/, '').trim();
|
||||
|
||||
acc[varName] = varValue;
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function variableDeclarationLines() {
|
||||
const editorValue = toValue(editor);
|
||||
if (!editorValue) return [];
|
||||
|
||||
const docLines = editorValue.state.doc.toString().split('\n');
|
||||
|
||||
const isVariableDeclarationLine = (line: string) =>
|
||||
['var', 'const', 'let'].some((varType) => line.startsWith(varType));
|
||||
|
||||
return docLines.filter(isVariableDeclarationLine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect uses of variables pointing to n8n syntax if they have been extended.
|
||||
*
|
||||
* x.first().
|
||||
* x.first().json.
|
||||
* x.json.
|
||||
*/
|
||||
function extendedUses(docLines: string[], varNames: string[]) {
|
||||
return docLines.reduce<{ itemField: string[]; jsonField: string[] }>(
|
||||
(acc, cur) => {
|
||||
varNames.forEach((varName) => {
|
||||
const accessorPattern = `(${varName}.first\\(\\)|${varName}.last\\(\\)|${varName}.item|${varName}.all\\(\\)\\[\\w+\\]).*`;
|
||||
|
||||
const methodMatch = cur.match(new RegExp(accessorPattern));
|
||||
|
||||
if (methodMatch) {
|
||||
if (/json(\.|\[)$/.test(methodMatch[0])) {
|
||||
acc.jsonField.push(methodMatch[0]);
|
||||
} else {
|
||||
acc.itemField.push(methodMatch[0]);
|
||||
}
|
||||
}
|
||||
|
||||
const jsonPattern = `^${varName}\\.json(\\.|\\[)$`;
|
||||
|
||||
const jsonMatch = cur.match(new RegExp(jsonPattern));
|
||||
|
||||
if (jsonMatch) {
|
||||
acc.jsonField.push(jsonMatch[0]);
|
||||
}
|
||||
});
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ itemField: [], jsonField: [] },
|
||||
);
|
||||
}
|
||||
|
||||
return { autocompletionExtension };
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import { NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION } from '../constants';
|
||||
import { addInfoRenderer, addVarType } from '../utils';
|
||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { escapeMappingString } from '@/utils/mappingUtils';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
function getAutoCompletableNodeNames(nodes: INodeUi[]) {
|
||||
return nodes
|
||||
.filter((node: INodeUi) => !NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION.includes(node.type))
|
||||
.map((node: INodeUi) => node.name);
|
||||
}
|
||||
|
||||
export function useBaseCompletions(
|
||||
mode: 'runOnceForEachItem' | 'runOnceForAllItems',
|
||||
language: string,
|
||||
) {
|
||||
const i18n = useI18n();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
|
||||
const itemCompletions = (context: CompletionContext): CompletionResult | null => {
|
||||
const preCursor = context.matchBefore(/i\w*/);
|
||||
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
|
||||
|
||||
const options: Completion[] = [];
|
||||
|
||||
if (mode === 'runOnceForEachItem') {
|
||||
options.push({
|
||||
label: 'item',
|
||||
info: i18n.baseText('codeNodeEditor.completer.$input.item'),
|
||||
});
|
||||
} else if (mode === 'runOnceForAllItems') {
|
||||
options.push({
|
||||
label: 'items',
|
||||
info: i18n.baseText('codeNodeEditor.completer.$input.all'),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* - Complete `$` to `$execution $input $prevNode $runIndex $workflow $now $today
|
||||
* $jmespath $ifEmpt $('nodeName')` in both modes.
|
||||
* - Complete `$` to `$json $binary $itemIndex` in single-item mode.
|
||||
*/
|
||||
const baseCompletions = (context: CompletionContext): CompletionResult | null => {
|
||||
const prefix = language === 'python' ? '_' : '$';
|
||||
const preCursor = context.matchBefore(new RegExp(`\\${prefix}\\w*`));
|
||||
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
|
||||
|
||||
const TOP_LEVEL_COMPLETIONS_IN_BOTH_MODES: Completion[] = [
|
||||
{
|
||||
label: `${prefix}execution`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$execution'),
|
||||
},
|
||||
{
|
||||
label: `${prefix}ifEmpty()`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$ifEmpty'),
|
||||
},
|
||||
{ label: `${prefix}input`, info: i18n.baseText('codeNodeEditor.completer.$input') },
|
||||
{
|
||||
label: `${prefix}prevNode`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$prevNode'),
|
||||
},
|
||||
{
|
||||
label: `${prefix}workflow`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$workflow'),
|
||||
},
|
||||
{
|
||||
label: `${prefix}vars`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$vars'),
|
||||
},
|
||||
{
|
||||
label: `${prefix}now`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$now'),
|
||||
},
|
||||
{
|
||||
label: `${prefix}today`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$today'),
|
||||
},
|
||||
{
|
||||
label: `${prefix}jmespath()`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$jmespath'),
|
||||
},
|
||||
{
|
||||
label: `${prefix}runIndex`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$runIndex'),
|
||||
},
|
||||
{
|
||||
label: `${prefix}nodeVersion`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$nodeVersion'),
|
||||
},
|
||||
];
|
||||
|
||||
const options: Completion[] = TOP_LEVEL_COMPLETIONS_IN_BOTH_MODES.map(addVarType);
|
||||
|
||||
options.push(
|
||||
...getAutoCompletableNodeNames(workflowsStore.allNodes).map((nodeName) => {
|
||||
return {
|
||||
label: `${prefix}('${escapeMappingString(nodeName)}')`,
|
||||
type: 'variable',
|
||||
info: i18n.baseText('codeNodeEditor.completer.$()', {
|
||||
interpolate: { nodeName },
|
||||
}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (mode === 'runOnceForEachItem') {
|
||||
const TOP_LEVEL_COMPLETIONS_IN_SINGLE_ITEM_MODE = [
|
||||
{ label: `${prefix}json` },
|
||||
{ label: `${prefix}binary` },
|
||||
{
|
||||
label: `${prefix}itemIndex`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$itemIndex'),
|
||||
},
|
||||
];
|
||||
|
||||
options.push(...TOP_LEVEL_COMPLETIONS_IN_SINGLE_ITEM_MODE.map(addVarType));
|
||||
}
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options: options.map(addInfoRenderer),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Complete `$(` to `$('nodeName')`.
|
||||
*/
|
||||
const nodeSelectorCompletions = (context: CompletionContext): CompletionResult | null => {
|
||||
const prefix = language === 'python' ? '_' : '$';
|
||||
const preCursor = context.matchBefore(new RegExp(`\\${prefix}\\(.*`));
|
||||
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
|
||||
|
||||
const options: Completion[] = getAutoCompletableNodeNames(workflowsStore.allNodes).map(
|
||||
(nodeName) => {
|
||||
return {
|
||||
label: `${prefix}('${escapeMappingString(nodeName)}')`,
|
||||
type: 'variable',
|
||||
info: i18n.baseText('codeNodeEditor.completer.$()', {
|
||||
interpolate: { nodeName },
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
itemCompletions,
|
||||
baseCompletions,
|
||||
nodeSelectorCompletions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { addInfoRenderer, addVarType, escape } from '../utils';
|
||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
export function useExecutionCompletions() {
|
||||
const i18n = useI18n();
|
||||
|
||||
/**
|
||||
* Complete `$execution.` to `.id .mode .resumeUrl .resumeFormUrl`
|
||||
*/
|
||||
const executionCompletions = (
|
||||
context: CompletionContext,
|
||||
matcher = '$execution',
|
||||
): CompletionResult | null => {
|
||||
const pattern = new RegExp(`${escape(matcher)}\..*`);
|
||||
|
||||
const preCursor = context.matchBefore(pattern);
|
||||
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
|
||||
|
||||
const options: Completion[] = [
|
||||
{
|
||||
label: `${matcher}.id`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$execution.id'),
|
||||
},
|
||||
{
|
||||
label: `${matcher}.mode`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$execution.mode'),
|
||||
},
|
||||
{
|
||||
label: `${matcher}.resumeUrl`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$execution.resumeUrl'),
|
||||
},
|
||||
{
|
||||
label: `${matcher}.resumeFormUrl`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$execution.resumeFormUrl'),
|
||||
},
|
||||
{
|
||||
label: `${matcher}.customData.set("key", "value")`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$execution.customData.set'),
|
||||
},
|
||||
{
|
||||
label: `${matcher}.customData.get("key")`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$execution.customData.get'),
|
||||
},
|
||||
{
|
||||
label: `${matcher}.customData.setAll({})`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$execution.customData.setAll'),
|
||||
},
|
||||
{
|
||||
label: `${matcher}.customData.getAll()`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$execution.customData.getAll'),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options: options.map(addVarType).map(addInfoRenderer),
|
||||
};
|
||||
};
|
||||
|
||||
return { executionCompletions };
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { CompletionContext } from '@codemirror/autocomplete';
|
||||
import { EditorSelection, EditorState } from '@codemirror/state';
|
||||
import { useItemFieldCompletions } from './itemField.completions';
|
||||
|
||||
describe('inputMethodCompletions', () => {
|
||||
test('should return completions for $input.item.|', () => {
|
||||
const { inputMethodCompletions } = useItemFieldCompletions('javaScript');
|
||||
expect(inputMethodCompletions(createContext('$input.item.|'))).toEqual({
|
||||
from: 0,
|
||||
options: [
|
||||
{
|
||||
info: expect.any(Function),
|
||||
label: '$input.item.json',
|
||||
type: 'variable',
|
||||
},
|
||||
|
||||
{
|
||||
info: expect.any(Function),
|
||||
label: '$input.item.binary',
|
||||
type: 'variable',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('should return completions for $input.first().|', () => {
|
||||
const { inputMethodCompletions } = useItemFieldCompletions('javaScript');
|
||||
expect(inputMethodCompletions(createContext('$input.first().|'))).toEqual({
|
||||
from: 0,
|
||||
options: [
|
||||
{
|
||||
info: expect.any(Function),
|
||||
label: '$input.first().json',
|
||||
type: 'variable',
|
||||
},
|
||||
|
||||
{
|
||||
info: expect.any(Function),
|
||||
label: '$input.first().binary',
|
||||
type: 'variable',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('should return completions for $input.all()[1].|', () => {
|
||||
const { inputMethodCompletions } = useItemFieldCompletions('javaScript');
|
||||
expect(inputMethodCompletions(createContext('$input.all()[1].|'))).toEqual({
|
||||
from: 0,
|
||||
options: [
|
||||
{
|
||||
info: expect.any(Function),
|
||||
label: '$input.all()[1].json',
|
||||
type: 'variable',
|
||||
},
|
||||
|
||||
{
|
||||
info: expect.any(Function),
|
||||
label: '$input.all()[1].binary',
|
||||
type: 'variable',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export function createContext(docWithCursor: string) {
|
||||
const cursorPosition = docWithCursor.indexOf('|');
|
||||
|
||||
const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1);
|
||||
|
||||
return new CompletionContext(
|
||||
EditorState.create({ doc, selection: EditorSelection.single(cursorPosition) }),
|
||||
cursorPosition,
|
||||
false,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { addInfoRenderer, addVarType, escape } from '../utils';
|
||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
export function useItemFieldCompletions(language: 'python' | 'javaScript') {
|
||||
const i18n = useI18n();
|
||||
|
||||
/**
|
||||
* - Complete `x.first().` to `.json .binary`
|
||||
* - Complete `x.last().` to `.json .binary`
|
||||
* - Complete `x.all()[index].` to `.json .binary`
|
||||
* - Complete `x.item.` to `.json .binary`.
|
||||
*/
|
||||
const matcherItemFieldCompletions = (
|
||||
context: CompletionContext,
|
||||
matcher: string,
|
||||
variablesToValues: Record<string, string>,
|
||||
): CompletionResult | null => {
|
||||
const preCursor = context.matchBefore(new RegExp(`${escape(matcher)}\..*`));
|
||||
|
||||
if (!preCursor) return null;
|
||||
|
||||
const [varName] = preCursor.text.split('.');
|
||||
|
||||
const originalValue = variablesToValues[varName];
|
||||
|
||||
if (!originalValue) return null;
|
||||
|
||||
const options: Completion[] = [
|
||||
{
|
||||
label: `${matcher}.json`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.json'),
|
||||
},
|
||||
{
|
||||
label: `${matcher}.binary`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.binary'),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options: options.map(addVarType),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* - Complete `$input.first().` to `.json .binary`.
|
||||
* - Complete `$input.last().` to `.json .binary`.
|
||||
* - Complete `$input.all()[index].` to `.json .binary`.
|
||||
* - Complete `$input.item.` to `.json .binary`.
|
||||
*/
|
||||
const inputMethodCompletions = (context: CompletionContext): CompletionResult | null => {
|
||||
const prefix = language === 'python' ? '_' : '$';
|
||||
const patterns = {
|
||||
first: new RegExp(`\\${prefix}input\\.first\\(\\)\\..*`),
|
||||
last: new RegExp(`\\${prefix}input\\.last\\(\\)\\..*`),
|
||||
item: new RegExp(`\\${prefix}input\\.item\\..*`),
|
||||
all: /\$input\.all\(\)\[(?<index>\w+)\]\..*/,
|
||||
};
|
||||
|
||||
for (const [name, regex] of Object.entries(patterns)) {
|
||||
const preCursor = context.matchBefore(regex);
|
||||
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) continue;
|
||||
|
||||
let replacementBase = '';
|
||||
|
||||
if (name === 'item') replacementBase = `${prefix}input.item`;
|
||||
|
||||
if (name === 'first') replacementBase = `${prefix}input.first()`;
|
||||
|
||||
if (name === 'last') replacementBase = `${prefix}input.last()`;
|
||||
|
||||
if (name === 'all') {
|
||||
const match = preCursor.text.match(regex);
|
||||
|
||||
if (!match?.groups?.index) continue;
|
||||
|
||||
const { index } = match.groups;
|
||||
|
||||
replacementBase = `${prefix}input.all()[${index}]`;
|
||||
}
|
||||
|
||||
const options: Completion[] = [
|
||||
{
|
||||
label: `${replacementBase}.json`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.json'),
|
||||
},
|
||||
{
|
||||
label: `${replacementBase}.binary`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.binary'),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options: options.map(addVarType).map(addInfoRenderer),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* - Complete `$('nodeName').first().` to `.json .binary`.
|
||||
* - Complete `$('nodeName').last().` to `.json .binary`.
|
||||
* - Complete `$('nodeName').all()[index].` to `.json .binary`.
|
||||
* - Complete `$('nodeName').item.` to `.json .binary`.
|
||||
*/
|
||||
const selectorMethodCompletions = (
|
||||
context: CompletionContext,
|
||||
matcher: string | null = null,
|
||||
): CompletionResult | null => {
|
||||
const patterns = {
|
||||
first: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.first\(\)\..*/,
|
||||
last: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.last\(\)\..*/,
|
||||
item: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.item\..*/,
|
||||
all: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.all\(\)\[(?<index>\w+)\]\..*/,
|
||||
};
|
||||
|
||||
for (const [name, regex] of Object.entries(patterns)) {
|
||||
const preCursor = context.matchBefore(regex);
|
||||
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) continue;
|
||||
|
||||
const match = preCursor.text.match(regex);
|
||||
|
||||
let start = '';
|
||||
|
||||
if (!matcher && match?.groups?.quotedNodeName) {
|
||||
start = `$(${match.groups.quotedNodeName})`;
|
||||
}
|
||||
|
||||
let replacementBase = '';
|
||||
|
||||
if (name === 'item') replacementBase = `${start}.item`;
|
||||
|
||||
if (name === 'first') replacementBase = `${start}.first()`;
|
||||
|
||||
if (name === 'last') replacementBase = `${start}.last()`;
|
||||
|
||||
if (name === 'all') {
|
||||
const match = preCursor.text.match(regex);
|
||||
|
||||
if (!match?.groups?.index) continue;
|
||||
|
||||
replacementBase = `${start}.all()[${match.groups.index}]`;
|
||||
}
|
||||
|
||||
const options: Completion[] = [
|
||||
{
|
||||
label: `${replacementBase}.json`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.json'),
|
||||
},
|
||||
{
|
||||
label: `${replacementBase}.binary`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.binary'),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options: options.map(addVarType),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
matcherItemFieldCompletions,
|
||||
inputMethodCompletions,
|
||||
selectorMethodCompletions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import type { CodeExecutionMode } from 'n8n-workflow';
|
||||
import { toValue, type MaybeRefOrGetter } from 'vue';
|
||||
import { escape } from '../utils';
|
||||
|
||||
export function useItemIndexCompletions(mode: MaybeRefOrGetter<CodeExecutionMode>) {
|
||||
const i18n = useI18n();
|
||||
/**
|
||||
* - Complete `$input.` to `.first() .last() .all() .itemMatching()` in all-items mode.
|
||||
* - Complete `$input.` to `.item` in single-item mode.
|
||||
*/
|
||||
const inputCompletions = (
|
||||
context: CompletionContext,
|
||||
matcher = '$input',
|
||||
): CompletionResult | null => {
|
||||
const pattern = new RegExp(`${escape(matcher)}\..*`);
|
||||
|
||||
const preCursor = context.matchBefore(pattern);
|
||||
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
|
||||
|
||||
const options: Completion[] = [];
|
||||
|
||||
if (toValue(mode) === 'runOnceForAllItems') {
|
||||
options.push(
|
||||
{
|
||||
label: `${matcher}.first()`,
|
||||
type: 'function',
|
||||
info: i18n.baseText('codeNodeEditor.completer.$input.first'),
|
||||
},
|
||||
{
|
||||
label: `${matcher}.last()`,
|
||||
type: 'function',
|
||||
info: i18n.baseText('codeNodeEditor.completer.$input.last'),
|
||||
},
|
||||
{
|
||||
label: `${matcher}.all()`,
|
||||
type: 'function',
|
||||
info: i18n.baseText('codeNodeEditor.completer.$input.all'),
|
||||
},
|
||||
{
|
||||
label: `${matcher}.itemMatching()`,
|
||||
type: 'function',
|
||||
info: i18n.baseText('codeNodeEditor.completer.$input.itemMatching'),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (toValue(mode) === 'runOnceForEachItem') {
|
||||
options.push({
|
||||
label: `${matcher}.item`,
|
||||
type: 'variable',
|
||||
info: i18n.baseText('codeNodeEditor.completer.$input.item'),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* - Complete `$('nodeName').` to `.params .context` in both modes.
|
||||
* - Complete `$('nodeName').` to `.first() .last() .all() .itemMatching()` in all-items mode.
|
||||
* - Complete `$('nodeName').` to `.item` in single-item mode.
|
||||
*/
|
||||
const selectorCompletions = (context: CompletionContext, matcher: string | null = null) => {
|
||||
const pattern =
|
||||
matcher === null
|
||||
? /\$\((?<quotedNodeName>['"][\S\s]+['"])\)\..*/ // $('nodeName').
|
||||
: new RegExp(`${matcher}\..*`);
|
||||
|
||||
const preCursor = context.matchBefore(pattern);
|
||||
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
|
||||
|
||||
const match = preCursor.text.match(pattern);
|
||||
|
||||
let replacementBase = '';
|
||||
|
||||
if (matcher === null && match?.groups?.quotedNodeName) {
|
||||
replacementBase = `$(${match.groups.quotedNodeName})`;
|
||||
} else if (matcher) {
|
||||
replacementBase = matcher;
|
||||
}
|
||||
|
||||
const options: Completion[] = [
|
||||
{
|
||||
label: `${replacementBase}.params`,
|
||||
type: 'variable',
|
||||
info: i18n.baseText('codeNodeEditor.completer.selector.params'),
|
||||
},
|
||||
{
|
||||
label: `${replacementBase}.context`,
|
||||
type: 'variable',
|
||||
info: i18n.baseText('codeNodeEditor.completer.selector.context'),
|
||||
},
|
||||
];
|
||||
|
||||
if (toValue(mode) === 'runOnceForAllItems') {
|
||||
options.push(
|
||||
{
|
||||
label: `${replacementBase}.first()`,
|
||||
type: 'function',
|
||||
info: i18n.baseText('codeNodeEditor.completer.$input.first'),
|
||||
},
|
||||
{
|
||||
label: `${replacementBase}.last()`,
|
||||
type: 'function',
|
||||
info: i18n.baseText('codeNodeEditor.completer.$input.last'),
|
||||
},
|
||||
{
|
||||
label: `${replacementBase}.all()`,
|
||||
type: 'function',
|
||||
info: i18n.baseText('codeNodeEditor.completer.$input.all'),
|
||||
},
|
||||
{
|
||||
label: `${replacementBase}.itemMatching()`,
|
||||
type: 'function',
|
||||
info: i18n.baseText('codeNodeEditor.completer.selector.itemMatching'),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (toValue(mode) === 'runOnceForEachItem') {
|
||||
options.push({
|
||||
label: `${replacementBase}.item`,
|
||||
type: 'variable',
|
||||
info: i18n.baseText('codeNodeEditor.completer.selector.item'),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
inputCompletions,
|
||||
selectorCompletions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { snippets } from '@codemirror/lang-javascript';
|
||||
import { completeFromList, snippetCompletion } from '@codemirror/autocomplete';
|
||||
|
||||
/**
|
||||
* https://github.com/codemirror/lang-javascript/blob/main/src/snippets.ts
|
||||
*/
|
||||
export const jsSnippets = completeFromList([
|
||||
...snippets.filter((snippet) => snippet.label !== 'class'),
|
||||
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
|
||||
snippetCompletion('console.log(${arg})', { label: 'console.log()' }),
|
||||
snippetCompletion('DateTime', { label: 'DateTime' }),
|
||||
snippetCompletion('Interval', { label: 'Interval' }),
|
||||
snippetCompletion('Duration', { label: 'Duration' }),
|
||||
]);
|
||||
@@ -0,0 +1,299 @@
|
||||
import { escape } from '../utils';
|
||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { isAllowedInDotNotation } from '@/plugins/codemirror/completions/utils';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { IPinData, IRunData, IDataObject } from 'n8n-workflow';
|
||||
|
||||
function useJsonFieldCompletions() {
|
||||
const i18n = useI18n();
|
||||
const ndvStore = useNDVStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
|
||||
/**
|
||||
* - Complete `x.first().json.` to `.field`.
|
||||
* - Complete `x.last().json.` to `.field`.
|
||||
* - Complete `x.all()[index].json.` to `.field`.
|
||||
* - Complete `x.item.json.` to `.field`.
|
||||
*
|
||||
* - Complete `x.first().json[` to `['field']`.
|
||||
* - Complete `x.last().json[` to `['field']`.
|
||||
* - Complete `x.all()[index].json[` to `['field']`.
|
||||
* - Complete `x.item.json[` to `['field']`.
|
||||
*/
|
||||
const matcherJsonFieldCompletions = (
|
||||
context: CompletionContext,
|
||||
matcher: string,
|
||||
variablesToValues: Record<string, string>,
|
||||
): CompletionResult | null => {
|
||||
const pattern = new RegExp(`(${escape(matcher)})\..*`);
|
||||
const preCursor = context.matchBefore(pattern);
|
||||
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
|
||||
|
||||
const inputNodeName = getInputNodeName();
|
||||
if (!inputNodeName) return null;
|
||||
|
||||
const [varName] = preCursor.text.split('.');
|
||||
const originalValue = variablesToValues[varName];
|
||||
if (!originalValue) return null;
|
||||
|
||||
for (const accessor of ['first', 'last', 'item']) {
|
||||
if (originalValue.includes(accessor) || preCursor.text.includes(accessor)) {
|
||||
const jsonOutput = getJsonOutput(inputNodeName, { accessor });
|
||||
if (!jsonOutput) return null;
|
||||
return toJsonFieldCompletions(preCursor, jsonOutput, matcher);
|
||||
}
|
||||
}
|
||||
|
||||
if (originalValue.includes('all')) {
|
||||
const match = originalValue.match(/\$(input|\(.*\))\.all\(\)\[(?<index>.+)\]$/);
|
||||
if (!match?.groups?.index) return null;
|
||||
|
||||
const { index } = match.groups;
|
||||
const jsonOutput = getJsonOutput(inputNodeName, { index: Number(index) });
|
||||
if (!jsonOutput) return null;
|
||||
|
||||
return toJsonFieldCompletions(preCursor, jsonOutput, matcher);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* - Complete `$input.first().json.` to `.field`.
|
||||
* - Complete `$input.last().json.` to `.field`.
|
||||
* - Complete `$input.all()[index].json.` to `.field`.
|
||||
* - Complete `$input.item.json.` to `.field`.
|
||||
*
|
||||
* - Complete `$input.first().json[` to `['field']`.
|
||||
* - Complete `$input.last().json[` to `['field']`.
|
||||
* - Complete `$input.all()[index].json[` to `['field']`.
|
||||
* - Complete `$input.item.json[` to `['field']`.
|
||||
*/
|
||||
const inputJsonFieldCompletions = (context: CompletionContext): CompletionResult | null => {
|
||||
const patterns = {
|
||||
first: /\$input\.first\(\)\.json(\[|\.).*/,
|
||||
last: /\$input\.last\(\)\.json(\[|\.).*/,
|
||||
item: /\$input\.item\.json(\[|\.).*/,
|
||||
all: /\$input\.all\(\)\[(?<index>\w+)\]\.json(\[|\.).*/,
|
||||
};
|
||||
|
||||
for (const [name, regex] of Object.entries(patterns)) {
|
||||
const preCursor = context.matchBefore(regex);
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) continue;
|
||||
|
||||
const inputNodeName = getInputNodeName();
|
||||
if (!inputNodeName) continue;
|
||||
|
||||
if (name === 'first' || name === 'last') {
|
||||
const jsonOutput = getJsonOutput(inputNodeName, { accessor: name });
|
||||
if (!jsonOutput) continue;
|
||||
return toJsonFieldCompletions(preCursor, jsonOutput, `$input.${name}().json`);
|
||||
}
|
||||
|
||||
if (name === 'item') {
|
||||
const jsonOutput = getJsonOutput(inputNodeName, { accessor: 'item' });
|
||||
if (!jsonOutput) continue;
|
||||
return toJsonFieldCompletions(preCursor, jsonOutput, '$input.item.json');
|
||||
}
|
||||
|
||||
if (name === 'all') {
|
||||
const match = preCursor.text.match(regex);
|
||||
if (!match?.groups?.index) continue;
|
||||
|
||||
const { index } = match.groups;
|
||||
const jsonOutput = getJsonOutput(inputNodeName, { index: Number(index) });
|
||||
if (!jsonOutput) continue;
|
||||
|
||||
return toJsonFieldCompletions(preCursor, jsonOutput, `$input.all()[${index}].json`);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Complete `$('nodeName').first().json.` to `.field`.
|
||||
* Complete `$('nodeName').last().json.` to `.field`.
|
||||
* Complete `$('nodeName').all()[index].json.` to `.field`.
|
||||
* Complete `$('nodeName').item.json.` to `.field`.
|
||||
*
|
||||
* Complete `$('nodeName').first().json[` to `['field']`.
|
||||
* Complete `$('nodeName').last().json[` to `['field']`.
|
||||
* Complete `$('nodeName').all()[index].json[` to `['field']`.
|
||||
* Complete `$('nodeName').item.json[` to `['field']`.
|
||||
*/
|
||||
const selectorJsonFieldCompletions = (context: CompletionContext): CompletionResult | null => {
|
||||
const patterns = {
|
||||
first: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.first\(\)\.json(\[|\.).*/,
|
||||
last: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.last\(\)\.json(\[|\.).*/,
|
||||
item: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.item\.json(\[|\.).*/,
|
||||
all: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.all\(\)\[(?<index>\w+)\]\.json(\[|\.).*/,
|
||||
};
|
||||
|
||||
for (const [name, regex] of Object.entries(patterns)) {
|
||||
const preCursor = context.matchBefore(regex);
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) continue;
|
||||
|
||||
const match = preCursor.text.match(regex);
|
||||
if (!match?.groups?.quotedNodeName) continue;
|
||||
|
||||
const { quotedNodeName } = match.groups;
|
||||
const selector = `$(${match.groups.quotedNodeName})`;
|
||||
|
||||
if (name === 'first' || name === 'last') {
|
||||
const jsonOutput = getJsonOutput(quotedNodeName, { accessor: name });
|
||||
if (!jsonOutput) continue;
|
||||
return toJsonFieldCompletions(preCursor, jsonOutput, `${selector}.${name}().json`);
|
||||
}
|
||||
|
||||
if (name === 'item') {
|
||||
const jsonOutput = getJsonOutput(quotedNodeName, { accessor: 'item' });
|
||||
if (!jsonOutput) continue;
|
||||
return toJsonFieldCompletions(preCursor, jsonOutput, `${selector}.item.json`);
|
||||
}
|
||||
|
||||
if (name === 'all') {
|
||||
const regexMatch = preCursor.text.match(regex);
|
||||
if (!regexMatch?.groups?.index) continue;
|
||||
|
||||
const { index } = regexMatch.groups;
|
||||
const jsonOutput = getJsonOutput(quotedNodeName, { index: Number(index) });
|
||||
if (!jsonOutput) continue;
|
||||
|
||||
return toJsonFieldCompletions(preCursor, jsonOutput, `${selector}.all()[${index}].json`);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getInputNodeName = (): string | null => {
|
||||
try {
|
||||
const activeNode = ndvStore.activeNode;
|
||||
if (activeNode) {
|
||||
const workflow = workflowsStore.getCurrentWorkflow();
|
||||
const input = workflow.connectionsByDestinationNode[activeNode.name];
|
||||
return input.main[0] ? input.main[0][0].node : null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* .json -> .json['field']
|
||||
* .json -> .json.field
|
||||
*/
|
||||
const toJsonFieldCompletions = (
|
||||
preCursor: NonNullable<ReturnType<CompletionContext['matchBefore']>>,
|
||||
jsonOutput: IDataObject,
|
||||
matcher: string,
|
||||
): CompletionResult | null => {
|
||||
if (
|
||||
/\.json\[/.test(preCursor.text) ||
|
||||
new RegExp(`(${escape(matcher)})\\[`).test(preCursor.text)
|
||||
) {
|
||||
const options: Completion[] = Object.keys(jsonOutput)
|
||||
.map((field) => `${matcher}['${field}']`)
|
||||
.map((label) => ({
|
||||
label,
|
||||
info: i18n.baseText('codeNodeEditor.completer.json'),
|
||||
}));
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
/\.json\./.test(preCursor.text) ||
|
||||
new RegExp(`(${escape(matcher)})\.`).test(preCursor.text)
|
||||
) {
|
||||
const options: Completion[] = Object.keys(jsonOutput)
|
||||
.filter(isAllowedInDotNotation)
|
||||
.map((field) => `${matcher}.${field}`)
|
||||
.map((label) => ({
|
||||
label,
|
||||
info: i18n.baseText('codeNodeEditor.completer.json'),
|
||||
}));
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the `json` output of a node from `runData` or `pinData`.
|
||||
*
|
||||
* `accessor` is the method or property used to find the item index.
|
||||
* `index` is only passed for `all()`.
|
||||
*/
|
||||
const getJsonOutput = (
|
||||
quotedNodeName: string,
|
||||
options?: { accessor?: string; index?: number },
|
||||
) => {
|
||||
let nodeName = quotedNodeName;
|
||||
|
||||
const isSingleQuoteWrapped = quotedNodeName.startsWith("'") && quotedNodeName.endsWith("'");
|
||||
const isDoubleQuoteWrapped = quotedNodeName.startsWith('"') && quotedNodeName.endsWith('"');
|
||||
|
||||
if (isSingleQuoteWrapped) {
|
||||
nodeName = quotedNodeName.replace(/^'/, '').replace(/'$/, '');
|
||||
} else if (isDoubleQuoteWrapped) {
|
||||
nodeName = quotedNodeName.replace(/^"/, '').replace(/"$/, '');
|
||||
}
|
||||
|
||||
const pinData: IPinData | undefined = useWorkflowsStore().pinnedWorkflowData;
|
||||
|
||||
const nodePinData = pinData?.[nodeName];
|
||||
|
||||
if (nodePinData) {
|
||||
try {
|
||||
let itemIndex = options?.index ?? 0;
|
||||
|
||||
if (options?.accessor === 'last') {
|
||||
itemIndex = nodePinData.length - 1;
|
||||
}
|
||||
|
||||
return nodePinData[itemIndex].json;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const runData: IRunData | null = useWorkflowsStore().getWorkflowRunData;
|
||||
|
||||
const nodeRunData = runData?.[nodeName];
|
||||
|
||||
if (!nodeRunData) return null;
|
||||
|
||||
try {
|
||||
let itemIndex = options?.index ?? 0;
|
||||
|
||||
if (options?.accessor === 'last') {
|
||||
const inputItems = nodeRunData[0].data?.main[0] ?? [];
|
||||
itemIndex = inputItems.length - 1;
|
||||
}
|
||||
|
||||
return nodeRunData[0].data?.main[0]?.[itemIndex].json;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
matcherJsonFieldCompletions,
|
||||
inputJsonFieldCompletions,
|
||||
selectorJsonFieldCompletions,
|
||||
};
|
||||
}
|
||||
|
||||
export { useJsonFieldCompletions };
|
||||
@@ -0,0 +1,101 @@
|
||||
import { escape } from '../utils';
|
||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import { createInfoBoxRenderer } from '@/plugins/codemirror/completions/infoBoxRenderer';
|
||||
import { luxonStaticDocs } from '@/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.static.docs';
|
||||
import { luxonInstanceDocs } from '@/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.instance.docs';
|
||||
|
||||
export function useLuxonCompletions() {
|
||||
/**
|
||||
* Complete `$today.` with luxon `DateTime` instance methods.
|
||||
*/
|
||||
const todayCompletions = (
|
||||
context: CompletionContext,
|
||||
matcher = '$today',
|
||||
): CompletionResult | null => {
|
||||
const pattern = new RegExp(`${escape(matcher)}\..*`);
|
||||
|
||||
const preCursor = context.matchBefore(pattern);
|
||||
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options: instanceCompletions(matcher),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Complete `$now.` with luxon `DateTime` instance methods.
|
||||
*/
|
||||
const nowCompletions = (
|
||||
context: CompletionContext,
|
||||
matcher = '$now',
|
||||
): CompletionResult | null => {
|
||||
const pattern = new RegExp(`${escape(matcher)}\..*`);
|
||||
|
||||
const preCursor = context.matchBefore(pattern);
|
||||
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options: instanceCompletions(matcher),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Complete `DateTime` with luxon `DateTime` static methods.
|
||||
*/
|
||||
const dateTimeCompletions = (
|
||||
context: CompletionContext,
|
||||
matcher = 'DateTime',
|
||||
): CompletionResult | null => {
|
||||
const pattern = new RegExp(`${escape(matcher)}\..*`);
|
||||
|
||||
const preCursor = context.matchBefore(pattern);
|
||||
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
|
||||
|
||||
const options: Completion[] = Object.entries(luxonStaticDocs.functions)
|
||||
.filter(([_, { doc }]) => doc && !doc.hidden)
|
||||
.map(([method, { doc }]) => {
|
||||
return {
|
||||
label: `DateTime.${method}()`,
|
||||
type: 'function',
|
||||
info: createInfoBoxRenderer(doc, true),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options,
|
||||
};
|
||||
};
|
||||
|
||||
const instanceCompletions = (matcher: string): Completion[] => {
|
||||
return Object.entries(luxonInstanceDocs.properties)
|
||||
.filter(([_, { doc }]) => doc && !doc.hidden)
|
||||
.map(([getter, { doc }]) => {
|
||||
return {
|
||||
label: `${matcher}.${getter}`,
|
||||
info: createInfoBoxRenderer(doc),
|
||||
};
|
||||
})
|
||||
.concat(
|
||||
Object.entries(luxonInstanceDocs.functions)
|
||||
.filter(([_, { doc }]) => doc && !doc.hidden)
|
||||
.map(([method, { doc }]) => {
|
||||
return {
|
||||
label: `${matcher}.${method}()`,
|
||||
info: createInfoBoxRenderer(doc, true),
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
todayCompletions,
|
||||
nowCompletions,
|
||||
dateTimeCompletions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { addVarType } from '../utils';
|
||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const DEFAULT_MATCHER = '$prevNode';
|
||||
|
||||
const escape = (str: string) => str.replace('$', '\\$');
|
||||
|
||||
export function usePrevNodeCompletions(matcher = DEFAULT_MATCHER) {
|
||||
const i18n = useI18n();
|
||||
|
||||
/**
|
||||
* Complete `$prevNode.` to `.name .outputIndex .runIndex`.
|
||||
*/
|
||||
const prevNodeCompletions = (context: CompletionContext): CompletionResult | null => {
|
||||
const pattern = new RegExp(`${escape(matcher)}\..*`);
|
||||
|
||||
const preCursor = context.matchBefore(pattern);
|
||||
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
|
||||
|
||||
const options: Completion[] = [
|
||||
{
|
||||
label: `${matcher}.name`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$prevNode.name'),
|
||||
},
|
||||
{
|
||||
label: `${matcher}.outputIndex`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$prevNode.outputIndex'),
|
||||
},
|
||||
{
|
||||
label: `${matcher}.runIndex`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$prevNode.runIndex'),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options: options.map(addVarType),
|
||||
};
|
||||
};
|
||||
|
||||
return { prevNodeCompletions };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { AUTOCOMPLETABLE_BUILT_IN_MODULES_JS } from '../constants';
|
||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
|
||||
export function useRequireCompletions() {
|
||||
const settingsStore = useSettingsStore();
|
||||
const allowedModules = settingsStore.allowedModules;
|
||||
|
||||
const toOption = (moduleName: string): Completion => ({
|
||||
label: `require('${moduleName}');`,
|
||||
type: 'variable',
|
||||
});
|
||||
/**
|
||||
* Complete `req` to `require('moduleName')` based on modules available in context.
|
||||
*/
|
||||
const requireCompletions = (context: CompletionContext): CompletionResult | null => {
|
||||
const preCursor = context.matchBefore(/req.*/);
|
||||
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
|
||||
|
||||
const options: Completion[] = [];
|
||||
|
||||
if (allowedModules.builtIn) {
|
||||
if (allowedModules.builtIn.includes('*')) {
|
||||
options.push(...AUTOCOMPLETABLE_BUILT_IN_MODULES_JS.map(toOption));
|
||||
} else if (allowedModules?.builtIn?.length > 0) {
|
||||
options.push(...allowedModules.builtIn.map(toOption));
|
||||
}
|
||||
}
|
||||
|
||||
if (allowedModules.external) {
|
||||
if (allowedModules?.external?.length > 0) {
|
||||
options.push(...allowedModules.external.map(toOption));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options,
|
||||
};
|
||||
};
|
||||
|
||||
return { requireCompletions };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { addVarType } from '../utils';
|
||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
|
||||
|
||||
const escape = (str: string) => str.replace('$', '\\$');
|
||||
|
||||
export function useSecretsCompletions() {
|
||||
const externalSecretsStore = useExternalSecretsStore();
|
||||
|
||||
/**
|
||||
* Complete `$secrets.` to `$secrets.providerName` and `$secrets.providerName.secretName`.
|
||||
*/
|
||||
const secretsCompletions = (
|
||||
context: CompletionContext,
|
||||
matcher = '$secrets',
|
||||
): CompletionResult | null => {
|
||||
const pattern = new RegExp(`${escape(matcher)}\..*`);
|
||||
const preCursor = context.matchBefore(pattern);
|
||||
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
|
||||
|
||||
const provider = preCursor.text.split('.')[1];
|
||||
let options: Completion[];
|
||||
|
||||
const optionsForObject = (leftSide: string, object: Record<string, unknown>): Completion[] => {
|
||||
return Object.entries(object).flatMap(([key, value]) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return optionsForObject(`${leftSide}.${key}`, value as Record<string, unknown>);
|
||||
}
|
||||
return {
|
||||
label: `${leftSide}.${key}`,
|
||||
info: '*******',
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
if (provider) {
|
||||
options = optionsForObject(
|
||||
`${matcher}.${provider}`,
|
||||
externalSecretsStore.secretsAsObject[provider] as Record<string, unknown>,
|
||||
);
|
||||
} else {
|
||||
options = Object.keys(externalSecretsStore.secretsAsObject).map((providerB) => ({
|
||||
label: `${matcher}.${providerB}`,
|
||||
info: JSON.stringify(externalSecretsStore.secretsAsObject[providerB]),
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options: options.map(addVarType),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
secretsCompletions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { addVarType } from '../utils';
|
||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import { useEnvironmentsStore } from '@/stores/environments.ee.store';
|
||||
|
||||
const escape = (str: string) => str.replace('$', '\\$');
|
||||
|
||||
export function useVariablesCompletions() {
|
||||
const environmentsStore = useEnvironmentsStore();
|
||||
|
||||
/**
|
||||
* Complete `$vars.` to `$vars.VAR_NAME`.
|
||||
*/
|
||||
const variablesCompletions = (
|
||||
context: CompletionContext,
|
||||
matcher = '$vars',
|
||||
): CompletionResult | null => {
|
||||
const pattern = new RegExp(`${escape(matcher)}\..*`);
|
||||
|
||||
const preCursor = context.matchBefore(pattern);
|
||||
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
|
||||
|
||||
const options: Completion[] = environmentsStore.variables.map((variable) => ({
|
||||
label: `${matcher}.${variable.key}`,
|
||||
info: variable.value,
|
||||
}));
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options: options.map(addVarType),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
variablesCompletions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { addVarType } from '../utils';
|
||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const escape = (str: string) => str.replace('$', '\\$');
|
||||
|
||||
export function useWorkflowCompletions() {
|
||||
const i18n = useI18n();
|
||||
|
||||
/**
|
||||
* Complete `$workflow.` to `.id .name .active`.
|
||||
*/
|
||||
const workflowCompletions = (
|
||||
context: CompletionContext,
|
||||
matcher = '$workflow',
|
||||
): CompletionResult | null => {
|
||||
const pattern = new RegExp(`${escape(matcher)}\..*`);
|
||||
|
||||
const preCursor = context.matchBefore(pattern);
|
||||
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
|
||||
|
||||
const options: Completion[] = [
|
||||
{
|
||||
label: `${matcher}.id`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$workflow.id'),
|
||||
},
|
||||
{
|
||||
label: `${matcher}.name`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$workflow.name'),
|
||||
},
|
||||
{
|
||||
label: `${matcher}.active`,
|
||||
info: i18n.baseText('codeNodeEditor.completer.$workflow.active'),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options: options.map(addVarType),
|
||||
};
|
||||
};
|
||||
|
||||
return { workflowCompletions };
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { STICKY_NODE_TYPE } from '@/constants';
|
||||
import type { Diagnostic } from '@codemirror/lint';
|
||||
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
|
||||
|
||||
export const NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION = [STICKY_NODE_TYPE];
|
||||
|
||||
export const AUTOCOMPLETABLE_BUILT_IN_MODULES_JS = [
|
||||
'console',
|
||||
'constants',
|
||||
'crypto',
|
||||
'dns',
|
||||
'dns/promises',
|
||||
'fs',
|
||||
'fs/promises',
|
||||
'http',
|
||||
'http2',
|
||||
'https',
|
||||
'inspector',
|
||||
'module',
|
||||
'os',
|
||||
'path',
|
||||
'process',
|
||||
'readline',
|
||||
'url',
|
||||
'util',
|
||||
'zlib',
|
||||
];
|
||||
|
||||
export const DEFAULT_LINTER_SEVERITY: Diagnostic['severity'] = 'error';
|
||||
|
||||
export const DEFAULT_LINTER_DELAY_IN_MS = 500;
|
||||
|
||||
/**
|
||||
* Length of the start of the script wrapper, used as offset for the linter to find a location in source text.
|
||||
*/
|
||||
export const OFFSET_FOR_SCRIPT_WRAPPER = 'module.exports = async function() {'.length;
|
||||
|
||||
export const CODE_PLACEHOLDERS: Partial<
|
||||
Record<CodeNodeEditorLanguage, Record<CodeExecutionMode, string>>
|
||||
> = {
|
||||
javaScript: {
|
||||
runOnceForAllItems: `
|
||||
// Loop over input items and add a new field called 'myNewField' to the JSON of each one
|
||||
for (const item of $input.all()) {
|
||||
item.json.myNewField = 1;
|
||||
}
|
||||
|
||||
return $input.all();`.trim(),
|
||||
runOnceForEachItem: `
|
||||
// Add a new field called 'myNewField' to the JSON of the item
|
||||
$input.item.json.myNewField = 1;
|
||||
|
||||
return $input.item;`.trim(),
|
||||
},
|
||||
python: {
|
||||
runOnceForAllItems: `
|
||||
# Loop over input items and add a new field called 'myNewField' to the JSON of each one
|
||||
for item in _input.all():
|
||||
item.json.myNewField = 1
|
||||
return _input.all()`.trim(),
|
||||
runOnceForEachItem: `
|
||||
# Add a new field called 'myNewField' to the JSON of the item
|
||||
_input.item.json.myNewField = 1
|
||||
return _input.item`.trim(),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,623 @@
|
||||
import type { Diagnostic } from '@codemirror/lint';
|
||||
import { linter as codeMirrorLinter } from '@codemirror/lint';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import * as esprima from 'esprima-next';
|
||||
import type { Node, MemberExpression } from 'estree';
|
||||
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
|
||||
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
|
||||
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import {
|
||||
DEFAULT_LINTER_DELAY_IN_MS,
|
||||
DEFAULT_LINTER_SEVERITY,
|
||||
OFFSET_FOR_SCRIPT_WRAPPER,
|
||||
} from './constants';
|
||||
import type { RangeNode } from './types';
|
||||
import { walk } from './utils';
|
||||
|
||||
export const useLinter = (
|
||||
mode: MaybeRefOrGetter<CodeExecutionMode>,
|
||||
language: MaybeRefOrGetter<CodeNodeEditorLanguage>,
|
||||
) => {
|
||||
const i18n = useI18n();
|
||||
const linter = computed(() => {
|
||||
switch (toValue(language)) {
|
||||
case 'javaScript':
|
||||
return codeMirrorLinter(lintSource, { delay: DEFAULT_LINTER_DELAY_IN_MS });
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
function lintSource(editorView: EditorView): Diagnostic[] {
|
||||
const doc = editorView.state.doc.toString();
|
||||
const script = `module.exports = async function() {${doc}\n}()`;
|
||||
|
||||
let ast: esprima.Program | null = null;
|
||||
|
||||
try {
|
||||
ast = esprima.parseScript(script, { range: true });
|
||||
} catch (syntaxError) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (ast === null) return [];
|
||||
|
||||
const lintings: Diagnostic[] = [];
|
||||
|
||||
/**
|
||||
* Lint for incorrect `.item()` instead of `.item` in `runOnceForEachItem` mode
|
||||
*
|
||||
* $input.item() -> $input.item
|
||||
*/
|
||||
|
||||
if (toValue(mode) === 'runOnceForEachItem') {
|
||||
const isItemCall = (node: Node) =>
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'MemberExpression' &&
|
||||
node.callee.property.type === 'Identifier' &&
|
||||
node.callee.property.name === 'item';
|
||||
|
||||
walk(ast, isItemCall).forEach((node) => {
|
||||
const [start, end] = getRange(node);
|
||||
|
||||
lintings.push({
|
||||
from: start,
|
||||
to: end,
|
||||
severity: DEFAULT_LINTER_SEVERITY,
|
||||
message: i18n.baseText('codeNodeEditor.linter.allItems.itemCall'),
|
||||
actions: [
|
||||
{
|
||||
name: 'Fix',
|
||||
apply(view, _, to) {
|
||||
view.dispatch({ changes: { from: end - '()'.length, to } });
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lint for `$json`, `$binary` and `$itemIndex` unavailable in `runOnceForAllItems` mode
|
||||
*
|
||||
* $json -> <removed>
|
||||
*/
|
||||
|
||||
if (toValue(mode) === 'runOnceForAllItems') {
|
||||
const isUnavailableVarInAllItems = (node: Node) =>
|
||||
node.type === 'Identifier' && ['$json', '$binary', '$itemIndex'].includes(node.name);
|
||||
|
||||
walk(ast, isUnavailableVarInAllItems).forEach((node) => {
|
||||
const [start, end] = getRange(node);
|
||||
|
||||
const varName = getText(editorView, node);
|
||||
|
||||
if (!varName) return;
|
||||
|
||||
const message = [
|
||||
`\`${varName}\``,
|
||||
i18n.baseText('codeNodeEditor.linter.allItems.unavailableVar'),
|
||||
].join(' ');
|
||||
|
||||
lintings.push({
|
||||
from: start,
|
||||
to: end,
|
||||
severity: DEFAULT_LINTER_SEVERITY,
|
||||
message,
|
||||
actions: [
|
||||
{
|
||||
name: 'Remove',
|
||||
apply(view, from, to) {
|
||||
view.dispatch({ changes: { from, to } });
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lint for `.item` unavailable in `$input` in `runOnceForAllItems` mode
|
||||
*
|
||||
* $input.item -> <removed>
|
||||
*/
|
||||
|
||||
if (toValue(mode) === 'runOnceForAllItems') {
|
||||
type TargetNode = RangeNode & { property: RangeNode };
|
||||
|
||||
const isInputIdentifier = (node: Node) =>
|
||||
node.type === 'Identifier' && node.name === '$input';
|
||||
const isPreviousNodeCall = (node: Node) =>
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'Identifier' &&
|
||||
node.callee.name === '$';
|
||||
const isDirectMemberExpression = (node: Node): node is MemberExpression =>
|
||||
node.type === 'MemberExpression' && !node.computed;
|
||||
const isItemIdentifier = (node: Node) => node.type === 'Identifier' && node.name === 'item';
|
||||
|
||||
const isUnavailableInputItemAccess = (node: Node) =>
|
||||
isDirectMemberExpression(node) &&
|
||||
(isInputIdentifier(node.object) || isPreviousNodeCall(node.object)) &&
|
||||
isItemIdentifier(node.property);
|
||||
|
||||
walk<TargetNode>(ast, isUnavailableInputItemAccess).forEach((node) => {
|
||||
const [start, end] = getRange(node.property);
|
||||
|
||||
lintings.push({
|
||||
from: start,
|
||||
to: end,
|
||||
severity: DEFAULT_LINTER_SEVERITY,
|
||||
message: i18n.baseText('codeNodeEditor.linter.allItems.unavailableProperty'),
|
||||
actions: [
|
||||
{
|
||||
name: 'Fix',
|
||||
apply(view) {
|
||||
view.dispatch({ changes: { from: start, to: end, insert: 'first()' } });
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lint for `items` (legacy var from Function node) unavailable
|
||||
* in `runOnceForEachItem` mode, unless user-defined `items`.
|
||||
*
|
||||
* items -> $input.item
|
||||
*/
|
||||
if (toValue(mode) === 'runOnceForEachItem' && !/(let|const|var) items =/.test(script)) {
|
||||
type TargetNode = RangeNode & { object: RangeNode & { name: string } };
|
||||
|
||||
const isUnavailableLegacyItems = (node: Node) =>
|
||||
node.type === 'Identifier' && node.name === 'items';
|
||||
|
||||
walk<TargetNode>(ast, isUnavailableLegacyItems).forEach((node) => {
|
||||
const [start, end] = getRange(node);
|
||||
|
||||
lintings.push({
|
||||
from: start,
|
||||
to: end,
|
||||
severity: DEFAULT_LINTER_SEVERITY,
|
||||
message: i18n.baseText('codeNodeEditor.linter.eachItem.unavailableItems'),
|
||||
actions: [
|
||||
{
|
||||
name: 'Fix',
|
||||
apply(view, from, to) {
|
||||
// prevent second insertion of unknown origin
|
||||
if (view.state.doc.toString().slice(from, to).includes('$input.item')) {
|
||||
return;
|
||||
}
|
||||
|
||||
view.dispatch({ changes: { from: start, to: end } });
|
||||
view.dispatch({ changes: { from, insert: '$input.item' } });
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lint for `.first()`, `.last()`, `.all()` and `.itemMatching()`
|
||||
* unavailable in `runOnceForEachItem` mode
|
||||
*
|
||||
* $input.first()
|
||||
* $input.last()
|
||||
* $input.all()
|
||||
* $input.itemMatching()
|
||||
*/
|
||||
|
||||
if (toValue(mode) === 'runOnceForEachItem') {
|
||||
type TargetNode = RangeNode & { property: RangeNode & { name: string } };
|
||||
|
||||
const isUnavailableMethodinEachItem = (node: Node) =>
|
||||
node.type === 'MemberExpression' &&
|
||||
!node.computed &&
|
||||
node.object.type === 'Identifier' &&
|
||||
node.object.name === '$input' &&
|
||||
node.property.type === 'Identifier' &&
|
||||
['first', 'last', 'all', 'itemMatching'].includes(node.property.name);
|
||||
|
||||
walk<TargetNode>(ast, isUnavailableMethodinEachItem).forEach((node) => {
|
||||
const [start, end] = getRange(node.property);
|
||||
|
||||
const method = getText(editorView, node.property);
|
||||
|
||||
if (!method) return;
|
||||
|
||||
lintings.push({
|
||||
from: start,
|
||||
to: end,
|
||||
severity: DEFAULT_LINTER_SEVERITY,
|
||||
message: i18n.baseText('codeNodeEditor.linter.eachItem.unavailableMethod', {
|
||||
interpolate: { method },
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lint for `.itemMatching()` called with no argument in `runOnceForAllItems` mode
|
||||
*
|
||||
* $input.itemMatching()
|
||||
*/
|
||||
|
||||
if (toValue(mode) === 'runOnceForAllItems') {
|
||||
type TargetNode = RangeNode & { callee: RangeNode & { property: RangeNode } };
|
||||
|
||||
const isItemMatchingCallWithoutArg = (node: Node) =>
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'MemberExpression' &&
|
||||
node.callee.property.type === 'Identifier' &&
|
||||
node.callee.property.name === 'itemMatching' &&
|
||||
node.arguments.length === 0;
|
||||
|
||||
walk<TargetNode>(ast, isItemMatchingCallWithoutArg).forEach((node) => {
|
||||
const [start, end] = getRange(node.callee.property);
|
||||
|
||||
lintings.push({
|
||||
from: start,
|
||||
to: end + '()'.length,
|
||||
severity: DEFAULT_LINTER_SEVERITY,
|
||||
message: i18n.baseText('codeNodeEditor.linter.allItems.itemMatchingNoArg'),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lint for `$input.first()` or `$input.last()` called with argument in `runOnceForAllItems` mode
|
||||
*
|
||||
* $input.first(arg) -> $input.first()
|
||||
* $input.last(arg) -> $input.last()
|
||||
*/
|
||||
|
||||
if (toValue(mode) === 'runOnceForAllItems') {
|
||||
type TargetNode = RangeNode & {
|
||||
callee: { property: { name: string } & RangeNode };
|
||||
};
|
||||
|
||||
const inputFirstOrLastCalledWithArg = (node: Node) =>
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'MemberExpression' &&
|
||||
!node.callee.computed &&
|
||||
node.callee.object.type === 'Identifier' &&
|
||||
node.callee.object.name === '$input' &&
|
||||
node.callee.property.type === 'Identifier' &&
|
||||
['first', 'last'].includes(node.callee.property.name) &&
|
||||
node.arguments.length !== 0;
|
||||
|
||||
walk<TargetNode>(ast, inputFirstOrLastCalledWithArg).forEach((node) => {
|
||||
const [start, end] = getRange(node.callee.property);
|
||||
|
||||
const message = [
|
||||
`\`$input.${node.callee.property.name}()\``,
|
||||
i18n.baseText('codeNodeEditor.linter.allItems.firstOrLastCalledWithArg'),
|
||||
].join(' ');
|
||||
|
||||
lintings.push({
|
||||
from: start,
|
||||
to: end,
|
||||
severity: DEFAULT_LINTER_SEVERITY,
|
||||
message,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lint for empty (i.e. no value) return
|
||||
*
|
||||
* return -> <no autofix>
|
||||
*/
|
||||
|
||||
const isEmptyReturn = (node: Node) => node.type === 'ReturnStatement' && node.argument === null;
|
||||
|
||||
const emptyReturnMessage =
|
||||
toValue(mode) === 'runOnceForAllItems'
|
||||
? i18n.baseText('codeNodeEditor.linter.allItems.emptyReturn')
|
||||
: i18n.baseText('codeNodeEditor.linter.eachItem.emptyReturn');
|
||||
|
||||
walk<RangeNode>(ast, isEmptyReturn).forEach((node) => {
|
||||
const [start, end] = node.range.map((loc) => loc - OFFSET_FOR_SCRIPT_WRAPPER);
|
||||
|
||||
lintings.push({
|
||||
from: start,
|
||||
to: end,
|
||||
severity: DEFAULT_LINTER_SEVERITY,
|
||||
message: emptyReturnMessage,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Lint for array return in `runOnceForEachItem` mode
|
||||
*
|
||||
* return [] -> <no autofix>
|
||||
*/
|
||||
|
||||
if (toValue(mode) === 'runOnceForEachItem') {
|
||||
const isArrayReturn = (node: Node) =>
|
||||
node.type === 'ReturnStatement' &&
|
||||
node.argument !== null &&
|
||||
node.argument !== undefined &&
|
||||
node.argument.type === 'ArrayExpression';
|
||||
|
||||
walk<RangeNode>(ast, isArrayReturn).forEach((node) => {
|
||||
const [start, end] = getRange(node);
|
||||
|
||||
lintings.push({
|
||||
from: start,
|
||||
to: end,
|
||||
severity: DEFAULT_LINTER_SEVERITY,
|
||||
message: i18n.baseText('codeNodeEditor.linter.eachItem.returnArray'),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lint for direct access to item property (i.e. not using `json`)
|
||||
* in `runOnceForAllItems` mode
|
||||
*
|
||||
* item.myField = 123 -> item.json.myField = 123;
|
||||
* const a = item.myField -> const a = item.json.myField;
|
||||
*/
|
||||
|
||||
if (toValue(mode) === 'runOnceForAllItems') {
|
||||
type TargetNode = RangeNode & {
|
||||
left: { declarations: Array<{ id: { type: string; name: string } }> };
|
||||
};
|
||||
|
||||
const isForOfStatementOverN8nVar = (node: Node) =>
|
||||
node.type === 'ForOfStatement' &&
|
||||
node.left.type === 'VariableDeclaration' &&
|
||||
node.left.declarations.length === 1 &&
|
||||
node.left.declarations[0].type === 'VariableDeclarator' &&
|
||||
node.left.declarations[0].id.type === 'Identifier' &&
|
||||
node.right.type === 'CallExpression' &&
|
||||
node.right.callee.type === 'MemberExpression' &&
|
||||
!node.right.callee.computed &&
|
||||
node.right.callee.object.type === 'Identifier' &&
|
||||
node.right.callee.object.name.startsWith('$'); // n8n var, e.g $input
|
||||
|
||||
const found = walk<TargetNode>(ast, isForOfStatementOverN8nVar);
|
||||
|
||||
if (found.length === 1) {
|
||||
const itemAlias = found[0].left.declarations[0].id.name;
|
||||
|
||||
/**
|
||||
* for (const item of $input.all()) {
|
||||
* const item = {}; // shadow item
|
||||
* }
|
||||
*/
|
||||
const isShadowItemVar = (node: Node) =>
|
||||
node.type === 'VariableDeclarator' &&
|
||||
node.id.type === 'Identifier' &&
|
||||
node.id.name === 'item' &&
|
||||
node.init !== null;
|
||||
|
||||
const shadowFound = walk(ast, isShadowItemVar);
|
||||
|
||||
let shadowStart: undefined | number;
|
||||
|
||||
if (shadowFound.length > 0) {
|
||||
const [shadow] = shadowFound;
|
||||
const [_shadowStart] = getRange(shadow);
|
||||
shadowStart = _shadowStart;
|
||||
}
|
||||
|
||||
const isDirectAccessToItem = (node: Node) =>
|
||||
node.type === 'MemberExpression' &&
|
||||
node.object.type === 'Identifier' &&
|
||||
node.object.name === itemAlias &&
|
||||
node.property.type === 'Identifier' &&
|
||||
!['json', 'binary'].includes(node.property.name);
|
||||
|
||||
walk(ast, isDirectAccessToItem).forEach((node) => {
|
||||
const [start, end] = getRange(node);
|
||||
|
||||
if (shadowStart && start > shadowStart) return; // skip shadow item
|
||||
|
||||
const varName = getText(editorView, node);
|
||||
|
||||
if (!varName) return;
|
||||
|
||||
lintings.push({
|
||||
from: start,
|
||||
to: end,
|
||||
severity: DEFAULT_LINTER_SEVERITY,
|
||||
message: i18n.baseText('codeNodeEditor.linter.bothModes.directAccess.itemProperty'),
|
||||
actions: [
|
||||
{
|
||||
name: 'Fix',
|
||||
apply(view, from, to) {
|
||||
// prevent second insertion of unknown origin
|
||||
if (view.state.doc.toString().slice(from, to).includes('.json')) return;
|
||||
|
||||
view.dispatch({ changes: { from: from + itemAlias.length, insert: '.json' } });
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lint for direct access to item property (i.e. not using `json`)
|
||||
* in `runOnceForEachItem` mode
|
||||
*
|
||||
* $input.item.myField = 123 -> $input.item.json.myField = 123;
|
||||
* const a = $input.item.myField -> const a = $input.item.json.myField;
|
||||
*/
|
||||
|
||||
if (toValue(mode) === 'runOnceForEachItem') {
|
||||
type TargetNode = RangeNode & { object: { property: RangeNode } };
|
||||
|
||||
const isDirectAccessToItemSubproperty = (node: Node) =>
|
||||
node.type === 'MemberExpression' &&
|
||||
node.object.type === 'MemberExpression' &&
|
||||
node.object.property.type === 'Identifier' &&
|
||||
node.object.property.name === 'item' &&
|
||||
node.property.type === 'Identifier' &&
|
||||
!['json', 'binary'].includes(node.property.name);
|
||||
|
||||
walk<TargetNode>(ast, isDirectAccessToItemSubproperty).forEach((node) => {
|
||||
const varName = getText(editorView, node);
|
||||
|
||||
if (!varName) return;
|
||||
|
||||
const [start, end] = getRange(node);
|
||||
|
||||
const [, fixEnd] = getRange(node.object.property);
|
||||
|
||||
lintings.push({
|
||||
from: start,
|
||||
to: end,
|
||||
severity: DEFAULT_LINTER_SEVERITY,
|
||||
message: i18n.baseText('codeNodeEditor.linter.bothModes.directAccess.itemProperty'),
|
||||
actions: [
|
||||
{
|
||||
name: 'Fix',
|
||||
apply(view, from, to) {
|
||||
// prevent second insertion of unknown origin
|
||||
if (view.state.doc.toString().slice(from, to).includes('.json')) return;
|
||||
|
||||
view.dispatch({ changes: { from: fixEnd, insert: '.json' } });
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lint for direct access to `first()` or `last()` output (i.e. not using `json`)
|
||||
*
|
||||
* $input.first().myField -> $input.first().json.myField
|
||||
*/
|
||||
|
||||
type TargetNode = RangeNode & { object: RangeNode };
|
||||
|
||||
const isDirectAccessToFirstOrLastCall = (node: Node) =>
|
||||
node.type === 'MemberExpression' &&
|
||||
node.property.type === 'Identifier' &&
|
||||
!['json', 'binary'].includes(node.property.name) &&
|
||||
node.object.type === 'CallExpression' &&
|
||||
node.object.arguments.length === 0 &&
|
||||
node.object.callee.type === 'MemberExpression' &&
|
||||
node.object.callee.property.type === 'Identifier' &&
|
||||
['first', 'last'].includes(node.object.callee.property.name);
|
||||
|
||||
walk<TargetNode>(ast, isDirectAccessToFirstOrLastCall).forEach((node) => {
|
||||
const [start, end] = getRange(node);
|
||||
|
||||
const [, fixEnd] = getRange(node.object);
|
||||
|
||||
lintings.push({
|
||||
from: start,
|
||||
to: end,
|
||||
severity: DEFAULT_LINTER_SEVERITY,
|
||||
message: i18n.baseText('codeNodeEditor.linter.bothModes.directAccess.firstOrLastCall'),
|
||||
actions: [
|
||||
{
|
||||
name: 'Fix',
|
||||
apply(view, from, to) {
|
||||
// prevent second insertion of unknown origin
|
||||
if (view.state.doc.toString().slice(from, to).includes('.json')) return;
|
||||
|
||||
view.dispatch({ changes: { from: fixEnd, insert: '.json' } });
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Lint for `$(variable)` usage where variable is not a string, in both modes.
|
||||
*
|
||||
* $(nodeName) -> <no autofix>
|
||||
*/
|
||||
const isDollarSignWithVariable = (node: Node) =>
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'Identifier' &&
|
||||
node.callee.name === '$' &&
|
||||
node.arguments.length === 1 &&
|
||||
((node.arguments[0].type !== 'Literal' && node.arguments[0].type !== 'TemplateLiteral') ||
|
||||
(node.arguments[0].type === 'TemplateLiteral' && node.arguments[0].expressions.length > 0));
|
||||
|
||||
type TargetCallNode = RangeNode & {
|
||||
callee: { name: string };
|
||||
arguments: Array<{ type: string }>;
|
||||
};
|
||||
|
||||
walk<TargetCallNode>(ast, isDollarSignWithVariable).forEach((node) => {
|
||||
const [start, end] = getRange(node);
|
||||
|
||||
lintings.push({
|
||||
from: start,
|
||||
to: end,
|
||||
severity: 'warning',
|
||||
message: i18n.baseText('codeNodeEditor.linter.bothModes.dollarSignVariable'),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Lint for $("myNode").item access in runOnceForAllItems mode
|
||||
*
|
||||
* $("myNode").item -> $("myNode").first()
|
||||
*/
|
||||
if (toValue(mode) === 'runOnceForEachItem') {
|
||||
type DollarItemNode = RangeNode & {
|
||||
property: { name: string; type: string } & RangeNode;
|
||||
};
|
||||
|
||||
const isDollarNodeItemAccess = (node: Node) =>
|
||||
node.type === 'MemberExpression' &&
|
||||
!node.computed &&
|
||||
node.object.type === 'CallExpression' &&
|
||||
node.object.callee.type === 'Identifier' &&
|
||||
node.object.callee.name === '$' &&
|
||||
node.object.arguments.length === 1 &&
|
||||
node.object.arguments[0].type === 'Literal' &&
|
||||
node.property.type === 'Identifier' &&
|
||||
node.property.name === 'item';
|
||||
|
||||
walk<DollarItemNode>(ast, isDollarNodeItemAccess).forEach((node) => {
|
||||
const [start, end] = getRange(node.property);
|
||||
|
||||
lintings.push({
|
||||
from: start,
|
||||
to: end,
|
||||
severity: 'warning',
|
||||
message: i18n.baseText('codeNodeEditor.linter.eachItem.preferFirst'),
|
||||
actions: [
|
||||
{
|
||||
name: 'Fix',
|
||||
apply(view) {
|
||||
view.dispatch({ changes: { from: start, to: end, insert: 'first()' } });
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return lintings;
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// helpers
|
||||
// ----------------------------------
|
||||
|
||||
function getText(editorView: EditorView, node: RangeNode) {
|
||||
const [start, end] = getRange(node);
|
||||
|
||||
return editorView.state.doc.toString().slice(start, end);
|
||||
}
|
||||
|
||||
function getRange(node: RangeNode) {
|
||||
return node.range.map((loc) => loc - OFFSET_FOR_SCRIPT_WRAPPER);
|
||||
}
|
||||
|
||||
return linter;
|
||||
};
|
||||
@@ -0,0 +1,261 @@
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { tags } from '@lezer/highlight';
|
||||
|
||||
/**
|
||||
* Light theme based on Tomorrow theme by Chris Kempson
|
||||
* https://github.com/vadimdemedes/thememirror/blob/main/source/themes/tomorrow.ts
|
||||
*
|
||||
* Dark theme based on Dracula theme by Zeno Rocha
|
||||
* https://github.com/vadimdemedes/thememirror/blob/main/source/themes/dracula.ts
|
||||
*/
|
||||
|
||||
const BASE_STYLING = {
|
||||
fontSize: '0.8em',
|
||||
fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace !important",
|
||||
maxHeight: '400px',
|
||||
tooltip: {
|
||||
maxWidth: '250px',
|
||||
lineHeight: '1.3em',
|
||||
},
|
||||
diagnosticButton: {
|
||||
backgroundColor: 'inherit',
|
||||
lineHeight: '1em',
|
||||
textDecoration: 'underline',
|
||||
marginLeft: '0.2em',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
};
|
||||
|
||||
interface ThemeSettings {
|
||||
isReadOnly?: boolean;
|
||||
maxHeight?: string;
|
||||
minHeight?: string;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
const codeEditorSyntaxHighlighting = syntaxHighlighting(
|
||||
HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: 'var(--color-code-tags-keyword)' },
|
||||
{
|
||||
tag: [
|
||||
tags.deleted,
|
||||
tags.character,
|
||||
tags.macroName,
|
||||
tags.definition(tags.name),
|
||||
tags.definition(tags.variableName),
|
||||
tags.atom,
|
||||
tags.bool,
|
||||
],
|
||||
color: 'var(--color-code-tags-variable)',
|
||||
},
|
||||
{ tag: [tags.name, tags.propertyName], color: 'var(--color-code-tags-property)' },
|
||||
{
|
||||
tag: [tags.processingInstruction, tags.string, tags.inserted, tags.special(tags.string)],
|
||||
color: 'var(--color-code-tags-string)',
|
||||
},
|
||||
{
|
||||
tag: [tags.function(tags.variableName), tags.labelName],
|
||||
color: 'var(--color-code-tags-function)',
|
||||
},
|
||||
{
|
||||
tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)],
|
||||
color: 'var(--color-code-tags-constant)',
|
||||
},
|
||||
{ tag: [tags.className], color: 'var(--color-code-tags-class)' },
|
||||
{
|
||||
tag: [tags.number, tags.changed, tags.annotation, tags.modifier, tags.self, tags.namespace],
|
||||
color: 'var(--color-code-tags-primitive)',
|
||||
},
|
||||
{ tag: [tags.typeName], color: 'var(--color-code-tags-type)' },
|
||||
{ tag: [tags.operator, tags.operatorKeyword], color: 'var(--color-code-tags-keyword)' },
|
||||
{
|
||||
tag: [tags.url, tags.escape, tags.regexp, tags.link],
|
||||
color: 'var(--color-code-tags-keyword)',
|
||||
},
|
||||
{ tag: [tags.meta, tags.comment, tags.lineComment], color: 'var(--color-code-tags-comment)' },
|
||||
{ tag: tags.strong, fontWeight: 'bold' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.link, textDecoration: 'underline' },
|
||||
{ tag: tags.heading, fontWeight: 'bold', color: 'var(--color-code-tags-heading)' },
|
||||
{ tag: tags.invalid, color: 'var(--color-code-tags-invalid)' },
|
||||
{ tag: tags.strikethrough, textDecoration: 'line-through' },
|
||||
{
|
||||
tag: [tags.derefOperator, tags.special(tags.variableName), tags.variableName, tags.separator],
|
||||
color: 'var(--color-code-foreground)',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
export const codeEditorTheme = ({ isReadOnly, minHeight, maxHeight, rows }: ThemeSettings) => [
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
'font-size': BASE_STYLING.fontSize,
|
||||
border: 'var(--border-base)',
|
||||
borderRadius: 'var(--border-radius-base)',
|
||||
backgroundColor: 'var(--color-code-background)',
|
||||
color: 'var(--color-code-foreground)',
|
||||
height: '100%',
|
||||
},
|
||||
'.cm-content': {
|
||||
fontFamily: BASE_STYLING.fontFamily,
|
||||
caretColor: isReadOnly ? 'transparent' : 'var(--color-code-caret)',
|
||||
lineHeight: 'var(--font-line-height-xloose)',
|
||||
paddingTop: 'var(--spacing-2xs)',
|
||||
paddingBottom: 'var(--spacing-s)',
|
||||
},
|
||||
'.cm-cursor, .cm-dropCursor': {
|
||||
borderLeftColor: 'var(--color-code-caret)',
|
||||
},
|
||||
'&.cm-focused > .cm-scroller .cm-selectionLayer > .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
|
||||
{
|
||||
background: 'var(--color-code-selection)',
|
||||
},
|
||||
'&.cm-editor': {
|
||||
...(isReadOnly ? { backgroundColor: 'var(--color-code-background-readonly)' } : {}),
|
||||
borderColor: 'var(--border-color-base)',
|
||||
},
|
||||
'&.cm-editor.cm-focused': {
|
||||
outline: 'none',
|
||||
borderColor: 'var(--color-secondary)',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'var(--color-code-lineHighlight)',
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'var(--color-code-lineHighlight)',
|
||||
},
|
||||
'.cm-lineNumbers .cm-activeLineGutter': {
|
||||
color: 'var(--color-code-gutter-foreground-active)',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: isReadOnly
|
||||
? 'var(--color-code-background-readonly)'
|
||||
: 'var(--color-code-gutter-background)',
|
||||
color: 'var(--color-code-gutter-foreground)',
|
||||
border: '0',
|
||||
borderRadius: 'var(--border-radius-base)',
|
||||
},
|
||||
'.cm-gutterElement': {
|
||||
padding: 0,
|
||||
},
|
||||
'.cm-tooltip': {
|
||||
maxWidth: BASE_STYLING.tooltip.maxWidth,
|
||||
lineHeight: BASE_STYLING.tooltip.lineHeight,
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
maxHeight: maxHeight ?? '100%',
|
||||
...(isReadOnly
|
||||
? {}
|
||||
: {
|
||||
minHeight: rows && rows !== -1 ? `${Number(rows + 1) * 1.3}em` : 'auto',
|
||||
}),
|
||||
},
|
||||
'.cm-lineNumbers .cm-gutterElement': {
|
||||
padding: '0 var(--spacing-5xs) 0 var(--spacing-2xs)',
|
||||
},
|
||||
'.cm-gutter,.cm-content': {
|
||||
minHeight: rows && rows !== -1 ? 'auto' : (minHeight ?? 'calc(35vh - var(--spacing-2xl))'),
|
||||
},
|
||||
'.cm-foldGutter': {
|
||||
width: '16px',
|
||||
},
|
||||
'.cm-fold-marker': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
},
|
||||
'.cm-activeLineGutter .cm-fold-marker, .cm-gutters:hover .cm-fold-marker': {
|
||||
opacity: 1,
|
||||
},
|
||||
'.cm-diagnosticAction': {
|
||||
backgroundColor: BASE_STYLING.diagnosticButton.backgroundColor,
|
||||
color: 'var(--color-primary)',
|
||||
lineHeight: BASE_STYLING.diagnosticButton.lineHeight,
|
||||
textDecoration: BASE_STYLING.diagnosticButton.textDecoration,
|
||||
marginLeft: BASE_STYLING.diagnosticButton.marginLeft,
|
||||
cursor: BASE_STYLING.diagnosticButton.cursor,
|
||||
},
|
||||
'.cm-diagnostic-error': {
|
||||
backgroundColor: 'var(--color-infobox-background)',
|
||||
},
|
||||
'.cm-diagnosticText': {
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
color: 'var(--color-text-base)',
|
||||
},
|
||||
'.cm-diagnosticDocs': {
|
||||
fontSize: 'var(--font-size-2xs)',
|
||||
},
|
||||
'.cm-foldPlaceholder': {
|
||||
color: 'var(--color-text-base)',
|
||||
backgroundColor: 'var(--color-background-base)',
|
||||
border: 'var(--border-base)',
|
||||
},
|
||||
'.cm-selectionMatch': {
|
||||
background: 'var(--color-code-selection-highlight)',
|
||||
},
|
||||
'.cm-selectionMatch-main': {
|
||||
background: 'var(--color-code-selection-highlight)',
|
||||
},
|
||||
'.cm-matchingBracket': {
|
||||
background: 'var(--color-code-selection)',
|
||||
},
|
||||
'.cm-completionMatchedText': {
|
||||
textDecoration: 'none',
|
||||
fontWeight: '600',
|
||||
color: 'var(--color-autocomplete-item-selected)',
|
||||
},
|
||||
'.cm-faded > span': {
|
||||
opacity: 0.6,
|
||||
},
|
||||
'.cm-panel.cm-search': {
|
||||
padding: 'var(--spacing-4xs) var(--spacing-2xs)',
|
||||
},
|
||||
'.cm-panels': {
|
||||
background: 'var(--color-background-light)',
|
||||
color: 'var(--color-text-base)',
|
||||
},
|
||||
'.cm-panels-bottom': {
|
||||
borderTop: 'var(--border-base)',
|
||||
},
|
||||
'.cm-textfield': {
|
||||
color: 'var(--color-text-dark)',
|
||||
background: 'var(--color-foreground-xlight)',
|
||||
borderRadius: 'var(--border-radius-base)',
|
||||
border: 'var(--border-base)',
|
||||
fontSize: '90%',
|
||||
},
|
||||
'.cm-textfield:focus': {
|
||||
outline: 'none',
|
||||
borderColor: 'var(--color-secondary)',
|
||||
},
|
||||
'.cm-panel button': {
|
||||
color: 'var(--color-text-base)',
|
||||
},
|
||||
'.cm-panel input[type="checkbox"]': {
|
||||
border: 'var(--border-base)',
|
||||
outline: 'none',
|
||||
},
|
||||
'.cm-panel input[type="checkbox"]:hover': {
|
||||
border: 'var(--border-base)',
|
||||
outline: 'none',
|
||||
},
|
||||
'.cm-panel.cm-search label': {
|
||||
fontSize: '90%',
|
||||
},
|
||||
'.cm-button': {
|
||||
outline: 'none',
|
||||
border: 'var(--border-base)',
|
||||
color: 'var(--color-text-dark)',
|
||||
backgroundColor: 'var(--color-foreground-xlight)',
|
||||
backgroundImage: 'none',
|
||||
borderRadius: 'var(--border-radius-base)',
|
||||
fontSize: '90%',
|
||||
},
|
||||
}),
|
||||
codeEditorSyntaxHighlighting,
|
||||
];
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { Workflow, CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
|
||||
import type { Node } from 'estree';
|
||||
import type { DefineComponent } from 'vue';
|
||||
|
||||
export type CodeNodeEditorMixin = InstanceType<
|
||||
DefineComponent & {
|
||||
editor: EditorView | null;
|
||||
mode: CodeExecutionMode;
|
||||
language: CodeNodeEditorLanguage;
|
||||
getCurrentWorkflow(): Workflow;
|
||||
}
|
||||
>;
|
||||
|
||||
export type RangeNode = Node & { range: [number, number] };
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as esprima from 'esprima-next';
|
||||
import { walk } from './utils';
|
||||
|
||||
describe('CodeNodeEditor utils', () => {
|
||||
describe('walk', () => {
|
||||
it('should find the correct syntax nodes', () => {
|
||||
const code = `const x = 'a'
|
||||
function f(arg) {
|
||||
arg['b'] = 1
|
||||
return arg
|
||||
}
|
||||
|
||||
const y = f({ a: 'c' })
|
||||
`;
|
||||
const program = esprima.parse(code);
|
||||
const stringLiterals = walk(
|
||||
program,
|
||||
(node) => node.type === esprima.Syntax.Literal && typeof node.value === 'string',
|
||||
);
|
||||
expect(stringLiterals).toEqual([
|
||||
new esprima.Literal('a', "'a'"),
|
||||
new esprima.Literal('b', "'b'"),
|
||||
new esprima.Literal('c', "'c'"),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle null syntax nodes', () => {
|
||||
// ,, in [1,,2] results in a `null` ArrayExpressionElement
|
||||
const code = 'const fn = () => [1,,2]';
|
||||
const program = esprima.parse(code);
|
||||
const arrayExpressions = walk(
|
||||
program,
|
||||
(node) => node.type === esprima.Syntax.ArrayExpression,
|
||||
);
|
||||
expect(arrayExpressions).toEqual([
|
||||
new esprima.ArrayExpression([
|
||||
new esprima.Literal(1, '1'),
|
||||
null,
|
||||
new esprima.Literal(2, '2'),
|
||||
]),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as esprima from 'esprima-next';
|
||||
import type { Completion } from '@codemirror/autocomplete';
|
||||
import type { RangeNode } from './types';
|
||||
import { sanitizeHtml } from '@/utils/htmlUtils';
|
||||
import type { Node } from 'estree';
|
||||
|
||||
export function walk<T extends RangeNode>(
|
||||
node: Node | esprima.Program,
|
||||
test: (node: Node) => boolean,
|
||||
found: Node[] = [],
|
||||
) {
|
||||
const isProgram = node.type === esprima.Syntax.Program;
|
||||
if (!isProgram && test(node)) found.push(node);
|
||||
|
||||
if (isProgram) {
|
||||
node.body.forEach((n) => walk(n as Node, test, found));
|
||||
} else {
|
||||
for (const key in node) {
|
||||
if (!(key in node)) continue;
|
||||
|
||||
// @ts-expect-error Node is not string indexable, but it has many possible properties
|
||||
const child = node[key];
|
||||
|
||||
if (child === null || typeof child !== 'object') continue;
|
||||
|
||||
if (Array.isArray(child)) {
|
||||
child.filter(Boolean).forEach((n) => walk(n, test, found));
|
||||
} else {
|
||||
walk(child, test, found);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return found as T[];
|
||||
}
|
||||
|
||||
export const escape = (str: string) =>
|
||||
str
|
||||
.replace('$', '\\$')
|
||||
.replace('(', '\\(')
|
||||
.replace(')', '\\)')
|
||||
.replace('[', '\\[')
|
||||
.replace(']', '\\]');
|
||||
|
||||
export const toVariableOption = (label: string) => ({ label, type: 'variable' });
|
||||
|
||||
export const addVarType = (option: Completion) => ({ ...option, type: 'variable' });
|
||||
|
||||
export const addInfoRenderer = (option: Completion): Completion => {
|
||||
const { info } = option;
|
||||
if (typeof info === 'string') {
|
||||
option.info = () => {
|
||||
const wrapper = document.createElement('span');
|
||||
wrapper.innerHTML = sanitizeHtml(info);
|
||||
return wrapper;
|
||||
};
|
||||
}
|
||||
return option;
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { STORES } from '@/constants';
|
||||
import CollaborationPane from '@/components/MainHeader/CollaborationPane.vue';
|
||||
import type { IUser } from '@/Interface';
|
||||
|
||||
import type { RenderOptions } from '@/__tests__/render';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { waitAllPromises } from '@/__tests__/utils';
|
||||
|
||||
const OWNER_USER = mock<IUser>({ id: 'owner-id' });
|
||||
const MEMBER_USER = mock<IUser>({ id: 'member-id' });
|
||||
const MEMBER_USER_2 = mock<IUser>({ id: 'member-id-2' });
|
||||
|
||||
const initialState = {
|
||||
[STORES.USERS]: {
|
||||
currentUserId: OWNER_USER.id,
|
||||
usersById: {
|
||||
[OWNER_USER.id]: OWNER_USER,
|
||||
[MEMBER_USER.id]: MEMBER_USER,
|
||||
[MEMBER_USER_2.id]: MEMBER_USER_2,
|
||||
},
|
||||
},
|
||||
[STORES.COLLABORATION]: {
|
||||
collaborators: [{ user: MEMBER_USER }, { user: OWNER_USER }],
|
||||
},
|
||||
};
|
||||
|
||||
const defaultRenderOptions: RenderOptions = {
|
||||
pinia: createTestingPinia({ initialState }),
|
||||
};
|
||||
|
||||
const renderComponent = createComponentRenderer(CollaborationPane, defaultRenderOptions);
|
||||
|
||||
describe('CollaborationPane', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should show only current workflow users', async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(getByTestId('collaboration-pane')).toBeInTheDocument();
|
||||
expect(getByTestId('user-stack-avatars')).toBeInTheDocument();
|
||||
expect(getByTestId(`user-stack-avatar-${OWNER_USER.id}`)).toBeInTheDocument();
|
||||
expect(getByTestId(`user-stack-avatar-${MEMBER_USER.id}`)).toBeInTheDocument();
|
||||
expect(queryByTestId(`user-stack-avatar-${MEMBER_USER_2.id}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('should always render the current user first in the list', async () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
const firstAvatar = getByTestId('user-stack-avatars').querySelector('.n8n-avatar');
|
||||
// Owner is second in the store but should be rendered first
|
||||
expect(firstAvatar).toHaveAttribute('data-test-id', `user-stack-avatar-${OWNER_USER.id}`);
|
||||
});
|
||||
|
||||
it('should not render the user-stack if there is only one user', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
...initialState,
|
||||
[STORES.COLLABORATION]: {
|
||||
collaborators: [{ user: OWNER_USER }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
await waitAllPromises();
|
||||
|
||||
const collaborationPane = getByTestId('collaboration-pane');
|
||||
expect(collaborationPane).toBeInTheDocument();
|
||||
expect(collaborationPane.querySelector('[data-test-id=user-stack-avatars]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import CollectionParameter from './CollectionParameter.vue';
|
||||
|
||||
const renderComponent = createComponentRenderer(CollectionParameter, {
|
||||
pinia: createTestingPinia(),
|
||||
});
|
||||
|
||||
describe('CollectionParameter', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render collection options correctly', async () => {
|
||||
const { getAllByTestId } = renderComponent({
|
||||
props: {
|
||||
path: 'parameters.additionalFields',
|
||||
parameter: {
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Currency',
|
||||
name: 'currency',
|
||||
type: 'string',
|
||||
default: 'USD',
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'value',
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
},
|
||||
nodeValues: {
|
||||
parameters: {
|
||||
additionalFields: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const options = getAllByTestId('collection-parameter-option');
|
||||
expect(options.length).toBe(2);
|
||||
expect(options.at(0)).toHaveTextContent('Currency');
|
||||
expect(options.at(1)).toHaveTextContent('Value');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import type { IUpdateInformation } from '@/Interface';
|
||||
|
||||
import type {
|
||||
INodeParameters,
|
||||
INodeProperties,
|
||||
INodePropertyCollection,
|
||||
INodePropertyOptions,
|
||||
} from 'n8n-workflow';
|
||||
import { deepCopy } from 'n8n-workflow';
|
||||
|
||||
import { get } from 'lodash-es';
|
||||
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const selectedOption = ref<string | undefined>(undefined);
|
||||
export interface Props {
|
||||
hideDelete?: boolean;
|
||||
nodeValues: INodeParameters;
|
||||
parameter: INodeProperties;
|
||||
path: string;
|
||||
values: INodeParameters;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
const emit = defineEmits<{
|
||||
valueChanged: [value: IUpdateInformation];
|
||||
}>();
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const ndvStore = useNDVStore();
|
||||
const i18n = useI18n();
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
|
||||
const getPlaceholderText = computed(() => {
|
||||
return (
|
||||
i18n.nodeText().placeholder(props.parameter, props.path) ??
|
||||
i18n.baseText('collectionParameter.choose')
|
||||
);
|
||||
});
|
||||
|
||||
function isNodePropertyCollection(
|
||||
object: INodePropertyOptions | INodeProperties | INodePropertyCollection,
|
||||
): object is INodePropertyCollection {
|
||||
return 'values' in object;
|
||||
}
|
||||
|
||||
function getParameterOptionLabel(
|
||||
item: INodePropertyOptions | INodeProperties | INodePropertyCollection,
|
||||
): string {
|
||||
if (isNodePropertyCollection(item)) {
|
||||
return i18n.nodeText().collectionOptionDisplayName(props.parameter, item, props.path);
|
||||
}
|
||||
|
||||
return 'displayName' in item ? item.displayName : item.name;
|
||||
}
|
||||
|
||||
function displayNodeParameter(parameter: INodeProperties) {
|
||||
if (parameter.displayOptions === undefined) {
|
||||
// If it is not defined no need to do a proper check
|
||||
return true;
|
||||
}
|
||||
return nodeHelpers.displayParameter(props.nodeValues, parameter, props.path, ndvStore.activeNode);
|
||||
}
|
||||
|
||||
function getOptionProperties(optionName: string) {
|
||||
const properties = [];
|
||||
for (const option of props.parameter.options ?? []) {
|
||||
if (option.name === optionName) {
|
||||
properties.push(option);
|
||||
}
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
const propertyNames = computed<string[]>(() => {
|
||||
if (props.values) {
|
||||
return Object.keys(props.values);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const getProperties = computed(() => {
|
||||
const returnProperties = [];
|
||||
let tempProperties;
|
||||
for (const name of propertyNames.value) {
|
||||
tempProperties = getOptionProperties(name) as INodeProperties[];
|
||||
if (tempProperties !== undefined) {
|
||||
returnProperties.push(...tempProperties);
|
||||
}
|
||||
}
|
||||
return returnProperties;
|
||||
});
|
||||
const filteredOptions = computed<Array<INodePropertyOptions | INodeProperties>>(() => {
|
||||
return (props.parameter.options as Array<INodePropertyOptions | INodeProperties>).filter(
|
||||
(option) => {
|
||||
return displayNodeParameter(option as INodeProperties);
|
||||
},
|
||||
);
|
||||
});
|
||||
const parameterOptions = computed(() => {
|
||||
return filteredOptions.value.filter((option) => {
|
||||
return !propertyNames.value.includes(option.name);
|
||||
});
|
||||
});
|
||||
|
||||
function optionSelected(optionName: string) {
|
||||
const options = getOptionProperties(optionName);
|
||||
if (options.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const option = options[0];
|
||||
const name = `${props.path}.${option.name}`;
|
||||
|
||||
let parameterData;
|
||||
|
||||
if (
|
||||
'typeOptions' in option &&
|
||||
option.typeOptions !== undefined &&
|
||||
option.typeOptions.multipleValues === true
|
||||
) {
|
||||
// Multiple values are allowed
|
||||
let newValue;
|
||||
if (option.type === 'fixedCollection') {
|
||||
// The "fixedCollection" entries are different as they save values
|
||||
// in an object and then underneath there is an array. So initialize
|
||||
// them differently.
|
||||
const retrievedObjectValue = get(props.nodeValues, [props.path, optionName], {});
|
||||
newValue = retrievedObjectValue;
|
||||
} else {
|
||||
// Everything else saves them directly as an array.
|
||||
const retrievedArrayValue = get(props.nodeValues, [props.path, optionName], []) as Array<
|
||||
typeof option.default
|
||||
>;
|
||||
if (Array.isArray(retrievedArrayValue)) {
|
||||
newValue = retrievedArrayValue;
|
||||
newValue.push(deepCopy(option.default));
|
||||
}
|
||||
}
|
||||
|
||||
parameterData = {
|
||||
name,
|
||||
value: newValue,
|
||||
};
|
||||
} else {
|
||||
// Add a new option
|
||||
parameterData = {
|
||||
name,
|
||||
value: 'default' in option ? deepCopy(option.default) : null,
|
||||
};
|
||||
}
|
||||
|
||||
emit('valueChanged', parameterData);
|
||||
selectedOption.value = undefined;
|
||||
}
|
||||
function valueChanged(parameterData: IUpdateInformation) {
|
||||
emit('valueChanged', parameterData);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="collection-parameter" @keydown.stop>
|
||||
<div class="collection-parameter-wrapper">
|
||||
<div v-if="getProperties.length === 0" class="no-items-exist">
|
||||
<n8n-text size="small">{{ i18n.baseText('collectionParameter.noProperties') }}</n8n-text>
|
||||
</div>
|
||||
|
||||
<Suspense>
|
||||
<ParameterInputList
|
||||
:parameters="getProperties"
|
||||
:node-values="nodeValues"
|
||||
:path="path"
|
||||
:hide-delete="hideDelete"
|
||||
:indent="true"
|
||||
:is-read-only="isReadOnly"
|
||||
@value-changed="valueChanged"
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<div v-if="parameterOptions.length > 0 && !isReadOnly" class="param-options">
|
||||
<n8n-button
|
||||
v-if="(parameter.options ?? []).length === 1"
|
||||
type="tertiary"
|
||||
block
|
||||
:label="getPlaceholderText"
|
||||
@click="optionSelected((parameter.options ?? [])[0].name)"
|
||||
/>
|
||||
<div v-else class="add-option">
|
||||
<n8n-select
|
||||
v-model="selectedOption"
|
||||
:placeholder="getPlaceholderText"
|
||||
size="small"
|
||||
filterable
|
||||
@update:model-value="optionSelected"
|
||||
>
|
||||
<n8n-option
|
||||
v-for="item in parameterOptions"
|
||||
:key="item.name"
|
||||
:label="getParameterOptionLabel(item)"
|
||||
:value="item.name"
|
||||
data-test-id="collection-parameter-option"
|
||||
>
|
||||
</n8n-option>
|
||||
</n8n-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.collection-parameter {
|
||||
padding-left: var(--spacing-s);
|
||||
|
||||
.param-options {
|
||||
margin-top: var(--spacing-xs);
|
||||
|
||||
.button {
|
||||
color: var(--color-text-dark);
|
||||
font-weight: var(--font-weight-normal);
|
||||
--button-border-color: var(--color-foreground-base);
|
||||
--button-background-color: var(--color-background-base);
|
||||
|
||||
--button-hover-font-color: var(--color-button-secondary-font);
|
||||
--button-hover-border-color: var(--color-foreground-base);
|
||||
--button-hover-background-color: var(--color-background-base);
|
||||
|
||||
--button-active-font-color: var(--color-button-secondary-font);
|
||||
--button-active-border-color: var(--color-foreground-base);
|
||||
--button-active-background-color: var(--color-background-base);
|
||||
|
||||
--button-focus-font-color: var(--color-button-secondary-font);
|
||||
--button-focus-border-color: var(--color-foreground-base);
|
||||
--button-focus-background-color: var(--color-background-base);
|
||||
|
||||
&:active,
|
||||
&.active,
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-items-exist {
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
.option {
|
||||
position: relative;
|
||||
padding: 0.25em 0 0.25em 1em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
loading: boolean;
|
||||
title?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n8n-card :class="$style.card" v-bind="$attrs">
|
||||
<template v-if="!loading && title" #header>
|
||||
<span :class="$style.title" v-text="title" />
|
||||
</template>
|
||||
<n8n-loading :loading="loading" :rows="3" variant="p" />
|
||||
<template v-if="!loading" #footer>
|
||||
<slot name="footer" />
|
||||
</template>
|
||||
</n8n-card>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.card {
|
||||
min-width: 235px;
|
||||
height: 140px;
|
||||
margin-right: var(--spacing-2xs);
|
||||
cursor: pointer;
|
||||
|
||||
&:last-child {
|
||||
margin-right: var(--spacing-5xs);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 4px rgba(68, 28, 23, 0.07);
|
||||
}
|
||||
|
||||
> div {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
font-size: var(--font-size-s);
|
||||
line-height: var(--font-line-height-regular);
|
||||
font-weight: var(--font-weight-bold);
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<script lang="ts" setup>
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import type { PublicInstalledPackage } from 'n8n-workflow';
|
||||
import { NPM_PACKAGE_DOCS_BASE_URL, COMMUNITY_PACKAGE_MANAGE_ACTIONS } from '@/constants';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
|
||||
interface Props {
|
||||
communityPackage?: PublicInstalledPackage | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
communityPackage: null,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const { openCommunityPackageUpdateConfirmModal, openCommunityPackageUninstallConfirmModal } =
|
||||
useUIStore();
|
||||
const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const packageActions = [
|
||||
{
|
||||
label: i18n.baseText('settings.communityNodes.viewDocsAction.label'),
|
||||
value: COMMUNITY_PACKAGE_MANAGE_ACTIONS.VIEW_DOCS,
|
||||
type: 'external-link',
|
||||
},
|
||||
{
|
||||
label: i18n.baseText('settings.communityNodes.uninstallAction.label'),
|
||||
value: COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL,
|
||||
},
|
||||
];
|
||||
|
||||
async function onAction(value: string) {
|
||||
if (!props.communityPackage) return;
|
||||
switch (value) {
|
||||
case COMMUNITY_PACKAGE_MANAGE_ACTIONS.VIEW_DOCS:
|
||||
telemetry.track('user clicked to browse the cnr package documentation', {
|
||||
package_name: props.communityPackage.packageName,
|
||||
package_version: props.communityPackage.installedVersion,
|
||||
});
|
||||
window.open(`${NPM_PACKAGE_DOCS_BASE_URL}${props.communityPackage.packageName}`, '_blank');
|
||||
break;
|
||||
case COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL:
|
||||
openCommunityPackageUninstallConfirmModal(props.communityPackage.packageName);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function onUpdateClick() {
|
||||
if (!props.communityPackage) return;
|
||||
openCommunityPackageUpdateConfirmModal(props.communityPackage.packageName);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.cardContainer" data-test-id="community-package-card">
|
||||
<div v-if="loading" :class="$style.cardSkeleton">
|
||||
<n8n-loading :class="$style.loader" variant="p" :rows="1" />
|
||||
<n8n-loading :class="$style.loader" variant="p" :rows="1" />
|
||||
</div>
|
||||
<div v-else-if="communityPackage" :class="$style.packageCard">
|
||||
<div :class="$style.cardInfoContainer">
|
||||
<div :class="$style.cardTitle">
|
||||
<n8n-text :bold="true" size="large">{{ communityPackage.packageName }}</n8n-text>
|
||||
</div>
|
||||
<div :class="$style.cardSubtitle">
|
||||
<n8n-text :bold="true" size="small" color="text-light">
|
||||
{{
|
||||
i18n.baseText('settings.communityNodes.packageNodes.label', {
|
||||
adjustToNumber: communityPackage.installedNodes.length,
|
||||
})
|
||||
}}:
|
||||
</n8n-text>
|
||||
<n8n-text size="small" color="text-light">
|
||||
<span v-for="(node, index) in communityPackage.installedNodes" :key="node.name">
|
||||
{{ node.name
|
||||
}}<span v-if="index != communityPackage.installedNodes.length - 1">,</span>
|
||||
</span>
|
||||
</n8n-text>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.cardControlsContainer">
|
||||
<n8n-text :bold="true" size="large" color="text-light">
|
||||
v{{ communityPackage.installedVersion }}
|
||||
</n8n-text>
|
||||
<n8n-tooltip v-if="communityPackage.failedLoading === true" placement="top">
|
||||
<template #content>
|
||||
<div>
|
||||
{{ i18n.baseText('settings.communityNodes.failedToLoad.tooltip') }}
|
||||
</div>
|
||||
</template>
|
||||
<n8n-icon icon="exclamation-triangle" color="danger" size="large" />
|
||||
</n8n-tooltip>
|
||||
<n8n-tooltip v-else-if="communityPackage.updateAvailable" placement="top">
|
||||
<template #content>
|
||||
<div>
|
||||
{{ i18n.baseText('settings.communityNodes.updateAvailable.tooltip') }}
|
||||
</div>
|
||||
</template>
|
||||
<n8n-button outline label="Update" @click="onUpdateClick" />
|
||||
</n8n-tooltip>
|
||||
<n8n-tooltip v-else placement="top">
|
||||
<template #content>
|
||||
<div>
|
||||
{{ i18n.baseText('settings.communityNodes.upToDate.tooltip') }}
|
||||
</div>
|
||||
</template>
|
||||
<n8n-icon icon="check-circle" color="text-light" size="large" />
|
||||
</n8n-tooltip>
|
||||
<div :class="$style.cardActions">
|
||||
<n8n-action-toggle :actions="packageActions" @action="onAction"></n8n-action-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.cardContainer {
|
||||
display: flex;
|
||||
padding: var(--spacing-s);
|
||||
border: var(--border-width-base) var(--border-style-base) var(--color-info-tint-1);
|
||||
border-radius: var(--border-radius-large);
|
||||
background-color: var(--color-background-xlight);
|
||||
}
|
||||
|
||||
.packageCard,
|
||||
.cardSkeleton {
|
||||
display: flex;
|
||||
flex-basis: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.packageCard {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cardSkeleton {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 50%;
|
||||
transform: scaleY(-1);
|
||||
|
||||
&:last-child {
|
||||
width: 70%;
|
||||
|
||||
div {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cardInfoContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
flex-basis: 100%;
|
||||
|
||||
span {
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.cardSubtitle {
|
||||
margin-top: 2px;
|
||||
padding-right: var(--spacing-m);
|
||||
}
|
||||
|
||||
.cardControlsContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3xs);
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
padding-left: var(--spacing-3xs);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import CommunityPackageInstallModal from './CommunityPackageInstallModal.vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { COMMUNITY_PACKAGE_INSTALL_MODAL_KEY, STORES } from '@/constants';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { cleanupAppModals, createAppModals, retry } from '@/__tests__/utils';
|
||||
|
||||
const renderComponent = createComponentRenderer(CommunityPackageInstallModal, {
|
||||
props: {
|
||||
appendToBody: false,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
packageName: 'n8n-nodes-hello',
|
||||
};
|
||||
},
|
||||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.UI]: {
|
||||
modalsById: {
|
||||
[COMMUNITY_PACKAGE_INSTALL_MODAL_KEY]: { open: true },
|
||||
},
|
||||
},
|
||||
[STORES.SETTINGS]: {
|
||||
settings: {
|
||||
templates: {
|
||||
host: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
describe('CommunityPackageInstallModal', () => {
|
||||
beforeEach(() => {
|
||||
createAppModals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupAppModals();
|
||||
});
|
||||
it('should disable install button until user agrees', async () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
await retry(() => expect(getByTestId('communityPackageInstall-modal')).toBeInTheDocument());
|
||||
|
||||
const packageNameInput = getByTestId('package-name-input');
|
||||
const installButton = getByTestId('install-community-package-button');
|
||||
|
||||
await userEvent.type(packageNameInput, 'n8n-nodes-test');
|
||||
|
||||
expect(installButton).toBeDisabled();
|
||||
|
||||
await userEvent.click(getByTestId('user-agreement-checkbox'));
|
||||
|
||||
expect(installButton).toBeEnabled();
|
||||
|
||||
await userEvent.click(getByTestId('user-agreement-checkbox'));
|
||||
|
||||
expect(installButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
<script setup lang="ts">
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import {
|
||||
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
|
||||
NPM_KEYWORD_SEARCH_URL,
|
||||
COMMUNITY_NODES_INSTALLATION_DOCS_URL,
|
||||
COMMUNITY_NODES_RISKS_DOCS_URL,
|
||||
} from '@/constants';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useCommunityNodesStore } from '@/stores/communityNodes.store';
|
||||
import { ref } from 'vue';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const communityNodesStore = useCommunityNodesStore();
|
||||
|
||||
const toast = useToast();
|
||||
const telemetry = useTelemetry();
|
||||
const i18n = useI18n();
|
||||
|
||||
const modalBus = createEventBus();
|
||||
|
||||
const loading = ref(false);
|
||||
const packageName = ref('');
|
||||
const userAgreed = ref(false);
|
||||
const checkboxWarning = ref(false);
|
||||
const infoTextErrorMessage = ref('');
|
||||
|
||||
const openNPMPage = () => {
|
||||
telemetry.track('user clicked cnr browse button', { source: 'cnr install modal' });
|
||||
window.open(NPM_KEYWORD_SEARCH_URL, '_blank');
|
||||
};
|
||||
|
||||
const onInstallClick = async () => {
|
||||
if (!userAgreed.value) {
|
||||
checkboxWarning.value = true;
|
||||
} else {
|
||||
try {
|
||||
telemetry.track('user started cnr package install', {
|
||||
input_string: packageName.value,
|
||||
source: 'cnr settings page',
|
||||
});
|
||||
infoTextErrorMessage.value = '';
|
||||
loading.value = true;
|
||||
await communityNodesStore.installPackage(packageName.value);
|
||||
loading.value = false;
|
||||
modalBus.emit('close');
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('settings.communityNodes.messages.install.success'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.httpStatusCode && error.httpStatusCode === 400) {
|
||||
infoTextErrorMessage.value = error.message;
|
||||
} else {
|
||||
toast.showError(error, i18n.baseText('settings.communityNodes.messages.install.error'));
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onCheckboxChecked = () => {
|
||||
checkboxWarning.value = false;
|
||||
};
|
||||
|
||||
const onModalClose = () => {
|
||||
return !loading.value;
|
||||
};
|
||||
|
||||
const onInputBlur = () => {
|
||||
packageName.value = packageName.value.replaceAll('npm i ', '').replaceAll('npm install ', '');
|
||||
};
|
||||
|
||||
const onMoreInfoTopClick = () => {
|
||||
telemetry.track('user clicked cnr docs link', { source: 'install package modal top' });
|
||||
};
|
||||
|
||||
const onLearnMoreLinkClick = () => {
|
||||
telemetry.track('user clicked cnr docs link', {
|
||||
source: 'install package modal bottom',
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
width="540px"
|
||||
:name="COMMUNITY_PACKAGE_INSTALL_MODAL_KEY"
|
||||
:title="i18n.baseText('settings.communityNodes.installModal.title')"
|
||||
:event-bus="modalBus"
|
||||
:center="true"
|
||||
:before-close="onModalClose"
|
||||
:show-close="!loading"
|
||||
>
|
||||
<template #content>
|
||||
<div :class="[$style.descriptionContainer, 'p-s']">
|
||||
<div>
|
||||
<n8n-text>
|
||||
{{ i18n.baseText('settings.communityNodes.installModal.description') }}
|
||||
</n8n-text>
|
||||
{{ ' ' }}
|
||||
<n8n-link :to="COMMUNITY_NODES_INSTALLATION_DOCS_URL" @click="onMoreInfoTopClick">
|
||||
{{ i18n.baseText('generic.moreInfo') }}
|
||||
</n8n-link>
|
||||
</div>
|
||||
<n8n-button
|
||||
:label="i18n.baseText('settings.communityNodes.browseButton.label')"
|
||||
icon="external-link-alt"
|
||||
:class="$style.browseButton"
|
||||
@click="openNPMPage"
|
||||
/>
|
||||
</div>
|
||||
<div :class="[$style.formContainer, 'mt-m']">
|
||||
<n8n-input-label
|
||||
:class="$style.labelTooltip"
|
||||
:label="i18n.baseText('settings.communityNodes.installModal.packageName.label')"
|
||||
:tooltip-text="
|
||||
i18n.baseText('settings.communityNodes.installModal.packageName.tooltip', {
|
||||
interpolate: { npmURL: NPM_KEYWORD_SEARCH_URL },
|
||||
})
|
||||
"
|
||||
>
|
||||
<n8n-input
|
||||
v-model="packageName"
|
||||
name="packageNameInput"
|
||||
type="text"
|
||||
data-test-id="package-name-input"
|
||||
:maxlength="214"
|
||||
:placeholder="
|
||||
i18n.baseText('settings.communityNodes.installModal.packageName.placeholder')
|
||||
"
|
||||
:required="true"
|
||||
:disabled="loading"
|
||||
@blur="onInputBlur"
|
||||
/>
|
||||
</n8n-input-label>
|
||||
<div :class="[$style.infoText, 'mt-4xs']">
|
||||
<span
|
||||
size="small"
|
||||
:class="[$style.infoText, infoTextErrorMessage ? $style.error : '']"
|
||||
v-text="infoTextErrorMessage"
|
||||
></span>
|
||||
</div>
|
||||
<el-checkbox
|
||||
v-model="userAgreed"
|
||||
:class="[$style.checkbox, checkboxWarning ? $style.error : '', 'mt-l']"
|
||||
:disabled="loading"
|
||||
data-test-id="user-agreement-checkbox"
|
||||
@update:model-value="onCheckboxChecked"
|
||||
>
|
||||
<n8n-text>
|
||||
{{ i18n.baseText('settings.communityNodes.installModal.checkbox.label') }} </n8n-text
|
||||
><br />
|
||||
<n8n-link :to="COMMUNITY_NODES_RISKS_DOCS_URL" @click="onLearnMoreLinkClick">{{
|
||||
i18n.baseText('generic.moreInfo')
|
||||
}}</n8n-link>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<n8n-button
|
||||
:loading="loading"
|
||||
:disabled="!userAgreed || packageName === '' || loading"
|
||||
:label="
|
||||
loading
|
||||
? i18n.baseText('settings.communityNodes.installModal.installButton.label.loading')
|
||||
: i18n.baseText('settings.communityNodes.installModal.installButton.label')
|
||||
"
|
||||
size="large"
|
||||
float="right"
|
||||
data-test-id="install-community-package-button"
|
||||
@click="onInstallClick"
|
||||
/>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.descriptionContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: var(--border-width-base) var(--border-style-base) var(--color-info-tint-1);
|
||||
border-radius: var(--border-radius-base);
|
||||
background-color: var(--color-background-light);
|
||||
|
||||
button {
|
||||
& > span {
|
||||
flex-direction: row-reverse;
|
||||
& > span {
|
||||
margin-left: var(--spacing-3xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.formContainer {
|
||||
font-size: var(--font-size-2xs);
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--color-text-base);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
span:nth-child(2) {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-danger);
|
||||
|
||||
span {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.el-tooltip__popper {
|
||||
max-width: 240px;
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
p {
|
||||
line-height: 1.2;
|
||||
}
|
||||
p + p {
|
||||
margin-top: var(--spacing-2xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import { COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, COMMUNITY_PACKAGE_MANAGE_ACTIONS } from '@/constants';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useCommunityNodesStore } from '@/stores/communityNodes.store';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
export type CommunityPackageManageMode = 'uninstall' | 'update' | 'view-documentation';
|
||||
|
||||
interface Props {
|
||||
modalName: string;
|
||||
activePackageName: string;
|
||||
mode: CommunityPackageManageMode;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const communityNodesStore = useCommunityNodesStore();
|
||||
|
||||
const modalBus = createEventBus();
|
||||
|
||||
const toast = useToast();
|
||||
const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const activePackage = computed(
|
||||
() => communityNodesStore.installedPackages[props.activePackageName],
|
||||
);
|
||||
|
||||
const getModalContent = computed(() => {
|
||||
if (props.mode === COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL) {
|
||||
return {
|
||||
title: i18n.baseText('settings.communityNodes.confirmModal.uninstall.title'),
|
||||
message: i18n.baseText('settings.communityNodes.confirmModal.uninstall.message', {
|
||||
interpolate: {
|
||||
packageName: props.activePackageName,
|
||||
},
|
||||
}),
|
||||
buttonLabel: i18n.baseText('settings.communityNodes.confirmModal.uninstall.buttonLabel'),
|
||||
buttonLoadingLabel: i18n.baseText(
|
||||
'settings.communityNodes.confirmModal.uninstall.buttonLoadingLabel',
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: i18n.baseText('settings.communityNodes.confirmModal.update.title', {
|
||||
interpolate: {
|
||||
packageName: props.activePackageName,
|
||||
},
|
||||
}),
|
||||
description: i18n.baseText('settings.communityNodes.confirmModal.update.description'),
|
||||
message: i18n.baseText('settings.communityNodes.confirmModal.update.message', {
|
||||
interpolate: {
|
||||
packageName: props.activePackageName,
|
||||
version: activePackage.value.updateAvailable ?? '',
|
||||
},
|
||||
}),
|
||||
buttonLabel: i18n.baseText('settings.communityNodes.confirmModal.update.buttonLabel'),
|
||||
buttonLoadingLabel: i18n.baseText(
|
||||
'settings.communityNodes.confirmModal.update.buttonLoadingLabel',
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const onModalClose = () => {
|
||||
return !loading.value;
|
||||
};
|
||||
|
||||
const onConfirmButtonClick = async () => {
|
||||
if (props.mode === COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL) {
|
||||
await onUninstall();
|
||||
} else if (props.mode === COMMUNITY_PACKAGE_MANAGE_ACTIONS.UPDATE) {
|
||||
await onUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const onUninstall = async () => {
|
||||
try {
|
||||
telemetry.track('user started cnr package deletion', {
|
||||
package_name: activePackage.value.packageName,
|
||||
package_node_names: activePackage.value.installedNodes.map((node) => node.name),
|
||||
package_version: activePackage.value.installedVersion,
|
||||
package_author: activePackage.value.authorName,
|
||||
package_author_email: activePackage.value.authorEmail,
|
||||
});
|
||||
loading.value = true;
|
||||
await communityNodesStore.uninstallPackage(props.activePackageName);
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('settings.communityNodes.messages.uninstall.success.title'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('settings.communityNodes.messages.uninstall.error'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
modalBus.emit('close');
|
||||
}
|
||||
};
|
||||
|
||||
const onUpdate = async () => {
|
||||
try {
|
||||
telemetry.track('user started cnr package update', {
|
||||
package_name: activePackage.value.packageName,
|
||||
package_node_names: activePackage.value.installedNodes.map((node) => node.name),
|
||||
package_version_current: activePackage.value.installedVersion,
|
||||
package_version_new: activePackage.value.updateAvailable,
|
||||
package_author: activePackage.value.authorName,
|
||||
package_author_email: activePackage.value.authorEmail,
|
||||
});
|
||||
loading.value = true;
|
||||
const updatedVersion = activePackage.value.updateAvailable;
|
||||
await communityNodesStore.updatePackage(props.activePackageName);
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('settings.communityNodes.messages.update.success.title'),
|
||||
message: i18n.baseText('settings.communityNodes.messages.update.success.message', {
|
||||
interpolate: {
|
||||
packageName: props.activePackageName,
|
||||
version: updatedVersion ?? '',
|
||||
},
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('settings.communityNodes.messages.update.error.title'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
modalBus.emit('close');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
width="540px"
|
||||
:name="COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY"
|
||||
:title="getModalContent.title"
|
||||
:event-bus="modalBus"
|
||||
:center="true"
|
||||
:show-close="!loading"
|
||||
:before-close="onModalClose"
|
||||
>
|
||||
<template #content>
|
||||
<n8n-text>{{ getModalContent.message }}</n8n-text>
|
||||
<div
|
||||
v-if="mode === COMMUNITY_PACKAGE_MANAGE_ACTIONS.UPDATE"
|
||||
:class="$style.descriptionContainer"
|
||||
>
|
||||
<n8n-info-tip theme="info" type="note" :bold="false">
|
||||
<span v-text="getModalContent.description"></span>
|
||||
</n8n-info-tip>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<n8n-button
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
:label="loading ? getModalContent.buttonLoadingLabel : getModalContent.buttonLabel"
|
||||
size="large"
|
||||
float="right"
|
||||
@click="onConfirmButtonClick"
|
||||
/>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.descriptionContainer {
|
||||
display: flex;
|
||||
margin: var(--spacing-s) 0;
|
||||
}
|
||||
|
||||
.descriptionIcon {
|
||||
align-self: center;
|
||||
color: var(--color-text-lighter);
|
||||
}
|
||||
|
||||
.descriptionText {
|
||||
padding: 0 var(--spacing-xs);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentModal.vue';
|
||||
import { COMMUNITY_PLUS_ENROLLMENT_MODAL } from '@/constants';
|
||||
import { useUsageStore } from '@/stores/usage.store';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
|
||||
vi.mock('@/composables/useToast', () => {
|
||||
const showMessage = vi.fn();
|
||||
const showError = vi.fn();
|
||||
return {
|
||||
useToast: () => {
|
||||
return {
|
||||
showMessage,
|
||||
showError,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/composables/useTelemetry', () => {
|
||||
const track = vi.fn();
|
||||
return {
|
||||
useTelemetry: () => {
|
||||
return {
|
||||
track,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(CommunityPlusEnrollmentModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
Modal: {
|
||||
template:
|
||||
'<div role="dialog"><slot name="header" /><slot name="content" /><slot name="footer" /></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('CommunityPlusEnrollmentModal', () => {
|
||||
const buttonLabel = 'Send me a free license key';
|
||||
|
||||
beforeEach(() => {
|
||||
createTestingPinia();
|
||||
});
|
||||
|
||||
it('should not throw error opened only with the name', () => {
|
||||
const props = {
|
||||
modalName: COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||
};
|
||||
|
||||
expect(() => renderComponent({ props })).not.toThrow();
|
||||
});
|
||||
|
||||
it('should test enrolling', async () => {
|
||||
const closeCallbackSpy = vi.fn();
|
||||
const usageStore = mockedStore(useUsageStore);
|
||||
const toast = useToast();
|
||||
|
||||
usageStore.registerCommunityEdition.mockResolvedValue({
|
||||
title: 'Title',
|
||||
text: 'Text',
|
||||
});
|
||||
|
||||
const props = {
|
||||
modalName: COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||
data: {
|
||||
closeCallback: closeCallbackSpy,
|
||||
},
|
||||
};
|
||||
|
||||
const { getByRole } = renderComponent({ props });
|
||||
const emailInput = getByRole('textbox');
|
||||
expect(emailInput).toBeVisible();
|
||||
|
||||
await userEvent.type(emailInput, 'not-an-email');
|
||||
expect(emailInput).toHaveValue('not-an-email');
|
||||
expect(getByRole('button', { name: buttonLabel })).toBeDisabled();
|
||||
|
||||
await userEvent.clear(emailInput);
|
||||
await userEvent.type(emailInput, 'test@ema.il');
|
||||
expect(emailInput).toHaveValue('test@ema.il');
|
||||
expect(getByRole('button', { name: buttonLabel })).toBeEnabled();
|
||||
|
||||
await userEvent.click(getByRole('button', { name: buttonLabel }));
|
||||
expect(usageStore.registerCommunityEdition).toHaveBeenCalledWith('test@ema.il');
|
||||
expect(toast.showMessage).toHaveBeenCalledWith({
|
||||
title: 'Title',
|
||||
message: 'Text',
|
||||
type: 'success',
|
||||
duration: 0,
|
||||
});
|
||||
expect(closeCallbackSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should test enrolling error', async () => {
|
||||
const closeCallbackSpy = vi.fn();
|
||||
const usageStore = mockedStore(useUsageStore);
|
||||
const toast = useToast();
|
||||
|
||||
usageStore.registerCommunityEdition.mockRejectedValue(
|
||||
new Error('Failed to register community edition'),
|
||||
);
|
||||
|
||||
const props = {
|
||||
modalName: COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||
data: {
|
||||
closeCallback: closeCallbackSpy,
|
||||
},
|
||||
};
|
||||
|
||||
const { getByRole } = renderComponent({ props });
|
||||
const emailInput = getByRole('textbox');
|
||||
expect(emailInput).toBeVisible();
|
||||
|
||||
await userEvent.type(emailInput, 'test@ema.il');
|
||||
expect(emailInput).toHaveValue('test@ema.il');
|
||||
expect(getByRole('button', { name: buttonLabel })).toBeEnabled();
|
||||
|
||||
await userEvent.click(getByRole('button', { name: buttonLabel }));
|
||||
expect(usageStore.registerCommunityEdition).toHaveBeenCalledWith('test@ema.il');
|
||||
expect(toast.showError).toHaveBeenCalledWith(
|
||||
new Error('Failed to register community edition'),
|
||||
'License request failed',
|
||||
);
|
||||
expect(closeCallbackSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should track skipping', async () => {
|
||||
const closeCallbackSpy = vi.fn();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const props = {
|
||||
modalName: COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||
data: {
|
||||
closeCallback: closeCallbackSpy,
|
||||
},
|
||||
};
|
||||
|
||||
const { getByRole } = renderComponent({ props });
|
||||
const skipButton = getByRole('button', { name: 'Skip' });
|
||||
expect(skipButton).toBeVisible();
|
||||
|
||||
await userEvent.click(skipButton);
|
||||
expect(telemetry.track).toHaveBeenCalledWith('User skipped community plus');
|
||||
});
|
||||
|
||||
it('should use user email if possible', async () => {
|
||||
const closeCallbackSpy = vi.fn();
|
||||
const usersStore = mockedStore(useUsersStore);
|
||||
|
||||
usersStore.currentUser = {
|
||||
id: '1',
|
||||
email: 'test@n8n.io',
|
||||
isDefaultUser: false,
|
||||
isPending: false,
|
||||
mfaEnabled: false,
|
||||
isPendingUser: false,
|
||||
};
|
||||
|
||||
const props = {
|
||||
modalName: COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||
data: {
|
||||
closeCallback: closeCallbackSpy,
|
||||
},
|
||||
};
|
||||
|
||||
const { getByRole } = renderComponent({ props });
|
||||
const emailInput = getByRole('textbox');
|
||||
expect(emailInput).toHaveValue('test@n8n.io');
|
||||
});
|
||||
|
||||
it('should not throw error if no close callback provided', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error');
|
||||
const props = {
|
||||
modalName: COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||
};
|
||||
|
||||
const { getByRole } = renderComponent({ props });
|
||||
const skipButton = getByRole('button', { name: 'Skip' });
|
||||
expect(skipButton).toBeVisible();
|
||||
|
||||
await userEvent.click(skipButton);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
<script lang="ts" setup="">
|
||||
import { ref } from 'vue';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import type { Validatable, IValidator } from '@n8n/design-system';
|
||||
import { N8nFormInput } from '@n8n/design-system';
|
||||
import { VALID_EMAIL_REGEX } from '@/constants';
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useUsageStore } from '@/stores/usage.store';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
|
||||
const props = defineProps<{
|
||||
modalName: string;
|
||||
data?: {
|
||||
closeCallback?: () => void;
|
||||
};
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
const usageStore = useUsageStore();
|
||||
const telemetry = useTelemetry();
|
||||
const usersStore = useUsersStore();
|
||||
|
||||
const valid = ref(false);
|
||||
const email = ref(usersStore.currentUser?.email ?? '');
|
||||
const validationRules = ref([{ name: 'email' }]);
|
||||
const validators = ref<{ [key: string]: IValidator }>({
|
||||
email: {
|
||||
validate: (value: Validatable) => {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!VALID_EMAIL_REGEX.test(value)) {
|
||||
return {
|
||||
message: 'settings.users.invalidEmailError',
|
||||
options: { interpolate: { email: value } },
|
||||
};
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const modalBus = createEventBus();
|
||||
|
||||
const closeModal = () => {
|
||||
telemetry.track('User skipped community plus');
|
||||
modalBus.emit('close');
|
||||
props.data?.closeCallback?.();
|
||||
};
|
||||
|
||||
const confirm = async () => {
|
||||
try {
|
||||
const { title, text } = await usageStore.registerCommunityEdition(email.value);
|
||||
closeModal();
|
||||
toast.showMessage({
|
||||
title: title ?? i18n.baseText('communityPlusModal.success.title'),
|
||||
message:
|
||||
text ??
|
||||
i18n.baseText('communityPlusModal.success.message', {
|
||||
interpolate: { email: email.value },
|
||||
}),
|
||||
type: 'success',
|
||||
duration: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('communityPlusModal.error.title'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
width="500px"
|
||||
:name="props.modalName"
|
||||
:event-bus="modalBus"
|
||||
:show-close="false"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
>
|
||||
<template #content>
|
||||
<div>
|
||||
<p :class="$style.top">
|
||||
<N8nBadge>{{ i18n.baseText('communityPlusModal.badge') }}</N8nBadge>
|
||||
</p>
|
||||
<N8nText tag="h1" align="center" size="xlarge" class="mb-m">{{
|
||||
i18n.baseText('communityPlusModal.title')
|
||||
}}</N8nText>
|
||||
<N8nText tag="p">{{ i18n.baseText('communityPlusModal.description') }}</N8nText>
|
||||
<ul :class="$style.features">
|
||||
<li>
|
||||
<i>🕰️</i>
|
||||
<N8nText>
|
||||
<strong>{{ i18n.baseText('communityPlusModal.features.first.title') }}</strong>
|
||||
{{ i18n.baseText('communityPlusModal.features.first.description') }}
|
||||
</N8nText>
|
||||
</li>
|
||||
<li>
|
||||
<i>🐞</i>
|
||||
<N8nText>
|
||||
<strong>{{ i18n.baseText('communityPlusModal.features.second.title') }}</strong>
|
||||
{{ i18n.baseText('communityPlusModal.features.second.description') }}
|
||||
</N8nText>
|
||||
</li>
|
||||
<li>
|
||||
<i>🔎</i>
|
||||
<N8nText>
|
||||
<strong>{{ i18n.baseText('communityPlusModal.features.third.title') }}</strong>
|
||||
{{ i18n.baseText('communityPlusModal.features.third.description') }}
|
||||
</N8nText>
|
||||
</li>
|
||||
</ul>
|
||||
<N8nFormInput
|
||||
id="email"
|
||||
v-model="email"
|
||||
:label="i18n.baseText('communityPlusModal.input.email.label')"
|
||||
type="email"
|
||||
name="email"
|
||||
label-size="small"
|
||||
tag-size="small"
|
||||
required
|
||||
:show-required-asterisk="true"
|
||||
:validate-on-blur="false"
|
||||
:validation-rules="validationRules"
|
||||
:validators="validators"
|
||||
@validate="valid = $event"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div :class="$style.buttons">
|
||||
<N8nButton :class="$style.skip" type="secondary" text @click="closeModal">{{
|
||||
i18n.baseText('communityPlusModal.button.skip')
|
||||
}}</N8nButton>
|
||||
<N8nButton :disabled="!valid" type="primary" @click="confirm">
|
||||
{{ i18n.baseText('communityPlusModal.button.confirm') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.top {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0 0 var(--spacing-s);
|
||||
}
|
||||
|
||||
.features {
|
||||
padding: var(--spacing-s) var(--spacing-l) 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
padding: 0 var(--spacing-s) var(--spacing-m) 0;
|
||||
|
||||
i {
|
||||
display: inline-block;
|
||||
margin: var(--spacing-5xs) var(--spacing-xs) 0 0;
|
||||
font-style: normal;
|
||||
font-size: var(--font-size-s);
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-4xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.skip {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { IN8nPromptResponse, ModalKey } from '@/Interface';
|
||||
import { VALID_EMAIL_REGEX } from '@/constants';
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
|
||||
defineProps<{
|
||||
modalName: ModalKey;
|
||||
}>();
|
||||
|
||||
const email = ref('');
|
||||
const modalBus = createEventBus();
|
||||
|
||||
const npsSurveyStore = useNpsSurveyStore();
|
||||
const rootStore = useRootStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const toast = useToast();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const title = computed(() => {
|
||||
if (npsSurveyStore.promptsData?.title) {
|
||||
return npsSurveyStore.promptsData.title;
|
||||
}
|
||||
|
||||
return 'You’re a power user 💪';
|
||||
});
|
||||
|
||||
const description = computed(() => {
|
||||
if (npsSurveyStore.promptsData?.message) {
|
||||
return npsSurveyStore.promptsData.message;
|
||||
}
|
||||
|
||||
return 'Your experience with n8n can help us improve — for you and our entire community.';
|
||||
});
|
||||
|
||||
const isEmailValid = computed(() => {
|
||||
return VALID_EMAIL_REGEX.test(String(email.value).toLowerCase());
|
||||
});
|
||||
|
||||
const closeDialog = () => {
|
||||
if (!isEmailValid.value) {
|
||||
telemetry.track('User closed email modal', {
|
||||
instance_id: rootStore.instanceId,
|
||||
email: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
if (isEmailValid.value) {
|
||||
const response = (await settingsStore.submitContactInfo(email.value)) as IN8nPromptResponse;
|
||||
|
||||
if (response.updated) {
|
||||
telemetry.track('User closed email modal', {
|
||||
instance_id: rootStore.instanceId,
|
||||
email: email.value,
|
||||
});
|
||||
toast.showMessage({
|
||||
title: 'Thanks!',
|
||||
message: "It's people like you that help make n8n better",
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
modalBus.emit('close');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:name="modalName"
|
||||
:event-bus="modalBus"
|
||||
:center="true"
|
||||
:close-on-press-escape="false"
|
||||
:before-close="closeDialog"
|
||||
custom-class="contact-prompt-modal"
|
||||
width="460px"
|
||||
>
|
||||
<template #header>
|
||||
<n8n-heading tag="h2" size="xlarge" color="text-dark">{{ title }}</n8n-heading>
|
||||
</template>
|
||||
<template #content>
|
||||
<div :class="$style.description">
|
||||
<n8n-text size="medium" color="text-base">{{ description }}</n8n-text>
|
||||
</div>
|
||||
<div @keyup.enter="send">
|
||||
<n8n-input v-model="email" placeholder="Your email address" />
|
||||
</div>
|
||||
<div :class="$style.disclaimer">
|
||||
<n8n-text size="small" color="text-base"
|
||||
>David from our product team will get in touch personally</n8n-text
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div :class="$style.footer">
|
||||
<n8n-button label="Send" float="right" :disabled="!isEmailValid" @click="send" />
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.description {
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
margin-top: var(--spacing-4xs);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.dialog-wrapper {
|
||||
.contact-prompt-modal {
|
||||
.el-dialog__body {
|
||||
padding: 16px 24px 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts" setup>
|
||||
import { type ContextMenuAction, useContextMenu } from '@/composables/useContextMenu';
|
||||
import { N8nActionDropdown } from '@n8n/design-system';
|
||||
import { watch, ref } from 'vue';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
|
||||
const contextMenu = useContextMenu();
|
||||
const { position, isOpen, actions, target } = contextMenu;
|
||||
const dropdown = ref<InstanceType<typeof N8nActionDropdown>>();
|
||||
const emit = defineEmits<{ action: [action: ContextMenuAction, nodeIds: string[]] }>();
|
||||
const container = ref<HTMLDivElement>();
|
||||
|
||||
watch(
|
||||
isOpen,
|
||||
() => {
|
||||
if (isOpen) {
|
||||
dropdown.value?.open();
|
||||
} else {
|
||||
dropdown.value?.close();
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
function onActionSelect(item: string) {
|
||||
const action = item as ContextMenuAction;
|
||||
contextMenu._dispatchAction(action);
|
||||
emit('action', action, contextMenu.targetNodeIds.value);
|
||||
}
|
||||
|
||||
function closeMenu(event: MouseEvent) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
event.stopPropagation();
|
||||
contextMenu.close();
|
||||
}
|
||||
|
||||
function onVisibleChange(open: boolean) {
|
||||
if (!open) {
|
||||
contextMenu.close();
|
||||
}
|
||||
}
|
||||
|
||||
onClickOutside(container, closeMenu);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="isOpen" to="body">
|
||||
<div
|
||||
ref="container"
|
||||
:class="$style.contextMenu"
|
||||
:style="{
|
||||
left: `${position[0]}px`,
|
||||
top: `${position[1]}px`,
|
||||
}"
|
||||
>
|
||||
<N8nActionDropdown
|
||||
ref="dropdown"
|
||||
:items="actions"
|
||||
placement="bottom-start"
|
||||
data-test-id="context-menu"
|
||||
:hide-arrow="target?.source !== 'node-button'"
|
||||
:teleported="false"
|
||||
@select="onActionSelect"
|
||||
@visible-change="onVisibleChange"
|
||||
>
|
||||
<template #activator>
|
||||
<div :class="$style.activator"></div>
|
||||
</template>
|
||||
</N8nActionDropdown>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.contextMenu {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.activator {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
47
packages/frontend/editor-ui/src/components/CopyInput.test.ts
Normal file
47
packages/frontend/editor-ui/src/components/CopyInput.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { merge } from 'lodash-es';
|
||||
|
||||
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
||||
import { STORES } from '@/constants';
|
||||
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import CopyInput from '@/components/CopyInput.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
|
||||
const DEFAULT_SETUP = {
|
||||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.SETTINGS]: {
|
||||
settings: merge({}, SETTINGS_STORE_DEFAULT_STATE.settings),
|
||||
},
|
||||
},
|
||||
}),
|
||||
props: {
|
||||
copyButtonText: 'Click to copy',
|
||||
label: 'Copy Input test',
|
||||
},
|
||||
};
|
||||
|
||||
const renderComponent = createComponentRenderer(CopyInput, DEFAULT_SETUP);
|
||||
|
||||
describe('BannerStack', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render default configuration', async () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
expect(getByTestId('input-label')).toHaveTextContent('Copy Input test');
|
||||
expect(getByTestId('copy-input')).toHaveTextContent('Click to copy');
|
||||
});
|
||||
|
||||
it('should render redacted version', async () => {
|
||||
const { getByTestId } = renderComponent(
|
||||
merge(DEFAULT_SETUP, {
|
||||
props: {
|
||||
redactValue: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(getByTestId('copy-input')).toHaveClass('ph-no-capture');
|
||||
});
|
||||
});
|
||||
142
packages/frontend/editor-ui/src/components/CopyInput.vue
Normal file
142
packages/frontend/editor-ui/src/components/CopyInput.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from '@/composables/useClipboard';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
|
||||
type Props = {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
value?: string;
|
||||
copyButtonText?: string;
|
||||
toastTitle?: string;
|
||||
toastMessage?: string;
|
||||
size?: 'medium' | 'large';
|
||||
collapse?: boolean;
|
||||
redactValue?: boolean;
|
||||
disableCopy?: boolean;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
value: '',
|
||||
placeholder: '',
|
||||
label: '',
|
||||
hint: '',
|
||||
size: 'medium',
|
||||
copyButtonText: useI18n().baseText('generic.copy'),
|
||||
toastTitle: useI18n().baseText('generic.copiedToClipboard'),
|
||||
disableCopy: false,
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
copy: [];
|
||||
}>();
|
||||
|
||||
const clipboard = useClipboard();
|
||||
const { showMessage } = useToast();
|
||||
|
||||
function copy() {
|
||||
if (props.disableCopy) return;
|
||||
|
||||
emit('copy');
|
||||
void clipboard.copy(props.value ?? '');
|
||||
|
||||
showMessage({
|
||||
title: props.toastTitle,
|
||||
message: props.toastMessage,
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n8n-input-label :label="label">
|
||||
<div
|
||||
:class="{
|
||||
[$style.copyText]: true,
|
||||
[$style[size]]: true,
|
||||
[$style.collapsed]: collapse,
|
||||
[$style.noHover]: disableCopy,
|
||||
'ph-no-capture': redactValue,
|
||||
}"
|
||||
data-test-id="copy-input"
|
||||
@click="copy"
|
||||
>
|
||||
<span ref="copyInputValue">{{ value }}</span>
|
||||
<div v-if="!disableCopy" :class="$style.copyButton">
|
||||
<span>{{ copyButtonText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</n8n-input-label>
|
||||
<div v-if="hint" :class="$style.hint">{{ hint }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.copyText {
|
||||
span {
|
||||
font-family: Monaco, Consolas;
|
||||
color: var(--color-text-base);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
padding: var(--spacing-xs);
|
||||
background-color: var(--color-background-light);
|
||||
border: var(--border-base);
|
||||
border-radius: var(--border-radius-base);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
font-weight: var(--font-weight-regular);
|
||||
|
||||
&:hover {
|
||||
--display-copy-button: flex;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.noHover {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.large {
|
||||
span {
|
||||
font-size: var(--font-size-s);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.medium {
|
||||
span {
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.collapsed {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
display: var(--display-copy-button, none);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: var(--spacing-xs);
|
||||
background-color: var(--color-background-light);
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
border-radius: var(--border-radius-base);
|
||||
|
||||
span {
|
||||
font-family: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: var(--spacing-2xs);
|
||||
font-size: var(--font-size-2xs);
|
||||
line-height: var(--font-line-height-loose);
|
||||
font-weight: var(--font-weight-regular);
|
||||
word-break: normal;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { within } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import CredentialCard from '@/components/CredentialCard.vue';
|
||||
import type { ICredentialsResponse } from '@/Interface';
|
||||
import type { ProjectSharingData } from '@/types/projects.types';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
|
||||
const renderComponent = createComponentRenderer(CredentialCard);
|
||||
|
||||
const createCredential = (overrides = {}): ICredentialsResponse => ({
|
||||
id: '',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
type: '',
|
||||
name: '',
|
||||
sharedWithProjects: [],
|
||||
isManaged: false,
|
||||
homeProject: {} as ProjectSharingData,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('CredentialCard', () => {
|
||||
let projectsStore: ReturnType<typeof useProjectsStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
const pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
projectsStore = useProjectsStore();
|
||||
});
|
||||
|
||||
it('should render name and home project name', () => {
|
||||
const projectName = 'Test Project';
|
||||
const data = createCredential({
|
||||
homeProject: {
|
||||
name: projectName,
|
||||
},
|
||||
});
|
||||
const { getByRole, getByTestId } = renderComponent({ props: { data } });
|
||||
|
||||
const heading = getByRole('heading');
|
||||
const badge = getByTestId('card-badge');
|
||||
|
||||
expect(heading).toHaveTextContent(data.name);
|
||||
expect(badge).toHaveTextContent(projectName);
|
||||
});
|
||||
|
||||
it('should render name and personal project name', () => {
|
||||
const projectName = 'John Doe <john@n8n.io>';
|
||||
const data = createCredential({
|
||||
homeProject: {
|
||||
name: projectName,
|
||||
},
|
||||
});
|
||||
const { getByRole, getByTestId } = renderComponent({ props: { data } });
|
||||
|
||||
const heading = getByRole('heading');
|
||||
const badge = getByTestId('card-badge');
|
||||
|
||||
expect(heading).toHaveTextContent(data.name);
|
||||
expect(badge).toHaveTextContent('John Doe');
|
||||
});
|
||||
|
||||
it('should show Move action only if there is resource permission and not on community plan', async () => {
|
||||
vi.spyOn(projectsStore, 'isTeamProjectFeatureEnabled', 'get').mockReturnValue(true);
|
||||
|
||||
const data = createCredential({
|
||||
scopes: ['credential:move'],
|
||||
});
|
||||
const { getByTestId } = renderComponent({ props: { data } });
|
||||
const cardActions = getByTestId('credential-card-actions');
|
||||
|
||||
expect(cardActions).toBeInTheDocument();
|
||||
|
||||
const cardActionsOpener = within(cardActions).getByRole('button');
|
||||
expect(cardActionsOpener).toBeInTheDocument();
|
||||
|
||||
const controllingId = cardActionsOpener.getAttribute('aria-controls');
|
||||
|
||||
await userEvent.click(cardActions);
|
||||
const actions = document.querySelector(`#${controllingId}`);
|
||||
if (!actions) {
|
||||
throw new Error('Actions menu not found');
|
||||
}
|
||||
expect(actions).toHaveTextContent('Move');
|
||||
});
|
||||
|
||||
it('should set readOnly variant based on prop', () => {
|
||||
const data = createCredential({});
|
||||
const { getByRole } = renderComponent({ props: { data, readOnly: true } });
|
||||
const heading = getByRole('heading');
|
||||
expect(heading).toHaveTextContent('Read only');
|
||||
});
|
||||
});
|
||||
230
packages/frontend/editor-ui/src/components/CredentialCard.vue
Normal file
230
packages/frontend/editor-ui/src/components/CredentialCard.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import dateformat from 'dateformat';
|
||||
import { MODAL_CONFIRM, PROJECT_MOVE_RESOURCE_MODAL } from '@/constants';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import CredentialIcon from '@/components/CredentialIcon.vue';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import TimeAgo from '@/components/TimeAgo.vue';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { ResourceType } from '@/utils/projects.utils';
|
||||
import type { CredentialsResource } from './layouts/ResourcesListLayout.vue';
|
||||
|
||||
const CREDENTIAL_LIST_ITEM_ACTIONS = {
|
||||
OPEN: 'open',
|
||||
DELETE: 'delete',
|
||||
MOVE: 'move',
|
||||
};
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [credentialId: string];
|
||||
}>();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
data: CredentialsResource;
|
||||
readOnly?: boolean;
|
||||
needsSetup?: boolean;
|
||||
}>(),
|
||||
{
|
||||
readOnly: false,
|
||||
needsSetup: false,
|
||||
},
|
||||
);
|
||||
|
||||
const locale = useI18n();
|
||||
const message = useMessage();
|
||||
const uiStore = useUIStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
const resourceTypeLabel = computed(() => locale.baseText('generic.credential').toLowerCase());
|
||||
const credentialType = computed(() =>
|
||||
credentialsStore.getCredentialTypeByName(props.data.type ?? ''),
|
||||
);
|
||||
const credentialPermissions = computed(() => getResourcePermissions(props.data.scopes).credential);
|
||||
const actions = computed(() => {
|
||||
const items = [
|
||||
{
|
||||
label: locale.baseText('credentials.item.open'),
|
||||
value: CREDENTIAL_LIST_ITEM_ACTIONS.OPEN,
|
||||
},
|
||||
];
|
||||
|
||||
if (credentialPermissions.value.delete) {
|
||||
items.push({
|
||||
label: locale.baseText('credentials.item.delete'),
|
||||
value: CREDENTIAL_LIST_ITEM_ACTIONS.DELETE,
|
||||
});
|
||||
}
|
||||
|
||||
if (credentialPermissions.value.move && projectsStore.isTeamProjectFeatureEnabled) {
|
||||
items.push({
|
||||
label: locale.baseText('credentials.item.move'),
|
||||
value: CREDENTIAL_LIST_ITEM_ACTIONS.MOVE,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
const formattedCreatedAtDate = computed(() => {
|
||||
const currentYear = new Date().getFullYear().toString();
|
||||
|
||||
return dateformat(
|
||||
props.data.createdAt,
|
||||
`d mmmm${String(props.data.createdAt).startsWith(currentYear) ? '' : ', yyyy'}`,
|
||||
);
|
||||
});
|
||||
|
||||
function onClick() {
|
||||
emit('click', props.data.id);
|
||||
}
|
||||
|
||||
async function onAction(action: string) {
|
||||
switch (action) {
|
||||
case CREDENTIAL_LIST_ITEM_ACTIONS.OPEN:
|
||||
onClick();
|
||||
break;
|
||||
case CREDENTIAL_LIST_ITEM_ACTIONS.DELETE:
|
||||
await deleteResource();
|
||||
break;
|
||||
case CREDENTIAL_LIST_ITEM_ACTIONS.MOVE:
|
||||
moveResource();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteResource() {
|
||||
const deleteConfirmed = await message.confirm(
|
||||
locale.baseText('credentialEdit.credentialEdit.confirmMessage.deleteCredential.message', {
|
||||
interpolate: { savedCredentialName: props.data.name },
|
||||
}),
|
||||
locale.baseText('credentialEdit.credentialEdit.confirmMessage.deleteCredential.headline'),
|
||||
{
|
||||
confirmButtonText: locale.baseText(
|
||||
'credentialEdit.credentialEdit.confirmMessage.deleteCredential.confirmButtonText',
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
if (deleteConfirmed === MODAL_CONFIRM) {
|
||||
await credentialsStore.deleteCredential({ id: props.data.id });
|
||||
}
|
||||
}
|
||||
|
||||
function moveResource() {
|
||||
uiStore.openModalWithData({
|
||||
name: PROJECT_MOVE_RESOURCE_MODAL,
|
||||
data: {
|
||||
resource: props.data,
|
||||
resourceType: ResourceType.Credential,
|
||||
resourceTypeLabel: resourceTypeLabel.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n8n-card :class="$style.cardLink" @click.stop="onClick">
|
||||
<template #prepend>
|
||||
<CredentialIcon :credential-type-name="credentialType?.name ?? ''" />
|
||||
</template>
|
||||
<template #header>
|
||||
<n8n-heading tag="h2" bold :class="$style.cardHeading">
|
||||
{{ data.name }}
|
||||
<N8nBadge v-if="readOnly" class="ml-3xs" theme="tertiary" bold>
|
||||
{{ locale.baseText('credentials.item.readonly') }}
|
||||
</N8nBadge>
|
||||
<N8nBadge v-if="needsSetup" class="ml-3xs" theme="warning">
|
||||
{{ locale.baseText('credentials.item.needsSetup') }}
|
||||
</N8nBadge>
|
||||
</n8n-heading>
|
||||
</template>
|
||||
<div :class="$style.cardDescription">
|
||||
<n8n-text color="text-light" size="small">
|
||||
<span v-if="credentialType">{{ credentialType.displayName }} | </span>
|
||||
<span v-show="data"
|
||||
>{{ locale.baseText('credentials.item.updated') }} <TimeAgo :date="data.updatedAt" /> |
|
||||
</span>
|
||||
<span v-show="data"
|
||||
>{{ locale.baseText('credentials.item.created') }} {{ formattedCreatedAtDate }}
|
||||
</span>
|
||||
</n8n-text>
|
||||
</div>
|
||||
<template #append>
|
||||
<div :class="$style.cardActions" @click.stop>
|
||||
<ProjectCardBadge
|
||||
:class="$style.cardBadge"
|
||||
:resource="data"
|
||||
:resource-type="ResourceType.Credential"
|
||||
:resource-type-label="resourceTypeLabel"
|
||||
:personal-project="projectsStore.personalProject"
|
||||
/>
|
||||
<n8n-action-toggle
|
||||
data-test-id="credential-card-actions"
|
||||
:actions="actions"
|
||||
theme="dark"
|
||||
@action="onAction"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</n8n-card>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.cardLink {
|
||||
--card--padding: 0 0 0 var(--spacing-s);
|
||||
|
||||
transition: box-shadow 0.3s ease;
|
||||
cursor: pointer;
|
||||
align-items: stretch;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(#441c17, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.cardHeading {
|
||||
font-size: var(--font-size-s);
|
||||
padding: var(--spacing-s) 0 0;
|
||||
}
|
||||
|
||||
.cardDescription {
|
||||
min-height: 19px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0 var(--spacing-s);
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
padding: 0 var(--spacing-s) 0 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@include mixins.breakpoint('sm-and-down') {
|
||||
.cardLink {
|
||||
--card--padding: 0 var(--spacing-s) var(--spacing-s);
|
||||
--card--append--width: 100%;
|
||||
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cardBadge {
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
import CredentialConfig from './CredentialEdit/CredentialConfig.vue';
|
||||
import { screen } from '@testing-library/vue';
|
||||
import type { ICredentialDataDecryptedObject, ICredentialType } from 'n8n-workflow';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import type { RenderOptions } from '@/__tests__/render';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { STORES } from '@/constants';
|
||||
|
||||
const defaultRenderOptions: RenderOptions = {
|
||||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.SETTINGS]: {
|
||||
settings: {
|
||||
enterprise: {
|
||||
sharing: false,
|
||||
externalSecrets: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
props: {
|
||||
isManaged: true,
|
||||
mode: 'edit',
|
||||
credentialType: {} as ICredentialType,
|
||||
credentialProperties: [],
|
||||
credentialData: {} as ICredentialDataDecryptedObject,
|
||||
credentialPermissions: {
|
||||
share: false,
|
||||
move: false,
|
||||
create: false,
|
||||
read: false,
|
||||
update: false,
|
||||
delete: false,
|
||||
list: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const renderComponent = createComponentRenderer(CredentialConfig, defaultRenderOptions);
|
||||
|
||||
describe('CredentialConfig', () => {
|
||||
it('should display a warning when isManaged is true', async () => {
|
||||
renderComponent();
|
||||
expect(
|
||||
screen.queryByText('This is a managed credential and cannot be edited.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display a warning when isManaged is false', async () => {
|
||||
renderComponent({ props: { isManaged: false } }, { merge: true });
|
||||
expect(
|
||||
screen.queryByText('This is a managed credential and cannot be edited.'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import ParameterInputFull from '@/components/ParameterInputFull.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { IUpdateInformation, NodeAuthenticationOption } from '@/Interface';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import {
|
||||
getAuthTypeForNodeCredential,
|
||||
getNodeAuthFields,
|
||||
getNodeAuthOptions,
|
||||
isAuthRelatedParameter,
|
||||
} from '@/utils/nodeTypesUtils';
|
||||
import type {
|
||||
ICredentialType,
|
||||
INodeProperties,
|
||||
INodeTypeDescription,
|
||||
NodeParameterValue,
|
||||
} from 'n8n-workflow';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
export interface Props {
|
||||
credentialType: ICredentialType;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
authTypeChanged: [value: string];
|
||||
}>();
|
||||
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const ndvStore = useNDVStore();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const selected = ref('');
|
||||
const authRelatedFieldsValues = ref<{ [key: string]: NodeParameterValue }>({});
|
||||
|
||||
onMounted(() => {
|
||||
if (activeNodeType.value?.credentials) {
|
||||
const credentialsForType =
|
||||
activeNodeType.value.credentials.find((cred) => cred.name === props.credentialType.name) ||
|
||||
null;
|
||||
const authOptionForCred = getAuthTypeForNodeCredential(
|
||||
activeNodeType.value,
|
||||
credentialsForType,
|
||||
);
|
||||
selected.value = authOptionForCred?.value || '';
|
||||
}
|
||||
|
||||
// Populate default values of related fields
|
||||
authRelatedFields.value.forEach((field) => {
|
||||
authRelatedFieldsValues.value = {
|
||||
...authRelatedFieldsValues.value,
|
||||
[field.name]: field.default as NodeParameterValue,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const activeNodeType = computed<INodeTypeDescription | null>(() => {
|
||||
const activeNode = ndvStore.activeNode;
|
||||
if (activeNode) {
|
||||
return nodeTypesStore.getNodeType(activeNode.type, activeNode.typeVersion);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const authOptions = computed<NodeAuthenticationOption[]>(() => {
|
||||
return getNodeAuthOptions(activeNodeType.value, ndvStore.activeNode?.typeVersion);
|
||||
});
|
||||
|
||||
const filteredNodeAuthOptions = computed<NodeAuthenticationOption[]>(() => {
|
||||
return authOptions.value.filter((option) => shouldShowAuthOption(option));
|
||||
});
|
||||
|
||||
// These are node properties that authentication fields depend on
|
||||
// (have them in their display options). They all are show here
|
||||
// instead of in the NDV
|
||||
const authRelatedFields = computed<INodeProperties[]>(() => {
|
||||
const nodeAuthFields = getNodeAuthFields(activeNodeType.value);
|
||||
return (
|
||||
activeNodeType.value?.properties.filter((prop) =>
|
||||
isAuthRelatedParameter(nodeAuthFields, prop),
|
||||
) || []
|
||||
);
|
||||
});
|
||||
|
||||
function shouldShowAuthOption(option: NodeAuthenticationOption): boolean {
|
||||
// Node auth radio button should be shown if any of the fields that it depends on
|
||||
// has value specified in it's displayOptions.show
|
||||
if (authRelatedFields.value.length === 0) {
|
||||
// If there are no related fields, show option
|
||||
return true;
|
||||
}
|
||||
|
||||
let shouldDisplay = false;
|
||||
Object.keys(authRelatedFieldsValues.value).forEach((fieldName) => {
|
||||
if (option.displayOptions?.show) {
|
||||
if (
|
||||
option.displayOptions.show[fieldName]?.includes(authRelatedFieldsValues.value[fieldName])
|
||||
) {
|
||||
shouldDisplay = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
return shouldDisplay;
|
||||
}
|
||||
|
||||
function onAuthTypeChange(newType: string): void {
|
||||
emit('authTypeChanged', newType);
|
||||
}
|
||||
|
||||
function valueChanged(data: IUpdateInformation): void {
|
||||
authRelatedFieldsValues.value = {
|
||||
...authRelatedFieldsValues.value,
|
||||
[data.name]: data.value as NodeParameterValue,
|
||||
};
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
onAuthTypeChange,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="filteredNodeAuthOptions.length > 0" data-test-id="node-auth-type-selector">
|
||||
<div v-for="parameter in authRelatedFields" :key="parameter.name" class="mb-l">
|
||||
<ParameterInputFull
|
||||
:parameter="parameter"
|
||||
:value="authRelatedFieldsValues[parameter.name] || parameter.default"
|
||||
:path="parameter.name"
|
||||
:display-options="false"
|
||||
@update="valueChanged"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<n8n-input-label
|
||||
:label="i18n.baseText('credentialEdit.credentialConfig.authTypeSelectorLabel')"
|
||||
:tooltip-text="i18n.baseText('credentialEdit.credentialConfig.authTypeSelectorTooltip')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<el-radio
|
||||
v-for="prop in filteredNodeAuthOptions"
|
||||
:key="prop.value"
|
||||
v-model="selected"
|
||||
:label="prop.value"
|
||||
:class="$style.authRadioButton"
|
||||
border
|
||||
@update:model-value="onAuthTypeChange"
|
||||
>{{ prop.name }}</el-radio
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.authRadioButton {
|
||||
margin-right: 0 !important;
|
||||
& + & {
|
||||
margin-left: 8px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,443 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeMount, watch } from 'vue';
|
||||
|
||||
import { getAppNameFromCredType, isCommunityPackageName } from '@/utils/nodeTypesUtils';
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialType,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { IUpdateInformation } from '@/Interface';
|
||||
import AuthTypeSelector from '@/components/CredentialEdit/AuthTypeSelector.vue';
|
||||
import EnterpriseEdition from '@/components/EnterpriseEdition.ee.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import {
|
||||
BUILTIN_CREDENTIALS_DOCS_URL,
|
||||
CREDENTIAL_DOCS_EXPERIMENT,
|
||||
DOCS_DOMAIN,
|
||||
EnterpriseEditionFeature,
|
||||
NEW_ASSISTANT_SESSION_MODAL,
|
||||
} from '@/constants';
|
||||
import type { PermissionsRecord } from '@/permissions';
|
||||
import { addCredentialTranslation } from '@/plugins/i18n';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import Banner from '../Banner.vue';
|
||||
import CopyInput from '../CopyInput.vue';
|
||||
import CredentialInputs from './CredentialInputs.vue';
|
||||
import GoogleAuthButton from './GoogleAuthButton.vue';
|
||||
import OauthButton from './OauthButton.vue';
|
||||
import CredentialDocs from './CredentialDocs.vue';
|
||||
import { CREDENTIAL_MARKDOWN_DOCS } from './docs';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
import { useAssistantStore } from '@/stores/assistant.store';
|
||||
import InlineAskAssistantButton from '@n8n/design-system/components/InlineAskAssistantButton/InlineAskAssistantButton.vue';
|
||||
|
||||
type Props = {
|
||||
mode: string;
|
||||
credentialType: ICredentialType;
|
||||
credentialProperties: INodeProperties[];
|
||||
credentialData: ICredentialDataDecryptedObject;
|
||||
credentialId?: string;
|
||||
credentialPermissions: PermissionsRecord['credential'];
|
||||
parentTypes?: string[];
|
||||
showValidationWarning?: boolean;
|
||||
authError?: string;
|
||||
testedSuccessfully?: boolean;
|
||||
isOAuthType?: boolean;
|
||||
allOAuth2BasePropertiesOverridden?: boolean;
|
||||
isOAuthConnected?: boolean;
|
||||
isRetesting?: boolean;
|
||||
requiredPropertiesFilled?: boolean;
|
||||
showAuthTypeSelector?: boolean;
|
||||
isManaged?: boolean;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
parentTypes: () => [],
|
||||
credentialId: '',
|
||||
authError: '',
|
||||
showValidationWarning: false,
|
||||
credentialPermissions: () => ({}) as PermissionsRecord['credential'],
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
update: [value: IUpdateInformation];
|
||||
authTypeChanged: [value: string];
|
||||
scrollToTop: [];
|
||||
retest: [];
|
||||
oauth: [];
|
||||
}>();
|
||||
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const ndvStore = useNDVStore();
|
||||
const rootStore = useRootStore();
|
||||
const uiStore = useUIStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const assistantStore = useAssistantStore();
|
||||
|
||||
const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
onBeforeMount(async () => {
|
||||
uiStore.activeCredentialType = props.credentialType.name;
|
||||
|
||||
if (rootStore.defaultLocale === 'en') return;
|
||||
|
||||
const key = `n8n-nodes-base.credentials.${props.credentialType.name}`;
|
||||
|
||||
if (i18n.exists(key)) return;
|
||||
|
||||
const credTranslation = await credentialsStore.getCredentialTranslation(
|
||||
props.credentialType.name,
|
||||
);
|
||||
|
||||
addCredentialTranslation(
|
||||
{ [props.credentialType.name]: credTranslation },
|
||||
rootStore.defaultLocale,
|
||||
);
|
||||
});
|
||||
|
||||
const appName = computed(() => {
|
||||
if (!props.credentialType) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (
|
||||
getAppNameFromCredType(props.credentialType.displayName) ||
|
||||
i18n.baseText('credentialEdit.credentialConfig.theServiceYouReConnectingTo')
|
||||
);
|
||||
});
|
||||
const credentialTypeName = computed(() => props.credentialType?.name);
|
||||
const credentialOwnerName = computed(() =>
|
||||
credentialsStore.getCredentialOwnerNameById(`${props.credentialId}`),
|
||||
);
|
||||
const documentationUrl = computed(() => {
|
||||
const type = props.credentialType;
|
||||
const activeNode = ndvStore.activeNode;
|
||||
const isCommunityNode = activeNode ? isCommunityPackageName(activeNode.type) : false;
|
||||
|
||||
const docUrl = type?.documentationUrl;
|
||||
|
||||
if (!docUrl) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
if (docUrl.startsWith('https://') || docUrl.startsWith('http://')) {
|
||||
url = new URL(docUrl);
|
||||
if (url.hostname !== DOCS_DOMAIN) return docUrl;
|
||||
} else {
|
||||
// Don't show documentation link for community nodes if the URL is not an absolute path
|
||||
if (isCommunityNode) return '';
|
||||
else url = new URL(`${BUILTIN_CREDENTIALS_DOCS_URL}${docUrl}/`);
|
||||
}
|
||||
|
||||
if (url.hostname === DOCS_DOMAIN) {
|
||||
url.searchParams.set('utm_source', 'n8n_app');
|
||||
url.searchParams.set('utm_medium', 'credential_settings');
|
||||
url.searchParams.set('utm_campaign', 'create_new_credentials_modal');
|
||||
}
|
||||
|
||||
return url.href;
|
||||
});
|
||||
|
||||
const isGoogleOAuthType = computed(
|
||||
() =>
|
||||
credentialTypeName.value === 'googleOAuth2Api' || props.parentTypes.includes('googleOAuth2Api'),
|
||||
);
|
||||
|
||||
const oAuthCallbackUrl = computed(() => {
|
||||
const oauthType =
|
||||
credentialTypeName.value === 'oAuth2Api' || props.parentTypes.includes('oAuth2Api')
|
||||
? 'oauth2'
|
||||
: 'oauth1';
|
||||
return rootStore.OAuthCallbackUrls[oauthType as keyof {}];
|
||||
});
|
||||
|
||||
const showOAuthSuccessBanner = computed(() => {
|
||||
return (
|
||||
props.isOAuthType &&
|
||||
props.requiredPropertiesFilled &&
|
||||
props.isOAuthConnected &&
|
||||
!props.authError
|
||||
);
|
||||
});
|
||||
|
||||
const isMissingCredentials = computed(() => props.credentialType === null);
|
||||
|
||||
const isNewCredential = computed(() => props.mode === 'new' && !props.credentialId);
|
||||
|
||||
const isAskAssistantAvailable = computed(
|
||||
() =>
|
||||
documentationUrl.value &&
|
||||
documentationUrl.value.includes(DOCS_DOMAIN) &&
|
||||
props.credentialProperties.length &&
|
||||
props.credentialPermissions.update &&
|
||||
!(props.isOAuthType && props.requiredPropertiesFilled) &&
|
||||
assistantStore.isAssistantEnabled,
|
||||
);
|
||||
|
||||
const assistantAlreadyAsked = computed<boolean>(() => {
|
||||
return assistantStore.isCredTypeActive(props.credentialType);
|
||||
});
|
||||
|
||||
const docs = computed(() => CREDENTIAL_MARKDOWN_DOCS[props.credentialType.name]);
|
||||
const showCredentialDocs = computed(
|
||||
() =>
|
||||
usePostHog().getVariant(CREDENTIAL_DOCS_EXPERIMENT.name) ===
|
||||
CREDENTIAL_DOCS_EXPERIMENT.variant && docs.value,
|
||||
);
|
||||
|
||||
function onDataChange(event: IUpdateInformation): void {
|
||||
emit('update', event);
|
||||
}
|
||||
|
||||
function onDocumentationUrlClick(): void {
|
||||
telemetry.track('User clicked credential modal docs link', {
|
||||
docs_link: documentationUrl.value,
|
||||
credential_type: credentialTypeName.value,
|
||||
source: 'modal',
|
||||
workflow_id: workflowsStore.workflowId,
|
||||
});
|
||||
}
|
||||
|
||||
function onAuthTypeChange(newType: string): void {
|
||||
emit('authTypeChanged', newType);
|
||||
}
|
||||
|
||||
async function onAskAssistantClick() {
|
||||
const sessionInProgress = !assistantStore.isSessionEnded;
|
||||
if (sessionInProgress) {
|
||||
uiStore.openModalWithData({
|
||||
name: NEW_ASSISTANT_SESSION_MODAL,
|
||||
data: {
|
||||
context: {
|
||||
credHelp: {
|
||||
credType: props.credentialType,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await assistantStore.initCredHelp(props.credentialType);
|
||||
}
|
||||
|
||||
watch(showOAuthSuccessBanner, (newValue, oldValue) => {
|
||||
if (newValue && !oldValue) {
|
||||
emit('scrollToTop');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n8n-callout v-if="isManaged" theme="warning" icon="exclamation-triangle">
|
||||
{{ i18n.baseText('freeAi.credits.credentials.edit') }}
|
||||
</n8n-callout>
|
||||
<div v-else>
|
||||
<div :class="$style.config" data-test-id="node-credentials-config-container">
|
||||
<Banner
|
||||
v-show="showValidationWarning"
|
||||
theme="danger"
|
||||
:message="
|
||||
i18n.baseText(
|
||||
`credentialEdit.credentialConfig.pleaseCheckTheErrorsBelow${
|
||||
credentialPermissions.update ? '' : '.sharee'
|
||||
}`,
|
||||
{ interpolate: { owner: credentialOwnerName } },
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<Banner
|
||||
v-if="authError && !showValidationWarning"
|
||||
theme="danger"
|
||||
:message="
|
||||
i18n.baseText(
|
||||
`credentialEdit.credentialConfig.couldntConnectWithTheseSettings${
|
||||
credentialPermissions.update ? '' : '.sharee'
|
||||
}`,
|
||||
{ interpolate: { owner: credentialOwnerName } },
|
||||
)
|
||||
"
|
||||
:details="authError"
|
||||
:button-label="i18n.baseText('credentialEdit.credentialConfig.retry')"
|
||||
button-loading-label="Retrying"
|
||||
:button-title="i18n.baseText('credentialEdit.credentialConfig.retryCredentialTest')"
|
||||
:button-loading="isRetesting"
|
||||
@click="$emit('retest')"
|
||||
/>
|
||||
|
||||
<Banner
|
||||
v-show="showOAuthSuccessBanner && !showValidationWarning"
|
||||
theme="success"
|
||||
:message="i18n.baseText('credentialEdit.credentialConfig.accountConnected')"
|
||||
:button-label="i18n.baseText('credentialEdit.credentialConfig.reconnect')"
|
||||
:button-title="i18n.baseText('credentialEdit.credentialConfig.reconnectOAuth2Credential')"
|
||||
data-test-id="oauth-connect-success-banner"
|
||||
@click="$emit('oauth')"
|
||||
>
|
||||
<template v-if="isGoogleOAuthType" #button>
|
||||
<p
|
||||
:class="$style.googleReconnectLabel"
|
||||
v-text="`${i18n.baseText('credentialEdit.credentialConfig.reconnect')}:`"
|
||||
/>
|
||||
<GoogleAuthButton @click="$emit('oauth')" />
|
||||
</template>
|
||||
</Banner>
|
||||
|
||||
<Banner
|
||||
v-show="testedSuccessfully && !showValidationWarning"
|
||||
theme="success"
|
||||
:message="i18n.baseText('credentialEdit.credentialConfig.connectionTestedSuccessfully')"
|
||||
:button-label="i18n.baseText('credentialEdit.credentialConfig.retry')"
|
||||
:button-loading-label="i18n.baseText('credentialEdit.credentialConfig.retrying')"
|
||||
:button-title="i18n.baseText('credentialEdit.credentialConfig.retryCredentialTest')"
|
||||
:button-loading="isRetesting"
|
||||
data-test-id="credentials-config-container-test-success"
|
||||
@click="$emit('retest')"
|
||||
/>
|
||||
|
||||
<template v-if="credentialPermissions.update">
|
||||
<n8n-notice
|
||||
v-if="documentationUrl && credentialProperties.length && !showCredentialDocs"
|
||||
theme="warning"
|
||||
>
|
||||
{{ i18n.baseText('credentialEdit.credentialConfig.needHelpFillingOutTheseFields') }}
|
||||
<span class="ml-4xs">
|
||||
<n8n-link :to="documentationUrl" size="small" bold @click="onDocumentationUrlClick">
|
||||
{{ i18n.baseText('credentialEdit.credentialConfig.openDocs') }}
|
||||
</n8n-link>
|
||||
</span>
|
||||
</n8n-notice>
|
||||
|
||||
<AuthTypeSelector
|
||||
v-if="showAuthTypeSelector && isNewCredential"
|
||||
:credential-type="credentialType"
|
||||
@auth-type-changed="onAuthTypeChange"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="isAskAssistantAvailable"
|
||||
:class="$style.askAssistantButton"
|
||||
data-test-id="credential-edit-ask-assistant-button"
|
||||
>
|
||||
<InlineAskAssistantButton :asked="assistantAlreadyAsked" @click="onAskAssistantClick" />
|
||||
<span>for setup instructions</span>
|
||||
</div>
|
||||
|
||||
<CopyInput
|
||||
v-if="isOAuthType && !allOAuth2BasePropertiesOverridden"
|
||||
:label="i18n.baseText('credentialEdit.credentialConfig.oAuthRedirectUrl')"
|
||||
:value="oAuthCallbackUrl"
|
||||
:copy-button-text="i18n.baseText('credentialEdit.credentialConfig.clickToCopy')"
|
||||
:hint="
|
||||
i18n.baseText('credentialEdit.credentialConfig.subtitle', {
|
||||
interpolate: { appName },
|
||||
})
|
||||
"
|
||||
:toast-title="
|
||||
i18n.baseText('credentialEdit.credentialConfig.redirectUrlCopiedToClipboard')
|
||||
"
|
||||
:redact-value="true"
|
||||
/>
|
||||
</template>
|
||||
<EnterpriseEdition v-else :features="[EnterpriseEditionFeature.Sharing]">
|
||||
<div>
|
||||
<n8n-info-tip :bold="false">
|
||||
{{
|
||||
i18n.baseText('credentialEdit.credentialEdit.info.sharee', {
|
||||
interpolate: { credentialOwnerName },
|
||||
})
|
||||
}}
|
||||
</n8n-info-tip>
|
||||
</div>
|
||||
</EnterpriseEdition>
|
||||
|
||||
<CredentialInputs
|
||||
v-if="credentialType && credentialPermissions.update"
|
||||
:credential-data="credentialData"
|
||||
:credential-properties="credentialProperties"
|
||||
:documentation-url="documentationUrl"
|
||||
:show-validation-warnings="showValidationWarning"
|
||||
@update="onDataChange"
|
||||
/>
|
||||
|
||||
<OauthButton
|
||||
v-if="
|
||||
isOAuthType &&
|
||||
requiredPropertiesFilled &&
|
||||
!isOAuthConnected &&
|
||||
credentialPermissions.update
|
||||
"
|
||||
:is-google-o-auth-type="isGoogleOAuthType"
|
||||
data-test-id="oauth-connect-button"
|
||||
@click="$emit('oauth')"
|
||||
/>
|
||||
|
||||
<n8n-text v-if="isMissingCredentials" color="text-base" size="medium">
|
||||
{{ i18n.baseText('credentialEdit.credentialConfig.missingCredentialType') }}
|
||||
</n8n-text>
|
||||
|
||||
<EnterpriseEdition :features="[EnterpriseEditionFeature.ExternalSecrets]">
|
||||
<template #fallback>
|
||||
<n8n-info-tip class="mt-s">
|
||||
{{ i18n.baseText('credentialEdit.credentialConfig.externalSecrets') }}
|
||||
<n8n-link bold :to="i18n.baseText('settings.externalSecrets.docs')" size="small">
|
||||
{{ i18n.baseText('credentialEdit.credentialConfig.externalSecrets.moreInfo') }}
|
||||
</n8n-link>
|
||||
</n8n-info-tip>
|
||||
</template>
|
||||
</EnterpriseEdition>
|
||||
</div>
|
||||
<CredentialDocs
|
||||
v-if="showCredentialDocs"
|
||||
:credential-type="credentialType"
|
||||
:documentation-url="documentationUrl"
|
||||
:docs="docs"
|
||||
:class="$style.docs"
|
||||
>
|
||||
</CredentialDocs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.config {
|
||||
--notice-margin: 0;
|
||||
flex-grow: 1;
|
||||
|
||||
> * {
|
||||
margin-bottom: var(--spacing-l);
|
||||
}
|
||||
|
||||
&:has(+ .docs) {
|
||||
padding-right: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.docs {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.googleReconnectLabel {
|
||||
margin-right: var(--spacing-3xs);
|
||||
}
|
||||
|
||||
.askAssistantButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
> span {
|
||||
margin-left: var(--spacing-3xs);
|
||||
font-size: var(--font-size-s);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import Feedback from '@/components/Feedback.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import type { ICredentialType } from 'n8n-workflow';
|
||||
import { ref } from 'vue';
|
||||
import VueMarkdown from 'vue-markdown-render';
|
||||
|
||||
type Props = {
|
||||
credentialType: ICredentialType;
|
||||
docs: string;
|
||||
documentationUrl: string;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
|
||||
const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const submittedFeedback = ref<'positive' | 'negative'>();
|
||||
|
||||
function onFeedback(feedback: 'positive' | 'negative') {
|
||||
submittedFeedback.value = feedback;
|
||||
telemetry.track('User gave feedback on credential docs', {
|
||||
feedback,
|
||||
docs_link: props.documentationUrl,
|
||||
credential_type: props.credentialType.name,
|
||||
workflow_id: workflowsStore.workflowId,
|
||||
});
|
||||
}
|
||||
|
||||
function onDocumentationUrlClick(): void {
|
||||
telemetry.track('User clicked credential modal docs link', {
|
||||
docs_link: props.documentationUrl,
|
||||
credential_type: props.credentialType.name,
|
||||
source: 'modal-docs-sidebar',
|
||||
workflow_id: workflowsStore.workflowId,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.docs">
|
||||
<div :class="$style.header">
|
||||
<p :class="$style.title">{{ i18n.baseText('credentialEdit.credentialEdit.setupGuide') }}</p>
|
||||
<n8n-link
|
||||
:class="$style.docsLink"
|
||||
theme="text"
|
||||
new-window
|
||||
:to="documentationUrl"
|
||||
@click="onDocumentationUrlClick"
|
||||
>
|
||||
{{ i18n.baseText('credentialEdit.credentialEdit.docs') }}
|
||||
<n8n-icon icon="external-link-alt" size="small" :class="$style.externalIcon" />
|
||||
</n8n-link>
|
||||
</div>
|
||||
<VueMarkdown :source="docs" :options="{ html: true }" :class="$style.markdown" />
|
||||
<Feedback
|
||||
:class="$style.feedback"
|
||||
:model-value="submittedFeedback"
|
||||
@update:model-value="onFeedback"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.docs {
|
||||
background-color: var(--color-background-light);
|
||||
border-left: var(--border-base);
|
||||
padding: var(--spacing-s);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-m);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: var(--spacing-2xs);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: var(--border-base);
|
||||
padding-bottom: var(--spacing-s);
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.docsLink {
|
||||
color: var(--color-text-light);
|
||||
|
||||
&:hover .externalIcon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.externalIcon {
|
||||
color: var(--color-text-light);
|
||||
padding-left: var(--spacing-4xs);
|
||||
}
|
||||
|
||||
.feedback {
|
||||
border-top: var(--border-base);
|
||||
padding-top: var(--spacing-s);
|
||||
margin-top: var(--spacing-s);
|
||||
}
|
||||
|
||||
.markdown {
|
||||
color: var(--color-text-base);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--font-line-height-xloose);
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-s);
|
||||
color: var(--color-text-base);
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin-top: var(--spacing-s);
|
||||
margin-bottom: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: var(--spacing-2xs) 0;
|
||||
margin-left: var(--spacing-m);
|
||||
}
|
||||
|
||||
ol ol {
|
||||
list-style-type: lower-alpha;
|
||||
}
|
||||
|
||||
li > ul,
|
||||
li > ol {
|
||||
margin: var(--spacing-4xs) 0;
|
||||
margin-left: var(--spacing-xs);
|
||||
}
|
||||
|
||||
li + li {
|
||||
margin-top: var(--spacing-4xs);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-base);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: var(--font-line-height-xloose);
|
||||
margin-bottom: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
padding: var(--spacing-4xs) 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import TimeAgo from '../TimeAgo.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { ICredentialsDecryptedResponse, ICredentialsResponse } from '@/Interface';
|
||||
import { N8nText } from '@n8n/design-system';
|
||||
|
||||
type Props = {
|
||||
currentCredential: ICredentialsResponse | ICredentialsDecryptedResponse | null;
|
||||
};
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const i18n = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<el-row v-if="currentCredential">
|
||||
<el-col :span="8" :class="$style.label">
|
||||
<N8nText :compact="true" :bold="true">
|
||||
{{ i18n.baseText('credentialEdit.credentialInfo.created') }}
|
||||
</N8nText>
|
||||
</el-col>
|
||||
<el-col :span="16" :class="$style.valueLabel">
|
||||
<N8nText :compact="true"
|
||||
><TimeAgo :date="currentCredential.createdAt" :capitalize="true"
|
||||
/></N8nText>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row v-if="currentCredential">
|
||||
<el-col :span="8" :class="$style.label">
|
||||
<N8nText :compact="true" :bold="true">
|
||||
{{ i18n.baseText('credentialEdit.credentialInfo.lastModified') }}
|
||||
</N8nText>
|
||||
</el-col>
|
||||
<el-col :span="16" :class="$style.valueLabel">
|
||||
<N8nText :compact="true"
|
||||
><TimeAgo :date="currentCredential.updatedAt" :capitalize="true"
|
||||
/></N8nText>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row v-if="currentCredential">
|
||||
<el-col :span="8" :class="$style.label">
|
||||
<N8nText :compact="true" :bold="true">
|
||||
{{ i18n.baseText('credentialEdit.credentialInfo.id') }}
|
||||
</N8nText>
|
||||
</el-col>
|
||||
<el-col :span="16" :class="$style.valueLabel">
|
||||
<N8nText :compact="true">{{ currentCredential.id }}</N8nText>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
> * {
|
||||
margin-bottom: var(--spacing-l);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: var(--font-weight-bold);
|
||||
max-width: 230px;
|
||||
}
|
||||
|
||||
.accessLabel {
|
||||
composes: label;
|
||||
margin-top: var(--spacing-5xs);
|
||||
}
|
||||
|
||||
.valueLabel {
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
INodeProperties,
|
||||
NodeParameterValueType,
|
||||
} from 'n8n-workflow';
|
||||
import type { IUpdateInformation } from '@/Interface';
|
||||
import ParameterInputExpanded from '../ParameterInputExpanded.vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
type Props = {
|
||||
credentialProperties: INodeProperties[];
|
||||
credentialData: ICredentialDataDecryptedObject;
|
||||
documentationUrl: string;
|
||||
showValidationWarnings?: boolean;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const credentialDataValues = computed(
|
||||
() => props.credentialData as Record<string, NodeParameterValueType>,
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [value: IUpdateInformation];
|
||||
}>();
|
||||
|
||||
function valueChanged(parameterData: IUpdateInformation) {
|
||||
const name = parameterData.name.split('.').pop() ?? parameterData.name;
|
||||
|
||||
emit('update', {
|
||||
name,
|
||||
value: parameterData.value,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="credentialProperties.length" :class="$style.container" @keydown.stop>
|
||||
<form
|
||||
v-for="parameter in credentialProperties"
|
||||
:key="parameter.name"
|
||||
autocomplete="off"
|
||||
data-test-id="credential-connection-parameter"
|
||||
@submit.prevent
|
||||
>
|
||||
<!-- Why form? to break up inputs, to prevent Chrome autofill -->
|
||||
<n8n-notice v-if="parameter.type === 'notice'" :content="parameter.displayName" />
|
||||
<ParameterInputExpanded
|
||||
v-else
|
||||
:parameter="parameter"
|
||||
:value="credentialDataValues[parameter.name]"
|
||||
:documentation-url="documentationUrl"
|
||||
:show-validation-warnings="showValidationWarnings"
|
||||
:label="{ size: 'medium' }"
|
||||
event-source="credentials"
|
||||
@update="valueChanged"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
> * {
|
||||
margin-bottom: var(--spacing-l);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import ProjectSharing from '@/components/Projects/ProjectSharing.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
import { EnterpriseEditionFeature } from '@/constants';
|
||||
import type { ICredentialsDecryptedResponse, ICredentialsResponse } from '@/Interface';
|
||||
import type { PermissionsRecord } from '@/permissions';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { useRolesStore } from '@/stores/roles.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
import type { RoleMap } from '@/types/roles.types';
|
||||
import { splitName } from '@/utils/projects.utils';
|
||||
import type { EventBus } from '@n8n/utils/event-bus';
|
||||
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
type Props = {
|
||||
credentialId: string;
|
||||
credentialData: ICredentialDataDecryptedObject;
|
||||
credentialPermissions: PermissionsRecord['credential'];
|
||||
credential?: ICredentialsResponse | ICredentialsDecryptedResponse | null;
|
||||
modalBus: EventBus;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { credential: null });
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ProjectSharingData[]];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const usersStore = useUsersStore();
|
||||
const uiStore = useUIStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const rolesStore = useRolesStore();
|
||||
|
||||
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||
|
||||
const sharedWithProjects = ref([...(props.credential?.sharedWithProjects ?? [])]);
|
||||
|
||||
const isSharingEnabled = computed(
|
||||
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing],
|
||||
);
|
||||
const credentialOwnerName = computed(() => {
|
||||
const { name, email } = splitName(props.credential?.homeProject?.name ?? '');
|
||||
return name ?? email ?? '';
|
||||
});
|
||||
|
||||
const credentialDataHomeProject = computed<ProjectSharingData | undefined>(() => {
|
||||
const credentialContainsProjectSharingData = (
|
||||
data: ICredentialDataDecryptedObject,
|
||||
): data is { homeProject: ProjectSharingData } => {
|
||||
return 'homeProject' in data;
|
||||
};
|
||||
|
||||
return props.credentialData && credentialContainsProjectSharingData(props.credentialData)
|
||||
? props.credentialData.homeProject
|
||||
: undefined;
|
||||
});
|
||||
|
||||
const projects = computed<ProjectListItem[]>(() => {
|
||||
return projectsStore.projects.filter(
|
||||
(project) =>
|
||||
project.id !== props.credential?.homeProject?.id &&
|
||||
project.id !== credentialDataHomeProject.value?.id,
|
||||
);
|
||||
});
|
||||
|
||||
const homeProject = computed<ProjectSharingData | undefined>(
|
||||
() => props.credential?.homeProject ?? credentialDataHomeProject.value,
|
||||
);
|
||||
const isHomeTeamProject = computed(() => homeProject.value?.type === ProjectTypes.Team);
|
||||
const credentialRoleTranslations = computed<Record<string, string>>(() => {
|
||||
return {
|
||||
'credential:user': i18n.baseText('credentialEdit.credentialSharing.role.user'),
|
||||
};
|
||||
});
|
||||
|
||||
const credentialRoles = computed<RoleMap['credential']>(() => {
|
||||
return rolesStore.processedCredentialRoles.map(({ role, scopes, licensed }) => ({
|
||||
role,
|
||||
name: credentialRoleTranslations.value[role],
|
||||
scopes,
|
||||
licensed,
|
||||
}));
|
||||
});
|
||||
|
||||
const sharingSelectPlaceholder = computed(() =>
|
||||
projectsStore.teamProjects.length
|
||||
? i18n.baseText('projects.sharing.select.placeholder.project')
|
||||
: i18n.baseText('projects.sharing.select.placeholder.user'),
|
||||
);
|
||||
|
||||
watch(
|
||||
sharedWithProjects,
|
||||
(changedSharedWithProjects) => {
|
||||
emit('update:modelValue', changedSharedWithProjects);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([usersStore.fetchUsers(), projectsStore.getAllProjects()]);
|
||||
});
|
||||
|
||||
function goToUpgrade() {
|
||||
void pageRedirectionHelper.goToUpgrade('credential_sharing', 'upgrade-credentials-sharing');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<div v-if="!isSharingEnabled">
|
||||
<N8nActionBox
|
||||
:heading="
|
||||
i18n.baseText(uiStore.contextBasedTranslationKeys.credentials.sharing.unavailable.title)
|
||||
"
|
||||
:description="
|
||||
i18n.baseText(
|
||||
uiStore.contextBasedTranslationKeys.credentials.sharing.unavailable.description,
|
||||
)
|
||||
"
|
||||
:button-text="
|
||||
i18n.baseText(uiStore.contextBasedTranslationKeys.credentials.sharing.unavailable.button)
|
||||
"
|
||||
@click:button="goToUpgrade"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<N8nInfoTip v-if="credentialPermissions.share" :bold="false" class="mb-s">
|
||||
{{ i18n.baseText('credentialEdit.credentialSharing.info.owner') }}
|
||||
</N8nInfoTip>
|
||||
<N8nInfoTip v-else-if="isHomeTeamProject" :bold="false" class="mb-s">
|
||||
{{ i18n.baseText('credentialEdit.credentialSharing.info.sharee.team') }}
|
||||
</N8nInfoTip>
|
||||
<N8nInfoTip v-else :bold="false" class="mb-s">
|
||||
{{
|
||||
i18n.baseText('credentialEdit.credentialSharing.info.sharee.personal', {
|
||||
interpolate: { credentialOwnerName },
|
||||
})
|
||||
}}
|
||||
</N8nInfoTip>
|
||||
<ProjectSharing
|
||||
v-model="sharedWithProjects"
|
||||
:projects="projects"
|
||||
:roles="credentialRoles"
|
||||
:home-project="homeProject"
|
||||
:readonly="!credentialPermissions.share"
|
||||
:static="!credentialPermissions.share"
|
||||
:placeholder="sharingSelectPlaceholder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
width: 100%;
|
||||
> * {
|
||||
margin-bottom: var(--spacing-l);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts" setup>
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const { baseUrl } = useRootStore();
|
||||
const type = useUIStore().appliedTheme === 'dark' ? '.dark.png' : '.png';
|
||||
const i18n = useI18n();
|
||||
const googleAuthButtons = {
|
||||
'--google-auth-btn-normal': `url(${baseUrl}static/google-auth/normal${type}`,
|
||||
'--google-auth-btn-focus': `url(${baseUrl}static/google-auth/focus${type}`,
|
||||
'--google-auth-btn-pressed': `url(${baseUrl}static/google-auth/pressed${type}`,
|
||||
'--google-auth-btn-disabled': `url(${baseUrl}static/google-auth/disabled${type}`,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:class="$style.googleAuthBtn"
|
||||
:title="i18n.baseText('credentialEdit.oAuthButton.signInWithGoogle')"
|
||||
:style="googleAuthButtons"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.googleAuthBtn {
|
||||
--google-auth-btn-height: 46px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background-image: var(--google-auth-btn-normal);
|
||||
background-repeat: no-repeat;
|
||||
background-color: transparent;
|
||||
background-size: 100% 100%;
|
||||
border-radius: 4px;
|
||||
height: var(--google-auth-btn-height);
|
||||
// We have to preserve exact google button ratio
|
||||
width: calc(var(--google-auth-btn-height) * 4.15217391);
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
outline: none;
|
||||
background-image: var(--google-auth-btn-focus);
|
||||
}
|
||||
&:active {
|
||||
background-image: var(--google-auth-btn-pressed);
|
||||
}
|
||||
&:disabled {
|
||||
background-image: var(--google-auth-btn-disabled);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import OauthButton from '@/components/CredentialEdit/OauthButton.vue';
|
||||
|
||||
const renderComponent = createComponentRenderer(OauthButton, {
|
||||
pinia: createTestingPinia(),
|
||||
});
|
||||
|
||||
describe('OauthButton', () => {
|
||||
test.each([
|
||||
['GoogleAuthButton', true],
|
||||
['n8n-button', false],
|
||||
])('should emit click event only once when %s is clicked', async (_, isGoogleOAuthType) => {
|
||||
const { emitted, getByRole } = renderComponent({
|
||||
props: { isGoogleOAuthType },
|
||||
});
|
||||
|
||||
const button = getByRole('button');
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(emitted().click).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import GoogleAuthButton from './GoogleAuthButton.vue';
|
||||
|
||||
defineProps<{
|
||||
isGoogleOAuthType: boolean;
|
||||
}>();
|
||||
const i18n = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<GoogleAuthButton v-if="isGoogleOAuthType" />
|
||||
<n8n-button
|
||||
v-else
|
||||
:label="i18n.baseText('credentialEdit.oAuthButton.connectMyAccount')"
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import CredentialEdit from '@/components/CredentialEdit/CredentialEdit.vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { CREDENTIAL_EDIT_MODAL_KEY, STORES } from '@/constants';
|
||||
import { cleanupAppModals, createAppModals, retry } from '@/__tests__/utils';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import type { ICredentialsResponse } from '@/Interface';
|
||||
|
||||
vi.mock('@/permissions', () => ({
|
||||
getResourcePermissions: vi.fn(() => ({
|
||||
credential: {
|
||||
create: true,
|
||||
update: true,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
const renderComponent = createComponentRenderer(CredentialEdit, {
|
||||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.UI]: {
|
||||
modalsById: {
|
||||
[CREDENTIAL_EDIT_MODAL_KEY]: { open: true },
|
||||
},
|
||||
},
|
||||
[STORES.SETTINGS]: {
|
||||
settings: {
|
||||
enterprise: {
|
||||
sharing: true,
|
||||
externalSecrets: false,
|
||||
},
|
||||
templates: {
|
||||
host: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
describe('CredentialEdit', () => {
|
||||
beforeEach(() => {
|
||||
createAppModals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupAppModals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('shows the save button when credentialId is null', async () => {
|
||||
const { queryByTestId } = renderComponent({
|
||||
props: {
|
||||
isTesting: false,
|
||||
isSaving: false,
|
||||
hasUnsavedChanges: false,
|
||||
modalName: CREDENTIAL_EDIT_MODAL_KEY,
|
||||
mode: 'new',
|
||||
},
|
||||
});
|
||||
await retry(() => expect(queryByTestId('credential-save-button')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('hides the save button when credentialId exists and there are no unsaved changes', async () => {
|
||||
const { queryByTestId } = renderComponent({
|
||||
props: {
|
||||
activeId: '123', // credentialId will be set to this value in edit mode
|
||||
isTesting: false,
|
||||
isSaving: false,
|
||||
hasUnsavedChanges: false,
|
||||
modalName: CREDENTIAL_EDIT_MODAL_KEY,
|
||||
mode: 'edit',
|
||||
},
|
||||
});
|
||||
await retry(() => expect(queryByTestId('credential-save-button')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('hides menu item when credential is managed', async () => {
|
||||
const credentialsStore = useCredentialsStore();
|
||||
|
||||
credentialsStore.state.credentials = {
|
||||
'123': {
|
||||
isManaged: false,
|
||||
} as ICredentialsResponse,
|
||||
};
|
||||
|
||||
const { queryByText } = renderComponent({
|
||||
props: {
|
||||
activeId: '123', // credentialId will be set to this value in edit mode
|
||||
isTesting: false,
|
||||
isSaving: false,
|
||||
hasUnsavedChanges: false,
|
||||
modalName: CREDENTIAL_EDIT_MODAL_KEY,
|
||||
mode: 'edit',
|
||||
},
|
||||
});
|
||||
|
||||
await retry(() => expect(queryByText('Details')).toBeInTheDocument());
|
||||
await retry(() => expect(queryByText('Connection')).toBeInTheDocument());
|
||||
await retry(() => expect(queryByText('Sharing')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('shows menu item when credential is not managed', async () => {
|
||||
const credentialsStore = useCredentialsStore();
|
||||
|
||||
credentialsStore.state.credentials = {
|
||||
'123': {
|
||||
isManaged: true,
|
||||
} as ICredentialsResponse,
|
||||
};
|
||||
|
||||
const { queryByText } = renderComponent({
|
||||
props: {
|
||||
activeId: '123', // credentialId will be set to this value in edit mode
|
||||
isTesting: false,
|
||||
isSaving: false,
|
||||
hasUnsavedChanges: false,
|
||||
modalName: CREDENTIAL_EDIT_MODAL_KEY,
|
||||
mode: 'edit',
|
||||
},
|
||||
});
|
||||
|
||||
await retry(() => expect(queryByText('Details')).not.toBeInTheDocument());
|
||||
await retry(() => expect(queryByText('Connection')).not.toBeInTheDocument());
|
||||
await retry(() => expect(queryByText('Sharing')).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { i18n } from '../../plugins/i18n';
|
||||
|
||||
export const CREDENTIAL_MARKDOWN_DOCS: Record<string, string> = {
|
||||
aws: i18n.baseText('credentialEdit.docs.aws'),
|
||||
gmailOAuth2: i18n.baseText('credentialEdit.docs.gmailOAuth2'),
|
||||
openAiApi: i18n.baseText('credentialEdit.docs.openAiApi'),
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { createTestingPinia, type TestingPinia } from '@pinia/testing';
|
||||
import type { ICredentialType, INodeTypeDescription } from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import CredentialIcon from '@/components/CredentialIcon.vue';
|
||||
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { useNodeTypesStore } from '../stores/nodeTypes.store';
|
||||
|
||||
describe('CredentialIcon', () => {
|
||||
const renderComponent = createComponentRenderer(CredentialIcon, {
|
||||
pinia: createTestingPinia(),
|
||||
global: {
|
||||
stubs: ['n8n-tooltip'],
|
||||
},
|
||||
});
|
||||
let pinia: TestingPinia;
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createTestingPinia({ stubActions: false });
|
||||
});
|
||||
|
||||
it('shows correct icon when iconUrl is set on credential', () => {
|
||||
const testIconUrl = 'icons/n8n-nodes-base/dist/nodes/Test/test.svg';
|
||||
useCredentialsStore().setCredentialTypes([
|
||||
mock<ICredentialType>({
|
||||
name: 'test',
|
||||
iconUrl: testIconUrl,
|
||||
}),
|
||||
]);
|
||||
|
||||
const { getByRole } = renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
credentialTypeName: 'test',
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByRole('img')).toHaveAttribute('src', useRootStore().baseUrl + testIconUrl);
|
||||
});
|
||||
|
||||
it('shows correct icon when icon is set on credential', () => {
|
||||
useCredentialsStore().setCredentialTypes([
|
||||
mock<ICredentialType>({
|
||||
name: 'test',
|
||||
icon: 'fa:clock',
|
||||
iconColor: 'azure',
|
||||
}),
|
||||
]);
|
||||
|
||||
const { getByRole } = renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
credentialTypeName: 'test',
|
||||
},
|
||||
});
|
||||
|
||||
const icon = getByRole('img', { hidden: true });
|
||||
expect(icon.tagName).toBe('svg');
|
||||
expect(icon).toHaveClass('fa-clock');
|
||||
});
|
||||
|
||||
it('shows correct icon when credential has an icon with node: prefix', () => {
|
||||
const testIconUrl = 'icons/n8n-nodes-base/dist/nodes/Test/test.svg';
|
||||
useCredentialsStore().setCredentialTypes([
|
||||
mock<ICredentialType>({
|
||||
name: 'test',
|
||||
icon: 'node:n8n-nodes-base.test',
|
||||
iconColor: 'azure',
|
||||
}),
|
||||
]);
|
||||
|
||||
useNodeTypesStore().setNodeTypes([
|
||||
mock<INodeTypeDescription>({
|
||||
version: 1,
|
||||
name: 'n8n-nodes-base.test',
|
||||
iconUrl: testIconUrl,
|
||||
}),
|
||||
]);
|
||||
|
||||
const { getByRole } = renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
credentialTypeName: 'test',
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByRole('img')).toHaveAttribute('src', useRootStore().baseUrl + testIconUrl);
|
||||
});
|
||||
|
||||
it('shows fallback icon when icon is not found', () => {
|
||||
useCredentialsStore().setCredentialTypes([
|
||||
mock<ICredentialType>({
|
||||
name: 'test',
|
||||
icon: 'node:n8n-nodes-base.test',
|
||||
iconColor: 'azure',
|
||||
}),
|
||||
]);
|
||||
|
||||
const { baseElement } = renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
credentialTypeName: 'test',
|
||||
},
|
||||
});
|
||||
|
||||
expect(baseElement.querySelector('.nodeIconPlaceholder')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
102
packages/frontend/editor-ui/src/components/CredentialIcon.vue
Normal file
102
packages/frontend/editor-ui/src/components/CredentialIcon.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { getThemedValue } from '@/utils/nodeTypesUtils';
|
||||
import { N8nNodeIcon } from '@n8n/design-system';
|
||||
import type { ICredentialType } from 'n8n-workflow';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
credentialTypeName: string | null;
|
||||
}>();
|
||||
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const rootStore = useRootStore();
|
||||
const uiStore = useUIStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
|
||||
const credentialWithIcon = computed(() => getCredentialWithIcon(props.credentialTypeName));
|
||||
|
||||
const nodeBasedIconUrl = computed(() => {
|
||||
const icon = getThemedValue(credentialWithIcon.value?.icon);
|
||||
if (!icon?.startsWith('node:')) return null;
|
||||
return nodeTypesStore.getNodeType(icon.replace('node:', ''))?.iconUrl;
|
||||
});
|
||||
|
||||
const iconSource = computed(() => {
|
||||
const themeIconUrl = getThemedValue(
|
||||
nodeBasedIconUrl.value ?? credentialWithIcon.value?.iconUrl,
|
||||
uiStore.appliedTheme,
|
||||
);
|
||||
|
||||
if (!themeIconUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return rootStore.baseUrl + themeIconUrl;
|
||||
});
|
||||
|
||||
const iconType = computed(() => {
|
||||
if (iconSource.value) return 'file';
|
||||
else if (iconName.value) return 'icon';
|
||||
return 'unknown';
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
const icon = getThemedValue(credentialWithIcon.value?.icon, uiStore.appliedTheme);
|
||||
if (!icon || !icon?.startsWith('fa:')) return undefined;
|
||||
return icon.replace('fa:', '');
|
||||
});
|
||||
|
||||
const iconColor = computed(() => {
|
||||
const { iconColor: color } = credentialWithIcon.value ?? {};
|
||||
if (!color) return undefined;
|
||||
return `var(--color-node-icon-${color})`;
|
||||
});
|
||||
|
||||
function getCredentialWithIcon(name: string | null): ICredentialType | null {
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = credentialsStore.getCredentialTypeByName(name);
|
||||
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type.icon ?? type.iconUrl) {
|
||||
return type;
|
||||
}
|
||||
|
||||
if (type.extends) {
|
||||
let parentCred = null;
|
||||
type.extends.forEach((credType) => {
|
||||
parentCred = getCredentialWithIcon(credType);
|
||||
if (parentCred !== null) return;
|
||||
});
|
||||
return parentCred;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nNodeIcon
|
||||
:class="$style.icon"
|
||||
:type="iconType"
|
||||
:size="26"
|
||||
:src="iconSource"
|
||||
:name="iconName"
|
||||
:color="iconColor"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.icon {
|
||||
--node-icon-color: var(--color-foreground-dark);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,276 @@
|
||||
import type { ICredentialMap, ICredentialTypeMap } from '@/Interface';
|
||||
|
||||
export const TEST_CREDENTIALS: ICredentialMap = {
|
||||
// OpenAI credential in personal
|
||||
1: {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
id: '1',
|
||||
name: 'OpenAi account',
|
||||
data: 'test123',
|
||||
type: 'openAiApi',
|
||||
isManaged: false,
|
||||
homeProject: {
|
||||
id: '1',
|
||||
type: 'personal',
|
||||
name: 'Kobi Dog <kobi@n8n.io>',
|
||||
icon: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
sharedWithProjects: [
|
||||
{
|
||||
id: '2',
|
||||
type: 'team',
|
||||
name: 'Test Project',
|
||||
icon: { type: 'icon', value: 'exchange-alt' },
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
scopes: [
|
||||
'credential:create',
|
||||
'credential:delete',
|
||||
'credential:list',
|
||||
'credential:move',
|
||||
'credential:read',
|
||||
'credential:share',
|
||||
'credential:update',
|
||||
],
|
||||
},
|
||||
// Supabase credential in another project
|
||||
2: {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
id: '2',
|
||||
name: 'Supabase account',
|
||||
data: 'test123',
|
||||
type: 'supabaseApi',
|
||||
isManaged: false,
|
||||
homeProject: {
|
||||
id: '2',
|
||||
type: 'team',
|
||||
name: 'Test Project',
|
||||
icon: { type: 'icon', value: 'exchange-alt' },
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
sharedWithProjects: [],
|
||||
scopes: [
|
||||
'credential:create',
|
||||
'credential:delete',
|
||||
'credential:list',
|
||||
'credential:move',
|
||||
'credential:read',
|
||||
'credential:share',
|
||||
'credential:update',
|
||||
],
|
||||
},
|
||||
// Slack account in personal
|
||||
3: {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
id: '3',
|
||||
name: 'Slack account',
|
||||
data: 'test123',
|
||||
type: 'slackOAuth2Api',
|
||||
isManaged: false,
|
||||
homeProject: {
|
||||
id: '1',
|
||||
type: 'personal',
|
||||
name: 'Kobi Dog <kobi@n8n.io>',
|
||||
icon: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
sharedWithProjects: [],
|
||||
scopes: [
|
||||
'credential:create',
|
||||
'credential:delete',
|
||||
'credential:list',
|
||||
'credential:move',
|
||||
'credential:read',
|
||||
'credential:share',
|
||||
'credential:update',
|
||||
],
|
||||
},
|
||||
// OpenAI credential in another project
|
||||
4: {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
id: '4',
|
||||
name: '[PROJECT] OpenAI Account',
|
||||
data: 'test123',
|
||||
type: 'openAiApi',
|
||||
isManaged: false,
|
||||
homeProject: {
|
||||
id: '2',
|
||||
type: 'team',
|
||||
name: 'Test Project',
|
||||
icon: { type: 'icon', value: 'exchange-alt' },
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
sharedWithProjects: [],
|
||||
scopes: [
|
||||
'credential:create',
|
||||
'credential:delete',
|
||||
'credential:list',
|
||||
'credential:move',
|
||||
'credential:read',
|
||||
'credential:share',
|
||||
'credential:update',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const TEST_CREDENTIAL_TYPES: ICredentialTypeMap = {
|
||||
openAiApi: {
|
||||
name: 'openAiApi',
|
||||
displayName: 'OpenAi',
|
||||
documentationUrl: 'openAi',
|
||||
properties: [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: {
|
||||
password: true,
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Base URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
default: 'https://api.openai.com/v1',
|
||||
description: 'Override the base URL for the API',
|
||||
},
|
||||
],
|
||||
authenticate: {
|
||||
type: 'generic',
|
||||
properties: {
|
||||
headers: {
|
||||
Authorization: '=Bearer {{$credentials.apiKey}}',
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
request: {
|
||||
baseURL: '={{$credentials?.url}}',
|
||||
url: '/models',
|
||||
},
|
||||
},
|
||||
iconUrl: {
|
||||
light: 'icons/n8n-nodes-base/dist/nodes/OpenAi/openai.svg',
|
||||
dark: 'icons/n8n-nodes-base/dist/nodes/OpenAi/openai.dark.svg',
|
||||
},
|
||||
supportedNodes: [
|
||||
'n8n-nodes-base.openAi',
|
||||
'@n8n/n8n-nodes-langchain.embeddingsOpenAi',
|
||||
'@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
'@n8n/n8n-nodes-langchain.lmOpenAi',
|
||||
],
|
||||
},
|
||||
supabaseApi: {
|
||||
name: 'supabaseApi',
|
||||
displayName: 'Supabase API',
|
||||
documentationUrl: 'supabase',
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Host',
|
||||
name: 'host',
|
||||
type: 'string',
|
||||
placeholder: 'https://your_account.supabase.co',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Service Role Secret',
|
||||
name: 'serviceRole',
|
||||
type: 'string',
|
||||
default: '',
|
||||
typeOptions: {
|
||||
password: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
authenticate: {
|
||||
type: 'generic',
|
||||
properties: {
|
||||
headers: {
|
||||
apikey: '={{$credentials.serviceRole}}',
|
||||
Authorization: '=Bearer {{$credentials.serviceRole}}',
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
request: {
|
||||
baseURL: '={{$credentials.host}}/rest/v1',
|
||||
headers: {
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
url: '/',
|
||||
},
|
||||
},
|
||||
iconUrl: 'icons/n8n-nodes-base/dist/nodes/Supabase/supabase.svg',
|
||||
supportedNodes: ['n8n-nodes-base.supabase'],
|
||||
},
|
||||
slackOAuth2Api: {
|
||||
name: 'slackOAuth2Api',
|
||||
extends: ['oAuth2Api'],
|
||||
displayName: 'Slack OAuth2 API',
|
||||
documentationUrl: 'slack',
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Grant Type',
|
||||
name: 'grantType',
|
||||
type: 'hidden',
|
||||
default: 'authorizationCode',
|
||||
},
|
||||
{
|
||||
displayName: 'Authorization URL',
|
||||
name: 'authUrl',
|
||||
type: 'hidden',
|
||||
default: 'https://slack.com/oauth/v2/authorize',
|
||||
},
|
||||
{
|
||||
displayName: 'Access Token URL',
|
||||
name: 'accessTokenUrl',
|
||||
type: 'hidden',
|
||||
default: 'https://slack.com/api/oauth.v2.access',
|
||||
},
|
||||
{
|
||||
displayName: 'Scope',
|
||||
name: 'scope',
|
||||
type: 'hidden',
|
||||
default: 'chat:write',
|
||||
},
|
||||
{
|
||||
displayName: 'Auth URI Query Parameters',
|
||||
name: 'authQueryParameters',
|
||||
type: 'hidden',
|
||||
default:
|
||||
'user_scope=channels:read channels:write chat:write files:read files:write groups:read im:read mpim:read reactions:read reactions:write stars:read stars:write usergroups:write usergroups:read users.profile:read users.profile:write users:read',
|
||||
},
|
||||
{
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'hidden',
|
||||
default: 'body',
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'If you get an Invalid Scopes error, make sure you add the correct one <a target="_blank" href="https://docs.n8n.io/integrations/builtin/credentials/slack/#using-oauth">here</a> to your Slack integration',
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
iconUrl: 'icons/n8n-nodes-base/dist/nodes/Slack/slack.svg',
|
||||
supportedNodes: ['n8n-nodes-base.slack'],
|
||||
},
|
||||
};
|
||||
|
||||
export const PERSONAL_OPENAI_CREDENTIAL = TEST_CREDENTIALS[1];
|
||||
export const PROJECT_OPENAI_CREDENTIAL = TEST_CREDENTIALS[4];
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import CredentialPicker from './CredentialPicker.vue';
|
||||
import {
|
||||
PERSONAL_OPENAI_CREDENTIAL,
|
||||
PROJECT_OPENAI_CREDENTIAL,
|
||||
TEST_CREDENTIAL_TYPES,
|
||||
TEST_CREDENTIALS,
|
||||
} from './CredentialPicker.test.constants';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { screen } from '@testing-library/vue';
|
||||
|
||||
vi.mock('vue-router', () => {
|
||||
const push = vi.fn();
|
||||
const resolve = vi.fn().mockReturnValue({ href: 'https://test.com' });
|
||||
return {
|
||||
useRouter: () => ({
|
||||
push,
|
||||
resolve,
|
||||
}),
|
||||
useRoute: () => ({}),
|
||||
RouterLink: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
let credentialsStore: ReturnType<typeof mockedStore<typeof useCredentialsStore>>;
|
||||
|
||||
const renderComponent = createComponentRenderer(CredentialPicker);
|
||||
|
||||
describe('CredentialPicker', () => {
|
||||
beforeEach(() => {
|
||||
createTestingPinia();
|
||||
credentialsStore = mockedStore(useCredentialsStore);
|
||||
credentialsStore.state.credentials = TEST_CREDENTIALS;
|
||||
credentialsStore.state.credentialTypes = TEST_CREDENTIAL_TYPES;
|
||||
});
|
||||
|
||||
it('should render', () => {
|
||||
expect(() =>
|
||||
renderComponent({
|
||||
props: {
|
||||
appName: 'OpenAI',
|
||||
credentialType: 'openAiApi',
|
||||
selectedCredentialId: null,
|
||||
},
|
||||
}),
|
||||
).not.toThrowError();
|
||||
});
|
||||
|
||||
it('should only render personal credentials of the specified type', async () => {
|
||||
const TEST_APP_NAME = 'OpenAI';
|
||||
const TEST_CREDENTIAL_TYPE = 'openAiApi';
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
appName: TEST_APP_NAME,
|
||||
credentialType: TEST_CREDENTIAL_TYPE,
|
||||
selectedCredentialId: null,
|
||||
},
|
||||
});
|
||||
expect(getByTestId('credential-dropdown')).toBeInTheDocument();
|
||||
expect(getByTestId('credential-dropdown')).toHaveAttribute(
|
||||
'credential-type',
|
||||
TEST_CREDENTIAL_TYPE,
|
||||
);
|
||||
// Open the dropdown
|
||||
await userEvent.click(getByTestId('credential-dropdown'));
|
||||
// Personal openAI credential should be in the dropdown
|
||||
expect(
|
||||
screen.getByTestId(`node-credentials-select-item-${PERSONAL_OPENAI_CREDENTIAL.id}`),
|
||||
).toBeInTheDocument();
|
||||
// OpenAI credential that belong to other project should not be in the dropdown
|
||||
expect(
|
||||
screen.queryByTestId(`node-credentials-select-item-${PROJECT_OPENAI_CREDENTIAL.id}`),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user