fix(editor): Fix broken types for globally defined components (no-changelog) (#16505)

Co-authored-by: Mutasem Aldmour <mutasem@n8n.io>
This commit is contained in:
Alex Grozav
2025-06-24 14:01:23 +03:00
committed by GitHub
parent 21ff173070
commit 20c63436d2
150 changed files with 1332 additions and 960 deletions

View File

@@ -8,6 +8,7 @@
"files": {
"ignore": [
"**/.turbo",
"**/components.d.ts",
"**/coverage",
"**/dist",
"**/package.json",

View File

@@ -26,3 +26,6 @@ coverage
*.njsproj
*.sln
*.sw?
# Auto-generated files
src/components.d.ts

View File

@@ -12,8 +12,8 @@
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . --ext .js,.ts,.vue --quiet",
"lintfix": "eslint . --ext .js,.ts,.vue --fix",
"format": "biome format --write src .storybook && prettier --write src/ --ignore-path ../../.prettierignore",
"format:check": "biome ci src .storybook && prettier --check src/ --ignore-path ../../.prettierignore",
"format": "biome format --write src .storybook && prettier --write src/ --ignore-path ../../../../.prettierignore",
"format:check": "biome ci src .storybook && prettier --check src/ --ignore-path ../../../../.prettierignore",
"storybook": "storybook dev -p 6006 --no-open",
"build:storybook": "storybook build"
},
@@ -36,6 +36,7 @@
}
},
"dependencies": {
"@n8n/design-system": "workspace:*",
"@vueuse/core": "catalog:frontend",
"highlight.js": "catalog:frontend",
"markdown-it-link-attributes": "^4.0.1",

View File

@@ -4,7 +4,8 @@ import hljsJavascript from 'highlight.js/lib/languages/javascript';
import hljsXML from 'highlight.js/lib/languages/xml';
import { computed, onMounted } from 'vue';
import { Chat, ChatWindow } from '@n8n/chat/components';
import Chat from '@n8n/chat/components/Chat.vue';
import ChatWindow from '@n8n/chat/components/ChatWindow.vue';
import { useOptions } from '@n8n/chat/composables';
defineProps({});

View File

@@ -105,7 +105,7 @@ onMounted(async () => {
<template>
<div ref="messageContainer" class="chat-message" :class="classes">
<div v-if="$slots.beforeMessage" class="chat-message-actions">
<div v-if="!!$slots.beforeMessage" class="chat-message-actions">
<slot name="beforeMessage" v-bind="{ message }" />
</div>
<slot>

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { N8nIcon, N8nText } from '@n8n/design-system';
import { ref, watch } from 'vue';
import Message from '@n8n/chat/components/Message.vue';

View File

@@ -1 +1,17 @@
/// <reference types="vite/client" />
declare module 'markdown-it-task-lists' {
import type { PluginWithOptions } from 'markdown-it';
declare namespace markdownItTaskLists {
interface Config {
enabled?: boolean;
label?: boolean;
labelAfter?: boolean;
}
}
declare const markdownItTaskLists: PluginWithOptions<markdownItTaskLists.Config>;
export = markdownItTaskLists;
}

View File

@@ -1,6 +0,0 @@
declare module '*.vue' {
import { defineComponent } from 'vue';
const component: ReturnType<typeof defineComponent>;
export default component;
}

View File

@@ -6,6 +6,7 @@
"baseUrl": "src",
"target": "esnext",
"module": "esnext",
"moduleResolution": "bundler",
"allowJs": true,
"importHelpers": true,
"incremental": false,
@@ -13,7 +14,8 @@
"resolveJsonModule": true,
"types": ["vitest/globals"],
"paths": {
"@n8n/chat/*": ["./*"]
"@n8n/chat/*": ["./*"],
"@n8n/design-system*": ["../../design-system/src*"]
},
"lib": ["esnext", "dom", "dom.iterable", "scripthost"],
// TODO: remove all options below this line

View File

@@ -5,9 +5,12 @@ import vue from '@vitejs/plugin-vue';
import icons from 'unplugin-icons/vite';
import dts from 'vite-plugin-dts';
import { vitestConfig } from '@n8n/vitest-config/frontend';
import iconsResolver from 'unplugin-icons/resolver';
import components from 'unplugin-vue-components/vite';
const includeVue = process.env.INCLUDE_VUE === 'true';
const srcPath = resolve(__dirname, 'src');
const packagesDir = resolve(__dirname, '..', '..', '..');
// https://vitejs.dev/config/
export default mergeConfig(
@@ -19,6 +22,18 @@ export default mergeConfig(
autoInstall: true,
}),
dts(),
components({
dts: './src/components.d.ts',
resolvers: [
(componentName) => {
if (componentName.startsWith('N8n'))
return { name: componentName, from: '@n8n/design-system' };
},
iconsResolver({
prefix: 'icon',
}),
],
}),
{
name: 'rename-css-file',
closeBundle() {
@@ -36,10 +51,24 @@ export default mergeConfig(
},
],
resolve: {
alias: {
'@': srcPath,
'@n8n/chat': srcPath,
},
alias: [
{
find: '@',
replacement: srcPath,
},
{
find: '@n8n/chat',
replacement: srcPath,
},
{
find: /^@n8n\/chat(.+)$/,
replacement: srcPath + '$1',
},
{
find: /^@n8n\/design-system(.+)$/,
replacement: resolve(packagesDir, 'frontend', '@n8n', 'design-system', 'src$1'),
},
],
},
define: {
'process.env.NODE_ENV': process.env.NODE_ENV ? `"${process.env.NODE_ENV}"` : '"development"',

View File

@@ -16,6 +16,7 @@ module.exports = {
'@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/prefer-optional-chain': 'warn',
'@typescript-eslint/prefer-nullish-coalescing': 'warn',
'vue/no-undef-components': 'error',
},
overrides: [

View File

@@ -8,6 +8,7 @@
"clean": "rimraf dist .turbo",
"build": "vite build",
"typecheck": "vue-tsc --noEmit",
"typecheck:watch": "vue-tsc --watch --noEmit",
"test": "vitest run",
"test:dev": "vitest",
"build:storybook": "storybook build",

View File

@@ -18,6 +18,9 @@ import AssistantLoadingMessage from '../AskAssistantLoadingMessage/AssistantLoad
import AssistantText from '../AskAssistantText/AssistantText.vue';
import BetaTag from '../BetaTag/BetaTag.vue';
import InlineAskAssistantButton from '../InlineAskAssistantButton/InlineAskAssistantButton.vue';
import N8nButton from '../N8nButton';
import N8nIcon from '../N8nIcon';
import N8nIconButton from '../N8nIconButton';
const { t } = useI18n();
@@ -124,7 +127,7 @@ function onSubmitFeedback(feedback: string) {
<slot name="header" />
</div>
<div :class="$style.back" data-test-id="close-chat-button" @click="onClose">
<n8n-icon icon="arrow-right" color="text-base" />
<N8nIcon icon="arrow-right" color="text-base" />
</div>
</div>
<div :class="$style.body">
@@ -222,14 +225,14 @@ function onSubmitFeedback(feedback: string) {
{{ t('assistantChat.quickRepliesTitle') }}
</div>
<div v-for="opt in message.quickReplies" :key="opt.type" data-test-id="quick-replies">
<n8n-button
<N8nButton
v-if="opt.text"
type="secondary"
size="mini"
@click="() => onQuickReply(opt)"
>
{{ opt.text }}
</n8n-button>
</N8nButton>
</div>
</div>
</data>
@@ -289,10 +292,10 @@ function onSubmitFeedback(feedback: string) {
@input.prevent="growInput"
@keydown.stop
/>
<n8n-icon-button
<N8nIconButton
:class="{ [$style.sendButton]: true }"
icon="paper-plane"
type="text"
:text="true"
size="large"
data-test-id="send-message-button"
:disabled="sendDisabled"

View File

@@ -69,6 +69,8 @@ exports[`AskAssistantChat > does not render retry button if no error is present
<n8n-icon-stub
color="text-base"
icon="arrow-right"
size="medium"
spin="false"
/>
</div>
</div>
@@ -175,12 +177,16 @@ exports[`AskAssistantChat > does not render retry button if no error is present
wrap="hard"
/>
<n8n-icon-button-stub
active="false"
class="sendButton"
data-test-id="send-message-button"
disabled="true"
icon="paper-plane"
loading="false"
outline="false"
size="large"
type="text"
text="true"
type="primary"
/>
</div>
@@ -257,6 +263,8 @@ exports[`AskAssistantChat > renders chat with messages correctly 1`] = `
<n8n-icon-stub
color="text-base"
icon="arrow-right"
size="medium"
spin="false"
/>
</div>
</div>
@@ -601,11 +609,18 @@ exports[`AskAssistantChat > renders chat with messages correctly 1`] = `
class="actions"
>
<n8n-button-stub
active="false"
block="false"
data-test-id="replace-code-button"
disabled="false"
element="button"
icon="refresh"
label=""
loading="false"
outline="false"
size="mini"
square="false"
text="false"
type="primary"
/>
</div>
@@ -625,8 +640,9 @@ exports[`AskAssistantChat > renders chat with messages correctly 1`] = `
>
<n8n-avatar-stub
first-name="Kobi"
last-name="Dog"
colors="--color-primary,--color-secondary,--color-avatar-accent-1,--color-avatar-accent-2,--color-primary-tint-1"
firstname="Kobi"
lastname="Dog"
size="xsmall"
/>
<span>
@@ -897,11 +913,18 @@ Testing more code
class="actions"
>
<n8n-button-stub
active="false"
block="false"
data-test-id="replace-code-button"
disabled="false"
element="button"
icon="refresh"
label=""
loading="false"
outline="false"
size="mini"
square="false"
text="false"
type="primary"
/>
</div>
@@ -921,7 +944,16 @@ Testing more code
data-test-id="quick-replies"
>
<n8n-button-stub
active="false"
block="false"
disabled="false"
element="button"
label=""
loading="false"
outline="false"
size="mini"
square="false"
text="false"
type="secondary"
/>
</div>
@@ -929,7 +961,16 @@ Testing more code
data-test-id="quick-replies"
>
<n8n-button-stub
active="false"
block="false"
disabled="false"
element="button"
label=""
loading="false"
outline="false"
size="mini"
square="false"
text="false"
type="secondary"
/>
</div>
@@ -954,12 +995,16 @@ Testing more code
wrap="hard"
/>
<n8n-icon-button-stub
active="false"
class="sendButton"
data-test-id="send-message-button"
disabled="true"
icon="paper-plane"
loading="false"
outline="false"
size="large"
type="text"
text="true"
type="primary"
/>
</div>
@@ -1036,6 +1081,8 @@ exports[`AskAssistantChat > renders default placeholder chat correctly 1`] = `
<n8n-icon-stub
color="text-base"
icon="arrow-right"
size="medium"
spin="false"
/>
</div>
</div>
@@ -1135,12 +1182,16 @@ exports[`AskAssistantChat > renders default placeholder chat correctly 1`] = `
wrap="hard"
/>
<n8n-icon-button-stub
active="false"
class="sendButton"
data-test-id="send-message-button"
disabled="true"
icon="paper-plane"
loading="false"
outline="false"
size="large"
type="text"
text="true"
type="primary"
/>
</div>
@@ -1217,6 +1268,8 @@ exports[`AskAssistantChat > renders end of session chat correctly 1`] = `
<n8n-icon-stub
color="text-base"
icon="arrow-right"
size="medium"
spin="false"
/>
</div>
</div>
@@ -1400,12 +1453,16 @@ exports[`AskAssistantChat > renders end of session chat correctly 1`] = `
wrap="hard"
/>
<n8n-icon-button-stub
active="false"
class="sendButton"
data-test-id="send-message-button"
disabled="true"
icon="paper-plane"
loading="false"
outline="false"
size="large"
type="text"
text="true"
type="primary"
/>
</div>
@@ -1482,6 +1539,8 @@ exports[`AskAssistantChat > renders error message correctly with retry button 1`
<n8n-icon-stub
color="text-base"
icon="arrow-right"
size="medium"
spin="false"
/>
</div>
</div>
@@ -1558,13 +1617,23 @@ exports[`AskAssistantChat > renders error message correctly with retry button 1`
class="errorIcon"
icon="exclamation-triangle"
size="small"
spin="false"
/>
This is an error message.
</p>
<n8n-button-stub
active="false"
block="false"
class="retryButton"
data-test-id="error-retry-button"
disabled="false"
element="button"
label=""
loading="false"
outline="false"
size="mini"
square="false"
text="false"
type="secondary"
/>
</div>
@@ -1590,12 +1659,16 @@ exports[`AskAssistantChat > renders error message correctly with retry button 1`
wrap="hard"
/>
<n8n-icon-button-stub
active="false"
class="sendButton"
data-test-id="send-message-button"
disabled="true"
icon="paper-plane"
loading="false"
outline="false"
size="large"
type="text"
text="true"
type="primary"
/>
</div>
@@ -1672,6 +1745,8 @@ exports[`AskAssistantChat > renders message with code snippet 1`] = `
<n8n-icon-stub
color="text-base"
icon="arrow-right"
size="medium"
spin="false"
/>
</div>
</div>
@@ -1848,12 +1923,16 @@ catch(e) {
wrap="hard"
/>
<n8n-icon-button-stub
active="false"
class="sendButton"
data-test-id="send-message-button"
disabled="true"
icon="paper-plane"
loading="false"
outline="false"
size="large"
type="text"
text="true"
type="primary"
/>
</div>
@@ -1930,6 +2009,8 @@ exports[`AskAssistantChat > renders streaming chat correctly 1`] = `
<n8n-icon-stub
color="text-base"
icon="arrow-right"
size="medium"
spin="false"
/>
</div>
</div>
@@ -2039,12 +2120,16 @@ exports[`AskAssistantChat > renders streaming chat correctly 1`] = `
wrap="hard"
/>
<n8n-icon-button-stub
active="false"
class="sendButton"
data-test-id="send-message-button"
disabled="true"
icon="paper-plane"
loading="false"
outline="false"
size="large"
type="text"
text="true"
type="primary"
/>
</div>

View File

@@ -4,6 +4,7 @@ import { computed } from 'vue';
import { useI18n } from '../../../composables/useI18n';
import type { ChatUI } from '../../../types/assistant';
import AssistantAvatar from '../../AskAssistantAvatar/AssistantAvatar.vue';
import N8nAvatar from '../../N8nAvatar';
interface Props {
message: ChatUI.AssistantMessage;
@@ -27,7 +28,7 @@ const isUserMessage = computed(() => props.message.role === 'user');
:class="{ [$style.roleName]: true, [$style.userSection]: !isUserMessage }"
>
<template v-if="isUserMessage">
<n8n-avatar :first-name="user?.firstName" :last-name="user?.lastName" size="xsmall" />
<N8nAvatar :first-name="user?.firstName" :last-name="user?.lastName" size="xsmall" />
<span>{{ t('assistantChat.you') }}</span>
</template>
<template v-else>

View File

@@ -2,6 +2,8 @@
import BaseMessage from './BaseMessage.vue';
import { useI18n } from '../../../composables/useI18n';
import type { ChatUI } from '../../../types/assistant';
import N8nButton from '../../N8nButton';
import N8nIcon from '../../N8nIcon';
interface Props {
message: ChatUI.ErrorMessage & { id: string; read: boolean };
@@ -20,10 +22,10 @@ const { t } = useI18n();
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<div :class="$style.error" data-test-id="chat-message-system">
<p :class="$style.errorText">
<n8n-icon icon="exclamation-triangle" size="small" :class="$style.errorIcon" />
<N8nIcon icon="exclamation-triangle" size="small" :class="$style.errorIcon" />
{{ message.content }}
</p>
<n8n-button
<N8nButton
v-if="message.retry"
type="secondary"
size="mini"
@@ -32,7 +34,7 @@ const { t } = useI18n();
@click="() => message.retry?.()"
>
{{ t('generic.retry') }}
</n8n-button>
</N8nButton>
</div>
</BaseMessage>
</template>

View File

@@ -6,6 +6,7 @@ import { useMarkdown } from './useMarkdown';
import { useI18n } from '../../../composables/useI18n';
import type { ChatUI } from '../../../types/assistant';
import BlinkingCursor from '../../BlinkingCursor/BlinkingCursor.vue';
import N8nButton from '../../N8nButton';
interface Props {
message: ChatUI.TextMessage & { id: string; read: boolean; quickReplies?: ChatUI.QuickReply[] };
@@ -55,15 +56,15 @@ async function onCopyButtonClick(content: string, e: MouseEvent) {
data-test-id="assistant-code-snippet"
>
<header v-if="isClipboardSupported">
<n8n-button
<N8nButton
type="tertiary"
text="true"
:text="true"
size="mini"
data-test-id="assistant-copy-snippet-button"
@click="onCopyButtonClick(message.codeSnippet, $event)"
>
{{ t('assistantChat.copy') }}
</n8n-button>
</N8nButton>
</header>
<div
v-n8n-html="renderMarkdown(message.codeSnippet).trim()"

View File

@@ -5,6 +5,8 @@ import { useI18n } from '@n8n/design-system/composables/useI18n';
import BaseWorkflowMessage from './BaseWorkflowMessage.vue';
import type { ChatUI } from '../../../../types/assistant';
import N8nButton from '../../../N8nButton';
import N8nInput from '../../../N8nInput';
interface Props {
message: ChatUI.RateWorkflowMessage & { id: string; read: boolean };
@@ -48,7 +50,7 @@ function onSubmitFeedback() {
<div :class="$style.content">
<p v-if="!showSuccess">{{ message.content }}</p>
<div v-if="!showFeedback && !showSuccess" :class="$style.buttons">
<n8n-button
<N8nButton
type="secondary"
size="small"
:label="t('assistantChat.builder.thumbsUp')"
@@ -56,7 +58,7 @@ function onSubmitFeedback() {
icon="thumbs-up"
@click="onRateButton('thumbsUp')"
/>
<n8n-button
<N8nButton
type="secondary"
size="small"
data-test-id="message-thumbs-down-button"
@@ -66,7 +68,7 @@ function onSubmitFeedback() {
/>
</div>
<div v-if="showFeedback" :class="$style.feedbackTextArea">
<n8n-input
<N8nInput
v-model="feedback"
:class="$style.feedbackInput"
type="textarea"
@@ -77,7 +79,7 @@ function onSubmitFeedback() {
:rows="5"
/>
<div :class="$style.feedbackTextArea__footer">
<n8n-button
<N8nButton
native-type="submit"
type="secondary"
size="small"
@@ -85,7 +87,7 @@ function onSubmitFeedback() {
@click="onSubmitFeedback"
>
{{ t('assistantChat.builder.submit') }}
</n8n-button>
</N8nButton>
</div>
</div>

View File

@@ -4,6 +4,9 @@ import { computed } from 'vue';
import { useI18n } from '@n8n/design-system/composables/useI18n';
import N8nButton from '../N8nButton';
import N8nIcon from '../N8nIcon';
const MIN_LINES = 4;
interface Props {
@@ -106,11 +109,11 @@ const diffs = computed(() => {
</div>
<div :class="$style.actions">
<div v-if="error">
<n8n-icon icon="exclamation-triangle" color="danger" class="mr-5xs" />
<N8nIcon icon="exclamation-triangle" color="danger" class="mr-5xs" />
<span :class="$style.infoText">{{ t('codeDiff.couldNotReplace') }}</span>
</div>
<div v-else-if="replaced">
<n8n-button
<N8nButton
type="secondary"
size="mini"
icon="undo"
@@ -118,13 +121,13 @@ const diffs = computed(() => {
@click="() => emit('undo')"
>
{{ t('codeDiff.undo') }}
</n8n-button>
<n8n-icon icon="check" color="success" class="ml-xs" />
</N8nButton>
<N8nIcon icon="check" color="success" class="ml-xs" />
<span :class="$style.infoText" data-test-id="code-replaced-message">
{{ t('codeDiff.codeReplaced') }}
</span>
</div>
<n8n-button
<N8nButton
v-else
:type="replacing ? 'secondary' : 'primary'"
size="mini"
@@ -133,7 +136,7 @@ const diffs = computed(() => {
:disabled="!content || streaming"
:loading="replacing"
@click="() => emit('replace')"
>{{ replacing ? t('codeDiff.replacing') : t('codeDiff.replaceMyCode') }}</n8n-button
>{{ replacing ? t('codeDiff.replacing') : t('codeDiff.replaceMyCode') }}</N8nButton
>
</div>
</div>

View File

@@ -271,11 +271,18 @@ exports[`CodeDiff > renders code diff correctly 1`] = `
class="actions"
>
<n8n-button-stub
active="false"
block="false"
data-test-id="replace-code-button"
disabled="false"
element="button"
icon="refresh"
label=""
loading="false"
outline="false"
size="mini"
square="false"
text="false"
type="primary"
/>
</div>
@@ -537,6 +544,8 @@ exports[`CodeDiff > renders error state correctly 1`] = `
class="mr-5xs"
color="danger"
icon="exclamation-triangle"
size="medium"
spin="false"
/>
<span
class="infoText"
@@ -800,15 +809,26 @@ exports[`CodeDiff > renders replaced code diff correctly 1`] = `
>
<div>
<n8n-button-stub
active="false"
block="false"
data-test-id="undo-replace-button"
disabled="false"
element="button"
icon="undo"
label=""
loading="false"
outline="false"
size="mini"
square="false"
text="false"
type="secondary"
/>
<n8n-icon-stub
class="ml-xs"
color="success"
icon="check"
size="medium"
spin="false"
/>
<span
class="infoText"
@@ -1072,11 +1092,18 @@ exports[`CodeDiff > renders replacing code diff correctly 1`] = `
class="actions"
>
<n8n-button-stub
active="false"
block="false"
data-test-id="replace-code-button"
disabled="false"
element="button"
icon="refresh"
label=""
loading="true"
outline="false"
size="mini"
square="false"
text="false"
type="secondary"
/>
</div>

View File

@@ -8,13 +8,13 @@ import N8nHeading from '../N8nHeading';
import N8nText from '../N8nText';
interface ActionBoxProps {
emoji: string;
heading: string;
emoji?: string;
heading?: string;
buttonText?: string;
buttonType?: ButtonType;
buttonDisabled?: boolean;
buttonIcon?: string;
description: string;
description?: string;
calloutText?: string;
calloutTheme?: CalloutTheme;
calloutIcon?: string;

View File

@@ -8,10 +8,11 @@
import { ElDropdown, ElDropdownMenu, ElDropdownItem, type Placement } from 'element-plus';
import { ref, useCssModule, useAttrs, computed } from 'vue';
import type { IconSize } from '@n8n/design-system/types/icon';
import type { ActionDropdownItem, IconSize, ButtonSize } from '@n8n/design-system/types';
import type { ActionDropdownItem } from '../../types';
import N8nBadge from '../N8nBadge';
import N8nIcon from '../N8nIcon';
import N8nIconButton from '../N8nIconButton';
import { N8nKeyboardShortcut } from '../N8nKeyboardShortcut';
const TRIGGER = ['click', 'hover'] as const;
@@ -20,7 +21,7 @@ interface ActionDropdownProps {
items: ActionDropdownItem[];
placement?: Placement;
activatorIcon?: string;
activatorSize?: IconSize;
activatorSize?: ButtonSize;
iconSize?: IconSize;
trigger?: (typeof TRIGGER)[number];
hideArrow?: boolean;
@@ -96,7 +97,7 @@ defineExpose({ open, close });
@visible-change="onVisibleChange"
>
<slot v-if="$slots.activator" name="activator" />
<n8n-icon-button
<N8nIconButton
v-else
type="tertiary"
text

View File

@@ -1,17 +1,18 @@
<script lang="ts" setup>
<script lang="ts" setup generic="UserType extends IUser, Actions extends UserAction<UserType>[]">
import { ElDropdown, ElDropdownMenu, ElDropdownItem, type Placement } from 'element-plus';
import { ref } from 'vue';
import type { UserAction } from '@n8n/design-system/types';
import type { IUser, UserAction } from '@n8n/design-system/types';
import type { IconOrientation, IconSize } from '@n8n/design-system/types/icon';
import N8nIcon from '../N8nIcon';
import N8nLoading from '../N8nLoading';
const SIZE = ['mini', 'small', 'medium'] as const;
const THEME = ['default', 'dark'] as const;
interface ActionToggleProps {
actions?: UserAction[];
interface ActionToggleProps<UserType extends IUser, Actions extends Array<UserAction<UserType>>> {
actions?: Actions;
placement?: Placement;
size?: (typeof SIZE)[number];
iconSize?: IconSize;
@@ -24,8 +25,10 @@ interface ActionToggleProps {
trigger?: 'click' | 'hover';
}
type ActionValue = Actions[number]['value'];
defineOptions({ name: 'N8nActionToggle' });
withDefaults(defineProps<ActionToggleProps>(), {
withDefaults(defineProps<ActionToggleProps<UserType, Array<UserAction<UserType>>>>(), {
actions: () => [],
placement: 'bottom',
size: 'medium',
@@ -42,9 +45,9 @@ withDefaults(defineProps<ActionToggleProps>(), {
const actionToggleRef = ref<InstanceType<typeof ElDropdown> | null>(null);
const emit = defineEmits<{
action: [value: string];
action: [value: ActionValue];
'visible-change': [value: boolean];
'item-mouseup': [action: UserAction];
'item-mouseup': [action: UserAction<UserType>];
}>();
const onCommand = (value: string) => emit('action', value);
@@ -57,7 +60,7 @@ const openActionToggle = (isOpen: boolean) => {
}
};
const onActionMouseUp = (action: UserAction) => {
const onActionMouseUp = (action: UserAction<UserType>) => {
emit('item-mouseup', action);
actionToggleRef.value?.handleClose();
};

View File

@@ -5,8 +5,8 @@ import Avatar from 'vue-boring-avatars';
import { getInitials } from '../../utils/labelUtil';
interface AvatarProps {
firstName?: string;
lastName?: string;
firstName?: string | null;
lastName?: string | null;
size?: 'xsmall' | 'small' | 'medium' | 'large';
colors?: string[];
}

View File

@@ -1,20 +1,10 @@
<script lang="ts" setup>
import type { TextSize } from '@n8n/design-system/types/text';
import type { TextSize, BadgeTheme } from '@n8n/design-system/types/';
import N8nText from '../N8nText';
const THEME = [
'default',
'success',
'warning',
'danger',
'primary',
'secondary',
'tertiary',
] as const;
interface BadgeProps {
theme?: (typeof THEME)[number];
theme?: BadgeTheme;
size?: TextSize;
bold?: boolean;
showBorder?: boolean;

View File

@@ -1,6 +1,6 @@
import type { StoryFn } from '@storybook/vue3';
import type { UserAction } from '@n8n/design-system/types';
import type { IUser, UserAction } from '@n8n/design-system/types';
import AsyncLoadingCacheDemo from './AsyncLoadingCacheDemo.vue';
import Breadcrumbs from './Breadcrumbs.vue';
@@ -125,7 +125,7 @@ SyncLoadingCacheTest.args = {
title: '[Demo] This will update the hidden items every time dropdown is opened',
};
const testActions: UserAction[] = [
const testActions: Array<UserAction<IUser>> = [
{ label: 'Create Folder', value: 'action1', disabled: false },
{ label: 'Create Workflow', value: 'action2', disabled: false },
{ label: 'Rename', value: 'action3', disabled: false },

View File

@@ -1,9 +1,13 @@
<script lang="ts" setup>
<script lang="ts" setup generic="UserType extends IUser">
import { computed, ref, watch } from 'vue';
import type { UserAction } from '@n8n/design-system/types';
import type { IUser, UserAction } from '@n8n/design-system/types';
import N8nActionToggle from '../N8nActionToggle';
import N8nLink from '../N8nLink';
import N8nLoading from '../N8nLoading';
import N8nText from '../N8nText';
import N8nTooltip from '../N8nTooltip';
export type PathItem = {
id: string;
@@ -66,7 +70,7 @@ const dropdownDisabled = computed(() => {
return props.pathTruncated && !hasHiddenItems.value;
});
const hiddenItemActions = computed((): UserAction[] => {
const hiddenItemActions = computed((): Array<UserAction<UserType>> => {
return loadedHiddenItems.value.map((item) => ({
value: item.id,
label: item.label,
@@ -132,7 +136,7 @@ const emitItemHover = (id: string) => {
emit('itemHover', item);
};
const onHiddenItemMouseUp = (item: UserAction) => {
const onHiddenItemMouseUp = (item: UserAction<UserType>) => {
const pathItem = [...props.items, ...loadedHiddenItems.value].find((i) => i.id === item.value);
if (!pathItem || !props.dragActive) {
return;
@@ -177,16 +181,13 @@ const handleTooltipClose = () => {
>
<!-- Show interactive dropdown for larger versions -->
<div v-if="props.theme !== 'small'" :class="$style['hidden-items-menu']">
<n8n-action-toggle
<N8nActionToggle
:actions="hiddenItemActions"
:loading="isLoadingHiddenItems"
:loading-row-count="loadingSkeletonRows"
:disabled="dropdownDisabled"
:class="$style['action-toggle']"
:popper-class="{
[$style['hidden-items-menu-popper']]: true,
[$style.dragging]: dragActive,
}"
:popper-class="`${$style['hidden-items-menu-popper']} ${dragActive ? $style.dragging : ''}`"
:trigger="hiddenItemsTrigger"
theme="dark"
placement="bottom"
@@ -197,11 +198,11 @@ const handleTooltipClose = () => {
@action="emitItemSelected"
@item-mouseup="onHiddenItemMouseUp"
>
<n8n-text :bold="true" :class="$style.dots">...</n8n-text>
</n8n-action-toggle>
<N8nText :bold="true" :class="$style.dots">...</N8nText>
</N8nActionToggle>
</div>
<!-- Just a tooltip for smaller versions -->
<n8n-tooltip
<N8nTooltip
v-else
:popper-class="$style.tooltip"
:disabled="dropdownDisabled"
@@ -222,12 +223,12 @@ const handleTooltipClose = () => {
</div>
<div v-else :class="$style.tooltipContent">
<div data-test-id="hidden-items-tooltip">
<n8n-text>{{ loadedHiddenItems.map((item) => item.label).join(' / ') }}</n8n-text>
<N8nText>{{ loadedHiddenItems.map((item) => item.label).join(' / ') }}</N8nText>
</div>
</div>
</template>
<span :class="$style['tooltip-ellipsis']">...</span>
</n8n-tooltip>
</N8nTooltip>
</li>
<li v-if="showEllipsis" :class="$style.separator">{{ separator }}</li>
<template v-for="(item, index) in items" :key="item.id">
@@ -245,8 +246,8 @@ const handleTooltipClose = () => {
@mouseenter="emitItemHover(item.id)"
@mouseup="onItemMouseUp(item)"
>
<n8n-link v-if="item.href" :href="item.href" theme="text">{{ item.label }}</n8n-link>
<n8n-text v-else>{{ item.label }}</n8n-text>
<N8nLink v-if="item.href" :href="item.href" theme="text">{{ item.label }}</N8nLink>
<N8nText v-else>{{ item.label }}</N8nText>
</li>
<li v-if="index !== items.length - 1" :class="$style.separator">
{{ separator }}

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, useAttrs, useCssModule, watchEffect } from 'vue';
import type { IconSize } from '@n8n/design-system/types';
import type { ButtonProps } from '@n8n/design-system/types/button';
import N8nIcon from '../N8nIcon';
@@ -34,7 +35,10 @@ const ariaBusy = computed(() => (props.loading ? 'true' : undefined));
const ariaDisabled = computed(() => (props.disabled ? 'true' : undefined));
const isDisabled = computed(() => props.disabled || props.loading);
const iconSize = computed(() => props.iconSize ?? (props.size === 'mini' ? 'xsmall' : props.size));
const iconSize = computed(
(): IconSize | undefined =>
props.iconSize ?? (props.size === 'xmini' || props.size === 'mini' ? 'xsmall' : props.size),
);
const classes = computed(() => {
return (

View File

@@ -1,14 +1,11 @@
<script lang="ts" setup>
import { computed, useCssModule } from 'vue';
import type { IconSize } from '@n8n/design-system/types/icon';
import type { IconSize, CalloutTheme } from '@n8n/design-system/types';
import N8nIcon from '../N8nIcon';
import N8nText from '../N8nText';
const THEMES = ['info', 'success', 'secondary', 'warning', 'danger', 'custom'] as const;
export type CalloutTheme = (typeof THEMES)[number];
const CALLOUT_DEFAULT_ICONS: Record<string, string> = {
info: 'info-circle',
success: 'check-circle',

View File

@@ -1,3 +1,3 @@
import N8nCallout from './Callout.vue';
export type { CalloutTheme } from './Callout.vue';
export type { CalloutTheme } from '../../types';
export default N8nCallout;

View File

@@ -32,7 +32,7 @@ import type {
Updater,
} from '@tanstack/vue-table';
import { createColumnHelper, FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table';
import { ElCheckbox } from 'element-plus';
import { ElCheckbox, ElOption, ElSelect, ElSkeletonItem } from 'element-plus';
import get from 'lodash/get';
import { computed, h, ref, shallowRef, useSlots, watch } from 'vue';
@@ -428,7 +428,7 @@ const table = useVueTable({
:key="coll.id"
class="el-skeleton is-animated"
>
<el-skeleton-item />
<ElSkeletonItem />
</td>
</tr>
</template>
@@ -470,14 +470,14 @@ const table = useVueTable({
</N8nPagination>
<div class="table-pagination__sizes">
<div class="table-pagination__sizes__label">Page size</div>
<el-select
<ElSelect
v-model.number="itemsPerPage"
class="table-pagination__sizes__select"
size="small"
:teleported="false"
>
<el-option v-for="item in pageSizes" :key="item" :label="item" :value="item" />
</el-select>
<ElOption v-for="item in pageSizes" :key="item" :label="item" :value="item" />
</ElSelect>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script lang="ts" setup generic="Item extends DatatableRow">
import { computed, ref } from 'vue';
import { useI18n } from '../../composables/useI18n';
@@ -13,7 +13,7 @@ const ALL_ROWS = -1;
interface DatatableProps {
columns: DatatableColumn[];
rows: DatatableRow[];
rows: Item[];
currentPage?: number;
pagination?: boolean;
rowsPerPage?: number;
@@ -69,7 +69,7 @@ function onRowsPerPageChange(value: number) {
}
}
function getTdValue(row: DatatableRow, column: DatatableColumn) {
function getTdValue(row: Item, column: DatatableColumn) {
return getValueByPath<DatatableRowDataType>(row, column.path);
}

View File

@@ -1,5 +1,10 @@
<script lang="ts" setup>
import type { IFormInput } from '@n8n/design-system/types';
import type {
FormFieldValue,
IFormInput,
FormFieldValueUpdate,
FormValues,
} from '@n8n/design-system/types';
import { createFormEventBus } from '../../utils';
import N8nButton from '../N8nButton';
@@ -17,12 +22,10 @@ interface FormBoxProps {
redirectLink?: string;
}
type Value = string | number | boolean | null | undefined;
defineOptions({ name: 'N8nFormBox' });
withDefaults(defineProps<FormBoxProps>(), {
title: '',
inputs: () => [],
inputs: (): IFormInput[] => [],
buttonLoading: false,
redirectText: '',
redirectLink: '',
@@ -30,13 +33,13 @@ withDefaults(defineProps<FormBoxProps>(), {
const formBus = createFormEventBus();
const emit = defineEmits<{
submit: [value: { [key: string]: Value }];
update: [value: { name: string; value: Value }];
submit: [value: FormValues];
update: [value: FormFieldValueUpdate];
secondaryClick: [value: Event];
}>();
const onUpdateModelValue = (e: { name: string; value: Value }) => emit('update', e);
const onSubmit = (e: { [key: string]: Value }) => emit('submit', e);
const onUpdateModelValue = (e: { name: string; value: FormFieldValue }) => emit('update', e);
const onSubmit = (e: { [key: string]: FormFieldValue }) => emit('submit', e);
const onButtonClick = () => formBus.emit('submit');
const onSecondaryButtonClick = (event: Event) => emit('secondaryClick', event);
</script>

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { ElSwitch } from 'element-plus';
import { computed, reactive, onMounted, ref, watch, useSlots } from 'vue';
import { computed, reactive, onMounted, ref, watch } from 'vue';
import { getValidationError, VALIDATORS } from './validators';
import { t } from '../../locale';
@@ -19,6 +19,7 @@ import type {
import N8nCheckbox from '../N8nCheckbox';
import N8nInput from '../N8nInput';
import N8nInputLabel from '../N8nInputLabel';
import N8nLink from '../N8nLink';
import N8nOption from '../N8nOption';
import N8nSelect from '../N8nSelect';
@@ -77,8 +78,6 @@ const state = reactive({
isTyping: false,
});
const slots = useSlots();
const inputRef = ref<HTMLTextAreaElement | null>(null);
function getInputValidationError(): ReturnType<IValidator['validate']> {
@@ -160,8 +159,6 @@ const validationError = computed<{ message: string } | null>(() => {
return null;
});
const hasDefaultSlot = computed(() => !!slots.default);
const showErrors = computed(
() =>
!!validationError.value &&
@@ -217,7 +214,7 @@ defineExpose({ inputRef });
:size="labelSize"
>
<div :class="showErrors ? $style.errorInput : ''" @keydown.stop @keydown.enter.exact="onEnter">
<slot v-if="hasDefaultSlot" />
<slot v-if="$slots.default" />
<N8nSelect
v-else-if="type === 'select' || type === 'multi-select'"
ref="inputRef"
@@ -261,7 +258,7 @@ defineExpose({ inputRef });
</div>
<div v-if="showErrors" :class="$style.errors">
<span v-text="validationError?.message" />
<n8n-link
<N8nLink
v-if="documentationUrl && documentationText"
:to="documentationUrl"
:new-window="true"
@@ -269,7 +266,7 @@ defineExpose({ inputRef });
theme="danger"
>
{{ documentationText }}
</n8n-link>
</N8nLink>
</div>
<div v-else-if="infoText" :class="$style.infoText">
<span size="small" v-text="infoText" />

View File

@@ -1,24 +1,22 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import type { IFormInput } from '../../types';
import type { FormFieldValue, IFormInput, FormFieldValueUpdate, FormValues } from '../../types';
import type { FormEventBus } from '../../utils';
import { createFormEventBus } from '../../utils';
import N8nFormInput from '../N8nFormInput';
import N8nText from '../N8nText';
import ResizeObserver from '../ResizeObserver';
export type FormInputsProps = {
inputs?: IFormInput[];
export interface FormInputsProps {
inputs: IFormInput[];
eventBus?: FormEventBus;
columnView?: boolean;
verticalSpacing?: '' | 'xs' | 's' | 'm' | 'l' | 'xl';
teleported?: boolean;
};
type Value = string | number | boolean | null | undefined;
}
const props = withDefaults(defineProps<FormInputsProps>(), {
inputs: () => [],
eventBus: createFormEventBus,
columnView: false,
verticalSpacing: '',
@@ -26,14 +24,14 @@ const props = withDefaults(defineProps<FormInputsProps>(), {
});
const emit = defineEmits<{
update: [value: { name: string; value: Value }];
'update:modelValue': [value: Record<string, Value>];
submit: [value: Record<string, Value>];
update: [value: FormFieldValueUpdate];
'update:modelValue': [value: FormValues];
submit: [value: FormValues];
ready: [value: boolean];
}>();
const showValidationWarnings = ref(false);
const values = reactive<Record<string, Value>>({});
const values = reactive<FormValues>({});
const validity = ref<Record<string, boolean>>({});
const filteredInputs = computed(() => {
@@ -50,7 +48,7 @@ watch(isReadyToSubmit, (ready) => {
emit('ready', ready);
});
function onUpdateModelValue(name: string, value: Value) {
function onUpdateModelValue(name: string, value: FormFieldValue) {
values[name] = value;
emit('update', { name, value });
emit('update:modelValue', values);
@@ -76,12 +74,15 @@ function onSubmit() {
return;
}
const toSubmit = filteredInputs.value.reduce<Record<string, Value>>((valuesToSubmit, input) => {
if (values[input.name]) {
valuesToSubmit[input.name] = values[input.name];
}
return valuesToSubmit;
}, {});
const toSubmit = filteredInputs.value.reduce<Record<string, FormFieldValue>>(
(valuesToSubmit, input) => {
if (values[input.name]) {
valuesToSubmit[input.name] = values[input.name];
}
return valuesToSubmit;
},
{},
);
emit('submit', toSubmit);
}
@@ -108,7 +109,7 @@ onMounted(() => {
:key="input.name"
:class="{ [`mt-${verticalSpacing}`]: verticalSpacing && index > 0 }"
>
<n8n-text
<N8nText
v-if="input.properties.type === 'info'"
color="text-base"
tag="div"
@@ -117,7 +118,7 @@ onMounted(() => {
class="form-text"
>
{{ input.properties.label }}
</n8n-text>
</N8nText>
<N8nFormInput
v-else
v-bind="input.properties"
@@ -127,7 +128,7 @@ onMounted(() => {
:data-test-id="input.name"
:show-validation-warnings="showValidationWarnings"
:teleported="teleported"
@update:model-value="(value: Value) => onUpdateModelValue(input.name, value)"
@update:model-value="(value: FormFieldValue) => onUpdateModelValue(input.name, value)"
@validate="(value: boolean) => onValidate(input.name, value)"
@enter="onSubmit"
/>

View File

@@ -60,6 +60,7 @@ describe('IconPicker', () => {
global: {
plugins: [router],
components,
stubs: ['N8nButton'],
},
});
const TEST_EMOJI_COUNT = 1962;
@@ -90,11 +91,12 @@ describe('IconPicker', () => {
global: {
plugins: [router],
components,
stubs: ['N8nButton'],
},
});
await userEvent.hover(getByTestId('icon-picker-button'));
expect(getByRole('tooltip').textContent).toBe(TOOLTIP);
expect(getByTestId('icon-picker-button').dataset.icon).toBe(ICON);
expect(getByTestId('icon-picker-button')).toHaveAttribute('icon', ICON);
});
it('renders emoji as default icon correctly', async () => {
const ICON = '🔥';
@@ -124,6 +126,7 @@ describe('IconPicker', () => {
global: {
plugins: [router],
components,
stubs: ['N8nButton'],
},
});
expect(queryByTestId('tab-icons')).not.toBeInTheDocument();
@@ -138,13 +141,14 @@ describe('IconPicker', () => {
global: {
plugins: [router],
components,
stubs: ['N8nButton'],
},
});
await fireEvent.click(getByTestId('icon-picker-button'));
// Select the first icon
await fireEvent.click(getAllByTestId('icon-picker-icon')[0]);
// Icon should be selected and popup should be closed
expect(getByTestId('icon-picker-button').dataset.icon).toBe(TEST_ICONS[0]);
expect(getByTestId('icon-picker-button')).toHaveAttribute('icon', TEST_ICONS[0]);
expect(queryByTestId('icon-picker-popup')).toBeNull();
expect(emitted()).toHaveProperty('update:modelValue');
// Should emit the selected icon

View File

@@ -6,6 +6,10 @@ import { isEmojiSupported } from 'is-emoji-supported';
import { ref, computed } from 'vue';
import { useI18n } from '../../composables/useI18n';
import N8nButton from '../N8nButton';
import N8nIcon from '../N8nIcon';
import N8nIconButton from '../N8nIconButton';
import N8nTabs from '../N8nTabs';
import N8nTooltip from '../N8nTooltip';
/**

View File

@@ -6,13 +6,14 @@ import type { IconColor } from '@n8n/design-system/types/icon';
import N8nIcon from '../N8nIcon';
import N8nText from '../N8nText';
import N8nTooltip from '../N8nTooltip';
interface IAccordionItem {
export interface IAccordionItem {
id: string;
label: string;
icon: string;
iconColor?: IconColor;
tooltip?: string;
tooltip?: string | null;
}
interface InfoAccordionProps {
@@ -69,12 +70,12 @@ const onTooltipClick = (item: string, event: MouseEvent) => emit('tooltipClick',
<!-- Info accordion can display list of items with icons or just a HTML description -->
<div v-if="items.length > 0" :class="$style.accordionItems">
<div v-for="item in items" :key="item.id" :class="$style.accordionItem">
<n8n-tooltip :disabled="!item.tooltip">
<N8nTooltip :disabled="!item.tooltip">
<template #content>
<div v-n8n-html="item.tooltip" @click="onTooltipClick(item.id, $event)"></div>
</template>
<N8nIcon :icon="item.icon" :color="item.iconColor" size="small" class="mr-2xs" />
</n8n-tooltip>
</N8nTooltip>
<N8nText size="small" color="text-base">{{ item.label }}</N8nText>
</div>
</div>

View File

@@ -8,7 +8,7 @@ import type { InputSize, InputType } from '@n8n/design-system/types/input';
import { uid } from '../../utils';
interface InputProps {
modelValue?: string | number;
modelValue?: string | number | null;
type?: InputType;
size?: InputSize;
placeholder?: string;

View File

@@ -12,7 +12,7 @@ import { escapeMarkdown, toggleCheckbox } from '../../utils/markdown';
import N8nLoading from '../N8nLoading';
interface IImage {
id: string;
id: string | number;
url: string;
}
@@ -24,7 +24,7 @@ interface Options {
}
interface MarkdownProps {
content?: string;
content?: string | null;
withMultiBreaks?: boolean;
images?: IImage[];
loading?: boolean;

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { ElSubMenu, ElMenuItem } from 'element-plus';
import { computed, useCssModule } from 'vue';
import { computed, useCssModule, getCurrentInstance } from 'vue';
import { useRoute } from 'vue-router';
import { doesMenuItemMatchCurrentRoute } from './routerUtil';
@@ -8,6 +8,7 @@ import type { IMenuItem } from '../../types';
import { getInitials } from '../../utils/labelUtil';
import ConditionalRouterLink from '../ConditionalRouterLink';
import N8nIcon from '../N8nIcon';
import N8nSpinner from '../N8nSpinner';
import N8nTooltip from '../N8nTooltip';
interface MenuItemProps {
@@ -61,6 +62,9 @@ const isItemActive = (item: IMenuItem): boolean => {
Array.isArray(item.children) && item.children.some((child) => isActive(child));
return isActive(item) || hasActiveChild;
};
// Get self component to avoid dependency cycle
const N8nMenuItem = getCurrentInstance()?.type;
</script>
<template>

View File

@@ -1,12 +1,14 @@
<script lang="ts" setup>
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import type { Placement } from 'element-plus';
import { computed } from 'vue';
import { computed, getCurrentInstance } from 'vue';
import N8nTooltip from '../N8nTooltip';
type IconType = 'file' | 'icon' | 'unknown';
interface NodeIconProps {
type: 'file' | 'icon' | 'unknown';
type: IconType;
src?: string;
name?: string;
nodeTypeName?: string;
@@ -16,7 +18,7 @@ interface NodeIconProps {
color?: string;
showTooltip?: boolean;
tooltipPosition?: Placement;
badge?: { src: string; type: string };
badge?: { src: string; type: IconType };
}
const props = withDefaults(defineProps<NodeIconProps>(), {
@@ -69,6 +71,9 @@ const badgeStyleData = computed((): Record<string, string> => {
bottom: `-${Math.floor(size / 2)}px`,
};
});
// Get self component to avoid dependency cycle
const N8nNodeIcon = getCurrentInstance()?.type;
</script>
<template>
@@ -97,7 +102,7 @@ const badgeStyleData = computed((): Record<string, string> => {
<img v-if="type === 'file'" :src="src" :class="$style.nodeIconImage" />
<FontAwesomeIcon v-else :icon="`${name}`" :style="fontStyleData" />
<div v-if="badge" :class="$style.badge" :style="badgeStyleData">
<n8n-node-icon :type="badge.type" :src="badge.src" :size="badgeSize"></n8n-node-icon>
<N8nNodeIcon :type="badge.type" :src="badge.src" :size="badgeSize" />
</div>
</div>
<div v-else :class="$style.nodeIconPlaceholder">

View File

@@ -1,4 +1,4 @@
<script lang="ts" setup generic="Value extends string">
<script lang="ts" setup generic="Value extends string | boolean">
import RadioButton from './RadioButton.vue';
interface RadioOption {
@@ -47,8 +47,9 @@ const onClick = (
>
<RadioButton
v-for="option in options"
:key="option.value"
:key="`${option.value}`"
v-bind="option"
:value="`${option.value}`"
:active="modelValue === option.value"
:size="size"
:disabled="disabled || option.disabled"

View File

@@ -1,11 +1,13 @@
<script lang="ts" setup>
<script lang="ts" setup generic="Key extends string, Item extends ItemWithKey<Key>">
import type { ComponentPublicInstance } from 'vue';
import { computed, onMounted, onBeforeMount, ref, nextTick, watch } from 'vue';
import type { ItemWithKey } from '@n8n/design-system/types';
interface RecycleScrollerProps {
itemSize: number;
items: Array<Record<string, string>>;
itemKey: string;
items: Item[];
itemKey: Key;
offset?: number;
}
@@ -24,18 +26,21 @@ const windowHeight = ref(0);
/** Cache */
const itemSizeCache = ref<Record<string, number>>({});
const itemSizeCache = ref<Record<Item[Key], number>>({} as Record<Item[Key], number>);
const itemPositionCache = computed(() => {
return props.items.reduce<Record<string, number>>((acc, item, index) => {
const key = item[props.itemKey];
const prevItem = props.items[index - 1];
const prevItemPosition = prevItem ? acc[prevItem[props.itemKey]] : 0;
const prevItemSize = prevItem ? itemSizeCache.value[prevItem[props.itemKey]] : 0;
return props.items.reduce<Record<Item[Key], number>>(
(acc, item, index) => {
const key = item[props.itemKey];
const prevItem = props.items[index - 1];
const prevItemPosition = prevItem ? acc[prevItem[props.itemKey]] : 0;
const prevItemSize = prevItem ? itemSizeCache.value[prevItem[props.itemKey]] : 0;
acc[key] = prevItemPosition + prevItemSize;
acc[key] = prevItemPosition + prevItemSize;
return acc;
}, {});
return acc;
},
{} as Record<Item[Key], number>,
);
});
/** Indexes */
@@ -186,7 +191,7 @@ function onScroll() {
<div
v-for="item in visibleItems"
:key="item[itemKey]"
:ref="(element) => (itemRefs[item[itemKey]] = element)"
:ref="(element) => (itemRefs[`${item[itemKey]}`] = element)"
class="recycle-scroller-item"
>
<slot :item="item" :update-item-size="onUpdateItemSize" />

View File

@@ -8,6 +8,9 @@ describe('N8nRoute', () => {
props: {
to: '/test',
},
global: {
stubs: ['RouterLink'],
},
});
expect(wrapper.html()).toMatchSnapshot();
});
@@ -18,6 +21,9 @@ describe('N8nRoute', () => {
to: '/test',
newWindow: true,
},
global: {
stubs: ['RouterLink'],
},
});
expect(wrapper.html()).toMatchSnapshot();
});
@@ -27,6 +33,9 @@ describe('N8nRoute', () => {
props: {
to: 'https://example.com/',
},
global: {
stubs: ['RouterLink'],
},
});
expect(wrapper.html()).toMatchSnapshot();
});

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { type RouteLocationRaw } from 'vue-router';
import { RouterLink, type RouteLocationRaw } from 'vue-router';
interface RouteProps {
to?: RouteLocationRaw | string;
@@ -27,9 +27,9 @@ const openNewWindow = computed(() => !useRouterLink.value);
</script>
<template>
<router-link v-if="useRouterLink && to" :to="to" v-bind="$attrs">
<RouterLink v-if="useRouterLink && to" :to="to" role="link" v-bind="$attrs">
<slot></slot>
</router-link>
</RouterLink>
<a
v-else
:href="to ? `${to}` : undefined"

View File

@@ -4,4 +4,4 @@ exports[`N8nRoute > should render external links 1`] = `"<a href="https://exampl
exports[`N8nRoute > should render internal links with newWindow=true 1`] = `"<a href="/test" target="_blank"></a>"`;
exports[`N8nRoute > should render internal router links 1`] = `"<router-link to="/test"></router-link>"`;
exports[`N8nRoute > should render internal router links 1`] = `"<router-link-stub to="/test" replace="false" custom="false" ariacurrentvalue="page" role="link"></router-link-stub>"`;

View File

@@ -2,6 +2,7 @@
import { computed } from 'vue';
import { useI18n } from '../../composables/useI18n';
import N8nIcon from '../N8nIcon';
const { t } = useI18n();

View File

@@ -1,12 +1,12 @@
<script lang="ts" setup>
import type { TextSize } from '@n8n/design-system/types/text';
import type { IconSize } from '@n8n/design-system/types';
import N8nIcon from '../N8nIcon';
const TYPE = ['dots', 'ring'] as const;
interface SpinnerProps {
size?: Exclude<TextSize, 'mini' | 'xlarge'>;
size?: IconSize;
type?: (typeof TYPE)[number];
}

View File

@@ -1,26 +1,19 @@
<script lang="ts" setup generic="Value extends string | number">
import { onMounted, onUnmounted, ref } from 'vue';
import type { RouteLocationRaw } from 'vue-router';
import { RouterLink } from 'vue-router';
import type { TabOptions } from '../../types';
import N8nIcon from '../N8nIcon';
interface TabOptions {
value: Value;
label?: string;
icon?: string;
href?: string;
tooltip?: string;
align?: 'left' | 'right';
to?: RouteLocationRaw;
}
import N8nTooltip from '../N8nTooltip';
interface TabsProps {
modelValue?: Value;
options?: TabOptions[];
options?: Array<TabOptions<Value>>;
size?: 'small' | 'medium';
}
withDefaults(defineProps<TabsProps>(), {
modelValue: undefined,
options: () => [],
size: 'medium',
});
@@ -108,14 +101,14 @@ const scrollRight = () => scroll(50);
</span>
</div>
</a>
<router-link
<RouterLink
v-else-if="option.to"
:to="option.to"
:class="[$style.tab, { [$style.activeTab]: modelValue === option.value }]"
>
<N8nIcon v-if="option.icon" :icon="option.icon" size="medium" />
<span v-if="option.label">{{ option.label }}</span>
</router-link>
</RouterLink>
<div
v-else
:class="{ [$style.tab]: true, [$style.activeTab]: modelValue === option.value }"

View File

@@ -27,7 +27,7 @@ const props = withDefaults(defineProps<TagsProp>(), {
const emit = defineEmits<{
expand: [value: boolean];
'click:tag': [tagId: string, e: MouseEvent];
'click:tag': [tagId: string, e: PointerEvent];
}>();
const { t } = useI18n();

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup generic="Value extends unknown = unknown">
import { computed, useCssModule } from 'vue';
import { computed, getCurrentInstance, useCssModule } from 'vue';
interface TreeProps {
value?: Record<string, Value>;
@@ -52,20 +52,23 @@ const getPath = (key: string): Array<string | number> => {
}
return [...props.path, key];
};
// Get self component to avoid dependency cycle
const N8nTree = getCurrentInstance()?.type;
</script>
<template>
<div v-if="isObject(value)" class="n8n-tree">
<div v-for="(label, i) in Object.keys(value)" :key="i" :class="classes">
<div v-if="isSimple(value[label])" :class="$style.simple">
<slot v-if="$slots.label" name="label" :label="label" :path="getPath(label)" />
<slot v-if="!!$slots.label" name="label" :label="label" :path="getPath(label)" />
<span v-else>{{ label }}</span>
<span>:</span>
<slot v-if="$slots.value" name="value" :value="value[label]" />
<slot v-if="!!$slots.value" name="value" :value="value[label]" />
<span v-else>{{ value[label] }}</span>
</div>
<div v-else>
<slot v-if="$slots.label" name="label" :label="label" :path="getPath(label)" />
<slot v-if="!!$slots.label" name="label" :label="label" :path="getPath(label)" />
<span v-else>{{ label }}</span>
<N8nTree
v-if="isObject(value[label])"
@@ -74,11 +77,11 @@ const getPath = (key: string): Array<string | number> => {
:value="value[label]"
:node-class="nodeClass"
>
<template v-if="$slots.label" #label="data">
<template v-if="!!$slots.label" #label="data">
<slot name="label" v-bind="data" />
</template>
<template v-if="$slots.value" #value="data">
<template v-if="!!$slots.value" #value="data">
<slot name="value" v-bind="data" />
</template>
</N8nTree>

View File

@@ -7,9 +7,9 @@ import N8nBadge from '../N8nBadge';
import N8nText from '../N8nText';
interface UsersInfoProps {
firstName?: string;
lastName?: string;
email?: string;
firstName?: string | null;
lastName?: string | null;
email?: string | null;
isOwner?: boolean;
isPendingUser?: boolean;
isCurrentUser?: boolean;

View File

@@ -74,7 +74,7 @@ const onBlur = () => emit('blur');
const onFocus = () => emit('focus');
const getLabel = (user: IUser) =>
!user.fullName ? user.email : `${user.fullName} (${user.email})`;
(!user.fullName ? user.email : `${user.fullName} (${user.email})`) ?? '';
</script>
<template>

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus';
import { computed } from 'vue';
import type { IUser, UserStackGroups } from '@n8n/design-system/types';
@@ -9,7 +10,7 @@ import N8nUserInfo from '../N8nUserInfo';
const props = withDefaults(
defineProps<{
users: UserStackGroups;
currentUserEmail?: string;
currentUserEmail?: string | null;
maxAvatars?: number;
dropdownTrigger?: 'hover' | 'click';
}>(),
@@ -63,7 +64,7 @@ const menuHeight = computed(() => {
<template>
<div class="user-stack" data-test-id="user-stack-container">
<el-dropdown
<ElDropdown
:trigger="$props.dropdownTrigger"
:max-height="menuHeight"
popper-class="user-stack-popper"
@@ -81,14 +82,14 @@ const menuHeight = computed(() => {
<div v-if="hiddenUsersCount > 0" :class="$style.hiddenBadge">+{{ hiddenUsersCount }}</div>
</div>
<template #dropdown>
<el-dropdown-menu class="user-stack-list" data-test-id="user-stack-list">
<ElDropdownMenu class="user-stack-list" data-test-id="user-stack-list">
<div v-for="(groupUsers, index) in nonEmptyGroups" :key="index">
<div :class="$style.groupContainer">
<el-dropdown-item>
<ElDropdownItem>
<header v-if="groupCount > 1" :class="$style.groupName">{{ index }}</header>
</el-dropdown-item>
</ElDropdownItem>
<div :class="$style.groupUsers">
<el-dropdown-item
<ElDropdownItem
v-for="user in groupUsers"
:key="user.id"
:data-test-id="`user-stack-info-${user.id}`"
@@ -98,13 +99,13 @@ const menuHeight = computed(() => {
v-bind="user"
:is-current-user="user.email === props.currentUserEmail"
/>
</el-dropdown-item>
</ElDropdownItem>
</div>
</div>
</div>
</el-dropdown-menu>
</ElDropdownMenu>
</template>
</el-dropdown>
</ElDropdown>
</div>
</template>

View File

@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script lang="ts" setup generic="UserType extends IUser = IUser">
import { computed } from 'vue';
import { useI18n } from '../../composables/useI18n';
@@ -8,10 +8,10 @@ import N8nBadge from '../N8nBadge';
import N8nUserInfo from '../N8nUserInfo';
interface UsersListProps {
users: IUser[];
users: UserType[];
readonly?: boolean;
currentUserId?: string;
actions?: UserAction[];
currentUserId?: string | null;
actions?: Array<UserAction<UserType>>;
isSamlLoginEnabled?: boolean;
}
@@ -26,7 +26,7 @@ const props = withDefaults(defineProps<UsersListProps>(), {
const { t } = useI18n();
const sortedUsers = computed(() =>
[...props.users].sort((a: IUser, b: IUser) => {
[...props.users].sort((a: UserType, b: UserType) => {
if (!a.email || !b.email) {
throw new Error('Expected all users to have email');
}
@@ -64,7 +64,7 @@ const sortedUsers = computed(() =>
);
const defaultGuard = () => true;
const getActions = (user: IUser): UserAction[] => {
const getActions = (user: UserType): Array<UserAction<UserType>> => {
if (user.isOwner) return [];
return props.actions.filter((action) => (action.guard ?? defaultGuard)(user));
@@ -73,7 +73,7 @@ const getActions = (user: IUser): UserAction[] => {
const emit = defineEmits<{
action: [value: { action: string; userId: string }];
}>();
const onUserAction = (user: IUser, action: string) =>
const onUserAction = (user: UserType, action: string) =>
emit('action', {
action,
userId: user.id,
@@ -101,7 +101,7 @@ const onUserAction = (user: IUser, action: string) =>
<N8nActionToggle
v-if="
!user.isOwner &&
!['ldap'].includes(user.signInType) &&
user.signInType !== 'ldap' &&
!readonly &&
getActions(user).length > 0 &&
actions.length > 0

View File

@@ -1,16 +1,11 @@
import type { Component, Plugin } from 'vue';
import type { Plugin } from 'vue';
import * as components from './components';
import * as directives from './directives';
export interface N8nPluginOptions {}
export const N8nPlugin: Plugin<N8nPluginOptions> = {
install: (app) => {
for (const [name, component] of Object.entries(components)) {
app.component(name, component as unknown as Component);
}
for (const [name, directive] of Object.entries(directives)) {
app.directive(name, directive);
}

View File

@@ -0,0 +1,10 @@
const BADGE_THEME = [
'default',
'success',
'warning',
'danger',
'primary',
'secondary',
'tertiary',
] as const;
export type BadgeTheme = (typeof BADGE_THEME)[number];

View File

@@ -7,7 +7,7 @@ export type ButtonElement = (typeof BUTTON_ELEMENT)[number];
const BUTTON_TYPE = ['primary', 'secondary', 'tertiary', 'success', 'warning', 'danger'] as const;
export type ButtonType = (typeof BUTTON_TYPE)[number];
const BUTTON_SIZE = ['mini', 'small', 'medium', 'large'] as const;
const BUTTON_SIZE = ['xmini', 'mini', 'small', 'medium', 'large'] as const;
export type ButtonSize = (typeof BUTTON_SIZE)[number];
const BUTTON_NATIVE_TYPE = ['submit', 'reset', 'button'] as const;
@@ -21,7 +21,7 @@ export interface IconButtonProps {
loading?: boolean;
outline?: boolean;
size?: ButtonSize;
iconSize?: Exclude<IconSize, 'xlarge'>;
iconSize?: IconSize;
text?: boolean;
type?: ButtonType;
nativeType?: ButtonNativeType;

View File

@@ -0,0 +1,2 @@
const CALLOUT_THEMES = ['info', 'success', 'secondary', 'warning', 'danger', 'custom'] as const;
export type CalloutTheme = (typeof CALLOUT_THEMES)[number];

View File

@@ -4,7 +4,7 @@ export type DatatableRowDataType = string | number | boolean | null | undefined;
export interface DatatableRow {
id: string | number;
[key: string]: DatatableRowDataType | Record<string, DatatableRowDataType>;
[key: string]: unknown;
}
export interface DatatableColumn {

View File

@@ -1,5 +1,13 @@
import type { N8nLocaleTranslateFnOptions } from '@n8n/design-system/types/i18n';
export type FormFieldValue = string | number | boolean | null | undefined;
export type FormInputsToFormValues<T extends IFormInput[], V> = {
[K in T[number]['name']]: V;
};
export type FormFieldValueUpdate = { name: string; value: FormFieldValue };
export type Rule = { name: string; config?: unknown };
export type RuleGroup = {
@@ -65,6 +73,8 @@ export type IFormInput = {
export type IFormInputs = IFormInput[];
export type FormValues = FormInputsToFormValues<IFormInput[], FormFieldValue>;
export type IFormBoxConfig = {
title: string;
buttonText?: string;

View File

@@ -1,12 +1,19 @@
export * from './action-dropdown';
export * from './assistant';
export * from './badge';
export * from './button';
export * from './callout';
export * from './datatable';
export * from './form';
export * from './i18n';
export * from './icon';
export * from './input';
export * from './menu';
export * from './select';
export * from './user';
export * from './keyboardshortcut';
export * from './menu';
export * from './node-creator-node';
export * from './recycle-scroller';
export * from './resize';
export * from './select';
export * from './tabs';
export * from './text';
export * from './user';

View File

@@ -0,0 +1,5 @@
export type ItemWithKey<Key extends string> = {
[K in Key]: string;
} & {
[key: string]: unknown;
};

View File

@@ -0,0 +1,11 @@
import type { RouteLocationRaw } from 'vue-router';
export interface TabOptions<Value extends string | number> {
value: Value;
label?: string;
icon?: string;
href?: string;
tooltip?: string;
align?: 'left' | 'right';
to?: RouteLocationRaw;
}

View File

@@ -1,23 +1,24 @@
export interface IUser {
export type IUser = {
id: string;
firstName?: string;
lastName?: string;
firstName?: string | null;
lastName?: string | null;
fullName?: string;
email?: string;
isOwner: boolean;
isPendingUser: boolean;
role?: string;
email?: string | null;
signInType?: string;
isOwner?: boolean;
isPendingUser?: boolean;
inviteAcceptUrl?: string;
disabled: boolean;
signInType: string;
}
disabled?: boolean;
};
export interface UserAction {
export interface UserAction<UserType extends IUser> {
label: string;
value: string;
disabled: boolean;
disabled?: boolean;
type?: 'external-link';
tooltip?: string;
guard?: (user: IUser) => boolean;
guard?: (user: UserType) => boolean;
}
export type UserStackGroups = { [groupName: string]: IUser[] };

View File

@@ -86,6 +86,7 @@
"vue": "catalog:frontend",
"vue-agile": "^2.0.0",
"vue-chartjs": "^5.2.0",
"vue-component-type-helpers": "^2.2.10",
"vue-github-button": "^3.1.3",
"vue-i18n": "catalog:frontend",
"vue-json-pretty": "2.2.4",

View File

@@ -293,6 +293,56 @@ export type BaseResource = {
name: string;
};
export type FolderResource = BaseFolderItem & {
resourceType: 'folder';
};
export type WorkflowResource = BaseResource & {
resourceType: 'workflow';
updatedAt: string;
createdAt: string;
active: boolean;
isArchived: boolean;
homeProject?: ProjectSharingData;
scopes?: Scope[];
tags?: ITag[] | string[];
sharedWithProjects?: ProjectSharingData[];
readOnly: boolean;
parentFolder?: ResourceParentFolder;
};
export type VariableResource = BaseResource & {
resourceType: 'variable';
key?: string;
value?: string;
};
export type CredentialsResource = BaseResource & {
resourceType: 'credential';
updatedAt: string;
createdAt: string;
type: string;
homeProject?: ProjectSharingData;
scopes?: Scope[];
sharedWithProjects?: ProjectSharingData[];
readOnly: boolean;
needsSetup: boolean;
};
export type Resource = WorkflowResource | FolderResource | CredentialsResource | VariableResource;
export type BaseFilters = {
search: string;
homeProject: string;
[key: string]: boolean | string | string[];
};
export type SortingAndPaginationUpdates = {
page?: number;
pageSize?: number;
sort?: string;
};
export type WorkflowListItem = Omit<
IWorkflowDb,
'nodes' | 'connections' | 'settings' | 'pinData' | 'usedCredentials' | 'meta'

View File

@@ -1,4 +1,4 @@
import type { Plugin } from 'vue';
import type { Component, Plugin } from 'vue';
import { render } from '@testing-library/vue';
import { i18nInstance } from '@n8n/i18n';
import { GlobalComponentsPlugin } from '@/plugins/components';
@@ -10,6 +10,7 @@ import type { Telemetry } from '@/plugins/telemetry';
import vueJsonPretty from 'vue-json-pretty';
import merge from 'lodash/merge';
import type { TestingPinia } from '@pinia/testing';
import * as components from '@n8n/design-system/components';
export type RenderComponent = Parameters<typeof render>[0];
export type RenderOptions = Parameters<typeof render>[1] & {
@@ -25,6 +26,14 @@ const TelemetryPlugin: Plugin<{}> = {
},
};
const TestingGlobalComponentsPlugin: Plugin<{}> = {
install(app) {
for (const [name, component] of Object.entries(components)) {
app.component(name, component as unknown as Component);
}
},
};
const defaultOptions = {
global: {
stubs: {
@@ -38,6 +47,7 @@ const defaultOptions = {
GlobalComponentsPlugin,
GlobalDirectivesPlugin,
TelemetryPlugin,
TestingGlobalComponentsPlugin,
],
},
};

View File

@@ -76,7 +76,7 @@ const getExpirationTime = (apiKey: ApiKey): string => {
<template #append>
<div ref="cardActions" :class="$style.cardActions">
<n8n-action-toggle :actions="ACTION_LIST" theme="dark" @action="onAction" />
<N8nActionToggle :actions="ACTION_LIST" theme="dark" @action="onAction" />
</div>
</template>
</n8n-card>

View File

@@ -6,7 +6,7 @@ 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 type { IFormInputs, IFormInput, FormFieldValueUpdate, FormValues } from '@/Interface';
import { useI18n } from '@n8n/i18n';
const config = ref<IFormInputs | null>(null);
@@ -33,17 +33,14 @@ const passwordsMatch = (value: string | number | boolean | null | undefined) =>
return false;
};
const onInput = (e: { name: string; value: string }) => {
if (e.name === 'password') {
const onInput = (e: FormFieldValueUpdate) => {
if (e.name === 'password' && typeof e.value === 'string') {
password.value = e.value;
}
};
const onSubmit = async (values: {
currentPassword: string;
password: string;
mfaCode?: string;
}) => {
const onSubmit = async (data: FormValues) => {
const values = data as { currentPassword: string; password: string; mfaCode?: string };
try {
loading.value = true;
await usersStore.updateCurrentUserPassword({
@@ -143,6 +140,7 @@ onMounted(() => {
>
<template #content>
<n8n-form-inputs
v-if="config"
:inputs="config"
:event-bus="formBus"
:column-view="true"

View File

@@ -1,10 +1,11 @@
<script lang="ts" setup>
import { useUIStore } from '@/stores/ui.store';
import type { PublicInstalledPackage } from 'n8n-workflow';
import type { IUser, PublicInstalledPackage } from 'n8n-workflow';
import { NPM_PACKAGE_DOCS_BASE_URL, COMMUNITY_PACKAGE_MANAGE_ACTIONS } from '@/constants';
import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useSettingsStore } from '@/stores/settings.store';
import type { UserAction } from '@n8n/design-system';
interface Props {
communityPackage?: PublicInstalledPackage | null;
@@ -22,7 +23,7 @@ const i18n = useI18n();
const telemetry = useTelemetry();
const settingsStore = useSettingsStore();
const packageActions = [
const packageActions: Array<UserAction<IUser>> = [
{
label: i18n.baseText('settings.communityNodes.viewDocsAction.label'),
value: COMMUNITY_PACKAGE_MANAGE_ACTIONS.VIEW_DOCS,

View File

@@ -12,7 +12,7 @@ import { useProjectsStore } from '@/stores/projects.store';
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
import { useI18n } from '@n8n/i18n';
import { ResourceType } from '@/utils/projects.utils';
import type { CredentialsResource } from './layouts/ResourcesListLayout.vue';
import type { CredentialsResource } from '@/Interface';
const CREDENTIAL_LIST_ITEM_ACTIONS = {
OPEN: 'open',

View File

@@ -4,6 +4,7 @@ import type { BaseTextKey } from '@n8n/i18n';
import type { TestTableColumn } from '@/components/Evaluations.ee/shared/TestTableBase.vue';
import { useI18n } from '@n8n/i18n';
import { useRouter } from 'vue-router';
import type { BadgeTheme } from '@n8n/design-system';
defineProps<{
column: TestTableColumn<T>;
@@ -39,7 +40,7 @@ const errorTooltipMap: Record<string, BaseTextKey> = {
};
// FIXME: move status logic to a parent component
const statusThemeMap: Record<string, string> = {
const statusThemeMap: Record<string, BadgeTheme> = {
new: 'default',
running: 'warning',
evaluation_running: 'warning',

View File

@@ -206,7 +206,7 @@ defineExpose({ focus, select });
outline
type="tertiary"
icon="external-link-alt"
size="xsmall"
size="mini"
:class="$style['expression-editor-modal-opener']"
data-test-id="expander"
@click="emit('modal-opener-click')"

View File

@@ -9,6 +9,7 @@ import { mockedStore } from '@/__tests__/utils';
import { useProjectsStore } from '@/stores/projects.store';
import { ProjectTypes, type Project } from '@/types/projects.types';
import { useFoldersStore } from '@/stores/folders.store';
import type { IUser } from 'n8n-workflow';
vi.mock('vue-router', async (importOriginal) => ({
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
@@ -41,7 +42,7 @@ const TEST_FOLDER_CHILD: FolderShortInfo = {
parentFolder: TEST_FOLDER.id,
};
const TEST_ACTIONS: UserAction[] = [
const TEST_ACTIONS: Array<UserAction<IUser>> = [
{ label: 'Action 1', value: 'action1', disabled: false },
{ label: 'Action 2', value: 'action2', disabled: true },
];

View File

@@ -7,11 +7,12 @@ import { type PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Brea
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useFoldersStore } from '@/stores/folders.store';
import type { FolderPathItem, FolderShortInfo } from '@/Interface';
import type { IUser } from 'n8n-workflow';
type Props = {
// Current folder can be null when showing breadcrumbs for workflows in project root
currentFolder?: FolderShortInfo | null;
actions?: UserAction[];
actions?: Array<UserAction<IUser>>;
hiddenItemsTrigger?: 'hover' | 'click';
currentFolderAsLink?: boolean;
visibleLevels?: 1 | 2;

View File

@@ -2,8 +2,8 @@ import { createComponentRenderer } from '@/__tests__/render';
import userEvent from '@testing-library/user-event';
import FolderCard from './FolderCard.vue';
import { createPinia, setActivePinia } from 'pinia';
import type { FolderResource } from '../layouts/ResourcesListLayout.vue';
import type { FolderPathItem, UserAction } from '@/Interface';
import type { FolderResource, FolderPathItem, UserAction } from '@/Interface';
import type { IUser } from 'n8n-workflow';
vi.mock('vue-router', () => {
const push = vi.fn();
@@ -54,7 +54,7 @@ const renderComponent = createComponentRenderer(FolderCard, {
actions: [
{ label: 'Open', value: 'open', disabled: false },
{ label: 'Delete', value: 'delete', disabled: false },
] as const satisfies UserAction[],
] as const satisfies Array<UserAction<IUser>>,
breadcrumbs: DEFAULT_BREADCRUMBS,
},
global: {

View File

@@ -1,20 +1,20 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { FOLDER_LIST_ITEM_ACTIONS } from './constants';
import type { FolderResource } from '../layouts/ResourcesListLayout.vue';
import { ProjectTypes, type Project } from '@/types/projects.types';
import { useI18n } from '@n8n/i18n';
import { useRoute, useRouter } from 'vue-router';
import { VIEWS } from '@/constants';
import type { UserAction } from '@/Interface';
import type { FolderResource, UserAction } from '@/Interface';
import { ResourceType } from '@/utils/projects.utils';
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import { useFoldersStore } from '@/stores/folders.store';
import { type IUser } from 'n8n-workflow';
type Props = {
data: FolderResource;
personalProject: Project | null;
actions: UserAction[];
actions: Array<UserAction<IUser>>;
readOnly?: boolean;
showOwnershipBadge?: boolean;
};
@@ -36,6 +36,7 @@ const emit = defineEmits<{
}>();
const hiddenBreadcrumbsItemsAsync = ref<Promise<PathItem[]>>(new Promise(() => {}));
const cachedHiddenBreadcrumbsItems = ref<PathItem[]>([]);
const resourceTypeLabel = computed(() => i18n.baseText('generic.folder').toLowerCase());

View File

@@ -118,7 +118,7 @@ const onClaimCreditsClicked = async () => {
})
}}</n8n-text
>&nbsp;
<n8n-text size="small" bold="true">
<n8n-text size="small" :bold="true">
{{ i18n.baseText('freeAi.credits.callout.success.title.part2') }}</n8n-text
>
</n8n-callout>

View File

@@ -11,7 +11,7 @@ import {
NodeConnectionTypes,
traverseNodeParameters,
} from 'n8n-workflow';
import type { IFormInput } from '@n8n/design-system';
import type { FormFieldValueUpdate, IFormInput } from '@n8n/design-system';
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useTelemetry } from '@/composables/useTelemetry';
@@ -249,9 +249,11 @@ const onExecute = async () => {
};
// Add handler for tool selection change
const onUpdate = (change: { name: string; value: string }) => {
const onUpdate = (change: FormFieldValueUpdate) => {
if (change.name !== 'toolName') return;
selectedTool.value = change.value;
if (typeof change.value === 'string') {
selectedTool.value = change.value;
}
};
</script>

View File

@@ -2,17 +2,18 @@
import { nextTick, ref } from 'vue';
import { useToast } from '@/composables/useToast';
import { onClickOutside } from '@vueuse/core';
import type { InputType } from '@n8n/design-system';
interface Props {
modelValue: string;
subtitle?: string;
type: string;
type: InputType;
readonly?: boolean;
placeholder?: string;
maxlength?: number;
required?: boolean;
autosize?: boolean | { minRows: number; maxRows: number };
inputType?: string;
inputType?: InputType;
maxHeight?: string;
}
const props = withDefaults(defineProps<Props>(), {

View File

@@ -2,7 +2,13 @@
import { computed, onMounted, ref } from 'vue';
import { useToast } from '@/composables/useToast';
import Modal from './Modal.vue';
import type { IFormInputs, IInviteResponse, IUser, InvitableRoleName } from '@/Interface';
import type {
FormFieldValueUpdate,
IFormInputs,
IInviteResponse,
IUser,
InvitableRoleName,
} from '@/Interface';
import { EnterpriseEditionFeature, VALID_EMAIL_REGEX, INVITE_USER_MODAL_KEY } from '@/constants';
import { ROLE } from '@n8n/api-types';
import { useUsersStore } from '@/stores/users.store';
@@ -127,11 +133,15 @@ const validateEmails = (value: string | number | boolean | null | undefined) =>
return false;
};
function onInput(e: { name: string; value: InvitableRoleName }) {
if (e.name === 'emails') {
function isInvitableRoleName(val: unknown): val is InvitableRoleName {
return typeof val === 'string' && [ROLE.Member, ROLE.Admin].includes(val as InvitableRoleName);
}
function onInput(e: FormFieldValueUpdate) {
if (e.name === 'emails' && typeof e.value === 'string') {
emails.value = e.value;
}
if (e.name === 'role') {
if (e.name === 'role' && isInvitableRoleName(e.value)) {
role.value = e.value;
}
}
@@ -312,7 +322,7 @@ function getEmail(email: string): string {
</n8n-users-list>
</div>
<n8n-form-inputs
v-else
v-else-if="config"
:inputs="config"
:event-bus="formBus"
:column-view="true"

View File

@@ -16,8 +16,8 @@ const emit = defineEmits<{
'update:modelValue': [tab: MAIN_HEADER_TABS, event: MouseEvent];
}>();
function onUpdateModelValue(tab: MAIN_HEADER_TABS, event: MouseEvent): void {
emit('update:modelValue', tab, event);
function onUpdateModelValue(tab: string, event: MouseEvent): void {
emit('update:modelValue', tab as MAIN_HEADER_TABS, event);
}
</script>

View File

@@ -428,7 +428,8 @@ async function handleFileImport(): Promise<void> {
}
}
async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void> {
async function onWorkflowMenuSelect(value: string): Promise<void> {
const action = value as WORKFLOW_MENU_ACTIONS;
switch (action) {
case WORKFLOW_MENU_ACTIONS.DUPLICATE: {
uiStore.openModalWithData({

View File

@@ -25,6 +25,7 @@ import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
import { N8nNavigationDropdown, N8nTooltip, N8nLink, N8nIconButton } from '@n8n/design-system';
import type { IMenuItem } from '@n8n/design-system';
import { onClickOutside, type VueInstance } from '@vueuse/core';
import Logo from './Logo/Logo.vue';
@@ -67,7 +68,7 @@ const userMenuItems = ref([
},
]);
const mainMenuItems = computed(() => [
const mainMenuItems = computed<IMenuItem[]>(() => [
{
id: 'cloud-admin',
position: 'bottom',

View File

@@ -8,7 +8,7 @@ import { LOCAL_STORAGE_MAIN_PANEL_RELATIVE_WIDTH, MAIN_NODE_PANEL_WIDTH } from '
import { useNDVStore } from '@/stores/ndv.store';
import { ndvEventBus } from '@/event-bus';
import NDVFloatingNodes from '@/components/NDVFloatingNodes.vue';
import type { MainPanelType, XYPosition } from '@/Interface';
import type { Direction, MainPanelType, XYPosition } from '@/Interface';
import { ref, onMounted, onBeforeUnmount, computed, watch, nextTick } from 'vue';
import { useUIStore } from '@/stores/ui.store';
import { useThrottleFn } from '@vueuse/core';
@@ -151,8 +151,8 @@ const outputPanelRelativeTranslate = computed((): number => {
return currentRelativeLeftDelta > 0 ? currentRelativeLeftDelta : 0;
});
const supportedResizeDirections = computed((): string[] => {
const supportedDirections = ['right'];
const supportedResizeDirections = computed((): Direction[] => {
const supportedDirections = ['right' as Direction];
if (props.isDraggable) supportedDirections.push('left');
return supportedDirections;

View File

@@ -99,10 +99,11 @@ describe('NDVSubConnections', () => {
<div class="connectionType"><span class="connectionLabel">Tools</span>
<div>
<div class="connectedNodesWrapper" style="--nodes-length: 0;">
<div class="plusButton">
<n8n-tooltip placement="top" teleported="true" offset="10" show-after="300" disabled="false">
<n8n-icon-button size="medium" icon="plus" type="tertiary" data-test-id="add-subnode-ai_tool-0"></n8n-icon-button>
</n8n-tooltip>
<div class="plusButton"><button class="button button tertiary medium withIcon square el-tooltip__trigger el-tooltip__trigger" aria-live="polite" data-test-id="add-subnode-ai_tool-0"><span class="icon"><span class="n8n-text compact size-medium regular n8n-icon n8n-icon"><!----></span></span>
<!--v-if-->
</button>
<!--teleport start-->
<!--teleport end-->
</div>
<!--v-if-->
</div>

View File

@@ -26,10 +26,11 @@ import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { useRouter } from 'vue-router';
import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { type IUpdateInformation } from '@/Interface';
import type { ButtonSize, IUpdateInformation } from '@/Interface';
import { generateCodeForAiTransform } from '@/components/ButtonParameter/utils';
import { needsAgentInput } from '@/utils/nodes/nodeTransforms';
import { useUIStore } from '@/stores/ui.store';
import type { ButtonType } from '@n8n/design-system';
const NODE_TEST_STEP_POPUP_COUNT_KEY = 'N8N_NODE_TEST_STEP_POPUP_COUNT';
const MAX_POPUP_COUNT = 10;
@@ -41,8 +42,8 @@ const props = withDefaults(
telemetrySource: string;
disabled?: boolean;
label?: string;
type?: string;
size?: string;
type?: ButtonType;
size?: ButtonSize;
transparent?: boolean;
hideIcon?: boolean;
tooltip?: string;

View File

@@ -119,7 +119,7 @@ const options = computed<ITab[]>(() => {
return options;
});
function onTabSelect(tab: string) {
function onTabSelect(tab: string | number) {
if (tab === 'docs' && props.nodeType) {
void externalHooks.run('dataDisplay.onDocumentationUrlClick', {
nodeType: props.nodeType,
@@ -147,7 +147,7 @@ function onTabSelect(tab: string) {
}
}
function onTooltipClick(tab: string, event: MouseEvent) {
function onTooltipClick(tab: string | number, event: MouseEvent) {
if (tab === 'communityNode' && (event.target as Element).localName === 'a') {
telemetry.track('user clicked cnr docs link', { source: 'node details view' });
}

View File

@@ -26,9 +26,9 @@ const emit = defineEmits<{
v-if="!isReadOnly"
type="tertiary"
:class="['n8n-input', $style.overrideCloseButton]"
outline="false"
:outline="false"
icon="xmark"
size="xsmall"
size="mini"
@click="emit('close')"
/>
</div>

View File

@@ -114,438 +114,441 @@ const isSaving = ref(false);
const userPermissions = computed(() =>
getResourcePermissions(usersStore.currentUser?.globalScopes),
);
const survey = computed<IFormInputs>(() => [
{
name: COMPANY_TYPE_KEY,
properties: {
label: i18n.baseText('personalizationModal.whatBestDescribesYourCompany'),
type: 'select',
placeholder: i18n.baseText('personalizationModal.select'),
options: [
{
label: i18n.baseText('personalizationModal.saas'),
value: SAAS_COMPANY_TYPE,
},
{
label: i18n.baseText('personalizationModal.eCommerce'),
value: ECOMMERCE_COMPANY_TYPE,
},
const survey = computed<IFormInputs>(
() =>
[
{
name: COMPANY_TYPE_KEY,
properties: {
label: i18n.baseText('personalizationModal.whatBestDescribesYourCompany'),
type: 'select',
placeholder: i18n.baseText('personalizationModal.select'),
options: [
{
label: i18n.baseText('personalizationModal.saas'),
value: SAAS_COMPANY_TYPE,
},
{
label: i18n.baseText('personalizationModal.eCommerce'),
value: ECOMMERCE_COMPANY_TYPE,
},
{
label: i18n.baseText('personalizationModal.digitalAgencyOrConsultant'),
value: DIGITAL_AGENCY_COMPANY_TYPE,
{
label: i18n.baseText('personalizationModal.digitalAgencyOrConsultant'),
value: DIGITAL_AGENCY_COMPANY_TYPE,
},
{
label: i18n.baseText('personalizationModal.systemsIntegrator'),
value: SYSTEMS_INTEGRATOR_COMPANY_TYPE,
},
{
value: EDUCATION_TYPE,
label: i18n.baseText('personalizationModal.education'),
},
{
label: i18n.baseText('personalizationModal.other'),
value: OTHER_COMPANY_TYPE,
},
{
label: i18n.baseText('personalizationModal.imNotUsingN8nForWork'),
value: PERSONAL_COMPANY_TYPE,
},
],
},
{
label: i18n.baseText('personalizationModal.systemsIntegrator'),
value: SYSTEMS_INTEGRATOR_COMPANY_TYPE,
},
{
name: COMPANY_INDUSTRY_EXTENDED_KEY,
properties: {
type: 'multi-select',
label: i18n.baseText('personalizationModal.whichIndustriesIsYourCompanyIn'),
placeholder: i18n.baseText('personalizationModal.select'),
options: [
{
value: FINANCE_INSURANCE_INDUSTRY,
label: i18n.baseText('personalizationModal.financeOrInsurance'),
},
{
value: GOVERNMENT_INDUSTRY,
label: i18n.baseText('personalizationModal.government'),
},
{
value: HEALTHCARE_INDUSTRY,
label: i18n.baseText('personalizationModal.healthcare'),
},
{
value: IT_INDUSTRY,
label: i18n.baseText('personalizationModal.it'),
},
{
value: LEGAL_INDUSTRY,
label: i18n.baseText('personalizationModal.legal'),
},
{
value: MSP_INDUSTRY,
label: i18n.baseText('personalizationModal.managedServiceProvider'),
},
{
value: MARKETING_INDUSTRY,
label: i18n.baseText('personalizationModal.marketing'),
},
{
value: MEDIA_INDUSTRY,
label: i18n.baseText('personalizationModal.media'),
},
{
value: MANUFACTURING_INDUSTRY,
label: i18n.baseText('personalizationModal.manufacturing'),
},
{
value: PHYSICAL_RETAIL_OR_SERVICES,
label: i18n.baseText('personalizationModal.physicalRetailOrServices'),
},
{
value: REAL_ESTATE_OR_CONSTRUCTION,
label: i18n.baseText('personalizationModal.realEstateOrConstruction'),
},
{
value: SECURITY_INDUSTRY,
label: i18n.baseText('personalizationModal.security'),
},
{
value: TELECOMS_INDUSTRY,
label: i18n.baseText('personalizationModal.telecoms'),
},
{
value: OTHER_INDUSTRY_OPTION,
label: i18n.baseText('personalizationModal.otherPleaseSpecify'),
},
],
},
{
value: EDUCATION_TYPE,
label: i18n.baseText('personalizationModal.education'),
shouldDisplay(values): boolean {
const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY];
return companyType === OTHER_COMPANY_TYPE;
},
{
label: i18n.baseText('personalizationModal.other'),
value: OTHER_COMPANY_TYPE,
},
{
name: OTHER_COMPANY_INDUSTRY_EXTENDED_KEY,
properties: {
placeholder: i18n.baseText('personalizationModal.specifyYourCompanysIndustry'),
},
{
label: i18n.baseText('personalizationModal.imNotUsingN8nForWork'),
value: PERSONAL_COMPANY_TYPE,
shouldDisplay(values): boolean {
const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY];
const companyIndustry = (values as IPersonalizationLatestVersion)[
COMPANY_INDUSTRY_EXTENDED_KEY
];
return (
companyType === OTHER_COMPANY_TYPE &&
!!companyIndustry &&
companyIndustry.includes(OTHER_INDUSTRY_OPTION)
);
},
],
},
},
{
name: COMPANY_INDUSTRY_EXTENDED_KEY,
properties: {
type: 'multi-select',
label: i18n.baseText('personalizationModal.whichIndustriesIsYourCompanyIn'),
placeholder: i18n.baseText('personalizationModal.select'),
options: [
{
value: FINANCE_INSURANCE_INDUSTRY,
label: i18n.baseText('personalizationModal.financeOrInsurance'),
},
{
name: ROLE_KEY,
properties: {
type: 'select',
label: i18n.baseText('personalizationModal.whichRoleBestDescribesYou'),
placeholder: i18n.baseText('personalizationModal.select'),
options: [
{
value: ROLE_BUSINESS_OWNER,
label: i18n.baseText('personalizationModal.businessOwner'),
},
{
value: ROLE_CUSTOMER_SUPPORT,
label: i18n.baseText('personalizationModal.customerSupport'),
},
{
value: ROLE_DATA_SCIENCE,
label: i18n.baseText('personalizationModal.dataScience'),
},
{
value: ROLE_DEVOPS,
label: i18n.baseText('personalizationModal.devops'),
},
{
value: ROLE_IT,
label: i18n.baseText('personalizationModal.it'),
},
{
value: ROLE_ENGINEERING,
label: i18n.baseText('personalizationModal.engineering'),
},
{
value: ROLE_SALES_AND_MARKETING,
label: i18n.baseText('personalizationModal.salesAndMarketing'),
},
{
value: ROLE_SECURITY,
label: i18n.baseText('personalizationModal.security'),
},
{
value: ROLE_OTHER,
label: i18n.baseText('personalizationModal.otherPleaseSpecify'),
},
],
},
{
value: GOVERNMENT_INDUSTRY,
label: i18n.baseText('personalizationModal.government'),
shouldDisplay(values): boolean {
const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY];
return companyType !== PERSONAL_COMPANY_TYPE;
},
{
value: HEALTHCARE_INDUSTRY,
label: i18n.baseText('personalizationModal.healthcare'),
},
{
name: ROLE_OTHER_KEY,
properties: {
placeholder: i18n.baseText('personalizationModal.specifyYourRole'),
},
{
value: IT_INDUSTRY,
label: i18n.baseText('personalizationModal.it'),
shouldDisplay(values): boolean {
const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY];
const role = (values as IPersonalizationLatestVersion)[ROLE_KEY];
return companyType !== PERSONAL_COMPANY_TYPE && role === ROLE_OTHER;
},
{
value: LEGAL_INDUSTRY,
label: i18n.baseText('personalizationModal.legal'),
},
{
name: DEVOPS_AUTOMATION_GOAL_KEY,
properties: {
type: 'multi-select',
label: i18n.baseText('personalizationModal.whatAreYouLookingToAutomate'),
placeholder: i18n.baseText('personalizationModal.select'),
options: [
{
value: DEVOPS_AUTOMATION_CI_CD_GOAL,
label: i18n.baseText('personalizationModal.cicd'),
},
{
value: DEVOPS_AUTOMATION_CLOUD_INFRASTRUCTURE_ORCHESTRATION_GOAL,
label: i18n.baseText('personalizationModal.cloudInfrastructureOrchestration'),
},
{
value: DEVOPS_AUTOMATION_DATA_SYNCING_GOAL,
label: i18n.baseText('personalizationModal.dataSynching'),
},
{
value: DEVOPS_INCIDENT_RESPONSE_GOAL,
label: i18n.baseText('personalizationModal.incidentResponse'),
},
{
value: DEVOPS_MONITORING_AND_ALERTING_GOAL,
label: i18n.baseText('personalizationModal.monitoringAndAlerting'),
},
{
value: DEVOPS_REPORTING_GOAL,
label: i18n.baseText('personalizationModal.reporting'),
},
{
value: DEVOPS_TICKETING_SYSTEMS_INTEGRATIONS_GOAL,
label: i18n.baseText('personalizationModal.ticketingSystemsIntegrations'),
},
{
value: OTHER_AUTOMATION_GOAL,
label: i18n.baseText('personalizationModal.other'),
},
],
},
{
value: MSP_INDUSTRY,
label: i18n.baseText('personalizationModal.managedServiceProvider'),
shouldDisplay(values): boolean {
const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY];
const role = (values as IPersonalizationLatestVersion)[ROLE_KEY] as string;
return (
companyType !== PERSONAL_COMPANY_TYPE &&
[ROLE_DEVOPS, ROLE_ENGINEERING, ROLE_IT].includes(role)
);
},
{
value: MARKETING_INDUSTRY,
label: i18n.baseText('personalizationModal.marketing'),
},
{
name: DEVOPS_AUTOMATION_GOAL_OTHER_KEY,
properties: {
placeholder: i18n.baseText('personalizationModal.specifyYourAutomationGoal'),
},
{
value: MEDIA_INDUSTRY,
label: i18n.baseText('personalizationModal.media'),
shouldDisplay(values): boolean {
const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY];
const goals = (values as IPersonalizationLatestVersion)[DEVOPS_AUTOMATION_GOAL_KEY];
const role = (values as IPersonalizationLatestVersion)[ROLE_KEY] as string;
return (
companyType !== PERSONAL_COMPANY_TYPE &&
[ROLE_DEVOPS, ROLE_ENGINEERING, ROLE_IT].includes(role) &&
!!goals &&
goals.includes(DEVOPS_AUTOMATION_OTHER)
);
},
{
value: MANUFACTURING_INDUSTRY,
label: i18n.baseText('personalizationModal.manufacturing'),
},
{
name: MARKETING_AUTOMATION_GOAL_KEY,
properties: {
type: 'multi-select',
label: i18n.baseText('personalizationModal.specifySalesMarketingGoal'),
placeholder: i18n.baseText('personalizationModal.select'),
options: [
{
label: i18n.baseText('personalizationModal.leadGeneration'),
value: MARKETING_AUTOMATION_LEAD_GENERATION_GOAL,
},
{
label: i18n.baseText('personalizationModal.customerCommunication'),
value: MARKETING_AUTOMATION_CUSTOMER_COMMUNICATION,
},
{
label: i18n.baseText('personalizationModal.customerActions'),
value: MARKETING_AUTOMATION_ACTIONS,
},
{
label: i18n.baseText('personalizationModal.adCampaign'),
value: MARKETING_AUTOMATION_AD_CAMPAIGN,
},
{
label: i18n.baseText('personalizationModal.reporting'),
value: MARKETING_AUTOMATION_REPORTING,
},
{
label: i18n.baseText('personalizationModal.dataSynching'),
value: MARKETING_AUTOMATION_DATA_SYNCHING,
},
{
label: i18n.baseText('personalizationModal.other'),
value: MARKETING_AUTOMATION_OTHER,
},
],
},
{
value: PHYSICAL_RETAIL_OR_SERVICES,
label: i18n.baseText('personalizationModal.physicalRetailOrServices'),
shouldDisplay(values): boolean {
const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY];
const role = (values as IPersonalizationLatestVersion)[ROLE_KEY];
return companyType !== PERSONAL_COMPANY_TYPE && role === ROLE_SALES_AND_MARKETING;
},
{
value: REAL_ESTATE_OR_CONSTRUCTION,
label: i18n.baseText('personalizationModal.realEstateOrConstruction'),
},
{
name: OTHER_MARKETING_AUTOMATION_GOAL_KEY,
properties: {
placeholder: i18n.baseText('personalizationModal.specifyOtherSalesAndMarketingGoal'),
},
{
value: SECURITY_INDUSTRY,
label: i18n.baseText('personalizationModal.security'),
shouldDisplay(values): boolean {
const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY];
const goals = (values as IPersonalizationLatestVersion)[MARKETING_AUTOMATION_GOAL_KEY];
const role = (values as IPersonalizationLatestVersion)[ROLE_KEY];
return (
companyType !== PERSONAL_COMPANY_TYPE &&
role === ROLE_SALES_AND_MARKETING &&
!!goals &&
goals.includes(MARKETING_AUTOMATION_OTHER)
);
},
{
value: TELECOMS_INDUSTRY,
label: i18n.baseText('personalizationModal.telecoms'),
},
{
name: AUTOMATION_BENEFICIARY_KEY,
properties: {
type: 'select',
label: i18n.baseText('personalizationModal.specifyAutomationBeneficiary'),
placeholder: i18n.baseText('personalizationModal.select'),
options: [
{
label: i18n.baseText('personalizationModal.myself'),
value: AUTOMATION_BENEFICIARY_SELF,
},
{
label: i18n.baseText('personalizationModal.myTeam'),
value: AUTOMATION_BENEFICIARY_MY_TEAM,
},
{
label: i18n.baseText('personalizationModal.otherTeams'),
value: AUTOMATION_BENEFICIARY_OTHER_TEAMS,
},
],
},
{
value: OTHER_INDUSTRY_OPTION,
label: i18n.baseText('personalizationModal.otherPleaseSpecify'),
shouldDisplay(values): boolean {
const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY];
return companyType !== PERSONAL_COMPANY_TYPE;
},
],
},
shouldDisplay(values): boolean {
const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY];
return companyType === OTHER_COMPANY_TYPE;
},
},
{
name: OTHER_COMPANY_INDUSTRY_EXTENDED_KEY,
properties: {
placeholder: i18n.baseText('personalizationModal.specifyYourCompanysIndustry'),
},
shouldDisplay(values): boolean {
const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY];
const companyIndustry = (values as IPersonalizationLatestVersion)[
COMPANY_INDUSTRY_EXTENDED_KEY
];
return (
companyType === OTHER_COMPANY_TYPE &&
!!companyIndustry &&
companyIndustry.includes(OTHER_INDUSTRY_OPTION)
);
},
},
{
name: ROLE_KEY,
properties: {
type: 'select',
label: i18n.baseText('personalizationModal.whichRoleBestDescribesYou'),
placeholder: i18n.baseText('personalizationModal.select'),
options: [
{
value: ROLE_BUSINESS_OWNER,
label: i18n.baseText('personalizationModal.businessOwner'),
},
{
name: COMPANY_SIZE_KEY,
properties: {
type: 'select',
label: i18n.baseText('personalizationModal.howBigIsYourCompany'),
placeholder: i18n.baseText('personalizationModal.select'),
options: [
{
label: i18n.baseText('personalizationModal.lessThan20People'),
value: COMPANY_SIZE_20_OR_LESS,
},
{
label: `20-99 ${i18n.baseText('personalizationModal.people')}`,
value: COMPANY_SIZE_20_99,
},
{
label: `100-499 ${i18n.baseText('personalizationModal.people')}`,
value: COMPANY_SIZE_100_499,
},
{
label: `500-999 ${i18n.baseText('personalizationModal.people')}`,
value: COMPANY_SIZE_500_999,
},
{
label: `1000+ ${i18n.baseText('personalizationModal.people')}`,
value: COMPANY_SIZE_1000_OR_MORE,
},
{
label: i18n.baseText('personalizationModal.imNotUsingN8nForWork'),
value: COMPANY_SIZE_PERSONAL_USE,
},
],
},
{
value: ROLE_CUSTOMER_SUPPORT,
label: i18n.baseText('personalizationModal.customerSupport'),
shouldDisplay(values): boolean {
const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY];
return companyType !== PERSONAL_COMPANY_TYPE;
},
{
value: ROLE_DATA_SCIENCE,
label: i18n.baseText('personalizationModal.dataScience'),
},
{
name: REPORTED_SOURCE_KEY,
properties: {
type: 'select',
label: i18n.baseText('personalizationModal.howDidYouHearAboutN8n'),
placeholder: i18n.baseText('personalizationModal.select'),
options: [
{
label: 'Google',
value: REPORTED_SOURCE_GOOGLE,
},
{
label: 'Twitter',
value: REPORTED_SOURCE_TWITTER,
},
{
label: 'LinkedIn',
value: REPORTED_SOURCE_LINKEDIN,
},
{
label: 'YouTube',
value: REPORTED_SOURCE_YOUTUBE,
},
{
label: i18n.baseText('personalizationModal.friendWordOfMouth'),
value: REPORTED_SOURCE_FRIEND,
},
{
label: i18n.baseText('personalizationModal.podcast'),
value: REPORTED_SOURCE_PODCAST,
},
{
label: i18n.baseText('personalizationModal.event'),
value: REPORTED_SOURCE_EVENT,
},
{
label: i18n.baseText('personalizationModal.otherPleaseSpecify'),
value: REPORTED_SOURCE_OTHER,
},
],
},
{
value: ROLE_DEVOPS,
label: i18n.baseText('personalizationModal.devops'),
},
{
name: REPORTED_SOURCE_OTHER_KEY,
properties: {
placeholder: i18n.baseText('personalizationModal.specifyReportedSource'),
},
{
value: ROLE_IT,
label: i18n.baseText('personalizationModal.it'),
shouldDisplay(values): boolean {
const reportedSource = (values as IPersonalizationLatestVersion)[REPORTED_SOURCE_KEY];
return reportedSource === REPORTED_SOURCE_OTHER;
},
{
value: ROLE_ENGINEERING,
label: i18n.baseText('personalizationModal.engineering'),
},
{
value: ROLE_SALES_AND_MARKETING,
label: i18n.baseText('personalizationModal.salesAndMarketing'),
},
{
value: ROLE_SECURITY,
label: i18n.baseText('personalizationModal.security'),
},
{
value: ROLE_OTHER,
label: i18n.baseText('personalizationModal.otherPleaseSpecify'),
},
],
},
shouldDisplay(values): boolean {
const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY];
return companyType !== PERSONAL_COMPANY_TYPE;
},
},
{
name: ROLE_OTHER_KEY,
properties: {
placeholder: i18n.baseText('personalizationModal.specifyYourRole'),
},
shouldDisplay(values): boolean {
const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY];
const role = (values as IPersonalizationLatestVersion)[ROLE_KEY];
return companyType !== PERSONAL_COMPANY_TYPE && role === ROLE_OTHER;
},
},
{
name: DEVOPS_AUTOMATION_GOAL_KEY,
properties: {
type: 'multi-select',
label: i18n.baseText('personalizationModal.whatAreYouLookingToAutomate'),
placeholder: i18n.baseText('personalizationModal.select'),
options: [
{
value: DEVOPS_AUTOMATION_CI_CD_GOAL,
label: i18n.baseText('personalizationModal.cicd'),
},
{
value: DEVOPS_AUTOMATION_CLOUD_INFRASTRUCTURE_ORCHESTRATION_GOAL,
label: i18n.baseText('personalizationModal.cloudInfrastructureOrchestration'),
},
{
value: DEVOPS_AUTOMATION_DATA_SYNCING_GOAL,
label: i18n.baseText('personalizationModal.dataSynching'),
},
{
value: DEVOPS_INCIDENT_RESPONSE_GOAL,
label: i18n.baseText('personalizationModal.incidentResponse'),
},
{
value: DEVOPS_MONITORING_AND_ALERTING_GOAL,
label: i18n.baseText('personalizationModal.monitoringAndAlerting'),
},
{
value: DEVOPS_REPORTING_GOAL,
label: i18n.baseText('personalizationModal.reporting'),
},
{
value: DEVOPS_TICKETING_SYSTEMS_INTEGRATIONS_GOAL,
label: i18n.baseText('personalizationModal.ticketingSystemsIntegrations'),
},
{
value: OTHER_AUTOMATION_GOAL,
label: i18n.baseText('personalizationModal.other'),
},
],
},
shouldDisplay(values): boolean {
const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY];
const role = (values as IPersonalizationLatestVersion)[ROLE_KEY] as string;
return (
companyType !== PERSONAL_COMPANY_TYPE &&
[ROLE_DEVOPS, ROLE_ENGINEERING, ROLE_IT].includes(role)
);
},
},
{
name: DEVOPS_AUTOMATION_GOAL_OTHER_KEY,
properties: {
placeholder: i18n.baseText('personalizationModal.specifyYourAutomationGoal'),
},
shouldDisplay(values): boolean {
const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY];
const goals = (values as IPersonalizationLatestVersion)[DEVOPS_AUTOMATION_GOAL_KEY];
const role = (values as IPersonalizationLatestVersion)[ROLE_KEY] as string;
return (
companyType !== PERSONAL_COMPANY_TYPE &&
[ROLE_DEVOPS, ROLE_ENGINEERING, ROLE_IT].includes(role) &&
!!goals &&
goals.includes(DEVOPS_AUTOMATION_OTHER)
);
},
},
{
name: MARKETING_AUTOMATION_GOAL_KEY,
properties: {
type: 'multi-select',
label: i18n.baseText('personalizationModal.specifySalesMarketingGoal'),
placeholder: i18n.baseText('personalizationModal.select'),
options: [
{
label: i18n.baseText('personalizationModal.leadGeneration'),
value: MARKETING_AUTOMATION_LEAD_GENERATION_GOAL,
},
{
label: i18n.baseText('personalizationModal.customerCommunication'),
value: MARKETING_AUTOMATION_CUSTOMER_COMMUNICATION,
},
{
label: i18n.baseText('personalizationModal.customerActions'),
value: MARKETING_AUTOMATION_ACTIONS,
},
{
label: i18n.baseText('personalizationModal.adCampaign'),
value: MARKETING_AUTOMATION_AD_CAMPAIGN,
},
{
label: i18n.baseText('personalizationModal.reporting'),
value: MARKETING_AUTOMATION_REPORTING,
},
{
label: i18n.baseText('personalizationModal.dataSynching'),
value: MARKETING_AUTOMATION_DATA_SYNCHING,
},
{
label: i18n.baseText('personalizationModal.other'),
value: MARKETING_AUTOMATION_OTHER,
},
],
},
shouldDisplay(values): boolean {
const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY];
const role = (values as IPersonalizationLatestVersion)[ROLE_KEY];
return companyType !== PERSONAL_COMPANY_TYPE && role === ROLE_SALES_AND_MARKETING;
},
},
{
name: OTHER_MARKETING_AUTOMATION_GOAL_KEY,
properties: {
placeholder: i18n.baseText('personalizationModal.specifyOtherSalesAndMarketingGoal'),
},
shouldDisplay(values): boolean {
const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY];
const goals = (values as IPersonalizationLatestVersion)[MARKETING_AUTOMATION_GOAL_KEY];
const role = (values as IPersonalizationLatestVersion)[ROLE_KEY];
return (
companyType !== PERSONAL_COMPANY_TYPE &&
role === ROLE_SALES_AND_MARKETING &&
!!goals &&
goals.includes(MARKETING_AUTOMATION_OTHER)
);
},
},
{
name: AUTOMATION_BENEFICIARY_KEY,
properties: {
type: 'select',
label: i18n.baseText('personalizationModal.specifyAutomationBeneficiary'),
placeholder: i18n.baseText('personalizationModal.select'),
options: [
{
label: i18n.baseText('personalizationModal.myself'),
value: AUTOMATION_BENEFICIARY_SELF,
},
{
label: i18n.baseText('personalizationModal.myTeam'),
value: AUTOMATION_BENEFICIARY_MY_TEAM,
},
{
label: i18n.baseText('personalizationModal.otherTeams'),
value: AUTOMATION_BENEFICIARY_OTHER_TEAMS,
},
],
},
shouldDisplay(values): boolean {
const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY];
return companyType !== PERSONAL_COMPANY_TYPE;
},
},
{
name: COMPANY_SIZE_KEY,
properties: {
type: 'select',
label: i18n.baseText('personalizationModal.howBigIsYourCompany'),
placeholder: i18n.baseText('personalizationModal.select'),
options: [
{
label: i18n.baseText('personalizationModal.lessThan20People'),
value: COMPANY_SIZE_20_OR_LESS,
},
{
label: `20-99 ${i18n.baseText('personalizationModal.people')}`,
value: COMPANY_SIZE_20_99,
},
{
label: `100-499 ${i18n.baseText('personalizationModal.people')}`,
value: COMPANY_SIZE_100_499,
},
{
label: `500-999 ${i18n.baseText('personalizationModal.people')}`,
value: COMPANY_SIZE_500_999,
},
{
label: `1000+ ${i18n.baseText('personalizationModal.people')}`,
value: COMPANY_SIZE_1000_OR_MORE,
},
{
label: i18n.baseText('personalizationModal.imNotUsingN8nForWork'),
value: COMPANY_SIZE_PERSONAL_USE,
},
],
},
shouldDisplay(values): boolean {
const companyType = (values as IPersonalizationLatestVersion)[COMPANY_TYPE_KEY];
return companyType !== PERSONAL_COMPANY_TYPE;
},
},
{
name: REPORTED_SOURCE_KEY,
properties: {
type: 'select',
label: i18n.baseText('personalizationModal.howDidYouHearAboutN8n'),
placeholder: i18n.baseText('personalizationModal.select'),
options: [
{
label: 'Google',
value: REPORTED_SOURCE_GOOGLE,
},
{
label: 'Twitter',
value: REPORTED_SOURCE_TWITTER,
},
{
label: 'LinkedIn',
value: REPORTED_SOURCE_LINKEDIN,
},
{
label: 'YouTube',
value: REPORTED_SOURCE_YOUTUBE,
},
{
label: i18n.baseText('personalizationModal.friendWordOfMouth'),
value: REPORTED_SOURCE_FRIEND,
},
{
label: i18n.baseText('personalizationModal.podcast'),
value: REPORTED_SOURCE_PODCAST,
},
{
label: i18n.baseText('personalizationModal.event'),
value: REPORTED_SOURCE_EVENT,
},
{
label: i18n.baseText('personalizationModal.otherPleaseSpecify'),
value: REPORTED_SOURCE_OTHER,
},
],
},
},
{
name: REPORTED_SOURCE_OTHER_KEY,
properties: {
placeholder: i18n.baseText('personalizationModal.specifyReportedSource'),
},
shouldDisplay(values): boolean {
const reportedSource = (values as IPersonalizationLatestVersion)[REPORTED_SOURCE_KEY];
return reportedSource === REPORTED_SOURCE_OTHER;
},
},
]);
},
] as const,
);
const onSave = () => {
formBus.emit('submit');
@@ -575,7 +578,7 @@ const closeDialog = () => {
}
};
const onSubmit = async (values: IPersonalizationLatestVersion) => {
const onSubmit = async (values: object) => {
isSaving.value = true;
try {

View File

@@ -4,11 +4,7 @@ import { useI18n } from '@n8n/i18n';
import { ResourceType, splitName } from '@/utils/projects.utils';
import type { Project, ProjectIcon as BadgeIcon } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types';
import type {
CredentialsResource,
FolderResource,
WorkflowResource,
} from '../layouts/ResourcesListLayout.vue';
import type { CredentialsResource, FolderResource, WorkflowResource } from '@/Interface';
import { VIEWS } from '@/constants';
type Props = {

View File

@@ -1,15 +1,11 @@
<script lang="ts" setup>
import type { ButtonType } from '@n8n/design-system';
import type { ButtonType, UserAction } from '@n8n/design-system';
import { N8nIconButton, N8nActionToggle } from '@n8n/design-system';
import { ref } from 'vue';
import type { IUser } from 'n8n-workflow';
import { useTemplateRef } from 'vue';
type Action = {
label: string;
value: string;
disabled: boolean;
};
defineProps<{
actions: Action[];
actions: Array<UserAction<IUser>>;
disabled?: boolean;
type?: ButtonType;
}>();
@@ -18,7 +14,7 @@ const emit = defineEmits<{
action: [id: string];
}>();
const actionToggleRef = ref<InstanceType<typeof N8nActionToggle> | null>(null);
const actionToggleRef = useTemplateRef('actionToggleRef');
defineExpose({
openActionToggle: (isOpen: boolean) => actionToggleRef.value?.openActionToggle(isOpen),

View File

@@ -16,6 +16,7 @@ import ProjectCreateResource from '@/components/Projects/ProjectCreateResource.v
import { useSettingsStore } from '@/stores/settings.store';
import { useProjectPages } from '@/composables/useProjectPages';
import { truncateTextToFitWidth } from '@/utils/formatters/textFormatter';
import type { IUser } from 'n8n-workflow';
const route = useRoute();
const router = useRouter();
@@ -96,7 +97,7 @@ const createWorkflowButton = computed(() => ({
}));
const menu = computed(() => {
const items: UserAction[] = [
const items: Array<UserAction<IUser>> = [
{
value: ACTION_TYPES.CREDENTIAL,
label: i18n.baseText('projects.header.create.credential'),

View File

@@ -240,7 +240,7 @@ onMounted(async () => {
v-for="p in filteredProjects"
:key="p.id"
:value="p.id"
:label="p.name"
:label="p.name ?? ''"
></N8nOption>
</N8nSelect>
<N8nText>

View File

@@ -43,10 +43,10 @@ const shared = computed<IMenuItem>(() => ({
},
}));
const getProjectMenuItem = (project: ProjectListItem) => ({
const getProjectMenuItem = (project: ProjectListItem): IMenuItem => ({
id: project.id,
label: project.name,
icon: project.icon,
label: project.name ?? '',
icon: project.icon as IMenuItem['icon'],
route: {
to: {
name: VIEWS.PROJECTS_WORKFLOWS,
@@ -70,6 +70,14 @@ const personalProject = computed<IMenuItem>(() => ({
const showAddFirstProject = computed(
() => projectsStore.isTeamProjectFeatureEnabled && !displayProjects.value.length,
);
const activeTabId = computed(() => {
return (
(Array.isArray(projectsStore.projectNavActiveId)
? projectsStore.projectNavActiveId[0]
: projectsStore.projectNavActiveId) ?? undefined
);
});
</script>
<template>
@@ -78,7 +86,7 @@ const showAddFirstProject = computed(
<N8nMenuItem
:item="home"
:compact="props.collapsed"
:active-tab="projectsStore.projectNavActiveId"
:active-tab="activeTabId"
mode="tabs"
data-test-id="project-home-menu-item"
/>
@@ -86,7 +94,7 @@ const showAddFirstProject = computed(
v-if="projectsStore.isTeamProjectFeatureEnabled || isFoldersFeatureEnabled"
:item="personalProject"
:compact="props.collapsed"
:active-tab="projectsStore.projectNavActiveId"
:active-tab="activeTabId"
mode="tabs"
data-test-id="project-personal-menu-item"
/>
@@ -94,7 +102,7 @@ const showAddFirstProject = computed(
v-if="projectsStore.isTeamProjectFeatureEnabled || isFoldersFeatureEnabled"
:item="shared"
:compact="props.collapsed"
:active-tab="projectsStore.projectNavActiveId"
:active-tab="activeTabId"
mode="tabs"
data-test-id="project-shared-menu-item"
/>
@@ -136,7 +144,7 @@ const showAddFirstProject = computed(
}"
:item="getProjectMenuItem(project)"
:compact="props.collapsed"
:active-tab="projectsStore.projectNavActiveId"
:active-tab="activeTabId"
mode="tabs"
data-test-id="project-menu-item"
/>

View File

@@ -136,7 +136,7 @@ watch(
v-for="project in filteredProjects"
:key="project.id"
:value="project.id"
:label="project.name"
:label="project.name ?? ''"
>
<ProjectSharingInfo :project="project" />
</N8nOption>

View File

@@ -5,6 +5,7 @@ import { useRoute } from 'vue-router';
import { VIEWS } from '@/constants';
import { useI18n } from '@n8n/i18n';
import type { BaseTextKey } from '@n8n/i18n';
import type { TabOptions } from '@n8n/design-system';
type Props = {
showSettings?: boolean;
@@ -23,6 +24,8 @@ const route = useRoute();
const selectedTab = ref<RouteRecordName | null | undefined>('');
const selectedTabLabel = computed(() => (selectedTab.value ? String(selectedTab.value) : ''));
const projectId = computed(() => {
return Array.isArray(route?.params?.projectId)
? route.params.projectId[0]
@@ -70,16 +73,16 @@ const createTab = (
label: BaseTextKey,
routeKey: string,
routes: Record<string, { name: RouteRecordName; params?: Record<string, string | number> }>,
) => {
): TabOptions<string> => {
return {
label: locale.baseText(label),
value: routes[routeKey].name,
value: routes[routeKey].name as string,
to: routes[routeKey],
};
};
// Generate the tabs configuration
const options = computed(() => {
const options = computed<Array<TabOptions<string>>>(() => {
const routes = getRouteConfigs();
const tabs = [
createTab('mainSidebar.workflows', 'workflows', routes),
@@ -93,7 +96,7 @@ const options = computed(() => {
if (props.showSettings) {
tabs.push({
label: locale.baseText('projects.settings'),
value: VIEWS.PROJECT_SETTINGS,
value: VIEWS.PROJECT_SETTINGS as string,
to: { name: VIEWS.PROJECT_SETTINGS, params: { projectId: projectId.value } },
});
}
@@ -110,8 +113,17 @@ watch(
},
{ immediate: true },
);
function onSelectTab(value: string | number) {
selectedTab.value = value as RouteRecordName;
}
</script>
<template>
<N8nTabs v-model="selectedTab" :options="options" data-test-id="project-tabs" />
<N8nTabs
:model-value="selectedTabLabel"
:options="options"
data-test-id="project-tabs"
@update:model-value="onSelectTab"
/>
</template>

View File

@@ -4,7 +4,7 @@ import Modal from '../Modal.vue';
import { PROMPT_MFA_CODE_MODAL_KEY } from '@/constants';
import { useI18n } from '@n8n/i18n';
import { promptMfaCodeBus } from '@/event-bus';
import type { IFormInputs } from '@/Interface';
import { type IFormInput } from '@/Interface';
import { createFormEventBus } from '@n8n/design-system/utils';
import { validate as validateUuid } from 'uuid';
@@ -13,7 +13,7 @@ const i18n = useI18n();
const formBus = createFormEventBus();
const readyToSubmit = ref(false);
const formFields: IFormInputs = [
const formFields: IFormInput[] = [
{
name: 'mfaCodeOrMfaRecoveryCode',
initialValue: '',
@@ -25,9 +25,14 @@ const formFields: IFormInputs = [
required: true,
},
},
];
] as const;
function onSubmit(values: { mfaCodeOrMfaRecoveryCode: string }) {
function onSubmit(values: object) {
if (
!('mfaCodeOrMfaRecoveryCode' in values && typeof values.mfaCodeOrMfaRecoveryCode === 'string')
) {
return;
}
if (validateUuid(values.mfaCodeOrMfaRecoveryCode)) {
promptMfaCodeBus.emit('close', {
mfaRecoveryCode: values.mfaCodeOrMfaRecoveryCode,

Some files were not shown because too many files have changed in this diff Show More