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

@@ -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[] };